Skip to content

Commit 8dfc13a

Browse files
committed
[ci] add per-method single-step training tests for fastvideo.train (PR 5a-i/9)
Phase 2 / PR 5/9 of the fastvideo.train CI plan, split into a first-half ``5a-i`` slice that establishes the per-method test pattern. A follow-up ``5a-ii`` will layer a device-keyed grad-norm regression on top of the same harness. Adds two GPU smoke tests under a new ``fastvideo/tests/train/methods/`` directory: * ``test_wan_finetune.py`` — ``WanModel`` + ``FineTuneMethod`` * ``test_wan_causal_dfsft.py`` — ``WanCausalModel`` + ``DiffusionForcingSFTMethod`` Both tests construct the method via its public constructor (``method = FineTuneMethod(cfg=cfg.method, role_models=...)``), build a tiny synthetic ``raw_batch`` (text embed + mask + vae latent), and run one end-to-end step: method.on_train_start() loss_map, outputs, _ = method.single_train_step(batch, 0) method.backward(loss_map, outputs, grad_accum_rounds=1) Asserts that ``loss_map["total_loss"]`` is finite and that the first transformer block (``model.transformer.blocks[0]``) has trainable parameters with finite, non-zero gradients. The first block's grad is computed *last* during backprop, so a healthy grad there implies the full forward + chain-rule path is intact — keeping the assertion surface to a single block keeps the reference data tiny for the follow-up regression PR. The harness deliberately avoids the full ``Trainer`` (no FSDP wrap, no data loader, no checkpointing) so the tests stay focused on the method-level wiring that the model-loading tests in ``tests/train/models`` don't cover. CI plumbing: * ``fastvideo/tests/modal/pr_test.py``: add ``./fastvideo/tests/train/methods`` to the ``run_train_framework_tests`` pytest path, and add a matching ``--ignore`` entry in ``run_unit_test`` so the CPU runner doesn't pick it up. * ``docs/contributing/testing.md``: mention the new ``methods/`` subdirectory in the Train Framework Tests entry. Two new fixture YAMLs under ``fastvideo/tests/train/fixtures/`` mirror the structure of the existing 4/9 fixtures but flip ``trainable`` to ``true`` and include the minimum ``training.distributed`` + ``training.optimizer`` + ``training.loop`` keys needed by the method's ``__init__`` and ``on_train_start``.
1 parent 72cb427 commit 8dfc13a

7 files changed

Lines changed: 330 additions & 3 deletions

File tree

docs/contributing/testing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ This guide explains how to add and run tests in FastVideo. The testing suite is
66

77
* **Unit Tests**: Located in `fastvideo/tests/api`, `fastvideo/tests/dataset`, `fastvideo/tests/entrypoints`, `fastvideo/tests/workflow`, and the CPU-only subset of `fastvideo/tests/train` (callbacks, utils). These test individual functions and classes.
88
* **Component Tests**: Located in `fastvideo/tests/encoders`, `fastvideo/tests/transformers`, and `fastvideo/tests/vaes`. These verify the loading and basic functionality of model components.
9-
* **Train Framework Tests** (GPU): Located in `fastvideo/tests/train/models`. Cover model loading + forward smoke for the new `fastvideo/train/` framework. Triggered via `/test train-framework` or as part of the Full Suite.
9+
* **Train Framework Tests** (GPU): Located in `fastvideo/tests/train/models` (model loading + forward smoke) and `fastvideo/tests/train/methods` (per-method single training step). Cover the new `fastvideo/train/` framework end-to-end on real checkpoints with tiny synthetic batches. Triggered via `/test train-framework` or as part of the Full Suite.
1010
* **SSIM Tests**: Located in `fastvideo/tests/ssim`. These are regression tests that compare generated videos against reference videos using the Structural Similarity Index Measure (SSIM) to detect quality degradation.
1111
* **Training Tests**: Located in `fastvideo/tests/training`. These validate training loops, loss calculations, and specific training techniques like LoRA, Distillation, and VSA.
1212
* **Inference Tests**: Located in `fastvideo/tests/inference`. These test specialized inference pipelines and optimizations (e.g., VSA, V-MoBA).

