[feat] Refactor training framework into fastvideo/train (#1159)

Co-authored-by: Peiyuan Zhang <a1286225768@gmail.com>
This commit is contained in:
alexzms
2026-03-09 15:16:42 -07:00
committed by GitHub
co-authored by Peiyuan Zhang
parent 2b13e117f0
commit bc27a032c5
68 changed files with 8720 additions and 184 deletions
+24 -2
View File
@@ -1,6 +1,6 @@
# FastVideo-WorldModel — Codebase Map
High-level structural index for agent orientation. Updated 2026-03-02.
High-level structural index for agent orientation. Updated 2026-03-08.
## Repository Layout
@@ -22,7 +22,18 @@ FastVideo-WorldModel/
│ ├── pipelines/ # End-to-end pipelines
│ │ ├── basic/ # Per-model pipelines (wan/, ltx2/, ...)
│ │ └── stages/ # Reusable pipeline stages
│ ├── training/ # Training infrastructure
│ ├── train/ # Refactored training framework (YAML-driven, preferred)
│ │ ├── trainer.py # Main training loop coordinator
│ │ ├── entrypoint/ # Training entrypoint (train.py) + checkpoint conversion
│ │ ├── methods/ # Training algorithms (FineTune, DFSFT, DMD2, SelfForcing)
│ │ │ ├── base.py # TrainingMethod ABC
│ │ │ ├── fine_tuning/ # FineTuneMethod, DiffusionForcingSFTMethod
│ │ │ └── distribution_matching/ # DMD2Method, SelfForcingMethod
│ │ ├── models/ # Per-role model wrappers (ModelBase, CausalModelBase)
│ │ │ └── wan/ # WanModel, WanCausalModel
│ │ ├── callbacks/ # Composable hooks (grad_clip, ema, validation)
│ │ └── utils/ # Config, builder, checkpoint, optimizer, tracking
│ ├── training/ # Legacy training infrastructure (being phased out)
│ │ ├── trackers.py # W&B tracker (BaseTracker → WandbTracker)
│ │ ├── training_utils.py # Checkpointing, grad clipping, state dicts
│ │ ├── training_pipeline.py # Base training pipeline
@@ -65,6 +76,17 @@ FastVideo-WorldModel/
## Key Training Entrypoints
### New framework (`fastvideo/train/`) — preferred
| Method | Config Example | Launch Pattern |
|--------|---------------|----------------|
| FineTune (Wan) | `examples/train/finetune_wan2.1_t2v_1.3B_vsa_*.yaml` | `torchrun -m fastvideo.train.entrypoint.train --config <yaml>` |
| DFSFT (Wan causal) | `examples/train/dfsft_wan_causal_t2v_1.3B.yaml` | `torchrun -m fastvideo.train.entrypoint.train --config <yaml>` |
| DMD2 distillation | `examples/train/distill_wan2.1_t2v_1.3B_dmd2.yaml` | `torchrun -m fastvideo.train.entrypoint.train --config <yaml>` |
| Self-Forcing | `examples/train/self_forcing_wan_causal_t2v_1.3B.yaml` | `torchrun -m fastvideo.train.entrypoint.train --config <yaml>` |
### Legacy pipelines (`fastvideo/training/`) — being phased out
| Pipeline | Entrypoint | Launch Pattern |
|----------|-----------|----------------|
| Wan T2V finetune | `fastvideo/training/wan_training_pipeline.py` | `torchrun --nproc_per_node N` |
+1 -1
View File
@@ -280,7 +280,7 @@ A comprehensive benchmark examining **four critical capabilities**:
**Priority**: **Highest** — this is the most important evaluation signal
**Trust**: Highest — but expensive
#### What It Measures
### What It Measures
Human evaluators compare generated videos and rate them on dimensions like:
- Overall quality and realism
- Temporal coherence and smoothness
+4 -3
View File
@@ -22,9 +22,10 @@ Read these files to build your context:
|----------|------|----------------|
| 1 | `AGENTS.md` | Coding guidelines, build/test commands, PR conventions |
| 2 | `docs/design/overview.md` | Architecture: models, pipelines, configs, registry |
| 3 | `docs/training/overview.md` | Training data flow and preprocessing |
| 4 | `docs/training/finetune.md` | Training arguments, parallelism, LoRA, validation |
| 5 | `docs/contributing/coding_agents.md` | How to add model pipelines with agent assistance |
| 3 | `fastvideo/train/` | Refactored training framework (YAML-driven, modular methods/models/callbacks) |
| 4 | `docs/training/overview.md` | Training data flow and preprocessing |
| 5 | `docs/training/finetune.md` | Training arguments, parallelism, LoRA, validation |
| 6 | `docs/contributing/coding_agents.md` | How to add model pipelines with agent assistance |
## Step 2: Discover Available Resources
+199 -67
View File
@@ -17,8 +17,25 @@ auto-regressive streaming generation.
- Full finetuning and LoRA on Wan / LTX-2 / MatrixGame models
- DMD-based distillation (few-step generation)
- Self-Forcing distillation (causal streaming)
- Consistency finetuning with causal ODE initialization
- Action injection modules for interactive control
- Diffusion-Forcing SFT (DFSFT) for causal models
- VSA (Variable Sparsity Acceleration) for efficient training
---
## Training Code: Two Generations
### New modular framework: `fastvideo/train/` (preferred)
The refactored training code uses a **YAML-only config-driven** architecture
with composable methods, per-role models, and a callback system. All new
training work should use this framework.
### Legacy pipelines: `fastvideo/training/` (deprecated)
The old monolithic pipeline classes (`WanTrainingPipeline`,
`DistillationPipeline`, etc.) still exist but are being phased out. The new
framework imports select utilities from `fastvideo/training/` for backward
compatibility (EMA, gradient clipping, checkpoint wrappers).
---
@@ -29,54 +46,189 @@ Read these **in order** before touching any training code:
| # | File | What You Learn |
|---|------|----------------|
| 1 | `docs/training/overview.md` | Training data flow: raw video → text embeddings + video latents → training |
| 2 | `docs/training/finetune.md` | All training arguments, parallelism (SP/TP), LoRA, validation settings |
| 2 | `docs/training/finetune.md` | Training arguments, parallelism (SP/TP), LoRA, validation settings |
| 3 | `docs/training/data_preprocess.md` | How to preprocess datasets into the expected format |
| 4 | `docs/design/overview.md` | Architecture: models, pipelines, configs, registry |
---
## Training Pipelines
## New Training Framework (`fastvideo/train/`)
Each pipeline is a Python entrypoint launched via `torchrun`:
### Architecture Overview
| Pipeline | Entrypoint | Use Case |
|----------|-----------|----------|
| **Wan T2V finetune** | `fastvideo/training/wan_training_pipeline.py` | Standard text-to-video full finetune / LoRA |
| **Wan I2V finetune** | `fastvideo/training/wan_i2v_training_pipeline.py` | Image-to-video (condition on first frame) |
| **MatrixGame finetune** | `fastvideo/training/matrixgame_training_pipeline.py` | Action-conditioned world model training |
| **LTX-2 finetune** | `fastvideo/training/ltx2_training_pipeline.py` | LTX-2 architecture finetuning |
| **Wan DMD distillation** | `fastvideo/training/wan_distillation_pipeline.py` | Few-step distillation via DMD |
| **Self-Forcing distill** | `fastvideo/training/wan_self_forcing_distillation_pipeline.py` | Causal streaming distillation |
| **Base training** | `fastvideo/training/training_pipeline.py` | Base class — not called directly |
```
fastvideo/train/
├── __init__.py → exports Trainer
├── trainer.py → main training loop coordinator
├── entrypoint/
│ ├── train.py → YAML-only training entrypoint
│ └── dcp_to_diffusers.py → checkpoint conversion utility
├── methods/ → training algorithms (TrainingMethod ABC)
│ ├── base.py → TrainingMethod base class
│ ├── fine_tuning/
│ │ ├── finetune.py → FineTuneMethod (supervised finetuning)
│ │ └── dfsft.py → DiffusionForcingSFTMethod (causal)
│ ├── distribution_matching/
│ │ ├── dmd2.py → DMD2Method (distribution matching distill)
│ │ └── self_forcing.py → SelfForcingMethod (causal streaming)
│ ├── knowledge_distillation/ → (stub, not yet implemented)
│ └── consistency_model/ → (stub, not yet implemented)
├── models/ → per-role model instances
│ ├── base.py → ModelBase & CausalModelBase (ABC)
│ └── wan/
│ ├── wan.py → WanModel (non-causal)
│ └── wan_causal.py → WanCausalModel (causal streaming)
├── callbacks/ → training hooks & monitoring
│ ├── callback.py → Callback base class + CallbackDict
│ ├── grad_clip.py → GradNormClipCallback
│ ├── ema.py → EMACallback (shadow weights)
│ └── validation.py → ValidationCallback (sampling + eval)
└── utils/ → configuration, building, checkpointing
├── builder.py → build_from_config() (config → runtime)
├── checkpoint.py → CheckpointManager (DCP-based)
├── config.py → load_run_config() (YAML → RunConfig)
├── training_config.py → TypedConfig dataclasses
├── optimizer.py → build_optimizer_and_scheduler()
├── instantiate.py → resolve_target() + instantiate()
├── tracking.py → build_tracker() (W&B, etc.)
├── dataloader.py → dataloader utilities
├── module_state.py → apply_trainable()
└── moduleloader.py → load_module_from_path()
```
### Key Concepts
**TrainingMethod** (`methods/base.py`): Abstract base class for all training
algorithms. Owns role models (student, teacher, critic), manages checkpoint
state, and defines the training step interface.
**ModelBase** (`models/base.py`): Per-role model wrapper. Each role (student,
teacher, critic) gets its own `ModelBase` instance owning a `transformer` and
`noise_scheduler`. `CausalModelBase` extends this for streaming models.
**Callback system** (`callbacks/`): Composable hooks for gradient clipping,
EMA, validation, etc. Configured via YAML, dispatched by `CallbackDict`.
**Config system** (`utils/config.py`, `utils/training_config.py`): YAML files
are parsed into typed `RunConfig` dataclass trees. Models and methods use
`_target_` fields for instantiation (similar to Hydra).
### Training Flow
```
run_training_from_config(config_path)
→ load_run_config() # YAML → RunConfig
→ init_distributed() # TP/SP setup
→ build_from_config() # instantiate models, method, dataloader
→ Trainer.run() # main loop:
├─ callbacks.on_train_start()
├─ checkpoint_manager.maybe_resume()
├─ for step in range(max_steps):
│ ├─ method.single_train_step(batch)
│ ├─ method.backward()
│ ├─ callbacks.on_before_optimizer_step()
│ ├─ method.optimizers_schedulers_step()
│ ├─ tracker.log(metrics, step)
│ ├─ callbacks.on_training_step_end()
│ └─ checkpoint_manager.maybe_save(step)
├─ callbacks.on_train_end()
└─ checkpoint_manager.save_final()
```
### Training Methods
| Method | Class | Use Case |
|--------|-------|----------|
| **FineTune** | `FineTuneMethod` | Single-role supervised finetuning |
| **DFSFT** | `DiffusionForcingSFTMethod` | Diffusion-forcing SFT with inhomogeneous timesteps |
| **DMD2** | `DMD2Method` | Multi-role distribution matching distillation (student + teacher + critic) |
| **Self-Forcing** | `SelfForcingMethod` | Extends DMD2 for causal student rollouts |
### Launching Training (New Framework)
Training is launched via `torchrun` with a single YAML config:
```bash
torchrun --nproc_per_node <N_GPUS> \
-m fastvideo.train.entrypoint.train \
--config examples/train/<config>.yaml
```
### Example YAML Configs
| Config | Method | Description |
|--------|--------|-------------|
| `examples/train/finetune_wan2.1_t2v_1.3B_vsa_phase3.4_0.9sparsity.yaml` | FineTune | Wan 1.3B finetuning with VSA sparsity |
| `examples/train/distill_wan2.1_t2v_1.3B_dmd2.yaml` | DMD2 | Wan 1.3B distillation (student + teacher + critic) |
| `examples/train/dfsft_wan_causal_t2v_1.3B.yaml` | DFSFT | Causal Wan 1.3B diffusion-forcing SFT |
| `examples/train/self_forcing_wan_causal_t2v_1.3B.yaml` | Self-Forcing | Causal streaming distillation |
### Checkpointing (New Framework)
**CheckpointManager** (`utils/checkpoint.py`) saves via `torch.distributed.checkpoint`:
```
output_dir/
└─ checkpoint-{step}/
├─ dcp/ # DCP state dict
├─ config.json # resolved training config
└─ .fastvideo_metadata.json
```
Checkpoint state includes: role model weights, per-role optimizers/schedulers,
CUDA RNG state, and callback state (e.g., EMA shadow weights).
### Config Structure
A YAML config defines the full training pipeline:
```yaml
models:
student:
_target_: fastvideo.train.models.wan.WanModel
model_path: ...
trainable: true
teacher: # optional, for distillation
_target_: fastvideo.train.models.wan.WanModel
model_path: ...
trainable: false
method:
_target_: fastvideo.train.methods.fine_tuning.FineTuneMethod
# method-specific params...
training:
distributed: { num_gpus: 8, tp_size: 1, sp_size: 8 }
data: { data_path: ..., batch_size: 1 }
optimizer: { lr: 1e-5, lr_scheduler: constant_with_warmup }
loop: { max_train_steps: 1000 }
checkpoint: { output_dir: ./outputs }
tracker: { trackers: [wandb], project_name: ... }
callbacks:
grad_clip:
_target_: fastvideo.train.callbacks.GradNormClipCallback
max_grad_norm: 1.0
validation:
_target_: fastvideo.train.callbacks.ValidationCallback
validation_steps: 100
```
---
## Example Scripts
## Legacy Training Pipelines (`fastvideo/training/`)
Ready-to-run training launches. Use these as starting templates:
> **Note:** Use the new `fastvideo/train/` framework for new work. This section
> is retained for reference on existing pipelines not yet migrated.
### Finetuning
| Model | Script | Notes |
|-------|--------|-------|
| Wan T2V 1.3B | `examples/training/finetune/wan_t2v_1.3B/crush_smol/finetune_t2v.sh` | Smallest, fastest for testing |
| Wan T2V 1.3B LoRA | `examples/training/finetune/wan_t2v_1.3B/crush_smol/finetune_t2v_lora.sh` | Lightweight adapter |
| Wan I2V 14B | `examples/training/finetune/wan_i2v_14B_480p/crush_smol/finetune_i2v.sh` | Large I2V model |
| LTX-2 | `examples/training/finetune/ltx2/finetune_t2v.sh` | Alternative architecture |
| MatrixGame 2.0 | `examples/training/finetune/MatrixGame2.0/finetune_i2v.sh` | Action-conditioned |
### Distillation
| Method | Script | Notes |
|--------|--------|-------|
| DMD Wan | `scripts/distill/v1_distill_dmd_wan.sh` | Full distillation launch |
| DMD Wan + VSA | `scripts/distill/v1_distill_dmd_wan_VSA.sh` | With variable-step acceleration |
| Consistency (causal ODE init) | `examples/training/consistency_finetune/causal_ode_init/finetune_ode_init.sh` | Consistency tuning |
### Data Preprocessing
Each model directory includes a `preprocess_*` script. Always preprocess first:
```bash
# Example for Wan T2V
bash examples/training/finetune/wan_t2v_1.3B/crush_smol/preprocess_wan_data_t2v.sh
```
| Pipeline | Entrypoint | Use Case |
|----------|-----------|----------|
| Wan T2V finetune | `fastvideo/training/wan_training_pipeline.py` | Standard text-to-video finetune / LoRA |
| Wan I2V finetune | `fastvideo/training/wan_i2v_training_pipeline.py` | Image-to-video (first frame conditioned) |
| MatrixGame finetune | `fastvideo/training/matrixgame_training_pipeline.py` | Action-conditioned world model |
| LTX-2 finetune | `fastvideo/training/ltx2_training_pipeline.py` | LTX-2 architecture finetuning |
| Wan DMD distillation | `fastvideo/training/wan_distillation_pipeline.py` | Few-step distillation via DMD |
| Self-Forcing distill | `fastvideo/training/wan_self_forcing_distillation_pipeline.py` | Causal streaming distillation |
---
@@ -84,19 +236,12 @@ bash examples/training/finetune/wan_t2v_1.3B/crush_smol/preprocess_wan_data_t2v.
### W&B Integration
- **Tracker**: `fastvideo/training/trackers.py` — `WandbTracker` class
- **New framework tracker**: `fastvideo/train/utils/tracking.py` — `build_tracker()`
- **Env vars**: `WANDB_API_KEY`, `WANDB_BASE_URL`, `WANDB_MODE`
- **Summaries**: Saved to `<output_dir>/tracker/wandb/latest-run/files/wandb-summary.json`
- **Tests**: `fastvideo/tests/training/Vanilla/test_training_loss.py` (compares against reference summaries)
### Checkpointing
- **Save**: `fastvideo/training/training_utils.py:save_checkpoint()`
- **Load**: `fastvideo/training/training_utils.py:load_checkpoint()`
- **Distill save**: `training_utils.py:save_distillation_checkpoint()` (multi-model)
- **Format**: FSDP distributed checkpoint → converted to HF format
### Parallelism
- **SP** (Sequence Parallel): splits video frames across GPUs — `--sp_size N`
- **TP** (Tensor Parallel): splits model layers across GPUs — `--tp_size N`
- **SP** (Sequence Parallel): splits video frames across GPUs — `sp_size: N`
- **TP** (Tensor Parallel): splits model layers across GPUs — `tp_size: N`
- Typical configs: SP=2–8, TP=1–2
---
@@ -148,23 +293,10 @@ their own error accumulation during long auto-regressive generation.
### DMD Distillation (Distribution Matching Distillation)
Reduces inference steps from ~50 to 3–4 by training a student model to match
the output distribution of the teacher model. Uses ODE pairs collected from
the teacher model as training targets.
the output distribution of the teacher model. Uses a critic network to estimate
distribution divergence.
---
## Quick-Start: Minimal Training Test
To verify the training infrastructure works, run the smallest possible experiment:
```bash
# 1. Download crush_smol dataset
bash examples/training/finetune/wan_t2v_1.3B/crush_smol/download_dataset.sh
# 2. Preprocess
bash examples/training/finetune/wan_t2v_1.3B/crush_smol/preprocess_wan_data_t2v.sh
# 3. Short training run (5 steps)
# Edit finetune_t2v.sh: set --max_train_steps 5
bash examples/training/finetune/wan_t2v_1.3B/crush_smol/finetune_t2v.sh
```
### Diffusion-Forcing SFT (DFSFT)
Supervised finetuning with **inhomogeneous timesteps** across chunks — each
chunk in a causal sequence can have a different noise level, training the model
to handle mixed-fidelity contexts.
+2
View File
@@ -28,6 +28,7 @@ description: <one-line description — Codex uses this for implicit invocation m
- <What this skill produces>
## Example Usage
```
<Example invocation or prompt snippet>
```
@@ -50,6 +51,7 @@ Each skill lives in its own directory under `.agents/skills/`:
```
After creating a new skill, add an entry to `.agents/skills/index.jsonl`:
```json
{"name": "<skill-name>", "description": "<description>", "path": "<skill-name>/SKILL.md", "status": "draft", "trust": "low"}
```
@@ -38,6 +38,7 @@ pytest fastvideo/tests/ssim/ -vs --video-path <generated> --reference-path <refe
```
Or use the SSIM utility directly:
```python
from fastvideo.tests.ssim.ssim_utils import compute_ssim
score = compute_ssim(generated_video, reference_video)
@@ -76,6 +76,7 @@ If not, create it:
- Updated catalog.
## Example Usage
```
Index the Self-Forcing paper:
@@ -88,6 +88,7 @@ torchrun --nnodes 1 --nproc_per_node <num_gpus> \
### 5. Log to experiment journal
After launching, append an entry to `.agents/memory/experiment-journal/README.md`:
```markdown
## [YYYY-MM-DD] Experiment: <run_name>
- **Hypothesis**: <user-provided or auto-generated>
@@ -101,6 +102,7 @@ After launching, append an entry to `.agents/memory/experiment-journal/README.md
- An experiment journal entry.
## Example Usage
```
Launch a Wan T2V 1.3B finetune on 4 GPUs with lr=5e-5 and max_train_steps=1000:
+1
View File
@@ -63,6 +63,7 @@ may be a repeat. If it's `status: running`, assume this is an update.
- Updated `.agents/memory/experiment-journal/README.md`.
## Example Usage
```
Log a completed experiment:
@@ -32,6 +32,7 @@ report before committing more resources.
### 1. Connect to the run
**Online mode** (preferred):
```python
import wandb
api = wandb.Api()
@@ -39,6 +40,7 @@ run = api.run("<run_id>")
```
**Offline fallback**:
```python
import json
summary_path = f"{output_dir}/tracker/wandb/latest-run/files/wandb-summary.json"
@@ -70,6 +72,7 @@ with open(summary_path) as f:
### 4. Emit structured status
Output format (agent-consumable):
```json
{
"run_id": "...",
@@ -111,6 +114,7 @@ After the first 30 minutes of wall-clock time:
- 30-minute checkpoint quality report.
## Example Usage
```
Monitor W&B run "fastvideo/Wan_distillation/abc123":
@@ -65,6 +65,7 @@ For each matching file:
- Ranked list of relevant related work entries with excerpts.
## Example Usage
```
Search for work related to video quality evaluation metrics:
+3
View File
@@ -31,6 +31,7 @@ online (W&B API) and offline (local `wandb-summary.json`) modes.
### 1. Load run summary
**Online**:
```python
import wandb
api = wandb.Api()
@@ -40,6 +41,7 @@ config = dict(run.config)
```
**Offline** (existing codebase pattern from `fastvideo/tests/training/`):
```python
import json
summary_path = f"{output_dir}/tracker/wandb/latest-run/files/wandb-summary.json"
@@ -114,6 +116,7 @@ with the final metrics and status.
- Updated experiment journal entry.
## Example Usage
```
Summarize the run in output directory "outputs/wan_finetune":
+369
View File
@@ -0,0 +1,369 @@
# Training Architecture
!!! warning "Work in Progress"
This training architecture (`fastvideo/train/`) is under active development
and is replacing the older `fastvideo/training/` module. APIs, config
formats, and supported methods may change. See the
[Current Status](#current-status) section for what is implemented so far.
FastVideo's training framework (`fastvideo/train/`) is built around a
**pluggable, YAML-driven architecture** that cleanly separates **models**,
**training methods**, and **infrastructure** into independent, composable
layers. A single YAML config file is all that is needed to train any supported
model with any supported algorithm — no code changes required to mix and match.
---
## Motivation
Training video diffusion models involves a tangle of concerns: model loading,
noise scheduling, distillation algorithms, distributed strategies,
checkpointing, and validation. Existing training scripts tend to hard-wire
these together, making it painful to:
1. **Try a new distillation algorithm** on an existing model (requires forking
the training loop).
2. **Add a new model** to an existing algorithm (requires re-implementing
boilerplate).
3. **Switch distributed strategies** (FSDP, TP, SP) without touching algorithm
code.
4. **Resume, checkpoint, and validate** uniformly across all combinations.
The training framework solves this by making each axis of variation an
independent plugin.
---
## Architecture Overview
```
YAML Config
|
v
+------------------+ +---------------------+ +------------------+
| Models Layer | | Methods Layer | | Infrastructure |
| (per-role) | | (algorithm) | | Layer |
| | | | | |
| - ModelBase |<----| - TrainingMethod |---->| - Trainer |
| - CausalModelBase| | - single_train_step| | - Callbacks |
| | | - backward | | - Checkpoint |
| Roles: | | - optimizers | | - Tracker (W&B) |
| student | | | | - Dataloader |
| teacher | | Algorithms: | | |
| critic | | DMD2, SelfForcing, | | Distributed: |
| | | SFT, DFSFT | | HSDP, TP, SP |
+------------------+ +---------------------+ +------------------+
```
### Three Layers
| Layer | Responsibility | Extension point |
|-------|---------------|-----------------|
| **Models** (`fastvideo/train/models/`) | Load transformer + scheduler, define `predict_noise`, `predict_x0`, `add_noise`, `backward`. Each training role (student/teacher/critic) is an independent instance. | Subclass `ModelBase` (or `CausalModelBase` for streaming). |
| **Methods** (`fastvideo/train/methods/`) | Implement the training algorithm: own role models, define `single_train_step` + `backward`, manage optimizers/schedulers. | Subclass `TrainingMethod`. |
| **Infrastructure** (`fastvideo/train/trainer.py`, `utils/`, `callbacks/`) | Training loop, gradient accumulation, distributed setup, checkpointing (DCP), W&B tracking, validation, EMA, grad clipping. | Add callbacks; everything else is shared. |
---
## YAML-Driven Configuration
Everything is configured declaratively. The `_target_` field selects the Python
class to instantiate:
```yaml
models:
student:
_target_: fastvideo.train.models.wan.WanModel
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
trainable: true
teacher:
_target_: fastvideo.train.models.wan.WanModel
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
trainable: false
critic:
_target_: fastvideo.train.models.wan.WanModel
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
trainable: true
method:
_target_: fastvideo.train.methods.distribution_matching.dmd2.DMD2Method
rollout_mode: simulate
dmd_denoising_steps: [1000, 850, 700, 550, 350, 275, 200, 125]
generator_update_interval: 5
real_score_guidance_scale: 3.5
# ...
training:
distributed: { num_gpus: 8, sp_size: 1, tp_size: 1 }
data: { data_path: ..., num_latent_t: 20, num_frames: 77 }
optimizer: { learning_rate: 2.0e-6, betas: [0.0, 0.999] }
loop: { max_train_steps: 4000 }
checkpoint: { output_dir: outputs/my_run }
callbacks:
grad_clip: { max_grad_norm: 1.0 }
validation: { pipeline_target: ..., every_steps: 100 }
```
To switch from DMD2 to SFT, change the `method._target_` and remove the
teacher/critic — no code changes needed.
---
## Model Abstraction
### `ModelBase` — Standard (Bidirectional) Models
Every role gets its own `ModelBase` instance owning a `transformer` and
`noise_scheduler`. The base class defines:
- **`prepare_batch()`** — Convert raw dataloader output into forward-ready
`TrainingBatch`.
- **`add_noise()`** — Apply forward-process noise at a given timestep.
- **`predict_noise()` / `predict_x0()`** — Run the transformer and return
predictions.
- **`backward()`** — Backward pass that restores forward context (attention
metadata, timesteps).
- **`init_preprocessors()`** — Lazy-load VAE, build dataloader (called only on
the student).
### `CausalModelBase` — Streaming / Causal Models
Extends `ModelBase` with streaming inference primitives for causal video
generation:
```python
class CausalModelBase(ModelBase):
def clear_caches(self, *, cache_tag: str = "pos") -> None: ...
def predict_noise_streaming(
self, ..., cache_tag, store_kv, cur_start_frame
) -> Tensor | None: ...
def predict_x0_streaming(
self, ..., cache_tag, store_kv, cur_start_frame
) -> Tensor | None: ...
```
KV caches are **internal** to the model instance, keyed by `cache_tag`. The
method controls when to store (`store_kv=True`) vs. read-only
(`store_kv=False`), enabling block-by-block causal rollout during training.
---
## Training Methods
### DMD2 (Distribution Matching Distillation)
**Roles:** student (trainable) + teacher (frozen) + critic (trainable)
The student learns to generate clean video in few steps by matching the
teacher's score function, with a critic network providing a learned fake-score
baseline.
- **Rollout modes:**
- `simulate` — Student starts from pure noise and iteratively denoises
through the full step schedule.
- `data_latent` — Student denoises from a single randomly-noised data
sample.
- **Losses:** Generator loss (DMD gradient) + critic flow-matching loss, with
alternating updates (`generator_update_interval`).
### Self-Forcing (Causal DMD)
**Roles:** student (causal, trainable) + teacher (frozen) + critic (trainable)
Extends DMD2 for **streaming/causal video generation**. The key idea: during
training, the student processes video in temporal chunks, using its own
previously-denoised outputs as context for future chunks — simulating online
autoregressive rollout.
- Video is split into blocks of `chunk_size` latent frames.
- Each block is denoised through the student's step schedule; a random
early-exit step is sampled per block.
- After denoising a block, its output is fed back (with optional
`context_noise`) as KV cache context for subsequent blocks via
`predict_noise_streaming(store_kv=True)`.
- Supports SDE and ODE sampling during rollout.
- Selective gradient control: `enable_gradient_in_rollout`,
`start_gradient_frame`.
### Supervised Fine-Tuning (SFT)
**Roles:** student only
Standard flow-matching loss between predicted and ground-truth noise/x0.
### Diffusion-Forcing SFT (DFSFT)
**Roles:** student only
SFT with **inhomogeneous (per-chunk) timesteps** — each temporal chunk in a
video gets a different noise level. This trains the model to handle mixed-noise
inputs, which is a prerequisite for causal/streaming inference where earlier
frames are cleaner than later ones.
---
## Training Loop
The `Trainer` runs a standard loop with pluggable method and callbacks:
```
for step in range(start_step, max_steps):
for accum_iter in range(grad_accum_steps):
batch <- dataloader
loss_map, outputs, metrics <- method.single_train_step(batch, step)
method.backward(loss_map, outputs)
callbacks.on_before_optimizer_step() # grad clipping
method.optimizers_schedulers_step()
method.optimizers_zero_grad()
callbacks.on_training_step_end() # logging
checkpoint_manager.maybe_save(step)
callbacks.on_validation_begin() # periodic inference
```
### Callbacks
- **GradNormClipCallback** — Per-module gradient norm logging + global
clipping.
- **ValidationCallback** — Periodic inference sampling with configurable
pipeline, sampling steps, and guidance scale.
- **EMACallback** — Exponential moving average of student weights.
### Checkpointing
- DCP (Distributed Checkpoint) format, compatible with FSDP/HSDP.
- Saves: model weights, optimizer states, scheduler states, RNG states (per
role).
- Full resume support: auto-restores step counter and all RNG states.
---
## Getting Started
```bash
# Install
uv pip install -e .[dev]
# Run DMD2 distillation on Wan 2.1
torchrun --nproc_per_node=8 -m fastvideo.train.entrypoint.train \
--config examples/train/distill_wan2.1_t2v_1.3B_dmd2.yaml
# Run SFT fine-tuning
torchrun --nproc_per_node=8 -m fastvideo.train.entrypoint.train \
--config examples/train/finetune_wan2.1_t2v_1.3B_vsa_phase3.4_0.9sparsity.yaml
```
Example configs are in `examples/train/`.
---
## File Structure
```
fastvideo/train/
trainer.py # Training loop
models/
base.py # ModelBase, CausalModelBase ABCs
wan/wan.py # Wan 2.1 T2V model plugin
wangame/wangame.py # WanGame 2.1 I2V model plugin
wangame/wangame_causal.py # WanGame causal (streaming) plugin
methods/
base.py # TrainingMethod ABC
distribution_matching/
dmd2.py # DMD2 distillation
self_forcing.py # Self-Forcing (causal DMD)
fine_tuning/
finetune.py # Supervised fine-tuning
dfsft.py # Diffusion-forcing SFT
callbacks/
grad_clip.py # Gradient clipping + norm logging
validation.py # Periodic inference validation
ema.py # EMA weight averaging
entrypoint/
train.py # CLI entrypoint (torchrun)
utils/
config.py # YAML parser -> RunConfig
builder.py # build_from_config: model/method instantiation
training_config.py # TrainingConfig dataclass
dataloader.py # Dataset/dataloader construction
optimizer.py # Optimizer/scheduler construction
checkpoint.py # DCP save/resume
tracking.py # W&B tracker
```
---
## Current Status
| Component | Status |
|-----------|--------|
| Core framework (trainer, config, callbacks) | Implemented and tested |
| `WanModel` (Wan 2.1 T2V) | Implemented and tested |
| `WanGameModel` (WanGame 2.1 I2V) | Implemented and tested |
| `WanGameCausalModel` (streaming) | Implemented and tested |
| `WanCausalModel` (Wan T2V causal) | In progress |
| DMD2 method | Implemented and tested |
| Self-Forcing method | Implemented and tested |
| SFT method | Implemented and tested |
| DFSFT method | Implemented and tested |
| DCP checkpointing + resume | Implemented and tested |
| EMA callback | Implemented |
| Validation callback | Implemented and tested |
| Causal DMD inference pipeline | Implemented |
---
## Open Questions
We welcome community feedback on the following topics:
### Model Plugin API
The current `ModelBase` interface requires implementing 6 methods. Is this the
right granularity?
- Should `prepare_batch` be split into separate concerns (noise sampling,
timestep sampling, attention metadata)?
- Should `backward` be lifted out of the model and into the method/trainer?
### Causal Streaming Interface
`CausalModelBase` adds `predict_noise_streaming` / `predict_x0_streaming` with
cache management. Alternatives considered:
- **(a) Current:** Cache is internal to the model, keyed by `cache_tag`.
Simple but couples cache lifecycle to model.
- **(b) External cache:** Method owns the cache dict, passes it into predict
calls. More explicit but verbose.
- **(c) Context manager:** `with model.streaming_context(tag) as ctx: ...` —
cleaner lifecycle but harder to compose.
### Method Composition
Currently, methods are monolithic classes. Should we support composing methods
(e.g., DFSFT pre-training followed by Self-Forcing distillation) within a
single config? Or is sequential training with checkpoint handoff sufficient?
### New Models and Methods
What models and training methods should we prioritize next?
- **Models:** HunyuanVideo, CogVideoX, other Wan variants?
- **Methods:** Consistency models, progressive distillation, reward-based
fine-tuning?
### Distributed Strategy
Currently supports HSDP (hybrid sharded data parallel) + TP + SP. Are there
scenarios where the current distributed setup is insufficient? Should we add
pipeline parallelism for very large models?
---
## References
- [Self-Forcing paper](https://arxiv.org/abs/2406.05477) — Chen et al., 2024.
- [DMD2 paper](https://arxiv.org/abs/2405.14867) — Yin et al., 2024.
- [Diffusion Forcing paper](https://arxiv.org/abs/2407.01392) — Chen et al.,
2024.
+595
View File
@@ -0,0 +1,595 @@
# Training Infrastructure
FastVideo's training infrastructure (`fastvideo/train/`) is a YAML-driven
framework for training and distilling video diffusion models. A single config
file controls everything — models, algorithms, distributed strategy,
checkpointing, and validation — with no code changes needed to mix and match.
!!! note "Relationship to legacy training"
This system replaces the older script-based training in `fastvideo/training/`.
The legacy scripts still work for basic fine-tuning, but new development
should use the config-driven system documented here.
---
## Quick Start
### Launch with the helper script
```bash
bash examples/train/run.sh examples/train/distill_wan2.1_t2v_1.3B_dmd2.yaml
```
The script auto-detects available GPUs and sets up `torchrun`. Override with
environment variables:
```bash
NUM_GPUS=4 NNODES=2 NODE_RANK=0 \
MASTER_ADDR=10.0.0.1 MASTER_PORT=29501 \
bash examples/train/run.sh my_config.yaml
```
### Launch directly with torchrun
```bash
torchrun --nproc_per_node=8 \
fastvideo/train/entrypoint/train.py \
--config examples/train/distill_wan2.1_t2v_1.3B_dmd2.yaml
```
### CLI flags
| Flag | Description |
|------|-------------|
| `--config` | Path to YAML config file (required) |
| `--resume-from-checkpoint` | Path to a DCP checkpoint directory to resume from |
| `--override-output-dir` | Override `training.checkpoint.output_dir` |
| `--dry-run` | Validate config and exit without training |
---
## Config Format
Every run is defined by a single YAML file with five top-level sections.
See `examples/train/example.yaml` for a fully-commented reference.
### `models` — Role-based model instances
Each entry defines a model role. The `_target_` field specifies the Python class
to instantiate:
```yaml
models:
student:
_target_: fastvideo.train.models.wan.WanModel
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
trainable: true
teacher:
_target_: fastvideo.train.models.wan.WanModel
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
trainable: false
disable_custom_init_weights: true
```
Common model parameters:
| Parameter | Default | Description |
|-----------|---------|-------------|
| `_target_` | *(required)* | Python class path for the model |
| `init_from` | *(required)* | HuggingFace repo ID or local checkpoint path |
| `trainable` | `true` | Whether the model's parameters require gradients |
| `disable_custom_init_weights` | `false` | Skip custom weight initialization (use for teacher/critic) |
| `flow_shift` | `3.0` | Timestep shifting factor |
| `enable_gradient_checkpointing_type` | `null` | Gradient checkpointing (`"full"` or `null`) |
Which roles are needed depends on the training method:
| Method | Required roles |
|--------|---------------|
| Fine-tune (SFT) | `student` |
| Diffusion-Forcing SFT | `student` |
| DMD2 | `student`, `teacher`, `critic` |
| Self-Forcing | `student` (causal), `teacher`, `critic` |
### `method` — Training algorithm
Selects and configures the training algorithm:
```yaml
method:
_target_: fastvideo.train.methods.distribution_matching.dmd2.DMD2Method
rollout_mode: simulate
dmd_denoising_steps: [1000, 750, 500, 250]
generator_update_interval: 5
```
To switch algorithms, change `_target_` and adjust the method-specific keys.
See [Training Methods](#training-methods) for details on each algorithm.
### `training` — Typed infrastructure config
This section maps to typed dataclasses with defaults and validation:
```yaml
training:
distributed:
num_gpus: 8
sp_size: 1 # sequence parallelism
tp_size: 1 # tensor parallelism
hsdp_replicate_dim: 1 # HSDP replication dimension
hsdp_shard_dim: 8 # HSDP sharding dimension
data:
data_path: data/my_dataset
train_batch_size: 1
dataloader_num_workers: 4
training_cfg_rate: 0.1 # classifier-free guidance dropout rate
seed: 1000
num_latent_t: 20
num_height: 448
num_width: 832
num_frames: 77
optimizer:
learning_rate: 2.0e-6
betas: [0.9, 0.999]
weight_decay: 0.01
lr_scheduler: constant # constant, linear, cosine, polynomial
lr_warmup_steps: 0
loop:
max_train_steps: 4000
gradient_accumulation_steps: 1
checkpoint:
output_dir: outputs/my_run
training_state_checkpointing_steps: 1000 # 0 = disabled
checkpoints_total_limit: 3 # 0 = keep all
tracker:
project_name: my_project
run_name: my_run
model:
weighting_scheme: uniform # uniform, logit_normal, mode
precondition_outputs: false
enable_gradient_checkpointing_type: full
vsa:
sparsity: 0.0 # 0.0 = disabled
decay_rate: 0.0
decay_interval_steps: 0
```
### `callbacks` — Pluggable hooks
Callbacks run at specific points in the training loop (before/after optimizer
steps, at validation time, etc.):
```yaml
callbacks:
grad_clip:
max_grad_norm: 1.0
ema:
_target_: fastvideo.train.callbacks.ema.EMACallback
decay: 0.9999
start_iter: 0
validation:
_target_: fastvideo.train.callbacks.validation.ValidationCallback
pipeline_target: fastvideo.pipelines.basic.wan.wan_pipeline.WanPipeline
dataset_file: path/to/validation.json
every_steps: 100
sampling_steps: [4]
guidance_scale: 5.0
```
See [Callbacks](#callbacks) for details on each callback.
### `pipeline` — Inference pipeline overrides
Optional overrides for the inference pipeline used during validation:
```yaml
pipeline:
flow_shift: 8
```
---
## Training Methods
### Supervised Fine-Tuning (SFT)
Standard flow-matching loss. The simplest method — train the student to predict
noise (or clean x0) from noised data samples.
```yaml
models:
student:
_target_: fastvideo.train.models.wan.WanModel
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
trainable: true
method:
_target_: fastvideo.train.methods.fine_tuning.finetune.FineTuneMethod
attn_kind: dense # "dense" or "vsa"
```
| Parameter | Default | Description |
|-----------|---------|-------------|
| `attn_kind` | `"dense"` | Attention mode: `"dense"` (standard) or `"vsa"` (sparse) |
### Diffusion-Forcing SFT (DFSFT)
SFT with **per-chunk inhomogeneous timesteps** — each temporal chunk of the
video gets a different noise level. This is a prerequisite for training causal /
streaming models that must handle mixed-noise inputs.
```yaml
method:
_target_: fastvideo.train.methods.fine_tuning.dfsft.DiffusionForcingSFTMethod
chunk_size: 3
min_timestep_ratio: 0.0
max_timestep_ratio: 1.0
attn_kind: dense
```
| Parameter | Default | Description |
|-----------|---------|-------------|
| `chunk_size` | `3` | Latent frames per temporal chunk |
| `min_timestep_ratio` | `0.0` | Lower bound of timestep sampling range |
| `max_timestep_ratio` | `1.0` | Upper bound of timestep sampling range |
| `attn_kind` | `"dense"` | `"dense"` or `"vsa"` |
### DMD2 (Distribution Matching Distillation)
Distill a many-step teacher into a few-step student. The student learns to match
the teacher's score function, guided by a trainable critic network.
```yaml
models:
student:
_target_: fastvideo.train.models.wan.WanModel
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
trainable: true
teacher:
_target_: fastvideo.train.models.wan.WanModel
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
trainable: false
disable_custom_init_weights: true
critic:
_target_: fastvideo.train.models.wan.WanModel
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
trainable: true
disable_custom_init_weights: true
method:
_target_: fastvideo.train.methods.distribution_matching.dmd2.DMD2Method
rollout_mode: simulate
dmd_denoising_steps: [1000, 750, 500, 250]
generator_update_interval: 5
real_score_guidance_scale: 4.5
fake_score_learning_rate: 8.0e-6
fake_score_betas: [0.0, 0.999]
fake_score_lr_scheduler: constant
```
| Parameter | Default | Description |
|-----------|---------|-------------|
| `rollout_mode` | *(required)* | `"simulate"` (pure noise) or `"data_latent"` (from data) |
| `dmd_denoising_steps` | *(required)* | Timestep schedule for student rollout |
| `generator_update_interval` | `1` | Update student every N critic steps |
| `real_score_guidance_scale` | `1.0` | CFG scale for teacher predictions |
| `fake_score_learning_rate` | *(required)* | Critic optimizer learning rate |
| `fake_score_betas` | *(required)* | Critic optimizer Adam betas |
| `fake_score_lr_scheduler` | *(required)* | Critic LR scheduler type |
### Self-Forcing (Causal DMD)
Extends DMD2 for **streaming / causal video generation**. The student processes
video in temporal chunks, feeding its own denoised outputs as context for future
chunks — simulating autoregressive rollout during training.
Requires a causal model class (e.g., `WanCausalModel`) for the student:
```yaml
models:
student:
_target_: fastvideo.train.models.wan.wan_causal.WanCausalModel
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
trainable: true
method:
_target_: fastvideo.train.methods.distribution_matching.self_forcing.SelfForcingMethod
rollout_mode: simulate
dmd_denoising_steps: [1000, 750, 500, 250]
student_sample_type: sde
context_noise: 0.0
enable_gradient_in_rollout: true
start_gradient_frame: 0
```
Self-Forcing inherits all DMD2 parameters, plus:
| Parameter | Default | Description |
|-----------|---------|-------------|
| `student_sample_type` | `"sde"` | `"sde"` or `"ode"` for intermediate steps |
| `same_step_across_blocks` | `false` | Use same exit timestep for all blocks |
| `last_step_only` | `false` | Always exit at the final denoising step |
| `context_noise` | `0.0` | Noise added to context frames (0 = clean) |
| `enable_gradient_in_rollout` | `true` | Enable backprop through rollout |
| `start_gradient_frame` | `0` | Frame index where gradients begin |
---
## Callbacks
Callbacks are pluggable hooks that run at specific points in the training loop.
Configure them under the `callbacks` section.
### GradNormClipCallback
Clips gradient norms before the optimizer step. Optionally logs per-module
gradient norms to the tracker.
```yaml
callbacks:
grad_clip:
max_grad_norm: 1.0 # 0.0 = disabled
log_grad_norms: false
```
### EMACallback
Maintains an exponential moving average of the student's weights. The EMA
weights are automatically swapped in during validation.
```yaml
callbacks:
ema:
_target_: fastvideo.train.callbacks.ema.EMACallback
decay: 0.9999
start_iter: 0 # delay EMA updates until this iteration
```
The EMA callback owns its own state and checkpoints independently — EMA weights
are saved and restored automatically on resume.
### ValidationCallback
Runs inference with the trained model at regular intervals, saving generated
videos and logging them to the tracker (W&B).
```yaml
callbacks:
validation:
_target_: fastvideo.train.callbacks.validation.ValidationCallback
pipeline_target: fastvideo.pipelines.basic.wan.wan_pipeline.WanPipeline
dataset_file: path/to/validation.json
every_steps: 100
sampling_steps: [4]
sampling_timesteps: [1000, 750, 500, 250] # explicit timestep list
guidance_scale: 5.0
rollout_mode: parallel # "parallel" or "streaming"
```
The validation dataset is a JSON file containing a list of prompt strings.
If EMA is enabled, validation automatically uses the EMA weights.
---
## Checkpointing and Resume
### Checkpoint format
Checkpoints use PyTorch Distributed Checkpoint (DCP) format, compatible with
FSDP/HSDP sharding. Each checkpoint saves:
- Model weights (all roles)
- Optimizer states (all roles)
- LR scheduler states
- RNG states (for exact reproducibility)
- EMA shadow weights (if enabled)
- Training step counter
Checkpoints are saved to `<output_dir>/checkpoint-<step>/`.
### Saving checkpoints
```yaml
training:
checkpoint:
output_dir: outputs/my_run
training_state_checkpointing_steps: 1000 # save every N steps (0 = off)
checkpoints_total_limit: 3 # rolling window (0 = keep all)
```
### Resuming training
Use `--resume-from-checkpoint` to resume from a specific checkpoint:
```bash
# Via the helper script
bash examples/train/run.sh my_config.yaml --resume outputs/my_run/checkpoint-2000
# Via torchrun directly
torchrun --nproc_per_node=8 \
fastvideo/train/entrypoint/train.py \
--config my_config.yaml \
--resume-from-checkpoint outputs/my_run/checkpoint-2000
```
Or set it in the YAML:
```yaml
training:
checkpoint:
resume_from_checkpoint: outputs/my_run/checkpoint-2000
```
### Reproducibility
The training entrypoint enables deterministic mode automatically:
- `torch.backends.cudnn.benchmark = False`
- `torch.backends.cudnn.deterministic = True`
- `torch.use_deterministic_algorithms(True)`
A shared CUDA RNG generator is seeded from `training.data.seed` and threaded
through all random operations (noise sampling, timestep sampling, etc.).
Ranks within the same sequence-parallel group share a seed, ensuring identical
noise across SP shards.
---
## Distributed Training
The framework supports HSDP (Hybrid Sharded Data Parallel), Tensor Parallelism
(TP), and Sequence Parallelism (SP):
```yaml
training:
distributed:
num_gpus: 8
sp_size: 1 # sequence parallelism group size
tp_size: 1 # tensor parallelism group size
hsdp_replicate_dim: 1 # number of HSDP replicas
hsdp_shard_dim: 8 # number of HSDP shards
```
**HSDP** shards model parameters across `hsdp_shard_dim` GPUs and replicates
across `hsdp_replicate_dim` groups. The product
`hsdp_replicate_dim * hsdp_shard_dim` should equal `num_gpus`.
**Sequence parallelism** splits the sequence (video frames) across `sp_size`
GPUs within each data-parallel group. Useful for long videos that don't fit on a
single GPU.
---
## VSA (Variable Sparse Attention)
VSA progressively increases attention sparsity during training, reducing compute
while maintaining quality:
```yaml
training:
vsa:
sparsity: 0.9 # target sparsity level
decay_rate: 0.03 # sparsity increment per decay interval
decay_interval_steps: 1 # steps between sparsity increases
```
The effective sparsity at step `t` is
`min(sparsity, decay_rate * (t // decay_interval_steps))`.
---
## Extending the Framework
### Adding a new model
1. Create a new module under `fastvideo/train/models/` (e.g.,
`fastvideo/train/models/mymodel/mymodel.py`).
2. Subclass `ModelBase` (or `CausalModelBase` for streaming models).
3. Implement the required methods:
- `prepare_batch()` — convert raw dataloader output to `TrainingBatch`
- `add_noise()` — forward-process noise addition
- `predict_noise()` — run the transformer forward pass
- `backward()` — backward pass with forward context restoration
4. Reference it in your YAML config:
```yaml
models:
student:
_target_: fastvideo.train.models.mymodel.mymodel.MyModel
init_from: my-org/my-model
trainable: true
```
### Adding a new training method
1. Create a new module under `fastvideo/train/methods/`.
2. Subclass `TrainingMethod`.
3. Implement the required methods:
- `single_train_step()` — one forward pass returning losses, outputs, metrics
- `get_optimizers()` — return optimizer list
- `get_lr_schedulers()` — return scheduler list
4. Reference it in your config:
```yaml
method:
_target_: fastvideo.train.methods.my_method.MyMethod
my_param: 42
```
Method-specific parameters are accessible via `self.method_config` (a plain
dict).
### Adding a new callback
1. Create a new module under `fastvideo/train/callbacks/`.
2. Subclass `Callback`.
3. Override the hooks you need: `on_train_start`, `on_training_step_end`,
`on_before_optimizer_step`, etc.
4. Optionally implement `state_dict()` / `load_state_dict()` for checkpoint
persistence.
5. Add it to your config:
```yaml
callbacks:
my_callback:
_target_: fastvideo.train.callbacks.my_callback.MyCallback
my_param: 42
```
---
## File Structure
```
fastvideo/train/
entrypoint/
train.py # CLI entrypoint (torchrun)
trainer.py # Training loop orchestrator
models/
base.py # ModelBase, CausalModelBase ABCs
wan/
wan.py # Wan 2.1 T2V model
wan_causal.py # Wan causal (streaming) model
methods/
base.py # TrainingMethod ABC
distribution_matching/
dmd2.py # DMD2 distillation
self_forcing.py # Self-Forcing (causal DMD)
fine_tuning/
finetune.py # Supervised fine-tuning
dfsft.py # Diffusion-forcing SFT
callbacks/
callback.py # Callback ABC and CallbackDict
grad_clip.py # Gradient clipping + norm logging
ema.py # EMA weight averaging
validation.py # Periodic inference validation
utils/
config.py # YAML parser -> RunConfig
training_config.py # Typed config dataclasses
builder.py # Model/method instantiation
optimizer.py # Optimizer/scheduler construction
checkpoint.py # DCP save/resume
dataloader.py # Dataset/dataloader construction
tracking.py # W&B tracker
```
---
## Related Docs
- [Training Architecture](../design/training_architecture.md) — design
rationale, model/method abstractions, and open questions.
- [Training Overview](overview.md) — data requirements and preprocessing.
- [Data Preprocessing](data_preprocess.md) — how to prepare datasets.
- [Config Reference](../../examples/train/example.yaml) — fully-commented
YAML config with all fields and defaults.
@@ -0,0 +1,71 @@
# DFSFT (Diffusion-Forcing SFT): Wan 2.1 T2V 1.3B Causal
#
# - Student: trainable causal Wan model
# - Training: inhomogeneous timesteps per chunk (diffusion forcing)
models:
student:
_target_: fastvideo.train.models.wan.WanCausalModel
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
trainable: true
method:
_target_: fastvideo.train.methods.fine_tuning.dfsft.DiffusionForcingSFTMethod
attn_kind: dense
chunk_size: 3
training:
distributed:
num_gpus: 8
sp_size: 1
tp_size: 1
hsdp_replicate_dim: 1
hsdp_shard_dim: 8
data:
data_path: data/Wan-Syn_77x448x832_600k
dataloader_num_workers: 4
train_batch_size: 1
training_cfg_rate: 0.0
seed: 1000
num_latent_t: 20
num_height: 448
num_width: 832
num_frames: 77
optimizer:
learning_rate: 2.0e-6
betas: [0.9, 0.999]
weight_decay: 0.01
lr_scheduler: constant
lr_warmup_steps: 0
loop:
max_train_steps: 4000
gradient_accumulation_steps: 1
checkpoint:
output_dir: outputs/wan2.1_causal_dfsft
training_state_checkpointing_steps: 1000
checkpoints_total_limit: 3
tracker:
project_name: distillation_wan_r
run_name: wan2.1_causal_dfsft
model:
enable_gradient_checkpointing_type: full
callbacks:
grad_clip:
max_grad_norm: 1.0
validation:
pipeline_target: fastvideo.pipelines.basic.wan.wan_causal_pipeline.WanCausalPipeline
dataset_file: examples/training/finetune/Wan2.1-VSA/Wan-Syn-Data/validation_64.json
every_steps: 50
sampling_steps: [40]
guidance_scale: 6.0
num_frames: 69
pipeline:
flow_shift: 8
@@ -0,0 +1,94 @@
# DMD2 distillation: Wan 2.1 T2V 1.3B (teacher 50-step -> student 4-step).
#
# - Teacher: frozen pretrained Wan 2.1 T2V 1.3B
# - Student: trainable, initialized from the same pretrained weights
# - Critic: trainable, initialized from the same pretrained weights
# - Validation: 4-step SDE sampling
models:
student:
_target_: fastvideo.train.models.wan.WanModel
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
trainable: true
teacher:
_target_: fastvideo.train.models.wan.WanModel
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
trainable: false
disable_custom_init_weights: true
critic:
_target_: fastvideo.train.models.wan.WanModel
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
trainable: true
disable_custom_init_weights: true
method:
_target_: fastvideo.train.methods.distribution_matching.dmd2.DMD2Method
rollout_mode: simulate
generator_update_interval: 5
real_score_guidance_scale: 4.5
dmd_denoising_steps: [1000, 750, 500, 250]
# Critic optimizer (required — no fallback to training.optimizer)
fake_score_learning_rate: 8.0e-6
fake_score_betas: [0.0, 0.999]
fake_score_lr_scheduler: constant
training:
distributed:
num_gpus: 4
sp_size: 1
tp_size: 1
hsdp_replicate_dim: 1
hsdp_shard_dim: 4
data:
data_path: data/Wan-Syn_77x448x832_600k
dataloader_num_workers: 4
train_batch_size: 1
training_cfg_rate: 0.0
seed: 1000
num_latent_t: 20
num_height: 448
num_width: 832
num_frames: 77
optimizer:
learning_rate: 2.0e-6
betas: [0.0, 0.999]
weight_decay: 0.01
lr_scheduler: constant
lr_warmup_steps: 0
loop:
max_train_steps: 4000
gradient_accumulation_steps: 1
checkpoint:
output_dir: outputs/wan2.1_dmd2_4steps
training_state_checkpointing_steps: 20
checkpoints_total_limit: 3
tracker:
project_name: distillation_wan
run_name: wan2.1_dmd2_4steps
model:
enable_gradient_checkpointing_type: full
callbacks:
grad_clip:
max_grad_norm: 1.0
validation:
pipeline_target: fastvideo.pipelines.basic.wan.wan_dmd_pipeline.WanDMDPipeline
dataset_file: examples/training/finetune/Wan2.1-VSA/Wan-Syn-Data/validation_4.json
every_steps: 50
sampling_steps: [4]
sampling_timesteps: [1000, 750, 500, 250]
guidance_scale: 6.0
ema:
_target_: fastvideo.train.callbacks.ema.EMACallback
decay: 0.98
start_iter: 0 # delay EMA updates until this iteration
pipeline:
flow_shift: 8
+197
View File
@@ -0,0 +1,197 @@
# ==============================================================================
# Full configuration reference for fastvideo.train
#
# Legend:
# [TYPED] — parsed into a typed dataclass; fields are validated with
# defaults. Unknown keys are silently ignored.
# [FREE] — free-form dict passed as-is to the target class / method.
# Keys depend on the _target_ class constructor / method_config.
# [RESOLVED] — parsed by PipelineConfig.from_kwargs(); auto-populated from
# the model's config files. Only scalar overrides are useful.
# ==============================================================================
# ------------------------------------------------------------------------------
# models: [FREE]
#
# Each role is instantiated via _target_(*, training_config=..., **kwargs).
# Keys here are constructor kwargs of the _target_ class (e.g. WanModel).
# You can define any role name (student, teacher, critic, etc.).
# ------------------------------------------------------------------------------
models:
student:
_target_: fastvideo.train.models.wan.WanModel # required
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers # required: HF repo or local path
trainable: true # default: true
disable_custom_init_weights: false # default: false
flow_shift: 3.0 # default: 3.0
enable_gradient_checkpointing_type: null # default: null (falls back to training.model)
teacher:
_target_: fastvideo.train.models.wan.WanModel
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
trainable: false
disable_custom_init_weights: true
critic:
_target_: fastvideo.train.models.wan.WanModel
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
trainable: true
disable_custom_init_weights: true
# ------------------------------------------------------------------------------
# method: [FREE]
#
# Instantiated via _target_(*, cfg=RunConfig, role_models=...).
# All keys besides _target_ are available in self.method_config (a plain dict).
# Keys depend entirely on the method class.
# ------------------------------------------------------------------------------
method:
_target_: fastvideo.train.methods.distribution_matching.dmd2.DMD2Method # required
# --- DMD2-specific keys (read from self.method_config) ---
rollout_mode: simulate # required: "simulate" or "data_latent"
generator_update_interval: 5 # default: 1
dmd_denoising_steps: [1000, 750, 500, 250] # SDE timestep schedule
# Critic optimizer (all required — no fallback)
fake_score_learning_rate: 8.0e-6
fake_score_betas: [0.0, 0.999]
fake_score_lr_scheduler: constant
# CFG conditioning policy (optional)
# cfg_uncond:
# on_missing: error # "error" or "ignore"
# text: keep # "keep", "zero", "drop", "negative_prompt"
# image: keep # "keep", "zero", "drop"
# action: keep # "keep", "zero", "drop"
# --- FineTuneMethod keys (if using finetune instead) ---
# _target_: fastvideo.train.methods.fine_tuning.finetune.FineTuneMethod
# attn_kind: vsa # "dense" or "vsa"
# ------------------------------------------------------------------------------
# training: [TYPED] -> TrainingConfig
#
# Every field below has a typed default. Unknown keys are ignored.
# ------------------------------------------------------------------------------
training:
# --- training.distributed [TYPED] -> DistributedConfig ---
distributed:
num_gpus: 8 # default: 1
tp_size: 1 # default: 1
sp_size: 1 # default: 1 (defaults to num_gpus in loader)
hsdp_replicate_dim: 1 # default: 1
hsdp_shard_dim: 8 # default: -1 (defaults to num_gpus in loader)
pin_cpu_memory: false # default: false
# --- training.data [TYPED] -> DataConfig ---
data:
data_path: data/my_dataset # default: ""
train_batch_size: 1 # default: 1
dataloader_num_workers: 4 # default: 0
training_cfg_rate: 0.1 # default: 0.0
seed: 1000 # default: 0
num_height: 448 # default: 0
num_width: 832 # default: 0
num_latent_t: 20 # default: 0
num_frames: 77 # default: 0
# --- training.optimizer [TYPED] -> OptimizerConfig ---
# Note: only for the student optimizer. Critic optimizer is in method config.
optimizer:
learning_rate: 2.0e-6 # default: 0.0
betas: [0.9, 0.999] # default: [0.9, 0.999]
weight_decay: 0.01 # default: 0.0
lr_scheduler: constant # default: "constant"
lr_warmup_steps: 0 # default: 0
lr_num_cycles: 0 # default: 0
lr_power: 0.0 # default: 0.0
min_lr_ratio: 0.5 # default: 0.5
# --- training.loop [TYPED] -> TrainingLoopConfig ---
loop:
max_train_steps: 10000 # default: 0
gradient_accumulation_steps: 1 # default: 1
# --- training.checkpoint [TYPED] -> CheckpointConfig ---
checkpoint:
output_dir: outputs/my_run # default: ""
resume_from_checkpoint: "" # default: "" (or use --resume-from-checkpoint CLI)
training_state_checkpointing_steps: 1000 # default: 0 (disabled)
checkpoints_total_limit: 3 # default: 0 (keep all)
# --- training.tracker [TYPED] -> TrackerConfig ---
tracker:
trackers: [] # default: [] (auto-adds "wandb" if project_name is set)
project_name: my_project # default: "fastvideo"
run_name: my_run # default: ""
# --- training.vsa [TYPED] -> VSAConfig ---
vsa:
sparsity: 0.0 # default: 0.0 (0.0 = disabled)
decay_rate: 0.0 # default: 0.0
decay_interval_steps: 0 # default: 0
# --- training.model [TYPED] -> ModelTrainingConfig ---
model:
weighting_scheme: uniform # default: "uniform"
logit_mean: 0.0 # default: 0.0
logit_std: 1.0 # default: 1.0
mode_scale: 1.0 # default: 1.0
precondition_outputs: false # default: false
moba_config: {} # default: {}
enable_gradient_checkpointing_type: full # default: null ("full" or null)
# --- training top-level [TYPED] ---
dit_precision: fp32 # default: "fp32" (master weight precision)
# model_path: ... # default: "" (auto-derived from models.student.init_from)
# ------------------------------------------------------------------------------
# callbacks: [FREE]
#
# Each callback is instantiated via _target_(*, **kwargs).
# The callback name (e.g. "grad_clip") is arbitrary — only _target_ matters.
# training_config is injected automatically (not from YAML).
# ------------------------------------------------------------------------------
callbacks:
# --- GradNormClipCallback ---
grad_clip:
_target_: fastvideo.train.callbacks.grad_clip.GradNormClipCallback # optional if using default registry
max_grad_norm: 1.0 # default: 0.0 (0.0 = disabled)
log_grad_norms: false # default: false
# --- EMACallback ---
# ema:
# _target_: fastvideo.train.callbacks.ema.EMACallback
# decay: 0.9999 # default: 0.9999
# start_iter: 0 # default: 0
# --- ValidationCallback ---
validation:
_target_: fastvideo.train.callbacks.validation.ValidationCallback # optional if using default registry
pipeline_target: fastvideo.pipelines.basic.wan.wan_pipeline.WanPipeline # required
dataset_file: path/to/validation.json # required
every_steps: 100 # default: 100
sampling_steps: [4] # default: [40]
guidance_scale: 5.0 # default: null (uses model default)
num_frames: null # default: null (derived from training.data)
output_dir: null # default: null (falls back to training.checkpoint.output_dir)
sampling_timesteps: null # default: null (explicit timestep list for streaming)
# ------------------------------------------------------------------------------
# pipeline: [RESOLVED] -> PipelineConfig
#
# Parsed by PipelineConfig.from_kwargs(). Most fields are auto-populated from
# the model's config files (vae_config, dit_config, text_encoder_configs, etc.).
# Only scalar overrides are typically needed here.
# ------------------------------------------------------------------------------
pipeline:
flow_shift: 3 # default: null (model-specific)
# flow_shift_sr: null # default: null (super-resolution shift)
# embedded_cfg_scale: 6.0 # default: 6.0
# is_causal: false # default: false
# vae_tiling: true # default: true
# vae_sp: true # default: true
# disable_autocast: false # default: false
@@ -0,0 +1,80 @@
# V3 config: Wan 2.1 T2V 1.3B finetune with VSA (phase 3.4, 0.9 sparsity).
#
# Uses _target_-based instantiation — each model role is an independent
# class instance; the method class is resolved directly from the YAML.
models:
student:
_target_: fastvideo.train.models.wan.WanModel
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
trainable: true
method:
_target_: fastvideo.train.methods.fine_tuning.finetune.FineTuneMethod
attn_kind: vsa
training:
distributed:
num_gpus: 4
sp_size: 1
tp_size: 1
hsdp_replicate_dim: 4
hsdp_shard_dim: 1
data:
data_path: data/Wan-Syn_77x448x832_600k
dataloader_num_workers: 4
train_batch_size: 1
training_cfg_rate: 0.1
seed: 1000
num_latent_t: 20
num_height: 448
num_width: 832
num_frames: 77
optimizer:
learning_rate: 1.0e-6
betas: [0.9, 0.999]
weight_decay: 0.01
lr_scheduler: constant
lr_warmup_steps: 0
loop:
max_train_steps: 4000
gradient_accumulation_steps: 1
checkpoint:
output_dir: outputs/phase3.4_wan2.1_finetune_vsa_0.9_v3
training_state_checkpointing_steps: 100
checkpoints_total_limit: 3
resume_from_checkpoint: latest
tracker:
project_name: distillation_wan_r
run_name: phase3.4_wan_finetune_vsa_0.9_v3
model:
enable_gradient_checkpointing_type: full
vsa:
sparsity: 0.9
decay_rate: 0.03
decay_interval_steps: 1
callbacks:
grad_clip:
_target_: fastvideo.train.callbacks.grad_clip.GradNormClipCallback
max_grad_norm: 1.0
ema:
_target_: fastvideo.train.callbacks.ema.EMACallback
decay: 0.9999
validation:
_target_: fastvideo.train.callbacks.validation.ValidationCallback
pipeline_target: fastvideo.pipelines.basic.wan.wan_pipeline.WanPipeline
dataset_file: examples/training/finetune/Wan2.1-VSA/Wan-Syn-Data/validation_4.json
every_steps: 50
sampling_steps: [50]
guidance_scale: 5.0
pipeline:
flow_shift: 3
@@ -0,0 +1,98 @@
# Self-Forcing distillation: Wan 2.1 T2V 1.3B Causal
#
# - Teacher: frozen pretrained Wan 2.1 T2V 1.3B
# - Student: trainable causal Wan model
# - Critic: trainable, initialized from pretrained weights
# - Training: streaming rollout with SDE sampling
models:
student:
_target_: fastvideo.train.models.wan.WanCausalModel
# init_from: wlsaidhi/SFWan2.1-T2V-1.3B-Diffusers
init_from: /mnt/weka/home/hao.zhang/alex/weights/WanOdeInit
trainable: true
teacher:
_target_: fastvideo.train.models.wan.WanModel
init_from: Wan-AI/Wan2.1-T2V-14B-Diffusers
trainable: false
disable_custom_init_weights: true
critic:
_target_: fastvideo.train.models.wan.WanModel
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
trainable: true
disable_custom_init_weights: true
method:
_target_: fastvideo.train.methods.distribution_matching.self_forcing.SelfForcingMethod
rollout_mode: simulate
generator_update_interval: 5
real_score_guidance_scale: 4.5
dmd_denoising_steps: [1000, 750, 500, 250]
# Self-forcing specific
chunk_size: 3
student_sample_type: sde
context_noise: 0.0
enable_gradient_in_rollout: true
start_gradient_frame: 0
# Critic optimizer
fake_score_learning_rate: 8.0e-6
fake_score_betas: [0.0, 0.999]
fake_score_lr_scheduler: constant
training:
distributed:
num_gpus: 8
sp_size: 1
tp_size: 1
hsdp_replicate_dim: 1
hsdp_shard_dim: 8
data:
data_path: data/Wan-Syn_77x448x832_600k
dataloader_num_workers: 4
train_batch_size: 1
training_cfg_rate: 0.0
seed: 1000
num_latent_t: 20
num_height: 448
num_width: 832
num_frames: 77
optimizer:
learning_rate: 1e-5
betas: [0.0, 0.999]
weight_decay: 0.01
lr_scheduler: constant
lr_warmup_steps: 0
loop:
max_train_steps: 4000
gradient_accumulation_steps: 1
checkpoint:
output_dir: outputs/wan2.1_causal_self_forcing
training_state_checkpointing_steps: 1000
checkpoints_total_limit: 3
tracker:
project_name: distillation_wan_r
run_name: wan2.1_causal_self_forcing
model:
enable_gradient_checkpointing_type: full
callbacks:
grad_clip:
max_grad_norm: 1.0
validation:
pipeline_target: fastvideo.pipelines.basic.wan.wan_causal_dmd_pipeline.WanCausalDMDPipeline
dataset_file: examples/training/finetune/Wan2.1-VSA/Wan-Syn-Data/validation_4.json
every_steps: 50
sampling_steps: [4]
sampling_timesteps: [1000, 750, 500, 250]
guidance_scale: 6.0
pipeline:
flow_shift: 5
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Launch training from a YAML config.
#
# Usage:
# bash examples/train/run.sh <config.yaml> [--dotted.key value ...]
#
# Examples:
# bash examples/train/run.sh examples/train/finetune_wan2.1_t2v_1.3B_vsa_phase3.4_0.9sparsity.yaml
# bash examples/train/run.sh examples/train/distill_wan2.1_t2v_1.3B_dmd2.yaml --dry-run
# bash examples/train/run.sh examples/train/distill_wan2.1_t2v_1.3B_dmd2.yaml \
# --training.distributed.num_gpus 4 \
# --training.optimizer.learning_rate 1e-5
# bash examples/train/run.sh examples/train/distill_wan2.1_t2v_1.3B_dmd2.yaml \
# --training.checkpoint.resume_from_checkpoint outputs/my_run/checkpoint-1000
#
# Logs are written to logs/<config_name>_<timestamp>.log (and also printed to stdout).
set -euo pipefail
CONFIG="${1:?Usage: $0 <config.yaml> [extra flags...]}"
shift
# ── GPU / node settings ──────────────────────────────────────────
NUM_GPUS="${NUM_GPUS:-$(nvidia-smi -L 2>/dev/null | wc -l)}"
NUM_GPUS="${NUM_GPUS:-1}"
NNODES="${NNODES:-1}"
NODE_RANK="${NODE_RANK:-0}"
MASTER_ADDR="${MASTER_ADDR:-127.0.0.1}"
MASTER_PORT="${MASTER_PORT:-29501}"
export TOKENIZERS_PARALLELISM=false
# ── W&B ──────────────────────────────────────────────────────────
export WANDB_API_KEY="${WANDB_API_KEY:-}"
export WANDB_MODE="${WANDB_MODE:-online}"
# ── Log file ─────────────────────────────────────────────────────
CONFIG_NAME="$(basename "${CONFIG}" .yaml)"
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
LOG_DIR="${LOG_DIR:-examples/train}"
mkdir -p "${LOG_DIR}"
LOG_FILE="${LOG_DIR}/${CONFIG_NAME}_${TIMESTAMP}.log"
echo "=== Train Training ==="
echo "Config: ${CONFIG}"
echo "Num GPUs: ${NUM_GPUS}"
echo "Num Nodes: ${NNODES}"
echo "Node Rank: ${NODE_RANK}"
echo "Master: ${MASTER_ADDR}:${MASTER_PORT}"
echo "Extra args: $*"
echo "Log file: ${LOG_FILE}"
echo "=============================="
torchrun \
--nnodes "${NNODES}" \
--node_rank "${NODE_RANK}" \
--nproc_per_node "${NUM_GPUS}" \
--master_addr "${MASTER_ADDR}" \
--master_port "${MASTER_PORT}" \
fastvideo/train/entrypoint/train.py \
--config "${CONFIG}" \
"$@" \
2>&1 | tee "${LOG_FILE}"
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# Submit a multi-node Slurm training job.
#
# Usage:
# bash examples/train/run_slurm.sh <config.yaml> <num_nodes> [--dotted.key value ...]
#
# Examples:
# bash examples/train/run_slurm.sh examples/train/configs/example.yaml 2
# bash examples/train/run_slurm.sh examples/train/configs/distill_wan2.1_t2v_1.3B_dmd2.yaml 4 \
# --training.optimizer.learning_rate 1e-5
# bash examples/train/run_slurm.sh examples/train/configs/example.yaml 8 \
# --training.checkpoint.resume_from_checkpoint outputs/my_run/checkpoint-1000
#
# Environment variables (override defaults):
# PARTITION Slurm partition (default: main)
# NUM_GPUS GPUs per node (default: 8)
# CPUS_PER_TASK CPUs per task (default: 128)
# MEM Memory per node (default: 1440G)
# JOB_NAME Slurm job name (default: derived from config)
# OUTPUT_DIR Directory for slurm logs (default: slurm_logs)
# MASTER_PORT Rendezvous port (default: 29500)
# EXCLUDE Nodes to exclude (default: "")
# WANDB_API_KEY W&B API key (default: "")
# WANDB_MODE W&B mode (default: online)
set -euo pipefail
CONFIG="${1:?Usage: $0 <config.yaml> <num_nodes> [extra flags...]}"
NUM_NODES="${2:?Usage: $0 <config.yaml> <num_nodes> [extra flags...]}"
shift 2
# ── Defaults ──────────────────────────────────────────────────────
PARTITION="${PARTITION:-main}"
NUM_GPUS="${NUM_GPUS:-8}"
CPUS_PER_TASK="${CPUS_PER_TASK:-128}"
MEM="${MEM:-1440G}"
MASTER_PORT="${MASTER_PORT:-29500}"
EXCLUDE="${EXCLUDE:-}"
WANDB_API_KEY="${WANDB_API_KEY:-}"
WANDB_MODE="${WANDB_MODE:-online}"
TOTAL_GPUS=$(( NUM_NODES * NUM_GPUS ))
CONFIG_NAME="$(basename "${CONFIG}" .yaml)"
JOB_NAME="${JOB_NAME:-${CONFIG_NAME}}"
OUTPUT_DIR="${OUTPUT_DIR:-logs/slurm}"
mkdir -p "${OUTPUT_DIR}"
# ── Build sbatch args ─────────────────────────────────────────────
SBATCH_ARGS=(
--job-name="${JOB_NAME}"
--partition="${PARTITION}"
--nodes="${NUM_NODES}"
--ntasks="${NUM_NODES}"
--ntasks-per-node=1
--gres="gpu:${NUM_GPUS}"
--cpus-per-task="${CPUS_PER_TASK}"
--mem="${MEM}"
--output="${OUTPUT_DIR}/${JOB_NAME}_%j.out"
--error="${OUTPUT_DIR}/${JOB_NAME}_%j.err"
--exclusive
)
if [[ -n "${EXCLUDE}" ]]; then
SBATCH_ARGS+=(--exclude="${EXCLUDE}")
fi
# ── Collect extra overrides for the training script ───────────────
EXTRA_ARGS=("$@")
echo "=== Slurm Training Submission ==="
echo "Config: ${CONFIG}"
echo "Nodes: ${NUM_NODES}"
echo "GPUs/node: ${NUM_GPUS}"
echo "Total GPUs: ${TOTAL_GPUS}"
echo "Partition: ${PARTITION}"
echo "Job name: ${JOB_NAME}"
echo "Extra args: ${EXTRA_ARGS[*]:-}"
echo "================================="
# ── Submit ────────────────────────────────────────────────────────
sbatch "${SBATCH_ARGS[@]}" <<EOF
#!/bin/bash
set -e -x
# ── Environment ───────────────────────────────────────────────────
export NCCL_P2P_DISABLE=1
export TORCH_NCCL_ENABLE_MONITORING=0
export TRITON_CACHE_DIR=/tmp/triton_cache_\${SLURM_PROCID}
export TOKENIZERS_PARALLELISM=false
export WANDB_API_KEY="${WANDB_API_KEY}"
export WANDB_MODE="${WANDB_MODE}"
# ── Rendezvous ────────────────────────────────────────────────────
export MASTER_PORT=${MASTER_PORT}
nodes=( \$(scontrol show hostnames \$SLURM_JOB_NODELIST) )
export MASTER_ADDR=\${nodes[0]}
export NODE_RANK=\$SLURM_PROCID
echo "MASTER_ADDR: \$MASTER_ADDR"
echo "NODE_RANK: \$NODE_RANK"
# ── Launch ────────────────────────────────────────────────────────
srun torchrun \\
--nnodes \$SLURM_JOB_NUM_NODES \\
--nproc_per_node ${NUM_GPUS} \\
--node_rank \$SLURM_PROCID \\
--rdzv_backend=c10d \\
--rdzv_endpoint="\$MASTER_ADDR:\$MASTER_PORT" \\
fastvideo/train/entrypoint/train.py \\
--config ${CONFIG} \\
--training.distributed.num_gpus ${TOTAL_GPUS} \\
${EXTRA_ARGS[*]:-}
EOF
@@ -343,11 +343,16 @@ class LatentsParquetMapStyleDataset(Dataset):
for idx in indices
]
# Inject sample indices for deterministic CFG dropout
# that is reproducible across checkpoint resume.
for row, idx in zip(rows, indices):
row["_sample_index"] = idx
batch = collate_rows_from_parquet_schema(rows,
self.parquet_schema,
self.text_padding_length,
cfg_rate=self.cfg_rate,
rng=self.rng)
seed=self.seed)
return batch
def __len__(self):
+24 -6
View File
@@ -100,7 +100,8 @@ def collate_rows_from_parquet_schema(rows,
parquet_schema,
text_padding_length,
cfg_rate=0.0,
rng=None) -> dict[str, Any]:
rng=None,
seed=0) -> dict[str, Any]:
"""
Collate rows from parquet files based on the provided schema.
Dynamically processes tensor fields based on schema and returns batched data.
@@ -149,13 +150,30 @@ def collate_rows_from_parquet_schema(rows,
if len(bytes_data) == 0:
tensor = torch.zeros(0, dtype=torch.bfloat16)
else:
# Convert bytes to tensor using float32 as default
if tensor_name == 'text_embedding' and (rng.random(
) if rng else random.random()) < cfg_rate:
data = np.zeros((512, 4096), dtype=np.float32)
# Deterministic per-sample CFG dropout
# using sample index (resume-safe).
drop = False
if (tensor_name == 'text_embedding'
and cfg_rate > 0):
sample_idx = row.get(
"_sample_index")
if sample_idx is not None:
drop = (random.Random(
seed ^ sample_idx
).random() < cfg_rate)
else:
drop = ((rng.random()
if rng else
random.random())
< cfg_rate)
if drop:
data = np.zeros(
(512, 4096), dtype=np.float32)
else:
data = np.frombuffer(
bytes_data, dtype=np.float32).reshape(shape).copy()
bytes_data,
dtype=np.float32,
).reshape(shape).copy()
tensor = torch.from_numpy(data)
# if len(data.shape) == 3:
# B, L, D = tensor.shape
@@ -30,21 +30,23 @@ class WanCausalPipeline(LoRAPipeline, ComposedPipelineBase):
"""Wan causal pipeline with standard multi-step denoising."""
_required_config_modules = [
"text_encoder", "tokenizer", "vae",
"transformer", "scheduler",
"text_encoder",
"tokenizer",
"vae",
"transformer",
"scheduler",
]
def initialize_pipeline(
self, fastvideo_args: FastVideoArgs,
self,
fastvideo_args: FastVideoArgs,
):
self.modules["scheduler"] = (
FlowUniPCMultistepScheduler(
shift=fastvideo_args.pipeline_config.flow_shift,
)
)
self.modules["scheduler"] = (FlowUniPCMultistepScheduler(
shift=fastvideo_args.pipeline_config.flow_shift, ))
def create_pipeline_stages(
self, fastvideo_args: FastVideoArgs,
self,
fastvideo_args: FastVideoArgs,
) -> None:
self.add_stage(
stage_name="input_validation_stage",
@@ -54,10 +56,8 @@ class WanCausalPipeline(LoRAPipeline, ComposedPipelineBase):
self.add_stage(
stage_name="prompt_encoding_stage",
stage=TextEncodingStage(
text_encoders=[
self.get_module("text_encoder")],
tokenizers=[
self.get_module("tokenizer")],
text_encoders=[self.get_module("text_encoder")],
tokenizers=[self.get_module("tokenizer")],
),
)
@@ -69,16 +69,14 @@ class WanCausalPipeline(LoRAPipeline, ComposedPipelineBase):
self.add_stage(
stage_name="timestep_preparation_stage",
stage=TimestepPreparationStage(
scheduler=self.get_module("scheduler"),
),
scheduler=self.get_module("scheduler"), ),
)
self.add_stage(
stage_name="latent_preparation_stage",
stage=LatentPreparationStage(
scheduler=self.get_module("scheduler"),
transformer=self.get_module(
"transformer", None),
transformer=self.get_module("transformer", None),
),
)
@@ -92,8 +90,7 @@ class WanCausalPipeline(LoRAPipeline, ComposedPipelineBase):
self.add_stage(
stage_name="decoding_stage",
stage=DecodingStage(
vae=self.get_module("vae")),
stage=DecodingStage(vae=self.get_module("vae")),
)
+2 -2
View File
@@ -7,8 +7,8 @@ complete diffusion pipelines.
"""
from fastvideo.pipelines.stages.base import PipelineStage
from fastvideo.pipelines.stages.causal_denoising import (
CausalDMDDenosingStage, CausalDenoisingStage)
from fastvideo.pipelines.stages.causal_denoising import (CausalDMDDenosingStage,
CausalDenoisingStage)
from fastvideo.pipelines.stages.conditioning import ConditioningStage
from fastvideo.pipelines.stages.decoding import DecodingStage
from fastvideo.pipelines.stages.denoising import (
+54 -79
View File
@@ -500,31 +500,22 @@ class CausalDenoisingStage(CausalDMDDenosingStage):
fastvideo_args: FastVideoArgs,
) -> ForwardBatch:
target_dtype = torch.bfloat16
autocast_enabled = (
target_dtype != torch.float32
and not fastvideo_args.disable_autocast
)
autocast_enabled = (target_dtype != torch.float32
and not fastvideo_args.disable_autocast)
latent_seq_length = (
batch.latents.shape[-1] * batch.latents.shape[-2]
)
patch_ratio = (
self.transformer.config.arch_config.patch_size[-1]
* self.transformer.config.arch_config.patch_size[-2]
)
latent_seq_length = (batch.latents.shape[-1] * batch.latents.shape[-2])
patch_ratio = (self.transformer.config.arch_config.patch_size[-1] *
self.transformer.config.arch_config.patch_size[-2])
self.frame_seq_length = latent_seq_length // patch_ratio
pos_cond_kwargs = self.prepare_extra_func_kwargs(
self.transformer.forward,
{
"encoder_attention_mask":
batch.prompt_attention_mask,
"encoder_attention_mask": batch.prompt_attention_mask,
},
)
assert batch.latents is not None, (
"latents must be provided"
)
assert batch.latents is not None, ("latents must be provided")
latents = batch.latents # [B, C, T, H, W]
b, c, t, h, w = latents.shape
prompt_embeds = batch.prompt_embeds
@@ -540,21 +531,16 @@ class CausalDenoisingStage(CausalDMDDenosingStage):
)
crossattn_cache = self._initialize_crossattn_cache(
batch_size=b,
max_text_len=(
fastvideo_args.pipeline_config
.text_encoder_configs[0]
.arch_config.text_len
),
max_text_len=(fastvideo_args.pipeline_config.
text_encoder_configs[0].arch_config.text_len),
dtype=target_dtype,
device=latents.device,
)
# Determine block sizes
if t % self.num_frames_per_block != 0:
raise ValueError(
"num_frames must be divisible by "
"num_frames_per_block"
)
raise ValueError("num_frames must be divisible by "
"num_frames_per_block")
num_blocks = t // self.num_frames_per_block
block_sizes = [self.num_frames_per_block] * num_blocks
start_index = 0
@@ -562,17 +548,19 @@ class CausalDenoisingStage(CausalDMDDenosingStage):
context_noise = getattr(
fastvideo_args.pipeline_config,
"context_noise", 0,
"context_noise",
0,
)
with self.progress_bar(
total=len(block_sizes) * num_inference_steps
) as progress_bar:
with self.progress_bar(total=len(block_sizes) *
num_inference_steps) as progress_bar:
for current_num_frames in block_sizes:
current_latents = latents[
:, :,
:,
:,
start_index:start_index + current_num_frames,
:, :,
:,
:,
]
# Reset scheduler per block so its multi-step
@@ -584,9 +572,7 @@ class CausalDenoisingStage(CausalDMDDenosingStage):
timesteps = self.scheduler.timesteps
for i, t_cur in enumerate(timesteps):
latent_model_input = current_latents.to(
target_dtype
)
latent_model_input = current_latents.to(target_dtype)
t_expanded = t_cur * torch.ones(
(b, 1),
device=latents.device,
@@ -594,16 +580,16 @@ class CausalDenoisingStage(CausalDMDDenosingStage):
)
with (
torch.autocast(
device_type="cuda",
dtype=target_dtype,
enabled=autocast_enabled,
),
set_forward_context(
current_timestep=i,
attn_metadata=None,
forward_batch=batch,
),
torch.autocast(
device_type="cuda",
dtype=target_dtype,
enabled=autocast_enabled,
),
set_forward_context(
current_timestep=i,
attn_metadata=None,
forward_batch=batch,
),
):
# Transformer returns [B, C, T, H, W],
# permute to [B, T, C, H, W] then flatten
@@ -614,10 +600,8 @@ class CausalDenoisingStage(CausalDMDDenosingStage):
t_expanded,
kv_cache=kv_cache,
crossattn_cache=crossattn_cache,
current_start=(
(pos_start_base + start_index)
* self.frame_seq_length
),
current_start=((pos_start_base + start_index) *
self.frame_seq_length),
start_frame=start_index,
**pos_cond_kwargs,
)
@@ -625,36 +609,29 @@ class CausalDenoisingStage(CausalDMDDenosingStage):
# Flatten [B,C,T,H,W] -> [B*T,C,H,W] for
# scheduler, then unflatten back.
nf = current_num_frames
noise_pred_flat = (
noise_pred
.permute(0, 2, 1, 3, 4)
.flatten(0, 1)
)
latents_flat = (
current_latents
.permute(0, 2, 1, 3, 4)
.flatten(0, 1)
)
noise_pred_flat = (noise_pred.permute(0, 2, 1, 3,
4).flatten(0, 1))
latents_flat = (current_latents.permute(0, 2, 1, 3,
4).flatten(0, 1))
updated_flat = self.scheduler.step(
noise_pred_flat,
t_cur,
latents_flat,
return_dict=False,
)[0]
current_latents = (
updated_flat
.unflatten(0, (b, nf))
.permute(0, 2, 1, 3, 4)
)
current_latents = (updated_flat.unflatten(
0, (b, nf)).permute(0, 2, 1, 3, 4))
if progress_bar is not None:
progress_bar.update()
# Write denoised block back
latents[
:, :,
:,
:,
start_index:start_index + current_num_frames,
:, :,
:,
:,
] = current_latents
# Update KV cache with clean context
@@ -665,16 +642,16 @@ class CausalDenoisingStage(CausalDMDDenosingStage):
) * int(context_noise)
context_bcthw = current_latents.to(target_dtype)
with (
torch.autocast(
device_type="cuda",
dtype=target_dtype,
enabled=autocast_enabled,
),
set_forward_context(
current_timestep=0,
attn_metadata=None,
forward_batch=batch,
),
torch.autocast(
device_type="cuda",
dtype=target_dtype,
enabled=autocast_enabled,
),
set_forward_context(
current_timestep=0,
attn_metadata=None,
forward_batch=batch,
),
):
self.transformer(
context_bcthw,
@@ -682,10 +659,8 @@ class CausalDenoisingStage(CausalDMDDenosingStage):
t_context.unsqueeze(1),
kv_cache=kv_cache,
crossattn_cache=crossattn_cache,
current_start=(
(pos_start_base + start_index)
* self.frame_seq_length
),
current_start=((pos_start_base + start_index) *
self.frame_seq_length),
start_frame=start_index,
**pos_cond_kwargs,
)
+3
View File
@@ -0,0 +1,3 @@
[style]
based_on_style = pep8
column_limit = 120
+7
View File
@@ -0,0 +1,7 @@
# SPDX-License-Identifier: Apache-2.0
from fastvideo.train.trainer import Trainer
__all__ = [
"Trainer",
]
+20
View File
@@ -0,0 +1,20 @@
# SPDX-License-Identifier: Apache-2.0
from fastvideo.train.callbacks.callback import (
Callback,
CallbackDict,
)
from fastvideo.train.callbacks.ema import (
EMACallback, )
from fastvideo.train.callbacks.grad_clip import (
GradNormClipCallback, )
from fastvideo.train.callbacks.validation import (
ValidationCallback, )
__all__ = [
"Callback",
"CallbackDict",
"EMACallback",
"GradNormClipCallback",
"ValidationCallback",
]
+175
View File
@@ -0,0 +1,175 @@
# SPDX-License-Identifier: Apache-2.0
"""Callback base class and CallbackDict manager.
Adapted from FastGen's callback pattern to FastVideo's types.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any, TYPE_CHECKING
from fastvideo.logger import init_logger
from fastvideo.train.utils.instantiate import instantiate
if TYPE_CHECKING:
from fastvideo.train.methods.base import TrainingMethod
from fastvideo.train.utils.training_config import (
TrainingConfig, )
logger = init_logger(__name__)
# Well-known callback names that don't need ``_target_`` in YAML.
_BUILTIN_CALLBACKS: dict[str, str] = {
"grad_clip": "fastvideo.train.callbacks.grad_clip.GradNormClipCallback",
"validation": "fastvideo.train.callbacks.validation.ValidationCallback",
"ema": "fastvideo.train.callbacks.ema.EMACallback",
}
class Callback:
"""Base callback with no-op hooks.
Subclasses override whichever hooks they need. The
``training_config`` and ``method`` attributes are set by
``CallbackDict`` after instantiation.
"""
training_config: TrainingConfig
method: TrainingMethod
_callback_dict: CallbackDict | None
def on_train_start(
self,
method: TrainingMethod,
iteration: int = 0,
) -> None:
pass
def on_training_step_end(
self,
method: TrainingMethod,
loss_dict: dict[str, Any],
iteration: int = 0,
) -> None:
pass
def on_before_optimizer_step(
self,
method: TrainingMethod,
iteration: int = 0,
) -> None:
pass
def on_validation_begin(
self,
method: TrainingMethod,
iteration: int = 0,
) -> None:
pass
def on_validation_end(
self,
method: TrainingMethod,
iteration: int = 0,
) -> None:
pass
def on_train_end(
self,
method: TrainingMethod,
iteration: int = 0,
) -> None:
pass
def state_dict(self) -> dict[str, Any]:
return {}
def load_state_dict(
self,
state_dict: dict[str, Any],
) -> None:
pass
class CallbackDict:
"""Manages a collection of named callbacks.
Instantiates each callback from its ``_target_`` config and
dispatches hook calls to all registered callbacks.
"""
def __init__(
self,
callback_configs: dict[str, dict[str, Any]],
training_config: TrainingConfig,
) -> None:
self._callbacks: dict[str, Callback] = {}
if not callback_configs:
return
for name, cb_cfg in callback_configs.items():
cb_cfg = dict(cb_cfg)
if "_target_" not in cb_cfg:
if name in _BUILTIN_CALLBACKS:
cb_cfg["_target_"] = (_BUILTIN_CALLBACKS[name])
else:
logger.warning(
"Callback %r is missing "
"'_target_', skipping: %s",
name,
cb_cfg,
)
continue
logger.info(
"Instantiating callback %r: %s",
name,
cb_cfg,
)
cb = instantiate(cb_cfg)
if not isinstance(cb, Callback):
raise TypeError(f"Callback {name!r} resolved to "
f"{type(cb).__name__}, expected a "
f"Callback subclass.")
cb.training_config = training_config
cb._callback_dict = self
self._callbacks[name] = cb
def __getattr__(
self,
method_name: str,
) -> Callable[..., Any]:
if method_name.startswith("_"):
raise AttributeError(method_name)
if method_name == "state_dict":
def _state_dict() -> dict[str, Any]:
return {n: cb.state_dict() for n, cb in self._callbacks.items()}
return _state_dict
if method_name == "load_state_dict":
def _load_state_dict(state_dict: dict[str, Any], ) -> None:
for n, cb in self._callbacks.items():
if n in state_dict:
cb.load_state_dict(state_dict[n])
else:
logger.warning(
"Callback %r not found in "
"checkpoint.",
n,
)
return _load_state_dict
def _dispatch(*args: Any, **kwargs: Any) -> None:
for cb in self._callbacks.values():
fn = getattr(cb, method_name, None)
if fn is None:
continue
if not callable(fn):
continue
fn(*args, **kwargs)
return _dispatch
+154
View File
@@ -0,0 +1,154 @@
# SPDX-License-Identifier: Apache-2.0
"""EMA (Exponential Moving Average) callback.
Owns the full EMA lifecycle: creation, per-step updates, weight
swapping for validation, and checkpoint state. All EMA config
lives under ``callbacks.ema`` in the YAML file.
"""
from __future__ import annotations
import contextlib
from collections.abc import Generator
from typing import Any, TYPE_CHECKING
import torch
from fastvideo.logger import init_logger
from fastvideo.train.callbacks.callback import Callback
from fastvideo.training.training_utils import EMA_FSDP
if TYPE_CHECKING:
from fastvideo.train.methods.base import TrainingMethod
logger = init_logger(__name__)
class EMACallback(Callback):
"""Manage EMA shadow weights for the student transformer.
All configuration lives in the YAML ``callbacks.ema`` section:
.. code-block:: yaml
callbacks:
ema:
decay: 0.9999
start_iter: 0
The callback creates an ``EMA_FSDP`` instance at train start,
updates it after each optimizer step, and exposes an
``ema_context()`` context manager for temporarily swapping
EMA weights into the live model (used by validation).
"""
def __init__(
self,
*,
decay: float = 0.9999,
start_iter: int = 0,
) -> None:
self._decay = float(decay)
self._start_iter = int(start_iter)
self._ema_started = False
self.student_ema: EMA_FSDP | None = None
# ----------------------------------------------------------
# Hooks
# ----------------------------------------------------------
def on_train_start(
self,
method: TrainingMethod,
iteration: int = 0,
) -> None:
student = getattr(method, "student", None)
if student is None or student.transformer is None:
raise ValueError("No student transformer found on method, cannot initialize EMA")
logger.info(
"Initializing EMA (local_shard) with "
"decay=%s from student transformer",
self._decay,
)
self.student_ema = EMA_FSDP(
student.transformer,
decay=self._decay,
mode="local_shard",
)
logger.info(
"EMA callback enabled (decay=%s, "
"start_iter=%d).",
self._decay,
self._start_iter,
)
def on_training_step_end(
self,
method: TrainingMethod,
loss_dict: dict[str, Any],
iteration: int = 0,
) -> None:
if self.student_ema is None:
return
if iteration < self._start_iter:
return
if not self._ema_started:
logger.info(
"Starting EMA updates at iteration %d "
"(re-initializing shadow from current "
"model).",
iteration,
)
self.student_ema._init_shadow(method.student.transformer, )
self._ema_started = True
self.student_ema.update(method.student.transformer, )
tracker = getattr(method, "tracker", None)
if tracker is not None:
tracker.log(
{"ema/decay": self.student_ema.decay},
iteration,
)
# ----------------------------------------------------------
# EMA context manager
# ----------------------------------------------------------
@contextlib.contextmanager
def ema_context(
self,
transformer: torch.nn.Module,
) -> Generator[torch.nn.Module, None, None]:
"""Temporarily swap EMA weights into *transformer*.
If EMA is not active, yields the transformer unchanged.
"""
if (self.student_ema is not None and self._ema_started):
with self.student_ema.apply_to_model(transformer, ):
yield transformer
else:
yield transformer
# ----------------------------------------------------------
# Checkpoint state
# ----------------------------------------------------------
def state_dict(self) -> dict[str, Any]:
if self.student_ema is None:
return {}
return {
"student_ema": self.student_ema.state_dict(),
"ema_started": self._ema_started,
}
def load_state_dict(
self,
state_dict: dict[str, Any],
) -> None:
ema_state = state_dict.get("student_ema")
if (ema_state is not None and self.student_ema is not None):
self.student_ema.load_state_dict(ema_state)
self._ema_started = bool(state_dict.get("ema_started", False), )
+61
View File
@@ -0,0 +1,61 @@
# SPDX-License-Identifier: Apache-2.0
"""Gradient norm clipping callback.
Clips gradients on modules returned by
``method.get_grad_clip_targets()`` before the optimizer step.
Optionally logs per-module grad norms to the tracker.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from fastvideo.logger import init_logger
from fastvideo.train.callbacks.callback import Callback
from fastvideo.train.utils.optimizer import (
clip_grad_norm_if_needed, )
if TYPE_CHECKING:
from fastvideo.train.methods.base import TrainingMethod
logger = init_logger(__name__)
class GradNormClipCallback(Callback):
"""Clip gradient norms before the optimizer step.
``max_grad_norm`` must be set explicitly in the callback
config (``callbacks.grad_clip.max_grad_norm``).
"""
def __init__(
self,
*,
max_grad_norm: float = 0.0,
log_grad_norms: bool = False,
) -> None:
self._max_grad_norm = float(max_grad_norm)
self._log_grad_norms = bool(log_grad_norms)
def on_before_optimizer_step(
self,
method: TrainingMethod,
iteration: int = 0,
) -> None:
max_norm = self._max_grad_norm
if max_norm <= 0.0:
return
targets = method.get_grad_clip_targets(iteration)
tracker = getattr(method, "tracker", None)
for name, module in targets.items():
grad_norm = clip_grad_norm_if_needed(
module,
max_norm,
)
if (self._log_grad_norms and tracker is not None and grad_norm > 0.0):
tracker.log(
{f"grad_norm/{name}": grad_norm},
iteration,
)
+459
View File
@@ -0,0 +1,459 @@
# SPDX-License-Identifier: Apache-2.0
"""Validation callback.
All configuration is read from the YAML ``callbacks.validation``
section. The pipeline class is resolved from
``pipeline_target``.
"""
from __future__ import annotations
import contextlib
import os
from dataclasses import dataclass
from typing import Any, TYPE_CHECKING
import imageio
import numpy as np
import torch
import torchvision
from einops import rearrange
from torch.utils.data import DataLoader
from fastvideo.configs.sample import SamplingParam
from fastvideo.dataset.validation_dataset import (
ValidationDataset, )
from fastvideo.distributed import (
get_sp_group,
get_world_group,
)
from fastvideo.logger import init_logger
from fastvideo.pipelines import ForwardBatch
from fastvideo.train.callbacks.callback import Callback
from fastvideo.train.utils.instantiate import resolve_target
from fastvideo.train.utils.moduleloader import (
make_inference_args, )
from fastvideo.training.trackers import DummyTracker
from fastvideo.utils import shallow_asdict
if TYPE_CHECKING:
from fastvideo.train.methods.base import TrainingMethod
logger = init_logger(__name__)
@dataclass(slots=True)
class _ValidationStepResult:
videos: list[list[np.ndarray]]
captions: list[str]
class ValidationCallback(Callback):
"""Generic validation callback driven entirely by YAML
config.
Works with any pipeline that follows the
``PipelineCls.from_pretrained(...)`` + ``pipeline.forward()``
contract.
"""
def __init__(
self,
*,
pipeline_target: str,
dataset_file: str,
every_steps: int = 100,
sampling_steps: list[int] | None = None,
guidance_scale: float | None = None,
num_frames: int | None = None,
output_dir: str | None = None,
sampling_timesteps: list[int] | None = None,
**pipeline_kwargs: Any,
) -> None:
self.pipeline_target = str(pipeline_target)
self.dataset_file = str(dataset_file)
self.every_steps = int(every_steps)
self.sampling_steps = ([int(s) for s in sampling_steps] if sampling_steps else [40])
self.guidance_scale = (float(guidance_scale) if guidance_scale is not None else None)
self.num_frames = (int(num_frames) if num_frames is not None else None)
self.output_dir = (str(output_dir) if output_dir is not None else None)
self.sampling_timesteps = ([int(s) for s in sampling_timesteps] if sampling_timesteps is not None else None)
self.pipeline_kwargs = dict(pipeline_kwargs)
# Set after on_train_start.
self._pipeline: Any | None = None
self._pipeline_key: tuple[Any, ...] | None = None
self._sampling_param: SamplingParam | None = None
self.tracker: Any = DummyTracker()
self.validation_random_generator: (torch.Generator | None) = None
self.seed: int = 0
# ----------------------------------------------------------
# Callback hooks
# ----------------------------------------------------------
def on_train_start(
self,
method: TrainingMethod,
iteration: int = 0,
) -> None:
self.method = method
tc = self.training_config
self.world_group = get_world_group()
self.sp_group = get_sp_group()
self.global_rank = self.world_group.rank
self.rank_in_sp_group = (self.sp_group.rank_in_group)
self.sp_world_size = self.sp_group.world_size
seed = tc.data.seed
if seed is None:
raise ValueError("training.data.seed must be set "
"for validation")
self.seed = int(seed)
self.validation_random_generator = (torch.Generator(device="cpu").manual_seed(self.seed))
tracker = getattr(method, "tracker", None)
if tracker is not None:
self.tracker = tracker
def on_validation_begin(
self,
method: TrainingMethod,
iteration: int = 0,
) -> None:
if self.every_steps <= 0:
return
if iteration % self.every_steps != 0:
return
self._run_validation(method, iteration)
# ----------------------------------------------------------
# Core validation logic
# ----------------------------------------------------------
def _run_validation(
self,
method: TrainingMethod,
step: int,
) -> None:
transformer = method.student.transformer
# Look for an EMA callback to temporarily swap
# EMA weights during validation.
ema_cb = self._find_ema_callback()
if ema_cb is not None:
ctx = ema_cb.ema_context(transformer)
else:
ctx = contextlib.nullcontext(transformer)
with ctx as t:
self._run_validation_inner(
method,
step,
t,
)
def _find_ema_callback(self) -> Any | None:
"""Find the EMA callback in the callback dict."""
from fastvideo.train.callbacks.ema import (
EMACallback, )
cb_dict = getattr(self, "_callback_dict", None)
if cb_dict is not None:
for cb in cb_dict._callbacks.values():
if isinstance(cb, EMACallback):
return cb
return None
def _run_validation_inner(
self,
method: TrainingMethod,
step: int,
transformer: torch.nn.Module,
) -> None:
tc = self.training_config
was_training = bool(getattr(transformer, "training", False))
output_dir = (self.output_dir or tc.checkpoint.output_dir)
try:
transformer.eval()
num_sp_groups = (self.world_group.world_size // self.sp_group.world_size)
for num_inference_steps in self.sampling_steps:
result = self._run_validation_for_steps(
num_inference_steps,
transformer=transformer,
)
if self.rank_in_sp_group != 0:
continue
if self.global_rank == 0:
all_videos = list(result.videos)
all_captions = list(result.captions)
for sp_idx in range(1, num_sp_groups):
src = (sp_idx * self.sp_world_size)
recv_v = (self.world_group.recv_object(src=src))
recv_c = (self.world_group.recv_object(src=src))
all_videos.extend(recv_v)
all_captions.extend(recv_c)
os.makedirs(
output_dir,
exist_ok=True,
)
video_filenames: list[str] = []
sp = self._get_sampling_param()
for i, video in enumerate(all_videos):
fname = os.path.join(
output_dir,
f"validation_step_{step}"
f"_inference_steps_"
f"{num_inference_steps}"
f"_video_{i}.mp4",
)
imageio.mimsave(
fname,
video,
fps=sp.fps,
)
video_filenames.append(fname)
video_logs = []
for fname, cap in zip(
video_filenames,
all_captions,
strict=True,
):
art = self.tracker.video(
fname,
caption=cap,
)
if art is not None:
video_logs.append(art)
if video_logs:
logs = {
f"validation_videos_"
f"{num_inference_steps}"
f"_steps": video_logs
}
self.tracker.log_artifacts(
logs,
step,
)
else:
self.world_group.send_object(
result.videos,
dst=0,
)
self.world_group.send_object(
result.captions,
dst=0,
)
finally:
if was_training:
transformer.train()
# ----------------------------------------------------------
# Pipeline management
# ----------------------------------------------------------
def _get_sampling_param(self) -> SamplingParam:
if self._sampling_param is None:
self._sampling_param = (SamplingParam.from_pretrained(self.training_config.model_path))
return self._sampling_param
def _get_pipeline(
self,
*,
transformer: torch.nn.Module,
) -> Any:
key = (id(transformer), )
if (self._pipeline is not None and self._pipeline_key == key):
return self._pipeline
tc = self.training_config
PipelineCls = resolve_target(self.pipeline_target)
flow_shift = getattr(
tc.pipeline_config,
"flow_shift",
None,
)
kwargs: dict[str, Any] = {
"inference_mode": True,
"loaded_modules": {
"transformer": transformer,
},
"tp_size": tc.distributed.tp_size,
"sp_size": tc.distributed.sp_size,
"num_gpus": tc.distributed.num_gpus,
"pin_cpu_memory": (tc.distributed.pin_cpu_memory),
"dit_cpu_offload": True,
}
if flow_shift is not None:
kwargs["flow_shift"] = float(flow_shift)
self._pipeline = PipelineCls.from_pretrained(
tc.model_path,
**kwargs,
)
self._pipeline_key = key
return self._pipeline
# ----------------------------------------------------------
# Batch preparation
# ----------------------------------------------------------
def _prepare_validation_batch(
self,
sampling_param: SamplingParam,
validation_batch: dict[str, Any],
num_inference_steps: int,
) -> ForwardBatch:
tc = self.training_config
sampling_param.prompt = validation_batch["prompt"]
sampling_param.height = tc.data.num_height
sampling_param.width = tc.data.num_width
sampling_param.num_inference_steps = int(num_inference_steps)
sampling_param.data_type = "video"
if self.guidance_scale is not None:
sampling_param.guidance_scale = float(self.guidance_scale)
sampling_param.seed = self.seed
# image_path for I2V pipelines.
img_path = (validation_batch.get("image_path") or validation_batch.get("video_path"))
if img_path is not None and (img_path.startswith("http") or os.path.isfile(img_path)):
sampling_param.image_path = img_path
temporal_compression_factor = int(
tc.pipeline_config.vae_config.arch_config.temporal_compression_ratio # type: ignore[union-attr]
)
default_num_frames = ((tc.data.num_latent_t - 1) * temporal_compression_factor + 1)
if self.num_frames is not None:
sampling_param.num_frames = int(self.num_frames)
else:
sampling_param.num_frames = int(default_num_frames)
latents_size = [
(sampling_param.num_frames - 1) // 4 + 1,
sampling_param.height // 8,
sampling_param.width // 8,
]
n_tokens = (latents_size[0] * latents_size[1] * latents_size[2])
sampling_timesteps_tensor = (torch.tensor(
[int(s) for s in self.sampling_timesteps],
dtype=torch.long,
) if self.sampling_timesteps is not None else None)
inference_args = make_inference_args(
tc,
model_path=tc.model_path,
)
batch = ForwardBatch(
**shallow_asdict(sampling_param),
latents=None,
generator=self.validation_random_generator,
n_tokens=n_tokens,
eta=0.0,
VSA_sparsity=tc.vsa.sparsity,
timesteps=sampling_timesteps_tensor,
)
batch._inference_args = inference_args # type: ignore[attr-defined]
# Conditionally set I2V fields.
if ("image" in validation_batch and validation_batch["image"] is not None):
batch.pil_image = validation_batch["image"]
return batch
# ----------------------------------------------------------
# Validation loop
# ----------------------------------------------------------
def _run_validation_for_steps(
self,
num_inference_steps: int,
*,
transformer: torch.nn.Module,
) -> _ValidationStepResult:
tc = self.training_config
pipeline = self._get_pipeline(transformer=transformer, )
sampling_param = self._get_sampling_param()
dataset = ValidationDataset(self.dataset_file)
dataloader = DataLoader(
dataset,
batch_size=None,
num_workers=0,
)
inference_args = make_inference_args(
tc,
model_path=tc.model_path,
)
videos: list[list[np.ndarray]] = []
captions: list[str] = []
for validation_batch in dataloader:
batch = self._prepare_validation_batch(
sampling_param,
validation_batch,
num_inference_steps,
)
assert (batch.prompt is not None and isinstance(batch.prompt, str))
captions.append(batch.prompt)
with torch.no_grad():
output_batch = pipeline.forward(
batch,
inference_args,
)
samples = output_batch.output.cpu()
if self.rank_in_sp_group != 0:
continue
video = rearrange(
samples,
"b c t h w -> t b c h w",
)
frames: list[np.ndarray] = []
for x in video:
x = torchvision.utils.make_grid(
x,
nrow=6,
)
x = (x.transpose(0, 1).transpose(1, 2).squeeze(-1))
frames.append((x * 255).numpy().astype(np.uint8))
videos.append(frames)
return _ValidationStepResult(
videos=videos,
captions=captions,
)
# ----------------------------------------------------------
# State management
# ----------------------------------------------------------
def state_dict(self) -> dict[str, Any]:
state: dict[str, Any] = {}
if self.validation_random_generator is not None:
state["validation_rng"] = (self.validation_random_generator.get_state())
return state
def load_state_dict(
self,
state_dict: dict[str, Any],
) -> None:
rng_state = state_dict.get("validation_rng")
if (rng_state is not None and self.validation_random_generator is not None):
self.validation_random_generator.set_state(rng_state)
+1
View File
@@ -0,0 +1 @@
# SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,397 @@
# SPDX-License-Identifier: Apache-2.0
"""Convert a DCP training checkpoint to a diffusers-style model directory.
Works on a single GPU regardless of how many GPUs were used for training
(DCP handles resharding automatically).
Usage (no torchrun needed)::
python -m fastvideo.train.entrypoint.dcp_to_diffusers \
--checkpoint /path/to/checkpoint-1000 \
--output-dir /path/to/diffusers_output
Or with torchrun (also fine)::
torchrun --nproc_per_node=1 \
-m fastvideo.train.entrypoint.dcp_to_diffusers \
--checkpoint ... --output-dir ...
The checkpoint must contain ``metadata.json`` (written by
``CheckpointManager``). If the checkpoint predates metadata
support, pass ``--config`` explicitly to provide the training
YAML.
"""
from __future__ import annotations
import argparse
import os
import sys
from typing import Any
from fastvideo.logger import init_logger
logger = init_logger(__name__)
def _ensure_distributed() -> None:
"""Set up a single-process distributed env if needed.
When running under ``torchrun`` the env vars are already set.
For plain ``python`` we fill in the minimum required vars so
that ``init_process_group`` succeeds with world_size=1.
"""
for key, default in [
("RANK", "0"),
("LOCAL_RANK", "0"),
("WORLD_SIZE", "1"),
("MASTER_ADDR", "127.0.0.1"),
("MASTER_PORT", "29500"),
]:
os.environ.setdefault(key, default)
def _save_role_pretrained(
*,
role: str,
base_model_path: str,
output_dir: str,
module_names: list[str] | None = None,
overwrite: bool = False,
model: Any,
) -> str:
"""Export a role's modules into a diffusers-style model dir.
Produces a ``model_path`` loadable by
``PipelineComponentLoader`` (``model_index.json``,
``transformer/``, ``vae/``, etc. copied from
``base_model_path``).
"""
import shutil
from pathlib import Path
import torch
import torch.distributed as dist
from torch.distributed.checkpoint.state_dict import (
StateDictOptions,
get_model_state_dict,
)
from fastvideo.utils import maybe_download_model
def _rank() -> int:
if dist.is_available() and dist.is_initialized():
return int(dist.get_rank())
return 0
def _barrier() -> None:
if dist.is_available() and dist.is_initialized():
dist.barrier()
local_base = Path(maybe_download_model(str(base_model_path))).resolve()
dst = Path(os.path.expanduser(str(output_dir))).resolve()
if _rank() == 0:
if dst.exists():
if overwrite:
shutil.rmtree(dst, ignore_errors=True)
else:
raise FileExistsError(f"Refusing to overwrite existing "
f"directory: {dst}. "
"Pass --overwrite to replace it.")
def _copy_or_link(src: str, dest: str) -> None:
try:
os.link(src, dest)
except OSError:
shutil.copy2(src, dest)
logger.info(
"Creating pretrained export dir at %s "
"(base=%s)",
dst,
local_base,
)
shutil.copytree(
local_base,
dst,
symlinks=True,
copy_function=_copy_or_link,
)
_barrier()
modules: dict[str, torch.nn.Module] = {}
if model.transformer is not None:
modules["transformer"] = model.transformer
if module_names is None:
module_names = sorted(modules.keys())
for module_name in module_names:
if module_name not in modules:
raise KeyError(f"Role {role!r} does not have module "
f"{module_name!r}. "
f"Available: {sorted(modules.keys())}")
module_dir = dst / module_name
if not module_dir.is_dir():
raise FileNotFoundError(f"Export directory missing component "
f"dir {module_name!r}: {module_dir}")
options = StateDictOptions(
full_state_dict=True,
cpu_offload=True,
)
state_dict = get_model_state_dict(
modules[module_name],
options=options,
)
if _rank() == 0:
for path in module_dir.glob("*.safetensors"):
path.unlink(missing_ok=True)
tensor_state: dict[str, torch.Tensor] = {}
for key, value in state_dict.items():
if not isinstance(value, torch.Tensor):
raise TypeError(f"Expected tensor in state_dict "
f"for {module_name}.{key}, "
f"got {type(value).__name__}")
tensor_state[key] = value.detach().cpu()
from safetensors.torch import save_file
out_path = module_dir / "model.safetensors"
logger.info(
"Saving %s weights to %s (%s tensors)",
module_name,
out_path,
len(tensor_state),
)
save_file(tensor_state, str(out_path))
_barrier()
return str(dst)
def convert(
*,
checkpoint_dir: str,
output_dir: str,
config_path: str | None = None,
role: str = "student",
overwrite: bool = False,
) -> str:
"""Load a DCP checkpoint and export as a diffusers model.
Returns the path to the exported model directory.
"""
_ensure_distributed()
from fastvideo.distributed import (
maybe_init_distributed_environment_and_model_parallel, )
from fastvideo.train.utils.builder import build_from_config
from fastvideo.train.utils.checkpoint import (
CheckpointManager,
_resolve_resume_checkpoint,
)
from fastvideo.train.utils.config import (
RunConfig,
load_run_config,
)
import torch.distributed.checkpoint as dcp
# -- Resolve checkpoint directory --
resolved = _resolve_resume_checkpoint(
checkpoint_dir,
output_dir=checkpoint_dir,
)
dcp_dir = resolved / "dcp"
if not dcp_dir.is_dir():
raise FileNotFoundError(f"Missing dcp/ under {resolved}")
# -- Obtain config --
cfg: RunConfig
if config_path is not None:
cfg = load_run_config(config_path)
else:
metadata = CheckpointManager.load_metadata(resolved)
raw_config = metadata.get("config")
if raw_config is None:
raise ValueError("Checkpoint metadata.json does not "
"contain 'config'. Pass --config "
"explicitly.")
cfg = _run_config_from_raw(raw_config)
tc = cfg.training
# -- Init distributed (1 GPU is enough; DCP reshards) --
maybe_init_distributed_environment_and_model_parallel(
tp_size=1,
sp_size=1,
)
# Override distributed config so model loading uses 1 GPU.
tc.distributed.tp_size = 1
tc.distributed.sp_size = 1
tc.distributed.num_gpus = 1
tc.distributed.hsdp_replicate_dim = 1
tc.distributed.hsdp_shard_dim = 1
# -- Build model (loads pretrained weights + FSDP) --
_, method, _, _ = build_from_config(cfg)
# -- Load DCP weights into the model --
states = method.checkpoint_state()
logger.info(
"Loading DCP checkpoint from %s",
resolved,
)
dcp.load(states, checkpoint_id=str(dcp_dir))
# -- Export to diffusers format --
model = method._role_models[role]
base_model_path = str(tc.model_path)
if not base_model_path:
raise ValueError("Cannot determine base_model_path from "
"config. Ensure models.student.init_from "
"is set.")
logger.info(
"Exporting role=%s to %s (base=%s)",
role,
output_dir,
base_model_path,
)
result = _save_role_pretrained(
role=role,
base_model_path=base_model_path,
output_dir=output_dir,
overwrite=overwrite,
model=model,
)
logger.info("Export complete: %s", result)
return result
def _run_config_from_raw(raw: dict[str, Any], ) -> Any:
"""Reconstruct a RunConfig from a raw config dict.
This mirrors ``load_run_config`` but operates on an
already-parsed dict (from metadata.json) instead of
reading from a YAML file.
"""
from fastvideo.train.utils.config import (
RunConfig,
_build_training_config,
_parse_pipeline_config,
_require_mapping,
_require_str,
)
models_raw = _require_mapping(
raw.get("models"),
where="models",
)
models: dict[str, dict[str, Any]] = {}
for role_key, model_cfg_raw in models_raw.items():
role_str = _require_str(
role_key,
where="models.<role>",
)
model_cfg = _require_mapping(
model_cfg_raw,
where=f"models.{role_str}",
)
models[role_str] = dict(model_cfg)
method_raw = _require_mapping(
raw.get("method"),
where="method",
)
method = dict(method_raw)
callbacks_raw = raw.get("callbacks")
callbacks: dict[str, dict[str, Any]] = (_require_mapping(
callbacks_raw,
where="callbacks",
) if callbacks_raw is not None else {})
pipeline_config = _parse_pipeline_config(
raw,
models=models,
)
training_raw = _require_mapping(
raw.get("training"),
where="training",
)
t = dict(training_raw)
training = _build_training_config(
t,
models=models,
pipeline_config=pipeline_config,
)
return RunConfig(
models=models,
method=method,
training=training,
callbacks=callbacks,
raw=raw,
)
def main() -> None:
parser = argparse.ArgumentParser(description=("Convert a DCP training checkpoint to a "
"diffusers-style model directory. "
"Only 1 GPU needed (DCP reshards "
"automatically)."), )
parser.add_argument(
"--checkpoint",
type=str,
required=True,
help=("Path to checkpoint-<step> dir, its dcp/ "
"subdir, or an output_dir (auto-picks "
"latest)."),
)
parser.add_argument(
"--output-dir",
type=str,
required=True,
help="Destination for the diffusers model.",
)
parser.add_argument(
"--config",
type=str,
default=None,
help=("Training YAML config. If omitted, read "
"from checkpoint metadata.json."),
)
parser.add_argument(
"--role",
type=str,
default="student",
help="Role to export (default: student).",
)
parser.add_argument(
"--overwrite",
action="store_true",
help="Overwrite output-dir if it exists.",
)
args = parser.parse_args(sys.argv[1:])
convert(
checkpoint_dir=args.checkpoint,
output_dir=args.output_dir,
config_path=args.config,
role=args.role,
overwrite=args.overwrite,
)
if __name__ == "__main__":
main()
@@ -0,0 +1,221 @@
# SPDX-License-Identifier: Apache-2.0
"""Convert Self-Forcing ode_init.pt to HuggingFace diffusers format.
The official ode_init.pt from
https://huggingface.co/gdhe17/Self-Forcing/resolve/main/checkpoints/ode_init.pt
stores weights under ``{"generator": {<original_wan_keys>}}``.
This script converts those keys to diffusers
``WanTransformer3DModel`` format, verifies them against a reference
model, and saves a complete diffusers-compatible model directory
(transformer + scheduler + vae + text_encoder + tokenizer).
Usage:
python -m fastvideo.train.entrypoint.misc.wan_ode_init_conversion \
--input /path/to/ode_init.pt \
--output /path/to/WanOdeInit \
--base-model Wan-AI/Wan2.1-T2V-1.3B-Diffusers
"""
from __future__ import annotations
import argparse
import os
import shutil
import torch
# -- Key conversion -----------------------------------------------
def _convert_key(key: str) -> str:
"""Map a single original-Wan key to diffusers key."""
k = key
if k.startswith("model."):
k = k[len("model."):]
# Top-level modules
k = k.replace("head.modulation", "scale_shift_table")
k = k.replace("head.head.", "proj_out.")
k = k.replace(
"time_embedding.0.",
"condition_embedder.time_embedder.linear_1.",
)
k = k.replace(
"time_embedding.2.",
"condition_embedder.time_embedder.linear_2.",
)
k = k.replace(
"time_projection.1.",
"condition_embedder.time_proj.",
)
k = k.replace(
"text_embedding.0.",
"condition_embedder.text_embedder.linear_1.",
)
k = k.replace(
"text_embedding.2.",
"condition_embedder.text_embedder.linear_2.",
)
# Block-level: order matters — rename attn sub-modules
# before renaming q/k/v/o so prefixes don't collide.
k = k.replace(".modulation", ".scale_shift_table")
k = k.replace(".self_attn.", ".attn1.")
k = k.replace(".cross_attn.", ".attn2.")
k = k.replace(".norm3.", ".norm2.")
# Attention projections
k = k.replace(".attn1.q.", ".attn1.to_q.")
k = k.replace(".attn1.k.", ".attn1.to_k.")
k = k.replace(".attn1.v.", ".attn1.to_v.")
k = k.replace(".attn1.o.", ".attn1.to_out.0.")
k = k.replace(".attn2.q.", ".attn2.to_q.")
k = k.replace(".attn2.k.", ".attn2.to_k.")
k = k.replace(".attn2.v.", ".attn2.to_v.")
k = k.replace(".attn2.o.", ".attn2.to_out.0.")
# FFN
k = k.replace(".ffn.0.", ".ffn.net.0.proj.")
k = k.replace(".ffn.2.", ".ffn.net.2.")
return k
def convert_state_dict(orig_sd: dict[str, torch.Tensor], ) -> dict[str, torch.Tensor]:
"""Convert an entire original-Wan state dict."""
return {_convert_key(k): v for k, v in orig_sd.items()}
# -- Main ---------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(description="Convert ode_init.pt to diffusers format", )
parser.add_argument(
"--input",
required=True,
help="Path to ode_init.pt",
)
parser.add_argument(
"--output",
required=True,
help="Output directory for diffusers model",
)
parser.add_argument(
"--base-model",
default="Wan-AI/Wan2.1-T2V-1.3B-Diffusers",
help="HF repo or local path for base model "
"(provides config, VAE, tokenizer, etc.)",
)
parser.add_argument(
"--skip-verify",
action="store_true",
help="Skip strict load verification",
)
args = parser.parse_args()
# 1. Load checkpoint
print(f"Loading {args.input} ...")
ckpt = torch.load(args.input, map_location="cpu", weights_only=False)
if "generator" in ckpt:
orig_sd = ckpt["generator"]
elif isinstance(ckpt, dict) and any(k.startswith("model.") for k in ckpt):
orig_sd = ckpt
else:
raise ValueError("Cannot find weights in checkpoint. "
"Expected key 'generator' or keys starting "
"with 'model.'.")
print(f" Found {len(orig_sd)} weight tensors")
# 2. Convert keys
print("Converting keys ...")
new_sd = convert_state_dict(orig_sd)
# 3. Verify against reference model
if not args.skip_verify:
from diffusers import WanTransformer3DModel
print(f"Loading reference model from "
f"{args.base_model} for verification ...")
ref_model = WanTransformer3DModel.from_pretrained(
args.base_model,
subfolder="transformer",
torch_dtype=torch.bfloat16,
)
ref_keys = set(ref_model.state_dict().keys())
new_keys = set(new_sd.keys())
missing = sorted(ref_keys - new_keys)
extra = sorted(new_keys - ref_keys)
if missing:
print(f" WARNING: {len(missing)} missing keys:")
for k in missing[:10]:
print(f" {k}")
if len(missing) > 10:
print(f" ... and {len(missing) - 10} more")
if extra:
print(f" WARNING: {len(extra)} extra keys:")
for k in extra[:10]:
print(f" {k}")
if len(extra) > 10:
print(f" ... and {len(extra) - 10} more")
if missing or extra:
raise RuntimeError("Key mismatch — conversion mapping needs "
"updating. Use --skip-verify to bypass.")
ref_model.load_state_dict(new_sd, strict=True)
print(" Strict load OK — all keys match!")
else:
ref_model = None
print(" Skipping verification")
# 4. Save transformer
output_dir = os.path.abspath(args.output)
os.makedirs(output_dir, exist_ok=True)
transformer_dir = os.path.join(output_dir, "transformer")
if ref_model is not None:
print(f"Saving transformer to {transformer_dir} ...")
ref_model.save_pretrained(transformer_dir)
else:
from safetensors.torch import save_file
os.makedirs(transformer_dir, exist_ok=True)
print(f"Saving transformer weights to "
f"{transformer_dir} ...")
save_file(new_sd, os.path.join(transformer_dir, "model.safetensors"))
# 5. Copy non-transformer files from base model
from huggingface_hub import snapshot_download
print(f"Downloading base model files from "
f"{args.base_model} ...")
base_path = snapshot_download(
args.base_model,
allow_patterns=[
"model_index.json",
"scheduler/*",
"tokenizer/*",
"vae/*",
"text_encoder/*",
],
)
for item in sorted(os.listdir(base_path)):
if item == "transformer":
continue
src = os.path.join(base_path, item)
dst = os.path.join(output_dir, item)
if os.path.isfile(src):
shutil.copy2(src, dst)
print(f" Copied {item}")
elif os.path.isdir(src):
if os.path.exists(dst):
shutil.rmtree(dst)
shutil.copytree(src, dst)
print(f" Copied {item}/")
print(f"\nDone! Diffusers model saved to {output_dir}")
if __name__ == "__main__":
main()
+139
View File
@@ -0,0 +1,139 @@
# SPDX-License-Identifier: Apache-2.0
"""YAML-only training entrypoint.
Usage::
torchrun --nproc_per_node=<N> -m fastvideo.train.entrypoint.train \
--config path/to/run.yaml
Any unknown ``--dotted.key value`` arguments are applied as
overrides to the YAML config before parsing. For example::
torchrun --nproc_per_node=8 -m fastvideo.train.entrypoint.train \
--config path/to/run.yaml \
--training.distributed.num_gpus 8 \
--training.optimizer.learning_rate 1e-5
"""
from __future__ import annotations
import argparse
import os
import sys
from typing import Any
import torch
from fastvideo.logger import init_logger
logger = init_logger(__name__)
def run_training_from_config(
config_path: str,
*,
dry_run: bool = False,
overrides: list[str] | None = None,
) -> None:
"""YAML-only training entrypoint (schema v2)."""
from fastvideo.distributed import (
maybe_init_distributed_environment_and_model_parallel, )
from fastvideo.train import Trainer
from fastvideo.train.utils.checkpoint import (
CheckpointConfig,
CheckpointManager,
)
from fastvideo.train.utils.builder import build_from_config
from fastvideo.train.utils.config import load_run_config
# Enable deterministic mode for reproducibility.
torch.backends.cudnn.benchmark = False
torch.backends.cudnn.deterministic = True
cfg = load_run_config(config_path, overrides=overrides)
tc = cfg.training
maybe_init_distributed_environment_and_model_parallel(
tc.distributed.tp_size,
tc.distributed.sp_size,
)
_, method, dataloader, start_step = build_from_config(cfg)
if dry_run:
logger.info("Dry-run: config parsed and "
"build_from_config succeeded.")
return
trainer = Trainer(
tc,
config=cfg.resolved_config(),
callback_configs=cfg.callbacks,
)
# Attach the exact YAML used for this run to the
# tracker (e.g., W&B Files).
trainer.tracker.log_file(
os.path.abspath(os.path.expanduser(config_path)),
name="run.yaml",
)
ckpt_config = CheckpointConfig(
save_steps=int(tc.checkpoint.training_state_checkpointing_steps or 0),
keep_last=int(tc.checkpoint.checkpoints_total_limit or 0),
)
checkpoint_manager = CheckpointManager(
method=method,
dataloader=dataloader,
output_dir=tc.checkpoint.output_dir,
config=ckpt_config,
callbacks=trainer.callbacks,
raw_config=cfg.raw,
)
trainer.run(
method,
dataloader=dataloader,
max_steps=tc.loop.max_train_steps,
start_step=start_step,
checkpoint_manager=checkpoint_manager,
)
def main(
args: Any,
overrides: list[str] | None = None,
) -> None:
config_path = str(args.config)
dry_run = bool(args.dry_run)
logger.info(
"Starting training from config=%s",
config_path,
)
run_training_from_config(
config_path,
dry_run=dry_run,
overrides=overrides,
)
logger.info("Training completed")
if __name__ == "__main__":
argv = sys.argv
parser = argparse.ArgumentParser(description="YAML-only training entrypoint.", )
parser.add_argument(
"--config",
type=str,
required=True,
help=("Path to training YAML config (schema v2)."),
)
parser.add_argument(
"--dry-run",
action="store_true",
help=("Parse config and build runtime, "
"but do not start training."),
)
args, unknown = parser.parse_known_args(argv[1:])
main(args, overrides=unknown if unknown else None)
+27
View File
@@ -0,0 +1,27 @@
# SPDX-License-Identifier: Apache-2.0
from fastvideo.train.methods.base import TrainingMethod
__all__ = [
"TrainingMethod",
"DMD2Method",
"FineTuneMethod",
"SelfForcingMethod",
"DiffusionForcingSFTMethod",
]
def __getattr__(name: str) -> object:
if name == "DMD2Method":
from fastvideo.train.methods.distribution_matching.dmd2 import DMD2Method
return DMD2Method
if name == "FineTuneMethod":
from fastvideo.train.methods.fine_tuning.finetune import FineTuneMethod
return FineTuneMethod
if name == "SelfForcingMethod":
from fastvideo.train.methods.distribution_matching.self_forcing import SelfForcingMethod
return SelfForcingMethod
if name == "DiffusionForcingSFTMethod":
from fastvideo.train.methods.fine_tuning.dfsft import DiffusionForcingSFTMethod
return DiffusionForcingSFTMethod
raise AttributeError(name)
+253
View File
@@ -0,0 +1,253 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Sequence
from typing import Any, Literal, TypeAlias, cast
import torch
from fastvideo.logger import init_logger
from fastvideo.train.models.base import ModelBase
from fastvideo.train.utils.checkpoint import _RoleModuleContainer
from fastvideo.training.checkpointing_utils import (
ModelWrapper,
OptimizerWrapper,
SchedulerWrapper,
)
logger = init_logger(__name__)
LogScalar: TypeAlias = float | int | torch.Tensor
class TrainingMethod(torch.nn.Module, ABC):
"""Base training method (algorithm layer).
Subclasses own their role models (student, teacher, critic, …) as
plain attributes and manage optimizers directly — no ``RoleManager``
or ``RoleHandle``.
The constructor receives *role_models* (a ``dict[str, ModelBase]``)
and a *cfg* object. It calls ``init_preprocessors`` on the student
and builds ``self.role_modules`` for FSDP wrapping.
A single shared CUDA RNG generator (``cuda_generator``) is
created in :meth:`on_train_start`. All ``torch.randn`` /
``torch.randint`` calls in methods **and** models must use this
generator instead of relying on global RNG state.
"""
# Shared CUDA RNG generator (initialized in on_train_start).
cuda_generator: torch.Generator | None = None
def __init__(
self,
*,
cfg: Any,
role_models: dict[str, ModelBase],
) -> None:
super().__init__()
self.tracker: Any | None = None
self._role_models: dict[str, ModelBase] = dict(role_models)
self.student = role_models["student"]
self.training_config = cfg.training
self.method_config: dict[str, Any] = dict(cfg.method)
self.validation_config: dict[str, Any] = dict(getattr(cfg, "validation", {}) or {})
# Build nn.ModuleDict for FSDP / checkpoint visibility.
self.role_modules = torch.nn.ModuleDict()
for role, model in role_models.items():
mods: dict[str, torch.nn.Module] = {}
transformer = getattr(model, "transformer", None)
if isinstance(transformer, torch.nn.Module):
mods["transformer"] = transformer
if mods:
self.role_modules[role] = torch.nn.ModuleDict(mods)
# ------------------------------------------------------------------
def set_tracker(self, tracker: Any) -> None:
self.tracker = tracker
@abstractmethod
def single_train_step(
self,
batch: dict[str, Any],
iteration: int,
*,
current_vsa_sparsity: float = 0.0,
) -> tuple[
dict[str, torch.Tensor],
dict[str, Any],
dict[str, LogScalar],
]:
raise NotImplementedError
@abstractmethod
def get_optimizers(
self,
iteration: int,
) -> Sequence[torch.optim.Optimizer]:
raise NotImplementedError
@abstractmethod
def get_lr_schedulers(
self,
iteration: int,
) -> Sequence[Any]:
raise NotImplementedError
@property
@abstractmethod
def _optimizer_dict(self) -> dict[str, Any]:
...
@property
@abstractmethod
def _lr_scheduler_dict(self) -> dict[str, Any]:
...
def checkpoint_state(self) -> dict[str, Any]:
"""Return DCP-ready checkpoint state for all trainable roles.
Keys follow the convention:
``roles.<role>.<module>``, ``optimizers.<role>``,
``schedulers.<role>``, ``random_state.*``.
EMA state is managed by the ``EMACallback`` and is
checkpointed through the callback state mechanism.
"""
states: dict[str, Any] = {}
for role, model in self._role_models.items():
if not getattr(model, "_trainable", False):
continue
modules: dict[str, torch.nn.Module] = {}
if model.transformer is not None:
modules["transformer"] = model.transformer
container = _RoleModuleContainer(modules)
for module_name, module in modules.items():
states[f"roles.{role}.{module_name}"] = ModelWrapper(module)
opt = self._optimizer_dict.get(role)
if opt is not None:
states[f"optimizers.{role}"] = OptimizerWrapper(container, opt)
sched = self._lr_scheduler_dict.get(role)
if sched is not None:
states[f"schedulers.{role}"] = SchedulerWrapper(sched)
return states
def backward(
self,
loss_map: dict[str, torch.Tensor],
outputs: dict[str, Any],
*,
grad_accum_rounds: int = 1,
) -> None:
del outputs
grad_accum_rounds = max(1, int(grad_accum_rounds))
(loss_map["total_loss"] / grad_accum_rounds).backward()
def optimizers_schedulers_step(
self,
iteration: int,
) -> None:
for optimizer in self.get_optimizers(iteration):
optimizer.step()
for scheduler in self.get_lr_schedulers(iteration):
scheduler.step()
def optimizers_zero_grad(
self,
iteration: int,
) -> None:
for optimizer in self.get_optimizers(iteration):
try:
optimizer.zero_grad(set_to_none=True)
except TypeError:
optimizer.zero_grad()
def seed_optimizer_state_for_resume(self) -> None:
"""Seed optimizer state so DCP can load saved state.
A fresh optimizer has empty state (exp_avg, exp_avg_sq,
step are only created on the first optimizer.step()).
DCP needs matching entries to load into; without them
the saved optimizer state is silently dropped.
"""
for opt in self.get_optimizers(0):
for group in opt.param_groups:
for p in group["params"]:
if not p.requires_grad:
continue
if len(opt.state.get(p, {})) > 0:
continue
opt.state[p] = {
"step": torch.tensor(0.0),
"exp_avg": torch.zeros_like(p),
"exp_avg_sq": torch.zeros_like(p),
}
# -- Shared hooks (override in subclasses as needed) --
def get_grad_clip_targets(
self,
iteration: int,
) -> dict[str, torch.nn.Module]:
"""Return modules whose gradients should be clipped.
Override in subclasses to add/conditionally include
modules (e.g. critic, conditionally student).
Default: student transformer.
"""
return {"student": self.student.transformer}
def on_train_start(self) -> None:
from fastvideo.distributed import (
get_world_group, )
from fastvideo.utils import set_random_seed
seed = self.training_config.data.seed
if seed is None:
raise ValueError("training.data.seed must be set")
seed = int(seed)
world_group = get_world_group()
global_rank = int(world_group.rank)
sp_size = int(self.training_config.distributed.sp_size or 1)
# Ranks within the same SP group share a seed.
if sp_size > 1:
sp_group_seed = seed + (global_rank // sp_size)
else:
sp_group_seed = seed + global_rank
set_random_seed(seed)
self.cuda_generator = torch.Generator(device=self.student.device).manual_seed(sp_group_seed)
self.student.on_train_start()
def get_rng_generators(self, ) -> dict[str, torch.Generator]:
generators: dict[str, torch.Generator] = {}
if self.cuda_generator is not None:
generators["cuda"] = self.cuda_generator
return generators
@staticmethod
def _parse_attn_kind(raw: Any, ) -> Literal["dense", "vsa"]:
if raw in (None, ""):
return "dense"
kind = str(raw).strip().lower()
if kind not in {"dense", "vsa"}:
raise ValueError("method_config.attn_kind must be one of "
f"{{'dense', 'vsa'}}, got {raw!r}.")
return cast(Literal["dense", "vsa"], kind)
@@ -0,0 +1,3 @@
# SPDX-License-Identifier: Apache-2.0
__all__: list[str] = []
@@ -0,0 +1,10 @@
# SPDX-License-Identifier: Apache-2.0
from fastvideo.train.methods.distribution_matching.dmd2 import DMD2Method
from fastvideo.train.methods.distribution_matching.self_forcing import (
SelfForcingMethod, )
__all__ = [
"DMD2Method",
"SelfForcingMethod",
]
@@ -0,0 +1,633 @@
# SPDX-License-Identifier: Apache-2.0
"""DMD2 distillation method (algorithm layer)."""
from __future__ import annotations
from typing import Any, Literal
import torch
import torch.nn.functional as F
from fastvideo.train.methods.base import TrainingMethod, LogScalar
from fastvideo.train.models.base import ModelBase
from fastvideo.train.utils.optimizer import (
build_optimizer_and_scheduler, )
from fastvideo.train.utils.config import (
get_optional_float,
get_optional_int,
parse_betas,
)
class DMD2Method(TrainingMethod):
"""DMD2 distillation algorithm (method layer).
Owns role model instances directly:
- ``self.student`` — trainable student :class:`ModelBase`
- ``self.teacher`` — frozen teacher :class:`ModelBase`
- ``self.critic`` — trainable critic :class:`ModelBase`
"""
def __init__(
self,
*,
cfg: Any,
role_models: dict[str, ModelBase],
) -> None:
super().__init__(cfg=cfg, role_models=role_models)
if "student" not in role_models:
raise ValueError("DMD2Method requires role 'student'")
if "teacher" not in role_models:
raise ValueError("DMD2Method requires role 'teacher'")
if "critic" not in role_models:
raise ValueError("DMD2Method requires role 'critic'")
self.teacher = role_models["teacher"]
self.critic = role_models["critic"]
if not self.student._trainable:
raise ValueError("DMD2Method requires student to be trainable")
if self.teacher._trainable:
raise ValueError("DMD2Method requires teacher to be "
"non-trainable")
if not self.critic._trainable:
raise ValueError("DMD2Method requires critic to be trainable")
self._cfg_uncond = self._parse_cfg_uncond()
self._rollout_mode = self._parse_rollout_mode()
self._denoising_step_list: torch.Tensor | None = (None)
# Initialize preprocessors on student.
self.student.init_preprocessors(self.training_config)
self._init_optimizers_and_schedulers()
@property
def _optimizer_dict(self, ) -> dict[str, torch.optim.Optimizer]:
return {
"student": self._student_optimizer,
"critic": self._critic_optimizer,
}
@property
def _lr_scheduler_dict(self) -> dict[str, Any]:
return {
"student": self._student_lr_scheduler,
"critic": self._critic_lr_scheduler,
}
# TrainingMethod override: single_train_step
def single_train_step(
self,
batch: dict[str, Any],
iteration: int,
*,
current_vsa_sparsity: float = 0.0,
) -> tuple[
dict[str, torch.Tensor],
dict[str, Any],
dict[str, LogScalar],
]:
latents_source: Literal["data", "zeros"] = "data"
if self._rollout_mode == "simulate":
latents_source = "zeros"
training_batch = self.student.prepare_batch(
batch,
generator=self.cuda_generator,
current_vsa_sparsity=current_vsa_sparsity,
latents_source=latents_source,
)
update_student = self._should_update_student(iteration)
generator_loss = torch.zeros(
(),
device=training_batch.latents.device,
dtype=training_batch.latents.dtype,
)
student_ctx = None
if update_student:
generator_pred_x0 = self._student_rollout(training_batch, with_grad=True)
student_ctx = (
training_batch.timesteps,
training_batch.attn_metadata_vsa,
)
generator_loss = self._dmd_loss(generator_pred_x0, training_batch)
(
fake_score_loss,
critic_ctx,
critic_outputs,
) = self._critic_flow_matching_loss(training_batch)
total_loss = generator_loss + fake_score_loss
loss_map = {
"total_loss": total_loss,
"generator_loss": generator_loss,
"fake_score_loss": fake_score_loss,
}
outputs: dict[str, Any] = dict(critic_outputs)
outputs["_fv_backward"] = {
"update_student": update_student,
"student_ctx": student_ctx,
"critic_ctx": critic_ctx,
}
metrics: dict[str, LogScalar] = {"update_student": float(update_student)}
return loss_map, outputs, metrics
# TrainingMethod override: backward
def backward(
self,
loss_map: dict[str, torch.Tensor],
outputs: dict[str, Any],
*,
grad_accum_rounds: int = 1,
) -> None:
grad_accum_rounds = max(1, int(grad_accum_rounds))
backward_ctx = outputs.get("_fv_backward")
if not isinstance(backward_ctx, dict):
super().backward(
loss_map,
outputs,
grad_accum_rounds=grad_accum_rounds,
)
return
update_student = bool(backward_ctx.get("update_student", False))
if update_student:
student_ctx = backward_ctx.get("student_ctx")
if student_ctx is None:
raise RuntimeError("Missing student backward context")
self.student.backward(
loss_map["generator_loss"],
student_ctx,
grad_accum_rounds=grad_accum_rounds,
)
critic_ctx = backward_ctx.get("critic_ctx")
if critic_ctx is None:
raise RuntimeError("Missing critic backward context")
self.critic.backward(
loss_map["fake_score_loss"],
critic_ctx,
grad_accum_rounds=grad_accum_rounds,
)
# TrainingMethod override: get_optimizers
def get_optimizers(
self,
iteration: int,
) -> list[torch.optim.Optimizer]:
optimizers: list[torch.optim.Optimizer] = []
optimizers.append(self._critic_optimizer)
if self._should_update_student(iteration):
optimizers.append(self._student_optimizer)
return optimizers
# TrainingMethod override: get_lr_schedulers
def get_lr_schedulers(
self,
iteration: int,
) -> list[Any]:
schedulers: list[Any] = []
schedulers.append(self._critic_lr_scheduler)
if self._should_update_student(iteration):
schedulers.append(self._student_lr_scheduler)
return schedulers
# TrainingMethod override: get_grad_clip_targets
def get_grad_clip_targets(
self,
iteration: int,
) -> dict[str, torch.nn.Module]:
targets: dict[str, torch.nn.Module] = {}
if self._should_update_student(iteration):
targets["student"] = (self.student.transformer)
targets["critic"] = self.critic.transformer
return targets
def _parse_rollout_mode(self, ) -> Literal["simulate", "data_latent"]:
raw = self.method_config.get("rollout_mode", None)
if raw is None:
raise ValueError("method_config.rollout_mode must be set "
"for DMD2")
if not isinstance(raw, str):
raise ValueError("method_config.rollout_mode must be a "
"string, "
f"got {type(raw).__name__}")
mode = raw.strip().lower()
if mode in ("simulate", "sim"):
return "simulate"
if mode in ("data_latent", "data", "vae_latent"):
return "data_latent"
raise ValueError("method_config.rollout_mode must be one of "
"{simulate, data_latent}, got "
f"{raw!r}")
def _parse_cfg_uncond(self, ) -> dict[str, Any] | None:
raw = self.method_config.get("cfg_uncond", None)
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError("method_config.cfg_uncond must be a dict "
f"when set, got {type(raw).__name__}")
cfg: dict[str, Any] = dict(raw)
on_missing_raw = cfg.get("on_missing", "error")
if on_missing_raw is None:
on_missing_raw = "error"
if not isinstance(on_missing_raw, str):
raise ValueError("method_config.cfg_uncond.on_missing must "
"be a string, got "
f"{type(on_missing_raw).__name__}")
on_missing = on_missing_raw.strip().lower()
if on_missing not in {"error", "ignore"}:
raise ValueError("method_config.cfg_uncond.on_missing must "
"be one of {error, ignore}, got "
f"{on_missing_raw!r}")
cfg["on_missing"] = on_missing
for channel, policy_raw in list(cfg.items()):
if channel == "on_missing":
continue
if policy_raw is None:
continue
if not isinstance(policy_raw, str):
raise ValueError("method_config.cfg_uncond values must "
"be strings, got "
f"{channel}="
f"{type(policy_raw).__name__}")
policy = policy_raw.strip().lower()
allowed = {"keep", "zero", "drop"}
if channel == "text":
allowed = {*allowed, "negative_prompt"}
if policy not in allowed:
raise ValueError("method_config.cfg_uncond values must "
"be one of "
f"{sorted(allowed)}, got "
f"{channel}={policy_raw!r}")
cfg[channel] = policy
return cfg
def _init_optimizers_and_schedulers(self) -> None:
tc = self.training_config
# Student optimizer/scheduler.
student_lr = float(tc.optimizer.learning_rate)
student_betas = tc.optimizer.betas
student_sched = str(tc.optimizer.lr_scheduler)
student_params = [p for p in self.student.transformer.parameters() if p.requires_grad]
(
self._student_optimizer,
self._student_lr_scheduler,
) = build_optimizer_and_scheduler(
params=student_params,
optimizer_config=tc.optimizer,
loop_config=tc.loop,
learning_rate=student_lr,
betas=student_betas,
scheduler_name=student_sched,
)
# Critic optimizer/scheduler — must be set in
# method config.
critic_lr_raw = get_optional_float(
self.method_config,
"fake_score_learning_rate",
where="method.fake_score_learning_rate",
)
if critic_lr_raw is None or critic_lr_raw == 0.0:
raise ValueError("method.fake_score_learning_rate must "
"be set to a positive value")
critic_lr = float(critic_lr_raw)
critic_betas_raw = self.method_config.get("fake_score_betas", None)
if critic_betas_raw is None:
raise ValueError("method.fake_score_betas must be set "
"(e.g. [0.0, 0.999])")
critic_betas = parse_betas(
critic_betas_raw,
where="method.fake_score_betas",
)
critic_sched_raw = self.method_config.get("fake_score_lr_scheduler", None)
if critic_sched_raw is None:
raise ValueError("method.fake_score_lr_scheduler must "
"be set (e.g. 'constant')")
critic_sched = str(critic_sched_raw)
critic_params = [p for p in self.critic.transformer.parameters() if p.requires_grad]
(
self._critic_optimizer,
self._critic_lr_scheduler,
) = build_optimizer_and_scheduler(
params=critic_params,
optimizer_config=tc.optimizer,
loop_config=tc.loop,
learning_rate=critic_lr,
betas=critic_betas,
scheduler_name=critic_sched,
)
def _should_update_student(
self,
iteration: int,
) -> bool:
interval = get_optional_int(
self.method_config,
"generator_update_interval",
where="method.generator_update_interval",
)
if interval is None:
interval = 1
if interval <= 0:
return True
return iteration % interval == 0
def _get_denoising_step_list(
self,
device: torch.device,
) -> torch.Tensor:
if (self._denoising_step_list is not None and self._denoising_step_list.device == device):
return self._denoising_step_list
raw = self.method_config.get("dmd_denoising_steps", None)
if not isinstance(raw, list) or not raw:
raise ValueError("method_config.dmd_denoising_steps must "
"be set for DMD2 distillation")
steps = torch.tensor(
[int(s) for s in raw],
dtype=torch.long,
device=device,
)
warp = self.method_config.get("warp_denoising_step", None)
if warp is None:
warp = False
if bool(warp):
timesteps = torch.cat((
self.student.noise_scheduler.timesteps.to("cpu"),
torch.tensor([0], dtype=torch.float32),
)).to(device)
steps = timesteps[1000 - steps]
self._denoising_step_list = steps
return steps
def _sample_rollout_timestep(
self,
device: torch.device,
) -> torch.Tensor:
step_list = self._get_denoising_step_list(device)
index = torch.randint(
0,
len(step_list),
[1],
device=device,
dtype=torch.long,
generator=self.cuda_generator,
)
return step_list[index]
def _student_rollout(
self,
batch: Any,
*,
with_grad: bool,
) -> torch.Tensor:
latents = batch.latents
device = latents.device
dtype = latents.dtype
step_list = self._get_denoising_step_list(device)
if self._rollout_mode != "simulate":
timestep = self._sample_rollout_timestep(device)
noise = torch.randn(
latents.shape,
device=device,
dtype=dtype,
generator=self.cuda_generator,
)
noisy_latents = self.student.add_noise(latents, noise, timestep)
pred_x0 = self.student.predict_x0(
noisy_latents,
timestep,
batch,
conditional=True,
cfg_uncond=self._cfg_uncond,
attn_kind="vsa",
)
batch.dmd_latent_vis_dict["generator_timestep"] = timestep
return pred_x0
target_timestep_idx = torch.randint(
0,
len(step_list),
[1],
device=device,
dtype=torch.long,
generator=self.cuda_generator,
)
target_timestep_idx_int = int(target_timestep_idx.item())
target_timestep = step_list[target_timestep_idx]
current_noise_latents = torch.randn(
latents.shape,
device=device,
dtype=dtype,
generator=self.cuda_generator,
)
current_noise_latents_copy = (current_noise_latents.clone())
max_target_idx = len(step_list) - 1
noise_latents: list[torch.Tensor] = []
noise_latent_index = target_timestep_idx_int - 1
if max_target_idx > 0:
with torch.no_grad():
for step_idx in range(max_target_idx):
current_timestep = step_list[step_idx]
current_timestep_tensor = (current_timestep * torch.ones(
1,
device=device,
dtype=torch.long,
))
pred_clean = self.student.predict_x0(
current_noise_latents,
current_timestep_tensor,
batch,
conditional=True,
cfg_uncond=self._cfg_uncond,
attn_kind="vsa",
)
next_timestep = step_list[step_idx + 1]
next_timestep_tensor = (next_timestep * torch.ones(
1,
device=device,
dtype=torch.long,
))
noise = torch.randn(
latents.shape,
device=device,
dtype=pred_clean.dtype,
generator=self.cuda_generator,
)
current_noise_latents = (self.student.add_noise(
pred_clean,
noise,
next_timestep_tensor,
))
noise_latents.append(current_noise_latents.clone())
if noise_latent_index >= 0:
if noise_latent_index >= len(noise_latents):
raise RuntimeError("noise_latent_index is out of bounds")
noisy_input = noise_latents[noise_latent_index]
else:
noisy_input = current_noise_latents_copy
if with_grad:
pred_x0 = self.student.predict_x0(
noisy_input,
target_timestep,
batch,
conditional=True,
cfg_uncond=self._cfg_uncond,
attn_kind="vsa",
)
else:
with torch.no_grad():
pred_x0 = self.student.predict_x0(
noisy_input,
target_timestep,
batch,
conditional=True,
cfg_uncond=self._cfg_uncond,
attn_kind="vsa",
)
batch.dmd_latent_vis_dict["generator_timestep"] = target_timestep.float().detach()
return pred_x0
def _critic_flow_matching_loss(
self,
batch: Any,
) -> tuple[torch.Tensor, Any, dict[str, Any]]:
with torch.no_grad():
generator_pred_x0 = self._student_rollout(batch, with_grad=False)
device = generator_pred_x0.device
fake_score_timestep = torch.randint(
0,
int(self.student.num_train_timesteps),
[1],
device=device,
dtype=torch.long,
generator=self.cuda_generator,
)
fake_score_timestep = (self.student.shift_and_clamp_timestep(fake_score_timestep))
noise = torch.randn(
generator_pred_x0.shape,
device=device,
dtype=generator_pred_x0.dtype,
generator=self.cuda_generator,
)
noisy_x0 = self.student.add_noise(generator_pred_x0, noise, fake_score_timestep)
pred_noise = self.critic.predict_noise(
noisy_x0,
fake_score_timestep,
batch,
conditional=True,
cfg_uncond=self._cfg_uncond,
attn_kind="dense",
)
target = noise - generator_pred_x0
flow_matching_loss = torch.mean((pred_noise - target)**2)
batch.fake_score_latent_vis_dict = {
"generator_pred_video": generator_pred_x0,
"fake_score_timestep": fake_score_timestep,
}
outputs = {"fake_score_latent_vis_dict": (batch.fake_score_latent_vis_dict)}
return (
flow_matching_loss,
(batch.timesteps, batch.attn_metadata),
outputs,
)
def _dmd_loss(
self,
generator_pred_x0: torch.Tensor,
batch: Any,
) -> torch.Tensor:
guidance_scale = get_optional_float(
self.method_config,
"real_score_guidance_scale",
where="method.real_score_guidance_scale",
)
if guidance_scale is None:
guidance_scale = 1.0
device = generator_pred_x0.device
with torch.no_grad():
timestep = torch.randint(
0,
int(self.student.num_train_timesteps),
[1],
device=device,
dtype=torch.long,
generator=self.cuda_generator,
)
timestep = (self.student.shift_and_clamp_timestep(timestep))
noise = torch.randn(
generator_pred_x0.shape,
device=device,
dtype=generator_pred_x0.dtype,
generator=self.cuda_generator,
)
noisy_latents = self.student.add_noise(generator_pred_x0, noise, timestep)
faker_x0 = self.critic.predict_x0(
noisy_latents,
timestep,
batch,
conditional=True,
cfg_uncond=self._cfg_uncond,
attn_kind="dense",
)
real_cond_x0 = self.teacher.predict_x0(
noisy_latents,
timestep,
batch,
conditional=True,
cfg_uncond=self._cfg_uncond,
attn_kind="dense",
)
real_uncond_x0 = self.teacher.predict_x0(
noisy_latents,
timestep,
batch,
conditional=False,
cfg_uncond=self._cfg_uncond,
attn_kind="dense",
)
real_cfg_x0 = real_uncond_x0 + (real_cond_x0 - real_uncond_x0) * guidance_scale
denom = torch.abs(generator_pred_x0 - real_cfg_x0).mean()
grad = (faker_x0 - real_cfg_x0) / denom
grad = torch.nan_to_num(grad)
loss = 0.5 * F.mse_loss(
generator_pred_x0.float(),
(generator_pred_x0.float() - grad.float()).detach(),
)
return loss
@@ -0,0 +1,576 @@
# SPDX-License-Identifier: Apache-2.0
"""Self-Forcing distillation method (algorithm layer)."""
from __future__ import annotations
from typing import Any, Literal, TYPE_CHECKING
import torch
import torch.distributed as dist
from fastvideo.train.models.base import (
CausalModelBase,
ModelBase,
)
from fastvideo.train.methods.distribution_matching.dmd2 import (
DMD2Method, )
from fastvideo.train.utils.config import (
get_optional_float,
get_optional_int,
)
from fastvideo.models.schedulers.scheduling_self_forcing_flow_match import (
SelfForcingFlowMatchScheduler, )
from fastvideo.models.utils import pred_noise_to_pred_video
if TYPE_CHECKING:
from fastvideo.pipelines import TrainingBatch
def _require_bool(raw: Any, *, where: str) -> bool:
if isinstance(raw, bool):
return raw
raise ValueError(f"Expected bool at {where}, got {type(raw).__name__}")
def _require_str(raw: Any, *, where: str) -> str:
if not isinstance(raw, str) or not raw.strip():
raise ValueError(f"Expected non-empty string at {where}")
return raw
class SelfForcingMethod(DMD2Method):
"""Self-Forcing DMD2 (distribution matching) method.
Requires a causal student implementing ``CausalModelBase``.
"""
def __init__(
self,
*,
cfg: Any,
role_models: dict[str, ModelBase],
) -> None:
super().__init__(
cfg=cfg,
role_models=role_models,
)
# Validate causal student.
if not isinstance(self.student, CausalModelBase):
raise ValueError("SelfForcingMethod requires a causal student "
"implementing CausalModelBase.")
if self._rollout_mode != "simulate":
raise ValueError("SelfForcingMethod only supports "
"method_config.rollout_mode='simulate'")
mcfg = self.method_config
chunk_size = get_optional_int(
mcfg,
"chunk_size",
where="method_config.chunk_size",
)
if chunk_size is None:
chunk_size = 3
if chunk_size <= 0:
raise ValueError("method_config.chunk_size must be a positive "
f"integer, got {chunk_size}")
self._chunk_size = int(chunk_size)
sample_type_raw = mcfg.get("student_sample_type", "sde")
sample_type = _require_str(
sample_type_raw,
where="method_config.student_sample_type",
)
sample_type = sample_type.strip().lower()
if sample_type not in {"sde", "ode"}:
raise ValueError("method_config.student_sample_type must be one "
f"of {{sde, ode}}, got {sample_type_raw!r}")
self._student_sample_type: Literal["sde", "ode"] = (
sample_type # type: ignore[assignment]
)
same_step_raw = mcfg.get("same_step_across_blocks", False)
if same_step_raw is None:
same_step_raw = False
self._same_step_across_blocks = _require_bool(
same_step_raw,
where="method_config.same_step_across_blocks",
)
last_step_raw = mcfg.get("last_step_only", False)
if last_step_raw is None:
last_step_raw = False
self._last_step_only = _require_bool(
last_step_raw,
where="method_config.last_step_only",
)
context_noise = get_optional_float(
mcfg,
"context_noise",
where="method_config.context_noise",
)
if context_noise is None:
context_noise = 0.0
if context_noise < 0.0:
raise ValueError("method_config.context_noise must be >= 0, "
f"got {context_noise}")
self._context_noise = float(context_noise)
enable_grad_raw = mcfg.get("enable_gradient_in_rollout", True)
if enable_grad_raw is None:
enable_grad_raw = True
self._enable_gradient_in_rollout = _require_bool(
enable_grad_raw,
where="method_config.enable_gradient_in_rollout",
)
start_grad_frame = get_optional_int(
mcfg,
"start_gradient_frame",
where="method_config.start_gradient_frame",
)
if start_grad_frame is None:
start_grad_frame = 0
if start_grad_frame < 0:
raise ValueError("method_config.start_gradient_frame must be "
f">= 0, got {start_grad_frame}")
self._start_gradient_frame = int(start_grad_frame)
shift = float(getattr(
self.training_config.pipeline_config,
"flow_shift",
0.0,
) or 0.0)
self._sf_scheduler = SelfForcingFlowMatchScheduler(
num_inference_steps=1000,
num_train_timesteps=int(self.student.num_train_timesteps),
shift=shift,
sigma_min=0.0,
extra_one_step=True,
training=True,
)
self._sf_denoising_step_list: torch.Tensor | None = None
def _get_denoising_step_list(self, device: torch.device) -> torch.Tensor:
if (self._sf_denoising_step_list is not None and self._sf_denoising_step_list.device == device):
return self._sf_denoising_step_list
raw = self.method_config.get("dmd_denoising_steps", None)
if not isinstance(raw, list) or not raw:
raise ValueError("method_config.dmd_denoising_steps must be set "
"for self_forcing")
steps = torch.tensor(
[int(s) for s in raw],
dtype=torch.long,
device=device,
)
warp = self.method_config.get("warp_denoising_step", None)
if warp is None:
warp = False
if bool(warp):
timesteps = torch.cat((
self._sf_scheduler.timesteps.to("cpu"),
torch.tensor([0], dtype=torch.float32),
)).to(device)
steps = timesteps[int(self.student.num_train_timesteps) - steps]
self._sf_denoising_step_list = steps
return steps
def _predict_x0_with_scheduler(
self,
model: ModelBase,
noisy_latents: torch.Tensor,
timestep: torch.Tensor,
batch: TrainingBatch,
*,
conditional: bool,
attn_kind: Literal["dense", "vsa"],
) -> torch.Tensor:
pred_noise = model.predict_noise(
noisy_latents,
timestep,
batch,
conditional=conditional,
cfg_uncond=self._cfg_uncond,
attn_kind=attn_kind,
)
pred_x0 = pred_noise_to_pred_video(
pred_noise=pred_noise.flatten(0, 1),
noise_input_latent=noisy_latents.flatten(0, 1),
timestep=timestep,
scheduler=self._sf_scheduler,
).unflatten(0, pred_noise.shape[:2])
return pred_x0
def _sf_add_noise(
self,
clean_latents: torch.Tensor,
noise: torch.Tensor,
timestep: torch.Tensor,
) -> torch.Tensor:
b, t = clean_latents.shape[:2]
noisy = self._sf_scheduler.add_noise(
clean_latents.flatten(0, 1),
noise.flatten(0, 1),
timestep,
).unflatten(0, (b, t))
return noisy
def _timestep_to_sigma(self, timestep: torch.Tensor) -> torch.Tensor:
sigmas = self._sf_scheduler.sigmas.to(device=timestep.device, dtype=torch.float32)
timesteps = self._sf_scheduler.timesteps.to(device=timestep.device, dtype=torch.float32)
t = timestep.to(device=timestep.device, dtype=torch.float32)
if t.ndim == 2:
t = t.flatten(0, 1)
elif t.ndim == 1 and t.numel() == 1:
t = t.expand(1)
elif t.ndim != 1:
raise ValueError("Invalid timestep shape: "
f"{tuple(timestep.shape)}")
idx = torch.argmin(
(timesteps.unsqueeze(0) - t.unsqueeze(1)).abs(),
dim=1,
)
return sigmas[idx]
def _sample_exit_indices(
self,
*,
num_blocks: int,
num_steps: int,
device: torch.device,
) -> list[int]:
if num_blocks <= 0:
return []
if num_steps <= 0:
raise ValueError("num_steps must be positive")
shape = ((1, ) if self._same_step_across_blocks else (num_blocks, ))
if not dist.is_initialized() or dist.get_rank() == 0:
if self._last_step_only:
indices = torch.full(
shape,
num_steps - 1,
dtype=torch.long,
device=device,
)
else:
indices = torch.randint(
low=0,
high=num_steps,
size=shape,
device=device,
generator=self.cuda_generator,
)
else:
indices = torch.empty(shape, dtype=torch.long, device=device)
if dist.is_initialized():
dist.broadcast(indices, src=0)
if self._same_step_across_blocks:
return [int(indices.item()) for _ in range(num_blocks)]
return [int(i) for i in indices.tolist()]
def _student_rollout(self, batch: Any, *, with_grad: bool) -> torch.Tensor:
if not isinstance(self.student, CausalModelBase):
raise ValueError("SelfForcingMethod requires a causal student "
"implementing CausalModelBase.")
return self._student_rollout_streaming(batch, with_grad=with_grad)
def _student_rollout_streaming(self, batch: Any, *, with_grad: bool) -> torch.Tensor:
assert isinstance(self.student, CausalModelBase)
latents = batch.latents
if latents is None:
raise RuntimeError("TrainingBatch.latents is required for "
"self-forcing rollout")
if latents.ndim != 5:
raise ValueError("TrainingBatch.latents must be [B, T, C, H, W]"
f", got shape={tuple(latents.shape)}")
device = latents.device
dtype = latents.dtype
batch_size = int(latents.shape[0])
num_frames = int(latents.shape[1])
denoising_steps = self._get_denoising_step_list(device)
num_steps = int(denoising_steps.numel())
noise_full = torch.randn_like(latents, device=device, dtype=dtype)
chunk = int(self._chunk_size)
if chunk <= 0:
raise ValueError("chunk_size must be positive")
remaining = num_frames % chunk
num_blocks = num_frames // chunk
if num_blocks == 0:
num_blocks = 1
remaining = num_frames
exit_indices = self._sample_exit_indices(
num_blocks=num_blocks,
num_steps=num_steps,
device=device,
)
denoised_blocks: list[torch.Tensor] = []
cache_tag = "pos"
self.student.clear_caches(cache_tag=cache_tag)
for block_idx in range(num_blocks):
if block_idx == 0:
start = 0
end = remaining + chunk if remaining else chunk
else:
start = remaining + block_idx * chunk
end = remaining + (block_idx + 1) * chunk
start = int(start)
end = int(min(end, num_frames))
if start >= end:
break
noisy_block = noise_full[:, start:end]
exit_idx = int(exit_indices[block_idx])
for step_idx, current_timestep in enumerate(denoising_steps):
exit_flag = step_idx == exit_idx
timestep_block = (current_timestep * torch.ones(
(batch_size, end - start),
device=device,
dtype=torch.float32,
))
enable_grad = (bool(with_grad) and bool(self._enable_gradient_in_rollout) and torch.is_grad_enabled()
and start >= int(self._start_gradient_frame))
if not exit_flag:
with torch.no_grad():
pred_noise = (self.student.predict_noise_streaming(
noisy_block,
timestep_block,
batch,
conditional=True,
cache_tag=cache_tag,
store_kv=False,
cur_start_frame=start,
cfg_uncond=self._cfg_uncond,
attn_kind="vsa",
))
if pred_noise is None:
raise RuntimeError("predict_noise_streaming "
"returned None "
"(store_kv=False)")
pred_x0_chunk = pred_noise_to_pred_video(
pred_noise=pred_noise.flatten(0, 1),
noise_input_latent=(noisy_block.flatten(0, 1)),
timestep=timestep_block,
scheduler=self._sf_scheduler,
).unflatten(0, pred_noise.shape[:2])
if step_idx + 1 >= num_steps:
break
next_timestep = denoising_steps[step_idx + 1]
if self._student_sample_type == "sde":
noisy_block = self._sf_add_noise(
pred_x0_chunk,
torch.randn_like(pred_x0_chunk),
next_timestep * torch.ones(
(batch_size, end - start),
device=device,
dtype=torch.float32,
),
)
else:
sigma_cur = self._timestep_to_sigma(timestep_block).view(batch_size, end - start, 1, 1, 1)
sigma_next = self._timestep_to_sigma(next_timestep * torch.ones(
(batch_size, end - start),
device=device,
dtype=torch.float32,
)).view(batch_size, end - start, 1, 1, 1)
eps = (noisy_block - (1 - sigma_cur) * pred_x0_chunk) / sigma_cur.clamp_min(1e-8)
noisy_block = ((1 - sigma_next) * pred_x0_chunk + sigma_next * eps)
continue
with torch.set_grad_enabled(enable_grad):
pred_noise = (self.student.predict_noise_streaming(
noisy_block,
timestep_block,
batch,
conditional=True,
cache_tag=cache_tag,
store_kv=False,
cur_start_frame=start,
cfg_uncond=self._cfg_uncond,
attn_kind="vsa",
))
if pred_noise is None:
raise RuntimeError("predict_noise_streaming returned "
"None (store_kv=False)")
pred_x0_chunk = pred_noise_to_pred_video(
pred_noise=pred_noise.flatten(0, 1),
noise_input_latent=(noisy_block.flatten(0, 1)),
timestep=timestep_block,
scheduler=self._sf_scheduler,
).unflatten(0, pred_noise.shape[:2])
break
denoised_blocks.append(pred_x0_chunk)
with torch.no_grad():
if self._context_noise > 0.0:
context_timestep = torch.ones(
(batch_size, end - start),
device=device,
dtype=torch.float32,
) * float(self._context_noise)
context_latents = self._sf_add_noise(
pred_x0_chunk.detach(),
torch.randn_like(pred_x0_chunk),
context_timestep,
)
else:
context_timestep = torch.zeros(
(batch_size, end - start),
device=device,
dtype=torch.float32,
)
context_latents = pred_x0_chunk.detach()
_ = self.student.predict_noise_streaming(
context_latents,
context_timestep,
batch,
conditional=True,
cache_tag=cache_tag,
store_kv=True,
cur_start_frame=start,
cfg_uncond=self._cfg_uncond,
attn_kind="vsa",
)
if not denoised_blocks:
raise RuntimeError("Self-forcing rollout produced no blocks")
self.student.clear_caches(cache_tag=cache_tag)
return torch.cat(denoised_blocks, dim=1)
def _critic_flow_matching_loss(self, batch: Any) -> tuple[torch.Tensor, Any, dict[str, Any]]:
with torch.no_grad():
generator_pred_x0 = self._student_rollout(batch, with_grad=False)
device = generator_pred_x0.device
fake_score_timestep = torch.randint(
0,
int(self.student.num_train_timesteps),
[1],
device=device,
dtype=torch.long,
generator=self.cuda_generator,
)
fake_score_timestep = (self.student.shift_and_clamp_timestep(fake_score_timestep))
noise = torch.randn(
generator_pred_x0.shape,
device=device,
dtype=generator_pred_x0.dtype,
generator=self.cuda_generator,
)
noisy_x0 = self._sf_add_noise(generator_pred_x0, noise, fake_score_timestep)
pred_noise = self.critic.predict_noise(
noisy_x0,
fake_score_timestep,
batch,
conditional=True,
cfg_uncond=self._cfg_uncond,
attn_kind="dense",
)
target = noise - generator_pred_x0
flow_matching_loss = torch.mean((pred_noise - target)**2)
batch.fake_score_latent_vis_dict = {
"generator_pred_video": generator_pred_x0,
"fake_score_timestep": fake_score_timestep,
}
outputs = {"fake_score_latent_vis_dict": (batch.fake_score_latent_vis_dict)}
return (
flow_matching_loss,
(batch.timesteps, batch.attn_metadata),
outputs,
)
def _dmd_loss(
self,
generator_pred_x0: torch.Tensor,
batch: Any,
) -> torch.Tensor:
guidance_scale = get_optional_float(
self.method_config,
"real_score_guidance_scale",
where="method.real_score_guidance_scale",
)
if guidance_scale is None:
guidance_scale = 1.0
device = generator_pred_x0.device
with torch.no_grad():
timestep = torch.randint(
0,
int(self.student.num_train_timesteps),
[1],
device=device,
dtype=torch.long,
generator=self.cuda_generator,
)
timestep = self.student.shift_and_clamp_timestep(timestep)
noise = torch.randn(
generator_pred_x0.shape,
device=device,
dtype=generator_pred_x0.dtype,
generator=self.cuda_generator,
)
noisy_latents = self._sf_add_noise(generator_pred_x0, noise, timestep)
faker_x0 = self._predict_x0_with_scheduler(
self.critic,
noisy_latents,
timestep,
batch,
conditional=True,
attn_kind="dense",
)
real_cond_x0 = self._predict_x0_with_scheduler(
self.teacher,
noisy_latents,
timestep,
batch,
conditional=True,
attn_kind="dense",
)
real_uncond_x0 = self._predict_x0_with_scheduler(
self.teacher,
noisy_latents,
timestep,
batch,
conditional=False,
attn_kind="dense",
)
real_cfg_x0 = real_uncond_x0 + (real_cond_x0 - real_uncond_x0) * guidance_scale
denom = torch.abs(generator_pred_x0 - real_cfg_x0).mean()
grad = (faker_x0 - real_cfg_x0) / denom
grad = torch.nan_to_num(grad)
loss = 0.5 * torch.mean((generator_pred_x0.float() - (generator_pred_x0.float() - grad.float()).detach())**2)
return loss
@@ -0,0 +1,28 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from fastvideo.train.methods.fine_tuning.dfsft import DiffusionForcingSFTMethod
from fastvideo.train.methods.fine_tuning.finetune import FineTuneMethod
__all__ = [
"DiffusionForcingSFTMethod",
"FineTuneMethod",
]
def __getattr__(name: str) -> object:
# Lazy import to avoid circular imports during registry bring-up.
if name == "DiffusionForcingSFTMethod":
from fastvideo.train.methods.fine_tuning.dfsft import (
DiffusionForcingSFTMethod, )
return DiffusionForcingSFTMethod
if name == "FineTuneMethod":
from fastvideo.train.methods.fine_tuning.finetune import FineTuneMethod
return FineTuneMethod
raise AttributeError(name)
@@ -0,0 +1,325 @@
# SPDX-License-Identifier: Apache-2.0
"""Diffusion-forcing SFT method (DFSFT; algorithm layer)."""
from __future__ import annotations
from typing import Any, Literal
import torch
import torch.nn.functional as F
from fastvideo.train.methods.base import TrainingMethod, LogScalar
from fastvideo.train.models.base import ModelBase
from fastvideo.train.utils.optimizer import (
build_optimizer_and_scheduler, )
class DiffusionForcingSFTMethod(TrainingMethod):
"""Diffusion-forcing SFT (DFSFT): train only ``student``
with inhomogeneous timesteps.
"""
def __init__(
self,
*,
cfg: Any,
role_models: dict[str, ModelBase],
) -> None:
super().__init__(cfg=cfg, role_models=role_models)
if "student" not in role_models:
raise ValueError("DFSFT requires role 'student'")
if not self.student._trainable:
raise ValueError("DFSFT requires student to be trainable")
self._attn_kind: Literal["dense", "vsa"] = (self._parse_attn_kind(self.method_config.get("attn_kind", None)))
self._chunk_size = self._parse_chunk_size(self.method_config.get("chunk_size", None))
self._timestep_index_range = (self._parse_timestep_index_range())
# Initialize preprocessors on student.
self.student.init_preprocessors(self.training_config)
self._init_optimizers_and_schedulers()
@property
def _optimizer_dict(self) -> dict[str, Any]:
return {"student": self._student_optimizer}
@property
def _lr_scheduler_dict(self) -> dict[str, Any]:
return {"student": self._student_lr_scheduler}
# TrainingMethod override: single_train_step
def single_train_step(
self,
batch: dict[str, Any],
iteration: int,
*,
current_vsa_sparsity: float = 0.0,
) -> tuple[
dict[str, torch.Tensor],
dict[str, Any],
dict[str, LogScalar],
]:
del iteration
training_batch = self.student.prepare_batch(
batch,
generator=self.cuda_generator,
current_vsa_sparsity=current_vsa_sparsity,
latents_source="data",
)
if training_batch.latents is None:
raise RuntimeError("prepare_batch() must set TrainingBatch.latents")
clean_latents = training_batch.latents
if not torch.is_tensor(clean_latents):
raise TypeError("TrainingBatch.latents must be a torch.Tensor")
if clean_latents.ndim != 5:
raise ValueError("TrainingBatch.latents must be "
"[B, T, C, H, W], got "
f"shape={tuple(clean_latents.shape)}")
batch_size, num_latents = (
int(clean_latents.shape[0]),
int(clean_latents.shape[1]),
)
expected_chunk = getattr(
self.student.transformer,
"num_frame_per_block",
None,
)
if (expected_chunk is not None and int(expected_chunk) != int(self._chunk_size)):
raise ValueError("DFSFT chunk_size must match "
"transformer.num_frame_per_block for "
f"causal training (got {self._chunk_size}, "
f"expected {expected_chunk}).")
timestep_indices = self._sample_t_inhom_indices(
batch_size=batch_size,
num_latents=num_latents,
device=clean_latents.device,
)
sp_size = int(self.training_config.distributed.sp_size)
sp_group = getattr(self.student, "sp_group", None)
if (sp_size > 1 and sp_group is not None and hasattr(sp_group, "broadcast")):
sp_group.broadcast(timestep_indices, src=0)
scheduler = self.student.noise_scheduler
if scheduler is None:
raise ValueError("DFSFT requires student.noise_scheduler")
schedule_timesteps = scheduler.timesteps.to(device=clean_latents.device, dtype=torch.float32)
schedule_sigmas = scheduler.sigmas.to(
device=clean_latents.device,
dtype=clean_latents.dtype,
)
t_inhom = schedule_timesteps[timestep_indices]
# Override the homogeneous timesteps from prepare_batch
# so that set_forward_context (in predict_noise and
# backward) receives the correct per-chunk timesteps.
training_batch.timesteps = t_inhom
noise = getattr(training_batch, "noise", None)
if noise is None:
noise = torch.randn_like(clean_latents)
else:
if not torch.is_tensor(noise):
raise TypeError("TrainingBatch.noise must be a "
"torch.Tensor when set")
noise = noise.permute(0, 2, 1, 3, 4).to(dtype=clean_latents.dtype)
noisy_latents = self.student.add_noise(
clean_latents,
noise,
t_inhom.flatten(),
)
pred = self.student.predict_noise(
noisy_latents,
t_inhom,
training_batch,
conditional=True,
attn_kind=self._attn_kind,
)
if bool(self.training_config.model.precondition_outputs):
sigmas = schedule_sigmas[timestep_indices]
sigmas = sigmas.unsqueeze(-1).unsqueeze(-1).unsqueeze(-1)
pred_x0 = noisy_latents - pred * sigmas
loss = F.mse_loss(pred_x0.float(), clean_latents.float())
else:
target = noise - clean_latents
loss = F.mse_loss(pred.float(), target.float())
if self._attn_kind == "vsa":
attn_metadata = training_batch.attn_metadata_vsa
else:
attn_metadata = training_batch.attn_metadata
loss_map = {"total_loss": loss, "dfsft_loss": loss}
outputs: dict[str, Any] = {
"_fv_backward": (
training_batch.timesteps,
attn_metadata,
)
}
metrics: dict[str, LogScalar] = {}
return loss_map, outputs, metrics
# TrainingMethod override: backward
def backward(
self,
loss_map: dict[str, torch.Tensor],
outputs: dict[str, Any],
*,
grad_accum_rounds: int = 1,
) -> None:
grad_accum_rounds = max(1, int(grad_accum_rounds))
ctx = outputs.get("_fv_backward")
if ctx is None:
super().backward(
loss_map,
outputs,
grad_accum_rounds=grad_accum_rounds,
)
return
self.student.backward(
loss_map["total_loss"],
ctx,
grad_accum_rounds=grad_accum_rounds,
)
# TrainingMethod override: get_optimizers
def get_optimizers(
self,
iteration: int,
) -> list[torch.optim.Optimizer]:
del iteration
return [self._student_optimizer]
# TrainingMethod override: get_lr_schedulers
def get_lr_schedulers(
self,
iteration: int,
) -> list[Any]:
del iteration
return [self._student_lr_scheduler]
def _parse_chunk_size(self, raw: Any) -> int:
if raw in (None, ""):
return 3
if isinstance(raw, bool):
raise ValueError("method_config.chunk_size must be an int, "
"got bool")
if isinstance(raw, float) and not raw.is_integer():
raise ValueError("method_config.chunk_size must be an int, "
"got float")
if isinstance(raw, str) and not raw.strip():
raise ValueError("method_config.chunk_size must be an int, "
"got empty string")
try:
value = int(raw)
except (TypeError, ValueError) as e:
raise ValueError("method_config.chunk_size must be an int, "
f"got {type(raw).__name__}") from e
if value <= 0:
raise ValueError("method_config.chunk_size must be > 0")
return value
def _parse_ratio(
self,
raw: Any,
*,
where: str,
default: float,
) -> float:
if raw in (None, ""):
return float(default)
if isinstance(raw, bool):
raise ValueError(f"{where} must be a number/string, got bool")
if isinstance(raw, int | float):
return float(raw)
if isinstance(raw, str) and raw.strip():
return float(raw)
raise ValueError(f"{where} must be a number/string, "
f"got {type(raw).__name__}")
def _parse_timestep_index_range(self, ) -> tuple[int, int]:
scheduler = self.student.noise_scheduler
if scheduler is None:
raise ValueError("DFSFT requires student.noise_scheduler")
num_steps = int(getattr(scheduler, "config", scheduler).num_train_timesteps)
min_ratio = self._parse_ratio(
self.method_config.get("min_timestep_ratio", None),
where="method.min_timestep_ratio",
default=0.0,
)
max_ratio = self._parse_ratio(
self.method_config.get("max_timestep_ratio", None),
where="method.max_timestep_ratio",
default=1.0,
)
if not (0.0 <= min_ratio <= 1.0 and 0.0 <= max_ratio <= 1.0):
raise ValueError("DFSFT timestep ratios must be in [0,1], "
f"got min={min_ratio}, max={max_ratio}")
if max_ratio < min_ratio:
raise ValueError("method_config.max_timestep_ratio must be "
">= min_timestep_ratio")
min_index = int(min_ratio * num_steps)
max_index = int(max_ratio * num_steps)
min_index = max(0, min(min_index, num_steps - 1))
max_index = max(0, min(max_index, num_steps - 1))
if max_index <= min_index:
max_index = min(num_steps - 1, min_index + 1)
return min_index, max_index + 1
def _init_optimizers_and_schedulers(self) -> None:
tc = self.training_config
student_lr = float(tc.optimizer.learning_rate)
if student_lr <= 0.0:
raise ValueError("training.learning_rate must be > 0 "
"for dfsft")
student_betas = tc.optimizer.betas
student_sched = str(tc.optimizer.lr_scheduler)
student_params = [p for p in self.student.transformer.parameters() if p.requires_grad]
(
self._student_optimizer,
self._student_lr_scheduler,
) = build_optimizer_and_scheduler(
params=student_params,
optimizer_config=tc.optimizer,
loop_config=tc.loop,
learning_rate=student_lr,
betas=student_betas,
scheduler_name=student_sched,
)
def _sample_t_inhom_indices(
self,
*,
batch_size: int,
num_latents: int,
device: torch.device,
) -> torch.Tensor:
chunk_size = self._chunk_size
num_chunks = ((num_latents + chunk_size - 1) // chunk_size)
low, high = self._timestep_index_range
chunk_indices = torch.randint(
low=low,
high=high,
size=(batch_size, num_chunks),
device=device,
dtype=torch.long,
generator=self.cuda_generator,
)
expanded = chunk_indices.repeat_interleave(chunk_size, dim=1)
return expanded[:, :num_latents]
@@ -0,0 +1,183 @@
# SPDX-License-Identifier: Apache-2.0
"""Supervised finetuning method (algorithm layer)."""
from __future__ import annotations
from typing import Any, Literal
import torch
import torch.nn.functional as F
from fastvideo.train.methods.base import TrainingMethod, LogScalar
from fastvideo.train.models.base import ModelBase
from fastvideo.train.utils.optimizer import (
build_optimizer_and_scheduler, )
class FineTuneMethod(TrainingMethod):
"""Supervised finetuning: only ``student`` participates."""
def __init__(
self,
*,
cfg: Any,
role_models: dict[str, ModelBase],
) -> None:
super().__init__(cfg=cfg, role_models=role_models)
if "student" not in role_models:
raise ValueError("FineTuneMethod requires role 'student'")
if not self.student._trainable:
raise ValueError("FineTuneMethod requires student to be "
"trainable")
self._attn_kind: Literal["dense", "vsa"] = (self._parse_attn_kind(self.method_config.get("attn_kind", None)))
# Initialize preprocessors on student.
self.student.init_preprocessors(self.training_config)
self._init_optimizers_and_schedulers()
@property
def _optimizer_dict(self) -> dict[str, Any]:
return {"student": self._student_optimizer}
@property
def _lr_scheduler_dict(self) -> dict[str, Any]:
return {"student": self._student_lr_scheduler}
# TrainingMethod override: single_train_step
def single_train_step(
self,
batch: dict[str, Any],
iteration: int,
*,
current_vsa_sparsity: float = 0.0,
) -> tuple[
dict[str, torch.Tensor],
dict[str, Any],
dict[str, LogScalar],
]:
del iteration
training_batch = self.student.prepare_batch(
batch,
generator=self.cuda_generator,
current_vsa_sparsity=current_vsa_sparsity,
latents_source="data",
)
if training_batch.latents is None:
raise RuntimeError("prepare_batch() must set "
"TrainingBatch.latents")
if training_batch.noisy_model_input is None:
raise RuntimeError("prepare_batch() must set "
"TrainingBatch.noisy_model_input")
if training_batch.noise is None:
raise RuntimeError("prepare_batch() must set "
"TrainingBatch.noise")
if training_batch.sigmas is None:
raise RuntimeError("prepare_batch() must set "
"TrainingBatch.sigmas")
if training_batch.timesteps is None:
raise RuntimeError("prepare_batch() must set "
"TrainingBatch.timesteps")
clean_latents = training_batch.latents
noisy_latents = (training_batch.noisy_model_input.permute(0, 2, 1, 3, 4))
noise = training_batch.noise.permute(0, 2, 1, 3, 4)
sigmas = training_batch.sigmas
timesteps = training_batch.timesteps
pred = self.student.predict_noise(
noisy_latents,
timesteps,
training_batch,
conditional=True,
attn_kind=self._attn_kind,
)
if bool(self.training_config.model.precondition_outputs):
pred_x0 = noisy_latents - pred * sigmas
loss = F.mse_loss(pred_x0.float(), clean_latents.float())
else:
target = noise - clean_latents
loss = F.mse_loss(pred.float(), target.float())
if self._attn_kind == "vsa":
attn_metadata = training_batch.attn_metadata_vsa
else:
attn_metadata = training_batch.attn_metadata
loss_map = {
"total_loss": loss,
"finetune_loss": loss,
}
outputs: dict[str, Any] = {
"_fv_backward": (
training_batch.timesteps,
attn_metadata,
)
}
metrics: dict[str, LogScalar] = {}
return loss_map, outputs, metrics
# TrainingMethod override: backward
def backward(
self,
loss_map: dict[str, torch.Tensor],
outputs: dict[str, Any],
*,
grad_accum_rounds: int = 1,
) -> None:
grad_accum_rounds = max(1, int(grad_accum_rounds))
ctx = outputs.get("_fv_backward")
if ctx is None:
super().backward(
loss_map,
outputs,
grad_accum_rounds=grad_accum_rounds,
)
return
self.student.backward(
loss_map["total_loss"],
ctx,
grad_accum_rounds=grad_accum_rounds,
)
# TrainingMethod override: get_optimizers
def get_optimizers(
self,
iteration: int,
) -> list[torch.optim.Optimizer]:
del iteration
return [self._student_optimizer]
# TrainingMethod override: get_lr_schedulers
def get_lr_schedulers(
self,
iteration: int,
) -> list[Any]:
del iteration
return [self._student_lr_scheduler]
def _init_optimizers_and_schedulers(self) -> None:
tc = self.training_config
student_lr = float(tc.optimizer.learning_rate)
if student_lr <= 0.0:
raise ValueError("training.learning_rate must be > 0 "
"for finetune")
student_betas = tc.optimizer.betas
student_sched = str(tc.optimizer.lr_scheduler)
student_params = [p for p in self.student.transformer.parameters() if p.requires_grad]
(
self._student_optimizer,
self._student_lr_scheduler,
) = build_optimizer_and_scheduler(
params=student_params,
optimizer_config=tc.optimizer,
loop_config=tc.loop,
learning_rate=student_lr,
betas=student_betas,
scheduler_name=student_sched,
)
@@ -0,0 +1,3 @@
# SPDX-License-Identifier: Apache-2.0
__all__: list[str] = []
+5
View File
@@ -0,0 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
"""Model build plugins for Phase 2/2.9 distillation.
These are "model plugins" selected by ``recipe.family`` / ``roles.<role>.family``.
"""
+196
View File
@@ -0,0 +1,196 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, Literal, TYPE_CHECKING
import torch
from fastvideo.models.utils import pred_noise_to_pred_video
if TYPE_CHECKING:
from fastvideo.train.utils.training_config import (
TrainingConfig, )
from fastvideo.pipelines import TrainingBatch
class ModelBase(ABC):
"""Per-role model instance.
Every role (student, teacher, critic, …) gets its own ``ModelBase``
instance. Each instance owns its own ``transformer`` and
``noise_scheduler``. Heavyweight resources (VAE, dataloader, RNG
seeds) are loaded lazily via :meth:`init_preprocessors`, which the
method calls **only on the student**.
"""
transformer: torch.nn.Module
noise_scheduler: Any
_trainable: bool
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def init_preprocessors( # noqa: B027
self,
training_config: TrainingConfig,
) -> None:
"""Load VAE, build dataloader, seed RNGs.
Called only on the student by the method's ``__init__``.
Default is a no-op so teacher/critic instances skip this.
"""
def on_train_start(self) -> None: # noqa: B027
"""Called once before the training loop begins."""
# ------------------------------------------------------------------
# Timestep helpers
# ------------------------------------------------------------------
@property
def num_train_timesteps(self) -> int:
"""Return the scheduler's training timestep horizon."""
return int(self.noise_scheduler.num_train_timesteps)
def shift_and_clamp_timestep(self, timestep: torch.Tensor) -> torch.Tensor:
"""Apply model/pipeline timestep shifting and clamp."""
return timestep
# ------------------------------------------------------------------
# Runtime primitives
# ------------------------------------------------------------------
@abstractmethod
def prepare_batch(
self,
raw_batch: dict[str, Any],
*,
generator: torch.Generator,
current_vsa_sparsity: float = 0.0,
latents_source: Literal["data", "zeros"] = "data",
) -> TrainingBatch:
"""Convert a dataloader batch into forward primitives."""
@abstractmethod
def add_noise(
self,
clean_latents: torch.Tensor,
noise: torch.Tensor,
timestep: torch.Tensor,
) -> torch.Tensor:
"""Apply forward-process noise at *timestep*."""
@abstractmethod
def predict_noise(
self,
noisy_latents: torch.Tensor,
timestep: torch.Tensor,
batch: TrainingBatch,
*,
conditional: bool,
cfg_uncond: dict[str, Any] | None = None,
attn_kind: Literal["dense", "vsa"] = "dense",
) -> torch.Tensor:
"""Predict noise/flow for the given noisy latents."""
def predict_x0(
self,
noisy_latents: torch.Tensor,
timestep: torch.Tensor,
batch: TrainingBatch,
*,
conditional: bool,
cfg_uncond: dict[str, Any] | None = None,
attn_kind: Literal["dense", "vsa"] = "dense",
) -> torch.Tensor:
"""Predict x0 via ``predict_noise`` + conversion."""
pred_noise = self.predict_noise(
noisy_latents,
timestep,
batch,
conditional=conditional,
cfg_uncond=cfg_uncond,
attn_kind=attn_kind,
)
return pred_noise_to_pred_video(
pred_noise=pred_noise.flatten(0, 1),
noise_input_latent=noisy_latents.flatten(0, 1),
timestep=timestep,
scheduler=self.noise_scheduler,
).unflatten(0, pred_noise.shape[:2])
@abstractmethod
def backward(
self,
loss: torch.Tensor,
ctx: Any,
*,
grad_accum_rounds: int,
) -> None:
"""Backward that may restore forward-context."""
class CausalModelBase(ModelBase):
"""Extension for causal / streaming model plugins.
Cache state is internal to the model instance and keyed by
*cache_tag* (no role handle needed).
"""
@abstractmethod
def clear_caches(self, *, cache_tag: str = "pos") -> None:
"""Clear internal caches before starting a new rollout."""
@abstractmethod
def predict_noise_streaming(
self,
noisy_latents: torch.Tensor,
timestep: torch.Tensor,
batch: TrainingBatch,
*,
conditional: bool,
cache_tag: str = "pos",
store_kv: bool = False,
cur_start_frame: int = 0,
cfg_uncond: dict[str, Any] | None = None,
attn_kind: Literal["dense", "vsa"] = "dense",
) -> torch.Tensor | None:
"""Streaming predict-noise that may update internal caches."""
def predict_x0_streaming(
self,
noisy_latents: torch.Tensor,
timestep: torch.Tensor,
batch: TrainingBatch,
*,
conditional: bool,
cache_tag: str = "pos",
store_kv: bool = False,
cur_start_frame: int = 0,
cfg_uncond: dict[str, Any] | None = None,
attn_kind: Literal["dense", "vsa"] = "dense",
) -> torch.Tensor | None:
"""Predict x0 streaming via
``predict_noise_streaming`` + conversion."""
pred_noise = self.predict_noise_streaming(
noisy_latents,
timestep,
batch,
conditional=conditional,
cache_tag=cache_tag,
store_kv=store_kv,
cur_start_frame=cur_start_frame,
cfg_uncond=cfg_uncond,
attn_kind=attn_kind,
)
if pred_noise is None:
return None
return pred_noise_to_pred_video(
pred_noise=pred_noise.flatten(0, 1),
noise_input_latent=noisy_latents.flatten(0, 1),
timestep=timestep,
scheduler=self.noise_scheduler,
).unflatten(0, pred_noise.shape[:2])
+7
View File
@@ -0,0 +1,7 @@
# SPDX-License-Identifier: Apache-2.0
"""Wan model plugin package."""
from fastvideo.train.models.wan.wan import (
WanModel as WanModel, )
from fastvideo.train.models.wan.wan_causal import (
WanCausalModel as WanCausalModel, )
+668
View File
@@ -0,0 +1,668 @@
# SPDX-License-Identifier: Apache-2.0
"""Wan model plugin (per-role instance)."""
from __future__ import annotations
import copy
import gc
from typing import Any, Literal, TYPE_CHECKING
import torch
import fastvideo.envs as envs
from fastvideo.configs.sample import SamplingParam
from fastvideo.distributed import (
get_local_torch_device,
get_sp_group,
get_world_group,
)
from fastvideo.forward_context import set_forward_context
from fastvideo.models.schedulers.scheduling_flow_match_euler_discrete import (
FlowMatchEulerDiscreteScheduler, )
from fastvideo.pipelines import TrainingBatch
from fastvideo.pipelines.basic.wan.wan_pipeline import (
WanPipeline, )
from fastvideo.pipelines.pipeline_batch_info import (
ForwardBatch, )
from fastvideo.training.activation_checkpoint import (
apply_activation_checkpointing, )
from fastvideo.training.training_utils import (
compute_density_for_timestep_sampling,
get_sigmas,
normalize_dit_input,
shift_timestep,
)
from fastvideo.utils import (
is_vmoba_available,
is_vsa_available,
)
from fastvideo.train.models.base import ModelBase
from fastvideo.train.utils.module_state import (
apply_trainable, )
from fastvideo.train.utils.moduleloader import (
load_module_from_path, )
if TYPE_CHECKING:
from fastvideo.train.utils.training_config import (
TrainingConfig, )
try:
from fastvideo.attention.backends.video_sparse_attn import (
VideoSparseAttentionMetadataBuilder, )
from fastvideo.attention.backends.vmoba import (
VideoMobaAttentionMetadataBuilder, )
except Exception:
VideoSparseAttentionMetadataBuilder = None # type: ignore[assignment]
VideoMobaAttentionMetadataBuilder = None # type: ignore[assignment]
class WanModel(ModelBase):
"""Wan per-role model: owns transformer + noise_scheduler."""
_transformer_cls_name: str = "WanTransformer3DModel"
def __init__(
self,
*,
init_from: str,
training_config: TrainingConfig,
trainable: bool = True,
disable_custom_init_weights: bool = False,
flow_shift: float = 3.0,
enable_gradient_checkpointing_type: str
| None = None,
) -> None:
self._init_from = str(init_from)
self._trainable = bool(trainable)
self.transformer = self._load_transformer(
init_from=self._init_from,
trainable=self._trainable,
disable_custom_init_weights=(disable_custom_init_weights),
enable_gradient_checkpointing_type=(enable_gradient_checkpointing_type),
training_config=training_config,
)
self.noise_scheduler = (FlowMatchEulerDiscreteScheduler(shift=float(flow_shift)))
# Filled by init_preprocessors (student only).
self.vae: Any = None
self.training_config: TrainingConfig = training_config
self.dataloader: Any = None
self.validator: Any = None
self.start_step: int = 0
self.world_group: Any = None
self.sp_group: Any = None
self.device: Any = get_local_torch_device()
self.negative_prompt_embeds: (torch.Tensor | None) = None
self.negative_prompt_attention_mask: (torch.Tensor | None) = None
# Timestep mechanics.
self.timestep_shift: float = float(flow_shift)
self.num_train_timestep: int = int(self.noise_scheduler.num_train_timesteps)
self.min_timestep: int = 0
self.max_timestep: int = self.num_train_timestep
def _load_transformer(
self,
*,
init_from: str,
trainable: bool,
disable_custom_init_weights: bool,
enable_gradient_checkpointing_type: str | None,
training_config: TrainingConfig,
) -> torch.nn.Module:
transformer = load_module_from_path(
model_path=init_from,
module_type="transformer",
training_config=training_config,
disable_custom_init_weights=(disable_custom_init_weights),
override_transformer_cls_name=(self._transformer_cls_name),
)
transformer = apply_trainable(transformer, trainable=trainable)
# Fall back to training_config.model if not set on the
# model YAML section directly.
ckpt_type = (enable_gradient_checkpointing_type or getattr(
getattr(training_config, "model", None),
"enable_gradient_checkpointing_type",
None,
))
if trainable and ckpt_type:
transformer = apply_activation_checkpointing(
transformer,
checkpointing_type=ckpt_type,
)
return transformer
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def init_preprocessors(self, training_config: TrainingConfig) -> None:
self.vae = load_module_from_path(
model_path=str(training_config.model_path),
module_type="vae",
training_config=training_config,
)
self.world_group = get_world_group()
self.sp_group = get_sp_group()
self._init_timestep_mechanics()
from fastvideo.dataset.dataloader.schema import (
pyarrow_schema_t2v, )
from fastvideo.train.utils.dataloader import (
build_parquet_t2v_train_dataloader, )
text_len = (
training_config.pipeline_config.text_encoder_configs[ # type: ignore[union-attr]
0].arch_config.text_len)
self.dataloader = build_parquet_t2v_train_dataloader(
training_config.data,
text_len=int(text_len),
parquet_schema=pyarrow_schema_t2v,
)
self.start_step = 0
@property
def num_train_timesteps(self) -> int:
return int(self.num_train_timestep)
def shift_and_clamp_timestep(self, timestep: torch.Tensor) -> torch.Tensor:
timestep = shift_timestep(
timestep,
self.timestep_shift,
self.num_train_timestep,
)
return timestep.clamp(self.min_timestep, self.max_timestep)
def on_train_start(self) -> None:
self.ensure_negative_conditioning()
# ------------------------------------------------------------------
# Runtime primitives
# ------------------------------------------------------------------
def prepare_batch(
self,
raw_batch: dict[str, Any],
*,
generator: torch.Generator,
current_vsa_sparsity: float = 0.0,
latents_source: Literal["data", "zeros"] = "data",
) -> TrainingBatch:
self.ensure_negative_conditioning()
assert self.training_config is not None
tc = self.training_config
dtype = self._get_training_dtype()
device = self.device
training_batch = TrainingBatch(current_vsa_sparsity=current_vsa_sparsity)
encoder_hidden_states = raw_batch["text_embedding"]
encoder_attention_mask = raw_batch["text_attention_mask"]
infos = raw_batch.get("info_list")
if latents_source == "zeros":
batch_size = encoder_hidden_states.shape[0]
vae_config = (
tc.pipeline_config.vae_config.arch_config # type: ignore[union-attr]
)
num_channels = vae_config.z_dim
spatial_compression_ratio = (vae_config.spatial_compression_ratio)
latent_height = (tc.data.num_height // spatial_compression_ratio)
latent_width = (tc.data.num_width // spatial_compression_ratio)
latents = torch.zeros(
batch_size,
num_channels,
tc.data.num_latent_t,
latent_height,
latent_width,
device=device,
dtype=dtype,
)
elif latents_source == "data":
if "vae_latent" not in raw_batch:
raise ValueError("vae_latent not found in batch "
"and latents_source='data'")
latents = raw_batch["vae_latent"]
latents = latents[:, :, :tc.data.num_latent_t]
latents = latents.to(device, dtype=dtype)
else:
raise ValueError(f"Unknown latents_source: "
f"{latents_source!r}")
training_batch.latents = latents
training_batch.encoder_hidden_states = (encoder_hidden_states.to(device, dtype=dtype))
training_batch.encoder_attention_mask = (encoder_attention_mask.to(device, dtype=dtype))
training_batch.infos = infos
training_batch.latents = normalize_dit_input("wan", training_batch.latents, self.vae)
training_batch = self._prepare_dit_inputs(training_batch, generator)
training_batch = self._build_attention_metadata(training_batch)
training_batch.attn_metadata_vsa = copy.deepcopy(training_batch.attn_metadata)
if training_batch.attn_metadata is not None:
training_batch.attn_metadata.VSA_sparsity = 0.0 # type: ignore[attr-defined]
return training_batch
def add_noise(
self,
clean_latents: torch.Tensor,
noise: torch.Tensor,
timestep: torch.Tensor,
) -> torch.Tensor:
b, t = clean_latents.shape[:2]
noisy = self.noise_scheduler.add_noise(
clean_latents.flatten(0, 1),
noise.flatten(0, 1),
timestep,
).unflatten(0, (b, t))
return noisy
def predict_noise(
self,
noisy_latents: torch.Tensor,
timestep: torch.Tensor,
batch: TrainingBatch,
*,
conditional: bool,
cfg_uncond: dict[str, Any] | None = None,
attn_kind: Literal["dense", "vsa"] = "dense",
) -> torch.Tensor:
device_type = self.device.type
dtype = noisy_latents.dtype
if conditional:
text_dict = batch.conditional_dict
if text_dict is None:
raise RuntimeError("Missing conditional_dict in "
"TrainingBatch")
else:
text_dict = self._get_uncond_text_dict(batch, cfg_uncond=cfg_uncond)
if attn_kind == "dense":
attn_metadata = batch.attn_metadata
elif attn_kind == "vsa":
attn_metadata = batch.attn_metadata_vsa
else:
raise ValueError(f"Unknown attn_kind: {attn_kind!r}")
with torch.autocast(device_type, dtype=dtype), set_forward_context(
current_timestep=batch.timesteps,
attn_metadata=attn_metadata,
):
input_kwargs = (self._build_distill_input_kwargs(noisy_latents, timestep, text_dict))
transformer = self._get_transformer(timestep)
pred_noise = transformer(**input_kwargs).permute(0, 2, 1, 3, 4)
return pred_noise
def backward(
self,
loss: torch.Tensor,
ctx: Any,
*,
grad_accum_rounds: int,
) -> None:
timesteps, attn_metadata = ctx
with set_forward_context(
current_timestep=timesteps,
attn_metadata=attn_metadata,
):
(loss / max(1, int(grad_accum_rounds))).backward()
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _get_training_dtype(self) -> torch.dtype:
return torch.bfloat16
def _init_timestep_mechanics(self) -> None:
assert self.training_config is not None
tc = self.training_config
self.timestep_shift = float(tc.pipeline_config.flow_shift # type: ignore[union-attr]
)
self.num_train_timestep = int(self.noise_scheduler.num_train_timesteps)
# min/max timestep ratios now come from method_config;
# default to full range.
self.min_timestep = 0
self.max_timestep = self.num_train_timestep
def ensure_negative_conditioning(self) -> None:
if self.negative_prompt_embeds is not None:
return
assert self.training_config is not None
tc = self.training_config
world_group = self.world_group
device = self.device
dtype = self._get_training_dtype()
from fastvideo.train.utils.moduleloader import (
make_inference_args, )
neg_embeds: torch.Tensor | None = None
neg_mask: torch.Tensor | None = None
if world_group.rank_in_group == 0:
sampling_param = SamplingParam.from_pretrained(tc.model_path)
negative_prompt = sampling_param.negative_prompt
inference_args = make_inference_args(tc, model_path=tc.model_path)
prompt_pipeline = WanPipeline.from_pretrained(
tc.model_path,
args=inference_args,
inference_mode=True,
loaded_modules={"transformer": self.transformer},
tp_size=tc.distributed.tp_size,
sp_size=tc.distributed.sp_size,
num_gpus=tc.distributed.num_gpus,
pin_cpu_memory=(tc.distributed.pin_cpu_memory),
dit_cpu_offload=True,
)
batch_negative = ForwardBatch(
data_type="video",
prompt=negative_prompt,
prompt_embeds=[],
prompt_attention_mask=[],
)
result_batch = prompt_pipeline.prompt_encoding_stage( # type: ignore[attr-defined]
batch_negative,
inference_args,
)
neg_embeds = result_batch.prompt_embeds[0].to(device=device, dtype=dtype)
neg_mask = (result_batch.prompt_attention_mask[0].to(device=device, dtype=dtype))
del prompt_pipeline
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
meta = torch.zeros((2, ), device=device, dtype=torch.int64)
if world_group.rank_in_group == 0:
assert neg_embeds is not None
assert neg_mask is not None
meta[0] = neg_embeds.ndim
meta[1] = neg_mask.ndim
world_group.broadcast(meta, src=0)
embed_ndim, mask_ndim = (
int(meta[0].item()),
int(meta[1].item()),
)
max_ndim = 8
embed_shape = torch.full((max_ndim, ), -1, device=device, dtype=torch.int64)
mask_shape = torch.full((max_ndim, ), -1, device=device, dtype=torch.int64)
if world_group.rank_in_group == 0:
assert neg_embeds is not None
assert neg_mask is not None
embed_shape[:embed_ndim] = torch.tensor(
list(neg_embeds.shape),
device=device,
dtype=torch.int64,
)
mask_shape[:mask_ndim] = torch.tensor(
list(neg_mask.shape),
device=device,
dtype=torch.int64,
)
world_group.broadcast(embed_shape, src=0)
world_group.broadcast(mask_shape, src=0)
embed_sizes = tuple(int(x) for x in embed_shape[:embed_ndim].tolist())
mask_sizes = tuple(int(x) for x in mask_shape[:mask_ndim].tolist())
if world_group.rank_in_group != 0:
neg_embeds = torch.empty(embed_sizes, device=device, dtype=dtype)
neg_mask = torch.empty(mask_sizes, device=device, dtype=dtype)
assert neg_embeds is not None
assert neg_mask is not None
world_group.broadcast(neg_embeds, src=0)
world_group.broadcast(neg_mask, src=0)
self.negative_prompt_embeds = neg_embeds
self.negative_prompt_attention_mask = neg_mask
def _sample_timesteps(
self,
batch_size: int,
device: torch.device,
generator: torch.Generator,
) -> torch.Tensor:
assert self.training_config is not None
tc = self.training_config
u = compute_density_for_timestep_sampling(
weighting_scheme=tc.model.weighting_scheme,
batch_size=batch_size,
generator=generator,
device=device,
logit_mean=tc.model.logit_mean,
logit_std=tc.model.logit_std,
mode_scale=tc.model.mode_scale,
)
indices = (u * self.noise_scheduler.config.num_train_timesteps).long()
return self.noise_scheduler.timesteps[indices.cpu()].to(device=device)
def _build_attention_metadata(self, training_batch: TrainingBatch) -> TrainingBatch:
assert self.training_config is not None
tc = self.training_config
latents_shape = training_batch.raw_latent_shape
patch_size = (
tc.pipeline_config.dit_config.patch_size # type: ignore[union-attr]
)
current_vsa_sparsity = (training_batch.current_vsa_sparsity)
assert latents_shape is not None
assert training_batch.timesteps is not None
if (envs.FASTVIDEO_ATTENTION_BACKEND == "VIDEO_SPARSE_ATTN"):
if (not is_vsa_available() or VideoSparseAttentionMetadataBuilder is None):
raise ImportError("FASTVIDEO_ATTENTION_BACKEND is "
"VIDEO_SPARSE_ATTN, but "
"fastvideo_kernel is not correctly "
"installed or detected.")
training_batch.attn_metadata = VideoSparseAttentionMetadataBuilder().build( # type: ignore[misc]
raw_latent_shape=latents_shape[2:5],
current_timestep=(training_batch.timesteps),
patch_size=patch_size,
VSA_sparsity=current_vsa_sparsity,
device=self.device,
)
elif (envs.FASTVIDEO_ATTENTION_BACKEND == "VMOBA_ATTN"):
if (not is_vmoba_available() or VideoMobaAttentionMetadataBuilder is None):
raise ImportError("FASTVIDEO_ATTENTION_BACKEND is "
"VMOBA_ATTN, but fastvideo_kernel "
"(or flash_attn>=2.7.4) is not "
"correctly installed.")
moba_params = tc.model.moba_config.copy()
moba_params.update({
"current_timestep": (training_batch.timesteps),
"raw_latent_shape": (training_batch.raw_latent_shape[2:5]),
"patch_size": patch_size,
"device": self.device,
})
training_batch.attn_metadata = VideoMobaAttentionMetadataBuilder().build(**
moba_params) # type: ignore[misc]
else:
training_batch.attn_metadata = None
return training_batch
def _prepare_dit_inputs(
self,
training_batch: TrainingBatch,
generator: torch.Generator,
) -> TrainingBatch:
assert self.training_config is not None
tc = self.training_config
latents = training_batch.latents
assert isinstance(latents, torch.Tensor)
batch_size = latents.shape[0]
noise = torch.randn(
latents.shape,
generator=generator,
device=latents.device,
dtype=latents.dtype,
)
timesteps = self._sample_timesteps(
batch_size,
latents.device,
generator,
)
if int(tc.distributed.sp_size or 1) > 1:
self.sp_group.broadcast(timesteps, src=0)
sigmas = get_sigmas(
self.noise_scheduler,
latents.device,
timesteps,
n_dim=latents.ndim,
dtype=latents.dtype,
)
noisy_model_input = ((1.0 - sigmas) * latents + sigmas * noise)
training_batch.noisy_model_input = (noisy_model_input)
training_batch.timesteps = timesteps
training_batch.sigmas = sigmas
training_batch.noise = noise
training_batch.raw_latent_shape = latents.shape
training_batch.conditional_dict = {
"encoder_hidden_states": (training_batch.encoder_hidden_states),
"encoder_attention_mask": (training_batch.encoder_attention_mask),
}
if (self.negative_prompt_embeds is not None and self.negative_prompt_attention_mask is not None):
neg_embeds = self.negative_prompt_embeds
neg_mask = (self.negative_prompt_attention_mask)
if (neg_embeds.shape[0] == 1 and batch_size > 1):
neg_embeds = neg_embeds.expand(batch_size, *neg_embeds.shape[1:]).contiguous()
if (neg_mask.shape[0] == 1 and batch_size > 1):
neg_mask = neg_mask.expand(batch_size, *neg_mask.shape[1:]).contiguous()
training_batch.unconditional_dict = {
"encoder_hidden_states": neg_embeds,
"encoder_attention_mask": neg_mask,
}
training_batch.latents = (training_batch.latents.permute(0, 2, 1, 3, 4))
return training_batch
def _build_distill_input_kwargs(
self,
noise_input: torch.Tensor,
timestep: torch.Tensor,
text_dict: dict[str, torch.Tensor] | None,
) -> dict[str, Any]:
if text_dict is None:
raise ValueError("text_dict cannot be None for "
"Wan distillation")
return {
"hidden_states": noise_input.permute(0, 2, 1, 3, 4),
"encoder_hidden_states": text_dict["encoder_hidden_states"],
"encoder_attention_mask": text_dict["encoder_attention_mask"],
"timestep": timestep,
"return_dict": False,
}
def _get_transformer(self, timestep: torch.Tensor) -> torch.nn.Module:
return self.transformer
def _get_uncond_text_dict(
self,
batch: TrainingBatch,
*,
cfg_uncond: dict[str, Any] | None,
) -> dict[str, torch.Tensor]:
if cfg_uncond is None:
text_dict = getattr(batch, "unconditional_dict", None)
if text_dict is None:
raise RuntimeError("Missing unconditional_dict; "
"ensure_negative_conditioning() "
"may have failed")
return text_dict
on_missing_raw = cfg_uncond.get("on_missing", "error")
if not isinstance(on_missing_raw, str):
raise ValueError("method_config.cfg_uncond.on_missing "
"must be a string, got "
f"{type(on_missing_raw).__name__}")
on_missing = on_missing_raw.strip().lower()
if on_missing not in {"error", "ignore"}:
raise ValueError("method_config.cfg_uncond.on_missing "
"must be one of {error, ignore}, got "
f"{on_missing_raw!r}")
for channel, policy_raw in cfg_uncond.items():
if channel in {"on_missing", "text"}:
continue
if policy_raw is None:
continue
if not isinstance(policy_raw, str):
raise ValueError("method_config.cfg_uncond values "
"must be strings, got "
f"{channel}="
f"{type(policy_raw).__name__}")
policy = policy_raw.strip().lower()
if policy == "keep":
continue
if on_missing == "ignore":
continue
raise ValueError("WanModel does not support "
"cfg_uncond channel "
f"{channel!r} (policy={policy!r}). "
"Set cfg_uncond.on_missing=ignore or "
"remove the channel.")
text_policy_raw = cfg_uncond.get("text", None)
if text_policy_raw is None:
text_policy = "negative_prompt"
elif not isinstance(text_policy_raw, str):
raise ValueError("method_config.cfg_uncond.text must be "
"a string, got "
f"{type(text_policy_raw).__name__}")
else:
text_policy = (text_policy_raw.strip().lower())
if text_policy in {"negative_prompt"}:
text_dict = getattr(batch, "unconditional_dict", None)
if text_dict is None:
raise RuntimeError("Missing unconditional_dict; "
"ensure_negative_conditioning() "
"may have failed")
return text_dict
if text_policy == "keep":
if batch.conditional_dict is None:
raise RuntimeError("Missing conditional_dict in "
"TrainingBatch")
return batch.conditional_dict
if text_policy == "zero":
if batch.conditional_dict is None:
raise RuntimeError("Missing conditional_dict in "
"TrainingBatch")
cond = batch.conditional_dict
enc = cond["encoder_hidden_states"]
mask = cond["encoder_attention_mask"]
if not torch.is_tensor(enc) or not torch.is_tensor(mask):
raise TypeError("conditional_dict must contain "
"tensor text inputs")
return {
"encoder_hidden_states": (torch.zeros_like(enc)),
"encoder_attention_mask": (torch.zeros_like(mask)),
}
if text_policy == "drop":
raise ValueError("cfg_uncond.text=drop is not supported "
"for Wan. Use "
"{negative_prompt, keep, zero}.")
raise ValueError("cfg_uncond.text must be one of "
"{negative_prompt, keep, zero, drop}, got "
f"{text_policy_raw!r}")
+437
View File
@@ -0,0 +1,437 @@
# SPDX-License-Identifier: Apache-2.0
"""Wan causal model plugin (per-role instance, streaming/cache)."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Literal, TYPE_CHECKING
import torch
from fastvideo.forward_context import set_forward_context
from fastvideo.train.models.base import CausalModelBase
from fastvideo.train.models.wan.wan import WanModel
if TYPE_CHECKING:
from fastvideo.train.utils.training_config import (
TrainingConfig, )
@dataclass(slots=True)
class _StreamingCaches:
kv_cache: list[dict[str, Any]]
crossattn_cache: list[dict[str, Any]] | None
frame_seq_length: int
local_attn_size: int
sliding_window_num_frames: int
batch_size: int
dtype: torch.dtype
device: torch.device
class WanCausalModel(WanModel, CausalModelBase):
"""Wan per-role model with causal/streaming primitives."""
_transformer_cls_name: str = ("CausalWanTransformer3DModel")
def __init__(
self,
*,
init_from: str,
training_config: TrainingConfig,
trainable: bool = True,
disable_custom_init_weights: bool = False,
flow_shift: float = 3.0,
enable_gradient_checkpointing_type: str
| None = None,
) -> None:
super().__init__(
init_from=init_from,
training_config=training_config,
trainable=trainable,
disable_custom_init_weights=(disable_custom_init_weights),
flow_shift=flow_shift,
enable_gradient_checkpointing_type=(enable_gradient_checkpointing_type),
)
self._streaming_caches: (dict[tuple[int, str], _StreamingCaches]) = {}
# --- CausalModelBase override: clear_caches ---
def clear_caches(
self,
*,
cache_tag: str = "pos",
) -> None:
self._streaming_caches.pop((id(self), str(cache_tag)), None)
# --- CausalModelBase override: predict_noise_streaming ---
def predict_noise_streaming(
self,
noisy_latents: torch.Tensor,
timestep: torch.Tensor,
batch: Any,
*,
conditional: bool,
cache_tag: str = "pos",
store_kv: bool = False,
cur_start_frame: int = 0,
cfg_uncond: dict[str, Any] | None = None,
attn_kind: Literal["dense", "vsa"] = "dense",
) -> torch.Tensor | None:
if attn_kind == "dense":
attn_metadata = batch.attn_metadata
elif attn_kind == "vsa":
attn_metadata = batch.attn_metadata_vsa
else:
raise ValueError(f"Unknown attn_kind: {attn_kind!r}")
cache_tag = str(cache_tag)
cur_start_frame = int(cur_start_frame)
if cur_start_frame < 0:
raise ValueError("cur_start_frame must be >= 0")
batch_size = int(noisy_latents.shape[0])
num_frames = int(noisy_latents.shape[1])
timestep_full = self._ensure_per_frame_timestep(
timestep=timestep,
batch_size=batch_size,
num_frames=num_frames,
device=noisy_latents.device,
)
transformer = self._get_transformer(timestep_full)
caches = self._get_or_init_streaming_caches(
cache_tag=cache_tag,
transformer=transformer,
noisy_latents=noisy_latents,
)
frame_seq_length = int(caches.frame_seq_length)
kv_cache = caches.kv_cache
crossattn_cache = caches.crossattn_cache
if (self._should_snapshot_streaming_cache() and torch.is_grad_enabled()):
kv_cache = self._snapshot_kv_cache_indices(kv_cache)
model_kwargs: dict[str, Any] = {
"kv_cache": kv_cache,
"crossattn_cache": crossattn_cache,
"current_start": (cur_start_frame * frame_seq_length),
"start_frame": cur_start_frame,
"is_cache": bool(store_kv),
}
device_type = self.device.type
dtype = noisy_latents.dtype
if conditional:
text_dict = batch.conditional_dict
if text_dict is None:
raise RuntimeError("Missing conditional_dict in "
"TrainingBatch")
else:
text_dict = self._get_uncond_text_dict(batch, cfg_uncond=cfg_uncond)
with (
torch.autocast(device_type, dtype=dtype),
set_forward_context(
current_timestep=batch.timesteps,
attn_metadata=attn_metadata,
),
):
input_kwargs = (self._build_distill_input_kwargs(
noisy_latents,
timestep_full,
text_dict,
))
input_kwargs["timestep"] = (timestep_full.to(
device=self.device,
dtype=torch.long,
))
input_kwargs.update(model_kwargs)
if store_kv:
with torch.no_grad():
_ = transformer(**input_kwargs)
return None
pred_noise = transformer(**input_kwargs, ).permute(0, 2, 1, 3, 4)
return pred_noise
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _ensure_per_frame_timestep(
self,
*,
timestep: torch.Tensor,
batch_size: int,
num_frames: int,
device: torch.device,
) -> torch.Tensor:
if timestep.ndim == 0:
return (timestep.view(1, 1).expand(batch_size, num_frames).to(device=device))
if timestep.ndim == 1:
if int(timestep.shape[0]) == batch_size:
return (timestep.view(batch_size, 1).expand(batch_size, num_frames).to(device=device))
raise ValueError("streaming timestep must be scalar, "
"[B], or [B, T]; got shape="
f"{tuple(timestep.shape)}")
if timestep.ndim == 2:
return timestep.to(device=device)
raise ValueError("streaming timestep must be scalar, "
"[B], or [B, T]; got ndim="
f"{int(timestep.ndim)}")
def _get_or_init_streaming_caches(
self,
*,
cache_tag: str,
transformer: torch.nn.Module,
noisy_latents: torch.Tensor,
) -> _StreamingCaches:
key = (id(self), cache_tag)
cached = self._streaming_caches.get(key)
batch_size = int(noisy_latents.shape[0])
dtype = noisy_latents.dtype
device = noisy_latents.device
frame_seq_length = (self._compute_frame_seq_length(transformer, noisy_latents))
local_attn_size = self._get_local_attn_size(transformer)
sliding_window_num_frames = (self._get_sliding_window_num_frames(transformer))
meta = (
frame_seq_length,
local_attn_size,
sliding_window_num_frames,
batch_size,
dtype,
device,
)
if cached is not None:
cached_meta = (
cached.frame_seq_length,
cached.local_attn_size,
cached.sliding_window_num_frames,
cached.batch_size,
cached.dtype,
cached.device,
)
if cached_meta == meta:
return cached
kv_cache = self._initialize_kv_cache(
transformer=transformer,
batch_size=batch_size,
dtype=dtype,
device=device,
frame_seq_length=frame_seq_length,
local_attn_size=local_attn_size,
sliding_window_num_frames=(sliding_window_num_frames),
checkpoint_safe=(self._should_use_checkpoint_safe_kv_cache()),
)
crossattn_cache = (self._initialize_crossattn_cache(
transformer=transformer,
device=device,
))
caches = _StreamingCaches(
kv_cache=kv_cache,
crossattn_cache=crossattn_cache,
frame_seq_length=frame_seq_length,
local_attn_size=local_attn_size,
sliding_window_num_frames=(sliding_window_num_frames),
batch_size=batch_size,
dtype=dtype,
device=device,
)
self._streaming_caches[key] = caches
return caches
def _compute_frame_seq_length(
self,
transformer: torch.nn.Module,
noisy_latents: torch.Tensor,
) -> int:
latent_seq_length = (int(noisy_latents.shape[-1]) * int(noisy_latents.shape[-2]))
patch_size = getattr(transformer, "patch_size", None)
if patch_size is None:
patch_size = getattr(
getattr(
getattr(transformer, "config", None),
"arch_config",
None,
),
"patch_size",
None,
)
if patch_size is None:
raise ValueError("Unable to determine "
"transformer.patch_size "
"for causal streaming")
patch_ratio = (int(patch_size[-1]) * int(patch_size[-2]))
if patch_ratio <= 0:
raise ValueError("Invalid patch_size for causal "
"streaming")
return latent_seq_length // patch_ratio
def _get_sliding_window_num_frames(
self,
transformer: torch.nn.Module,
) -> int:
cfg = getattr(transformer, "config", None)
arch_cfg = getattr(cfg, "arch_config", None)
value = (getattr(
arch_cfg,
"sliding_window_num_frames",
None,
) if arch_cfg is not None else None)
if value is None:
return 15
return int(value)
def _get_local_attn_size(
self,
transformer: torch.nn.Module,
) -> int:
try:
value = getattr(transformer, "local_attn_size", -1)
except Exception:
value = -1
if value is None:
return -1
return int(value)
def _initialize_kv_cache(
self,
*,
transformer: torch.nn.Module,
batch_size: int,
dtype: torch.dtype,
device: torch.device,
frame_seq_length: int,
local_attn_size: int,
sliding_window_num_frames: int,
checkpoint_safe: bool,
) -> list[dict[str, Any]]:
num_blocks = len(getattr(transformer, "blocks", []))
if num_blocks <= 0:
raise ValueError("Unexpected transformer.blocks "
"for causal streaming")
try:
num_attention_heads = int(transformer.num_attention_heads) # type: ignore[attr-defined]
except AttributeError as e:
raise ValueError("Transformer is missing "
"num_attention_heads") from e
try:
attention_head_dim = int(transformer.attention_head_dim) # type: ignore[attr-defined]
except AttributeError:
try:
hidden_size = int(transformer.hidden_size) # type: ignore[attr-defined]
except AttributeError as e:
raise ValueError("Transformer is missing "
"attention_head_dim and "
"hidden_size") from e
attention_head_dim = (hidden_size // max(1, num_attention_heads))
if local_attn_size != -1:
kv_cache_size = (int(local_attn_size) * int(frame_seq_length))
else:
kv_cache_size = (int(frame_seq_length) * int(sliding_window_num_frames))
if checkpoint_safe:
tc = getattr(self, "training_config", None)
total_frames = int(tc.data.num_frames if tc is not None else 0)
if total_frames <= 0:
raise ValueError("training.num_frames must be set "
"to enable checkpoint-safe "
"streaming KV cache; got "
f"{total_frames}")
kv_cache_size = max(
kv_cache_size,
int(frame_seq_length) * total_frames,
)
kv_cache: list[dict[str, Any]] = []
for _ in range(num_blocks):
kv_cache.append({
"k":
torch.zeros(
[
batch_size,
kv_cache_size,
num_attention_heads,
attention_head_dim,
],
dtype=dtype,
device=device,
),
"v":
torch.zeros(
[
batch_size,
kv_cache_size,
num_attention_heads,
attention_head_dim,
],
dtype=dtype,
device=device,
),
"global_end_index":
torch.zeros((), dtype=torch.long, device=device),
"local_end_index":
torch.zeros((), dtype=torch.long, device=device),
})
return kv_cache
def _should_use_checkpoint_safe_kv_cache(self, ) -> bool:
tc = getattr(self, "training_config", None)
if tc is not None:
checkpointing_type = (tc.model.enable_gradient_checkpointing_type)
else:
checkpointing_type = None
return (bool(checkpointing_type) and bool(self._trainable))
def _should_snapshot_streaming_cache(self, ) -> bool:
return (self._should_use_checkpoint_safe_kv_cache())
def _snapshot_kv_cache_indices(
self,
kv_cache: list[dict[str, Any]],
) -> list[dict[str, Any]]:
snapshot: list[dict[str, Any]] = []
for block_cache in kv_cache:
global_end_index = block_cache.get("global_end_index")
local_end_index = block_cache.get("local_end_index")
if (not isinstance(global_end_index, torch.Tensor) or not isinstance(local_end_index, torch.Tensor)):
raise ValueError("Unexpected kv_cache index "
"tensors; expected tensors at "
"kv_cache[*].{global_end_index, "
"local_end_index}")
copied = dict(block_cache)
copied["global_end_index"] = (global_end_index.detach().clone())
copied["local_end_index"] = (local_end_index.detach().clone())
snapshot.append(copied)
return snapshot
def _initialize_crossattn_cache(
self,
*,
transformer: torch.nn.Module,
device: torch.device,
) -> list[dict[str, Any]] | None:
num_blocks = len(getattr(transformer, "blocks", []))
if num_blocks <= 0:
return None
return [{
"is_init": False,
"k": torch.empty(0, device=device),
"v": torch.empty(0, device=device),
} for _ in range(num_blocks)]
+212
View File
@@ -0,0 +1,212 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import time
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Any, TYPE_CHECKING
import torch
from tqdm.auto import tqdm
from fastvideo.distributed import get_sp_group, get_world_group
from fastvideo.train.callbacks.callback import CallbackDict
from fastvideo.train.methods.base import TrainingMethod
from fastvideo.train.utils.tracking import build_tracker
if TYPE_CHECKING:
from fastvideo.train.utils.training_config import (
TrainingConfig, )
def _coerce_log_scalar(value: Any, *, where: str) -> float:
if isinstance(value, torch.Tensor):
if value.numel() != 1:
raise ValueError(f"Expected scalar tensor at {where}, "
f"got shape={tuple(value.shape)}")
return float(value.detach().item())
if isinstance(value, float | int):
return float(value)
raise TypeError(f"Expected a scalar (float/int/Tensor) at "
f"{where}, got {type(value).__name__}")
@dataclass(slots=True)
class TrainLoopState:
step: int
accum_iter: int
current_vsa_sparsity: float
class Trainer:
def __init__(
self,
training_config: TrainingConfig,
*,
config: dict[str, Any] | None = None,
callback_configs: dict[str, dict[str, Any]]
| None = None,
) -> None:
self.training_config = training_config
self.world_group = get_world_group()
self.sp_group = get_sp_group()
self.global_rank = self.world_group.rank
self.local_rank = self.world_group.local_rank
self.tracker = build_tracker(
training_config.tracker,
training_config.checkpoint,
config=config,
)
self.callbacks = CallbackDict(
callback_configs or {},
training_config,
)
def _iter_dataloader(self, dataloader: Any) -> Iterator[dict[str, Any]]:
data_iter = iter(dataloader)
while True:
batch = next(data_iter, None)
if batch is None:
data_iter = iter(dataloader)
batch = next(data_iter)
yield batch
def _get_current_vsa_sparsity(self, step: int) -> float:
tc = self.training_config
vsa_sparsity = tc.vsa.sparsity
vsa_decay_rate = tc.vsa.decay_rate
vsa_decay_interval_steps = (tc.vsa.decay_interval_steps)
if vsa_decay_interval_steps > 1:
current_decay_times = min(
step // vsa_decay_interval_steps,
int(vsa_sparsity // vsa_decay_rate),
)
return current_decay_times * vsa_decay_rate
return vsa_sparsity
def run(
self,
method: TrainingMethod,
*,
dataloader: Any,
max_steps: int,
start_step: int = 0,
checkpoint_manager: Any | None = None,
) -> None:
tc = self.training_config
grad_accum = max(
1,
int(tc.loop.gradient_accumulation_steps or 1),
)
method.set_tracker(self.tracker)
method.on_train_start()
self.callbacks.on_train_start(
method,
iteration=start_step,
)
resume_from_checkpoint = (tc.checkpoint.resume_from_checkpoint or "")
if checkpoint_manager is not None:
if resume_from_checkpoint:
method.seed_optimizer_state_for_resume()
resumed_step = (checkpoint_manager.maybe_resume(resume_from_checkpoint=(resume_from_checkpoint)))
if resumed_step is not None:
start_step = int(resumed_step)
self.callbacks.on_validation_begin(
method,
iteration=start_step,
)
method.optimizers_zero_grad(start_step)
data_stream = self._iter_dataloader(dataloader)
# Restore the RNG snapshot LAST — after dcp.load,
# after iter(dataloader), after everything that may
# have advanced the RNG as a side-effect.
if (checkpoint_manager is not None and resume_from_checkpoint):
checkpoint_manager.load_rng_snapshot(resume_from_checkpoint, )
progress = tqdm(
range(start_step + 1, max_steps + 1),
initial=start_step,
desc="Steps",
disable=self.local_rank > 0,
)
for step in progress:
t0 = time.perf_counter()
current_vsa_sparsity = (self._get_current_vsa_sparsity(step))
loss_sums: dict[str, float] = {}
metric_sums: dict[str, float] = {}
for accum_iter in range(grad_accum):
batch = next(data_stream)
loss_map, outputs, step_metrics = method.single_train_step(
batch,
step,
current_vsa_sparsity=(current_vsa_sparsity),
)
method.backward(
loss_map,
outputs,
grad_accum_rounds=grad_accum,
)
for k, v in loss_map.items():
if isinstance(v, torch.Tensor):
loss_sums[k] = loss_sums.get(k, 0.0) + float(v.detach().item())
for k, v in step_metrics.items():
if k in loss_sums:
raise ValueError(f"Metric key {k!r} collides "
"with loss key. Use a "
"different name (e.g. prefix "
"with 'train/').")
metric_sums[k] = metric_sums.get(k, 0.0) + _coerce_log_scalar(
v,
where=("method.single_train_step()"
f".metrics[{k!r}]"),
)
self.callbacks.on_before_optimizer_step(
method,
iteration=step,
)
method.optimizers_schedulers_step(step)
method.optimizers_zero_grad(step)
metrics = {k: v / grad_accum for k, v in loss_sums.items()}
metrics.update({k: v / grad_accum for k, v in metric_sums.items()})
metrics["step_time_sec"] = (time.perf_counter() - t0)
metrics["vsa_sparsity"] = float(current_vsa_sparsity)
if self.global_rank == 0 and metrics:
self.tracker.log(metrics, step)
self.callbacks.on_training_step_end(
method,
metrics,
iteration=step,
)
if checkpoint_manager is not None:
checkpoint_manager.maybe_save(step)
self.callbacks.on_validation_begin(
method,
iteration=step,
)
self.callbacks.on_validation_end(
method,
iteration=step,
)
self.callbacks.on_train_end(
method,
iteration=max_steps,
)
if checkpoint_manager is not None:
checkpoint_manager.save_final(max_steps)
self.tracker.finish()
+2
View File
@@ -0,0 +1,2 @@
# SPDX-License-Identifier: Apache-2.0
"""Distillation utilities shared across families/methods/entrypoints."""
+56
View File
@@ -0,0 +1,56 @@
# SPDX-License-Identifier: Apache-2.0
"""Assembly: build method + dataloader from a ``_target_``-based config."""
from __future__ import annotations
from typing import Any, TYPE_CHECKING
from fastvideo.train.utils.instantiate import (
instantiate,
resolve_target,
)
from fastvideo.train.utils.config import RunConfig
if TYPE_CHECKING:
from fastvideo.train.utils.training_config import (
TrainingConfig, )
from fastvideo.train.methods.base import TrainingMethod
def build_from_config(cfg: RunConfig, ) -> tuple[TrainingConfig, TrainingMethod, Any, int]:
"""Build method + dataloader from a v3 run config.
1. Instantiate each model in ``cfg.models`` via ``_target_``.
2. Resolve the method class from ``cfg.method["_target_"]``
and construct it with ``(cfg=cfg, role_models=...)``.
3. Return ``(training_args, method, dataloader, start_step)``.
"""
from fastvideo.train.models.base import ModelBase
# --- 1. Build role model instances ---
role_models: dict[str, ModelBase] = {}
for role, model_cfg in cfg.models.items():
model = instantiate(model_cfg, training_config=cfg.training)
if not isinstance(model, ModelBase):
raise TypeError(f"models.{role}._target_ must resolve to a "
f"ModelBase subclass, got {type(model).__name__}")
role_models[role] = model
# --- 2. Build method ---
method_cfg = dict(cfg.method)
method_target = str(method_cfg.pop("_target_"))
method_cls = resolve_target(method_target)
# The student model provides the dataloader.
student = role_models.get("student")
method = method_cls(
cfg=cfg,
role_models=role_models,
)
# --- 3. Gather dataloader and start_step ---
dataloader = (getattr(student, "dataloader", None) if student is not None else None)
start_step = int(getattr(student, "start_step", 0) if student is not None else 0)
return cfg.training, method, dataloader, start_step
+388
View File
@@ -0,0 +1,388 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import json
import os
import random
import re
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import numpy as np
import torch
import torch.distributed as dist
import torch.distributed.checkpoint as dcp
from fastvideo.logger import init_logger
logger = init_logger(__name__)
_CHECKPOINT_DIR_RE = re.compile(r"^checkpoint-(\d+)$")
def _is_stateful(obj: Any) -> bool:
return callable(getattr(obj, "state_dict", None)) and callable(getattr(obj, "load_state_dict", None))
def _rank() -> int:
if dist.is_available() and dist.is_initialized():
return int(dist.get_rank())
return 0
def _barrier() -> None:
if dist.is_available() and dist.is_initialized():
dist.barrier()
def _parse_step_from_dir(checkpoint_dir: Path) -> int:
match = _CHECKPOINT_DIR_RE.match(checkpoint_dir.name)
if not match:
raise ValueError(f"Invalid checkpoint directory name {checkpoint_dir.name!r}; "
"expected 'checkpoint-<step>'")
return int(match.group(1))
def _find_latest_checkpoint(output_dir: Path) -> Path | None:
if not output_dir.exists():
return None
candidates: list[tuple[int, Path]] = []
for child in output_dir.iterdir():
if not child.is_dir():
continue
if not _CHECKPOINT_DIR_RE.match(child.name):
continue
if not (child / "dcp").is_dir():
continue
try:
step = _parse_step_from_dir(child)
except Exception:
continue
candidates.append((step, child))
if not candidates:
return None
candidates.sort(key=lambda x: x[0])
return candidates[-1][1]
def _resolve_resume_checkpoint(resume_from_checkpoint: str, *, output_dir: str) -> Path | None:
"""Resolve a user-provided resume path to a concrete checkpoint dir.
Accepted values:
- "latest" (auto-pick latest checkpoint-*/dcp under output_dir,
or ``None`` if no checkpoint exists yet — starts from scratch)
- /path/to/output_dir/checkpoint-<step>
- /path/to/output_dir/checkpoint-<step>/dcp
- /path/to/output_dir (auto-pick latest checkpoint-*/dcp)
"""
if str(resume_from_checkpoint).strip().lower() == "latest":
out = Path(os.path.expanduser(str(output_dir))).resolve()
latest = _find_latest_checkpoint(out)
if latest is None:
logger.info(
"resume_from_checkpoint='latest' but no "
"checkpoints found under %s; starting from "
"scratch.",
out,
)
return latest
raw = os.path.expanduser(str(resume_from_checkpoint))
path = Path(raw).resolve()
if not path.exists():
raise FileNotFoundError(f"resume_from_checkpoint not found: {path}")
if path.is_dir() and path.name == "dcp":
path = path.parent
if path.is_dir() and _CHECKPOINT_DIR_RE.match(path.name):
if not (path / "dcp").is_dir():
raise FileNotFoundError(f"Missing dcp dir under checkpoint: {path / 'dcp'}")
return path
# Treat as output_dir -> pick latest.
latest = _find_latest_checkpoint(path)
if latest is not None:
return latest
# Give a clearer error message.
out = Path(os.path.expanduser(str(output_dir))).resolve()
raise ValueError("Could not resolve resume checkpoint. Expected a checkpoint directory "
f"named 'checkpoint-<step>' (with 'dcp/' inside), or an output_dir "
f"containing such checkpoints. Got: {path} (output_dir={out}).")
class _RoleModuleContainer(torch.nn.Module):
"""Ephemeral container to expose multiple role modules as a single
``nn.Module``.
Used by ``OptimizerWrapper`` which expects a single root module
covering all parameters owned by the optimizer.
"""
def __init__(self, modules: dict[str, torch.nn.Module]) -> None:
super().__init__()
for name, module in modules.items():
self.add_module(name, module)
class _CallbackStateWrapper:
"""Wraps a CallbackDict for DCP save/load."""
def __init__(self, callbacks: Any) -> None:
self._callbacks = callbacks
def state_dict(self) -> dict[str, Any]:
return self._callbacks.state_dict()
def load_state_dict(
self,
state_dict: dict[str, Any],
) -> None:
self._callbacks.load_state_dict(state_dict)
@dataclass(slots=True)
class CheckpointConfig:
save_steps: int
keep_last: int
class CheckpointManager:
"""Role-based checkpoint manager for training runtime.
- Checkpoint policy lives in YAML (via TrainingArgs fields).
- Resume path is typically provided via CLI (``--resume-from-checkpoint``).
"""
def __init__(
self,
*,
method: Any,
dataloader: Any,
output_dir: str,
config: CheckpointConfig,
callbacks: Any | None = None,
raw_config: dict[str, Any] | None = None,
) -> None:
self.method = method
self.dataloader = dataloader
self.output_dir = str(output_dir)
self.config = config
self._callbacks = callbacks
self._raw_config = raw_config
self._last_saved_step: int | None = None
def _build_states(self) -> dict[str, Any]:
states: dict[str, Any] = self.method.checkpoint_state()
# Dataloader (optional but recommended for exact resume).
if _is_stateful(self.dataloader):
states["dataloader"] = self.dataloader
# Callback state (e.g. EMA shadow weights, validation RNG).
if self._callbacks is not None and _is_stateful(self._callbacks):
states["callbacks"] = _CallbackStateWrapper(self._callbacks, )
return states
def _checkpoint_dir(self, step: int) -> Path:
return Path(self.output_dir) / f"checkpoint-{step}"
def _dcp_dir(self, step: int) -> Path:
return self._checkpoint_dir(step) / "dcp"
def maybe_save(self, step: int) -> None:
save_steps = int(self.config.save_steps or 0)
if save_steps <= 0:
return
if step % save_steps != 0:
return
if self._last_saved_step == step:
return
self.save(step)
def save_final(self, step: int) -> None:
save_steps = int(self.config.save_steps or 0)
if save_steps <= 0:
return
self.save(step)
def save(self, step: int) -> None:
checkpoint_dir = self._checkpoint_dir(step)
dcp_dir = self._dcp_dir(step)
os.makedirs(dcp_dir, exist_ok=True)
states = self._build_states()
if _rank() == 0:
logger.info(
"Saving checkpoint to %s",
checkpoint_dir,
)
self._write_metadata(checkpoint_dir, step)
dcp.save(states, checkpoint_id=str(dcp_dir))
_barrier()
# Save RNG state AFTER dcp.save so it captures the
# exact state the continuous run continues with.
# dcp.save triggers FSDP all-gather ops that can
# advance the RNG between when DCP captures it and
# when the save completes.
self._save_rng_snapshot(checkpoint_dir)
_barrier()
self._last_saved_step = step
self._cleanup_old_checkpoints()
def _write_metadata(
self,
checkpoint_dir: Path,
step: int,
) -> None:
metadata: dict[str, Any] = {"step": step}
if self._raw_config is not None:
metadata["config"] = self._raw_config
meta_path = checkpoint_dir / "metadata.json"
with open(meta_path, "w", encoding="utf-8") as f:
json.dump(metadata, f, indent=2)
@staticmethod
def load_metadata(checkpoint_dir: str | Path, ) -> dict[str, Any]:
"""Read ``metadata.json`` from a checkpoint dir."""
meta_path = Path(checkpoint_dir) / "metadata.json"
if not meta_path.is_file():
raise FileNotFoundError(f"No metadata.json in {checkpoint_dir}")
with open(meta_path, encoding="utf-8") as f:
return json.load(f) # type: ignore[no-any-return]
def _save_rng_snapshot(self, checkpoint_dir: Path) -> None:
"""Save per-rank RNG state to a separate file.
Called AFTER ``dcp.save`` so the snapshot reflects
the exact state the continuous run continues with.
Each rank saves its own file because CUDA RNG and
custom generators differ across ranks.
"""
rank = _rank()
rng: dict[str, Any] = {
"torch_rng": torch.get_rng_state(),
"python_rng": random.getstate(),
"numpy_rng": np.random.get_state(),
}
if torch.cuda.is_available():
rng["cuda_rng"] = torch.cuda.get_rng_state()
generators = (self.method.get_rng_generators() if hasattr(self.method, "get_rng_generators") else {})
for name, gen in (generators or {}).items():
if gen is not None:
rng[f"gen_{name}"] = gen.get_state()
torch.save(
rng,
checkpoint_dir / f"rng_state_rank{rank}.pt",
)
def load_rng_snapshot(
self,
checkpoint_path: str,
) -> None:
"""Restore per-rank RNG state from the snapshot file.
Must be called AFTER ``dcp.load`` **and** after
``iter(dataloader)`` so no later operation can
clobber the restored state.
"""
resolved = _resolve_resume_checkpoint(
checkpoint_path,
output_dir=self.output_dir,
)
if resolved is None:
return
rank = _rank()
rng_path = resolved / f"rng_state_rank{rank}.pt"
if not rng_path.is_file():
# Fall back to legacy single-file snapshot.
rng_path = resolved / "rng_state.pt"
if not rng_path.is_file():
logger.warning(
"No rng_state in %s; skipping "
"RNG snapshot restore.",
resolved,
)
return
rng = torch.load(
rng_path,
map_location="cpu",
weights_only=False,
)
if "torch_rng" in rng:
torch.set_rng_state(rng["torch_rng"])
if "python_rng" in rng:
random.setstate(rng["python_rng"])
if "numpy_rng" in rng:
np.random.set_state(rng["numpy_rng"])
if torch.cuda.is_available() and "cuda_rng" in rng:
torch.cuda.set_rng_state(rng["cuda_rng"])
generators = (self.method.get_rng_generators() if hasattr(self.method, "get_rng_generators") else {})
for name, gen in (generators or {}).items():
key = f"gen_{name}"
if key in rng and gen is not None:
gen.set_state(rng[key])
logger.info(
"Restored RNG snapshot from %s",
rng_path,
)
def maybe_resume(self, *, resume_from_checkpoint: str | None) -> int | None:
if not resume_from_checkpoint:
return None
resolved = _resolve_resume_checkpoint(
resume_from_checkpoint,
output_dir=self.output_dir,
)
if resolved is None:
return None
step = _parse_step_from_dir(resolved)
states = self._build_states()
logger.info("Loading Phase 2 checkpoint from %s", resolved)
dcp.load(states, checkpoint_id=str(resolved / "dcp"))
_barrier()
logger.info("Checkpoint loaded; resuming from step=%s", step)
return step
def _cleanup_old_checkpoints(self) -> None:
keep_last = int(self.config.keep_last or 0)
if keep_last <= 0:
return
if _rank() != 0:
_barrier()
return
output_dir = Path(self.output_dir)
candidates: list[tuple[int, Path]] = []
for child in output_dir.iterdir():
if not child.is_dir():
continue
if not _CHECKPOINT_DIR_RE.match(child.name):
continue
try:
step = _parse_step_from_dir(child)
except Exception:
continue
candidates.append((step, child))
candidates.sort(key=lambda x: x[0])
to_delete = candidates[:-keep_last] if len(candidates) > keep_last else []
for step, path in to_delete:
logger.info("Removing old checkpoint (keep_last=%s): %s", keep_last, path)
shutil.rmtree(path, ignore_errors=True)
_barrier()
+511
View File
@@ -0,0 +1,511 @@
# SPDX-License-Identifier: Apache-2.0
"""Training run config (``_target_`` based YAML)."""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
from fastvideo.logger import init_logger
from fastvideo.train.utils.training_config import (
CheckpointConfig,
DataConfig,
TrainingConfig,
DistributedConfig,
ModelTrainingConfig,
OptimizerConfig,
TrackerConfig,
TrainingLoopConfig,
VSAConfig,
)
logger = init_logger(__name__)
@dataclass(slots=True)
class RunConfig:
"""Parsed run config loaded from YAML."""
models: dict[str, dict[str, Any]]
method: dict[str, Any]
training: TrainingConfig
callbacks: dict[str, dict[str, Any]]
raw: dict[str, Any]
def resolved_config(self) -> dict[str, Any]:
"""Return a fully-resolved config dict with defaults.
Suitable for logging to W&B so that every parameter
(including defaults) is visible.
"""
import dataclasses
def _safe_asdict(obj: Any) -> Any:
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
return {
f.name: _safe_asdict(getattr(obj, f.name))
for f in dataclasses.fields(obj) if not callable(getattr(obj, f.name))
}
if isinstance(obj, dict):
return {k: _safe_asdict(v) for k, v in obj.items()}
if isinstance(obj, list | tuple):
return type(obj)(_safe_asdict(v) for v in obj)
return obj
resolved: dict[str, Any] = {}
resolved["models"] = dict(self.models)
resolved["method"] = dict(self.method)
resolved["training"] = _safe_asdict(self.training)
resolved["callbacks"] = dict(self.callbacks)
return resolved
# ---- parsing helpers (kept for use by methods) ----
def _resolve_existing_file(path: str) -> str:
if not path:
return path
expanded = os.path.expanduser(path)
resolved = Path(expanded).resolve()
if not resolved.exists():
raise FileNotFoundError(f"Config file not found: {resolved}")
if not resolved.is_file():
raise ValueError(f"Expected a file path, got: {resolved}")
return str(resolved)
def _require_mapping(raw: Any, *, where: str) -> dict[str, Any]:
if not isinstance(raw, dict):
raise ValueError(f"Expected mapping at {where}, "
f"got {type(raw).__name__}")
return raw
def _require_str(raw: Any, *, where: str) -> str:
if not isinstance(raw, str) or not raw.strip():
raise ValueError(f"Expected non-empty string at {where}")
return raw
def get_optional_int(mapping: dict[str, Any], key: str, *, where: str) -> int | None:
raw = mapping.get(key)
if raw is None:
return None
if isinstance(raw, bool):
raise ValueError(f"Expected int at {where}, got bool")
if isinstance(raw, int):
return int(raw)
if isinstance(raw, float) and raw.is_integer():
return int(raw)
if isinstance(raw, str) and raw.strip():
return int(raw)
raise ValueError(f"Expected int at {where}, "
f"got {type(raw).__name__}")
def get_optional_float(mapping: dict[str, Any], key: str, *, where: str) -> float | None:
raw = mapping.get(key)
if raw is None:
return None
if isinstance(raw, bool):
raise ValueError(f"Expected float at {where}, got bool")
if isinstance(raw, int | float):
return float(raw)
if isinstance(raw, str) and raw.strip():
return float(raw)
raise ValueError(f"Expected float at {where}, "
f"got {type(raw).__name__}")
def parse_betas(raw: Any, *, where: str) -> tuple[float, float]:
if raw is None:
raise ValueError(f"Missing betas for {where}")
if isinstance(raw, tuple | list) and len(raw) == 2:
return float(raw[0]), float(raw[1])
if isinstance(raw, str):
parts = [p.strip() for p in raw.split(",") if p.strip()]
if len(parts) != 2:
raise ValueError(f"Expected betas as 'b1,b2' at {where}, "
f"got {raw!r}")
return float(parts[0]), float(parts[1])
raise ValueError(f"Expected betas as 'b1,b2' at {where}, "
f"got {type(raw).__name__}")
# ---- config convenience helpers ----
def require_positive_int(
mapping: dict[str, Any],
key: str,
*,
default: int | None = None,
where: str | None = None,
) -> int:
"""Read an int that must be > 0."""
loc = where or key
raw = mapping.get(key)
if raw is None:
if default is not None:
return default
raise ValueError(f"Missing required key {loc!r}")
val = get_optional_int(mapping, key, where=loc)
if val is None or val <= 0:
raise ValueError(f"{loc} must be a positive integer, got {raw!r}")
return val
def require_non_negative_int(
mapping: dict[str, Any],
key: str,
*,
default: int | None = None,
where: str | None = None,
) -> int:
"""Read an int that must be >= 0."""
loc = where or key
raw = mapping.get(key)
if raw is None:
if default is not None:
return default
raise ValueError(f"Missing required key {loc!r}")
val = get_optional_int(mapping, key, where=loc)
if val is None or val < 0:
raise ValueError(f"{loc} must be a non-negative integer, "
f"got {raw!r}")
return val
def require_non_negative_float(
mapping: dict[str, Any],
key: str,
*,
default: float | None = None,
where: str | None = None,
) -> float:
"""Read a float that must be >= 0."""
loc = where or key
raw = mapping.get(key)
if raw is None:
if default is not None:
return default
raise ValueError(f"Missing required key {loc!r}")
val = get_optional_float(mapping, key, where=loc)
if val is None or val < 0.0:
raise ValueError(f"{loc} must be a non-negative float, "
f"got {raw!r}")
return val
def require_choice(
mapping: dict[str, Any],
key: str,
choices: set[str] | frozenset[str],
*,
default: str | None = None,
where: str | None = None,
) -> str:
"""Read a string that must be one of *choices*."""
loc = where or key
raw = mapping.get(key)
if raw is None:
if default is not None:
if default not in choices:
raise ValueError(f"Default {default!r} not in {choices}")
return default
raise ValueError(f"Missing required key {loc!r}")
if not isinstance(raw, str) or not raw.strip():
raise ValueError(f"{loc} must be a non-empty string, "
f"got {type(raw).__name__}")
val = raw.strip().lower()
if val not in choices:
raise ValueError(f"{loc} must be one of {sorted(choices)}, "
f"got {raw!r}")
return val
def require_bool(
mapping: dict[str, Any],
key: str,
*,
default: bool | None = None,
where: str | None = None,
) -> bool:
"""Read a bool value."""
loc = where or key
raw = mapping.get(key)
if raw is None:
if default is not None:
return default
raise ValueError(f"Missing required key {loc!r}")
if not isinstance(raw, bool):
raise ValueError(f"{loc} must be a bool, "
f"got {type(raw).__name__}")
return raw
def _parse_pipeline_config(
cfg: dict[str, Any],
*,
models: dict[str, dict[str, Any]],
) -> Any:
"""Resolve PipelineConfig from the ``pipeline:`` YAML key."""
from fastvideo.configs.pipelines.base import PipelineConfig
pipeline_raw = cfg.get("pipeline")
if pipeline_raw is None:
return None
# Derive model_path from models.student.init_from —
# needed by PipelineConfig.from_kwargs.
model_path: str | None = None
student_cfg = models.get("student")
if student_cfg is not None:
init_from = student_cfg.get("init_from")
if init_from is not None:
model_path = str(init_from)
kwargs: dict[str, Any] = {"pipeline_config": pipeline_raw}
if model_path is not None:
kwargs["model_path"] = model_path
if isinstance(pipeline_raw, str):
kwargs["pipeline_config"] = _resolve_existing_file(pipeline_raw)
return PipelineConfig.from_kwargs(kwargs)
def _build_training_config(
t: dict[str, Any],
*,
models: dict[str, dict[str, Any]],
pipeline_config: Any,
) -> TrainingConfig:
"""Build TrainingConfig from nested training: YAML."""
d = dict(t.get("distributed", {}) or {})
da = dict(t.get("data", {}) or {})
o = dict(t.get("optimizer", {}) or {})
lo = dict(t.get("loop", {}) or {})
ck = dict(t.get("checkpoint", {}) or {})
tr = dict(t.get("tracker", {}) or {})
vs = dict(t.get("vsa", {}) or {})
m = dict(t.get("model", {}) or {})
num_gpus = int(d.get("num_gpus", 1) or 1)
betas_raw = o.get("betas", "0.9,0.999")
betas = parse_betas(betas_raw, where="training.optimizer.betas")
model_path = str(t.get("model_path", "") or "")
if not model_path:
student_cfg = models.get("student")
if student_cfg is not None:
init_from = student_cfg.get("init_from")
if init_from is not None:
model_path = str(init_from)
return TrainingConfig(
distributed=DistributedConfig(
num_gpus=num_gpus,
tp_size=int(d.get("tp_size", 1) or 1),
sp_size=int(d.get("sp_size", num_gpus) or num_gpus),
hsdp_replicate_dim=int(d.get("hsdp_replicate_dim", 1) or 1),
hsdp_shard_dim=int(d.get("hsdp_shard_dim", num_gpus) or num_gpus),
pin_cpu_memory=bool(d.get("pin_cpu_memory", False)),
),
data=DataConfig(
data_path=str(da.get("data_path", "") or ""),
train_batch_size=int(da.get("train_batch_size", 1) or 1),
dataloader_num_workers=int(da.get("dataloader_num_workers", 0) or 0),
training_cfg_rate=float(da.get("training_cfg_rate", 0.0) or 0.0),
seed=int(da.get("seed", 0) or 0),
num_height=int(da.get("num_height", 0) or 0),
num_width=int(da.get("num_width", 0) or 0),
num_latent_t=int(da.get("num_latent_t", 0) or 0),
num_frames=int(da.get("num_frames", 0) or 0),
),
optimizer=OptimizerConfig(
learning_rate=float(o.get("learning_rate", 0.0) or 0.0),
betas=betas,
weight_decay=float(o.get("weight_decay", 0.0) or 0.0),
lr_scheduler=str(o.get("lr_scheduler", "constant") or "constant"),
lr_warmup_steps=int(o.get("lr_warmup_steps", 0) or 0),
lr_num_cycles=int(o.get("lr_num_cycles", 0) or 0),
lr_power=float(o.get("lr_power", 0.0) or 0.0),
min_lr_ratio=float(o.get("min_lr_ratio", 0.5) or 0.5),
),
loop=TrainingLoopConfig(
max_train_steps=int(lo.get("max_train_steps", 0) or 0),
gradient_accumulation_steps=int(lo.get("gradient_accumulation_steps", 1) or 1),
),
checkpoint=CheckpointConfig(
output_dir=str(ck.get("output_dir", "") or ""),
resume_from_checkpoint=str(ck.get("resume_from_checkpoint", "") or ""),
training_state_checkpointing_steps=int(ck.get("training_state_checkpointing_steps", 0) or 0),
checkpoints_total_limit=int(ck.get("checkpoints_total_limit", 0) or 0),
),
tracker=TrackerConfig(
trackers=list(tr.get("trackers", []) or []),
project_name=str(tr.get("project_name", "fastvideo") or "fastvideo"),
run_name=str(tr.get("run_name", "") or ""),
),
vsa=VSAConfig(
sparsity=float(vs.get("sparsity", 0.0) or 0.0),
decay_rate=float(vs.get("decay_rate", 0.0) or 0.0),
decay_interval_steps=int(vs.get("decay_interval_steps", 0) or 0),
),
model=ModelTrainingConfig(
weighting_scheme=str(m.get("weighting_scheme", "uniform") or "uniform"),
logit_mean=float(m.get("logit_mean", 0.0) or 0.0),
logit_std=float(m.get("logit_std", 1.0) or 1.0),
mode_scale=float(m.get("mode_scale", 1.0) or 1.0),
precondition_outputs=bool(m.get("precondition_outputs", False)),
moba_config=dict(m.get("moba_config", {}) or {}),
enable_gradient_checkpointing_type=(m.get("enable_gradient_checkpointing_type")),
),
pipeline_config=pipeline_config,
model_path=model_path,
dit_precision=str(t.get("dit_precision", "fp32") or "fp32"),
)
def _parse_cli_overrides(overrides: list[str], ) -> dict[str, Any]:
"""Parse ``--dotted.key value`` CLI overrides.
Returns a flat dict mapping dotted keys to parsed
values. Values are auto-cast: ``true``/``false`` to
bool, integers/floats to numbers, and YAML list
literals (``[a, b]``) to lists.
"""
result: dict[str, Any] = {}
i = 0
while i < len(overrides):
arg = overrides[i]
if not arg.startswith("--"):
raise ValueError(f"Expected --dotted.key, got {arg!r}")
key = arg[2:] # strip leading --
if "=" in key:
key, raw_val = key.split("=", 1)
else:
i += 1
if i >= len(overrides):
raise ValueError(f"Missing value for override {arg!r}")
raw_val = overrides[i]
result[key] = _cast_value(raw_val)
i += 1
return result
def _cast_value(raw: str) -> Any:
"""Best-effort cast a CLI string to a Python value."""
if raw.lower() == "true":
return True
if raw.lower() == "false":
return False
if raw.lower() in ("none", "null"):
return None
# Try int
try:
return int(raw)
except ValueError:
pass
# Try float
try:
return float(raw)
except ValueError:
pass
# Try YAML list literal like [1, 2]
if raw.startswith("[") and raw.endswith("]"):
try:
return yaml.safe_load(raw)
except yaml.YAMLError:
pass
return raw
def _apply_overrides(
cfg: dict[str, Any],
overrides: dict[str, Any],
) -> None:
"""Apply dotted-key overrides to a nested dict."""
for dotted_key, value in overrides.items():
parts = dotted_key.split(".")
d = cfg
for part in parts[:-1]:
if part not in d or not isinstance(d[part], dict):
d[part] = {}
d = d[part]
d[parts[-1]] = value
def load_run_config(
path: str,
overrides: list[str] | None = None,
) -> RunConfig:
"""Load a run config from YAML.
Expected top-level keys: ``models``, ``method``,
``training`` (nested), and optionally ``callbacks``
and ``pipeline``.
Args:
path: Path to the YAML config file.
overrides: Optional list of CLI override tokens,
e.g. ``["--training.distributed.num_gpus", "4"]``.
Dotted keys map to nested YAML paths.
"""
path = _resolve_existing_file(path)
with open(path, encoding="utf-8") as f:
raw = yaml.safe_load(f)
cfg = _require_mapping(raw, where=path)
# Apply CLI overrides before building typed config.
if overrides:
parsed = _parse_cli_overrides(overrides)
_apply_overrides(cfg, parsed)
logger.info("Applied CLI overrides: %s", parsed)
# --- models ---
models_raw = _require_mapping(cfg.get("models"), where="models")
models: dict[str, dict[str, Any]] = {}
for role, model_cfg_raw in models_raw.items():
role_str = _require_str(role, where="models.<role>")
model_cfg = _require_mapping(model_cfg_raw, where=f"models.{role_str}")
if "_target_" not in model_cfg:
raise ValueError(f"models.{role_str} must have a "
"'_target_' key")
models[role_str] = dict(model_cfg)
# --- method ---
method_raw = _require_mapping(cfg.get("method"), where="method")
if "_target_" not in method_raw:
raise ValueError("method must have a '_target_' key")
method = dict(method_raw)
# --- callbacks ---
callbacks_raw = cfg.get("callbacks", None)
if callbacks_raw is None:
callbacks: dict[str, dict[str, Any]] = {}
else:
callbacks = _require_mapping(callbacks_raw, where="callbacks")
# --- pipeline config ---
pipeline_config = _parse_pipeline_config(cfg, models=models)
# --- training config ---
training_raw = _require_mapping(cfg.get("training"), where="training")
t = dict(training_raw)
training = _build_training_config(t, models=models, pipeline_config=pipeline_config)
return RunConfig(
models=models,
method=method,
training=training,
callbacks=callbacks,
raw=cfg,
)
+33
View File
@@ -0,0 +1,33 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
from typing import Any, TYPE_CHECKING
if TYPE_CHECKING:
from fastvideo.train.utils.training_config import (
DataConfig, )
def build_parquet_t2v_train_dataloader(
data_config: DataConfig,
*,
text_len: int,
parquet_schema: Any,
) -> Any:
"""Build a parquet dataloader for T2V-style datasets."""
from fastvideo.dataset import (
build_parquet_map_style_dataloader, )
_dataset, dataloader = (build_parquet_map_style_dataloader(
data_config.data_path,
data_config.train_batch_size,
num_data_workers=(data_config.dataloader_num_workers),
parquet_schema=parquet_schema,
cfg_rate=data_config.training_cfg_rate,
drop_last=True,
text_padding_length=int(text_len),
seed=int(data_config.seed or 0),
))
return dataloader
+89
View File
@@ -0,0 +1,89 @@
# SPDX-License-Identifier: Apache-2.0
"""``_target_``-based instantiation utilities.
These helpers resolve a dotted Python path to a class and instantiate it,
filtering constructor kwargs through ``inspect.signature`` so that only
recognized parameters are forwarded. Unrecognized keys emit a warning
rather than raising — this keeps YAML configs forward-compatible when
a class drops a parameter in a later version.
"""
from __future__ import annotations
import importlib
import inspect
import warnings
from typing import Any
def resolve_target(target: str) -> type:
"""Import and return the class (or callable) at *target*.
*target* must be a fully-qualified dotted path, e.g.
``"fastvideo.train.models.wan.wan.WanModel"``.
"""
if not isinstance(target, str) or not target.strip():
raise ValueError(f"_target_ must be a non-empty dotted path string, "
f"got {target!r}")
target = target.strip()
parts = target.rsplit(".", 1)
if len(parts) != 2:
raise ValueError(f"_target_ must contain at least one dot "
f"(module.ClassName), got {target!r}")
module_path, attr_name = parts
try:
module = importlib.import_module(module_path)
except ModuleNotFoundError as exc:
raise ImportError(f"Cannot import module {module_path!r} "
f"(from _target_={target!r})") from exc
try:
cls = getattr(module, attr_name)
except AttributeError as exc:
raise ImportError(f"Module {module_path!r} has no attribute "
f"{attr_name!r} (from _target_={target!r})") from exc
return cls
def instantiate(cfg: dict[str, Any], **extra: Any) -> Any:
"""Instantiate the class specified by ``cfg["_target_"]``.
All remaining keys in *cfg* (minus ``_target_``) plus any *extra*
keyword arguments are forwarded to the constructor. Keys that do
not match an ``__init__`` parameter are silently warned about and
dropped, so callers can safely pass a superset.
"""
if not isinstance(cfg, dict):
raise TypeError(f"instantiate() expects a dict with '_target_', "
f"got {type(cfg).__name__}")
target_str = cfg.get("_target_")
if target_str is None:
raise KeyError("Config dict is missing '_target_' key")
cls = resolve_target(str(target_str))
kwargs: dict[str, Any] = {k: v for k, v in cfg.items() if k != "_target_"}
kwargs.update(extra)
sig = inspect.signature(cls.__init__) # type: ignore[misc]
params = sig.parameters
has_var_keyword = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values())
if not has_var_keyword:
valid_names = {
name
for name, p in params.items() if p.kind in (
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.KEYWORD_ONLY,
)
}
valid_names.discard("self")
unrecognized = set(kwargs) - valid_names
if unrecognized:
warnings.warn(
f"instantiate({target_str}): dropping unrecognized "
f"kwargs {sorted(unrecognized)}",
stacklevel=2,
)
for key in unrecognized:
del kwargs[key]
return cls(**kwargs)
+16
View File
@@ -0,0 +1,16 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import torch
def apply_trainable(module: torch.nn.Module, *, trainable: bool) -> torch.nn.Module:
"""Apply train/eval mode + requires_grad based on a role's trainable flag."""
module.requires_grad_(bool(trainable))
if trainable:
module.train()
else:
module.eval()
return module
+144
View File
@@ -0,0 +1,144 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import os
from typing import Any, TYPE_CHECKING
import torch
from fastvideo.configs.pipelines.base import PipelineConfig
from fastvideo.fastvideo_args import ExecutionMode, TrainingArgs
from fastvideo.models.loader.component_loader import (
PipelineComponentLoader, )
from fastvideo.utils import (
maybe_download_model,
verify_model_config_and_directory,
)
if TYPE_CHECKING:
from fastvideo.train.utils.training_config import (
TrainingConfig, )
# ------------------------------------------------------------------
# TrainingArgs builders (only place that creates FastVideoArgs)
# ------------------------------------------------------------------
def _make_training_args(
tc: TrainingConfig,
*,
model_path: str,
) -> TrainingArgs:
"""Build a TrainingArgs for PipelineComponentLoader."""
pipeline_config = tc.pipeline_config or PipelineConfig()
# Propagate dit_precision from TrainingConfig to PipelineConfig
# so that TransformerLoader.load() picks up the correct
# default_dtype (e.g. fp32 master weights for training).
if tc.dit_precision and tc.dit_precision != pipeline_config.dit_precision:
pipeline_config.dit_precision = tc.dit_precision
return TrainingArgs(
model_path=model_path,
mode=ExecutionMode.DISTILLATION,
inference_mode=False,
pipeline_config=pipeline_config,
num_gpus=tc.distributed.num_gpus,
tp_size=tc.distributed.tp_size,
sp_size=tc.distributed.sp_size,
hsdp_replicate_dim=tc.distributed.hsdp_replicate_dim,
hsdp_shard_dim=tc.distributed.hsdp_shard_dim,
pin_cpu_memory=tc.distributed.pin_cpu_memory,
dit_cpu_offload=False,
dit_layerwise_offload=False,
vae_cpu_offload=False,
text_encoder_cpu_offload=False,
image_encoder_cpu_offload=False,
use_fsdp_inference=False,
enable_torch_compile=False,
)
def make_inference_args(
tc: TrainingConfig,
*,
model_path: str,
) -> TrainingArgs:
"""Build a TrainingArgs for inference (validation / pipelines)."""
args = _make_training_args(tc, model_path=model_path)
args.inference_mode = True
args.mode = ExecutionMode.INFERENCE
args.dit_cpu_offload = True
args.VSA_sparsity = tc.vsa.sparsity
return args
# ------------------------------------------------------------------
# Module loading
# ------------------------------------------------------------------
def load_module_from_path(
*,
model_path: str,
module_type: str,
training_config: TrainingConfig,
disable_custom_init_weights: bool = False,
override_transformer_cls_name: str | None = None,
) -> torch.nn.Module:
"""Load a single pipeline component module.
Accepts a ``TrainingConfig`` and internally builds the
``TrainingArgs`` needed by ``PipelineComponentLoader``.
"""
fastvideo_args: Any = _make_training_args(training_config, model_path=model_path)
local_model_path = maybe_download_model(model_path)
config = verify_model_config_and_directory(local_model_path)
if module_type not in config:
raise ValueError(f"Module {module_type!r} not found in "
f"config at {local_model_path}")
module_info = config[module_type]
if module_info is None:
raise ValueError(f"Module {module_type!r} has null value in "
f"config at {local_model_path}")
transformers_or_diffusers, _architecture = module_info
component_path = os.path.join(local_model_path, module_type)
old_override: str | None = None
if override_transformer_cls_name is not None:
old_override = getattr(
fastvideo_args,
"override_transformer_cls_name",
None,
)
fastvideo_args.override_transformer_cls_name = str(override_transformer_cls_name)
if disable_custom_init_weights:
fastvideo_args._loading_teacher_critic_model = True
try:
module = PipelineComponentLoader.load_module(
module_name=module_type,
component_model_path=component_path,
transformers_or_diffusers=(transformers_or_diffusers),
fastvideo_args=fastvideo_args,
)
finally:
if disable_custom_init_weights and hasattr(fastvideo_args, "_loading_teacher_critic_model"):
del fastvideo_args._loading_teacher_critic_model
if override_transformer_cls_name is not None:
if old_override is None:
if hasattr(
fastvideo_args,
"override_transformer_cls_name",
):
fastvideo_args.override_transformer_cls_name = (None)
else:
fastvideo_args.override_transformer_cls_name = (old_override)
if not isinstance(module, torch.nn.Module):
raise TypeError(f"Loaded {module_type!r} is not a "
f"torch.nn.Module: {type(module)}")
return module
+72
View File
@@ -0,0 +1,72 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
from typing import TYPE_CHECKING
import torch
from fastvideo.training.training_utils import (
clip_grad_norm_while_handling_failing_dtensor_cases,
get_scheduler,
)
if TYPE_CHECKING:
from fastvideo.train.utils.training_config import (
OptimizerConfig,
TrainingLoopConfig,
)
def build_optimizer_and_scheduler(
*,
params: list[torch.nn.Parameter],
optimizer_config: OptimizerConfig,
loop_config: TrainingLoopConfig,
learning_rate: float,
betas: tuple[float, float],
scheduler_name: str,
) -> tuple[torch.optim.Optimizer, object]:
"""Build an AdamW optimizer and LR scheduler.
Returns ``(optimizer, lr_scheduler)`` so the caller can store them
as method-level attributes.
"""
if not params:
raise ValueError("No trainable parameters passed to "
"build_optimizer_and_scheduler")
optimizer = torch.optim.AdamW(
params,
lr=float(learning_rate),
betas=betas,
weight_decay=float(optimizer_config.weight_decay),
eps=1e-8,
)
scheduler = get_scheduler(
str(scheduler_name),
optimizer=optimizer,
num_warmup_steps=int(optimizer_config.lr_warmup_steps),
num_training_steps=int(loop_config.max_train_steps),
num_cycles=int(optimizer_config.lr_num_cycles),
power=float(optimizer_config.lr_power),
min_lr_ratio=float(optimizer_config.min_lr_ratio),
last_epoch=-1,
)
return optimizer, scheduler
def clip_grad_norm_if_needed(
module: torch.nn.Module,
max_grad_norm: float,
) -> float:
if max_grad_norm <= 0.0:
return 0.0
grad_norm = (clip_grad_norm_while_handling_failing_dtensor_cases(
[p for p in module.parameters()],
max_grad_norm,
foreach=None,
))
return (float(grad_norm.item()) if grad_norm is not None else 0.0)
+51
View File
@@ -0,0 +1,51 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import os
from typing import Any, TYPE_CHECKING
from fastvideo.distributed import get_world_group
from fastvideo.training.trackers import (
initialize_trackers,
Trackers,
)
if TYPE_CHECKING:
from fastvideo.train.utils.training_config import (
CheckpointConfig,
TrackerConfig,
)
def build_tracker(
tracker_config: TrackerConfig,
checkpoint_config: CheckpointConfig,
*,
config: dict[str, Any] | None,
) -> Any:
"""Build a tracker instance for a distillation run."""
world_group = get_world_group()
trackers = list(tracker_config.trackers)
if not trackers and str(tracker_config.project_name):
trackers.append(Trackers.WANDB.value)
if world_group.rank != 0:
trackers = []
tracker_log_dir = (checkpoint_config.output_dir or os.getcwd())
if trackers:
tracker_log_dir = os.path.join(tracker_log_dir, "tracker")
tracker_config_dict = config if trackers else None
tracker_run_name = tracker_config.run_name or None
project = (tracker_config.project_name or "fastvideo")
return initialize_trackers(
trackers,
experiment_name=project,
config=tracker_config_dict,
log_dir=tracker_log_dir,
run_name=tracker_run_name,
)
+99
View File
@@ -0,0 +1,99 @@
# SPDX-License-Identifier: Apache-2.0
"""Typed training config — replaces TrainingArgs."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from fastvideo.configs.pipelines.base import PipelineConfig
@dataclass(slots=True)
class DistributedConfig:
num_gpus: int = 1
tp_size: int = 1
sp_size: int = 1
hsdp_replicate_dim: int = 1
hsdp_shard_dim: int = -1
pin_cpu_memory: bool = False
@dataclass(slots=True)
class DataConfig:
data_path: str = ""
train_batch_size: int = 1
dataloader_num_workers: int = 0
training_cfg_rate: float = 0.0
seed: int = 0
num_height: int = 0
num_width: int = 0
num_latent_t: int = 0
num_frames: int = 0
@dataclass(slots=True)
class OptimizerConfig:
learning_rate: float = 0.0
betas: tuple[float, float] = (0.9, 0.999)
weight_decay: float = 0.0
lr_scheduler: str = "constant"
lr_warmup_steps: int = 0
lr_num_cycles: int = 0
lr_power: float = 0.0
min_lr_ratio: float = 0.5
@dataclass(slots=True)
class TrainingLoopConfig:
max_train_steps: int = 0
gradient_accumulation_steps: int = 1
@dataclass(slots=True)
class CheckpointConfig:
output_dir: str = ""
resume_from_checkpoint: str = ""
training_state_checkpointing_steps: int = 0
checkpoints_total_limit: int = 0
@dataclass(slots=True)
class TrackerConfig:
trackers: list[str] = field(default_factory=list)
project_name: str = "fastvideo"
run_name: str = ""
@dataclass(slots=True)
class VSAConfig:
sparsity: float = 0.0
decay_rate: float = 0.0
decay_interval_steps: int = 0
@dataclass(slots=True)
class ModelTrainingConfig:
weighting_scheme: str = "uniform"
logit_mean: float = 0.0
logit_std: float = 1.0
mode_scale: float = 1.0
precondition_outputs: bool = False
moba_config: dict = field(default_factory=dict)
enable_gradient_checkpointing_type: str | None = None
@dataclass(slots=True)
class TrainingConfig:
distributed: DistributedConfig = field(default_factory=DistributedConfig)
data: DataConfig = field(default_factory=DataConfig)
optimizer: OptimizerConfig = field(default_factory=OptimizerConfig)
loop: TrainingLoopConfig = field(default_factory=TrainingLoopConfig)
checkpoint: CheckpointConfig = field(default_factory=CheckpointConfig)
tracker: TrackerConfig = field(default_factory=TrackerConfig)
vsa: VSAConfig = field(default_factory=VSAConfig)
model: ModelTrainingConfig = field(default_factory=ModelTrainingConfig)
pipeline_config: PipelineConfig | None = None
model_path: str = ""
dit_precision: str = "fp32"
+25
View File
@@ -89,6 +89,13 @@ class BaseTracker:
if artifacts:
self.log(artifacts, step)
def log_file(
self,
file_path: str,
name: str | None = None,
) -> None:
"""Attach a file to the tracker run (e.g. config YAML)."""
def finish(self) -> None: # pragma: no cover - interface
"""Finalize the tracker session."""
@@ -151,6 +158,16 @@ class WandbTracker(BaseTracker):
self._run.log(metrics, step=step)
self._timed_metrics = {}
def log_file(
self,
file_path: str,
name: str | None = None,
) -> None:
self._wandb.save(
file_path,
base_path=os.path.dirname(file_path),
)
def finish(self) -> None:
self._run.finish()
@@ -201,6 +218,14 @@ class SequentialTracker(BaseTracker):
tracker.log_artifacts(artifacts, step)
self._timed_metrics = {}
def log_file(
self,
file_path: str,
name: str | None = None,
) -> None:
for tracker in self._trackers:
tracker.log_file(file_path, name=name)
def finish(self) -> None:
for tracker in self._trackers:
tracker.finish()
+5 -4
View File
@@ -65,7 +65,8 @@ def gather_state_dict_on_cpu_rank0(
def compute_density_for_timestep_sampling(
weighting_scheme: str,
batch_size: int,
generator,
generator: torch.Generator,
device: torch.device | str = "cpu",
logit_mean: float | None = None,
logit_std: float | None = None,
mode_scale: float | None = None,
@@ -83,15 +84,15 @@ def compute_density_for_timestep_sampling(
mean=logit_mean,
std=logit_std,
size=(batch_size, ),
device="cpu",
device=device,
generator=generator,
)
u = torch.nn.functional.sigmoid(u)
elif weighting_scheme == "mode":
u = torch.rand(size=(batch_size, ), device="cpu", generator=generator)
u = torch.rand(size=(batch_size, ), device=device, generator=generator)
u = 1 - u - mode_scale * (torch.cos(math.pi * u / 2)**2 - 1 + u)
else:
u = torch.rand(size=(batch_size, ), device="cpu", generator=generator)
u = torch.rand(size=(batch_size, ), device=device, generator=generator)
return u
+2
View File
@@ -137,6 +137,7 @@ nav:
- STA Mask Search (Archived): inference/examples/sta_mask_search.md
- Training:
- Overview: training/overview.md
- Training Infrastructure: training/train_infra.md
- Data Preprocessing: training/data_preprocess.md
- Fine-tuning: training/finetune.md
- Examples:
@@ -159,6 +160,7 @@ nav:
- Debugging: utilities/debugging.md
- Design:
- Overview: design/overview.md
- Training Architecture: design/training_architecture.md
- Developer Guide:
- Overview: contributing/overview.md
- Developer Environment: