Files

384 lines
12 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""CPU-only unit tests for :func:`load_run_config`."""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
import yaml
from fastvideo.train.utils.config import RunConfig, load_run_config
from fastvideo.train.utils.training_config import TrainingConfig
def _write_yaml(tmp_path: Path, data: dict[str, Any]) -> str:
path = tmp_path / "run.yaml"
path.write_text(yaml.safe_dump(data), encoding="utf-8")
return str(path)
def _minimal_yaml() -> dict[str, Any]:
return {
"models": {
"student": {
"_target_": "fastvideo.train.models.wan.WanModel",
"init_from": "fake/model",
},
},
"method": {
"_target_": "fastvideo.train.methods.fine_tuning.finetune.FineTuneMethod",
},
"training": {},
}
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
def test_minimal_yaml_loads_happy_path(tmp_path: Path) -> None:
cfg = load_run_config(_write_yaml(tmp_path, _minimal_yaml()))
assert isinstance(cfg, RunConfig)
assert isinstance(cfg.training, TrainingConfig)
assert cfg.method["_target_"] == ("fastvideo.train.methods.fine_tuning.finetune.FineTuneMethod")
assert "student" in cfg.models
assert cfg.callbacks == {}
# raw retains the original YAML dict for downstream logging.
assert "models" in cfg.raw and "method" in cfg.raw
def test_minimal_yaml_applies_all_defaults(tmp_path: Path) -> None:
cfg = load_run_config(_write_yaml(tmp_path, _minimal_yaml()))
t = cfg.training
assert t.distributed.num_gpus == 1
assert t.distributed.tp_size == 1
assert t.distributed.sp_size == 1
assert t.distributed.hsdp_replicate_dim == 1
assert t.distributed.pin_cpu_memory is False
assert t.data.train_batch_size == 1
assert t.data.dataloader_num_workers == 0
assert t.data.training_cfg_rate == 0.0
assert t.data.seed == 0
assert t.optimizer.learning_rate == 0.0
assert t.optimizer.betas == (0.9, 0.999)
assert t.optimizer.weight_decay == 0.0
assert t.optimizer.lr_scheduler == "constant"
assert t.optimizer.min_lr_ratio == 0.5
assert t.loop.max_train_steps == 0
assert t.loop.gradient_accumulation_steps == 1
assert t.checkpoint.output_dir == ""
assert t.checkpoint.checkpoints_total_limit == 0
assert t.tracker.trackers == []
assert t.tracker.project_name == "fastvideo"
assert t.tracker.run_name == ""
assert t.model.weighting_scheme == "uniform"
assert t.model.precondition_outputs is False
assert t.model.moba_config == {}
assert t.dit_precision == "fp32"
assert t.vsa_sparsity == 0.0
assert t.pipeline_config is None
def test_full_yaml_populates_all_training_fields(tmp_path: Path) -> None:
data = _minimal_yaml()
data["training"] = {
"distributed": {
"num_gpus": 4,
"tp_size": 2,
"sp_size": 2,
"hsdp_replicate_dim": 2,
"hsdp_shard_dim": 2,
"pin_cpu_memory": True,
},
"data": {
"data_path": "/some/path",
"train_batch_size": 2,
"dataloader_num_workers": 4,
"training_cfg_rate": 0.1,
"seed": 42,
"num_height": 256,
"num_width": 512,
"num_latent_t": 8,
"num_frames": 33,
},
"optimizer": {
"learning_rate": 1e-4,
"betas": [0.9, 0.95],
"weight_decay": 0.01,
"lr_scheduler": "cosine",
"lr_warmup_steps": 100,
"min_lr_ratio": 0.1,
},
"loop": {
"max_train_steps": 1000,
"gradient_accumulation_steps": 4,
},
"checkpoint": {
"output_dir": "/out",
"training_state_checkpointing_steps": 50,
"checkpoints_total_limit": 3,
},
"tracker": {
"trackers": ["wandb"],
"project_name": "myproj",
"run_name": "myrun",
},
"vsa": {
"sparsity": 0.5
},
"model": {
"weighting_scheme": "logit_normal",
"logit_mean": 0.5,
"logit_std": 1.5,
"precondition_outputs": True,
},
"dit_precision": "bf16",
}
cfg = load_run_config(_write_yaml(tmp_path, data))
t = cfg.training
assert t.distributed.num_gpus == 4
assert t.distributed.tp_size == 2
assert t.distributed.pin_cpu_memory is True
assert t.data.train_batch_size == 2
assert t.data.data_path == "/some/path"
assert t.data.num_frames == 33
assert t.data.seed == 42
assert t.optimizer.learning_rate == pytest.approx(1e-4)
assert t.optimizer.betas == (0.9, 0.95)
assert t.optimizer.lr_scheduler == "cosine"
assert t.optimizer.lr_warmup_steps == 100
assert t.optimizer.min_lr_ratio == pytest.approx(0.1)
assert t.loop.max_train_steps == 1000
assert t.loop.gradient_accumulation_steps == 4
assert t.checkpoint.output_dir == "/out"
assert t.checkpoint.checkpoints_total_limit == 3
assert t.tracker.trackers == ["wandb"]
assert t.tracker.project_name == "myproj"
assert t.vsa_sparsity == pytest.approx(0.5)
assert t.model.weighting_scheme == "logit_normal"
assert t.model.precondition_outputs is True
assert t.dit_precision == "bf16"
# ---------------------------------------------------------------------------
# Schema validation
# ---------------------------------------------------------------------------
def test_missing_models_raises(tmp_path: Path) -> None:
data = _minimal_yaml()
del data["models"]
with pytest.raises(ValueError, match="models"):
load_run_config(_write_yaml(tmp_path, data))
def test_missing_method_raises(tmp_path: Path) -> None:
data = _minimal_yaml()
del data["method"]
with pytest.raises(ValueError, match="method"):
load_run_config(_write_yaml(tmp_path, data))
def test_missing_training_raises(tmp_path: Path) -> None:
data = _minimal_yaml()
del data["training"]
with pytest.raises(ValueError, match="training"):
load_run_config(_write_yaml(tmp_path, data))
def test_models_role_without_target_raises(tmp_path: Path) -> None:
data = _minimal_yaml()
data["models"]["student"] = {"init_from": "fake/model"}
with pytest.raises(ValueError, match="_target_"):
load_run_config(_write_yaml(tmp_path, data))
def test_method_without_target_raises(tmp_path: Path) -> None:
data = _minimal_yaml()
data["method"] = {"some_param": 5}
with pytest.raises(ValueError, match="_target_"):
load_run_config(_write_yaml(tmp_path, data))
def test_missing_config_file_raises(tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError):
load_run_config(str(tmp_path / "does_not_exist.yaml"))
# ---------------------------------------------------------------------------
# Special parsing
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("betas_value, expected", [
([0.8, 0.9], (0.8, 0.9)),
("0.9,0.999", (0.9, 0.999)),
])
def test_betas_parses_list_and_string_forms(
tmp_path: Path,
betas_value: Any,
expected: tuple[float, float],
) -> None:
data = _minimal_yaml()
data["training"] = {"optimizer": {"betas": betas_value}}
cfg = load_run_config(_write_yaml(tmp_path, data))
assert cfg.training.optimizer.betas == expected
def test_pipeline_quant_config_resolves_for_load_and_export(tmp_path: Path) -> None:
from fastvideo.layers.quantization.nvfp4_qat_train_config import (
NVFP4QATTrainConfig, )
from fastvideo.train.entrypoint.dcp_to_diffusers import (
_run_config_from_raw, )
data = _minimal_yaml()
data["models"]["student"]["init_from"] = ("FastVideo/LTX2-Distilled-Diffusers")
data["pipeline"] = {"dit_config": {"quant_config": "nvfp4_qat_train"}}
cfg = load_run_config(_write_yaml(tmp_path, data))
assert isinstance(cfg.training.pipeline_config.dit_config.quant_config, NVFP4QATTrainConfig)
export_cfg = _run_config_from_raw(cfg.raw)
assert isinstance(
export_cfg.training.pipeline_config.dit_config.quant_config,
NVFP4QATTrainConfig,
)
def test_pipeline_quant_config_rejects_unknown_name(tmp_path: Path) -> None:
data = _minimal_yaml()
data["models"]["student"]["init_from"] = ("FastVideo/LTX2-Distilled-Diffusers")
data["pipeline"] = {"dit_config": {"quant_config": "not_a_quantization_method"}}
with pytest.raises(ValueError, match="Invalid quantization method"):
load_run_config(_write_yaml(tmp_path, data))
def test_data_path_mapping_parses_repeat_counts(tmp_path: Path) -> None:
# Config loading should preserve structured multi-dataset paths so the
# dataset layer can interpret repeat counts later.
data = _minimal_yaml()
data["training"] = {
"data": {
"data_path": {
"data/path1": 1,
"data/path2": 2,
}
}
}
cfg = load_run_config(_write_yaml(tmp_path, data))
assert cfg.training.data.data_path == {
"data/path1": 1,
"data/path2": 2,
}
def test_dotted_override_replaces_mapping_data_path(tmp_path: Path) -> None:
# A dict-valued data_path is a single leaf for overrides: a scalar
# --training.data.data_path replaces the whole mapping.
data = _minimal_yaml()
data["training"] = {
"data": {
"data_path": {
"data/path1": 1,
"data/path2": 2,
}
}
}
cfg = load_run_config(
_write_yaml(tmp_path, data),
overrides=["--training.data.data_path=data/only"],
)
assert cfg.training.data.data_path == "data/only"
def test_dotted_overrides_apply_with_type_coercion(tmp_path: Path) -> None:
path = _write_yaml(tmp_path, _minimal_yaml())
overrides = [
"--training.distributed.num_gpus=4",
"--training.optimizer.learning_rate=1e-3",
"--training.distributed.pin_cpu_memory=true",
"--training.tracker.project_name=overridden",
]
cfg = load_run_config(path, overrides=overrides)
assert cfg.training.distributed.num_gpus == 4
assert cfg.training.optimizer.learning_rate == pytest.approx(1e-3)
assert cfg.training.distributed.pin_cpu_memory is True
assert cfg.training.tracker.project_name == "overridden"
def test_dotted_overrides_accept_separate_value_token(tmp_path: Path) -> None:
path = _write_yaml(tmp_path, _minimal_yaml())
cfg = load_run_config(
path,
overrides=["--training.distributed.num_gpus", "8"],
)
assert cfg.training.distributed.num_gpus == 8
def test_overrides_create_intermediate_keys(tmp_path: Path) -> None:
"""Overrides into a nested key absent from YAML should still apply."""
data = _minimal_yaml()
# No `training.checkpoint` block in the minimal YAML.
path = _write_yaml(tmp_path, data)
cfg = load_run_config(
path,
overrides=["--training.checkpoint.checkpoints_total_limit=5"],
)
assert cfg.training.checkpoint.checkpoints_total_limit == 5
def test_hsdp_shard_dim_defaults_to_num_gpus(tmp_path: Path) -> None:
"""When unset, hsdp_shard_dim and sp_size fall back to num_gpus."""
data = _minimal_yaml()
data["training"] = {"distributed": {"num_gpus": 4}}
cfg = load_run_config(_write_yaml(tmp_path, data))
assert cfg.training.distributed.hsdp_shard_dim == 4
assert cfg.training.distributed.sp_size == 4
def test_model_path_derived_from_student_init_from(tmp_path: Path) -> None:
cfg = load_run_config(_write_yaml(tmp_path, _minimal_yaml()))
assert cfg.training.model_path == "fake/model"
def test_callbacks_default_to_empty_when_absent(tmp_path: Path) -> None:
cfg = load_run_config(_write_yaml(tmp_path, _minimal_yaml()))
assert cfg.callbacks == {}
def test_callbacks_passed_through_when_present(tmp_path: Path) -> None:
data = _minimal_yaml()
data["callbacks"] = {
"grad_clip": {
"_target_": "fastvideo.train.callbacks.grad_clip.GradNormClipCallback",
"max_grad_norm": 1.0,
},
}
cfg = load_run_config(_write_yaml(tmp_path, data))
assert "grad_clip" in cfg.callbacks
assert cfg.callbacks["grad_clip"]["max_grad_norm"] == 1.0