1794 lines
88 KiB
Python
1794 lines
88 KiB
Python
# Modified from scripts/minimax_h3/train_lora.py for Parallel Decoding Distillation (PDD, arXiv 2607.26004).
|
|
# Scaffold (parameter set, resume, checkpointing) follows `scripts/minimax_h3/train_lora.py`.
|
|
#
|
|
# Data-free PDD LoRA of the packed-sequence transformer, covering `fl2va` (FL2VA / t2va layout) and `ref2va`.
|
|
# PDD trains a *parallel decoder*: the sampling interval is discretized into `N` intervals grouped into blocks of
|
|
# `L`, and one network evaluation predicts the mean velocity of every interval of the next block, so generation
|
|
# advances `L` intervals per evaluation (`NFE = N / L`). The student is the teacher's own backbone with the two
|
|
# final heads repeated `N` times (`videox_fun/models/minimax_h3_pdd.py`); the loss is a plain MSE onto a
|
|
# Runge-Kutta estimate of the teacher's mean velocity — no VSD, no adversarial term, no JVP.
|
|
#
|
|
# Training is *data-free* (Algorithm 3 of the paper): no target video is ever read. Each rank carries one
|
|
# trajectory, rolls it forward with the student's own predictions, and resets to fresh noise and a fresh prompt
|
|
# when it reaches the end of the grid. The conditioning is either pre-encoded (`--enable_preprocess_training`,
|
|
# which keeps the 62 GB conditioner out of the run) or encoded on the fly from an annotation JSON; `ref2va`
|
|
# supports both, its on-the-fly route additionally VAE-encoding each request's reference latents.
|
|
# FSDP / DeepSpeed follow `scripts/minimax_h3/train_lora.py`: the plugin is read off Accelerator, ZeRO-3
|
|
# skips `zero.Init` on the frozen VAEs, FSDP stage 3 / ZeRO-3 resume through `accelerator.save_state`, and the
|
|
# student / teacher forwards always go through the prepared wrapper so a sharded 33 B backbone all-gathers.
|
|
#
|
|
# MiniMax-H3's rectified-flow convention is the *opposite* of Wan's and is reproduced here from
|
|
# `MiniMaxH3Scheduler.scale_noise` / `MiniMaxH3Scheduler.step`, the single source of truth:
|
|
# * noising: `x_t = t * x0 + (1 - t) * noise` with `t = 1` clean, `t = 1 - sigma`,
|
|
# * the sigma grid is exponentially shifted, `sigma' = s * sigma / (1 + (s - 1) * sigma)`, `s = 12.0` for video and `3.0` for audio,
|
|
# * the transformer predicts a data-ward velocity, so the regression target is `x0 - noise`.
|
|
#
|
|
# The checkpoint is guidance-distilled: one forward per step, no unconditional branch.
|
|
#
|
|
# Usage:
|
|
# # fl2va off a pre-encoded prompt cache (`--enable_preprocess_training`):
|
|
# accelerate launch --mixed_precision no scripts/minimax_h3/train_pdd_lora.py \
|
|
# --pretrained_model_name_or_path=models/Diffusion_Transformer/MiniMax-H3 \
|
|
# --train_mode=fl2va --enable_preprocess_training \
|
|
# --train_data_meta=datasets/minimax_h3_pdd_prompt_cache/outputs.json \
|
|
# --output_dir=output_dir_minimax_h3_pdd_lora --gradient_checkpointing --resume_from_checkpoint=latest
|
|
#
|
|
# # ref2va loaded directly from a request annotation (no request cache; encodes on the fly):
|
|
# accelerate launch --mixed_precision no scripts/minimax_h3/train_pdd_lora.py \
|
|
# --pretrained_model_name_or_path=models/Diffusion_Transformer/MiniMax-H3 \
|
|
# --train_mode=ref2va \
|
|
# --train_data_meta=datasets/X-Fun-Videos-Audios-Demo/metadata_add_width_height.json \
|
|
# --output_dir=output_dir_minimax_h3_pdd_ref2va_lora --gradient_checkpointing --resume_from_checkpoint=latest
|
|
|
|
import argparse
|
|
import gc
|
|
import json
|
|
import logging
|
|
import math
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import time
|
|
import warnings
|
|
from types import SimpleNamespace
|
|
|
|
import accelerate
|
|
import diffusers
|
|
import numpy as np
|
|
import torch
|
|
import torch.nn.functional as F
|
|
import transformers
|
|
from torch.utils.data import DataLoader, Dataset
|
|
from accelerate import Accelerator
|
|
from accelerate.logging import get_logger
|
|
from accelerate.state import AcceleratorState
|
|
from accelerate.utils import ProjectConfiguration, set_seed
|
|
from diffusers.optimization import get_scheduler
|
|
from diffusers.training_utils import EMAModel
|
|
from diffusers.utils.torch_utils import is_compiled_module
|
|
from packaging import version
|
|
from transformers.utils import ContextManagers
|
|
|
|
current_file_path = os.path.abspath(__file__)
|
|
project_roots = [os.path.dirname(current_file_path), os.path.dirname(os.path.dirname(current_file_path)), os.path.dirname(os.path.dirname(os.path.dirname(current_file_path)))]
|
|
for project_root in project_roots:
|
|
sys.path.insert(0, project_root) if project_root not in sys.path else None
|
|
|
|
from videox_fun.data import (BatchSampler, ImageVideoSafetensorsDataset,
|
|
RandomSampler, TextDataset)
|
|
from videox_fun.models import (AutoencoderKLMiniMaxH3,
|
|
AutoencoderKLMiniMaxH3Audio,
|
|
MiniMaxH3Transformer3DModel, Qwen2TokenizerFast,
|
|
Qwen3VLForConditionalGeneration,
|
|
Qwen3VLProcessor)
|
|
from videox_fun.models.minimax_h3_pdd import (attach_parallel_decoder,
|
|
pdd_teacher_mean_velocity,
|
|
set_parallel_plan)
|
|
from videox_fun.utils.lora_utils_pdd import (PDD_EMA_WEIGHTS_NAME,
|
|
PDD_LEGACY_LIVE_WEIGHTS_NAME,
|
|
PDD_WEIGHTS_NAME, PDDLoRALinear,
|
|
PDDParallelHead, add_pdd_lora,
|
|
pdd_sampling_plan,
|
|
pdd_state_dict,
|
|
pdd_teacher_mode,
|
|
pdd_time_grid,
|
|
pdd_training_plan)
|
|
from videox_fun.pipeline import MiniMaxH3Pipeline
|
|
from videox_fun.pipeline.pipeline_minimax_h3 import (
|
|
MINIMAX_H3_KEYFRAME_NOISE_AUG, align_num_frames, audio_latent_num_frames,
|
|
build_packed_sequence, build_ref2va_packed_sequence, build_row_timesteps,
|
|
check_ref2va_references, normalize_ref2va_references,
|
|
patchify_video_latents, video_latent_num_frames)
|
|
from videox_fun.utils import MiniMaxH3Scheduler
|
|
from videox_fun.utils.tqdm_bar import PauseAwareTqdm
|
|
from videox_fun.utils.utils import save_videos_with_audio_grid
|
|
|
|
# The on-the-fly route (without `--enable_preprocess_training`) encodes conditioning with the canonical MiniMax-H3
|
|
# recipes rather than re-deriving them; these modules sit in the same directory, already on `sys.path`.
|
|
# `encode_prompt` builds the `fl2va` / `ref2va` presentation, `encode_reference_latents_for_training` the `ref2va`
|
|
# reference latents, and `load_requests` / `parse_reference` read a `ref2va` request annotation exactly as
|
|
# `generate_ref2va_request_cache.py` does.
|
|
from train_lora import encode_prompt, encode_reference_latents_for_training
|
|
from generate_ref2va_request_cache import load_requests, parse_reference
|
|
|
|
# Silences diffusers' `randn_tensor` notice about CPU generators producing CUDA tensors (the tensor is created
|
|
# on CPU and moved to GPU; harmless, only a marginal speed note).
|
|
warnings.filterwarnings("ignore", message="The passed generator was created on")
|
|
|
|
|
|
def linear_decay(initial_value, final_value, total_steps, current_step):
|
|
if current_step >= total_steps:
|
|
return final_value
|
|
current_step = max(0, current_step)
|
|
step_size = (final_value - initial_value) / total_steps
|
|
return initial_value + step_size * current_step
|
|
|
|
|
|
# The `ref2va` request cache flattens its ragged reference structure into tensors (safetensors holds tensors only):
|
|
# the per-reference kind / has-audio pair becomes two int vectors and the per-reference latents become indexed
|
|
# tensors under a count. This maps the kind id back to the string `Ref2VATrajectory` and `log_validation` expect.
|
|
_REFERENCE_KINDS = ("image", "video", "audio")
|
|
|
|
|
|
def reconstruct_cache_entry(state_dict, train_mode):
|
|
r"""Rebuild one normalized conditioning entry from a flat safetensors `state_dict`.
|
|
|
|
`ImageVideoSafetensorsDataset.__getitem__` returns the raw tensor dict written by `generate_prompt_cache.py`
|
|
(`fl2va`) or `generate_ref2va_request_cache.py` (`ref2va`); this restores the `{prompt_embeds, text_token_tags}`
|
|
(plus, for `ref2va`, `{reference_kinds, condition_latents, audio_condition_latents}`) shape the trajectories and
|
|
validation consume, so both are agnostic to how the cache was serialized.
|
|
"""
|
|
entry = {
|
|
"prompt_embeds": state_dict["prompt_embeds"],
|
|
"text_token_tags": state_dict["text_token_tags"],
|
|
}
|
|
if train_mode == "ref2va":
|
|
kind_ids = state_dict["reference_kind_ids"].tolist()
|
|
has_audio = state_dict["reference_has_audio"].tolist()
|
|
entry["reference_kinds"] = [
|
|
(_REFERENCE_KINDS[int(kind)], bool(int(flag))) for kind, flag in zip(kind_ids, has_audio)
|
|
]
|
|
num_condition = int(state_dict["num_condition_latents"])
|
|
entry["condition_latents"] = [state_dict[f"condition_latents_{i}"] for i in range(num_condition)]
|
|
num_audio_condition = int(state_dict["num_audio_condition_latents"])
|
|
entry["audio_condition_latents"] = [
|
|
state_dict[f"audio_condition_latents_{i}"] for i in range(num_audio_condition)
|
|
]
|
|
return entry
|
|
|
|
|
|
class _RequestDataset(Dataset):
|
|
r"""Map-style wrapper over the in-memory `ref2va` request list `load_requests` returns.
|
|
|
|
The on-the-fly `ref2va` route has no pre-encoded safetensors to read, so the requests (each a
|
|
`{"prompt": str, "references": [str, ...]}` record) are carried in memory and flow through the same
|
|
accelerate-sharded DataLoader as the other conditioning routes. The reference media are parsed and encoded in the
|
|
conditioning iterator (main process), never in the collate / DataLoader workers.
|
|
"""
|
|
|
|
def __init__(self, requests):
|
|
self.requests = list(requests)
|
|
|
|
def __len__(self):
|
|
return len(self.requests)
|
|
|
|
def __getitem__(self, index):
|
|
return self.requests[index]
|
|
|
|
|
|
class FL2VATrajectory:
|
|
r"""
|
|
One rank's carried trajectory of the data-free PDD algorithm on the FL2VA / t2va layout.
|
|
|
|
The state is a partially denoised sample plus the grid index it sits at. A step reads it, rolls it forward by
|
|
`L_min` intervals with the student's own prediction, and the trajectory is thrown away and re-drawn from noise
|
|
(with a new prompt) once it reaches the end of the grid.
|
|
"""
|
|
|
|
def __init__(self, geometry, patch_size, latent_channels, audio_channels, condition_iter, device):
|
|
self.geometry = geometry
|
|
self.patch_size = patch_size
|
|
self.latent_channels = latent_channels
|
|
self.audio_channels = audio_channels
|
|
self.condition_iter = condition_iter
|
|
self.device = device
|
|
self.index = None
|
|
|
|
def reset(self):
|
|
num_latent_frames, latent_height, latent_width, num_audio_latents = self.geometry
|
|
# Draw the next conditioning entry from the (accelerate-sharded, cycling) dataloader iterator instead of
|
|
# picking at random from a whole in-memory cache: each rank now sees a distinct slice of the data.
|
|
cached = next(self.condition_iter)
|
|
self.prompt_embeds = cached["prompt_embeds"].to(self.device)
|
|
text_token_tags = cached["text_token_tags"]
|
|
if not torch.is_tensor(text_token_tags):
|
|
text_token_tags = torch.tensor(text_token_tags, dtype=torch.long)
|
|
else:
|
|
# `accelerator.prepare` moves the cached conditioning onto the GPU, but `build_packed_sequence` assembles
|
|
# the layout on CPU (its outputs are moved to `self.device` just below), so keep the tags on CPU to match.
|
|
text_token_tags = text_token_tags.cpu()
|
|
self.layout = build_packed_sequence(
|
|
text_token_tags,
|
|
num_latent_frames,
|
|
latent_height,
|
|
latent_width,
|
|
num_audio_latents,
|
|
self.patch_size,
|
|
)
|
|
self.indices = {
|
|
name: getattr(self.layout, name).to(self.device)
|
|
for name in ("token_tags", "position_ids", "video_indices", "audio_indices", "text_indices")
|
|
}
|
|
rows_per_frame = (latent_height // self.patch_size[1]) * (latent_width // self.patch_size[2])
|
|
video_patch_dim = self.latent_channels * math.prod(self.patch_size)
|
|
self.video = torch.randn(
|
|
num_latent_frames * rows_per_frame, video_patch_dim, device=self.device, dtype=torch.float32
|
|
)
|
|
self.audio = torch.randn(
|
|
num_audio_latents * 2, self.audio_channels, device=self.device, dtype=torch.float32
|
|
)
|
|
self.index = 0
|
|
|
|
def forward_kwargs(self, video_time, audio_time):
|
|
unique_timesteps, timestep_indices = build_row_timesteps(
|
|
self.layout, float(video_time), float(audio_time), float(video_time), 1.0
|
|
)
|
|
return dict(
|
|
encoder_hidden_states=self.prompt_embeds,
|
|
timestep=unique_timesteps.to(self.device),
|
|
timestep_indices=timestep_indices.to(self.device),
|
|
return_dict=False,
|
|
**self.indices,
|
|
)
|
|
|
|
def generated(self, video, audio):
|
|
return video, audio
|
|
|
|
def with_generated(self, video_tail, audio_tail):
|
|
return video_tail, audio_tail
|
|
|
|
|
|
class Ref2VATrajectory:
|
|
r"""
|
|
One rank's carried trajectory of the data-free PDD algorithm, on a `ref2va` layout.
|
|
|
|
The state is the two packed streams — each of them the request's fixed conditioning rows followed by the
|
|
partially denoised generated rows — plus the grid index they sit at. The conditioning rows are drawn once per
|
|
trajectory and never move; only the generated tail is rolled forward and supervised.
|
|
"""
|
|
|
|
def __init__(self, geometry, patch_size, latent_channels, audio_channels, condition_iter, scheduler, device):
|
|
self.geometry = geometry
|
|
self.patch_size = patch_size
|
|
self.latent_channels = latent_channels
|
|
self.audio_channels = audio_channels
|
|
self.condition_iter = condition_iter
|
|
self.scheduler = scheduler
|
|
self.device = device
|
|
self.index = None
|
|
|
|
def reset(self):
|
|
num_latent_frames, latent_height, latent_width, num_audio_latents = self.geometry
|
|
# Draw the next cached request from the (accelerate-sharded, cycling) dataloader iterator.
|
|
request = next(self.condition_iter)
|
|
self.prompt_embeds = request["prompt_embeds"].to(self.device)
|
|
references = [SimpleNamespace(kind=kind, has_audio=has_audio) for kind, has_audio in request["reference_kinds"]]
|
|
condition_latents = request["condition_latents"]
|
|
audio_condition_latents = request["audio_condition_latents"]
|
|
text_token_tags = request["text_token_tags"]
|
|
if not torch.is_tensor(text_token_tags):
|
|
text_token_tags = torch.tensor(text_token_tags, dtype=torch.long)
|
|
else:
|
|
# `accelerator.prepare` moves the cached conditioning onto the GPU, but `build_ref2va_packed_sequence`
|
|
# assembles the layout on CPU (its outputs are moved to `self.device` just below), so keep the tags there.
|
|
text_token_tags = text_token_tags.cpu()
|
|
|
|
self.layout = build_ref2va_packed_sequence(
|
|
text_token_tags,
|
|
references,
|
|
condition_latents,
|
|
audio_condition_latents,
|
|
num_latent_frames,
|
|
latent_height,
|
|
latent_width,
|
|
num_audio_latents,
|
|
self.patch_size,
|
|
)
|
|
self.indices = {
|
|
name: getattr(self.layout, name).to(self.device)
|
|
for name in ("token_tags", "position_ids", "video_indices", "audio_indices", "text_indices")
|
|
}
|
|
self.num_condition_video_rows = self.layout.num_condition_video_rows
|
|
self.num_condition_audio_rows = self.layout.num_condition_audio_rows
|
|
|
|
condition_rows = [
|
|
patchify_video_latents(
|
|
self.scheduler.scale_noise(
|
|
condition.to(self.device),
|
|
MINIMAX_H3_KEYFRAME_NOISE_AUG,
|
|
torch.randn(condition.shape, device=self.device, dtype=torch.float32),
|
|
),
|
|
self.patch_size,
|
|
)
|
|
for condition in condition_latents
|
|
]
|
|
|
|
rows_per_frame = (latent_height // self.patch_size[1]) * (latent_width // self.patch_size[2])
|
|
video_patch_dim = self.latent_channels * math.prod(self.patch_size)
|
|
video = torch.randn(
|
|
num_latent_frames * rows_per_frame, video_patch_dim, device=self.device, dtype=torch.float32
|
|
)
|
|
audio = torch.randn(num_audio_latents * 2, self.audio_channels, device=self.device, dtype=torch.float32)
|
|
self.video = torch.cat(condition_rows + [video]) if condition_rows else video
|
|
self.audio = (
|
|
torch.cat([rows.to(self.device) for rows in audio_condition_latents] + [audio])
|
|
if audio_condition_latents
|
|
else audio
|
|
)
|
|
self.index = 0
|
|
|
|
def forward_kwargs(self, video_time, audio_time):
|
|
unique_timesteps, timestep_indices = build_row_timesteps(
|
|
self.layout,
|
|
float(video_time),
|
|
float(audio_time),
|
|
max(float(video_time), MINIMAX_H3_KEYFRAME_NOISE_AUG),
|
|
1.0,
|
|
)
|
|
return dict(
|
|
encoder_hidden_states=self.prompt_embeds,
|
|
timestep=unique_timesteps.to(self.device),
|
|
timestep_indices=timestep_indices.to(self.device),
|
|
return_dict=False,
|
|
**self.indices,
|
|
)
|
|
|
|
def generated(self, video, audio):
|
|
return video[self.num_condition_video_rows :], audio[self.num_condition_audio_rows :]
|
|
|
|
def with_generated(self, video_tail, audio_tail):
|
|
video = torch.cat([self.video[: self.num_condition_video_rows], video_tail])
|
|
audio = torch.cat([self.audio[: self.num_condition_audio_rows], audio_tail])
|
|
return video, audio
|
|
|
|
|
|
logger = get_logger(__name__, log_level="INFO")
|
|
|
|
|
|
def log_validation(
|
|
vae, audio_vae, transformer, scheduler, audio_scheduler, args, accelerator, val_cache, grids, global_step,
|
|
):
|
|
r"""
|
|
Render every validation cache entry with the student at `--validation_nfe` and save the mp4 (video + audio)
|
|
next to the run. Each entry is already-cached Qwen3-VL conditioning, so no text encoder is loaded here.
|
|
|
|
PDD generation is an ordinary Euler loop over the *block boundaries* of the grid: those boundaries are exactly
|
|
the schedule `MiniMaxH3Scheduler` builds for `NFE` steps, so the released pipeline drives the student unchanged
|
|
and the only PDD-specific work is arming the heads before each step.
|
|
"""
|
|
sharded = (
|
|
getattr(accelerator.state, "fsdp_plugin", None) is not None
|
|
or getattr(accelerator.state, "deepspeed_plugin", None) is not None
|
|
)
|
|
if sharded:
|
|
# Under FSDP / DeepSpeed every forward all-gathers the sharded params, so it is a *collective* that all ranks
|
|
# must enter the same number of times. Splitting the entries by `index % num_processes` gives ranks different
|
|
# counts whenever `len(val_cache)` is not a multiple of `num_processes` (2 entries over 4 ranks leaves ranks 2
|
|
# and 3 with nothing to render); a rank that returns early never joins the all-gather the others block on, and
|
|
# NCCL times out. So every rank walks the same `ceil(len / ranks)` rounds, cycling the entry list, and writes
|
|
# a file only for the entries it owns (`index < len`) — the extra cycles are collective ballast that keep the
|
|
# ranks in lockstep, not duplicate renders.
|
|
num_rounds = math.ceil(len(val_cache) / accelerator.num_processes)
|
|
assigned = []
|
|
for round_index in range(num_rounds):
|
|
index = round_index * accelerator.num_processes + accelerator.process_index
|
|
assigned.append((index, val_cache[index % len(val_cache)], index < len(val_cache)))
|
|
else:
|
|
assigned = [
|
|
(index, entry, True)
|
|
for index, entry in enumerate(val_cache)
|
|
if index % accelerator.num_processes == accelerator.process_index
|
|
]
|
|
if not assigned:
|
|
return
|
|
|
|
try:
|
|
with torch.no_grad():
|
|
logger.info("Running validation... ")
|
|
_, _, video_steps, audio_steps = grids
|
|
num_steps = video_steps.shape[0]
|
|
student = accelerator.unwrap_model(transformer)
|
|
|
|
pipeline = MiniMaxH3Pipeline(
|
|
vae=vae,
|
|
audio_vae=audio_vae,
|
|
text_encoder=None,
|
|
tokenizer=None,
|
|
processor=None,
|
|
transformer=transformer,
|
|
scheduler=scheduler,
|
|
audio_scheduler=audio_scheduler,
|
|
)
|
|
# Avoid `.to()` on an FSDP / DeepSpeed wrapper: it rematerializes FlatParameters on every rank.
|
|
if not sharded:
|
|
pipeline = pipeline.to(accelerator.device)
|
|
block_size = num_steps // args.validation_nfe
|
|
|
|
def arm(step_index):
|
|
start = step_index * block_size
|
|
set_parallel_plan(
|
|
student,
|
|
pdd_sampling_plan(video_steps, start, block_size).float(),
|
|
pdd_sampling_plan(audio_steps, start, block_size).float(),
|
|
)
|
|
|
|
def callback(pipe, step_index, timestep, callback_kwargs):
|
|
if step_index + 1 < args.validation_nfe:
|
|
arm(step_index + 1)
|
|
return {}
|
|
|
|
vae.to(accelerator.device)
|
|
audio_vae.to(accelerator.device)
|
|
os.makedirs(os.path.join(args.output_dir, "sample"), exist_ok=True)
|
|
|
|
for index, entry, owner in assigned:
|
|
arm(0)
|
|
if args.seed is None:
|
|
generator = None
|
|
else:
|
|
prompt_seed = args.seed + index
|
|
generator = torch.Generator(device=accelerator.device).manual_seed(prompt_seed)
|
|
logger.info(f"Rank {accelerator.process_index} prompt {index} using seed: {prompt_seed}")
|
|
|
|
call_kwargs = dict(
|
|
prompt=None,
|
|
prompt_embeds=entry["prompt_embeds"],
|
|
text_token_tags=entry["text_token_tags"],
|
|
height=args.video_sample_height,
|
|
width=args.video_sample_width,
|
|
num_frames=args.video_sample_n_frames,
|
|
num_inference_steps=args.validation_nfe,
|
|
generator=generator,
|
|
output_type="pt",
|
|
callback_on_step_end=callback,
|
|
)
|
|
if args.train_mode == "ref2va":
|
|
call_kwargs.update(
|
|
normalized_references=[
|
|
SimpleNamespace(kind=kind, has_audio=has_audio) for kind, has_audio in entry["reference_kinds"]
|
|
],
|
|
condition_latents=entry["condition_latents"],
|
|
audio_condition_latents=entry["audio_condition_latents"],
|
|
)
|
|
|
|
output = pipeline(**call_kwargs)
|
|
if owner:
|
|
save_videos_with_audio_grid(
|
|
output.videos,
|
|
output.audio,
|
|
os.path.join(
|
|
args.output_dir,
|
|
f"sample/sample-{global_step}-prompt{index}-{args.train_mode}-nfe{args.validation_nfe}.mp4",
|
|
),
|
|
fps=24,
|
|
audio_sample_rate=output.sampling_rate,
|
|
)
|
|
del output
|
|
|
|
del pipeline
|
|
gc.collect()
|
|
torch.cuda.empty_cache()
|
|
torch.cuda.ipc_collect()
|
|
vae.to(accelerator.device if not args.low_vram else "cpu")
|
|
audio_vae.to(accelerator.device if not args.low_vram else "cpu")
|
|
except Exception as e:
|
|
gc.collect()
|
|
torch.cuda.empty_cache()
|
|
torch.cuda.ipc_collect()
|
|
print(f"Eval error on rank {accelerator.process_index} with info {e}")
|
|
vae.to(accelerator.device if not args.low_vram else "cpu")
|
|
audio_vae.to(accelerator.device if not args.low_vram else "cpu")
|
|
|
|
|
|
def parse_args():
|
|
parser = argparse.ArgumentParser(
|
|
description="Parallel Decoding Distillation LoRA of MiniMax-H3 (FL2VA / Ref2VA, video + audio)."
|
|
)
|
|
parser.add_argument(
|
|
"--pretrained_model_name_or_path",
|
|
type=str,
|
|
default=None,
|
|
required=True,
|
|
help="Path to pretrained model or model identifier from huggingface.co/models.",
|
|
)
|
|
parser.add_argument(
|
|
"--output_dir",
|
|
type=str,
|
|
default="output_dir_minimax_h3_pdd_lora",
|
|
help="The output directory where the model predictions and checkpoints will be written.",
|
|
)
|
|
parser.add_argument("--seed", type=int, default=43, help="A seed for reproducible training.")
|
|
parser.add_argument(
|
|
"--train_batch_size", type=int, default=1, help="Batch size (per device) for the training dataloader."
|
|
)
|
|
parser.add_argument("--num_train_epochs", type=int, default=100)
|
|
parser.add_argument(
|
|
"--max_train_steps",
|
|
type=int,
|
|
default=None,
|
|
help="Total number of training steps to perform. If provided, overrides num_train_epochs.",
|
|
)
|
|
parser.add_argument(
|
|
"--gradient_accumulation_steps",
|
|
type=int,
|
|
default=1,
|
|
help="Number of updates steps to accumulate before performing a backward/update pass.",
|
|
)
|
|
parser.add_argument(
|
|
"--gradient_checkpointing",
|
|
action="store_true",
|
|
help="Whether or not to use gradient checkpointing to save memory at the expense of slower backward pass.",
|
|
)
|
|
parser.add_argument(
|
|
"--gradient_checkpointing_save_on_cpu",
|
|
action="store_true",
|
|
help="Offload the activations saved for backward of the transformer blocks to CPU memory.",
|
|
)
|
|
parser.add_argument(
|
|
"--learning_rate",
|
|
type=float,
|
|
default=1e-5,
|
|
help="Initial learning rate (after the potential warmup period) to use.",
|
|
)
|
|
parser.add_argument(
|
|
"--scale_lr",
|
|
action="store_true",
|
|
default=False,
|
|
help="Scale the learning rate by the number of GPUs, gradient accumulation steps, and batch size.",
|
|
)
|
|
parser.add_argument(
|
|
"--lr_scheduler",
|
|
type=str,
|
|
default="constant",
|
|
help=(
|
|
'The scheduler type to use. Choose between ["linear", "cosine", "cosine_with_restarts", "polynomial",'
|
|
' "constant", "constant_with_warmup"]'
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--lr_warmup_steps", type=int, default=0, help="Number of steps for the warmup in the lr scheduler."
|
|
)
|
|
parser.add_argument(
|
|
"--use_8bit_adam", action="store_true", help="Whether or not to use 8-bit Adam from bitsandbytes."
|
|
)
|
|
parser.add_argument(
|
|
"--allow_tf32",
|
|
action="store_true",
|
|
help=(
|
|
"Whether or not to allow TF32 on Ampere GPUs. Can be used to speed up training."
|
|
),
|
|
)
|
|
parser.add_argument("--adam_beta1", type=float, default=0.9, help="The beta1 parameter for the Adam optimizer.")
|
|
parser.add_argument("--adam_beta2", type=float, default=0.999, help="The beta2 parameter for the Adam optimizer.")
|
|
parser.add_argument("--adam_weight_decay", type=float, default=0.0, help="Weight decay to use.")
|
|
parser.add_argument("--adam_epsilon", type=float, default=1e-08, help="Epsilon value for the Adam optimizer")
|
|
parser.add_argument("--max_grad_norm", default=1.0, type=float, help="Max gradient norm.")
|
|
parser.add_argument(
|
|
"--mixed_precision",
|
|
type=str,
|
|
default=None,
|
|
choices=["no", "fp16", "bf16"],
|
|
help=(
|
|
"Whether to use mixed precision. Choose between fp16 and bf16 (bfloat16). Bf16 requires PyTorch >="
|
|
" 1.10 and an Nvidia Ampere GPU. Default to the value of accelerate config of the current system or the"
|
|
" flag passed with the `accelerate.launch` command. Use this argument to override the accelerate config."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--report_to",
|
|
type=str,
|
|
default="tensorboard",
|
|
help=(
|
|
'The integration to report the results and logs to. Supported platforms are `"tensorboard"`'
|
|
' (default), `"wandb"` and `"comet_ml"`. Use `"all"` to report to all integrations.'
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--logging_dir",
|
|
type=str,
|
|
default="logs",
|
|
help=(
|
|
"[TensorBoard](https://www.tensorflow.org/tensorboard) log directory. Will default to"
|
|
" *output_dir/runs/**CURRENT_DATETIME_HOSTNAME***."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--checkpointing_steps",
|
|
type=int,
|
|
default=50,
|
|
help=(
|
|
"Save a checkpoint of the training state every X updates. These checkpoints are only suitable for resuming"
|
|
" training using `--resume_from_checkpoint`."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--checkpoints_total_limit",
|
|
type=int,
|
|
default=None,
|
|
help=("Max number of checkpoints to store."),
|
|
)
|
|
parser.add_argument(
|
|
"--resume_from_checkpoint",
|
|
type=str,
|
|
default=None,
|
|
help=(
|
|
"Whether training should be resumed from a previous checkpoint. Use a path saved by"
|
|
' `--checkpointing_steps`, or `"latest"` to automatically select the last available checkpoint.'
|
|
),
|
|
)
|
|
parser.add_argument("--save_state", action="store_true", help="Whether or not to save state.")
|
|
parser.add_argument(
|
|
"--transformer_path",
|
|
type=str,
|
|
default=None,
|
|
help=("If you want to load the weight from other transformers, input its path."),
|
|
)
|
|
parser.add_argument(
|
|
"--use_deepspeed", action="store_true", help="Whether or not to use deepspeed."
|
|
)
|
|
parser.add_argument(
|
|
"--use_fsdp", action="store_true", help="Whether or not to use fsdp."
|
|
)
|
|
parser.add_argument("--local_rank", type=int, default=-1, help="For distributed training: local_rank")
|
|
parser.add_argument(
|
|
"--rank",
|
|
type=int,
|
|
default=64,
|
|
help=("The dimension of the LoRA update matrices."),
|
|
)
|
|
parser.add_argument(
|
|
"--network_alpha",
|
|
type=int,
|
|
default=64,
|
|
help=("The dimension of the LoRA update matrices."),
|
|
)
|
|
parser.add_argument(
|
|
"--target_name",
|
|
type=str,
|
|
default="to_q,to_k,to_v,to_out.0,ff.net.0.proj,ff.net.2,adaln_proj.linear",
|
|
help=("The module is trained in loras."),
|
|
)
|
|
# MiniMax-H3 specific
|
|
parser.add_argument(
|
|
"--train_mode",
|
|
type=str,
|
|
default="fl2va",
|
|
choices=["fl2va", "ref2va"],
|
|
help="fl2va: FL2VA / t2va packed layout. ref2va: Ref2VA layout, which also consumes cached reference latents.",
|
|
)
|
|
parser.add_argument(
|
|
"--video_loss_weight",
|
|
type=float,
|
|
default=0.5,
|
|
help="Weight of the video flow-matching loss in the joint video + audio loss.",
|
|
)
|
|
parser.add_argument(
|
|
"--audio_loss_weight",
|
|
type=float,
|
|
default=0.5,
|
|
help="Weight of the audio flow-matching loss in the joint video + audio loss.",
|
|
)
|
|
parser.add_argument(
|
|
"--video_sample_n_frames",
|
|
type=int,
|
|
default=124,
|
|
help="Number of frames (form 17*n+5).",
|
|
)
|
|
parser.add_argument(
|
|
"--low_vram",
|
|
action="store_true",
|
|
help="Keep VAE and conditioner on CPU, move to GPU only while encoding.",
|
|
)
|
|
parser.add_argument(
|
|
"--tracker_project_name",
|
|
type=str,
|
|
default="minimax_h3_pdd_lora",
|
|
help=(
|
|
"The `project_name` argument passed to Accelerator.init_trackers for"
|
|
" more information see https://huggingface.co/docs/accelerate/v0.17.0/en/package_reference/accelerator#accelerate.Accelerator"
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--validation_steps",
|
|
type=int,
|
|
default=50,
|
|
help="Run validation every X steps.",
|
|
)
|
|
# PDD
|
|
parser.add_argument(
|
|
"--enable_preprocess_training",
|
|
action="store_true",
|
|
help=(
|
|
"Train on the pre-processed (cached) conditioning instead of encoding it on the fly. When set, read the "
|
|
"pre-encoded safetensors via `ImageVideoSafetensorsDataset(--train_data_meta=outputs.json, "
|
|
"data_root=--train_data_dir)` — the ~62 GB conditioner stays out of training. When unset, load a Qwen3-VL "
|
|
"conditioner in the run and encode the conditioning on the fly: `fl2va` reads prompts via "
|
|
"`TextDataset(--train_data_meta)`, and `ref2va` reads a request annotation via `load_requests` and also "
|
|
"VAE-encodes each request's reference latents."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--train_data_dir",
|
|
type=str,
|
|
default=None,
|
|
help="Optional root prepended to each `file_path` of `--train_data_meta` (used with "
|
|
"`--enable_preprocess_training`); leave empty when `outputs.json` already stores absolute paths.",
|
|
)
|
|
parser.add_argument(
|
|
"--train_data_meta",
|
|
type=str,
|
|
default=None,
|
|
help="Annotation JSON of the training conditioning. With `--enable_preprocess_training`: the `outputs.json` "
|
|
"written by `generate_prompt_cache.py` / `generate_ref2va_request_cache.py`. Without it: `fl2va` reads a "
|
|
"list of `{\"text\": ...}` records (`TextDataset`), and `ref2va` reads a list of requests "
|
|
"(`{\"prompt\": ..., \"references\": [...]}`, or the demo record `load_requests` derives them from).",
|
|
)
|
|
parser.add_argument(
|
|
"--val_data_meta",
|
|
type=str,
|
|
default=None,
|
|
help="Optional annotation JSON of the held-out conditioning used by validation, mirroring `--train_data_meta`: "
|
|
"with `--enable_preprocess_training` the `outputs.json` written by `generate_prompt_cache.py` / "
|
|
"`generate_ref2va_request_cache.py`; without it the on-the-fly annotation (`fl2va`: `{\"text\": ...}` "
|
|
"records; `ref2va`: the request list). Validation is skipped when it is left empty.",
|
|
)
|
|
parser.add_argument(
|
|
"--transformer_subfolder",
|
|
type=str,
|
|
default=None,
|
|
help="Transformer subfolder. Default: `transformer_ref` for `--train_mode=ref2va`, else `transformer`.",
|
|
)
|
|
parser.add_argument(
|
|
"--pdd_num_steps",
|
|
type=int,
|
|
default=32,
|
|
help="The grid size `N`. The paper uses 128 for video models with the midpoint solver, 256 with Euler.",
|
|
)
|
|
parser.add_argument(
|
|
"--pdd_block_size",
|
|
type=int,
|
|
default=4,
|
|
help="`L_min`: the block the carried state advances by, so the student is trained for `N / L_min` NFE.",
|
|
)
|
|
parser.add_argument(
|
|
"--pdd_max_block_size",
|
|
type=int,
|
|
default=None,
|
|
help="`L_max`: the widest block a loss target is drawn from. Defaults to `--pdd_block_size`.",
|
|
)
|
|
parser.add_argument(
|
|
"--pdd_solver",
|
|
type=str,
|
|
default="midpoint",
|
|
choices=["euler", "midpoint"],
|
|
help="Runge-Kutta method the teacher's mean velocity is estimated with.",
|
|
)
|
|
parser.add_argument(
|
|
"--pdd_num_targets",
|
|
type=int,
|
|
default=2,
|
|
help="How many intra-block indices `k` one student evaluation is supervised at.",
|
|
)
|
|
parser.add_argument("--lora_learning_rate", type=float, default=1e-4, help="Learning rate of the low-rank updates.")
|
|
parser.add_argument(
|
|
"--use_ema",
|
|
action="store_true",
|
|
help="Keep an exponential moving average of the trainable set. Validation and `pdd_ema.safetensors` use it.",
|
|
)
|
|
parser.add_argument("--ema_decay", type=float, default=0.99)
|
|
parser.add_argument("--abnormal_norm_clip_start", type=int, default=1000)
|
|
parser.add_argument("--initial_grad_norm_ratio", type=int, default=5)
|
|
parser.add_argument(
|
|
"--video_sample_size",
|
|
type=int,
|
|
default=1280,
|
|
help="Square canvas size (height = width) for validation and latent geometry; must be a multiple of 32.",
|
|
)
|
|
parser.add_argument(
|
|
"--fix_sample_size",
|
|
nargs=2, type=int, default=None,
|
|
help="Fix Sample size [height, width] to override `--video_sample_size` with a fixed non-square shape.",
|
|
)
|
|
parser.add_argument("--validation_nfe", type=int, default=8)
|
|
|
|
args = parser.parse_args()
|
|
env_local_rank = int(os.environ.get("LOCAL_RANK", -1))
|
|
if env_local_rank != -1 and env_local_rank != args.local_rank:
|
|
args.local_rank = env_local_rank
|
|
if args.pdd_max_block_size is None:
|
|
args.pdd_max_block_size = args.pdd_block_size
|
|
if args.fix_sample_size is not None:
|
|
args.video_sample_height, args.video_sample_width = args.fix_sample_size
|
|
else:
|
|
args.video_sample_height = args.video_sample_width = args.video_sample_size
|
|
return args
|
|
|
|
|
|
def gather_full_state_dict(model, accelerator):
|
|
r"""Consolidated `state_dict` of a (possibly sharded) model, gathered to rank-0 CPU.
|
|
|
|
Under `--fsdp_state_dict_type=SHARDED_STATE_DICT`, `accelerator.get_state_dict(..., unwrap=True)` hands back
|
|
*sharded* tensors, so `pdd_state_dict`'s `.detach().cpu()` only materializes the params of the root wrap
|
|
unit (the token refiner) and silently drops every separately-wrapped child unit — the `MiniMaxH3TransformerBlock`
|
|
LoRA and both `PDDParallelHead`s — leaving a 13 MB stub instead of the full ~1.4 GB trainable set. Temporarily
|
|
switching the FSDP root to `FULL_STATE_DICT` (offloaded to CPU, rank-0 only) reconstructs every original-named
|
|
param across all wrap units. DeepSpeed ZeRO-3 and DDP already consolidate in `accelerator.get_state_dict`, so
|
|
they keep using it. Collective: call on every rank; only rank 0 receives a non-empty dict.
|
|
"""
|
|
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
|
|
# `accelerator.get_state_dict(..., unwrap=True)` unwraps the FSDP root and reads `.state_dict()` off the *sharded*
|
|
# original-param views, so it silently returns only the root wrap unit's tensors (the token refiner) and drops
|
|
# every separately-wrapped child unit. Detect the FSDP wrapper directly — not via `fsdp_plugin`, which is not
|
|
# reliably plumbed this far — and gather a FULL_STATE_DICT on the wrapped root instead.
|
|
if isinstance(model, FSDP):
|
|
from torch.distributed.fsdp import FullStateDictConfig, StateDictType
|
|
with FSDP.state_dict_type(
|
|
model,
|
|
StateDictType.FULL_STATE_DICT,
|
|
FullStateDictConfig(offload_to_cpu=True, rank0_only=True),
|
|
):
|
|
state_dict = model.state_dict()
|
|
if accelerator.is_main_process:
|
|
logger.info(
|
|
"gather_full_state_dict[FSDP FULL_STATE_DICT]: %d tensors (blocks.*=%d, proj_out*=%d).",
|
|
len(state_dict),
|
|
sum("blocks." in key for key in state_dict),
|
|
sum("proj_out" in key for key in state_dict),
|
|
)
|
|
return state_dict
|
|
state_dict = accelerator.get_state_dict(model, unwrap=True)
|
|
if accelerator.is_main_process:
|
|
logger.info(
|
|
"gather_full_state_dict[get_state_dict fallback]: %d tensors (type(model)=%s).",
|
|
len(state_dict) if state_dict else 0,
|
|
type(model).__name__,
|
|
)
|
|
return state_dict
|
|
|
|
|
|
def save_pdd_weights(path, state_dict):
|
|
from safetensors.torch import save_file
|
|
save_file(
|
|
{name: tensor.detach().contiguous().cpu() for name, tensor in state_dict.items()},
|
|
path,
|
|
metadata={"format": "pt"},
|
|
)
|
|
|
|
|
|
def dump_pdd_config(args, save_path):
|
|
r"""Write `pdd_config.json` with both this script's LoRA flags and the inference aliases `predict_t2v.py` reads."""
|
|
config = dict(vars(args))
|
|
config["lora_rank"] = args.rank
|
|
config["lora_alpha"] = args.network_alpha
|
|
config["lora_targets"] = args.target_name
|
|
with open(os.path.join(save_path, "pdd_config.json"), "w") as handle:
|
|
json.dump(config, handle, indent=1)
|
|
|
|
|
|
def save_resume_state(save_path, student, optimizer, lr_scheduler, ema, accelerator):
|
|
r"""Optimizer / scheduler / live `pdd.safetensors` / EMA shadow — the pieces `pdd_ema.safetensors` does not hold."""
|
|
os.makedirs(save_path, exist_ok=True)
|
|
torch.save(optimizer.state_dict(), os.path.join(save_path, "optimizer.pt"))
|
|
torch.save(lr_scheduler.state_dict(), os.path.join(save_path, "scheduler.pt"))
|
|
save_pdd_weights(os.path.join(save_path, PDD_WEIGHTS_NAME), pdd_state_dict(student))
|
|
if ema is not None:
|
|
torch.save(ema.state_dict(), os.path.join(save_path, "ema.pt"))
|
|
if getattr(accelerator, "scaler", None) is not None:
|
|
torch.save(accelerator.scaler.state_dict(), os.path.join(save_path, "scaler.pt"))
|
|
|
|
|
|
def load_resume_state(save_path, student, optimizer, lr_scheduler, ema, trainable_params, accelerator):
|
|
r"""Load the trainer state written by [`save_resume_state`]. Prefers legacy `pdd_live.safetensors` when present."""
|
|
from safetensors.torch import load_file
|
|
|
|
legacy_live = os.path.join(save_path, PDD_LEGACY_LIVE_WEIGHTS_NAME)
|
|
weights_path = legacy_live if os.path.isfile(legacy_live) else os.path.join(save_path, PDD_WEIGHTS_NAME)
|
|
state_dict = load_file(weights_path, device="cpu")
|
|
m, u = student.load_state_dict(state_dict, strict=False)
|
|
print(f"missing keys: {len(m)}, unexpected keys: {len(u)}")
|
|
assert len(u) == 0
|
|
print(f"Loaded {len(state_dict)} PDD tensors from {weights_path}")
|
|
|
|
device = accelerator.device
|
|
optimizer_file_pt = os.path.join(save_path, "optimizer.pt")
|
|
optimizer_file_bin = os.path.join(save_path, "optimizer.bin")
|
|
optimizer_file_to_load = None
|
|
if os.path.exists(optimizer_file_pt):
|
|
optimizer_file_to_load = optimizer_file_pt
|
|
elif os.path.exists(optimizer_file_bin):
|
|
optimizer_file_to_load = optimizer_file_bin
|
|
if optimizer_file_to_load:
|
|
try:
|
|
accelerator.print(f"Loading optimizer state from {optimizer_file_to_load}")
|
|
optimizer.load_state_dict(torch.load(optimizer_file_to_load, map_location=device))
|
|
accelerator.print("Optimizer state loaded successfully.")
|
|
except Exception as e:
|
|
accelerator.print(f"Failed to load optimizer state from {optimizer_file_to_load}: {e}")
|
|
|
|
scheduler_file_pt = os.path.join(save_path, "scheduler.pt")
|
|
scheduler_file_bin = os.path.join(save_path, "scheduler.bin")
|
|
scheduler_file_to_load = None
|
|
if os.path.exists(scheduler_file_pt):
|
|
scheduler_file_to_load = scheduler_file_pt
|
|
elif os.path.exists(scheduler_file_bin):
|
|
scheduler_file_to_load = scheduler_file_bin
|
|
if scheduler_file_to_load:
|
|
try:
|
|
accelerator.print(f"Loading scheduler state from {scheduler_file_to_load}")
|
|
lr_scheduler.load_state_dict(torch.load(scheduler_file_to_load, map_location=device))
|
|
accelerator.print("Scheduler state loaded successfully.")
|
|
except Exception as e:
|
|
accelerator.print(f"Failed to load scheduler state from {scheduler_file_to_load}: {e}")
|
|
|
|
if getattr(accelerator, "scaler", None) is not None:
|
|
scaler_file = os.path.join(save_path, "scaler.pt")
|
|
if os.path.exists(scaler_file):
|
|
try:
|
|
accelerator.print(f"Loading GradScaler state from {scaler_file}")
|
|
accelerator.scaler.load_state_dict(torch.load(scaler_file, map_location=device))
|
|
accelerator.print("GradScaler state loaded successfully.")
|
|
except Exception as e:
|
|
accelerator.print(f"Failed to load GradScaler state: {e}")
|
|
|
|
if ema is None:
|
|
return
|
|
ema_path = os.path.join(save_path, "ema.pt")
|
|
if os.path.exists(ema_path):
|
|
try:
|
|
print(f"Loading EMA state from {ema_path}")
|
|
ema.load_state_dict(torch.load(ema_path, map_location="cpu"))
|
|
print("EMA state loaded successfully.")
|
|
return
|
|
except Exception as e:
|
|
print(f"Failed to load EMA state from {ema_path}: {e}")
|
|
ema.shadow_params = [parameter.detach().clone() for parameter in trainable_params]
|
|
print(f"No ema.pt under {save_path}; EMA is re-seeded from the loaded weights.")
|
|
|
|
|
|
def main():
|
|
args = parse_args()
|
|
|
|
if args.train_mode not in ("fl2va", "ref2va"):
|
|
raise ValueError(f"`train_mode` must be 'fl2va' or 'ref2va', got {args.train_mode!r}.")
|
|
aligned_frames = align_num_frames(int(args.video_sample_n_frames))
|
|
if aligned_frames != int(args.video_sample_n_frames):
|
|
raise ValueError(
|
|
f"`video_sample_n_frames` has to be of the form 17 * n + 5 the video VAE encodes, got "
|
|
f"{args.video_sample_n_frames} (nearest is {aligned_frames})."
|
|
)
|
|
if args.video_sample_height % 32 or args.video_sample_width % 32:
|
|
raise ValueError(
|
|
f"`video_sample_size` / `fix_sample_size` ({args.video_sample_height}x{args.video_sample_width}) "
|
|
"must be multiples of 32: the canvas is patched 2x2 into the transformer and its RoPE grid keys off that."
|
|
)
|
|
if args.pdd_num_steps % args.pdd_block_size:
|
|
raise ValueError(
|
|
f"The grid size {args.pdd_num_steps} must be a multiple of the block size {args.pdd_block_size}: the "
|
|
"block starts of the data-free algorithm are the multiples of `L_min` and the last one has to be the end "
|
|
"of the grid."
|
|
)
|
|
if args.pdd_num_steps % args.validation_nfe:
|
|
raise ValueError(
|
|
f"`--validation_nfe` {args.validation_nfe} must divide the grid size {args.pdd_num_steps}: generation "
|
|
"advances `N / NFE` intervals per evaluation."
|
|
)
|
|
if not args.train_data_meta:
|
|
raise ValueError(
|
|
"`--train_data_meta` is required: the `outputs.json` of the cached conditioning (with "
|
|
"`--enable_preprocess_training`) or the on-the-fly annotation JSON without it (`fl2va`: `{\"text\": ...}` "
|
|
"records; `ref2va`: the request list `load_requests` reads)."
|
|
)
|
|
if args.train_batch_size != 1:
|
|
raise ValueError("Data-free PDD carries one trajectory per rank and requires --train_batch_size=1.")
|
|
|
|
logging_dir = os.path.join(args.output_dir, args.logging_dir)
|
|
accelerator_project_config = ProjectConfiguration(project_dir=args.output_dir, logging_dir=logging_dir)
|
|
accelerator = Accelerator(
|
|
gradient_accumulation_steps=args.gradient_accumulation_steps,
|
|
mixed_precision=args.mixed_precision,
|
|
log_with=args.report_to,
|
|
project_config=accelerator_project_config,
|
|
)
|
|
|
|
deepspeed_plugin = accelerator.state.deepspeed_plugin if hasattr(accelerator.state, "deepspeed_plugin") else None
|
|
fsdp_plugin = accelerator.state.fsdp_plugin if hasattr(accelerator.state, "fsdp_plugin") else None
|
|
if deepspeed_plugin is not None:
|
|
zero_stage = int(deepspeed_plugin.zero_stage)
|
|
fsdp_stage = 0
|
|
print(f"Using DeepSpeed Zero stage: {zero_stage}")
|
|
args.use_deepspeed = True
|
|
if zero_stage == 3:
|
|
print("Auto set save_state to True because zero_stage == 3")
|
|
args.save_state = True
|
|
elif fsdp_plugin is not None:
|
|
from torch.distributed.fsdp import ShardingStrategy
|
|
zero_stage = 0
|
|
if fsdp_plugin.sharding_strategy is ShardingStrategy.FULL_SHARD:
|
|
fsdp_stage = 3
|
|
elif fsdp_plugin.sharding_strategy is None:
|
|
fsdp_stage = 3
|
|
elif fsdp_plugin.sharding_strategy is ShardingStrategy.SHARD_GRAD_OP:
|
|
fsdp_stage = 2
|
|
else:
|
|
fsdp_stage = 0
|
|
print(f"Using FSDP stage: {fsdp_stage}")
|
|
args.use_fsdp = True
|
|
if fsdp_stage == 3:
|
|
print("Auto set save_state to True because fsdp_stage == 3")
|
|
args.save_state = True
|
|
else:
|
|
zero_stage = 0
|
|
fsdp_stage = 0
|
|
print("DeepSpeed/FSDP is not enabled.")
|
|
|
|
logging.basicConfig(format="%(asctime)s - %(levelname)s - %(name)s - %(message)s", datefmt="%m/%d/%Y %H:%M:%S", level=logging.INFO)
|
|
logger.info(accelerator.state, main_process_only=False)
|
|
if accelerator.is_local_main_process:
|
|
transformers.utils.logging.set_verbosity_warning()
|
|
diffusers.utils.logging.set_verbosity_info()
|
|
else:
|
|
transformers.utils.logging.set_verbosity_error()
|
|
diffusers.utils.logging.set_verbosity_error()
|
|
|
|
# If passed along, set the training seed now.
|
|
# Per-rank seeding: the ranks of one global batch have to roll out *different* noise, otherwise the trajectories
|
|
# of a step differ only in their prompt. The conditioning order is drawn by the dataloader's own seeded sampler.
|
|
if args.seed is not None:
|
|
set_seed(args.seed + accelerator.process_index)
|
|
print(f"Init seed {args.seed + accelerator.process_index}. Process_index is {accelerator.process_index}")
|
|
else:
|
|
print(f"Init without fixed seed. Process_index is {accelerator.process_index}")
|
|
|
|
# Handle the repository creation
|
|
if accelerator.is_main_process:
|
|
if args.output_dir is not None:
|
|
os.makedirs(args.output_dir, exist_ok=True)
|
|
|
|
# For mixed precision training we cast non-trainable weights to half-precision.
|
|
weight_dtype = torch.float32
|
|
if accelerator.mixed_precision == "fp16":
|
|
weight_dtype = torch.float16
|
|
args.mixed_precision = accelerator.mixed_precision
|
|
elif accelerator.mixed_precision == "bf16":
|
|
weight_dtype = torch.bfloat16
|
|
args.mixed_precision = accelerator.mixed_precision
|
|
# PDD: the released checkpoint already pins `proj_out` / `audio_proj_out` in float32
|
|
# (`_keep_in_fp32_modules`), so the parallel heads built from them are float32 master weights over a bfloat16
|
|
# backbone. The model casts every input to its projection's dtype itself, so the run needs no autocast.
|
|
weight_dtype = torch.bfloat16
|
|
|
|
# ------------------------------------------------------------------ models
|
|
# `pretrained_model_name_or_path` may point at a converted diffusers layout or at an *original* MiniMax-H3
|
|
# partition; every component's `from_pretrained` auto-detects the layout and stream-converts the original
|
|
# shards on the fly, so the caller never branches on the format itself.
|
|
transformer_subfolder = args.transformer_subfolder or (
|
|
"transformer_ref" if args.train_mode == "ref2va" else "transformer"
|
|
)
|
|
print(f"Loading transformer from subfolder `{transformer_subfolder}` (train_mode={args.train_mode}).")
|
|
transformer = MiniMaxH3Transformer3DModel.from_pretrained(
|
|
args.pretrained_model_name_or_path, subfolder=transformer_subfolder, low_cpu_mem_usage=True, torch_dtype=weight_dtype,
|
|
)
|
|
|
|
def deepspeed_zero_init_disabled_context_manager():
|
|
"""
|
|
returns either a context list that includes one that will disable zero.Init or an empty context list
|
|
"""
|
|
deepspeed_plugin = AcceleratorState().deepspeed_plugin if accelerate.state.is_initialized() else None
|
|
if deepspeed_plugin is None:
|
|
return []
|
|
|
|
return [deepspeed_plugin.zero3_init_context_manager(enable=False)]
|
|
|
|
# Currently Accelerate doesn't know how to handle multiple models under Deepspeed ZeRO stage 3.
|
|
# For this to work properly all models must be run through `accelerate.prepare`. But accelerate
|
|
# will try to assign the same optimizer with the same weights to all models during
|
|
# `deepspeed.initialize`, which of course doesn't work.
|
|
#
|
|
# For now the following workaround will partially support Deepspeed ZeRO-3, by excluding the
|
|
# frozen models from being partitioned during `zero.Init` which gets called during
|
|
# `from_pretrained`. So the two VAEs will not enjoy the parameter sharding across multiple gpus
|
|
# and only the transformer will get ZeRO sharded. The 62 GB conditioner is loaded only when conditioning is
|
|
# encoded on the fly (without `--enable_preprocess_training`); the cached route keeps it out of the run entirely.
|
|
uses_text_encoder = not args.enable_preprocess_training
|
|
tokenizer = processor = text_encoder = None
|
|
with ContextManagers(deepspeed_zero_init_disabled_context_manager()):
|
|
# The two VAEs stay float32 as released (the encode/decode recipe is float16 autocast over float32
|
|
# weights), so they are loaded without `torch_dtype`; the mixed-precision loader mixin restores the
|
|
# pinned fp32 modules anyway. PDD validation is the only consumer.
|
|
vae = AutoencoderKLMiniMaxH3.from_pretrained(
|
|
args.pretrained_model_name_or_path, subfolder="vae", low_cpu_mem_usage=True,
|
|
)
|
|
audio_vae = AutoencoderKLMiniMaxH3Audio.from_pretrained(
|
|
args.pretrained_model_name_or_path, subfolder="audio_vae", low_cpu_mem_usage=True,
|
|
)
|
|
if uses_text_encoder:
|
|
# On-the-fly conditioning (`fl2va` prompts, `ref2va` requests): the same Qwen3-VL components
|
|
# `train_lora.py` loads, so `train_lora.encode_prompt` runs unchanged. Mirrors `scripts/minimax_h3/train_lora.py`.
|
|
tokenizer = Qwen2TokenizerFast.from_pretrained(os.path.join(args.pretrained_model_name_or_path, "tokenizer"))
|
|
processor = Qwen3VLProcessor.from_pretrained(os.path.join(args.pretrained_model_name_or_path, "processor"))
|
|
text_encoder = Qwen3VLForConditionalGeneration.from_pretrained(
|
|
os.path.join(args.pretrained_model_name_or_path, "text_encoder"), low_cpu_mem_usage=True, torch_dtype=weight_dtype,
|
|
)
|
|
text_encoder = text_encoder.eval()
|
|
scheduler = MiniMaxH3Scheduler.from_pretrained(args.pretrained_model_name_or_path, subfolder="scheduler")
|
|
audio_scheduler = MiniMaxH3Scheduler.from_pretrained(args.pretrained_model_name_or_path, subfolder="audio_scheduler")
|
|
|
|
# Freeze everything; the LoRA modules and parallel heads created below are the only trainable parameters.
|
|
transformer.requires_grad_(False)
|
|
vae.requires_grad_(False)
|
|
audio_vae.requires_grad_(False)
|
|
if uses_text_encoder:
|
|
text_encoder.requires_grad_(False)
|
|
|
|
# ------------------------------------------------------------------ LoRA
|
|
num_adapters = add_pdd_lora(transformer, args.target_name.split(","), args.rank, args.network_alpha)
|
|
attach_parallel_decoder(transformer, args.pdd_num_steps)
|
|
transformer.train()
|
|
# FSDP flattens one dtype per wrap unit. DDP keeps float32 LoRA master weights; under FSDP the adapters match
|
|
# the Linear they wrap (bf16) so each `MiniMaxH3TransformerBlock` is uniform. The parallel heads stay float32
|
|
# and are wrapped as their own units. Frozen `_keep_in_fp32_modules` embeddings are ignored so they are not
|
|
# mixed into the bf16 root flatten (`--mixed_precision=no` does not install an FSDP MixedPrecision policy).
|
|
if fsdp_plugin is not None:
|
|
for module in transformer.modules():
|
|
if isinstance(module, PDDLoRALinear):
|
|
dtype = module.base.weight.dtype
|
|
module.lora_down.data = module.lora_down.data.to(dtype)
|
|
module.lora_up.data = module.lora_up.data.to(dtype)
|
|
wrap_names = list(fsdp_plugin.transformer_cls_names_to_wrap or [])
|
|
if "PDDParallelHead" not in wrap_names:
|
|
wrap_names.append("PDDParallelHead")
|
|
fsdp_plugin.transformer_cls_names_to_wrap = wrap_names
|
|
ignored = []
|
|
for name in ("proj_in", "audio_proj_in", "time_embedder", "rope"):
|
|
module = getattr(transformer, name, None)
|
|
if isinstance(module, torch.nn.Module):
|
|
# `sync_module_states=True` rejects CPU params on ignored modules; FSDP's `device_id` only
|
|
# moves the flattened units.
|
|
module.to(accelerator.device)
|
|
ignored.append(module)
|
|
fsdp_plugin.ignored_modules = ignored
|
|
logger.info(
|
|
"FSDP: LoRA adapters cast to the backbone dtype; wrap %s; ignored_modules=%s.",
|
|
wrap_names,
|
|
[module.__class__.__name__ for module in ignored],
|
|
)
|
|
|
|
if args.transformer_path is not None:
|
|
print(f"From checkpoint: {args.transformer_path}")
|
|
if args.transformer_path.endswith("safetensors"):
|
|
from safetensors.torch import load_file
|
|
state_dict = load_file(args.transformer_path)
|
|
else:
|
|
state_dict = torch.load(args.transformer_path, map_location="cpu")
|
|
state_dict = state_dict["state_dict"] if "state_dict" in state_dict else state_dict
|
|
|
|
m, u = transformer.load_state_dict(state_dict, strict=False)
|
|
print(f"missing keys: {len(m)}, unexpected keys: {len(u)}")
|
|
assert len(u) == 0
|
|
|
|
# Function for unwrapping if model was compiled with `torch.compile`.
|
|
def unwrap_model(model):
|
|
model = accelerator.unwrap_model(model)
|
|
model = model._orig_mod if is_compiled_module(model) else model
|
|
return model
|
|
|
|
# ------------------------------------------------------------------ save / load hooks
|
|
# `accelerate` 0.16.0+ supports custom saving hooks. Under FSDP / ZeRO-3 the hook writes
|
|
# live `pdd.safetensors` from the gathered trainable tensors so DDP `--save_state` resume can reload
|
|
# the current step; popping `weights` on the DDP path keeps `save_state` from serializing the frozen
|
|
# backbone. The EMA inference export `pdd_ema.safetensors` is written after `ema.copy_to`.
|
|
if version.parse(accelerate.__version__) >= version.parse("0.16.0"):
|
|
if fsdp_stage != 0 or zero_stage == 3:
|
|
def save_model_hook(models, weights, output_dir):
|
|
accelerate_state_dict = gather_full_state_dict(models[-1], accelerator)
|
|
if accelerator.is_main_process and accelerate_state_dict is not None:
|
|
os.makedirs(output_dir, exist_ok=True)
|
|
save_pdd_weights(
|
|
os.path.join(output_dir, PDD_WEIGHTS_NAME),
|
|
pdd_state_dict(unwrap_model(models[-1]), accelerate_state_dict),
|
|
)
|
|
dump_pdd_config(args, output_dir)
|
|
|
|
def load_model_hook(models, input_dir):
|
|
return
|
|
|
|
else:
|
|
def save_model_hook(models, weights, output_dir):
|
|
accelerate_state_dict = gather_full_state_dict(models[-1], accelerator)
|
|
if accelerator.is_main_process and accelerate_state_dict is not None:
|
|
os.makedirs(output_dir, exist_ok=True)
|
|
save_pdd_weights(
|
|
os.path.join(output_dir, PDD_WEIGHTS_NAME),
|
|
pdd_state_dict(unwrap_model(models[-1]), accelerate_state_dict),
|
|
)
|
|
dump_pdd_config(args, output_dir)
|
|
if not args.use_deepspeed:
|
|
for _ in range(len(weights)):
|
|
weights.pop()
|
|
|
|
def load_model_hook(models, input_dir):
|
|
return
|
|
|
|
accelerator.register_save_state_pre_hook(save_model_hook)
|
|
accelerator.register_load_state_pre_hook(load_model_hook)
|
|
|
|
if args.gradient_checkpointing:
|
|
transformer.enable_gradient_checkpointing()
|
|
|
|
# Enable TF32 for faster training on Ampere GPUs,
|
|
# see https://pytorch.org/docs/stable/notes/cuda.html#tensorfloat-32-tf32-on-ampere-devices
|
|
if args.allow_tf32:
|
|
torch.backends.cuda.matmul.allow_tf32 = True
|
|
|
|
if args.scale_lr:
|
|
lr_scale = args.gradient_accumulation_steps * args.train_batch_size * accelerator.num_processes
|
|
args.learning_rate = args.learning_rate * lr_scale
|
|
args.lora_learning_rate = args.lora_learning_rate * lr_scale
|
|
|
|
head_params, lora_params = [], []
|
|
for name, parameter in transformer.named_parameters():
|
|
if not parameter.requires_grad:
|
|
continue
|
|
(head_params if "proj_out" in name else lora_params).append(parameter)
|
|
trainable_params = head_params + lora_params
|
|
logger.info(
|
|
f"LoRA created: {num_adapters} adapters, {sum(p.numel() for p in lora_params) / 1e6:.2f} M parameters; "
|
|
f"{len(head_params)} parallel head tensors, {sum(p.numel() for p in head_params) / 1e6:.2f} M parameters."
|
|
)
|
|
|
|
# ------------------------------------------------------------------ optimizer
|
|
if args.use_8bit_adam:
|
|
try:
|
|
import bitsandbytes as bnb
|
|
except ImportError:
|
|
raise ImportError("Please install bitsandbytes to use 8-bit Adam.")
|
|
optimizer_cls = bnb.optim.AdamW8bit
|
|
else:
|
|
optimizer_cls = torch.optim.AdamW
|
|
optimizer = optimizer_cls(
|
|
[
|
|
{"params": head_params, "lr": args.learning_rate},
|
|
{"params": lora_params, "lr": args.lora_learning_rate},
|
|
],
|
|
lr=args.learning_rate,
|
|
betas=(args.adam_beta1, args.adam_beta2),
|
|
weight_decay=args.adam_weight_decay,
|
|
eps=args.adam_epsilon,
|
|
)
|
|
|
|
# ------------------------------------------------------------------ data
|
|
# Data-free PDD never reads a target video: each rank carries one trajectory and needs only the conditioning —
|
|
# either pre-encoded safetensors (`--enable_preprocess_training`) or an annotation encoded on the fly (without it).
|
|
# All three routes go through a DataLoader so `accelerator.prepare` shards the entries across ranks, replacing the
|
|
# old whole-cache random pick.
|
|
if args.enable_preprocess_training:
|
|
train_dataset = ImageVideoSafetensorsDataset(args.train_data_meta, data_root=args.train_data_dir)
|
|
|
|
def collate_fn(examples):
|
|
return reconstruct_cache_entry(examples[0], args.train_mode)
|
|
elif args.train_mode == "ref2va":
|
|
# On-the-fly `ref2va`: read the request annotation with `load_requests` (the same reader the cache generator
|
|
# uses) and encode each request's prompt + reference latents in the conditioning iterator below.
|
|
train_dataset = _RequestDataset(load_requests(args.train_data_meta))
|
|
|
|
def collate_fn(examples):
|
|
# `--train_batch_size=1`: one request per trajectory reset, so hand over the single record; the Qwen3-VL /
|
|
# VAE encode runs in the conditioning iterator below (main process), not in the collate / workers.
|
|
return {"prompt": examples[0]["prompt"], "references": list(examples[0]["references"])}
|
|
else:
|
|
train_dataset = TextDataset(args.train_data_meta)
|
|
|
|
def collate_fn(examples):
|
|
# `--train_batch_size=1`: one conditioning entry per trajectory reset, so hand over the single record; the
|
|
# Qwen3-VL encode runs in the conditioning iterator below (main process), not in the collate / workers.
|
|
return {"text": examples[0]["text"]}
|
|
|
|
batch_sampler_generator = torch.Generator().manual_seed(args.seed)
|
|
batch_sampler = BatchSampler(
|
|
RandomSampler(train_dataset, generator=batch_sampler_generator),
|
|
batch_size=args.train_batch_size,
|
|
drop_last=True,
|
|
)
|
|
train_dataloader = DataLoader(train_dataset, batch_sampler=batch_sampler, collate_fn=collate_fn)
|
|
|
|
# The held-out validation conditioning mirrors the training route (a cache *or* on-the-fly prompts) instead of
|
|
# forcing a pre-processed cache, so it is built further down — once the conditioner is sharded / on-device — right
|
|
# before the trajectory setup. Validation is skipped when `--val_data_meta` is empty.
|
|
|
|
# Scheduler and math around the number of training steps. One epoch is one pass through the conditioning set
|
|
# (batch size is 1). `--max_train_steps` overrides `--num_train_epochs`, matching `scripts/minimax_h3/train_lora.py`.
|
|
overrode_max_train_steps = False
|
|
num_update_steps_per_epoch = math.ceil(len(train_dataset) / args.gradient_accumulation_steps)
|
|
if args.max_train_steps is None:
|
|
args.max_train_steps = args.num_train_epochs * num_update_steps_per_epoch
|
|
overrode_max_train_steps = True
|
|
|
|
lr_scheduler = get_scheduler(
|
|
args.lr_scheduler,
|
|
optimizer=optimizer,
|
|
num_warmup_steps=args.lr_warmup_steps * accelerator.num_processes,
|
|
num_training_steps=args.max_train_steps * accelerator.num_processes,
|
|
)
|
|
|
|
transformer.gradient_checkpointing_save_on_cpu = args.gradient_checkpointing_save_on_cpu
|
|
transformer, optimizer, train_dataloader, lr_scheduler = accelerator.prepare(
|
|
transformer, optimizer, train_dataloader, lr_scheduler
|
|
)
|
|
|
|
# Shard the frozen text encoder *after* prepare (mirrors `train_lora.py`): the Qwen3-VL conditioner (~62 GB) is
|
|
# wrapped per decoder layer so the per-step unshard footprint stays small, and a post-prepare shard keeps it out of
|
|
# the trainable FSDP unit. Only the on-the-fly text route loads it (`uses_text_encoder`).
|
|
sharded_text_encoder = uses_text_encoder and (fsdp_stage != 0 or zero_stage != 0)
|
|
if sharded_text_encoder:
|
|
from videox_fun.dist import shard_model
|
|
text_encoder.model = shard_model(
|
|
text_encoder.model,
|
|
device_id=accelerator.device,
|
|
param_dtype=weight_dtype,
|
|
module_to_wrapper=list(text_encoder.model.language_model.layers),
|
|
)
|
|
|
|
device = accelerator.device
|
|
# The two VAEs stay float32 (mirrors the pipeline: float32 weights, float16 autocast only at the
|
|
# encode/decode call site), so they are moved without a dtype cast.
|
|
vae.to(device if not args.low_vram else "cpu")
|
|
audio_vae.to(device if not args.low_vram else "cpu")
|
|
if fsdp_stage == 0 and zero_stage == 0:
|
|
transformer.to(device)
|
|
# An FSDP/ZeRO-sharded text encoder is already on-device and dtype-pinned by `shard_model`; otherwise move it to
|
|
# the GPU, or keep it on CPU under `--low_vram` (the conditioning iterator moves it up only for each encode).
|
|
if uses_text_encoder and not sharded_text_encoder:
|
|
text_encoder.to(device if not args.low_vram else "cpu", dtype=weight_dtype)
|
|
|
|
trainable_params = [parameter for parameter in transformer.parameters() if parameter.requires_grad]
|
|
ema = (
|
|
EMAModel(trainable_params, decay=args.ema_decay, use_ema_warmup=False, foreach=True)
|
|
if args.use_ema
|
|
else None
|
|
)
|
|
|
|
# We need to recalculate our total training steps as the size of the training dataloader may have changed. One
|
|
# epoch is one pass through the conditioning set (batch size 1), keeping `--num_train_epochs` / `--max_train_steps`
|
|
# consistent with `train_lora.py`.
|
|
num_update_steps_per_epoch = math.ceil(len(train_dataset) / args.gradient_accumulation_steps)
|
|
if overrode_max_train_steps:
|
|
args.max_train_steps = args.num_train_epochs * num_update_steps_per_epoch
|
|
args.num_train_epochs = math.ceil(args.max_train_steps / num_update_steps_per_epoch)
|
|
|
|
if accelerator.is_main_process:
|
|
master_dtypes = {parameter.dtype for parameter in transformer.parameters()}
|
|
num_local_params = sum(parameter.numel() for parameter in transformer.parameters())
|
|
logger.info(
|
|
f"Master parameter dtype(s): {master_dtypes}, {num_local_params / 1e9:.2f} B parameters per rank "
|
|
f"over {accelerator.num_processes} process(es)."
|
|
)
|
|
|
|
if accelerator.is_main_process:
|
|
tracker_config = dict(vars(args))
|
|
tracker_config = {k: v for k, v in tracker_config.items() if not isinstance(v, list)}
|
|
accelerator.init_trackers(args.tracker_project_name, tracker_config)
|
|
|
|
# ------------------------------------------------------------------ constants
|
|
# Read the transformer config through the unwrap so it works under FSDP (the prepared `transformer` is a
|
|
# sharded wrapper) as well as single-process.
|
|
student = unwrap_model(transformer)
|
|
patch_size = tuple(student.config.patch_size)
|
|
latent_channels = student.config.in_channels
|
|
audio_channels = student.config.audio_in_channels
|
|
geometry = (
|
|
video_latent_num_frames(args.video_sample_n_frames),
|
|
args.video_sample_height // vae.spatial_compression_ratio,
|
|
args.video_sample_width // vae.spatial_compression_ratio,
|
|
audio_latent_num_frames(args.video_sample_n_frames),
|
|
)
|
|
video_grid = pdd_time_grid(scheduler.shift, args.pdd_num_steps)
|
|
audio_grid = pdd_time_grid(audio_scheduler.shift, args.pdd_num_steps)
|
|
grids = (video_grid, audio_grid, video_grid.diff(), audio_grid.diff())
|
|
logger.info(
|
|
f"Grid N={args.pdd_num_steps}, L_min={args.pdd_block_size}, L_max={args.pdd_max_block_size}: block starts at "
|
|
f"t = {[round(float(video_grid[i]), 4) for i in range(0, args.pdd_num_steps + 1, args.pdd_block_size)]}"
|
|
)
|
|
|
|
# On-the-fly `ref2va` conditioning, shared by the training iterator and validation: parse the request's
|
|
# references, then encode the prompt (Qwen3-VL) and the reference latents (the two VAEs) with the same recipes
|
|
# `generate_ref2va_request_cache.py` uses. Under `--low_vram` the two encodes are serialized — conditioner up then
|
|
# down, VAEs up then down — so the ~62 GB text encoder and a 124-frame reference video never share the GPU.
|
|
ref2va_num_frames = align_num_frames(int(args.video_sample_n_frames))
|
|
ref2va_audio_sr = getattr(audio_vae.config, "sampling_rate", 32000)
|
|
|
|
def encode_request_on_the_fly(request):
|
|
references = [parse_reference(entry) for entry in request["references"]]
|
|
references = check_ref2va_references(references)
|
|
references = normalize_ref2va_references(references, ref2va_num_frames, ref2va_audio_sr)
|
|
|
|
if args.low_vram and not sharded_text_encoder:
|
|
text_encoder.to(device)
|
|
with torch.no_grad():
|
|
prompt_embeds, text_token_tags = encode_prompt(
|
|
text_encoder, tokenizer, processor, request["prompt"],
|
|
references=references, device=device, dtype=weight_dtype,
|
|
)
|
|
if args.low_vram and not sharded_text_encoder:
|
|
text_encoder.to("cpu")
|
|
torch.cuda.empty_cache()
|
|
|
|
if args.low_vram:
|
|
vae.to(device)
|
|
audio_vae.to(device)
|
|
with torch.no_grad():
|
|
condition_latents, audio_condition_latents = encode_reference_latents_for_training(
|
|
vae, audio_vae, references, patch_size, device, audio_latent_channels=audio_channels,
|
|
)
|
|
if args.low_vram:
|
|
vae.to("cpu")
|
|
audio_vae.to("cpu")
|
|
torch.cuda.empty_cache()
|
|
|
|
return {
|
|
"prompt_embeds": prompt_embeds,
|
|
"text_token_tags": text_token_tags,
|
|
"reference_kinds": [(reference.kind, bool(reference.has_audio)) for reference in references],
|
|
"condition_latents": condition_latents,
|
|
"audio_condition_latents": audio_condition_latents,
|
|
}
|
|
|
|
# Validation conditioning, built now that the conditioner is sharded / on-device so it can mirror the training
|
|
# route: with `--enable_preprocess_training` a `generate_*_cache.py` cache (`outputs.json` + safetensors); without
|
|
# it, the on-the-fly annotation encoded here (`fl2va` prompts via `TextDataset`, `ref2va` requests via
|
|
# `encode_request_on_the_fly`). Every rank builds the full list (exactly like the cache route); `log_validation`
|
|
# shards it at render time.
|
|
val_cache = []
|
|
if args.val_data_meta:
|
|
if args.enable_preprocess_training:
|
|
val_dataset = ImageVideoSafetensorsDataset(args.val_data_meta, data_root=args.train_data_dir)
|
|
val_cache = [reconstruct_cache_entry(val_dataset[i], args.train_mode) for i in range(len(val_dataset))]
|
|
elif args.train_mode == "ref2va":
|
|
val_dataset = _RequestDataset(load_requests(args.val_data_meta))
|
|
val_cache = [encode_request_on_the_fly(val_dataset[i]) for i in range(len(val_dataset))]
|
|
else:
|
|
val_dataset = TextDataset(args.val_data_meta)
|
|
if args.low_vram and not sharded_text_encoder:
|
|
text_encoder.to(device)
|
|
with torch.no_grad():
|
|
for i in range(len(val_dataset)):
|
|
prompt_embeds, text_token_tags = encode_prompt(
|
|
text_encoder, tokenizer, processor, val_dataset[i]["text"], device=device, dtype=weight_dtype,
|
|
)
|
|
val_cache.append({"prompt_embeds": prompt_embeds, "text_token_tags": text_token_tags})
|
|
if args.low_vram and not sharded_text_encoder:
|
|
text_encoder.to("cpu")
|
|
torch.cuda.empty_cache()
|
|
|
|
# The conditioning iterator turns the (accelerate-sharded, cycling) dataloader into the normalized entries the
|
|
# trajectories pull on `reset()`. The pre-processed route already yields `{prompt_embeds, text_token_tags}` (plus
|
|
# the ref2va reference tensors); the on-the-fly route encodes each entry here in the main process — `fl2va` prompts
|
|
# via `encode_prompt`, `ref2va` requests via `encode_request_on_the_fly` — moving the conditioner / VAEs up only
|
|
# for the encode under `--low_vram`.
|
|
def conditioning_iterator():
|
|
while True:
|
|
for batch in train_dataloader:
|
|
if not uses_text_encoder:
|
|
yield batch
|
|
elif args.train_mode == "ref2va":
|
|
yield encode_request_on_the_fly(batch)
|
|
else:
|
|
if args.low_vram and not sharded_text_encoder:
|
|
text_encoder.to(device)
|
|
with torch.no_grad():
|
|
prompt_embeds, text_token_tags = encode_prompt(
|
|
text_encoder, tokenizer, processor, batch["text"], device=device, dtype=weight_dtype,
|
|
)
|
|
if args.low_vram and not sharded_text_encoder:
|
|
text_encoder.to("cpu")
|
|
torch.cuda.empty_cache()
|
|
yield {"prompt_embeds": prompt_embeds, "text_token_tags": text_token_tags}
|
|
|
|
condition_iter = conditioning_iterator()
|
|
|
|
if args.train_mode == "ref2va":
|
|
trajectory = Ref2VATrajectory(
|
|
geometry, patch_size, latent_channels, audio_channels, condition_iter, scheduler, device,
|
|
)
|
|
else:
|
|
trajectory = FL2VATrajectory(
|
|
geometry, patch_size, latent_channels, audio_channels, condition_iter, device,
|
|
)
|
|
target_seed = (args.seed if args.seed is not None else 0) + 1000 + accelerator.process_index
|
|
target_rng = np.random.default_rng(np.random.PCG64(target_seed))
|
|
|
|
# ------------------------------------------------------------------ train loop
|
|
total_batch_size = args.train_batch_size * accelerator.num_processes * args.gradient_accumulation_steps
|
|
logger.info("***** Running training *****")
|
|
logger.info(f" Num examples = {len(train_dataset)}")
|
|
logger.info(f" Num Epochs = {args.num_train_epochs}")
|
|
logger.info(f" Instantaneous batch size per device = {args.train_batch_size}")
|
|
logger.info(f" Total train batch size (w. parallel, distributed & accumulation) = {total_batch_size}")
|
|
logger.info(f" Gradient Accumulation steps = {args.gradient_accumulation_steps}")
|
|
logger.info(f" Total optimization steps = {args.max_train_steps}")
|
|
logger.info(f" Video / audio loss weights = {args.video_loss_weight} / {args.audio_loss_weight}")
|
|
|
|
global_step = 0
|
|
|
|
# Potentially load in the weights and states from a previous save
|
|
if args.resume_from_checkpoint:
|
|
if args.resume_from_checkpoint != "latest":
|
|
path = os.path.basename(args.resume_from_checkpoint)
|
|
else:
|
|
# Get the most recent checkpoint
|
|
dirs = os.listdir(args.output_dir) if os.path.isdir(args.output_dir) else []
|
|
dirs = [d for d in dirs if d.startswith("checkpoint")]
|
|
dirs = sorted(dirs, key=lambda x: int(x.split("-")[1]))
|
|
path = dirs[-1] if len(dirs) > 0 else None
|
|
|
|
if path is None:
|
|
accelerator.print(
|
|
f"Checkpoint '{args.resume_from_checkpoint}' does not exist. Starting a new training run."
|
|
)
|
|
args.resume_from_checkpoint = None
|
|
initial_global_step = 0
|
|
else:
|
|
global_step = int(path.split("-")[1])
|
|
|
|
initial_global_step = global_step
|
|
|
|
if args.resume_from_checkpoint != "latest" and os.path.isdir(args.resume_from_checkpoint):
|
|
checkpoint_folder_path = args.resume_from_checkpoint
|
|
else:
|
|
checkpoint_folder_path = os.path.join(args.output_dir, path)
|
|
if zero_stage != 3 and not args.use_fsdp:
|
|
load_resume_state(
|
|
checkpoint_folder_path, student, optimizer, lr_scheduler, ema, trainable_params, accelerator
|
|
)
|
|
else:
|
|
accelerator.load_state(checkpoint_folder_path)
|
|
accelerator.print("accelerator.load_state() completed for FSDP / ZeRO stage 3.")
|
|
if ema is not None:
|
|
ema.shadow_params = [parameter.detach().clone() for parameter in trainable_params]
|
|
print(f"EMA is re-seeded from the loaded FSDP / ZeRO weights under {checkpoint_folder_path}.")
|
|
print(f"Resumed training from {checkpoint_folder_path} at step {global_step}.")
|
|
else:
|
|
initial_global_step = 0
|
|
|
|
if ema is not None:
|
|
ema.to(device)
|
|
|
|
progress_bar = PauseAwareTqdm(
|
|
range(0, args.max_train_steps),
|
|
initial=initial_global_step,
|
|
desc="Steps",
|
|
disable=not accelerator.is_local_main_process,
|
|
)
|
|
|
|
train_loss = 0.0
|
|
train_video_loss = 0.0
|
|
train_audio_loss = 0.0
|
|
step_started = time.time()
|
|
|
|
while global_step < args.max_train_steps:
|
|
with accelerator.accumulate(transformer):
|
|
if trajectory.index is None or trajectory.index >= args.pdd_num_steps:
|
|
trajectory.reset()
|
|
start = trajectory.index
|
|
|
|
# Sample the intra-block indices the loss is evaluated at, `k ~ U{n, ..., min(n + L_max, N) - 1}`, without
|
|
# replacement so that several targets always supervise several distinct heads.
|
|
reach = min(start + args.pdd_max_block_size, args.pdd_num_steps)
|
|
targets = sorted(
|
|
target_rng.choice(
|
|
np.arange(start, reach), size=min(args.pdd_num_targets, reach - start), replace=False
|
|
).tolist()
|
|
)
|
|
|
|
# One student evaluation yields, per target, the displacement to `X_k` and the velocity `u_k` the loss
|
|
# regresses, plus the `L_min` advance of the carried state (the paper's layer fusion, §3.1).
|
|
set_parallel_plan(
|
|
student,
|
|
pdd_training_plan(grids[2], start, targets, args.pdd_block_size).float(),
|
|
pdd_training_plan(grids[3], start, targets, args.pdd_block_size).float(),
|
|
)
|
|
video_output, audio_output = transformer(
|
|
hidden_states=trajectory.video[None],
|
|
audio_hidden_states=trajectory.audio[None],
|
|
**trajectory.forward_kwargs(video_grid[start], audio_grid[start]),
|
|
)
|
|
# The heads run over every row and the modality rows are selected afterwards, so a ref2va output still
|
|
# carries the conditioning rows in front. Only the generated tail is rolled forward and supervised.
|
|
video_output = video_output[0].unflatten(-1, (-1, latent_channels * math.prod(patch_size)))
|
|
audio_output = audio_output[0].unflatten(-1, (-1, audio_channels))
|
|
video_output, audio_output = trajectory.generated(video_output, audio_output)
|
|
state_video_tail, state_audio_tail = trajectory.generated(trajectory.video, trajectory.audio)
|
|
|
|
video_loss = video_output.new_zeros(())
|
|
audio_loss = audio_output.new_zeros(())
|
|
for position, target in enumerate(targets):
|
|
# The teacher's mean velocity is estimated on the student's own intra-block state (on-policy), and
|
|
# the state is a constant of the loss (eq. 11's stop-gradient). Conditioning rows are put back in
|
|
# front of the generated tail on ref2va; fl2va has no conditioning rows.
|
|
state_video, state_audio = trajectory.with_generated(
|
|
state_video_tail + video_output[:, 2 * position].detach(),
|
|
state_audio_tail + audio_output[:, 2 * position].detach(),
|
|
)
|
|
with pdd_teacher_mode(student), torch.no_grad():
|
|
target_video, target_audio = pdd_teacher_mean_velocity(
|
|
transformer, trajectory.forward_kwargs, state_video, state_audio, target, grids, args.pdd_solver
|
|
)
|
|
target_video, target_audio = trajectory.generated(target_video, target_audio)
|
|
video_loss = video_loss + F.mse_loss(video_output[:, 2 * position + 1].float(), target_video)
|
|
audio_loss = audio_loss + F.mse_loss(audio_output[:, 2 * position + 1].float(), target_audio)
|
|
video_loss = video_loss / len(targets)
|
|
audio_loss = audio_loss / len(targets)
|
|
loss = args.video_loss_weight * video_loss + args.audio_loss_weight * audio_loss
|
|
|
|
# Gather the losses across all processes for logging (if we use distributed training).
|
|
avg_loss = accelerator.gather(loss.detach()[None]).mean()
|
|
train_loss += avg_loss.item() / args.gradient_accumulation_steps
|
|
train_video_loss += (
|
|
accelerator.gather(video_loss.detach()[None]).mean().item() / args.gradient_accumulation_steps
|
|
)
|
|
train_audio_loss += (
|
|
accelerator.gather(audio_loss.detach()[None]).mean().item() / args.gradient_accumulation_steps
|
|
)
|
|
|
|
# Backpropagate
|
|
accelerator.backward(loss)
|
|
if accelerator.sync_gradients:
|
|
max_grad_norm = linear_decay(
|
|
args.max_grad_norm * args.initial_grad_norm_ratio,
|
|
args.max_grad_norm,
|
|
args.abnormal_norm_clip_start,
|
|
global_step,
|
|
)
|
|
accelerator.clip_grad_norm_(trainable_params, max_grad_norm)
|
|
optimizer.step()
|
|
lr_scheduler.step()
|
|
optimizer.zero_grad(set_to_none=True)
|
|
|
|
trajectory.video, trajectory.audio = trajectory.with_generated(
|
|
state_video_tail + video_output[:, -1].detach(),
|
|
state_audio_tail + audio_output[:, -1].detach(),
|
|
)
|
|
trajectory.index = start + args.pdd_block_size
|
|
del video_output, audio_output
|
|
|
|
# Checks if the accelerator has performed an optimization step behind the scenes
|
|
if accelerator.sync_gradients:
|
|
if ema is not None:
|
|
ema.step(trainable_params)
|
|
progress_bar.update(1)
|
|
global_step += 1
|
|
accelerator.log(
|
|
{
|
|
"train_loss": train_loss,
|
|
"video_loss": train_video_loss,
|
|
"audio_loss": train_audio_loss,
|
|
"grid_index": start,
|
|
"lr_heads": lr_scheduler.get_last_lr()[0],
|
|
"lr_lora": lr_scheduler.get_last_lr()[-1],
|
|
"step_seconds": time.time() - step_started,
|
|
},
|
|
step=global_step,
|
|
)
|
|
train_loss = 0.0
|
|
train_video_loss = 0.0
|
|
train_audio_loss = 0.0
|
|
step_started = time.time()
|
|
|
|
if global_step % args.checkpointing_steps == 0:
|
|
with progress_bar.paused():
|
|
if args.use_deepspeed or args.use_fsdp or accelerator.is_main_process:
|
|
# _before_ saving state, check if this save would set us over the `checkpoints_total_limit`
|
|
if args.checkpoints_total_limit is not None:
|
|
checkpoints = os.listdir(args.output_dir)
|
|
checkpoints = [d for d in checkpoints if d.startswith("checkpoint")]
|
|
checkpoints = sorted(checkpoints, key=lambda x: int(x.split("-")[1]))
|
|
|
|
# before we save the new checkpoint, we need to have at _most_ `checkpoints_total_limit - 1` checkpoints
|
|
if len(checkpoints) >= args.checkpoints_total_limit:
|
|
num_to_remove = len(checkpoints) - args.checkpoints_total_limit + 1
|
|
removing_checkpoints = checkpoints[0:num_to_remove]
|
|
|
|
logger.info(
|
|
f"{len(checkpoints)} checkpoints already exist, removing {len(removing_checkpoints)} checkpoints"
|
|
)
|
|
logger.info(f"removing checkpoints: {', '.join(removing_checkpoints)}")
|
|
|
|
for removing_checkpoint in removing_checkpoints:
|
|
removing_checkpoint = os.path.join(args.output_dir, removing_checkpoint)
|
|
shutil.rmtree(removing_checkpoint)
|
|
gc.collect()
|
|
torch.cuda.empty_cache()
|
|
torch.cuda.ipc_collect()
|
|
save_path = os.path.join(args.output_dir, f"checkpoint-{global_step}")
|
|
if args.use_deepspeed or args.use_fsdp or args.save_state:
|
|
accelerator.save_state(save_path)
|
|
else:
|
|
save_resume_state(save_path, student, optimizer, lr_scheduler, ema, accelerator)
|
|
dump_pdd_config(args, save_path)
|
|
|
|
if ema is not None:
|
|
ema.store(trainable_params)
|
|
ema.copy_to(trainable_params)
|
|
checkpoint_dir = os.path.join(args.output_dir, f"checkpoint-{global_step}")
|
|
if args.use_deepspeed or args.use_fsdp:
|
|
state_dict = gather_full_state_dict(transformer, accelerator)
|
|
if accelerator.is_main_process and state_dict is not None:
|
|
save_pdd_weights(
|
|
os.path.join(checkpoint_dir, PDD_EMA_WEIGHTS_NAME),
|
|
pdd_state_dict(unwrap_model(transformer), state_dict),
|
|
)
|
|
dump_pdd_config(args, checkpoint_dir)
|
|
elif accelerator.is_main_process:
|
|
save_pdd_weights(
|
|
os.path.join(checkpoint_dir, PDD_EMA_WEIGHTS_NAME),
|
|
pdd_state_dict(unwrap_model(transformer)),
|
|
)
|
|
ema.restore(trainable_params)
|
|
if accelerator.is_main_process:
|
|
logger.info(f"Saved state to {os.path.join(args.output_dir, f'checkpoint-{global_step}')}")
|
|
accelerator.wait_for_everyone()
|
|
|
|
if global_step % args.validation_steps == 0 and val_cache:
|
|
with progress_bar.paused():
|
|
if ema is not None:
|
|
ema.store(trainable_params)
|
|
ema.copy_to(trainable_params)
|
|
accelerator.wait_for_everyone()
|
|
log_validation(
|
|
vae, audio_vae, transformer, scheduler, audio_scheduler, args, accelerator,
|
|
val_cache, grids, global_step,
|
|
)
|
|
accelerator.wait_for_everyone()
|
|
if ema is not None:
|
|
ema.restore(trainable_params)
|
|
step_started = time.time()
|
|
|
|
logs = {"step_loss": loss.detach().item(), "lr": lr_scheduler.get_last_lr()[0]}
|
|
progress_bar.set_postfix(**logs)
|
|
|
|
# Close the bar before the end-of-run checkpoint: tqdm keeps redrawing a live bar whenever
|
|
# something else writes to the console. PauseAwareTqdm.close() rebases the closing line onto
|
|
# the smoothed rate, so the worker warm-up and the first dataloader fetch do not dilute it.
|
|
progress_bar.close()
|
|
|
|
# Create the pipeline using the trained modules and save it.
|
|
accelerator.wait_for_everyone()
|
|
if args.use_deepspeed or args.use_fsdp or accelerator.is_main_process:
|
|
gc.collect()
|
|
torch.cuda.empty_cache()
|
|
torch.cuda.ipc_collect()
|
|
save_path = os.path.join(args.output_dir, f"checkpoint-{global_step}")
|
|
if args.use_deepspeed or args.use_fsdp or args.save_state:
|
|
accelerator.save_state(save_path)
|
|
else:
|
|
save_resume_state(save_path, student, optimizer, lr_scheduler, ema, accelerator)
|
|
dump_pdd_config(args, save_path)
|
|
if ema is not None:
|
|
ema.copy_to(trainable_params)
|
|
if args.use_deepspeed or args.use_fsdp:
|
|
state_dict = gather_full_state_dict(transformer, accelerator)
|
|
if accelerator.is_main_process and state_dict is not None:
|
|
save_pdd_weights(
|
|
os.path.join(save_path, PDD_EMA_WEIGHTS_NAME),
|
|
pdd_state_dict(unwrap_model(transformer), state_dict),
|
|
)
|
|
dump_pdd_config(args, save_path)
|
|
elif accelerator.is_main_process:
|
|
save_pdd_weights(os.path.join(save_path, PDD_EMA_WEIGHTS_NAME), pdd_state_dict(unwrap_model(transformer)))
|
|
if accelerator.is_main_process:
|
|
logger.info(f"Saved state to {save_path}")
|
|
accelerator.end_training()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|