fastvideo/tests/modal/pr_test.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ def run_self_forcing_tests():
214214
@app.function(gpu="L40S:1", image=image, timeout=900)
215215
def run_unit_test():
216216
run_test(
217-
"pytest ./fastvideo/tests/api/ ./fastvideo/tests/contract/ ./fastvideo/tests/dataset/ ./fastvideo/tests/workflow/ ./fastvideo/tests/entrypoints/ ./fastvideo/tests/train/ --ignore=./fastvideo/tests/entrypoints/test_openai_api_integration.py --ignore=./fastvideo/tests/train/models -vs"
217+
"pytest ./fastvideo/tests/api/ ./fastvideo/tests/contract/ ./fastvideo/tests/dataset/ ./fastvideo/tests/workflow/ ./fastvideo/tests/entrypoints/ ./fastvideo/tests/train/ --ignore=./fastvideo/tests/entrypoints/test_openai_api_integration.py --ignore=./fastvideo/tests/train/models --ignore=./fastvideo/tests/train/methods -vs"
218218
)
219219

220220

@@ -228,7 +228,7 @@ def run_unit_test():
228228
volumes={"/root/data": model_vol})
229229
def run_train_framework_tests():
230230
run_test(
231-
"export HF_HOME='/root/data/.cache' && hf auth login --token $HF_API_KEY && pytest ./fastvideo/tests/train/models -vs"
231+
"export HF_HOME='/root/data/.cache' && hf auth login --token $HF_API_KEY && pytest ./fastvideo/tests/train/models ./fastvideo/tests/train/methods -vs"
232232
)
233233

234234

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Minimum config to run a single training step of
2+
# DiffusionForcingSFTMethod on WanCausalModel for the per-method
3+
# smoke test. Uses the real Wan 2.1 1.3B checkpoint (loaded as a
4+
# CausalWanTransformer3DModel by WanCausalModel) with tiny synthetic
5+
# latents.
6+
7+
models:
8+
student:
9+
_target_: fastvideo.train.models.wan.WanCausalModel
10+
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
11+
trainable: true
12+
13+
method:
14+
_target_: fastvideo.train.methods.fine_tuning.dfsft.DiffusionForcingSFTMethod
15+
chunk_size: 3
16+
17+
training:
18+
dit_precision: bf16
19+
20+
distributed:
21+
num_gpus: 1
22+
sp_size: 1
23+
tp_size: 1
24+
25+
data:
26+
seed: 42
27+
train_batch_size: 1
28+
training_cfg_rate: 0.0
29+
# Multiple of chunk_size=3 so the diffusion-forcing chunker has
30+
# whole chunks to work with.
31+
num_latent_t: 6
32+
num_height: 64
33+
num_width: 64
34+
num_frames: 21
35+
36+
optimizer:
37+
learning_rate: 2.0e-6
38+
betas: [0.9, 0.999]
39+
weight_decay: 0.01
40+
lr_scheduler: constant
41+
lr_warmup_steps: 0
42+
43+
loop:
44+
max_train_steps: 1
45+
gradient_accumulation_steps: 1
46+
47+
pipeline: {}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Minimum config to run a single training step of FineTuneMethod on
2+
# WanModel for the per-method smoke test. Uses the real Wan 2.1
3+
# 1.3B checkpoint (loaded by WanModel.__init__) but with tiny synthetic
4+
# latents and a single-rank distributed setup so the test fits on one
5+
# L40S.
6+
7+
models:
8+
student:
9+
_target_: fastvideo.train.models.wan.WanModel
10+
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
11+
trainable: true
12+
13+
method:
14+
_target_: fastvideo.train.methods.fine_tuning.finetune.FineTuneMethod
15+
16+
training:
17+
dit_precision: bf16
18+
19+
distributed:
20+
num_gpus: 1
21+
sp_size: 1
22+
tp_size: 1
23+
24+
data:
25+
seed: 42
26+
train_batch_size: 1
27+
training_cfg_rate: 0.0
28+
num_latent_t: 4
29+
num_height: 64
30+
num_width: 64
31+
num_frames: 13
32+
33+
optimizer:
34+
learning_rate: 1.0e-6
35+
betas: [0.9, 0.999]
36+
weight_decay: 0.01
37+
lr_scheduler: constant
38+
lr_warmup_steps: 0
39+
40+
loop:
41+
max_train_steps: 1
42+
gradient_accumulation_steps: 1
43+
44+
# Empty dict (not omitted) so _parse_pipeline_config resolves the
45+
# Wan-specific dit_config from the model checkpoint instead of
46+
# falling back to a bare DiTConfig.
47+
pipeline: {}

fastvideo/tests/train/methods/__init__.py

