Merge remote-tracking branch 'origin/main' into feat/kaiqin/mg_solaris

This commit is contained in:
H1yori233
2026-03-10 15:10:34 -07:00
88 changed files with 11611 additions and 107 deletions
+94
View File
@@ -0,0 +1,94 @@
# Agent Infrastructure — Status Dashboard
Developer-maintained overview of all agent components and their maturity.
Use this to understand what exists, how complete it is, and how much to trust it.
_Last synced: 2026-03-02_
> To resync this dashboard, use the workflow: `.agents/workflows/sync-dashboard.md`
---
## Summary
| Category | Total | ✅ Ready | 🟡 Draft | 🔴 Stub | Trust |
|----------|-------|---------|---------|---------|-------|
| Skills | 8 | 0 | 8 | 0 | Low — newly created, untested |
| Workflows (SOPs) | 4 | 0 | 4 | 0 | Low — newly created, untested |
| Memory files | 4 | 1 | 3 | 0 | Medium — codebase_map is solid |
| Lessons | 0 | — | — | — | N/A — empty |
| Exploration logs | 0 | — | — | — | N/A — empty |
---
## Skills (`.agents/skills/`)
| Skill | File | Status | Trust | Tested | Notes |
|-------|------|--------|-------|--------|-------|
| Launch Experiment | `launch-experiment.md` | 🟡 Draft | Low | ❌ | Needs dry-run validation |
| Monitor Experiment | `monitor-experiment.md` | 🟡 Draft | Low | ❌ | Requires W&B API access to test |
| Summarize Run | `summarize-run.md` | 🟡 Draft | Low | ❌ | Pattern from existing test infra |
| Log Experiment | `log-experiment.md` | 🟡 Draft | Low | ❌ | Journal formatting only |
| Evaluate Video Quality | `evaluate-video-quality.md` | 🟡 Draft | Low | ❌ | SSIM section most mature |
| Index Related Work | `index-related-work.md` | 🟡 Draft | Low | ❌ | Schema defined, no entries yet |
| Search Related Work | `search-related-work.md` | 🟡 Draft | Low | ❌ | Depends on indexed entries |
| Skill Template | `SKILL_TEMPLATE.md` | ✅ Ready | High | ✅ | Meta-template, stable |
### Trust Level Definitions
- **High**: Tested in production, validated against real experiments
- **Medium**: Logic is sound, partially tested or based on existing patterns
- **Low**: Newly written, not yet validated
- **None**: Placeholder only
---
## Workflows / SOPs (`.agents/workflows/`)
| Workflow | File | Status | Trust | Tested | Notes |
|----------|------|--------|-------|--------|-------|
| Experiment Lifecycle | `experiment-lifecycle.md` | 🟡 Draft | Low | ❌ | End-to-end flow, untested |
| Evaluation Development | `evaluation-development.md` | 🟡 Draft | Low | ❌ | Metric dev process |
| Experiment Journaling | `experiment-journaling.md` | 🟡 Draft | Low | ❌ | Journaling cadence |
| Lesson Capture | `lesson-capture.md` | 🟡 Draft | Low | ❌ | Post-experiment reflection |
| Sync Dashboard | `sync-dashboard.md` | 🟡 Draft | Low | ❌ | This dashboard's updater |
---
## Memory (`.agents/memory/`)
| File | Status | Trust | Notes |
|------|--------|-------|-------|
| `codebase_map.md` | ✅ Ready | High | Synthesized from full repo research |
| `experiment_journal.md` | 🟡 Draft | Medium | Schema defined, no entries yet |
| `evaluation_registry.md` | 🟡 Draft | Medium | SSIM/loss metrics documented |
| `related_work/README.md` | 🟡 Draft | Medium | Schema defined, no entries yet |
---
## Lessons (`.agents/lessons/`)
| File | Category | Severity | Notes |
|------|----------|----------|-------|
_No lessons captured yet._
---
## Exploration Logs (`.agents/exploration/`)
| File | Status | Topic | Notes |
|------|--------|-------|-------|
_No exploration logs yet._
---
## What to Do Next
1. **Validate skills**: Run a minimal training experiment using the
`experiment-lifecycle` SOP to test `launch-experiment` → `monitor-experiment`
→ `summarize-run` end-to-end.
2. **Index first related work**: Use `index-related-work` to add at least one
paper (e.g., the Self-Forcing paper used in the codebase).
3. **Capture first lesson**: After the validation run, capture any findings.
4. **Promote to Ready**: As each skill/SOP is tested, update its status here.
+46
View File
@@ -0,0 +1,46 @@
# Exploration Logs
This directory holds draft procedures and investigation notes for tasks that
don't yet have a standardized skill or SOP. Each exploration should follow this
template.
## When to Create an Exploration Log
- You are working on a task with no existing skill or workflow.
- You are experimenting with a new metric, training technique, or tool.
- You want to document findings before they are promoted to a standard.
## File Naming
`<topic-slug>.md` — e.g., `fvd-metric-investigation.md`
## Template
```markdown
# Exploration Log: <Topic>
## Status: draft | under_review | promoted | abandoned
## Context
<Why this exploration is needed — link to experiment or task if applicable.>
## Progress
- [ ] Step 1: ...
- [ ] Step 2: ...
## Findings
<What you have learned so far.>
## Mistakes / Dead Ends
<What didn't work and why — these become lessons.>
## Proposed Standardization
<If this works, describe the skill/SOP/workflow to create.>
```
## Lifecycle
1. **Create** during exploration mode.
2. **Update** as you make progress.
3. **Promote**: If findings are solid, create a skill in `.agents/skills/` or an SOP in `.agents/workflows/`.
4. **Archive mistakes**: Move failures into `.agents/lessons/`.
+48
View File
@@ -0,0 +1,48 @@
# Lessons Learned Database
This directory stores documented mistakes, unexpected behaviors, and their fixes.
Each lesson is a permanent record that helps agents and humans avoid repeating
past errors.
## When to Create a Lesson
- An experiment failed for a non-obvious reason.
- A configuration or hyperparameter choice led to wasted compute.
- A porting, data, or infrastructure issue was discovered and resolved.
- A workaround was needed for a known framework/library bug.
## File Naming
`<YYYY-MM-DD>_<short-slug>.md` — e.g., `2026-03-02_lr-too-high-for-lora.md`
## Template
```markdown
---
date: <ISO-8601>
experiment: <reference to experiment_journal.md entry, if applicable>
category: hyperparameter | data | infrastructure | evaluation | porting | other
severity: critical | important | minor
---
# <Short Descriptive Title>
## What Happened
<Description of the problem and its symptoms.>
## Root Cause
<Analysis of why it happened.>
## Fix / Workaround
<What resolved the issue.>
## Prevention
<How to avoid this in the future — updated skills, SOPs, or checks.>
```
## Usage
- Before starting a task, **search this directory** for relevant lessons.
- After completing or failing a task, **check if a new lesson should be created**.
- Periodically review lessons for **patterns** — recurring themes may warrant
a new skill, SOP, or codebase fix.
+129
View File
@@ -0,0 +1,129 @@
# FastVideo-WorldModel — Codebase Map
High-level structural index for agent orientation. Updated 2026-03-08.
## Repository Layout
```
FastVideo-WorldModel/
├── fastvideo/ # Core Python package
│ ├── models/ # Model implementations
│ │ ├── dits/ # DiT transformers (wanvideo, ltx2, ...)
│ │ ├── vaes/ # VAE models
│ │ ├── encoders/ # Text/image encoders (T5, CLIP)
│ │ ├── schedulers/ # Noise schedulers
│ │ ├── upsamplers/ # Super-resolution models
│ │ ├── audio/ # Audio models
│ │ └── loader/ # Component loaders for HF repos
│ ├── configs/ # Configuration system
│ │ ├── models/ # Arch configs + param_names_mapping
│ │ ├── pipelines/ # Pipeline wiring
│ │ └── sample/ # Default sampling parameters
│ ├── pipelines/ # End-to-end pipelines
│ │ ├── basic/ # Per-model pipelines (wan/, ltx2/, ...)
│ │ └── stages/ # Reusable pipeline stages
│ ├── 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
│ │ ├── wan_training_pipeline.py # Wan T2V training
│ │ ├── wan_i2v_training_pipeline.py # Wan I2V training
│ │ ├── distillation_pipeline.py # Distillation base
│ │ ├── wan_distillation_pipeline.py # Wan distillation
│ │ ├── self_forcing_distillation_pipeline.py # Self-forcing distill
│ │ ├── ltx2_training_pipeline.py # LTX-2 training
│ │ └── matrixgame_training_pipeline.py # MatrixGame training
│ ├── attention/ # Attention backends
│ ├── distributed/ # Sequence/tensor parallel utilities
│ ├── layers/ # Tensor-parallel layers
│ ├── tests/ # Package-level tests
│ │ ├── training/ # Training regression tests (W&B summary comparison)
│ │ ├── ssim/ # SSIM visual regression tests
│ │ ├── encoders/ # Encoder parity tests
│ │ └── modal/ # Modal CI test runner
│ └── registry.py # Unified config registry
├── fastvideo-kernel/ # CUDA/custom kernels (separate build: ./build.sh)
├── scripts/ # Utility scripts
│ ├── distill/ # Distillation launch scripts
│ ├── inference/ # Inference scripts
│ ├── checkpoint_conversion/ # Weight conversion tools
│ ├── finetune/ # Finetune scripts
│ └── preprocess/ # Data preprocessing
├── examples/ # Ready-to-run examples
│ ├── training/ # Training examples (finetune/, consistency_finetune/)
│ ├── distill/ # Distillation examples
│ ├── inference/ # Inference examples
│ └── dataset/ # Dataset examples
├── docs/ # MkDocs documentation source
│ ├── design/overview.md # Architecture overview
│ ├── training/ # Training guides
│ └── contributing/ # Contributor guides + coding_agents.md
├── tests/ # Top-level tests (local_tests/)
├── AGENTS.md # Agent coding guidelines
└── .agents/ # Agent infrastructure (you are here)
```
## 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` |
| Wan I2V finetune | `fastvideo/training/wan_i2v_training_pipeline.py` | `torchrun --nproc_per_node N` |
| Wan distillation (DMD) | `fastvideo/training/wan_distillation_pipeline.py` | `torchrun --nproc_per_node N` |
| Self-forcing distill | `fastvideo/training/wan_self_forcing_distillation_pipeline.py` | `torchrun --nproc_per_node N` |
| LTX-2 finetune | `fastvideo/training/ltx2_training_pipeline.py` | `torchrun --nproc_per_node N` |
| MatrixGame | `fastvideo/training/matrixgame_training_pipeline.py` | `torchrun --nproc_per_node N` |
## W&B Integration
- **Tracker classes**: `fastvideo/training/trackers.py`
- `WandbTracker` — logs metrics, videos, timing
- `SequentialTracker` — fan-out to multiple trackers
- `DummyTracker` — no-op for offline/test
- **Run summary location**: `<output_dir>/tracker/wandb/latest-run/files/wandb-summary.json`
- **Reference summaries**: `fastvideo/tests/training/*/` (e.g., `a40_reference_wandb_summary.json`)
- **Environment**: `WANDB_API_KEY`, `WANDB_BASE_URL`, `WANDB_MODE`
## Critical Environment Variables
| Variable | Purpose |
|----------|---------|
| `WANDB_API_KEY` | W&B authentication |
| `WANDB_MODE` | `online` / `offline` |
| `FASTVIDEO_ATTENTION_BACKEND` | `FLASH_ATTN` / `TORCH_SDPA` |
| `TOKENIZERS_PARALLELISM` | Set `false` to avoid fork warnings |
| `HF_HOME` | HuggingFace cache directory |
## Build & Test Commands
```bash
uv pip install -e .[dev] # Editable install
pre-commit run --all-files # Lint/format/spell
pytest tests/ # Top-level tests
pytest fastvideo/tests/ -v # Package tests
pytest fastvideo/tests/training/Vanilla -srP # Training loss regression
pytest fastvideo/tests/ssim/ -vs # SSIM visual regression
cd fastvideo-kernel && ./build.sh # Build kernels
```
@@ -0,0 +1,327 @@
# Evaluation Metrics Registry
Living catalog of all evaluation metrics for FastVideo-WorldModel video quality
assessment. Each metric includes a detailed explanation, implementation status,
usage instructions, and interpretation guide.
_Last updated: 2026-03-02_
---
## Metric Summary
| Metric | Category | Status | Location | Trust |
|--------|----------|--------|----------|-------|
| **FVD** | Distribution | ✅ Implemented | `benchmarks/fvd/` | High |
| **SSIM** | Reference | ✅ Implemented | `fastvideo/tests/ssim/` | High |
| **LPIPS** | Perceptual | ✅ Implemented | `scripts/lora_extraction/` | Medium |
| **Loss trajectory** | Training signal | ✅ Implemented | W&B `train_loss` | Medium |
| **Grad norm stability** | Training signal | ✅ Implemented | W&B `grad_norm` | Medium |
| **GameWorld Score** | Multi-dim benchmark | 🟡 External | Matrix-Game repo | Low |
| **Human preference** | Gold standard | 🔴 Manual | N/A | Highest |
---
## Implemented Metrics
### FVD — Fréchet Video Distance
**Category**: Distribution-level quality metric
**Status**: ✅ Fully implemented in `benchmarks/fvd/`
**Trust**: High — standard protocol, I3D feature extractor
#### What It Measures
FVD measures the distance between the **distribution** of generated videos and
a distribution of real/reference videos. It works by:
1. Extracting spatiotemporal features from both real and generated video sets
using a pretrained **I3D** (Inflated 3D ConvNet) model.
2. Modeling each set of features as a multivariate Gaussian (mean + covariance).
3. Computing the **Fréchet distance** between the two Gaussians.
Lower FVD = generated videos are more statistically similar to real videos.
#### Why It Matters
- FVD is the **de facto standard** for benchmarking video generation models.
- It captures both **visual quality** (are individual frames realistic?) and
**temporal coherence** (do frames flow naturally?).
- Matrix-Game 2.0, Open-Sora, and most video generation papers report FVD.
#### Limitations
- Requires a **large sample set** (standard protocol uses 2048 videos) to
produce stable statistics. Small sample sizes yield noisy results.
- Measures **distributional similarity**, not per-video quality. A model could
have low FVD by generating a diverse set of "roughly okay" videos.
- The I3D model was trained on Kinetics-400 (human actions). It may be less
sensitive to domain-specific artifacts in non-human-action videos (e.g.,
driving, game environments).
- Does not directly measure text-video alignment or action controllability.
#### How to Use
```python
# Programmatic
from benchmarks.fvd import compute_fvd_with_config, FVDConfig
config = FVDConfig.fvd2048_16f() # Standard: 2048 videos, 16 frames
results = compute_fvd_with_config('data/real/', 'outputs/gen/', config)
print(f"FVD: {results['fvd']:.2f}")
```
```bash
# CLI
python -m benchmarks.fvd.cli \
--real-path data/real/ \
--gen-path outputs/gen/ \
--protocol fvd2048_16f
```
**Preset protocols**:
| Protocol | Videos | Frames | Use Case |
|----------|--------|--------|----------|
| `fvd2048_16f` | 2048 | 16 | Standard benchmark (papers) |
| `fvd2048_128f` | 2048 | 128 | Long video evaluation |
| `quick_test` | 100 | 16 | Fast dev iteration |
**Feature extractors**: `i3d` (default, standard), `clip`, `videomae`
#### Interpretation
| FVD Range | Interpretation |
|-----------|---------------|
| < 100 | Excellent — near-real quality |
| 100–300 | Good — competitive with SOTA |
| 300–600 | Fair — noticeable gap from real |
| > 600 | Poor — significant quality issues |
> FVD values are dataset-dependent. Always compare against baselines evaluated
> on the same real video distribution.
---
### SSIM — Structural Similarity Index
**Category**: Per-frame reference comparison
**Status**: ✅ Implemented in `fastvideo/tests/ssim/`
**Trust**: High — used in CI regression tests
#### What It Measures
SSIM compares two images (or video frames) based on three components:
1. **Luminance**: brightness similarity
2. **Contrast**: dynamic range similarity
3. **Structure**: spatial pattern similarity
The final score is a value in [0, 1] where 1.0 = identical.
#### Why It Matters
- Used as a **regression guard** in CI: ensures model updates don't degrade
visual output below a threshold.
- More perceptually meaningful than raw pixel MSE.
- Fast to compute — suitable for automated testing.
#### Limitations
- Requires a **pixel-aligned reference** video. Cannot compare videos with
different seeds, prompts, or angles.
- Operates **per-frame** — does not capture temporal coherence.
- Insensitive to some perceptual artifacts (color shifts, high-frequency noise).
#### How to Use
```bash
pytest fastvideo/tests/ssim/ -vs
```
#### Interpretation
| SSIM Range | Quality |
|------------|---------|
| > 0.90 | Excellent — very close to reference |
| 0.80–0.90 | Good — acceptable for most uses |
| 0.70–0.80 | Fair — noticeable differences |
| < 0.70 | Poor — significant divergence |
---
### LPIPS — Learned Perceptual Image Patch Similarity
**Category**: Per-frame perceptual distance
**Status**: ✅ Implemented in `scripts/lora_extraction/lora_inference_comparison.py`
**Trust**: Medium — available but only used for LoRA comparison currently
#### What It Measures
LPIPS uses a pretrained neural network (AlexNet by default) to extract
deep features from two images and computes the distance between them in
feature space. Unlike SSIM, LPIPS correlates much more strongly with
**human perceptual judgments**.
Lower LPIPS = more perceptually similar.
#### Why It Matters
- Best available automated proxy for **human visual judgments** at the frame
level.
- Captures semantic and structural differences that SSIM misses (e.g., texture
changes, minor recoloring).
- Used for validating LoRA merge quality.
#### Limitations
- Per-frame metric — no temporal awareness.
- Requires reference video (paired comparison only).
- Slightly slower than SSIM due to neural network forward pass.
#### How to Use
```bash
python scripts/lora_extraction/lora_inference_comparison.py \
--base merged_model \
--ft path/to/finetuned \
--adapter NONE \
--output-dir results \
--prompt "A cat" \
--compute-lpips
```
#### Interpretation
| LPIPS Range | Quality |
|-------------|---------|
| < 0.10 | Excellent — nearly indistinguishable |
| 0.10–0.20 | Good — minor perceptual differences |
| 0.20–0.40 | Fair — noticeable differences |
| > 0.40 | Poor — clearly different |
---
### Loss Trajectory
**Category**: Training signal proxy
**Status**: ✅ Active (from W&B `train_loss`)
**Trust**: Medium — proxy, not direct quality measure
#### What It Measures
Tracks the training loss over time. A healthy training run shows:
- **Decreasing loss** over the first hundreds of steps.
- **Stable gradient norms** (no wild spikes).
- **Consistent step times** (no infrastructure issues).
#### Why It Matters
- Cheapest evaluation signal — available in real-time from W&B.
- Critical for the **30-minute quality check** workflow.
- At later training stages (when loss becomes meaningful), trajectory shape
can predict final model quality.
#### Context: How This Evolves
The team's experience shows evaluation signals change during a project:
- **Early stage**: Loss may be flat or meaningless → focus on SSIM & visual
inspection instead.
- **Mid stage**: Loss starts decreasing → trajectory shape becomes useful.
- **Late stage**: Loss is meaningful → can compare trajectories across runs.
This dynamic is a key insight from the team's workflow: don't over-rely on
loss early; don't ignore it late.
---
### Grad Norm Stability
**Category**: Training health diagnostic
**Status**: ✅ Active (from W&B `grad_norm`)
**Trust**: Medium — diagnostic, not quality metric
#### What It Measures
The magnitude of gradients during training. Stable grad norms indicate
healthy optimization. Spikes or NaN values indicate training instability.
#### Alert Thresholds
| Condition | Meaning |
|-----------|---------|
| Stable ~0.3–0.5 | Normal training |
| Single spike > 3× average | Possible bad batch, monitor |
| NaN or Inf | 🔴 Training has diverged — stop run |
| Increasing trend | Learning rate may be too high |
---
## External Benchmarks
### GameWorld Score Benchmark (Matrix-Game)
**Category**: Multi-dimensional evaluation framework for interactive world models
**Status**: 🟡 External — not implemented in-repo
**Source**: [Matrix-Game 1.0 benchmark](https://github.com/SkyworkAI/Matrix-Game), used in [Matrix-Game 2.0 paper](https://arxiv.org/abs/2508.13009)
#### What It Measures
A comprehensive benchmark examining **four critical capabilities**:
| Dimension | What It Evaluates | Example Signals |
|-----------|-------------------|-----------------|
| **Visual quality** | Frame-level realism, absence of artifacts | Color fidelity, sharpness, coherence |
| **Temporal quality** | Smoothness across frames, motion consistency | Jitter, flickering, temporal aliasing |
| **Action controllability** | Response to input actions (keyboard/mouse) | Action delay, correctness, smoothness |
| **Physical rule understanding** | Adherence to physics (gravity, collision) | Object persistence, plausible motion |
#### Context from Matrix-Game 2.0
- Evaluation uses **597-frame composite action sequences** over 32 Minecraft
scenes and 16 wild scenes.
- Action controllability assessment is **Minecraft-specific** — cannot be
directly applied to wild/general scenes.
- The paper notes that models that "collapse" to static frames can
paradoxically score higher on consistency metrics — beware of this confound.
#### Relevance to FastVideo
- Matrix-Game 2.0 is built on SkyReels-V2/Wan2.1 architecture — **same model
family as FastVideo**.
- Their distillation uses DMD-based Self-Forcing — **same technique** as our
`self_forcing_distillation_pipeline.py`.
- GameWorld Score dimensions are a useful framework for thinking about world
model quality even outside gaming contexts.
---
## Human Preference Evaluation
**Category**: Gold-standard quality assessment
**Status**: 🔴 Manual process — no automated implementation
**Priority**: **Highest** — this is the most important evaluation signal
**Trust**: Highest — but expensive
### What It Measures
Human evaluators compare generated videos and rate them on dimensions like:
- Overall quality and realism
- Temporal coherence and smoothness
- Prompt adherence / action correctness
- Absence of artifacts
#### Why It's the Most Important Metric
All automated metrics are **proxies** for human judgment. They can be gamed
or may miss artifacts that humans easily notice. Human preference is the
ultimate ground truth for video generation quality.
#### Cost & Practicality
| Approach | Cost | Scale | When to Use |
|----------|------|-------|-------------|
| Internal team review | Low | ~10–50 videos | Every major checkpoint |
| Crowdsource (MTurk, Scale) | Medium | 100+ videos | Pre-release validation |
| A/B preference test | Medium | Pairs | Comparing two model versions |
#### Recommended Protocol
1. Sample 10–20 videos from the model at a checkpoint.
2. Include diverse prompts (easy + hard, short + long).
3. Have 2–3 evaluators score each video 1–5 on: quality, coherence, fidelity.
4. Record scores in the experiment journal.
---
## Metrics NOT Used
| Metric | Reason |
|--------|--------|
| ~~CLIP-Score~~ | Not used by the team. Measures text-image alignment using CLIP embeddings, but not well-suited for video temporal quality. |
| Inception Score (IS) | Less informative than FVD for video; primarily an image metric. |
| PSNR | Pixel-level metric; less perceptually meaningful than SSIM/LPIPS. |
---
## Adding a New Metric
Follow the SOP: `.agents/workflows/evaluation-development.md`
1. Prototype in `.agents/exploration/`
2. Validate on known-good and known-bad samples
3. Add to this registry
4. Update the `evaluate-video-quality` skill
@@ -0,0 +1,21 @@
# Experiment Journal
Living log of all experiments. Each entry captures what was tried, the result,
and any insights. Newest entries go at the top.
_No experiments logged yet. Use the `log-experiment` skill to add entries._
<!-- TEMPLATE — copy and fill for each new experiment:
## [YYYY-MM-DD] Experiment: <name>
- **Hypothesis**: <what you expected to learn>
- **Config**: model=..., lr=..., sp_size=..., gpus=..., script=...
- **W&B run**: <run_id or URL>
- **Duration**: <total wall time>
- **Key metrics**: loss=..., step_time=..., grad_norm=...
- **Checkpoint**: <path>
- **Insight**: <what was learned>
- **Status**: running | completed | failed | abandoned
- **Related lessons**: `.agents/lessons/<filename>.md`
-->
+4
View File
@@ -0,0 +1,4 @@
{"name": "codebase-map", "description": "High-level structural index of the FastVideo-WorldModel repository", "path": "codebase-map/README.md", "status": "ready", "trust": "high"}
{"name": "evaluation-registry", "description": "Catalog of all evaluation metrics with detailed explanations, implementation status, and usage guides", "path": "evaluation-registry/README.md", "status": "draft", "trust": "medium"}
{"name": "experiment-journal", "description": "Living log of all experiments with hypotheses, configs, metrics, and insights", "path": "experiment-journal/README.md", "status": "draft", "trust": "medium"}
{"name": "related-work", "description": "Index of related papers, repos, and blog posts with structured comparisons to FastVideo", "path": "related-work/README.md", "status": "draft", "trust": "low"}
+34
View File
@@ -0,0 +1,34 @@
# Related Work Index
Each file in this directory is a structured summary of a related paper, repo,
or blog post relevant to FastVideo-WorldModel training.
## File Format
Each file is named `<slug>.md` and follows this structure:
```markdown
---
title: <paper/repo title>
source: <URL or citation>
type: paper | repo | blog
date_indexed: <ISO-8601>
tags: [world-model, distillation, evaluation, reward-shaping, ...]
---
## Summary
<1-2 paragraph summary of the work.>
## Key Differences from FastVideo
- <Bullet points comparing their approach to ours.>
## Actionable Insights
- <What we could adopt or adapt.>
```
## How to Add New Entries
Use the `index-related-work` skill, or manually create a file following the
template above.
_No related work indexed yet._
+76
View File
@@ -0,0 +1,76 @@
# Agent Onboarding — FastVideo-WorldModel
Welcome, agent. This is the **master onboarding** guide. Follow the steps below,
then check if a **domain-specific onboarding** exists for your task.
## Domain-Specific Onboarding
If your task falls into one of these areas, read the specialized guide **after**
completing the general steps below:
| Domain | Guide | When to Use |
|--------|-------|-------------|
| **WorldModel Training** | `worldmodel-training/README.md` | Training, finetuning, distillation, experiment management |
---
## Step 1: Understand the Codebase
Read these files to build your context:
| Priority | File | What you learn |
|----------|------|----------------|
| 1 | `AGENTS.md` | Coding guidelines, build/test commands, PR conventions |
| 2 | `docs/design/overview.md` | Architecture: models, pipelines, configs, registry |
| 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
Read these two index files to see what skills and memory modules exist:
- **`.agents/skills/index.jsonl`** — catalog of all agent skills (name + description)
- **`.agents/memory/index.jsonl`** — catalog of all memory modules (name + description)
Each entry has a `path` field pointing to the full content. Only load the
full README.md for modules relevant to your current task.
## Step 3: Check for Existing Skills & SOPs
Before writing new code or procedures:
1. **Skills**: Read `.agents/skills/index.jsonl` — find a matching skill by description.
2. **Workflows/SOPs**: Browse `.agents/workflows/` — step-by-step procedures for common tasks.
3. **Lessons**: Browse `.agents/lessons/` — known pitfalls and their fixes.
If a skill or SOP exists for your task, **use it**. If not, you are in **exploration mode** — see Step 4.
## Step 4: Exploration Mode
If no existing skill/SOP covers your task:
1. Document your progress in `.agents/exploration/<topic>.md` using the template in `.agents/exploration/README.md`.
2. At the end of your session, reflect:
- **What worked** → propose a new skill or SOP in the exploration log.
- **What failed** → create a lesson in `.agents/lessons/`.
3. Flag the exploration log for human review.
## Quick Reference
```
.agents/
├── ONBOARDING.md ← you are here
├── STATUS.md ← dashboard: completeness & trust of all components
├── skills/ ← reusable agent skills
├── workflows/ ← SOPs and procedures
├── memory/ ← persistent context (folder per topic + index.jsonl)
│ ├── index.jsonl
│ ├── codebase-map/
│ ├── experiment-journal/
│ ├── evaluation-registry/
│ └── related-work/
├── lessons/ ← mistakes and fixes
└── exploration/ ← draft procedures
```
@@ -0,0 +1,302 @@
# WorldModel Training — Agent Onboarding
Specialized onboarding for agents working on FastVideo-WorldModel training,
distillation, and evaluation. Read the master onboarding (`.agents/onboarding/README.md`)
first, then come here.
---
## Domain Context
FastVideo-WorldModel trains **interactive world models** — video generation systems
that respond to user actions (keyboard/mouse) in real-time. The architecture is
based on **Wan2.1** (SkyReels-V2) DiT models with causal attention for
auto-regressive streaming generation.
**Key techniques you will work with:**
- Full finetuning and LoRA on Wan / LTX-2 / MatrixGame models
- DMD-based distillation (few-step generation)
- Self-Forcing distillation (causal streaming)
- 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).
---
## Essential Reading (Training-Specific)
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` | 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 |
---
## New Training Framework (`fastvideo/train/`)
### Architecture Overview
```
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
```
---
## Legacy Training Pipelines (`fastvideo/training/`)
> **Note:** Use the new `fastvideo/train/` framework for new work. This section
> is retained for reference on existing pipelines not yet migrated.
| 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 |
---
## Key Infrastructure
### 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`
### Parallelism
- **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
---
## Evaluation (for training runs)
Read `.agents/memory/evaluation-registry/README.md` for the full metric catalog.
**Quick summary for training agents:**
| Metric | When to Use | Trust |
|--------|-------------|-------|
| **Loss trajectory** | Every run, real-time from W&B | Medium |
| **SSIM** | When comparing against reference outputs | High |
| **FVD** | For benchmarking model quality (`benchmarks/fvd/`) | High |
| **LPIPS** | LoRA merge validation | Medium |
| **Human preference** | Major checkpoints | Highest |
---
## Common Workflows
| Task | Skill / SOP |
|------|-------------|
| Launch a training run | `.agents/skills/launch-experiment/SKILL.md` |
| Monitor a running experiment | `.agents/skills/monitor-experiment/SKILL.md` |
| Summarize final results | `.agents/skills/summarize-run/SKILL.md` |
| Full experiment lifecycle | `.agents/workflows/experiment-lifecycle.md` |
| Capture lessons from failures | `.agents/workflows/lesson-capture.md` |
---
## World Model–Specific Concepts
### Action Injection (MatrixGame)
The MatrixGame pipeline adds **action modules** to each DiT block, enabling
frame-level mouse/keyboard input conditioning. The action sequence is injected
per-frame alongside the latent video tokens.
### Causal Architecture
For streaming generation, the model uses **causal attention** (each frame only
attends to previous frames). This enables auto-regressive chunk-by-chunk
generation — critical for real-time interactive world models.
### Self-Forcing Distillation
A **data-free** distillation method where the student model is trained to
generate coherent video sequences by being forced to use its own previous
outputs (rather than ground-truth) as context. This produces models robust to
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 a critic network to estimate
distribution divergence.
### 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.
+57
View File
@@ -0,0 +1,57 @@
---
name: <skill-name>
description: <one-line description — Codex uses this for implicit invocation matching>
---
# <Skill Name>
## Purpose
<Why this skill exists and when to use it.>
## Prerequisites
- <What must be true before using this skill>
## Inputs
| Parameter | Required | Description |
|-----------|----------|-------------|
| `param1` | Yes | ... |
## Steps
1. **Step 1 title**
- Detail...
2. **Step 2 title**
- Detail...
## Outputs
- <What this skill produces>
## Example Usage
```
<Example invocation or prompt snippet>
```
## References
- <Links to relevant files in the codebase>
---
## Folder Structure
Each skill lives in its own directory under `.agents/skills/`:
```
.agents/skills/<skill-name>/
├── SKILL.md # Required: instructions + metadata (this file)
├── scripts/ # Optional: executable helper scripts
├── references/ # Optional: documentation, papers
└── assets/ # Optional: templates, resources
```
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"}
```
@@ -0,0 +1,128 @@
---
name: evaluate-video-quality
description: Evaluate generated video quality using available metrics (SSIM, loss trajectory, caption consistency)
---
# Evaluate Video Quality
## Purpose
Assess the quality of videos generated by a training run. Combines multiple
signals to give a holistic quality assessment. This skill is **evolving** —
new metrics will be added as they are developed.
## Prerequisites
- Generated videos available locally or via W&B artifacts.
- For SSIM: reference videos from official implementations.
- For caption consistency: LLM access (optional, stub for now).
## Inputs
| Parameter | Required | Description |
|-----------|----------|-------------|
| `video_paths` | Yes | List of paths to generated videos |
| `reference_paths` | No | Paths to reference videos (for SSIM) |
| `prompts` | No | Prompts used to generate videos (for caption check) |
| `loss_summary` | No | Path to W&B summary JSON (for loss trajectory) |
| `metrics` | No | Which metrics to run (default: all available) |
## Available Metrics
Check `.agents/memory/evaluation-registry/README.md` for the current catalog.
### SSIM (Active)
Leverages the existing infrastructure in `fastvideo/tests/ssim/`.
```bash
pytest fastvideo/tests/ssim/ -vs --video-path <generated> --reference-path <reference>
```
Or use the SSIM utility directly:
```python
from fastvideo.tests.ssim.ssim_utils import compute_ssim
score = compute_ssim(generated_video, reference_video)
# score > 0.85 is typically "acceptable"
```
**Interpretation**:
| SSIM Range | Quality |
|------------|---------|
| > 0.90 | Excellent — very close to reference |
| 0.80–0.90 | Good — acceptable for most uses |
| 0.70–0.80 | Fair — noticeable differences |
| < 0.70 | Poor — significant quality issues |
### Loss Trajectory (Active)
Analyze the loss curve shape from W&B summary:
```python
import json
with open(loss_summary_path) as f:
summary = json.load(f)
final_loss = summary["train_loss"]
runtime = summary["_runtime"]
steps = summary["_step"]
```
**Early-stage heuristics** (first 500 steps):
- Loss should be decreasing (even slightly).
- Grad norm should be stable (no wild oscillations).
- If loss is flat or increasing, flag for review.
### Caption Consistency (Draft — Not Yet Calibrated)
Use an LLM to evaluate whether the video content matches the input prompt.
```
Prompt: "A golden retriever playing in the snow"
Video: <path>
Score the video on:
1. Object presence (is there a golden retriever?)
2. Action accuracy (is it playing?)
3. Environment match (is there snow?)
4. Overall coherence (does it look natural?)
Each 1-5, total /20.
```
> ⚠️ This metric is in **draft** status. Results should not be treated as
> ground truth until calibrated against human judgments.
## Steps
1. **Identify available metrics** — Check `.agents/memory/evaluation-registry/README.md`.
2. **Run each metric** — Collect scores.
3. **Aggregate** — Produce a combined quality report.
4. **Log** — Update the experiment journal with quality results.
## Outputs
```markdown
## Video Quality Report: <experiment_name>
| Metric | Score | Threshold | Status |
|--------|-------|-----------|--------|
| SSIM (avg) | 0.87 | > 0.80 | ✅ Pass |
| Loss trajectory | decreasing | decreasing | ✅ Pass |
| Caption consistency | 16/20 | > 14/20 | ✅ Pass |
### Per-Video Scores
| Video | SSIM | Caption |
|-------|------|---------|
| video_001.mp4 | 0.89 | 17/20 |
| video_002.mp4 | 0.85 | 15/20 |
```
## References
- `fastvideo/tests/ssim/` — SSIM test infrastructure
- `fastvideo/tests/training/Vanilla/test_training_loss.py` — loss comparison
- `.agents/memory/evaluation-registry/README.md` — metric catalog
## Changelog
| Date | Change |
|------|--------|
| 2026-03-02 | Initial version with SSIM, loss trajectory, caption consistency stub |
@@ -0,0 +1,94 @@
---
name: index-related-work
description: Ingest a paper or repository into the related work index
---
# Index Related Work
## Purpose
Create a structured summary of a related paper, repository, or blog post and
add it to `.agents/memory/related-work/` for future reference. This builds the
agent's knowledge base for making informed decisions about training, evaluation,
and architecture choices.
## Prerequisites
- Access to the paper/repo (URL, PDF, or local clone).
## Inputs
| Parameter | Required | Description |
|-----------|----------|-------------|
| `source` | Yes | URL, citation, or local path |
| `type` | Yes | `paper`, `repo`, or `blog` |
| `tags` | No | List of tags (default: inferred from content) |
## Steps
### 1. Extract key information
For **papers**: Read abstract, method section, experimental setup, and results.
For **repos**: Read README, key source files, and training scripts.
For **blogs**: Read the full post.
Focus on:
- What problem does it solve?
- What architecture/technique is used?
- How does it relate to FastVideo's approach?
### 2. Create the index entry
Write to `.agents/memory/related-work/<slug>.md`:
```markdown
---
title: <title>
source: <URL or citation>
type: paper | repo | blog
date_indexed: <ISO-8601>
tags: [world-model, distillation, evaluation, ...]
---
## Summary
<1-2 paragraph summary.>
## Key Differences from FastVideo
- <comparison points>
## Actionable Insights
- <what we could adopt or adapt>
```
### 3. Update the catalog
If `.agents/memory/related-work/_catalog.md` exists, append the new entry.
If not, create it:
```markdown
# Related Work Catalog
| Slug | Title | Type | Tags | Date |
|------|-------|------|------|------|
| <slug> | <title> | <type> | <tags> | <date> |
```
## Outputs
- New file in `.agents/memory/related-work/<slug>.md`.
- Updated catalog.
## Example Usage
```
Index the Self-Forcing paper:
source: https://arxiv.org/abs/2406.xxxxx
type: paper
tags: [world-model, self-forcing, distillation]
```
## References
- `.agents/memory/related-work/README.md` — schema documentation
## Changelog
| Date | Change |
|------|--------|
| 2026-03-02 | Initial version |
+7
View File
@@ -0,0 +1,7 @@
{"name": "launch-experiment", "description": "Generate and execute a training launch command for FastVideo models", "path": "launch-experiment/SKILL.md", "status": "draft", "trust": "low"}
{"name": "monitor-experiment", "description": "Poll a running W&B training run for progress and emit structured alerts", "path": "monitor-experiment/SKILL.md", "status": "draft", "trust": "low"}
{"name": "summarize-run", "description": "Extract a W&B run summary into a structured experiment report", "path": "summarize-run/SKILL.md", "status": "draft", "trust": "low"}
{"name": "log-experiment", "description": "Append or update an experiment entry in the experiment journal", "path": "log-experiment/SKILL.md", "status": "draft", "trust": "low"}
{"name": "evaluate-video-quality", "description": "Evaluate generated video quality using available metrics (SSIM, loss trajectory, caption consistency)", "path": "evaluate-video-quality/SKILL.md", "status": "draft", "trust": "low"}
{"name": "index-related-work", "description": "Ingest a paper or repository into the related work index", "path": "index-related-work/SKILL.md", "status": "draft", "trust": "low"}
{"name": "search-related-work", "description": "Query the related work index for relevant papers, repos, or comparisons", "path": "search-related-work/SKILL.md", "status": "draft", "trust": "low"}
+127
View File
@@ -0,0 +1,127 @@
---
name: launch-experiment
description: Generate and execute a training launch command for FastVideo models
---
# Launch Experiment
## Purpose
Construct a fully-specified `torchrun` training command for a FastVideo model
given a target pipeline, dataset, and hyperparameter overrides. This skill
automates the boilerplate of setting environment variables, picking the right
entrypoint, and applying defaults from the closest example script.
## Prerequisites
- The repo is cloned and `fastvideo` is installed (`uv pip install -e .[dev]`).
- Dataset is preprocessed (see `docs/training/data_preprocess.md`).
- `WANDB_API_KEY` is set in the environment (or `WANDB_MODE=offline` for local).
- GPU resources are available (multi-GPU requires NCCL).
## Inputs
| Parameter | Required | Description |
|-----------|----------|-------------|
| `pipeline` | Yes | Training pipeline type: `finetune`, `distill-dmd`, `self-forcing`, `lora`, `consistency` |
| `model` | Yes | Model family: `wan-t2v-1.3B`, `wan-i2v-14B`, `ltx2`, `matrixgame` |
| `data_path` | Yes | Path to preprocessed dataset (parquet) |
| `num_gpus` | Yes | Number of GPUs |
| `overrides` | No | Dict of hyperparameter overrides (any CLI arg) |
| `output_dir` | No | Output directory (default: `outputs/<model>_<pipeline>`) |
| `run_name` | No | W&B run name (default: auto-generated) |
## Steps
### 1. Identify the training entrypoint
| Pipeline | Entrypoint |
|----------|-----------|
| `finetune` (Wan T2V) | `fastvideo/training/wan_training_pipeline.py` |
| `finetune` (Wan I2V) | `fastvideo/training/wan_i2v_training_pipeline.py` |
| `finetune` (LTX-2) | `fastvideo/training/ltx2_training_pipeline.py` |
| `finetune` (MatrixGame) | `fastvideo/training/matrixgame_training_pipeline.py` |
| `distill-dmd` | `fastvideo/training/wan_distillation_pipeline.py` |
| `self-forcing` | `fastvideo/training/wan_self_forcing_distillation_pipeline.py` |
### 2. Resolve default hyperparameters
Find the closest example script in `examples/training/` for the model:
| Model | Example Script Directory |
|-------|-------------------------|
| `wan-t2v-1.3B` | `examples/training/finetune/wan_t2v_1.3B/crush_smol/` |
| `wan-i2v-14B` | `examples/training/finetune/wan_i2v_14B_480p/crush_smol/` |
| `ltx2` | `examples/training/finetune/ltx2/` |
| `matrixgame` | `examples/training/finetune/MatrixGame2.0/` |
| `distill-dmd` | `scripts/distill/v1_distill_dmd_wan.sh` |
Read the script to extract default values for:
- `--learning_rate`, `--train_batch_size`, `--sp_size`, `--tp_size`
- `--num_latent_t`, `--num_height`, `--num_width`, `--num_frames`
- `--gradient_accumulation_steps`, `--max_train_steps`
- `--mixed_precision`, `--weight_decay`, `--max_grad_norm`
- `--validation_steps`, `--validation_sampling_steps`
### 3. Set environment variables
```bash
export WANDB_API_KEY="${WANDB_API_KEY}"
export WANDB_BASE_URL="https://api.wandb.ai"
export FASTVIDEO_ATTENTION_BACKEND=FLASH_ATTN
export TOKENIZERS_PARALLELISM=false
export TRITON_CACHE_DIR=/tmp/triton_cache
```
### 4. Construct the torchrun command
```bash
torchrun --nnodes 1 --nproc_per_node <num_gpus> \
<entrypoint> \
--pretrained_model_name_or_path <model_hf_id> \
--data_path "<data_path>" \
--output_dir "<output_dir>" \
--wandb_run_name "<run_name>" \
--tracker_project_name "<project_name>" \
--log_validation \
<...all hyperparameters...>
```
### 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>
- **Config**: model=<model>, lr=<lr>, sp_size=<sp>, gpus=<n>, script=<entrypoint>
- **W&B run**: <pending — will be updated by monitor skill>
- **Status**: running
```
## Outputs
- A ready-to-execute shell command.
- 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:
pipeline: finetune
model: wan-t2v-1.3B
data_path: data/crush_smol_preprocessed/
num_gpus: 4
overrides:
learning_rate: 5e-5
max_train_steps: 1000
```
## References
- `examples/training/finetune/wan_t2v_1.3B/crush_smol/finetune_t2v.sh`
- `scripts/distill/v1_distill_dmd_wan.sh`
- `docs/training/finetune.md` (training arguments table)
- `fastvideo/training/trackers.py` (tracker initialization)
## Changelog
| Date | Change |
|------|--------|
| 2026-03-02 | Initial version |
+87
View File
@@ -0,0 +1,87 @@
---
name: log-experiment
description: Append or update an experiment entry in the experiment journal
---
# Log Experiment
## Purpose
Create or update an entry in `.agents/memory/experiment-journal/README.md` to maintain
a living record of all experiments and their outcomes.
## Prerequisites
- `.agents/memory/experiment-journal/README.md` exists.
## Inputs
| Parameter | Required | Description |
|-----------|----------|-------------|
| `name` | Yes | Experiment name / identifier |
| `hypothesis` | No | What you expected to learn |
| `config` | Yes | Key config: model, lr, sp_size, gpus, script |
| `wandb_run` | No | W&B run ID or URL |
| `duration` | No | Total wall time |
| `metrics` | No | Key metrics dict (loss, step_time, grad_norm) |
| `checkpoint` | No | Path to checkpoint |
| `insight` | No | What was learned |
| `status` | Yes | `running`, `completed`, `failed`, `abandoned` |
| `lessons` | No | Paths to related lesson files |
## Steps
### 1. Check for existing entry
Search `.agents/memory/experiment-journal/README.md` for an entry with the same name.
If found, update it instead of creating a duplicate.
### 2. Format the entry
```markdown
## [YYYY-MM-DD] Experiment: <name>
- **Hypothesis**: <hypothesis or "N/A">
- **Config**: model=<model>, lr=<lr>, sp_size=<sp>, gpus=<n>, script=<script>
- **W&B run**: <wandb_run or "pending">
- **Duration**: <duration or "in progress">
- **Key metrics**: loss=<loss>, step_time=<step_time>, grad_norm=<grad_norm>
- **Checkpoint**: <checkpoint or "N/A">
- **Insight**: <insight or "pending">
- **Status**: <status>
- **Related lessons**: <lessons or "none">
```
### 3. Insert at the top of the journal
New entries go at the top of the file (after the header), so the most recent
experiments are always visible first.
### 4. Warn on duplicates
If a similar experiment name exists with `status: completed`, warn that this
may be a repeat. If it's `status: running`, assume this is an update.
## Outputs
- Updated `.agents/memory/experiment-journal/README.md`.
## Example Usage
```
Log a completed experiment:
name: wan-t2v-finetune-lr5e5-sp4
config: model=wan-t2v-1.3B, lr=5e-5, sp_size=4, gpus=4
wandb_run: fastvideo/training/run_abc123
duration: 2h 15m
metrics: {loss: 0.065, step_time: 2.3, grad_norm: 0.35}
checkpoint: outputs/wan_finetune/checkpoint-1000
insight: LR 5e-5 converges 30% faster than 1e-5 with no quality loss
status: completed
```
## References
- `.agents/memory/experiment-journal/README.md` — journal file
- `.agents/workflows/experiment-lifecycle.md` — when to log
## Changelog
| Date | Change |
|------|--------|
| 2026-03-02 | Initial version |
+134
View File
@@ -0,0 +1,134 @@
---
name: monitor-experiment
description: Poll a running W&B training run for progress and emit structured alerts
---
# Monitor Experiment
## Purpose
Continuously (or on-demand) check a running experiment's W&B metrics and emit
alerts for anomalies. Supports the "30-minute quality check" paradigm: after
the first 30 minutes of a long training run, produce a checkpoint quality
report before committing more resources.
## Prerequisites
- `WANDB_API_KEY` is set in the environment.
- The experiment is actively logging to W&B (not in `WANDB_MODE=offline`).
- For offline mode: read from local `wandb-summary.json` instead.
## Inputs
| Parameter | Required | Description |
|-----------|----------|-------------|
| `run_id` | Yes* | W&B run ID (e.g., `entity/project/run_id`) |
| `output_dir` | Yes* | Local output directory (for offline mode fallback) |
| `poll_interval` | No | Seconds between polls (default: 60) |
| `alert_on` | No | List of alert conditions to enable (default: all) |
\* One of `run_id` or `output_dir` is required.
## Steps
### 1. Connect to the run
**Online mode** (preferred):
```python
import wandb
api = wandb.Api()
run = api.run("<run_id>")
```
**Offline fallback**:
```python
import json
summary_path = f"{output_dir}/tracker/wandb/latest-run/files/wandb-summary.json"
with open(summary_path) as f:
summary = json.load(f)
```
### 2. Track key metrics
| Metric | W&B Key | Description |
|--------|---------|-------------|
| Training loss | `train_loss` | Primary training loss |
| Gradient norm | `grad_norm` | Gradient magnitude |
| Step time | `step_time` | Wall-clock seconds per step |
| Learning rate | `learning_rate` | Current LR |
| Avg step time | `avg_step_time` | Running average step time |
| Validation videos | `validation_videos_*` | Generated validation samples |
### 3. Evaluate alert conditions
| Alert | Condition | Severity |
|-------|-----------|----------|
| **Loss spike** | `current_loss > 3 × rolling_avg_loss` | 🔴 Critical |
| **NaN/Inf gradient** | `grad_norm` is NaN or Inf | 🔴 Critical |
| **Step time regression** | `step_time > 2 × baseline_step_time` | 🟡 Warning |
| **No progress** | No new W&B logs for > 10 minutes | 🟡 Warning |
| **Loss plateau** | Loss change < 1% over last 100 steps | 🟢 Info |
### 4. Emit structured status
Output format (agent-consumable):
```json
{
"run_id": "...",
"step": 500,
"metrics": {
"train_loss": 0.078,
"grad_norm": 0.41,
"step_time": 2.5,
"learning_rate": 1e-6
},
"alerts": [
{"type": "loss_spike", "severity": "critical", "message": "Loss jumped to 0.45 (avg: 0.08)"}
],
"status": "running"
}
```
### 5. 30-Minute Quality Check
After the first 30 minutes of wall-clock time:
1. Summarize the loss curve shape (decreasing? at what rate?).
2. Check if validation videos have been generated.
3. Report step count, loss at start vs. current, and estimated time to completion.
4. Produce a go/no-go recommendation.
```markdown
## 30-Minute Check: <run_name>
- **Steps completed**: 150
- **Loss**: 0.12 → 0.08 (↓ 33%)
- **Grad norm**: stable at ~0.4
- **Step time**: 2.5s/step (consistent)
- **Validation videos**: 5 generated at step 100
- **Recommendation**: ✅ Continue — loss is decreasing normally
```
## Outputs
- Structured JSON status updates.
- Alert messages for anomalous conditions.
- 30-minute checkpoint quality report.
## Example Usage
```
Monitor W&B run "fastvideo/Wan_distillation/abc123":
run_id: fastvideo/Wan_distillation/abc123
poll_interval: 120
alert_on: [loss_spike, nan_gradient, step_time_regression]
```
## References
- `fastvideo/training/trackers.py` — `WandbTracker` implementation
- `fastvideo/tests/training/Vanilla/test_training_loss.py` — how summaries are compared
- `fastvideo/tests/training/Vanilla/a40_reference_wandb_summary.json` — reference summary format
## Changelog
| Date | Change |
|------|--------|
| 2026-03-02 | Initial version |
@@ -0,0 +1,82 @@
---
name: search-related-work
description: Query the related work index for relevant papers, repos, or comparisons
---
# Search Related Work
## Purpose
Search through `.agents/memory/related-work/` to find indexed papers, repos,
or blog posts relevant to a query. Use this when you need to understand how
other work compares to FastVideo's approach, or when looking for techniques
to adopt.
## Prerequisites
- The related work index has entries (`.agents/memory/related-work/*.md`).
## Inputs
| Parameter | Required | Description |
|-----------|----------|-------------|
| `query` | Yes | Natural language query |
| `tags` | No | Filter by tags (e.g., `[distillation, evaluation]`) |
| `type` | No | Filter by type (`paper`, `repo`, `blog`) |
## Steps
### 1. Search the index
Use grep-based search through `.agents/memory/related-work/`:
```bash
# Search by content
grep -rl "<query>" .agents/memory/related-work/
# Search by tags (in frontmatter)
grep -l "tags:.*<tag>" .agents/memory/related-work/*.md
```
### 2. Rank results
For each matching file:
1. Read the file.
2. Score relevance to the query based on:
- Title match
- Tag match
- Content match (summary, differences, insights)
3. Return top results.
### 3. Format output
```markdown
## Related Work Search: "<query>"
### 1. <Title> (relevance: high)
- **Source**: <URL>
- **Tags**: <tags>
- **Key insight**: <most relevant excerpt>
- **File**: `.agents/memory/related-work/<slug>.md`
### 2. <Title> (relevance: medium)
...
```
## Outputs
- Ranked list of relevant related work entries with excerpts.
## Example Usage
```
Search for work related to video quality evaluation metrics:
query: "video generation quality evaluation metrics"
tags: [evaluation]
```
## References
- `.agents/memory/related-work/README.md` — index schema
## Changelog
| Date | Change |
|------|--------|
| 2026-03-02 | Initial version |
+137
View File
@@ -0,0 +1,137 @@
---
name: summarize-run
description: Extract a W&B run summary into a structured experiment report
---
# Summarize Run
## Purpose
After a training run completes (or at any checkpoint), extract key metrics from
the W&B run summary and produce a structured markdown report. Supports both
online (W&B API) and offline (local `wandb-summary.json`) modes.
## Prerequisites
- Run has completed or reached a checkpoint with a saved summary.
- For online: `WANDB_API_KEY` set in environment.
- For offline: access to `<output_dir>/tracker/wandb/latest-run/files/wandb-summary.json`.
## Inputs
| Parameter | Required | Description |
|-----------|----------|-------------|
| `run_id` | Yes* | W&B run ID for online access |
| `output_dir` | Yes* | Local output dir for offline access |
| `reference_run` | No | Path to reference `wandb-summary.json` for comparison |
| `experiment_name` | No | Name for the journal entry (default: from W&B) |
\* One of `run_id` or `output_dir` is required.
## Steps
### 1. Load run summary
**Online**:
```python
import wandb
api = wandb.Api()
run = api.run("<run_id>")
summary = dict(run.summary)
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"
with open(summary_path) as f:
summary = json.load(f)
```
### 2. Extract key fields
| Field | Source | Description |
|-------|--------|-------------|
| `train_loss` | `summary["train_loss"]` | Final training loss |
| `avg_step_time` | `summary["avg_step_time"]` | Average seconds per step |
| `step_time` | `summary["step_time"]` | Last step time |
| `grad_norm` | `summary["grad_norm"]` | Final gradient norm |
| `learning_rate` | `summary["learning_rate"]` | Final LR |
| `_step` | `summary["_step"]` | Total steps completed |
| `_runtime` | `summary["_runtime"]` | Total wall-clock seconds |
| `validation_videos_*` | `summary[key]` | Validation video artifacts |
### 3. Compare against reference (optional)
Follow the pattern in `fastvideo/tests/training/Vanilla/test_training_loss.py`:
```python
# Fields to compare
compare_fields = ["train_loss", "grad_norm", "avg_step_time"]
tolerance = 0.05 # 5% relative tolerance
for field in compare_fields:
ref_val = reference_summary[field]
cur_val = summary[field]
diff_pct = abs(cur_val - ref_val) / abs(ref_val) * 100
status = "✅" if diff_pct < tolerance * 100 else "⚠️"
print(f"{status} {field}: {cur_val:.4f} (ref: {ref_val:.4f}, diff: {diff_pct:.1f}%)")
```
### 4. Generate report
```markdown
# Run Summary: <experiment_name>
| Metric | Value | Reference | Diff |
|--------|-------|-----------|------|
| Train Loss | 0.0788 | 0.0800 | -1.5% ✅ |
| Avg Step Time | 2.81s | 2.80s | +0.4% ✅ |
| Grad Norm | 0.408 | 0.410 | -0.5% ✅ |
| Total Steps | 500 | — | — |
| Wall Time | 23m 30s | — | — |
## Configuration
- Model: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
- Learning Rate: 1e-6
- Batch Size: 1
- GPUs: 8 × (SP=1, TP=1)
- Mixed Precision: bf16
## Validation Videos
<list of validation video paths if available>
## Notes
<any observations or anomalies>
```
### 5. Update experiment journal
Append or update the experiment's entry in `.agents/memory/experiment-journal/README.md`
with the final metrics and status.
## Outputs
- Structured markdown report.
- Updated experiment journal entry.
## Example Usage
```
Summarize the run in output directory "outputs/wan_finetune":
output_dir: outputs/wan_finetune
reference_run: fastvideo/tests/training/Vanilla/a40_reference_wandb_summary.json
experiment_name: wan-t2v-finetune-lr1e6
```
## References
- `fastvideo/tests/training/Vanilla/test_training_loss.py` — reference comparison pattern
- `fastvideo/tests/training/Vanilla/a40_reference_wandb_summary.json` — example summary
- `fastvideo/tests/training/lora/test_lora_training.py` — LoRA summary comparison
- `fastvideo/training/trackers.py` — tracker summary generation
## Changelog
| Date | Change |
|------|--------|
| 2026-03-02 | Initial version |
@@ -0,0 +1,54 @@
---
description: How to develop, validate, and register a new evaluation metric
---
# Evaluation Development SOP
Standard procedure for adding new video quality evaluation metrics to the
FastVideo agent toolkit.
## When to Use
- You need a metric that doesn't exist in `.agents/memory/evaluation-registry/README.md`.
- An existing metric needs significant changes to its methodology.
- You're exploring a new evaluation approach.
## Steps
### 1. Research
- Search `.agents/memory/related-work/` for existing evaluation approaches.
- Check the `evaluation_registry.md` for current metrics and their limitations.
- Review literature: FVD, CLIP-Score, human preference, etc.
### 2. Prototype
- Write a standalone script in `.agents/exploration/<metric-name>.md`.
- Keep it simple: one script, minimal dependencies.
- Test on a few known-good and known-bad video samples.
### 3. Validate
- **Known-good test**: Metric should score high on reference-quality videos.
- **Known-bad test**: Metric should score low on degraded/unrelated videos.
- **Sensitivity test**: Small quality differences should produce meaningful
score differences.
- Document thresholds and their justification.
### 4. Register
Update `.agents/memory/evaluation-registry/README.md`:
- Add the metric with status `Active`.
- Document location, thresholds, and trust level.
### 5. Integrate
Update `.agents/skills/evaluate-video-quality.md`:
- Add the new metric as a section.
- Include code examples and interpretation guide.
### 6. Document
- Move the exploration log content into the skill.
- Clean up the exploration file or mark it as `promoted`.
- If anything went wrong during development, create a lesson.
@@ -0,0 +1,47 @@
---
description: When and how to log experiments in the experiment journal
---
# Experiment Journaling SOP
Ensures every experiment is properly recorded with context and outcomes.
## When to Log
**Always.** Every experiment — even quick tests — should be journaled.
## Steps
### 1. Before Launch — Create Draft Entry
Use the `log-experiment` skill with `status: running`:
- Include hypothesis and config.
- Leave metrics, duration, and insight blank.
### 2. After 30-Minute Check — Update with Initial Metrics
Update the entry with:
- Current loss and its trajectory direction.
- Step time.
- Number of validation videos generated.
- Preliminary go/no-go assessment.
### 3. On Completion — Fill Final Entry
Update the entry with `status: completed`:
- Final loss, grad norm, avg step time.
- Total duration and steps.
- Checkpoint path.
- Key insight.
### 4. On Failure — Document Failure Mode
Update the entry with `status: failed`:
- What went wrong (OOM, NaN, crash, etc.).
- At what step the failure occurred.
- Create a lesson in `.agents/lessons/` for non-trivial failures.
### 5. Cross-Reference
- Link related lessons: `**Related lessons**: .agents/lessons/<filename>.md`
- Link related experiments: if this is a follow-up, reference the prior entry.
+87
View File
@@ -0,0 +1,87 @@
---
description: End-to-end experiment lifecycle from hypothesis to lessons learned
---
# Experiment Lifecycle SOP
Standard operating procedure for running ML training experiments on
FastVideo-WorldModel. Every experiment should follow this flow.
## Overview
```
Plan → Launch → Monitor → Summarize → Journal → Reflect
```
## Steps
### 1. Plan the Experiment
Before launching:
- [ ] Define a clear **hypothesis** (what you expect to learn).
- [ ] Select the **model** and **pipeline** type (finetune, distill, lora, etc.).
- [ ] Prepare the **dataset** (preprocessed into parquet format).
- [ ] Review existing experiments in `.agents/memory/experiment-journal/README.md` for related work.
- [ ] Check `.agents/lessons/` for known pitfalls with this configuration.
- [ ] Document the plan in the experiment journal as a draft entry.
### 2. Launch the Experiment
Use the `launch-experiment` skill:
- Provide: pipeline, model, data_path, num_gpus, and any hyperparameter overrides.
- The skill generates the `torchrun` command and creates a journal entry.
- Verify the command looks correct before executing.
Reference: `.agents/skills/launch-experiment.md`
### 3. Monitor the Experiment
Use the `monitor-experiment` skill:
- Provide the W&B run ID (or output_dir for offline).
- Monitor alerts: loss spikes, NaN gradients, step time regressions.
- At the **30-minute mark**: perform the quality check.
- Is loss decreasing?
- Are validation videos reasonable?
- Is step time consistent?
- **Decision point**: Continue or abort based on the 30-min check.
Reference: `.agents/skills/monitor-experiment.md`
### 4. Summarize the Run
After completion (or at any checkpoint), use the `summarize-run` skill:
- Extract final metrics from W&B summary.
- Compare against reference runs if available.
- Generate a structured report.
Reference: `.agents/skills/summarize-run.md`
### 5. Update the Experiment Journal
Use the `log-experiment` skill to update the journal entry:
- Fill in final metrics, duration, checkpoint paths.
- Record the key insight learned.
- Set status to `completed`, `failed`, or `abandoned`.
Reference: `.agents/skills/log-experiment.md`
### 6. Reflect and Capture Lessons
After every experiment:
- **What went right?** → Note in the journal insight field.
- **What went wrong?** → Create a lesson in `.agents/lessons/`:
- Use the template in `.agents/lessons/README.md`.
- Cross-reference the experiment journal entry.
- **What was surprising?** → Consider creating an exploration log if this
warrants further investigation.
Reference: `.agents/workflows/lesson-capture.md`
## Validation Criteria
This SOP is validated when an agent can:
1. Follow steps 1–6 end-to-end for a minimal training run
(e.g., `examples/training/finetune/wan_t2v_1.3B/crush_smol/finetune_t2v.sh`
with `--max_train_steps 5`).
2. Produce a complete experiment journal entry.
3. Generate a run summary report.
+71
View File
@@ -0,0 +1,71 @@
---
description: Post-experiment reflection to capture lessons learned
---
# Lesson Capture SOP
Systematic procedure for turning experiment outcomes into persistent knowledge.
## When to Use
After **every** completed or failed experiment. Even successful experiments
can yield lessons (e.g., "LR 5e-5 works better than 1e-5 for LoRA").
## Steps
### 1. Review the Experiment
Read the experiment journal entry. Ask:
- Did anything go wrong?
- Was anything surprising?
- Did anything take longer than expected?
- Was a workaround needed?
### 2. Decide: Lesson or Not?
| Situation | Action |
|-----------|--------|
| Something broke | Create a lesson (category: `infrastructure` or `data`) |
| Hyperparameter choice mattered | Create a lesson (category: `hyperparameter`) |
| Porting issue found | Create a lesson (category: `porting`) |
| Evaluation metric was misleading | Create a lesson (category: `evaluation`) |
| Everything went smoothly | No lesson needed, but note in the journal insight |
### 3. Create the Lesson File
In `.agents/lessons/`, create `<YYYY-MM-DD>_<short-slug>.md`:
```markdown
---
date: <ISO-8601>
experiment: <journal entry reference>
category: hyperparameter | data | infrastructure | evaluation | porting
severity: critical | important | minor
---
# <Short Descriptive Title>
## What Happened
<description>
## Root Cause
<analysis>
## Fix / Workaround
<resolution>
## Prevention
<how to avoid in future>
```
### 4. Cross-Reference
- Update the experiment journal entry with a link to the lesson file.
- If a similar lesson already exists, add a reference or update it.
### 5. Periodic Pattern Review
Every ~10 lessons, scan for patterns:
- Multiple lessons in the same category → consider a new skill or SOP.
- Repeated mistakes → strengthen the relevant SOP with a checklist item.
- Infrastructure issues → propose a codebase fix.
+67
View File
@@ -0,0 +1,67 @@
---
description: Synchronize the STATUS.md dashboard by scanning .agents/ directories
---
# Sync Dashboard
Updates `.agents/STATUS.md` by scanning the skills, workflows, memory, lessons,
and exploration directories to reflect what actually exists on disk.
## When to Use
- After adding, removing, or renaming any file in `.agents/`.
- Periodically (e.g., at end of each conversation session).
- When the dashboard feels out of date.
## Steps
### 1. Scan directories
List all files in each directory:
```bash
echo "=== Skills ==="
ls -1 .agents/skills/*.md 2>/dev/null | grep -v SKILL_TEMPLATE
echo "=== Workflows ==="
ls -1 .agents/workflows/*.md 2>/dev/null
echo "=== Memory ==="
ls -1 .agents/memory/*.md 2>/dev/null
ls -1 .agents/memory/related-work/*.md 2>/dev/null | grep -v README
echo "=== Lessons ==="
ls -1 .agents/lessons/*.md 2>/dev/null | grep -v README
echo "=== Exploration ==="
ls -1 .agents/exploration/*.md 2>/dev/null | grep -v README
```
### 2. Compare with STATUS.md
For each file found:
- If it's in STATUS.md → leave it (preserve status/trust/tested fields).
- If it's NOT in STATUS.md → add it with status `🔴 Stub`, trust `None`, tested `❌`.
For each entry in STATUS.md:
- If the file no longer exists → mark it as `❌ Removed` or delete the row.
### 3. Update counts
Recalculate the summary table at the top:
- Count files per category.
- Count by status (Ready, Draft, Stub).
### 4. Update timestamp
Set `_Last synced: <current date>_` at the top of STATUS.md.
### 5. Review
Read through the updated STATUS.md for accuracy. Flag anything that looks wrong.
## Notes
- Do NOT change trust levels during sync — those are set manually after testing.
- Do NOT change status during sync — status changes require actual validation.
- This workflow only handles structural sync (file existence), not content review.
+14
View File
@@ -40,3 +40,17 @@
- test evidence (`pytest`/SSIM outputs or rationale if skipped),
- linked issue/PR context,
- screenshots or sample outputs for UI/demo/docs changes.
## Agent Infrastructure
This repository is agent-friendly. Before doing any work, read:
1. `.agents/onboarding/README.md` — full onboarding guide with step-by-step instructions.
2. `.agents/memory/codebase-map/README.md` — structural index of the entire repository.
3. `.agents/skills/` — available agent skills (check if one exists before writing code).
4. `.agents/workflows/` — SOPs for common procedures (experiment lifecycle, evaluation, etc.).
5. `.agents/lessons/` — known pitfalls and their documented fixes.
If you are exploring a new procedure that has no existing SOP, document your
progress in `.agents/exploration/` and flag it for review at the end of your
session.
+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/configs/example.yaml) — fully-commented
YAML config with all fields and defaults.
@@ -0,0 +1,70 @@
# 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
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
+194
View File
@@ -0,0 +1,194 @@
# ==============================================================================
# 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
# ------------------------------------------------------------------------------
# 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)
# --- 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,74 @@
# 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
training:
distributed:
num_gpus: 8
sp_size: 1
tp_size: 1
hsdp_replicate_dim: 8
hsdp_shard_dim: 1
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: 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
callbacks:
grad_clip:
_target_: fastvideo.train.callbacks.grad_clip.GradNormClipCallback
max_grad_norm: 1.0
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
trainable: true
transformer_override_safetensor: /mnt/weka/home/hao.zhang/wl/Self-Forcing/diffusers_ode_init/model.safetensors
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]
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: self-forcing-wan
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]
num_frames: 69
guidance_scale: 6.0
pipeline:
flow_shift: 5
@@ -0,0 +1,105 @@
# Self-Forcing distillation: Wan 2.1 T2V 1.3B Causal with VSA (0.9 sparsity)
#
# - Teacher: frozen pretrained Wan 2.1 T2V 14B
# - Student: trainable causal Wan model with VSA sparse attention
# - Critic: trainable, initialized from pretrained weights
# - Training: streaming rollout with SDE sampling + VSA 0.9 sparsity
models:
student:
_target_: fastvideo.train.models.wan.WanCausalModel
init_from: wlsaidhi/SFWan2.1-T2V-1.3B-Diffusers
trainable: true
transformer_override_safetensor: /mnt/weka/home/hao.zhang/wl/Self-Forcing/diffusers_ode_init/model.safetensors
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]
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
attn_kind: vsa
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_vsa_0.9
training_state_checkpointing_steps: 1000
checkpoints_total_limit: 3
tracker:
project_name: self-forcing-wan
run_name: wan2.1_causal_self_forcing_vsa_0.9
model:
enable_gradient_checkpointing_type: full
vsa:
sparsity: 0.9
decay_rate: 0.03
decay_interval_steps: 1
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]
num_frames: 69
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 "=============================="
python -m torch.distributed.run \
--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
@@ -2,35 +2,15 @@
"data": [
{
"caption": "In the video, a woman is elegantly showcasing her earrings, bringing attention to their intricate design with a gentle touch of her fingers. She is bathed in ambient purple and pink lighting, which casts a soft glow on her delicate features and enhances the vivid tones of her lipstick and eye makeup. Her hair is styled to frame her face smoothly, emphasizing the contours of her jawline and cheekbones. The background features a blurred neon light, adding an artistic and modern touch to the overall aesthetic.",
"video_path": "Fashion/mixkit-face-of-an-elegant-and-captivating-woman-41914_clip_1.mp4",
"num_inference_steps": 3,
"height": 448,
"width": 832,
"num_frames": 61
},
{
"caption": "In the video, a lone rider guides a majestic horse across an expansive, open field as the sun sets in the background. The rider, dressed in a classic blue shirt and wide-brimmed hat, sits confidently in the saddle, silhouetted against the warm glow of the evening sky. The horse moves gracefully, its mane and tail flowing with each step, creating a sense of harmony between horse and rider. Surrounding the pair, towering trees form a natural border, their leaves gently rustling in the breeze. The shadows lengthen on the ground, accentuating the serene and timeless feel of the scene. The distant hills and wooden fences frame the horizon, adding depth to the tranquil landscape. A few horses graze peacefully in the background, blending into the pastoral setting. The overall ambiance evokes a sense of calmness and quietude, capturing a perfect moment in the golden light of dusk.",
"video_path": "Man/mixkit-a-rancher-riding-a-horse-at-sunset-1143_clip_1.mp4",
"num_inference_steps": 3,
"height": 448,
"width": 832,
"num_frames": 61
},
{
"caption": "In a dimly lit, eerie setting, a mysterious pink bottle labeled \"Authentic 100% organic POISON\" sits prominently in the foreground, casting a menacing aura. The bottle is accentuated by green fog, which swirls lightly around it, enhancing its sinister allure. Behind it, a shadowy golden bottle adorned with a spider emblem subtly emerges, adding an extra layer of mystery to the scene. Dim candles provide faint, flickering light, which complements the dark atmosphere, making the setting ideal for an illusion of hidden dangers.",
"video_path": "smoke/mixkit-poison-in-halloween-ritual-33879_clip_1.mp4",
"num_inference_steps": 3,
"height": 448,
"width": 832,
"num_frames": 61
},
{
"caption": "The video opens with a tranquil scene in the heart of a dense forest, emphasizing two large, textured tree trunks in the foreground framing the view. Sunlight filters through the canopy above, casting intricate patterns of light and shadow on the trees and the ground. Between the tree trunks, a clear view of a calm, muddy river unfolds, its surface shimmering under the gentle sunlight. The riverbank is decorated with a variety of small bushes and vibrant foliage, subtly transitioning into the deep greens of tall, leafy plants. In the background, the dense forest looms, filled with dark, towering trees, their branches intertwining to form an intricate canopy. The scene is bathed in the soft glow of the sun, creating a serene and picturesque setting. Occasional sunbeams pierce through the foliage, adding a magical aura to the landscape. The vibrant reds and oranges of the smaller plants add contrast, bringing warmth to the earthy tones of the scenery. Overall, this harmonious blend of natural elements creates a peaceful and idyllic forest setting.",
"video_path": "forest/mixkit-view-of-a-river-between-two-old-trees-560_clip_1.mp4",
"num_inference_steps": 3,
"height": 448,
"width": 832,
"num_frames": 61
}
]
}
@@ -1,20 +1,15 @@
import math
import torch
from .block_sparse_attn import block_sparse_attn
from .triton_kernels.block_sparse_attn_triton import triton_block_sparse_attn_forward
from .triton_kernels.st_attn_triton import sliding_tile_attention_triton
from .triton_kernels.index import map_to_index
# Try to load the C++ extension
try:
from fastvideo_kernel._C import fastvideo_kernel_ops
sta_fwd = getattr(fastvideo_kernel_ops, "sta_fwd", None)
block_sparse_fwd = getattr(fastvideo_kernel_ops, "block_sparse_fwd", None)
block_sparse_bwd = getattr(fastvideo_kernel_ops, "block_sparse_bwd", None)
except ImportError:
sta_fwd = None
block_sparse_fwd = None
block_sparse_bwd = None
def sliding_tile_attention(
q: torch.Tensor,
@@ -135,14 +130,8 @@ def video_sparse_attn(
mask = torch.zeros_like(scores,
dtype=torch.bool).scatter_(-1, topk_idx, True)
idx, num = map_to_index(mask)
if block_sparse_fwd is not None:
# Use autograd-enabled wrapper so backward works (and still uses SM90 kernel when available)
out_s = block_sparse_attn(q, k, v, mask, variable_block_sizes)[0]
else:
# Triton-only forward (kept for environments without the wrapper deps)
out_s, _ = triton_block_sparse_attn_forward(q, k, v, idx, num, variable_block_sizes)
# out_s = block_sparse_attn(q, k, v, mask, variable_block_sizes)[0]
out_s = block_sparse_attn(q, k, v, mask, variable_block_sizes)[0]
if compress_attn_weight is not None:
return out_c * compress_attn_weight + out_s
+28 -15
View File
@@ -4,26 +4,36 @@ import torch
import torch.nn.functional as F
from flash_attn import flash_attn_func as flash_attn_2_func
from dataclasses import dataclass
try:
from flash_attn_interface import flash_attn_func as flash_attn_3_func
from fastvideo.attention.utils.flash_attn_cute import flash_attn_func
# flash_attn 3 no longer have a different API, see following commit:
# https://github.com/Dao-AILab/flash-attention/commit/ed209409acedbb2379f870bbd03abce31a7a51b7
flash_attn_func = flash_attn_3_func
fa_version = "4"
except ImportError:
flash_attn_func = flash_attn_2_func
try:
from flash_attn_interface import flash_attn_func as flash_attn_3_func
from fastvideo.attention.backends.abstract import (AttentionBackend,
AttentionImpl,
AttentionMetadata,
AttentionMetadataBuilder)
# flash_attn 3 no longer have a different API, see following commit:
# https://github.com/Dao-AILab/flash-attention/commit/ed209409acedbb2379f870bbd03abce31a7a51b7
flash_attn_func = flash_attn_3_func
fa_version = "3"
except ImportError:
flash_attn_func = flash_attn_2_func
fa_version = "2"
from fastvideo.attention.backends.abstract import (
AttentionBackend,
AttentionImpl,
AttentionMetadata,
AttentionMetadataBuilder,
)
from fastvideo.logger import init_logger
logger = init_logger(__name__)
logger.info("Using FlashAttention-%s backend", fa_version)
class FlashAttentionBackend(AttentionBackend):
accept_output_buffer: bool = True
@staticmethod
@@ -117,11 +127,13 @@ class FlashAttentionImpl(AttentionImpl):
f"expected {key_len}, got {key_padding_mask.shape[-1]}")
return key_padding_mask
if attn_metadata is not None and hasattr(
attn_metadata,
"attn_mask") and attn_metadata.attn_mask is not None:
if (attn_metadata is not None and hasattr(attn_metadata, "attn_mask")
and attn_metadata.attn_mask is not None):
from fastvideo.attention.utils.flash_attn_no_pad import (
flash_attn_no_pad, flash_attn_varlen_qk_no_pad)
flash_attn_no_pad,
flash_attn_varlen_qk_no_pad,
)
attn_mask = attn_metadata.attn_mask
# flash_attn_no_pad packs q/k/v as one tensor and assumes equal q/k
@@ -160,5 +172,6 @@ class FlashAttentionImpl(AttentionImpl):
key,
value,
softmax_scale=self.softmax_scale,
causal=self.causal)
causal=self.causal,
)
return output
@@ -0,0 +1,264 @@
from __future__ import annotations
import torch
if torch.cuda.is_available():
from flash_attn.cute.interface import _flash_attn_bwd, _flash_attn_fwd
else:
# This error will be caught in flash_attn.py or flash_attn_no_pad.py
raise ImportError(
"flash_attn.cute is only available on CUDA devices; this error must be handled internally"
)
def _check_dropout(dropout_p: float) -> None:
if dropout_p != 0.0:
raise NotImplementedError(
f"flash_attn.cute does not support dropout (got dropout_p={dropout_p})"
)
@torch.library.custom_op(
"fastvideo::_flash_attn_cute_forward",
mutates_args=(),
device_types="cuda",
)
def _flash_attn_cute_forward(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
softmax_scale: float | None,
causal: bool,
deterministic: bool,
) -> tuple[torch.Tensor, torch.Tensor]:
out, lse = _flash_attn_fwd(
q,
k,
v,
softmax_scale=softmax_scale,
causal=causal,
window_size_left=None,
window_size_right=None,
softcap=0.0,
num_splits=1,
pack_gqa=None,
)
return out, lse
@torch.library.register_fake("fastvideo::_flash_attn_cute_forward")
def _flash_attn_cute_forward_fake(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
softmax_scale: float | None,
causal: bool,
deterministic: bool,
) -> tuple[torch.Tensor, torch.Tensor]:
del k, softmax_scale, causal, deterministic
batch, seqlen_q, nheads = q.shape[:3]
out = q.new_empty(batch, seqlen_q, nheads, v.shape[-1])
lse = q.new_empty(batch, nheads, seqlen_q, dtype=torch.float32)
return out, lse
def _flash_attn_cute_setup_context(ctx: torch.autograd.function.FunctionCtx,
inputs, output) -> None:
q, k, v, softmax_scale, causal, deterministic = inputs
out, lse = output
ctx.save_for_backward(q, k, v, out, lse)
ctx.softmax_scale = softmax_scale
ctx.causal = causal
ctx.deterministic = deterministic
def _flash_attn_cute_backward(
ctx: torch.autograd.function.FunctionCtx,
grad_out: torch.Tensor,
grad_lse: torch.Tensor | None,
):
del grad_lse
q, k, v, out, lse = ctx.saved_tensors
dq, dk, dv = _flash_attn_bwd(
q,
k,
v,
out,
grad_out,
lse,
softmax_scale=ctx.softmax_scale,
causal=ctx.causal,
softcap=0.0,
window_size_left=None,
window_size_right=None,
deterministic=ctx.deterministic,
)
return dq, dk, dv, None, None, None
torch.library.register_autograd(
"fastvideo::_flash_attn_cute_forward",
_flash_attn_cute_backward,
setup_context=_flash_attn_cute_setup_context,
)
@torch.library.custom_op(
"fastvideo::_flash_attn_cute_varlen_forward",
mutates_args=(),
device_types="cuda",
)
def _flash_attn_cute_varlen_forward(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
cu_seqlens_q: torch.Tensor,
cu_seqlens_k: torch.Tensor,
max_seqlen_q: int,
max_seqlen_k: int,
softmax_scale: float | None,
causal: bool,
deterministic: bool,
) -> tuple[torch.Tensor, torch.Tensor]:
out, lse = _flash_attn_fwd(
q,
k,
v,
cu_seqlens_q=cu_seqlens_q,
cu_seqlens_k=cu_seqlens_k,
max_seqlen_q=max_seqlen_q,
max_seqlen_k=max_seqlen_k,
softmax_scale=softmax_scale,
causal=causal,
window_size_left=None,
window_size_right=None,
softcap=0.0,
num_splits=1,
pack_gqa=None,
)
return out, lse
@torch.library.register_fake("fastvideo::_flash_attn_cute_varlen_forward")
def _flash_attn_cute_varlen_forward_fake(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
cu_seqlens_q: torch.Tensor,
cu_seqlens_k: torch.Tensor,
max_seqlen_q: int,
max_seqlen_k: int,
softmax_scale: float | None,
causal: bool,
deterministic: bool,
) -> tuple[torch.Tensor, torch.Tensor]:
del k, cu_seqlens_q, cu_seqlens_k, max_seqlen_q, max_seqlen_k, softmax_scale
del causal
del deterministic
total_q, nheads = q.shape[:2]
out = q.new_empty(total_q, nheads, v.shape[-1])
lse = q.new_empty(nheads, total_q, dtype=torch.float32)
return out, lse
def _flash_attn_cute_varlen_setup_context(
ctx: torch.autograd.function.FunctionCtx, inputs, output) -> None:
(
q,
k,
v,
cu_seqlens_q,
cu_seqlens_k,
max_seqlen_q,
max_seqlen_k,
softmax_scale,
causal,
deterministic,
) = inputs
out, lse = output
ctx.save_for_backward(q, k, v, out, lse, cu_seqlens_q, cu_seqlens_k)
ctx.max_seqlen_q = max_seqlen_q
ctx.max_seqlen_k = max_seqlen_k
ctx.softmax_scale = softmax_scale
ctx.causal = causal
ctx.deterministic = deterministic
def _flash_attn_cute_varlen_backward(
ctx: torch.autograd.function.FunctionCtx,
grad_out: torch.Tensor,
grad_lse: torch.Tensor | None,
):
del grad_lse
q, k, v, out, lse, cu_seqlens_q, cu_seqlens_k = ctx.saved_tensors
dq, dk, dv = _flash_attn_bwd(
q,
k,
v,
out,
grad_out,
lse,
softmax_scale=ctx.softmax_scale,
causal=ctx.causal,
softcap=0.0,
window_size_left=None,
window_size_right=None,
cu_seqlens_q=cu_seqlens_q,
cu_seqlens_k=cu_seqlens_k,
max_seqlen_q=ctx.max_seqlen_q,
max_seqlen_k=ctx.max_seqlen_k,
deterministic=ctx.deterministic,
)
return dq, dk, dv, None, None, None, None, None, None, None
torch.library.register_autograd(
"fastvideo::_flash_attn_cute_varlen_forward",
_flash_attn_cute_varlen_backward,
setup_context=_flash_attn_cute_varlen_setup_context,
)
def flash_attn_func(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
dropout_p: float = 0.0,
softmax_scale: float | None = None,
causal: bool = False,
deterministic: bool = False,
) -> torch.Tensor:
"""Only returns the output, not the lse."""
_check_dropout(dropout_p)
out, _ = torch.ops.fastvideo._flash_attn_cute_forward(
q, k, v, softmax_scale, causal, deterministic)
return out
def flash_attn_varlen_func(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
cu_seqlens_q: torch.Tensor,
cu_seqlens_k: torch.Tensor,
max_seqlen_q: int,
max_seqlen_k: int,
dropout_p: float = 0.0,
softmax_scale: float | None = None,
causal: bool = False,
deterministic: bool = False,
) -> torch.Tensor:
"""Only returns the output, not the lse."""
_check_dropout(dropout_p)
out, _ = torch.ops.fastvideo._flash_attn_cute_varlen_forward(
q,
k,
v,
cu_seqlens_q,
cu_seqlens_k,
max_seqlen_q,
max_seqlen_k,
softmax_scale,
causal,
deterministic,
)
return out
+65 -45
View File
@@ -15,16 +15,29 @@
# See the License for the specific language governing permissions and limitations under the License.
from einops import rearrange
from flash_attn.bert_padding import pad_input, unpad_input
from flash_attn import flash_attn_varlen_qkvpacked_func
try:
from fastvideo.attention.utils.flash_attn_cute import (
flash_attn_varlen_func as flash_attn_varlen_func_impl, )
except ImportError:
try:
from flash_attn_interface import (
flash_attn_varlen_func as flash_attn_varlen_func_impl, )
except ImportError:
from flash_attn import (
flash_attn_varlen_func as flash_attn_varlen_func_impl, )
def flash_attn_no_pad(qkv,
key_padding_mask,
causal=False,
dropout_p=0.0,
softmax_scale=None,
deterministic=False):
from flash_attn import flash_attn_varlen_qkvpacked_func
from flash_attn.bert_padding import pad_input, unpad_input
def flash_attn_no_pad(
qkv,
key_padding_mask,
causal=False,
dropout_p=0.0,
softmax_scale=None,
deterministic=False,
):
batch_size = qkv.shape[0]
seqlen = qkv.shape[1]
nheads = qkv.shape[-2]
@@ -46,22 +59,28 @@ def flash_attn_no_pad(qkv,
deterministic=deterministic,
)
output = rearrange(
pad_input(rearrange(output_unpad, "nnz h d -> nnz (h d)"), indices,
batch_size, seqlen),
pad_input(
rearrange(output_unpad, "nnz h d -> nnz (h d)"),
indices,
batch_size,
seqlen,
),
"b s (h d) -> b s h d",
h=nheads,
)
return output
def flash_attn_no_pad_v3(qkv,
key_padding_mask,
causal=False,
dropout_p=0.0,
softmax_scale=None,
deterministic=False):
from flash_attn.bert_padding import pad_input, unpad_input
from flash_attn_interface import flash_attn_varlen_func as flash_attn_varlen_func_v3
def flash_attn_no_pad_v3(
qkv,
key_padding_mask,
causal=False,
dropout_p=0.0,
softmax_scale=None,
deterministic=False,
):
from flash_attn_interface import (
flash_attn_varlen_func as flash_attn_varlen_func_v3, )
if flash_attn_varlen_func_v3 is None:
raise ImportError("FlashAttention V3 backend not available")
@@ -80,22 +99,29 @@ def flash_attn_no_pad_v3(qkv,
key_unpad = rearrange(key_unpad, "nnz (h d) -> nnz h d", h=nheads)
value_unpad = rearrange(value_unpad, "nnz (h d) -> nnz h d", h=nheads)
output_unpad = flash_attn_varlen_func_v3(query_unpad,
key_unpad,
value_unpad,
cu_seqlens_q,
cu_seqlens_k,
max_seqlen_q,
max_seqlen_q,
softmax_scale=softmax_scale,
causal=causal,
deterministic=deterministic)
output_unpad = flash_attn_varlen_func_v3(
query_unpad,
key_unpad,
value_unpad,
cu_seqlens_q,
cu_seqlens_k,
max_seqlen_q,
max_seqlen_q,
softmax_scale=softmax_scale,
causal=causal,
deterministic=deterministic,
)
output = rearrange(pad_input(
rearrange(output_unpad, "nnz h d -> nnz (h d)"), indices, batch_size,
seqlen),
"b s (h d) -> b s h d",
h=nheads)
output = rearrange(
pad_input(
rearrange(output_unpad, "nnz h d -> nnz (h d)"),
indices,
batch_size,
seqlen,
),
"b s (h d) -> b s h d",
h=nheads,
)
return output
@@ -110,16 +136,6 @@ def flash_attn_varlen_qk_no_pad(
softmax_scale=None,
deterministic=False,
):
from flash_attn.bert_padding import pad_input, unpad_input
try:
from flash_attn_interface import flash_attn_varlen_func as flash_attn_varlen_func_impl
except ImportError:
from flash_attn import flash_attn_varlen_func as flash_attn_varlen_func_impl
if flash_attn_varlen_func_impl is None:
raise ImportError("FlashAttention varlen backend not available")
batch_size, q_seqlen, nheads, _ = query.shape
query_unpad, q_indices, cu_seqlens_q, max_seqlen_q, _ = unpad_input(
@@ -148,8 +164,12 @@ def flash_attn_varlen_qk_no_pad(
)
output = rearrange(
pad_input(rearrange(output_unpad, "nnz h d -> nnz (h d)"), q_indices,
batch_size, q_seqlen),
pad_input(
rearrange(output_unpad, "nnz h d -> nnz (h d)"),
q_indices,
batch_size,
q_seqlen,
),
"b s (h d) -> b s h d",
h=nheads,
)
@@ -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):
@@ -387,7 +392,6 @@ def build_parquet_map_style_dataloader(
collate_fn=passthrough,
num_workers=num_data_workers,
pin_memory=True,
pin_memory_device=current_platform.device_name,
persistent_workers=num_data_workers > 0,
)
return dataset, loader
+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
@@ -0,0 +1,97 @@
# SPDX-License-Identifier: Apache-2.0
"""Wan causal pipeline with standard multi-step denoising.
Block-by-block causal inference with KV caching, using the full
scheduler timestep schedule (40-50 steps) rather than DMD few-step.
"""
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.logger import init_logger
from fastvideo.models.schedulers.scheduling_flow_unipc_multistep import (
FlowUniPCMultistepScheduler)
from fastvideo.pipelines import ComposedPipelineBase, LoRAPipeline
# isort: off
from fastvideo.pipelines.stages import (
CausalDenoisingStage,
ConditioningStage,
DecodingStage,
InputValidationStage,
LatentPreparationStage,
TextEncodingStage,
TimestepPreparationStage,
)
# isort: on
logger = init_logger(__name__)
class WanCausalPipeline(LoRAPipeline, ComposedPipelineBase):
"""Wan causal pipeline with standard multi-step denoising."""
_required_config_modules = [
"text_encoder",
"tokenizer",
"vae",
"transformer",
"scheduler",
]
def initialize_pipeline(
self,
fastvideo_args: FastVideoArgs,
):
self.modules["scheduler"] = (FlowUniPCMultistepScheduler(
shift=fastvideo_args.pipeline_config.flow_shift, ))
def create_pipeline_stages(
self,
fastvideo_args: FastVideoArgs,
) -> None:
self.add_stage(
stage_name="input_validation_stage",
stage=InputValidationStage(),
)
self.add_stage(
stage_name="prompt_encoding_stage",
stage=TextEncodingStage(
text_encoders=[self.get_module("text_encoder")],
tokenizers=[self.get_module("tokenizer")],
),
)
self.add_stage(
stage_name="conditioning_stage",
stage=ConditioningStage(),
)
self.add_stage(
stage_name="timestep_preparation_stage",
stage=TimestepPreparationStage(
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),
),
)
self.add_stage(
stage_name="denoising_stage",
stage=CausalDenoisingStage(
transformer=self.get_module("transformer"),
scheduler=self.get_module("scheduler"),
),
)
self.add_stage(
stage_name="decoding_stage",
stage=DecodingStage(vae=self.get_module("vae")),
)
EntryClass = WanCausalPipeline
+3 -1
View File
@@ -7,7 +7,8 @@ complete diffusion pipelines.
"""
from fastvideo.pipelines.stages.base import PipelineStage
from fastvideo.pipelines.stages.causal_denoising import CausalDMDDenosingStage
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 (
@@ -63,6 +64,7 @@ __all__ = [
"DenoisingStage",
"DmdDenoisingStage",
"CausalDMDDenosingStage",
"CausalDenoisingStage",
"MatrixGameCausalDenoisingStage",
"HYWorldDenoisingStage",
"GameCraftDenoisingStage",
@@ -482,3 +482,190 @@ class CausalDMDDenosingStage(DenoisingStage):
"negative_prompt_embeds", batch.negative_prompt_embeds, lambda x:
not batch.do_classifier_free_guidance or V.list_not_empty(x))
return result
class CausalDenoisingStage(CausalDMDDenosingStage):
"""Causal block-by-block denoising with standard multi-step
flow matching (scheduler.step), not DMD few-step.
Each block is fully denoised through all scheduler timesteps
before moving to the next block. After each block is denoised,
the KV cache is updated with clean context so subsequent blocks
can attend to prior clean frames.
"""
def forward(
self,
batch: ForwardBatch,
fastvideo_args: FastVideoArgs,
) -> ForwardBatch:
target_dtype = torch.bfloat16
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])
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,
},
)
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
assert torch.isnan(prompt_embeds[0]).sum() == 0
num_inference_steps = batch.num_inference_steps
# Initialize caches
kv_cache = self._initialize_kv_cache(
batch_size=b,
dtype=target_dtype,
device=latents.device,
)
crossattn_cache = self._initialize_crossattn_cache(
batch_size=b,
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")
num_blocks = t // self.num_frames_per_block
block_sizes = [self.num_frames_per_block] * num_blocks
start_index = 0
pos_start_base = 0
context_noise = getattr(
fastvideo_args.pipeline_config,
"context_noise",
0,
)
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
# history starts fresh.
self.scheduler.set_timesteps(
num_inference_steps,
device=latents.device,
)
timesteps = self.scheduler.timesteps
for i, t_cur in enumerate(timesteps):
latent_model_input = current_latents.to(target_dtype)
t_expanded = t_cur * torch.ones(
(b, 1),
device=latents.device,
dtype=torch.long,
)
with (
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
# B*T for the scheduler.
noise_pred = self.transformer(
latent_model_input,
prompt_embeds,
t_expanded,
kv_cache=kv_cache,
crossattn_cache=crossattn_cache,
current_start=((pos_start_base + start_index) *
self.frame_seq_length),
start_frame=start_index,
**pos_cond_kwargs,
)
# 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))
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))
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
t_context = torch.ones(
[b],
device=latents.device,
dtype=torch.long,
) * 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,
),
):
self.transformer(
context_bcthw,
prompt_embeds,
t_context.unsqueeze(1),
kv_cache=kv_cache,
crossattn_cache=crossattn_cache,
current_start=((pos_start_base + start_index) *
self.frame_seq_length),
start_frame=start_index,
**pos_cond_kwargs,
)
start_index += current_num_frames
batch.latents = latents
return batch
@@ -0,0 +1,223 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
import torch
_MODULE_NAME = "_fastvideo_flash_attn_cute_for_tests"
def _load_local_flash_attn_cute_module():
if _MODULE_NAME in sys.modules:
return sys.modules[_MODULE_NAME]
module_path = (
Path(__file__).resolve().parents[2]
/ "attention"
/ "utils"
/ "flash_attn_cute.py"
)
spec = importlib.util.spec_from_file_location(_MODULE_NAME, module_path)
if spec is None or spec.loader is None:
raise RuntimeError(f"Failed to load module spec from {module_path}")
module = importlib.util.module_from_spec(spec)
sys.modules[_MODULE_NAME] = module
spec.loader.exec_module(module)
return module
def _assert_close(
actual: torch.Tensor,
expected: torch.Tensor,
*,
dtype: torch.dtype,
is_grad: bool = False,
) -> None:
if dtype == torch.float16:
atol = 6e-3 if is_grad else 4e-3
rtol = 6e-3 if is_grad else 4e-3
else:
atol = 2e-2 if is_grad else 1e-2
rtol = 2e-2 if is_grad else 1e-2
torch.testing.assert_close(actual, expected, atol=atol, rtol=rtol)
def _extract_out(x):
if isinstance(x, tuple):
return x[0]
return x
@pytest.fixture(scope="module")
def flash_attn_impls():
if not torch.cuda.is_available():
pytest.skip("CUDA is required for flash_attn.cute parity tests")
major, _ = torch.cuda.get_device_capability()
if major not in (9, 10, 11):
pytest.skip(
f"flash_attn.cute supports SM 9/10/11, got compute capability {major}"
)
try:
from flash_attn.cute import (
flash_attn_func as ref_flash_attn_func,
flash_attn_varlen_func as ref_flash_attn_varlen_func,
)
except ImportError as exc:
pytest.skip(f"flash_attn.cute is not available: {exc}")
mod = _load_local_flash_attn_cute_module()
return (
mod.flash_attn_func,
mod.flash_attn_varlen_func,
ref_flash_attn_func,
ref_flash_attn_varlen_func,
)
@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
@pytest.mark.parametrize("causal", [False, True])
def test_flash_attn_func_parity_forward_backward(
flash_attn_impls, dtype: torch.dtype, causal: bool
):
if dtype == torch.bfloat16 and not torch.cuda.is_bf16_supported():
pytest.skip("bfloat16 is not supported on this GPU")
custom_flash_attn_func, _, ref_flash_attn_func, _ = flash_attn_impls
torch.manual_seed(0)
device = torch.device("cuda")
batch, seqlen, nheads, headdim = 2, 64, 4, 64
q_ref = torch.randn(
batch,
seqlen,
nheads,
headdim,
device=device,
dtype=dtype,
requires_grad=True,
)
k_ref = torch.randn_like(q_ref, requires_grad=True)
v_ref = torch.randn_like(q_ref, requires_grad=True)
q_test = q_ref.detach().clone().requires_grad_(True)
k_test = k_ref.detach().clone().requires_grad_(True)
v_test = v_ref.detach().clone().requires_grad_(True)
out_ref = _extract_out(ref_flash_attn_func(
q_ref,
k_ref,
v_ref,
softmax_scale=None,
causal=causal,
deterministic=False,
))
out_test = _extract_out(custom_flash_attn_func(
q_test,
k_test,
v_test,
dropout_p=0.0,
softmax_scale=None,
causal=causal,
deterministic=False,
))
_assert_close(out_test, out_ref, dtype=dtype)
dout = torch.randn_like(out_ref)
dq_ref, dk_ref, dv_ref = torch.autograd.grad(
(out_ref * dout).sum(), (q_ref, k_ref, v_ref)
)
dq_test, dk_test, dv_test = torch.autograd.grad(
(out_test * dout).sum(), (q_test, k_test, v_test)
)
_assert_close(dq_test, dq_ref, dtype=dtype, is_grad=True)
_assert_close(dk_test, dk_ref, dtype=dtype, is_grad=True)
_assert_close(dv_test, dv_ref, dtype=dtype, is_grad=True)
@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
@pytest.mark.parametrize("causal", [False, True])
def test_flash_attn_varlen_func_parity_forward_backward(
flash_attn_impls, dtype: torch.dtype, causal: bool
):
if dtype == torch.bfloat16 and not torch.cuda.is_bf16_supported():
pytest.skip("bfloat16 is not supported on this GPU")
(
_,
custom_flash_attn_varlen_func,
_,
ref_flash_attn_varlen_func,
) = flash_attn_impls
torch.manual_seed(1)
device = torch.device("cuda")
nheads, headdim = 4, 64
seqlens = [40, 24, 16]
cu_vals = [0]
for seqlen in seqlens:
cu_vals.append(cu_vals[-1] + seqlen)
cu = torch.tensor(cu_vals, device=device, dtype=torch.int32)
total = int(cu[-1].item())
max_seqlen = max(seqlens)
q_ref = torch.randn(
total, nheads, headdim, device=device, dtype=dtype, requires_grad=True
)
k_ref = torch.randn_like(q_ref, requires_grad=True)
v_ref = torch.randn_like(q_ref, requires_grad=True)
q_test = q_ref.detach().clone().requires_grad_(True)
k_test = k_ref.detach().clone().requires_grad_(True)
v_test = v_ref.detach().clone().requires_grad_(True)
out_ref = _extract_out(ref_flash_attn_varlen_func(
q_ref,
k_ref,
v_ref,
cu_seqlens_q=cu,
cu_seqlens_k=cu,
max_seqlen_q=max_seqlen,
max_seqlen_k=max_seqlen,
softmax_scale=None,
causal=causal,
deterministic=False,
))
out_test = _extract_out(custom_flash_attn_varlen_func(
q_test,
k_test,
v_test,
cu,
cu,
max_seqlen,
max_seqlen,
dropout_p=0.0,
softmax_scale=None,
causal=causal,
deterministic=False,
))
_assert_close(out_test, out_ref, dtype=dtype)
dout = torch.randn_like(out_ref)
dq_ref, dk_ref, dv_ref = torch.autograd.grad(
(out_ref * dout).sum(), (q_ref, k_ref, v_ref)
)
dq_test, dk_test, dv_test = torch.autograd.grad(
(out_test * dout).sum(), (q_test, k_test, v_test)
)
_assert_close(dq_test, dq_ref, dtype=dtype, is_grad=True)
_assert_close(dk_test, dk_ref, dtype=dtype, is_grad=True)
_assert_close(dv_test, dv_ref, dtype=dtype, is_grad=True)
+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 = 1.0,
log_grad_norms: bool = True,
) -> 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,
)
+464
View File
@@ -0,0 +1,464 @@
# 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,
)
# Propagate sampling_timesteps to pipeline_config so
# causal/DMD denoising stages can read them.
if (self.sampling_timesteps is not None and inference_args.pipeline_config.dmd_denoising_steps is None):
inference_args.pipeline_config.dmd_denoising_steps = ([int(s) for s in self.sampling_timesteps])
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()
+146
View File
@@ -0,0 +1,146 @@
# 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
# Auto-set attention backend for VSA when sparsity is configured.
if tc.vsa_sparsity > 0.0:
os.environ.setdefault(
"FASTVIDEO_ATTENTION_BACKEND",
"VIDEO_SPARSE_ATTN",
)
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)
+244
View File
@@ -0,0 +1,244 @@
# 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
import torch
from fastvideo import envs
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,
) -> 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()
@staticmethod
def _infer_attn_kind() -> Literal["dense", "vsa"]:
"""Derive attn_kind from ``FASTVIDEO_ATTENTION_BACKEND``."""
backend = envs.FASTVIDEO_ATTENTION_BACKEND
if backend == "VIDEO_SPARSE_ATTN":
return "vsa"
return "dense"
@@ -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,630 @@
# 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,
) -> 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,
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,322 @@
# 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._infer_attn_kind())
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,
) -> tuple[
dict[str, torch.Tensor],
dict[str, Any],
dict[str, LogScalar],
]:
del iteration
training_batch = self.student.prepare_batch(
batch,
generator=self.cuda_generator,
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,180 @@
# 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._infer_attn_kind())
# 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,
) -> tuple[
dict[str, torch.Tensor],
dict[str, Any],
dict[str, LogScalar],
]:
del iteration
training_batch = self.student.prepare_batch(
batch,
generator=self.cuda_generator,
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``.
"""
+195
View File
@@ -0,0 +1,195 @@
# 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,
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, )
+671
View File
@@ -0,0 +1,671 @@
# 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,
transformer_override_safetensor: 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,
transformer_override_safetensor=(transformer_override_safetensor),
)
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,
transformer_override_safetensor: str | None = None,
) -> 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_override_safetensor=(transformer_override_safetensor),
)
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,
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()
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]
)
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=tc.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}")
+440
View File
@@ -0,0 +1,440 @@
# 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,
transformer_override_safetensor: 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),
transformer_override_safetensor=(transformer_override_safetensor),
)
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)]
+196
View File
@@ -0,0 +1,196 @@
# 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
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 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()
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,
)
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(tc.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
+380
View File
@@ -0,0 +1,380 @@
# 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(),
}
rng["cuda_rng"] = torch.cuda.get_rng_state()
rng["gen_cuda"] = self.method.cuda_generator.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"])
torch.cuda.set_rng_state(rng["cuda_rng"])
self.method.cuda_generator.set_state(rng["gen_cuda"])
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()
+506
View File
@@ -0,0 +1,506 @@
# 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,
)
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_sparsity=float(vs.get("sparsity", 0.0) or 0.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
+148
View File
@@ -0,0 +1,148 @@
# 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,
transformer_override_safetensor: 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 transformer_override_safetensor:
fastvideo_args.init_weights_from_safetensors = str(transformer_override_safetensor)
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,
)
+92
View File
@@ -0,0 +1,92 @@
# 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 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_sparsity: float = 0.0
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."""
@@ -162,6 +169,16 @@ class WandbTracker(BaseTracker):
self.log(normalized_artifacts, step)
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()
@@ -212,6 +229,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: