Files
2026-07-13 12:31:40 +08:00

1484 lines
71 KiB
Python

# Adopted from https://github.com/guandeh17/Self-Forcing
# SPDX-License-Identifier: Apache-2.0
import gc
import logging
from utils.dataset import cycle
from utils.dataset import MultiVideoConcatDataset, MultiTextConcatDataset, multi_video_collate_fn, eval_collate_fn, DEFAULT_SCENE_CUT_PREFIX
from utils.config import section_get, wan_default_config
from utils.distributed import EMA_FSDP, fsdp_wrap, launch_distributed_job
from utils.misc import (
set_seed,
merge_dict_list
)
import torch.distributed as dist
from omegaconf import OmegaConf
from model import DMD
import torch
import wandb
import os
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
from torch.distributed.fsdp import (
StateDictType, FullStateDictConfig, FullOptimStateDictConfig
)
from torchvision.io import write_video
# LoRA related imports
import peft
from peft import get_peft_model_state_dict
from pipeline import (
CausalDiffusionInferencePipeline
)
import time
class Trainer:
def __init__(self, config):
self.config = config
self.step = 0
# Step 1: Initialize the distributed training environment (rank, seed, dtype, logging etc.)
torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True
launch_distributed_job()
global_rank = dist.get_rank()
self.world_size = dist.get_world_size()
self.dtype = torch.bfloat16 if config.mixed_precision else torch.float32
self.device = torch.cuda.current_device()
self.is_main_process = global_rank == 0
self.causal = getattr(config, "causal", getattr(config, "all_causal", True))
self.disable_wandb = config.disable_wandb
# use a random seed for the training
if config.seed == 0:
random_seed = torch.randint(0, 10000000, (1,), device=self.device)
dist.broadcast(random_seed, src=0)
config.seed = random_seed.item()
set_seed(config.seed + global_rank)
if self.is_main_process and not self.disable_wandb:
if getattr(config, "wandb_key", None):
wandb.login(key=config.wandb_key)
wandb.init(
config=OmegaConf.to_container(config, resolve=True),
name=config.config_name,
id=config.config_name,
mode="online",
entity=config.wandb_entity,
project=config.wandb_project,
dir=config.wandb_save_dir,
resume="allow"
)
self.output_path = config.logdir
# Step 2: Initialize the model
if config.distribution_loss == "dmd":
self.model = DMD(config, device=self.device)
else:
raise ValueError(f"Unsupported distribution matching loss: {config.distribution_loss}")
# Save pretrained model state_dicts to CPU
self.fake_score_state_dict_cpu = self.model.fake_score.state_dict()
# ================================= NVFP4 Quantized Training / Inference =================================
# `generator_quant` is the preferred student flag; `model_quant` is kept
# as a legacy alias used by earlier Sage configs.
self.generator_quant = getattr(config, "generator_quant", getattr(config, "model_quant", False))
self.real_score_quant = getattr(config, "real_score_quant", False)
self.fake_score_quant = getattr(config, "fake_score_quant", False)
self.real_score_quant_materialize = getattr(config, "real_score_quant_materialize", True)
if self.generator_quant or self.real_score_quant or self.fake_score_quant:
from utils.quant import ModelQuantizationConfig, quantize_model_with_filter
fallback_sr = getattr(config, "model_quant_scale_rule", "static_6")
fallback_asr = getattr(config, "model_quant_activation_scale_rule", "static_6")
fallback_wsr = getattr(config, "model_quant_weight_scale_rule", None)
fallback_gsr = getattr(config, "model_quant_gradient_scale_rule", None)
if self.generator_quant:
gen_cfg = ModelQuantizationConfig(
scale_rule=getattr(config, "generator_quant_scale_rule", fallback_sr),
activation_scale_rule=getattr(config, "generator_quant_activation_scale_rule", fallback_asr),
weight_scale_rule=getattr(config, "generator_quant_weight_scale_rule", fallback_wsr),
gradient_scale_rule=getattr(config, "generator_quant_gradient_scale_rule", fallback_gsr),
keep_master_weights=True,
weight_scale_2d=True,
)
self.model.generator.model, gen_matched = quantize_model_with_filter(
self.model.generator.model,
quant_config=gen_cfg,
filtered_modules=getattr(config, "generator_quant_filtered_modules", None),
filter_profile="student",
use_default_filtered_modules=getattr(
config, "generator_quant_use_default_filtered_modules", True
),
cast_model_to_bf16=False,
materialize_for_inference=False,
verbose=self.is_main_process,
)
if self.is_main_process:
print(
"[NVFP4] Generator (student) quantized training enabled, "
f"scale_rule={gen_cfg.scale_rule}, {len(gen_matched)} modules excluded"
)
if self.real_score_quant:
real_cfg = ModelQuantizationConfig(
scale_rule=getattr(config, "real_score_quant_scale_rule", fallback_sr),
activation_scale_rule=getattr(config, "real_score_quant_activation_scale_rule", fallback_asr),
weight_scale_rule=getattr(config, "real_score_quant_weight_scale_rule", fallback_wsr),
gradient_scale_rule=None,
keep_master_weights=True,
weight_scale_2d=True,
)
self.model.real_score.model, real_matched = quantize_model_with_filter(
self.model.real_score.model,
quant_config=real_cfg,
filtered_modules=getattr(config, "real_score_quant_filtered_modules", None),
filter_profile="teacher",
use_default_filtered_modules=getattr(
config, "real_score_quant_use_default_filtered_modules", True
),
cast_model_to_bf16=False,
materialize_for_inference=False,
verbose=self.is_main_process,
)
if self.is_main_process:
real_score_plan = (
"auto materialize before FSDP wrapping"
if self.real_score_quant_materialize
else "keep master weights after checkpoint load"
)
print(
"[NVFP4] Real_score (teacher) quantized inference enabled, "
f"scale_rule={real_cfg.scale_rule}, {len(real_matched)} modules excluded, "
f"{real_score_plan}"
)
if self.fake_score_quant:
fake_cfg = ModelQuantizationConfig(
scale_rule=getattr(config, "fake_score_quant_scale_rule", fallback_sr),
activation_scale_rule=getattr(config, "fake_score_quant_activation_scale_rule", fallback_asr),
weight_scale_rule=getattr(config, "fake_score_quant_weight_scale_rule", fallback_wsr),
gradient_scale_rule=getattr(config, "fake_score_quant_gradient_scale_rule", fallback_gsr),
keep_master_weights=True,
weight_scale_2d=True,
)
self.model.fake_score.model, fake_matched = quantize_model_with_filter(
self.model.fake_score.model,
quant_config=fake_cfg,
filtered_modules=getattr(config, "fake_score_quant_filtered_modules", None),
filter_profile="critic",
use_default_filtered_modules=getattr(
config, "fake_score_quant_use_default_filtered_modules", True
),
cast_model_to_bf16=False,
materialize_for_inference=False,
verbose=self.is_main_process,
)
if self.is_main_process:
print(
"[NVFP4] Fake_score (critic) quantized training enabled, "
f"scale_rule={fake_cfg.scale_rule}, {len(fake_matched)} modules excluded"
)
# Auto resume configuration (needed for LoRA checkpoint loading)
auto_resume = getattr(config, "auto_resume", True) # Default to True
# ================================= LoRA Configuration =================================
self.is_lora_enabled = False
self.lora_config = None
if hasattr(config, 'adapter') and config.adapter is not None:
self.is_lora_enabled = True
self.lora_config = config.adapter
if self.is_main_process:
print(f"LoRA enabled with config: {self.lora_config}")
print("Loading base model and applying LoRA before FSDP wrapping...")
# 1. Load base model first (config.generator_ckpt) before applying LoRA.
generator_checkpoint_path = getattr(config, "generator_ckpt", None)
if generator_checkpoint_path:
if self.is_main_process:
print(f"Loading base model from {generator_checkpoint_path} (before applying LoRA)")
generator_checkpoint = torch.load(generator_checkpoint_path, map_location="cpu")
# Load generator (directly; no key alignment needed since LoRA not applied yet)
if isinstance(generator_checkpoint, dict) and "generator" in generator_checkpoint:
if self.is_main_process:
print(f"Loading pretrained generator from {generator_checkpoint_path}")
self.model.generator.load_state_dict(generator_checkpoint["generator"], strict=True)
if self.is_main_process:
print("Generator weights loaded successfully")
elif isinstance(generator_checkpoint, dict) and "model" in generator_checkpoint:
if self.is_main_process:
print(f"Loading pretrained generator from {generator_checkpoint_path}")
self.model.generator.load_state_dict(generator_checkpoint["model"], strict=True)
if self.is_main_process:
print("Generator weights loaded successfully")
else:
self.model.generator.load_state_dict(generator_checkpoint, strict=True)
if self.is_main_process:
print("Loading base model as raw state_dict")
# Load critic from full/base checkpoints when available.
if isinstance(generator_checkpoint, dict) and "critic" in generator_checkpoint:
if self.is_main_process:
print(f"Loading pretrained critic from {generator_checkpoint_path}")
self.model.fake_score.load_state_dict(generator_checkpoint["critic"], strict=True)
if self.is_main_process:
print("Critic weights loaded successfully")
# Load training step from checkpoint metadata.
if isinstance(generator_checkpoint, dict) and "step" in generator_checkpoint:
self.step = generator_checkpoint["step"]
if self.is_main_process:
print(f"base_checkpoint step: {self.step}")
else:
if self.is_main_process:
print("Warning: Step not found in checkpoint, starting from step 0.")
del generator_checkpoint
gc.collect()
else:
if self.is_main_process:
print("No base model checkpoint specified, skipping base weight loading for LoRA training.")
# Load real_score from a separate checkpoint (independent of LoRA / auto_resume)
real_score_ckpt = getattr(config, "real_score_ckpt", None)
if real_score_ckpt:
if self.is_main_process:
print(f"Loading real_score from {real_score_ckpt}")
real_ckpt = torch.load(real_score_ckpt, map_location="cpu")
if "generator" in real_ckpt:
self.model.real_score.load_state_dict(real_ckpt["generator"], strict=True)
elif "critic" in real_ckpt:
self.model.real_score.load_state_dict(real_ckpt["critic"], strict=True)
elif "model" in real_ckpt:
self.model.real_score.load_state_dict(real_ckpt["model"], strict=True)
else:
if self.is_main_process:
print(f"No recognized key in {real_score_ckpt}, treating as raw state_dict")
self.model.real_score.load_state_dict(real_ckpt, strict=True)
del real_ckpt
gc.collect()
if self.is_main_process:
print(f"Successfully loaded real_score from {real_score_ckpt}")
# Apply LoRA wrapping if enabled (after all base weights are loaded, before FSDP)
if self.is_lora_enabled:
# 2. Apply LoRA wrapping now (after loading base model, before FSDP wrapping)
if self.is_main_process:
print("Applying LoRA to models...")
self.model.generator.model = self._configure_lora_for_model(self.model.generator.model, "generator")
# Configure LoRA for fake_score if needed
if getattr(self.lora_config, 'apply_to_critic', True):
self.model.fake_score.model = self._configure_lora_for_model(self.model.fake_score.model, "fake_score")
if self.is_main_process:
print("LoRA applied to both generator and critic")
else:
if self.is_main_process:
print("LoRA applied to generator only")
# 3. Load LoRA weights before FSDP wrapping (if a checkpoint is available).
# Priority: auto_resume -> legacy lora_ckpt -> initialized adapters.
lora_checkpoint_path = None
lora_checkpoint = None
if auto_resume and self.output_path:
latest_checkpoint = self.find_latest_checkpoint(self.output_path)
if latest_checkpoint:
lora_checkpoint_path = latest_checkpoint
if self.is_main_process:
print(f"Auto resume: Found LoRA checkpoint at {lora_checkpoint_path}")
else:
if self.is_main_process:
print("Auto resume: No LoRA checkpoint found in logdir")
elif auto_resume:
if self.is_main_process:
print("Auto resume enabled but no logdir specified for LoRA")
else:
if self.is_main_process:
print("Auto resume disabled for LoRA")
if lora_checkpoint_path is not None:
lora_checkpoint = torch.load(lora_checkpoint_path, map_location="cpu")
elif getattr(config, "lora_ckpt", None):
lora_checkpoint_path = config.lora_ckpt
lora_checkpoint = torch.load(lora_checkpoint_path, map_location="cpu")
if self.is_main_process:
print(f"Using legacy lora_ckpt: {lora_checkpoint_path}")
elif self.is_main_process:
print("No LoRA checkpoint specified, starting LoRA training from scratch")
# Load LoRA checkpoint (before FSDP wrapping)
if lora_checkpoint is not None:
if self.is_main_process:
print(f"Loading LoRA checkpoint from {lora_checkpoint_path} (before FSDP wrapping)")
if "generator_lora" not in lora_checkpoint:
raise ValueError(f"LoRA checkpoint {lora_checkpoint_path} is not a valid LoRA checkpoint. "
f"Found keys: {list(lora_checkpoint.keys())}")
if self.is_main_process:
print(f"Loading LoRA generator weights: {len(lora_checkpoint['generator_lora'])} keys in checkpoint")
peft.set_peft_model_state_dict(self.model.generator.model, lora_checkpoint["generator_lora"])
del lora_checkpoint["generator_lora"]
if getattr(self.lora_config, 'apply_to_critic', True):
if "critic_lora" not in lora_checkpoint:
raise ValueError(f"LoRA checkpoint {lora_checkpoint_path} is missing critic_lora.")
if self.is_main_process:
print(f"Loading LoRA critic weights: {len(lora_checkpoint['critic_lora'])} keys in checkpoint")
peft.set_peft_model_state_dict(self.model.fake_score.model, lora_checkpoint["critic_lora"])
del lora_checkpoint["critic_lora"]
gc.collect()
if "step" in lora_checkpoint:
self.step = lora_checkpoint["step"]
if self.is_main_process:
print(f"Resuming LoRA training from step {self.step}")
else:
if self.is_main_process:
print("No LoRA checkpoint to load, starting from scratch")
# Materialize quantized inference-only weights before FSDP can expose
# sharded 1D parameter views. The student/critic are materialized only
# in LoRA mode because their base weights are frozen after adapters.
if self.generator_quant and self.is_lora_enabled:
self._materialize_quantized_model_before_fsdp(
self.model.generator.model,
"Generator",
cache_transposed_weights=True,
)
apply_lora_to_critic = getattr(self.lora_config, "apply_to_critic", True) if self.lora_config else False
if self.fake_score_quant and self.is_lora_enabled and apply_lora_to_critic:
self._materialize_quantized_model_before_fsdp(
self.model.fake_score.model,
"Fake_score",
cache_transposed_weights=True,
)
if self.real_score_quant and self.real_score_quant_materialize:
self._materialize_quantized_model_before_fsdp(
self.model.real_score.model,
"Real_score",
cache_transposed_weights=False,
)
self.model.generator = fsdp_wrap(
self.model.generator,
sharding_strategy=config.sharding_strategy,
mixed_precision=config.mixed_precision,
wrap_strategy=config.generator_fsdp_wrap_strategy
)
self.model.real_score = fsdp_wrap(
self.model.real_score,
sharding_strategy=config.sharding_strategy,
mixed_precision=config.mixed_precision,
wrap_strategy=config.real_score_fsdp_wrap_strategy
)
self.model.fake_score = fsdp_wrap(
self.model.fake_score,
sharding_strategy=config.sharding_strategy,
mixed_precision=config.mixed_precision,
wrap_strategy=config.fake_score_fsdp_wrap_strategy
)
self.model.text_encoder = fsdp_wrap(
self.model.text_encoder,
sharding_strategy=config.sharding_strategy,
mixed_precision=config.mixed_precision,
wrap_strategy=config.text_encoder_fsdp_wrap_strategy,
cpu_offload=getattr(config, "text_encoder_cpu_offload", False)
)
self.model.vae = self.model.vae.to(
device=self.device, dtype=torch.bfloat16 if config.mixed_precision else torch.float32)
# Step 3: Set up EMA parameter containers
rename_param = (
lambda name: name.replace("_fsdp_wrapped_module.", "")
.replace("_checkpoint_wrapped_module.", "")
.replace("_orig_mod.", "")
)
self.name_to_trainable_params = {}
for n, p in self.model.generator.named_parameters():
if not p.requires_grad:
continue
renamed_n = rename_param(n)
self.name_to_trainable_params[renamed_n] = p
ema_weight = config.ema_weight
self.generator_ema = None
if (ema_weight is not None) and (ema_weight > 0.0):
if self.is_lora_enabled:
if self.is_main_process:
print(f"EMA disabled in LoRA mode (LoRA provides efficient parameter updates without EMA)")
self.generator_ema = None
else:
print(f"Setting up EMA with weight {ema_weight}")
self.generator_ema = EMA_FSDP(self.model.generator, decay=ema_weight)
# Step 4: Initialize the optimizer
self.generator_optimizer = torch.optim.AdamW(
[param for param in self.model.generator.parameters()
if param.requires_grad],
lr=config.lr,
betas=(config.beta1, config.beta2),
weight_decay=config.weight_decay
)
self.critic_optimizer = torch.optim.AdamW(
[param for param in self.model.fake_score.parameters()
if param.requires_grad],
lr=config.lr_critic if hasattr(config, "lr_critic") else config.lr,
betas=(config.beta1_critic, config.beta2_critic),
weight_decay=config.weight_decay
)
# Step 5: Initialize the dataloader
self.use_backward_simulation = getattr(config, "backward_simulation", True)
model_name = config.model_kwargs.model_name
frame_raw_height = list(config.image_or_video_shape)[3] * wan_default_config[model_name]["spatial_compression_ratio"]
frame_raw_width = list(config.image_or_video_shape)[4] * wan_default_config[model_name]["spatial_compression_ratio"]
num_frame_per_block = getattr(config, "num_frame_per_block", 1)
self.fps = wan_default_config[model_name].get("fps", 16)
latent_frames_for_dataset = list(config.image_or_video_shape)[1]
num_training_frames = getattr(config, "num_training_frames", latent_frames_for_dataset)
assert latent_frames_for_dataset >= num_training_frames, (
f"image_or_video_shape[1] ({latent_frames_for_dataset}) must be >= "
f"num_training_frames ({num_training_frames}), otherwise the dataset "
f"will not provide enough prompts for the rollout."
)
total_frames = (latent_frames_for_dataset - 1) * wan_default_config[model_name]["temporal_compression_ratio"] + 1
if dist.get_rank() == 0:
print(f"[Dataset] latent_frames_for_dataset={latent_frames_for_dataset}, total_frames={total_frames}")
temporal_compression_ratio = wan_default_config[model_name]["temporal_compression_ratio"]
first_chunk_frames = 1 + (num_frame_per_block - 1) * temporal_compression_ratio
subsequent_chunk_frames = num_frame_per_block * temporal_compression_ratio
num_blocks = 1 + (total_frames - first_chunk_frames) // subsequent_chunk_frames
if not getattr(config, "generator_is_causal", True):
num_blocks = 1
chunks_per_shot = getattr(config, "chunks_per_shot", 0)
scene_cut_prefix = getattr(config, "scene_cut_prefix", DEFAULT_SCENE_CUT_PREFIX)
single_video_only = getattr(config, "uniform_prompt", False)
allow_padding = getattr(config, "allow_padding", False)
min_latent_frames = getattr(config, "min_latent_frames", 0)
dataset_sample_warning_seconds = getattr(config, "dataset_sample_warning_seconds", 60.0)
dataset_sample_warning_interval_seconds = getattr(
config, "dataset_sample_warning_interval_seconds", 60.0
)
if self.use_backward_simulation and not self.config.i2v:
dataset = MultiTextConcatDataset(
data_path=config.data_path,
num_blocks=num_blocks,
chunks_per_shot=chunks_per_shot,
scene_cut_prefix=scene_cut_prefix,
)
collate_fn = eval_collate_fn
if dist.get_rank() == 0:
print(f"[backward_simulation] Using MultiTextConcatDataset: "
f"data_path={config.data_path}, num_blocks={num_blocks}, "
f"chunks_per_shot={chunks_per_shot}")
else:
dataset = MultiVideoConcatDataset(
data_dir=config.data_path,
video_size=(frame_raw_height, frame_raw_width),
total_frames=total_frames,
deterministic=False,
num_frame_per_block=num_frame_per_block,
temporal_compression_ratio=temporal_compression_ratio,
target_fps=self.fps,
allow_padding=allow_padding,
min_latent_frames=min_latent_frames,
single_video_only=single_video_only,
independent_first_frame=getattr(config, "independent_first_frame", False),
return_image=getattr(config, "i2v", False),
max_chunks_per_shot=chunks_per_shot,
scene_cut_prefix=scene_cut_prefix,
sample_warning_seconds=dataset_sample_warning_seconds,
sample_warning_interval_seconds=dataset_sample_warning_interval_seconds,
)
collate_fn = multi_video_collate_fn
if dist.get_rank() == 0 and single_video_only:
print(f"[uniform_prompt] single_video_only enabled: each sample uses one video only")
random_seed = int(time.time()) % (2**31) * dist.get_rank()
sampler = torch.utils.data.distributed.DistributedSampler(
dataset, shuffle=True, drop_last=True, seed=random_seed)
dataloader = torch.utils.data.DataLoader(
dataset, batch_size=config.batch_size, sampler=sampler,
num_workers=2, prefetch_factor=1, pin_memory=False,
persistent_workers=False, collate_fn=collate_fn,
)
if dist.get_rank() == 0:
print("DATASET SIZE %d" % len(dataset))
self.dataloader = cycle(dataloader)
# Step 6: Initialize the validation dataloader for visualization (fixed prompts)
self.fixed_vis_batch = None
self.vis_interval = section_get(config, "evaluation", "interval", getattr(config, "vis_interval", -1))
configured_vis_lengths = section_get(config, "evaluation", "num_frames", getattr(config, "vis_video_lengths", []))
self.save_vis_latents_only = section_get(
config,
"evaluation",
"save_latents_only",
getattr(config, "return_latents", True),
aliases=("return_latents", "save_latent_only"),
)
if isinstance(configured_vis_lengths, int):
configured_vis_lengths = [configured_vis_lengths]
if self.vis_interval > 0 and len(configured_vis_lengths) > 0:
# Determine validation data path
val_data_path = (
getattr(config, "eval_data_path", None)
or getattr(config, "val_data_path", None)
or config.data_path
)
if self.config.i2v:
val_dataset = MultiVideoConcatDataset(
data_dir=val_data_path,
video_size=(frame_raw_height, frame_raw_width),
total_frames=total_frames,
deterministic=True,
num_frame_per_block=num_frame_per_block,
temporal_compression_ratio=temporal_compression_ratio,
target_fps=self.fps,
allow_padding=allow_padding,
min_latent_frames=min_latent_frames,
single_video_only=single_video_only,
independent_first_frame=getattr(config, "independent_first_frame", False),
return_image=True,
max_chunks_per_shot=chunks_per_shot,
scene_cut_prefix=scene_cut_prefix,
sample_warning_seconds=dataset_sample_warning_seconds,
sample_warning_interval_seconds=dataset_sample_warning_interval_seconds,
)
val_collate_fn = multi_video_collate_fn
else:
val_dataset = MultiTextConcatDataset(
data_path=val_data_path,
num_blocks=num_blocks,
chunks_per_shot=chunks_per_shot,
scene_cut_prefix=scene_cut_prefix,
deterministic=True,
)
val_collate_fn = eval_collate_fn
if dist.get_rank() == 0:
print("VAL DATASET SIZE %d" % len(val_dataset))
sampler = torch.utils.data.distributed.DistributedSampler(
val_dataset, shuffle=False, drop_last=False)
val_dataloader = torch.utils.data.DataLoader(
val_dataset,
batch_size=section_get(config, "evaluation", "val_batch_size", getattr(config, "val_batch_size", 1)),
sampler=sampler,
num_workers=0,
collate_fn=val_collate_fn,
)
# Take the first batch as fixed visualization batch
try:
self.fixed_vis_batch = next(iter(val_dataloader))
except StopIteration:
self.fixed_vis_batch = None
# ----------------------------------------------------------------------------------------------------------
# Visualization settings
# ----------------------------------------------------------------------------------------------------------
# List of video lengths to visualize, e.g. [8, 16, 32]
self.vis_video_lengths = configured_vis_lengths
for _vl in self.vis_video_lengths:
assert _vl <= latent_frames_for_dataset, (
f"vis_video_lengths entry {_vl} exceeds "
f"image_or_video_shape[1] ({latent_frames_for_dataset}), "
f"the dataset will not provide enough prompts for visualization."
)
if self.vis_interval > 0 and len(self.vis_video_lengths) > 0:
self._setup_visualizer()
if not self.is_lora_enabled:
# ================================= Standard (non-LoRA) model logic =================================
checkpoint_path = None
if auto_resume and self.output_path:
# Auto resume: find latest checkpoint in logdir
latest_checkpoint = self.find_latest_checkpoint(self.output_path)
if latest_checkpoint:
checkpoint_path = latest_checkpoint
if self.is_main_process:
print(f"Auto resume: Found latest checkpoint at {checkpoint_path}")
else:
if self.is_main_process:
print("Auto resume: No checkpoint found in logdir, starting from scratch")
elif auto_resume:
if self.is_main_process:
print("Auto resume enabled but no logdir specified, starting from scratch")
else:
if self.is_main_process:
print("Auto resume disabled, starting from scratch")
if checkpoint_path is None:
if getattr(config, "generator_ckpt", False):
# Explicit checkpoint path provided
checkpoint_path = config.generator_ckpt
if self.is_main_process:
print(f"Using explicit checkpoint: {checkpoint_path}")
# Pre-load fake_score from a separate checkpoint if specified.
# This will be overwritten if checkpoint_path also contains "critic".
fake_score_ckpt = getattr(config, "fake_score_ckpt", None)
if fake_score_ckpt:
if self.is_main_process:
print(f"Loading fake_score from {fake_score_ckpt}")
fake_ckpt = torch.load(fake_score_ckpt, map_location="cpu")
if "critic" in fake_ckpt:
self.model.fake_score.load_state_dict(fake_ckpt["critic"], strict=True)
elif "fake_score" in fake_ckpt:
self.model.fake_score.load_state_dict(fake_ckpt["fake_score"], strict=True)
elif "model" in fake_ckpt:
self.model.fake_score.load_state_dict(fake_ckpt["model"], strict=True)
else:
if self.is_main_process:
print(f"No recognized key in {fake_score_ckpt}, treating as raw state_dict")
self.model.fake_score.load_state_dict(fake_ckpt, strict=True)
del fake_ckpt
gc.collect()
if self.is_main_process:
print(f"Successfully loaded fake_score from {fake_score_ckpt}")
if checkpoint_path:
if self.is_main_process:
print(f"Loading checkpoint from {checkpoint_path}")
checkpoint = torch.load(checkpoint_path, map_location="cpu")
# Load generator
if "generator" in checkpoint:
if self.is_main_process:
print(f"Loading pretrained generator from {checkpoint_path}")
self.model.generator.load_state_dict(checkpoint["generator"], strict=True)
del checkpoint["generator"]
elif "model" in checkpoint:
if self.is_main_process:
print(f"Loading pretrained generator from {checkpoint_path}")
self.model.generator.load_state_dict(checkpoint["model"], strict=True)
del checkpoint["model"]
else:
if self.is_main_process:
print(f"No 'generator'/'model' key found in {checkpoint_path}, treating as raw state_dict")
self.model.generator.load_state_dict(checkpoint, strict=True)
# Load critic
if "critic" in checkpoint:
if self.is_main_process:
print(f"Loading pretrained critic from {checkpoint_path}")
self.model.fake_score.load_state_dict(checkpoint["critic"], strict=True)
del checkpoint["critic"]
else:
if self.is_main_process:
print("Warning: Critic checkpoint not found.")
# Load EMA
if "generator_ema" in checkpoint and self.generator_ema is not None:
if self.is_main_process:
print(f"Loading pretrained EMA from {checkpoint_path}")
self.generator_ema.load_state_dict(checkpoint["generator_ema"])
del checkpoint["generator_ema"]
else:
if self.is_main_process:
print("Warning: EMA checkpoint not found or EMA not initialized.")
gc.collect()
# For auto resume, always resume full training state
# Load optimizers
if "generator_optimizer" in checkpoint:
if self.is_main_process:
print("Resuming generator optimizer...")
gen_osd = FSDP.optim_state_dict_to_load(
self.model.generator, # FSDP root module
self.generator_optimizer, # newly created optimizer
checkpoint["generator_optimizer"] # optimizer state dict at save time
)
del checkpoint["generator_optimizer"]
self.generator_optimizer.load_state_dict(gen_osd)
del gen_osd
else:
if self.is_main_process:
print("Warning: Generator optimizer checkpoint not found.")
if "critic_optimizer" in checkpoint:
if self.is_main_process:
print("Resuming critic optimizer...")
crit_osd = FSDP.optim_state_dict_to_load(
self.model.fake_score,
self.critic_optimizer,
checkpoint["critic_optimizer"]
)
del checkpoint["critic_optimizer"]
self.critic_optimizer.load_state_dict(crit_osd)
del crit_osd
else:
if self.is_main_process:
print("Warning: Critic optimizer checkpoint not found.")
# Load training step
if "step" in checkpoint:
self.step = checkpoint["step"]
if self.is_main_process:
print(f"Resuming from step {self.step}")
else:
if self.is_main_process:
print("Warning: Step not found in checkpoint, starting from step 0.")
del checkpoint
gc.collect()
# Load real_score from a separate checkpoint (independent of auto_resume)
real_score_ckpt = getattr(config, "real_score_ckpt", None)
if real_score_ckpt and not (self.real_score_quant and self.real_score_quant_materialize):
if self.is_main_process:
print(f"Loading real_score from {real_score_ckpt}")
real_ckpt = torch.load(real_score_ckpt, map_location="cpu")
if "generator" in real_ckpt:
self.model.real_score.load_state_dict(real_ckpt["generator"], strict=True)
elif "critic" in real_ckpt:
self.model.real_score.load_state_dict(real_ckpt["critic"], strict=True)
elif "model" in real_ckpt:
self.model.real_score.load_state_dict(real_ckpt["model"], strict=True)
else:
if self.is_main_process:
print(f"No recognized key in {real_score_ckpt}, treating as raw state_dict")
self.model.real_score.load_state_dict(real_ckpt, strict=True)
del real_ckpt
gc.collect()
if self.is_main_process:
print(f"Successfully loaded real_score from {real_score_ckpt}")
##############################################################################################################
# Let's delete EMA params for early steps to save some computes at training and inference
# Note: This should be done after potential resume to avoid accidentally deleting resumed EMA
if self.step < config.ema_start_step:
self.generator_ema = None
self.max_grad_norm_generator = getattr(config, "max_grad_norm_generator", 10.0)
self.max_grad_norm_critic = getattr(config, "max_grad_norm_critic", 10.0)
self.gradient_accumulation_steps = getattr(config, "gradient_accumulation_steps", 1)
self.previous_time = None
if self.is_main_process:
print(f"Gradient accumulation steps: {self.gradient_accumulation_steps}")
if self.gradient_accumulation_steps > 1:
print(f"Effective batch size: {config.batch_size * self.gradient_accumulation_steps * self.world_size}")
def _move_optimizer_to_device(self, optimizer, device):
"""Move optimizer state to the specified device."""
for state in optimizer.state.values():
for k, v in state.items():
if isinstance(v, torch.Tensor):
state[k] = v.to(device)
def _materialize_quantized_model_before_fsdp(
self,
model,
model_label,
cache_transposed_weights=False,
):
"""Materialize NVFP4 weights before FSDP wraps quantized modules."""
from utils.quant import _materialize_quantized_weights_for_inference
current_rank = dist.get_rank()
target_device = torch.device("cuda", torch.cuda.current_device())
if self.is_main_process:
print(f"[NVFP4] Materializing {model_label} sequentially before FSDP")
for materialize_rank in range(self.world_size):
if current_rank == materialize_rank:
first_param = next(model.parameters(), None)
model_device = first_param.device if first_param is not None else target_device
if model_device != target_device:
model.to(target_device)
mat_modules, master_bytes, quant_bytes = _materialize_quantized_weights_for_inference(
model,
target_device=target_device,
cache_transposed_weights=cache_transposed_weights,
)
if self.is_main_process:
print(
f"[NVFP4] {model_label} materialized: {len(mat_modules)} modules, "
f"master_weight={master_bytes / (1024**3):.3f} GiB freed, "
f"quantized_weight={quant_bytes / (1024**3):.3f} GiB"
)
gc.collect()
dist.barrier()
def find_latest_checkpoint(self, logdir):
"""Find the latest checkpoint in the logdir."""
if not os.path.exists(logdir):
return None
checkpoint_dirs = []
for item in os.listdir(logdir):
if item.startswith("checkpoint_model_") and os.path.isdir(os.path.join(logdir, item)):
try:
# Extract step number from directory name
step_str = item.replace("checkpoint_model_", "")
step = int(step_str)
checkpoint_path = os.path.join(logdir, item, "model.pt")
if os.path.exists(checkpoint_path):
checkpoint_dirs.append((step, checkpoint_path))
except ValueError:
continue
if not checkpoint_dirs:
return None
# Sort by step number and return the latest one
checkpoint_dirs.sort(key=lambda x: x[0])
latest_step, latest_path = checkpoint_dirs[-1]
return latest_path
def get_all_checkpoints(self, logdir):
"""Get all checkpoints in the logdir sorted by step number."""
if not os.path.exists(logdir):
return []
checkpoint_dirs = []
for item in os.listdir(logdir):
if item.startswith("checkpoint_model_") and os.path.isdir(os.path.join(logdir, item)):
try:
# Extract step number from directory name
step_str = item.replace("checkpoint_model_", "")
step = int(step_str)
checkpoint_dir_path = os.path.join(logdir, item)
checkpoint_file_path = os.path.join(checkpoint_dir_path, "model.pt")
if os.path.exists(checkpoint_file_path):
checkpoint_dirs.append((step, checkpoint_dir_path, item))
except ValueError:
continue
# Sort by step number (ascending order)
checkpoint_dirs.sort(key=lambda x: x[0])
return checkpoint_dirs
def cleanup_old_checkpoints(self, logdir, max_checkpoints):
"""Remove old checkpoints if the number exceeds max_checkpoints.
Only the main process performs the actual deletion to avoid race conditions
in distributed training.
"""
if max_checkpoints <= 0:
return
# Only main process should perform cleanup to avoid race conditions
if not self.is_main_process:
return
checkpoints = self.get_all_checkpoints(logdir)
if len(checkpoints) > max_checkpoints:
# Calculate how many to remove
num_to_remove = len(checkpoints) - max_checkpoints
checkpoints_to_remove = checkpoints[:num_to_remove] # Remove oldest ones
print(f"Checkpoint cleanup: Found {len(checkpoints)} checkpoints, removing {num_to_remove} oldest ones (keeping {max_checkpoints})")
import shutil
removed_count = 0
for step, checkpoint_dir_path, dir_name in checkpoints_to_remove:
try:
print(f" Removing: {dir_name} (step {step})")
shutil.rmtree(checkpoint_dir_path)
removed_count += 1
except Exception as e:
print(f" Warning: Failed to remove checkpoint {dir_name}: {e}")
print(f"Checkpoint cleanup completed: removed {removed_count}/{num_to_remove} old checkpoints")
else:
if len(checkpoints) > 0:
print(f"Checkpoint cleanup: Found {len(checkpoints)} checkpoints (max: {max_checkpoints}, no cleanup needed)")
def save(self):
print("Start gathering distributed model states...")
if self.is_lora_enabled:
gen_lora_sd = self._gather_lora_state_dict(
self.model.generator.model)
crit_lora_sd = self._gather_lora_state_dict(
self.model.fake_score.model)
state_dict = {
"generator_lora": gen_lora_sd,
"critic_lora": crit_lora_sd,
"step": self.step,
}
else:
with FSDP.state_dict_type(
self.model.generator,
StateDictType.FULL_STATE_DICT,
FullStateDictConfig(rank0_only=True, offload_to_cpu=True),
FullOptimStateDictConfig(rank0_only=True, offload_to_cpu=True),
):
generator_state_dict = self.model.generator.state_dict()
generator_opim_state_dict = FSDP.optim_state_dict(self.model.generator,
self.generator_optimizer)
if dist.is_initialized():
dist.barrier()
with FSDP.state_dict_type(
self.model.fake_score,
StateDictType.FULL_STATE_DICT,
FullStateDictConfig(rank0_only=True, offload_to_cpu=True),
FullOptimStateDictConfig(rank0_only=True, offload_to_cpu=True),
):
critic_state_dict = self.model.fake_score.state_dict()
critic_opim_state_dict = FSDP.optim_state_dict(self.model.fake_score,
self.critic_optimizer)
if self.config.ema_start_step < self.step and self.generator_ema is not None:
state_dict = {
"generator": generator_state_dict,
"critic": critic_state_dict,
"generator_ema": self.generator_ema.state_dict(),
"generator_optimizer": generator_opim_state_dict,
"critic_optimizer": critic_opim_state_dict,
"step": self.step,
}
else:
state_dict = {
"generator": generator_state_dict,
"critic": critic_state_dict,
"generator_optimizer": generator_opim_state_dict,
"critic_optimizer": critic_opim_state_dict,
"step": self.step,
}
if self.is_main_process:
checkpoint_dir = os.path.join(self.output_path, f"checkpoint_model_{self.step:06d}")
os.makedirs(checkpoint_dir, exist_ok=True)
checkpoint_file = os.path.join(checkpoint_dir, "model.pt")
torch.save(state_dict, checkpoint_file)
print("Model saved to", checkpoint_file)
# Cleanup old checkpoints if max_checkpoints is set
max_checkpoints = getattr(self.config, "max_checkpoints", 0)
if max_checkpoints > 0:
self.cleanup_old_checkpoints(self.output_path, max_checkpoints)
# Keep all ranks in sync so non-rank0 workers don't kick off the next
# training iteration (and trigger NCCL watchdog timeouts) while rank0
# is still writing the checkpoint to disk.
if dist.is_initialized():
dist.barrier()
torch.cuda.empty_cache()
import gc
gc.collect()
def fwdbwd_one_step(self, batch, train_generator):
self.model.eval() # prevent any randomness (e.g. dropout)
if self.step % 5 == 0:
torch.cuda.empty_cache()
# Step 1: Get the next batch of text prompts
text_prompts = batch["prompts"]
if getattr(self.config, "uniform_prompt", False):
text_prompts = [[sample[0]] * len(sample) for sample in text_prompts]
batch_size = len(text_prompts)
image_or_video_shape = list(self.config.image_or_video_shape)
image_or_video_shape[0] = batch_size
# Step 1.5: Prepare clean_latent and initial_latent for off-policy mode
clean_latent = None
initial_latent = None
if not self.use_backward_simulation:
if not getattr(self.config, "load_raw_video", False):
clean_latent = batch["ode_latent"][:, -1].to(
device=self.device, dtype=self.dtype)
else:
frames = batch["frames"].to(
device=self.device, dtype=self.dtype)
with torch.no_grad():
clean_latent = self.model.vae.encode_to_latent(frames).to(
device=self.device, dtype=self.dtype)
if getattr(self.config, "i2v", False):
initial_latent = clean_latent[:, 0:1]
elif getattr(self.config, "i2v", False):
image = batch.get("image", None)
if image is None:
raise ValueError("DMD i2v backward-simulation requires batch['image'].")
image = image.to(device=self.device, dtype=self.dtype)
if image.ndim == 4:
image = image.unsqueeze(2)
elif image.ndim != 5:
raise ValueError(
f"Expected i2v image with shape [B,C,H,W] or [B,C,T,H,W], got {tuple(image.shape)}"
)
with torch.no_grad():
initial_latent = self.model.vae.encode_to_latent(image).to(
device=self.device, dtype=self.dtype)
# Step 2: Extract the conditional infos
with torch.no_grad():
# MultiVideoConcatDataset returns List[List[str]], flatten to List[str]
text_prompts_flat = [p for sublist in text_prompts for p in sublist]
conditional_dict = self.model.text_encoder(
text_prompts=text_prompts_flat)
if not getattr(self, "unconditional_dict", None):
unconditional_dict = self.model.text_encoder(
text_prompts=[self.config.negative_prompt] * batch_size)
unconditional_dict = {k: v.detach()
for k, v in unconditional_dict.items()}
self.unconditional_dict = unconditional_dict
else:
unconditional_dict = self.unconditional_dict
use_scene_cut_mask = (
section_get(self.config, "inference", "multi_shot_sink", False)
or section_get(
self.config,
"inference",
"multi_shot_rope_offset",
0.0,
) != 0.0
)
if use_scene_cut_mask:
_prefix = getattr(self.config, "scene_cut_prefix", DEFAULT_SCENE_CUT_PREFIX)
conditional_dict["scene_cut_mask"] = [
p.startswith(_prefix) for p in text_prompts[0]
]
# Step 3: Store gradients for the generator (if training the generator)
if train_generator:
generator_loss, generator_log_dict = self.model.generator_loss(
image_or_video_shape=image_or_video_shape,
conditional_dict=conditional_dict,
unconditional_dict=unconditional_dict,
clean_latent=clean_latent,
initial_latent=initial_latent
)
# Scale loss for gradient accumulation and backward
scaled_generator_loss = generator_loss / self.gradient_accumulation_steps
scaled_generator_loss.backward()
generator_log_dict.update({"generator_loss": generator_loss,
"generator_grad_norm": torch.tensor(0.0, device=self.device)})
return generator_log_dict
else:
generator_log_dict = {}
critic_loss, critic_log_dict = self.model.critic_loss(
image_or_video_shape=image_or_video_shape,
conditional_dict=conditional_dict,
unconditional_dict=unconditional_dict,
clean_latent=clean_latent,
initial_latent=initial_latent
)
# Scale loss for gradient accumulation and backward
scaled_critic_loss = critic_loss / self.gradient_accumulation_steps
scaled_critic_loss.backward()
critic_log_dict.update({"critic_loss": critic_loss,
"critic_grad_norm": torch.tensor(0.0, device=self.device)})
return critic_log_dict
def generate_video(self, pipeline, num_frames, prompts, image=None, latents_only=False):
batch_size = len(prompts)
if image is not None:
image = image.to(device=self.device, dtype=self.dtype)
if image.ndim == 4:
image = image.unsqueeze(2)
elif image.ndim != 5:
raise ValueError(f"Expected i2v image with shape [B,C,H,W] or [B,C,T,H,W], got {tuple(image.shape)}")
# Encode the input image as the first latent
initial_latent = pipeline.vae.encode_to_latent(image).to(device=self.device, dtype=self.dtype)
if initial_latent.shape[0] != batch_size:
initial_latent = initial_latent.repeat(batch_size, 1, 1, 1, 1)
num_noise_frames = num_frames
if num_noise_frames <= initial_latent.shape[1]:
raise ValueError(
f"num_frames must exceed the i2v conditioning frames; "
f"got {num_frames} and {initial_latent.shape[1]}"
)
sampled_noise = torch.randn(
[batch_size, num_noise_frames, self.config.image_or_video_shape[2], self.config.image_or_video_shape[3], self.config.image_or_video_shape[4]],
device=self.device,
dtype=self.dtype
)
else:
initial_latent = None
sampled_noise = torch.randn(
[batch_size, num_frames, self.config.image_or_video_shape[2], self.config.image_or_video_shape[3], self.config.image_or_video_shape[4]],
device=self.device,
dtype=self.dtype
)
with torch.no_grad():
kwargs = dict(noise=sampled_noise, text_prompts=prompts)
if initial_latent is not None:
kwargs["initial_latent"] = initial_latent
kwargs["return_latents"] = latents_only
result = pipeline.inference(**kwargs)
if latents_only:
return result
video = result
current_video = video.permute(0, 1, 3, 4, 2).cpu().numpy() * 255.0
if hasattr(pipeline, 'vae') and hasattr(pipeline.vae, 'model') and hasattr(pipeline.vae.model, 'clear_cache'):
pipeline.vae.model.clear_cache()
return current_video
def train(self):
start_step = self.step
try:
while True:
# Check if we should train generator on this optimization step
TRAIN_GENERATOR = self.step % self.config.dfake_gen_update_ratio == 0
if TRAIN_GENERATOR:
self.generator_optimizer.zero_grad(set_to_none=True)
self.critic_optimizer.zero_grad(set_to_none=True)
# Whole-cycle gradient accumulation loop
accumulated_generator_logs = []
accumulated_critic_logs = []
for accumulation_step in range(self.gradient_accumulation_steps):
batch = next(self.dataloader)
# Train generator (if needed)
if TRAIN_GENERATOR:
extra_gen = self.fwdbwd_one_step(batch, True)
accumulated_generator_logs.append(extra_gen)
# Train critic
extra_crit = self.fwdbwd_one_step(batch, False)
accumulated_critic_logs.append(extra_crit)
# Compute grad norm and update parameters
if TRAIN_GENERATOR:
generator_grad_norm = self.model.generator.clip_grad_norm_(self.max_grad_norm_generator)
generator_log_dict = merge_dict_list(accumulated_generator_logs)
generator_log_dict["generator_grad_norm"] = generator_grad_norm
self.generator_optimizer.step()
if self.generator_ema is not None:
self.generator_ema.update(self.model.generator)
else:
generator_log_dict = {}
critic_grad_norm = self.model.fake_score.clip_grad_norm_(self.max_grad_norm_critic)
critic_log_dict = merge_dict_list(accumulated_critic_logs)
critic_log_dict["critic_grad_norm"] = critic_grad_norm
self.critic_optimizer.step()
# Increment the step since we finished gradient update
self.step += 1
# Create EMA params (if not already created)
if (self.step >= self.config.ema_start_step) and \
(self.generator_ema is None) and (self.config.ema_weight > 0):
if not self.is_lora_enabled:
self.generator_ema = EMA_FSDP(self.model.generator, decay=self.config.ema_weight)
if self.is_main_process:
print(f"EMA created at step {self.step} with weight {self.config.ema_weight}")
else:
if self.is_main_process:
print(f"EMA creation skipped at step {self.step} (disabled in LoRA mode)")
# Save the model
if (not self.config.no_save) and (self.step - start_step) > 0 and self.step % self.config.log_iters == 0:
torch.cuda.empty_cache()
self.save()
torch.cuda.empty_cache()
# Logging
if self.is_main_process:
wandb_loss_dict = {}
if TRAIN_GENERATOR and generator_log_dict:
wandb_loss_dict.update(
{
"generator_loss": generator_log_dict["generator_loss"].mean().item(),
"generator_grad_norm": generator_log_dict["generator_grad_norm"].mean().item(),
"dmdtrain_gradient_norm": generator_log_dict["dmdtrain_gradient_norm"].mean().item()
}
)
wandb_loss_dict.update(
{
"critic_loss": critic_log_dict["critic_loss"].mean().item(),
"critic_grad_norm": critic_log_dict["critic_grad_norm"].mean().item()
}
)
if not self.disable_wandb:
wandb.log(wandb_loss_dict, step=self.step)
if self.step % self.config.gc_interval == 0:
if dist.get_rank() == 0:
logging.info("DistGarbageCollector: Running GC.")
gc.collect()
torch.cuda.empty_cache()
if self.is_main_process:
current_time = time.time()
iteration_time = 0 if self.previous_time is None else current_time - self.previous_time
if not self.disable_wandb:
wandb.log({"per iteration time": iteration_time}, step=self.step)
self.previous_time = current_time
# Log training progress
if TRAIN_GENERATOR and generator_log_dict:
print(f"step {self.step}, per iteration time {iteration_time}, generator_loss {generator_log_dict['generator_loss'].mean().item()}, generator_grad_norm {generator_log_dict['generator_grad_norm'].mean().item()}, dmdtrain_gradient_norm {generator_log_dict['dmdtrain_gradient_norm'].mean().item()}, critic_loss {critic_log_dict['critic_loss'].mean().item()}, critic_grad_norm {critic_log_dict['critic_grad_norm'].mean().item()}")
else:
print(f"step {self.step}, per iteration time {iteration_time}, critic_loss {critic_log_dict['critic_loss'].mean().item()}, critic_grad_norm {critic_log_dict['critic_grad_norm'].mean().item()}")
# ---------------------------------------- Visualization ---------------------------------------------------
if self.vis_interval > 0 and (self.step % self.vis_interval == 0):
self._visualize()
if self.step >= self.config.max_iters:
break
except Exception as e:
print(f"[ERROR] [Rank {dist.get_rank()}] Training crashed at step {self.step} with exception: {e}")
print(f"[ERROR] [Rank {dist.get_rank()}] Exception traceback:", flush=True)
import traceback
traceback.print_exc()
def _configure_lora_for_model(self, transformer, model_name):
"""Configure LoRA for a WanDiffusionWrapper model"""
# Find all Linear modules in WanAttentionBlock modules
target_linear_modules = set()
# Define the specific modules we want to apply LoRA to
all_causal = getattr(self.config, 'all_causal', False)
generator_is_causal = getattr(self.config, 'generator_is_causal', True)
if model_name == 'generator':
adapter_target_modules = ['CausalWanAttentionBlock'] if generator_is_causal else ['WanAttentionBlock']
elif model_name == 'fake_score':
adapter_target_modules = ['CausalWanAttentionBlock'] if all_causal else ['WanAttentionBlock']
else:
raise ValueError(f"Invalid model name: {model_name}")
for name, module in transformer.named_modules():
if module.__class__.__name__ in adapter_target_modules:
for full_submodule_name, submodule in module.named_modules(prefix=name):
if isinstance(submodule, torch.nn.Linear):
target_linear_modules.add(full_submodule_name)
target_linear_modules = list(target_linear_modules)
if self.is_main_process:
print(f"LoRA target modules for {model_name}: {len(target_linear_modules)} Linear layers")
if getattr(self.lora_config, 'verbose', False):
for module_name in sorted(target_linear_modules):
print(f" - {module_name}")
# Create LoRA config
adapter_type = self.lora_config.get('type', 'lora')
if adapter_type == 'lora':
peft_config = peft.LoraConfig(
r=self.lora_config.get('rank', 16),
lora_alpha=self.lora_config.get('alpha', None) or self.lora_config.get('rank', 16),
lora_dropout=self.lora_config.get('dropout', 0.0),
target_modules=target_linear_modules,
)
else:
raise NotImplementedError(f'Adapter type {adapter_type} is not implemented')
# Apply LoRA to the transformer
lora_model = peft.get_peft_model(transformer, peft_config)
if self.is_main_process:
print('peft_config', peft_config)
lora_model.print_trainable_parameters()
return lora_model
def _gather_lora_state_dict(self, lora_model):
"On rank-0, gather FULL_STATE_DICT, then filter only LoRA weights"
with FSDP.state_dict_type(
lora_model, # lora_model contains nested FSDP submodules
StateDictType.FULL_STATE_DICT,
FullStateDictConfig(rank0_only=True, offload_to_cpu=True)
):
full = lora_model.state_dict()
return get_peft_model_state_dict(lora_model, state_dict=full)
# --------------------------------------------------------------------------------------------------------------
# Visualization helpers
# --------------------------------------------------------------------------------------------------------------
def _setup_visualizer(self):
"""Initialize the inference pipeline for visualization on CPU, to be moved to GPU only when needed."""
generator_is_causal = getattr(self.config, "generator_is_causal", True)
if not generator_is_causal:
# Bidirectional generator: no pipeline object needed,
# _visualize_bidirectional handles the loop directly.
self.vis_pipeline = "bidirectional"
else:
from copy import deepcopy
vis_config = deepcopy(self.config)
if "guidance_scale" not in getattr(vis_config, "inference", {}):
vis_config.guidance_scale = 1.0
if section_get(self.config, "inference", "sampling_steps", None) is None:
vis_config.sampling_steps = 50
self.vis_pipeline = CausalDiffusionInferencePipeline(
args=vis_config,
device=self.device,
generator=self.model.generator,
text_encoder=self.model.text_encoder,
vae=self.model.vae)
# Visualization output directory (default: <logdir>/vis)
self.vis_output_dir = os.path.join(self.output_path, "vis")
os.makedirs(self.vis_output_dir, exist_ok=True)
if section_get(self.config, "evaluation", "use_ema", getattr(self.config, "vis_ema", False)):
raise NotImplementedError("Visualization with EMA is not implemented")
@torch.no_grad()
def _generate_bidirectional(self, num_frames, prompts):
"""Full-sequence bidirectional multi-step denoising for visualization."""
from wan_5b.utils.fm_solvers_unipc import FlowUniPCMultistepScheduler
batch_size = len(prompts)
# Flatten prompts (List[List[str]] → List[str], take first per sample)
text_prompts_flat = [p[0] if isinstance(p, list) else p for p in prompts]
conditional_dict = self.model.text_encoder(text_prompts=text_prompts_flat)
noise = torch.randn(
[batch_size, num_frames,
self.config.image_or_video_shape[2],
self.config.image_or_video_shape[3],
self.config.image_or_video_shape[4]],
device=self.device, dtype=self.dtype)
sampling_steps = section_get(self.config, "inference", "sampling_steps", getattr(self.config, "sampling_steps", 50))
scheduler = self.model.generator.get_scheduler()
sample_scheduler = FlowUniPCMultistepScheduler(
num_train_timesteps=scheduler.num_train_timesteps,
shift=1, use_dynamic_shifting=False)
sample_scheduler.set_timesteps(sampling_steps, device=self.device,
shift=scheduler.shift)
latents = noise
for t in sample_scheduler.timesteps:
timestep = t * torch.ones(
[batch_size, num_frames], device=self.device, dtype=torch.float32)
flow_pred, _ = self.model.generator(
noisy_image_or_video=latents,
conditional_dict=conditional_dict,
timestep=timestep,
)
latents = sample_scheduler.step(flow_pred, t, latents, return_dict=False)[0]
return latents
def _visualize(self):
"""Generate validation samples to monitor training progress."""
if self.vis_interval <= 0 or not hasattr(self, "vis_pipeline"):
return False
# FSDP forward includes communication, so every rank must enter
# visualization together; running rank 0 alone would hang.
if not getattr(self, "fixed_vis_batch", None):
print("[Warning] No fixed validation batch available for visualization.")
return False
step_vis_dir = os.path.join(self.vis_output_dir, f"step_{self.step:07d}")
os.makedirs(step_vis_dir, exist_ok=True)
batch = self.fixed_vis_batch
prompts = batch["prompts"]
image = None
if self.config.i2v and ("image" in batch):
image = batch["image"]
mode_info = ""
if self.is_lora_enabled:
mode_info = "_lora"
if self.is_main_process:
print(f"Generating latents in LoRA mode (step {self.step})")
for vid_len in self.vis_video_lengths:
print(f"Generating validation samples of length {vid_len}")
if self.vis_pipeline == "bidirectional":
samples = self._generate_bidirectional(vid_len, prompts)
if not self.save_vis_latents_only:
samples = self.model.vae.decode_to_pixel(samples)
samples = (samples * 0.5 + 0.5).clamp(0, 1)
samples = samples.permute(0, 1, 3, 4, 2).cpu().numpy() * 255.0
else:
samples = self.generate_video(
self.vis_pipeline,
vid_len,
prompts,
image=image,
latents_only=self.save_vis_latents_only,
)
for idx in range(samples.shape[0]):
if self.save_vis_latents_only:
sample_name = f"latents_step_{self.step:07d}_rank_{dist.get_rank()}_sample_{idx}_len_{vid_len}{mode_info}.pt"
out_path = os.path.join(step_vis_dir, sample_name)
torch.save(samples[idx].cpu(), out_path)
else:
sample_name = f"video_step_{self.step:07d}_rank_{dist.get_rank()}_sample_{idx}_len_{vid_len}{mode_info}.mp4"
out_path = os.path.join(step_vis_dir, sample_name)
write_video(out_path, torch.as_tensor(samples[idx]).to(torch.uint8), fps=24)
del samples
torch.cuda.empty_cache()
# Save prompts for reference
prompt_path = os.path.join(
step_vis_dir,
f"prompts_rank_{dist.get_rank()}.txt",
)
with open(prompt_path, "w") as f:
for i, p in enumerate(prompts):
f.write(f"[sample {i}] {p}\n")
# Release KV / cross-attention caches allocated during inference to prevent OOM
# when training resumes. These caches can consume ~20+ GB of GPU memory.
if hasattr(self.vis_pipeline, 'clear_cache'):
self.vis_pipeline.clear_cache()
torch.cuda.empty_cache()
import gc
gc.collect()
# Synchronize all ranks so that a crashed rank is detected immediately
# rather than causing a 10-minute NCCL timeout on the next training collective.
dist.barrier()
return True