Whitespace-only changes.
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
"""Per-method GPU smoke test: ``WanCausalModel`` + ``DiffusionForcingSFTMethod``.
3+
4+
Mirrors ``test_wan_finetune.py`` for the diffusion-forcing SFT
5+
(DFSFT) algorithm on the causal Wan transformer. The harness is
6+
intentionally identical so the two tests are easy to compare and so
7+
future per-method tests can copy this template verbatim.
8+
9+
DFSFT samples *inhomogeneous* timesteps per chunk (``chunk_size=3``
10+
in the fixture) and is the natural training counterpart of the
11+
``WanCausalModel`` plugin.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import os
17+
18+
os.environ.setdefault("MASTER_ADDR", "localhost")
19+
os.environ.setdefault("MASTER_PORT", "29517")
20+
21+
from pathlib import Path
22+
23+
import pytest
24+
import torch
25+
26+
from fastvideo.train.methods.fine_tuning.dfsft import (
27+
DiffusionForcingSFTMethod, )
28+
from fastvideo.train.models.wan import WanCausalModel
29+
from fastvideo.train.utils.config import load_run_config
30+
31+
32+
_FIXTURE = str(
33+
Path(__file__).resolve().parent.parent / "fixtures"
34+
/ "wan_causal_t2v_dfsft_min.yaml")
35+
36+
37+
def _build_synthetic_batch(
38+
device: torch.device,
39+
dtype: torch.dtype,
40+
) -> dict[str, torch.Tensor]:
41+
"""Tiny synthetic ``raw_batch`` for the causal Wan path.
42+
43+
``num_latent_t`` in the fixture is 6 (= 2 * chunk_size) so the
44+
diffusion-forcing chunker has two whole chunks to operate on
45+
even after ``prepare_batch`` truncates.
46+
"""
47+
batch_size = 1
48+
return {
49+
"text_embedding":
50+
torch.randn(batch_size, 16, 4096, device=device, dtype=dtype),
51+
"text_attention_mask":
52+
torch.ones(batch_size, 16, device=device, dtype=dtype),
53+
"vae_latent":
54+
torch.randn(batch_size, 16, 6, 8, 8, device=device, dtype=dtype),
55+
}
56+
57+
58+
@pytest.mark.usefixtures("distributed_setup")
59+
def test_wan_causal_dfsft_single_train_step() -> None:
60+
if not torch.cuda.is_available():
61+
pytest.skip("requires CUDA")
62+
63+
cfg = load_run_config(_FIXTURE)
64+
65+
device = torch.device("cuda:0")
66+
dtype = torch.bfloat16
67+
68+
model = WanCausalModel(
69+
init_from=cfg.models["student"]["init_from"],
70+
training_config=cfg.training,
71+
trainable=True,
72+
)
73+
model.transformer = model.transformer.to(device=device, dtype=dtype)
74+
75+
method = DiffusionForcingSFTMethod(
76+
cfg=cfg,
77+
role_models={"student": model},
78+
)
79+
method.on_train_start()
80+
81+
batch = _build_synthetic_batch(device, dtype)
82+
loss_map, outputs, _metrics = method.single_train_step(batch, iteration=0)
83+
84+
loss = loss_map["total_loss"]
85+
assert torch.is_tensor(loss), "total_loss must be a torch.Tensor"
86+
assert torch.isfinite(loss).item(), (
87+
f"total_loss is not finite: {loss.item()}")
88+
89+
method.backward(loss_map, outputs, grad_accum_rounds=1)
90+
91+
blocks = getattr(model.transformer, "blocks", None)
92+
assert blocks is not None and len(blocks) > 0, (
93+
"CausalWanTransformer is expected to expose ``.blocks``")
94+
layer0 = blocks[0]
95+
96+
trainable = [p for p in layer0.parameters() if p.requires_grad]
97+
assert len(trainable) > 0, "layer 0 has no trainable parameters"
98+
99+
for i, p in enumerate(trainable):
100+
assert p.grad is not None, f"layer 0 param[{i}] has None grad"
101+
assert torch.isfinite(p.grad).all().item(), (
102+
f"layer 0 param[{i}] grad contains NaN/Inf")
103+
104+
any_nonzero = any(
105+
p.grad.detach().float().norm().item() > 0.0 for p in trainable)
106+
assert any_nonzero, (
107+
"all layer-0 grads are exactly zero; backward did not "
108+
"reach the first transformer block")
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
"""Per-method GPU smoke test: ``WanModel`` + ``FineTuneMethod``.
3+
4+
Establishes the per-method test pattern for ``fastvideo/train``:
5+
6+
1. Instantiate the model + method via their public constructors
7+
(no ``Trainer`` setup, no FSDP wrapping).
8+
2. Feed a synthetic ``raw_batch`` dict through
9+
``method.single_train_step()`` + ``method.backward()``.
10+
3. Assert that the loss is finite and that the first transformer
11+
block received a finite, non-zero gradient.
12+
13+
The first block's gradient is the *last* one computed during
14+
backprop, so a healthy grad there implies the full
15+
forward + chain-rule path is intact. Keeping the assertion to a
16+
single block keeps the reference surface tiny — a later PR layers a
17+
device-keyed grad-norm regression on top of this same harness.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import os
23+
24+
# Required by the ``distributed_setup`` fixture pulled from
25+
# ``fastvideo/tests/conftest.py``. Set before any fastvideo import.
26+
os.environ.setdefault("MASTER_ADDR", "localhost")
27+
os.environ.setdefault("MASTER_PORT", "29516")
28+
29+
from pathlib import Path
30+
31+
import pytest
32+
import torch
33+
34+
from fastvideo.train.methods.fine_tuning.finetune import (
35+
FineTuneMethod, )
36+
from fastvideo.train.models.wan import WanModel
37+
from fastvideo.train.utils.config import load_run_config
38+
39+
40+
_FIXTURE = str(
41+
Path(__file__).resolve().parent.parent / "fixtures"
42+
/ "wan_t2v_finetune_min.yaml")
43+
44+
45+
def _build_synthetic_batch(
46+
device: torch.device,
47+
dtype: torch.dtype,
48+
) -> dict[str, torch.Tensor]:
49+
"""Tiny synthetic ``raw_batch`` matching ``WanModel.prepare_batch``.
50+
51+
Shapes are intentionally small so the 1.3B base model + one
52+
backward pass fits comfortably on a single L40S. Wan uses T5
53+
text embeddings (hidden=4096) and a VAE with ``z_dim=16``.
54+
"""
55+
batch_size = 1
56+
return {
57+
"text_embedding":
58+
torch.randn(batch_size, 16, 4096, device=device, dtype=dtype),
59+
"text_attention_mask":
60+
torch.ones(batch_size, 16, device=device, dtype=dtype),
61+
# vae_latent in (B, C, T, H, W); ``prepare_batch`` will
62+
# truncate T to ``training.data.num_latent_t``.
63+
"vae_latent":
64+
torch.randn(batch_size, 16, 4, 8, 8, device=device, dtype=dtype),
65+
}
66+
67+
68+
@pytest.mark.usefixtures("distributed_setup")
69+
def test_wan_finetune_single_train_step() -> None:
70+
if not torch.cuda.is_available():
71+
pytest.skip("requires CUDA")
72+
73+
cfg = load_run_config(_FIXTURE)
74+
75+
device = torch.device("cuda:0")
76+
dtype = torch.bfloat16
77+
78+
model = WanModel(
79+
init_from=cfg.models["student"]["init_from"],
80+
training_config=cfg.training,
81+
trainable=True,
82+
)
83+
# Move transformer to device + training dtype. Real training
84+
# wraps the transformer with FSDP and shards across ranks; for a
85+
# single-step smoke we just move it directly.
86+
model.transformer = model.transformer.to(device=device, dtype=dtype)
87+
88+
method = FineTuneMethod(
89+
cfg=cfg,
90+
role_models={"student": model},
91+
)
92+
# cuda_generator + RNG seeding (normally done by ``Trainer``).
93+
method.on_train_start()
94+
95+
batch = _build_synthetic_batch(device, dtype)
96+
loss_map, outputs, _metrics = method.single_train_step(batch, iteration=0)
97+
98+
loss = loss_map["total_loss"]
99+
assert torch.is_tensor(loss), "total_loss must be a torch.Tensor"
100+
assert torch.isfinite(loss).item(), (
101+
f"total_loss is not finite: {loss.item()}")
102+
103+
method.backward(loss_map, outputs, grad_accum_rounds=1)
104+
105+
blocks = getattr(model.transformer, "blocks", None)
106+
assert blocks is not None and len(blocks) > 0, (
107+
"Wan transformer is expected to expose ``.blocks``")
108+
layer0 = blocks[0]
109+
110+
trainable = [p for p in layer0.parameters() if p.requires_grad]
111+
assert len(trainable) > 0, "layer 0 has no trainable parameters"
112+
113+
for i, p in enumerate(trainable):
114+
assert p.grad is not None, f"layer 0 param[{i}] has None grad"
115+
assert torch.isfinite(p.grad).all().item(), (
116+
f"layer 0 param[{i}] grad contains NaN/Inf")
117+
118+
# At least one layer-0 grad must have non-zero L2 norm so we
119+
# catch the case where backward ran but the first block was
120+
# detached from the loss (silent connectivity bug).
121+
any_nonzero = any(
122+
p.grad.detach().float().norm().item() > 0.0 for p in trainable)
123+
assert any_nonzero, (
124+
"all layer-0 grads are exactly zero; backward did not "
125+
"reach the first transformer block")

0 commit comments

Comments
 (0)