1629 lines
62 KiB
Python
1629 lines
62 KiB
Python
# 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 gc
|
|
import json
|
|
import os
|
|
import time
|
|
from copy import deepcopy
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, TYPE_CHECKING
|
|
|
|
import numpy as np
|
|
import torch
|
|
import torchvision
|
|
from einops import rearrange
|
|
from torch.utils.data import DataLoader
|
|
|
|
from fastvideo.api.sampling_param 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.train.utils.validation_media import write_validation_mp4
|
|
from fastvideo.training.trackers import DummyTracker
|
|
from fastvideo.utils import pixels_to_uint8, shallow_asdict
|
|
|
|
if TYPE_CHECKING:
|
|
from fastvideo.train.methods.base import TrainingMethod
|
|
|
|
logger = init_logger(__name__)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class _ValidationStepResult:
|
|
"""Keep decoded media aligned with the prompt and conditions that produced it."""
|
|
|
|
videos: list[list[np.ndarray]]
|
|
captions: list[str]
|
|
audio_waveforms: list[torch.Tensor | np.ndarray | None] = field(default_factory=list)
|
|
audio_sample_rates: list[int | None] = field(default_factory=list)
|
|
overlay_videos: list[list[np.ndarray]] = field(default_factory=list)
|
|
overlay_captions: list[str] = field(default_factory=list)
|
|
ref_videos: list[str | None] = field(default_factory=list)
|
|
actions: list[dict[str, Any] | None] = field(default_factory=list)
|
|
mouse_pitch_signs: list[int | None] = field(default_factory=list)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class _ValidationMetricStats:
|
|
sums: dict[str, float] = field(default_factory=dict)
|
|
counts: dict[str, float] = field(default_factory=dict)
|
|
per_video: list[dict[str, Any]] = field(default_factory=list)
|
|
errors: list[str] = field(default_factory=list)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class _SavedValidationVideos:
|
|
"""Identify verified MP4 files and their positions in the generated batch."""
|
|
|
|
filenames: list[str] = field(default_factory=list)
|
|
indices: list[int] = field(default_factory=list)
|
|
audio_video_count: int = 0
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class _ValidationMetricsConfig:
|
|
enabled: bool = False
|
|
names: list[str] = field(default_factory=list)
|
|
device: str = "cuda"
|
|
calibration_path: str | None = None
|
|
mouse_pitch_sign: int = 1
|
|
skip_missing_deps: bool = True
|
|
strict: bool = False
|
|
unload_after_validation: bool = True
|
|
loader_threads: int = 1
|
|
prefetch_factor: int = 2
|
|
log_prefix: str = "metrics/validation"
|
|
|
|
|
|
DEFAULT_VALIDATION_VBENCH_METRICS = [
|
|
"vbench.imaging_quality",
|
|
"vbench.aesthetic_quality",
|
|
"vbench.temporal_flickering",
|
|
"vbench.motion_smoothness",
|
|
"vbench.subject_consistency",
|
|
"vbench.background_consistency",
|
|
"vbench.dynamic_degree",
|
|
]
|
|
|
|
SYNTHETIC_OPTICAL_FLOW_METRIC = "optical_flow.synthetic_optical_flow"
|
|
SYNTHETIC_OPTICAL_FLOW_LOG_KEYS = (
|
|
"mf_epe_mean",
|
|
"mf_angle_err_mean",
|
|
"mf_cosine_mean",
|
|
"mf_mag_ratio_mean",
|
|
"pixel_epe_mean_mean",
|
|
"px_angle_rmse_mean",
|
|
"fl_all_mean",
|
|
"foe_dist_mean",
|
|
"flow_kl_2d_mean",
|
|
)
|
|
|
|
|
|
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,
|
|
run_at_start: bool = True,
|
|
sampling_steps: list[int] | None = None,
|
|
guidance_scale: float | None = None,
|
|
num_frames: int | None = None,
|
|
num_videos_per_prompt: int = 1,
|
|
use_validation_media_conditioning: bool = True,
|
|
output_dir: str | None = None,
|
|
sampling_timesteps: list[int] | None = None,
|
|
overlay_actions: bool = False,
|
|
keyboard_value_scale: float = 1.0,
|
|
offload_training_state: bool = False,
|
|
unload_pipeline_after_validation: bool = False,
|
|
attn_qat_infer: bool = False,
|
|
**pipeline_kwargs: Any,
|
|
) -> None:
|
|
"""Configure validation cadence, generation parameters, and pipeline loading.
|
|
|
|
``run_at_start`` controls the pre-training baseline event.
|
|
``use_validation_media_conditioning`` lets text-to-video recipes use
|
|
captions from a dataset that also contains source-media paths.
|
|
"""
|
|
self.pipeline_target = str(pipeline_target)
|
|
self.dataset_file = str(dataset_file)
|
|
self.every_steps = int(every_steps)
|
|
self.run_at_start = self._coerce_bool(run_at_start)
|
|
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.num_videos_per_prompt = int(num_videos_per_prompt)
|
|
if self.num_videos_per_prompt <= 0:
|
|
raise ValueError("callbacks.validation.num_videos_per_prompt must be positive")
|
|
self.use_validation_media_conditioning = self._coerce_bool(use_validation_media_conditioning)
|
|
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.overlay_actions = self._coerce_bool(overlay_actions)
|
|
# Validation-only action amplification for world model; training keeps raw action values.
|
|
self.keyboard_value_scale = float(keyboard_value_scale)
|
|
metrics_config = pipeline_kwargs.pop("metrics", None)
|
|
self.metrics_config = self._parse_metrics_config(metrics_config)
|
|
self.offload_training_state = self._coerce_bool(offload_training_state)
|
|
self.unload_pipeline_after_validation = self._coerce_bool(unload_pipeline_after_validation)
|
|
self.attn_qat_infer = self._coerce_bool(attn_qat_infer)
|
|
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._metric_evaluator: Any | None = None
|
|
self.tracker: Any = DummyTracker()
|
|
self.validation_random_generator: (torch.Generator | None) = None
|
|
self.seed: int = 0
|
|
|
|
@staticmethod
|
|
def _parse_metrics_config(config: Any) -> _ValidationMetricsConfig:
|
|
if config is None or config is False:
|
|
return _ValidationMetricsConfig(enabled=False)
|
|
if config is True:
|
|
return _ValidationMetricsConfig(
|
|
enabled=True,
|
|
names=list(DEFAULT_VALIDATION_VBENCH_METRICS),
|
|
)
|
|
if isinstance(config, str):
|
|
return _ValidationMetricsConfig(
|
|
enabled=True,
|
|
names=[config],
|
|
)
|
|
if isinstance(config, list | tuple):
|
|
return _ValidationMetricsConfig(
|
|
enabled=bool(config),
|
|
names=[str(name) for name in config],
|
|
)
|
|
if not isinstance(config, dict):
|
|
raise TypeError("callbacks.validation.metrics must be a bool, string, list, or mapping")
|
|
|
|
enabled = bool(config.get("enabled", True))
|
|
raw_names = config.get("names", config.get("metrics", None))
|
|
if raw_names is None:
|
|
names = list(DEFAULT_VALIDATION_VBENCH_METRICS)
|
|
elif isinstance(raw_names, str):
|
|
names = [raw_names]
|
|
else:
|
|
names = [str(name) for name in raw_names]
|
|
|
|
return _ValidationMetricsConfig(
|
|
enabled=enabled and bool(names),
|
|
names=names,
|
|
device=str(config.get("device", "cuda")),
|
|
calibration_path=(str(config["calibration_path"]) if config.get("calibration_path") is not None else None),
|
|
mouse_pitch_sign=int(config.get("mouse_pitch_sign", 1)),
|
|
skip_missing_deps=bool(config.get("skip_missing_deps", True)),
|
|
strict=bool(config.get("strict", False)),
|
|
unload_after_validation=bool(config.get("unload_after_validation", True)),
|
|
loader_threads=int(config.get("loader_threads", 1)),
|
|
prefetch_factor=int(config.get("prefetch_factor", 2)),
|
|
log_prefix=str(config.get("log_prefix", "metrics/validation")),
|
|
)
|
|
|
|
@staticmethod
|
|
def _coerce_bool(value: Any) -> bool:
|
|
if isinstance(value, str):
|
|
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
return bool(value)
|
|
|
|
# ----------------------------------------------------------
|
|
# Callback hooks
|
|
# ----------------------------------------------------------
|
|
|
|
def _adopt_training_denoising_ladder(
|
|
self,
|
|
method: TrainingMethod,
|
|
) -> None:
|
|
"""Keep validation on the timestep ladder the method trains against.
|
|
|
|
``sampling_steps`` only sets ``num_inference_steps``, which the
|
|
scheduler turns into an N-point sigma grid -- N-1 forwards on its own
|
|
spacing, not the trained ladder. Validation reaches the trained
|
|
operating point only when ``sampling_timesteps`` repeats
|
|
``method.dmd_denoising_steps`` exactly, so inherit it by default and
|
|
refuse to run when the two disagree.
|
|
"""
|
|
method_config = getattr(method, "method_config", None)
|
|
if not isinstance(method_config, dict):
|
|
return
|
|
raw = method_config.get("dmd_denoising_steps")
|
|
if not isinstance(raw, list) or not raw:
|
|
return
|
|
trained = [int(s) for s in raw]
|
|
|
|
if self.sampling_timesteps is None:
|
|
self.sampling_timesteps = trained
|
|
logger.info(
|
|
"validation: inheriting the trained denoising ladder %s "
|
|
"(%d forwards)",
|
|
trained,
|
|
len(trained),
|
|
)
|
|
return
|
|
if self.sampling_timesteps != trained:
|
|
raise ValueError("callbacks.validation.sampling_timesteps "
|
|
f"{self.sampling_timesteps} disagrees with the trained ladder "
|
|
f"{trained} (method.dmd_denoising_steps). Validation would "
|
|
"sample off the operating point the student is trained for. "
|
|
"Drop the override to inherit the ladder, or align the two.")
|
|
|
|
def on_train_start(
|
|
self,
|
|
method: TrainingMethod,
|
|
iteration: int = 0,
|
|
) -> None:
|
|
self.method = method
|
|
tc = self.training_config
|
|
self._adopt_training_denoising_ladder(method)
|
|
|
|
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:
|
|
"""Run the optional step-zero baseline and each scheduled validation event."""
|
|
if self.every_steps <= 0:
|
|
return
|
|
# Step zero measures the checkpoint before the first optimizer update.
|
|
if iteration == 0 and not self.run_at_start:
|
|
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
|
|
try:
|
|
with self._validation_memory_context(
|
|
method,
|
|
validation_transformer=transformer,
|
|
):
|
|
# Look for an EMA callback to temporarily swap
|
|
# EMA weights during validation.
|
|
ema_cb = self._find_ema_callback()
|
|
ctx = ema_cb.ema_context(transformer) if ema_cb is not None else contextlib.nullcontext(transformer)
|
|
with ctx as t, self._attn_qat_infer_context(t):
|
|
self._run_validation_inner(
|
|
method,
|
|
step,
|
|
t,
|
|
)
|
|
finally:
|
|
if self.unload_pipeline_after_validation:
|
|
self._clear_pipeline_cache()
|
|
|
|
@contextlib.contextmanager
|
|
def _attn_qat_infer_context(self, transformer: torch.nn.Module):
|
|
if not self.attn_qat_infer:
|
|
yield
|
|
return
|
|
|
|
from fastvideo.attention.backends.attn_qat_infer import (AttnQatInferImpl, is_attn_qat_infer_available)
|
|
from fastvideo.attention.backends.attn_qat_train import (
|
|
AttnQatTrainImpl, )
|
|
from fastvideo.platforms import AttentionBackendEnum
|
|
|
|
layers = [
|
|
module for module in transformer.modules()
|
|
if isinstance(getattr(module, "attn_impl", None), AttnQatTrainImpl)
|
|
]
|
|
if not layers:
|
|
raise RuntimeError("attn_qat_infer validation requested, but the transformer has no ATTN_QAT_TRAIN layers")
|
|
if not is_attn_qat_infer_available():
|
|
from fastvideo.attention.backends.attn_qat_infer import (
|
|
attn_qat_infer_receipt, )
|
|
raise RuntimeError("attn_qat_infer validation requested but no ATTN_QAT_INFER kernel serves "
|
|
f"this device ({attn_qat_infer_receipt()}). Set "
|
|
"callbacks.validation.attn_qat_infer=false to validate with ATTN_QAT_TRAIN.")
|
|
|
|
previous = [(layer, layer.attn_impl, layer.backend) for layer in layers]
|
|
try:
|
|
for layer, impl, _ in previous:
|
|
layer.attn_impl = AttnQatInferImpl(
|
|
num_heads=layer.num_heads,
|
|
head_size=layer.head_size,
|
|
num_kv_heads=layer.num_kv_heads,
|
|
causal=impl.causal,
|
|
softmax_scale=layer.softmax_scale,
|
|
)
|
|
layer.backend = AttentionBackendEnum.ATTN_QAT_INFER
|
|
logger.info("Enabled ATTN_QAT_INFER for %d validation attention layers.", len(layers))
|
|
yield
|
|
finally:
|
|
for layer, impl, backend in previous:
|
|
layer.attn_impl = impl
|
|
layer.backend = backend
|
|
|
|
@contextlib.contextmanager
|
|
def _validation_memory_context(
|
|
self,
|
|
method: TrainingMethod,
|
|
*,
|
|
validation_transformer: torch.nn.Module,
|
|
):
|
|
if not self.offload_training_state:
|
|
yield
|
|
return
|
|
|
|
optimizer_tensor_records: list[tuple[Any, Any, torch.device]] = []
|
|
module_records: list[tuple[str, torch.nn.Module, torch.device]] = []
|
|
try:
|
|
self._offload_optimizer_states_to_cpu(
|
|
method,
|
|
optimizer_tensor_records,
|
|
)
|
|
self._offload_inactive_role_modules_to_cpu(
|
|
method,
|
|
validation_transformer=validation_transformer,
|
|
module_records=module_records,
|
|
)
|
|
self._empty_cuda_cache()
|
|
yield
|
|
finally:
|
|
self._restore_inactive_role_modules(module_records)
|
|
self._restore_optimizer_states(optimizer_tensor_records)
|
|
self._empty_cuda_cache()
|
|
|
|
def _offload_optimizer_states_to_cpu(
|
|
self,
|
|
method: TrainingMethod,
|
|
records: list[tuple[Any, Any, torch.device]],
|
|
) -> None:
|
|
optimizers = getattr(method, "_optimizer_dict", {})
|
|
if not optimizers:
|
|
return
|
|
moved = 0
|
|
for optimizer in optimizers.values():
|
|
state = getattr(optimizer, "state", None)
|
|
if not isinstance(state, dict):
|
|
continue
|
|
for param_state in state.values():
|
|
moved += self._offload_tensor_container_to_cpu(
|
|
param_state,
|
|
records,
|
|
)
|
|
if moved:
|
|
logger.info(
|
|
"Offloaded %d optimizer state tensors to CPU for validation.",
|
|
moved,
|
|
)
|
|
|
|
def _offload_tensor_container_to_cpu(
|
|
self,
|
|
obj: Any,
|
|
records: list[tuple[Any, Any, torch.device]],
|
|
) -> int:
|
|
moved = 0
|
|
if isinstance(obj, dict):
|
|
for key, value in list(obj.items()):
|
|
if torch.is_tensor(value) and value.device.type == "cuda":
|
|
records.append((obj, key, value.device))
|
|
obj[key] = value.detach().cpu()
|
|
moved += 1
|
|
else:
|
|
moved += self._offload_tensor_container_to_cpu(value, records)
|
|
return moved
|
|
if isinstance(obj, list):
|
|
for idx, value in enumerate(list(obj)):
|
|
if torch.is_tensor(value) and value.device.type == "cuda":
|
|
records.append((obj, idx, value.device))
|
|
obj[idx] = value.detach().cpu()
|
|
moved += 1
|
|
else:
|
|
moved += self._offload_tensor_container_to_cpu(value, records)
|
|
return moved
|
|
|
|
def _restore_optimizer_states(
|
|
self,
|
|
records: list[tuple[Any, Any, torch.device]],
|
|
) -> None:
|
|
for container, key, device in reversed(records):
|
|
value = container[key]
|
|
if torch.is_tensor(value):
|
|
container[key] = value.to(device=device)
|
|
if records:
|
|
logger.info(
|
|
"Restored %d optimizer state tensors after validation.",
|
|
len(records),
|
|
)
|
|
|
|
def _offload_inactive_role_modules_to_cpu(
|
|
self,
|
|
method: TrainingMethod,
|
|
*,
|
|
validation_transformer: torch.nn.Module,
|
|
module_records: list[tuple[str, torch.nn.Module, torch.device]],
|
|
) -> None:
|
|
role_models = getattr(method, "_role_models", {})
|
|
if not isinstance(role_models, dict):
|
|
return
|
|
|
|
for role, model in role_models.items():
|
|
module = getattr(model, "transformer", None)
|
|
if not isinstance(module, torch.nn.Module):
|
|
continue
|
|
if module is validation_transformer:
|
|
continue
|
|
device = self._first_cuda_tensor_device(module)
|
|
if device is None:
|
|
continue
|
|
try:
|
|
module.to("cpu")
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Could not offload role %r transformer to CPU before validation: %s",
|
|
role,
|
|
exc,
|
|
)
|
|
continue
|
|
module_records.append((str(role), module, device))
|
|
logger.info(
|
|
"Offloaded role %r transformer from %s to CPU for validation.",
|
|
role,
|
|
device,
|
|
)
|
|
|
|
def _restore_inactive_role_modules(
|
|
self,
|
|
module_records: list[tuple[str, torch.nn.Module, torch.device]],
|
|
) -> None:
|
|
for role, module, device in reversed(module_records):
|
|
module.to(device)
|
|
logger.info(
|
|
"Restored role %r transformer to %s after validation.",
|
|
role,
|
|
device,
|
|
)
|
|
|
|
@staticmethod
|
|
def _first_cuda_tensor_device(module: torch.nn.Module) -> torch.device | None:
|
|
for tensor in list(module.parameters(recurse=True)) + list(module.buffers(recurse=True)):
|
|
device = getattr(tensor, "device", None)
|
|
if isinstance(device, torch.device) and device.type == "cuda":
|
|
return device
|
|
return None
|
|
|
|
def _clear_pipeline_cache(self) -> None:
|
|
self._pipeline = None
|
|
self._pipeline_key = None
|
|
self._empty_cuda_cache()
|
|
|
|
@staticmethod
|
|
def _empty_cuda_cache() -> None:
|
|
if torch.cuda.is_available():
|
|
torch.cuda.empty_cache()
|
|
|
|
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:
|
|
"""Generate one event and gather sequence-parallel group outputs for logging.
|
|
|
|
Each sequence-parallel group leader saves its local media. Global rank
|
|
zero gathers the leaders' paths and statistics into one tracker event.
|
|
"""
|
|
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)
|
|
sp = self._get_sampling_param()
|
|
|
|
for num_inference_steps in self.sampling_steps:
|
|
validation_started_at = time.perf_counter()
|
|
result = self._run_validation_for_steps(
|
|
num_inference_steps,
|
|
transformer=transformer,
|
|
)
|
|
|
|
# Every rank participates in sequence-parallel inference, but
|
|
# only the group leader retains decoded media for saving.
|
|
if self.rank_in_sp_group != 0:
|
|
continue
|
|
|
|
os.makedirs(
|
|
output_dir,
|
|
exist_ok=True,
|
|
)
|
|
local_videos = self._save_validation_videos(
|
|
result.videos,
|
|
output_dir=output_dir,
|
|
step=step,
|
|
num_inference_steps=num_inference_steps,
|
|
fps=sp.fps,
|
|
audio_waveforms=result.audio_waveforms,
|
|
audio_sample_rates=result.audio_sample_rates,
|
|
)
|
|
local_overlay_videos = self._save_validation_videos(
|
|
result.overlay_videos,
|
|
output_dir=output_dir,
|
|
step=step,
|
|
num_inference_steps=num_inference_steps,
|
|
fps=sp.fps,
|
|
suffix="_overlay",
|
|
)
|
|
local_video_filenames = local_videos.filenames
|
|
local_overlay_video_filenames = local_overlay_videos.filenames
|
|
local_captions = self._select_by_indices(
|
|
result.captions,
|
|
local_videos.indices,
|
|
)
|
|
local_overlay_captions = self._select_by_indices(
|
|
result.overlay_captions,
|
|
local_overlay_videos.indices,
|
|
)
|
|
local_ref_videos = self._select_by_indices(
|
|
result.ref_videos,
|
|
local_videos.indices,
|
|
)
|
|
local_actions = self._select_by_indices(
|
|
result.actions,
|
|
local_videos.indices,
|
|
)
|
|
local_mouse_pitch_signs = self._select_by_indices(
|
|
result.mouse_pitch_signs,
|
|
local_videos.indices,
|
|
)
|
|
local_metric_stats = self._evaluate_validation_metrics(
|
|
video_filenames=local_video_filenames,
|
|
captions=local_captions,
|
|
ref_videos=local_ref_videos,
|
|
actions=local_actions,
|
|
mouse_pitch_signs=local_mouse_pitch_signs,
|
|
fps=sp.fps,
|
|
output_dir=output_dir,
|
|
step=step,
|
|
num_inference_steps=num_inference_steps,
|
|
)
|
|
|
|
if self.global_rank == 0:
|
|
all_video_filenames = list(local_video_filenames)
|
|
all_overlay_video_filenames = list(local_overlay_video_filenames)
|
|
all_captions = list(local_captions)
|
|
all_overlay_captions = list(local_overlay_captions)
|
|
all_audio_video_count = local_videos.audio_video_count
|
|
all_metric_stats = local_metric_stats
|
|
for sp_idx in range(1, num_sp_groups):
|
|
# Sequence-parallel group leaders occupy the first
|
|
# global rank in each contiguous group.
|
|
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))
|
|
recv_ov = (self.world_group.recv_object(src=src))
|
|
recv_oc = (self.world_group.recv_object(src=src))
|
|
recv_m = (self.world_group.recv_object(src=src))
|
|
recv_audio_video_count = (self.world_group.recv_object(src=src))
|
|
all_video_filenames.extend(recv_v)
|
|
all_overlay_video_filenames.extend(recv_ov)
|
|
all_captions.extend(recv_c)
|
|
all_overlay_captions.extend(recv_oc)
|
|
all_audio_video_count += int(recv_audio_video_count)
|
|
self._merge_metric_stats(
|
|
all_metric_stats,
|
|
recv_m,
|
|
)
|
|
|
|
self._log_validation_metrics(
|
|
all_metric_stats,
|
|
step=step,
|
|
)
|
|
# Media and completion counts share one tracker event so
|
|
# artifacts and verification data remain aligned.
|
|
self._log_validation_video_artifacts(
|
|
all_video_filenames,
|
|
all_captions,
|
|
key=f"validation_videos_{num_inference_steps}_steps",
|
|
step=step,
|
|
fps=sp.fps,
|
|
scalar_metrics={
|
|
f"validation/{num_inference_steps}_steps_video_count":
|
|
float(len(all_video_filenames)),
|
|
f"validation/{num_inference_steps}_steps_duration_sec":
|
|
(time.perf_counter() - validation_started_at),
|
|
f"validation/{num_inference_steps}_steps_audio_video_count":
|
|
float(all_audio_video_count),
|
|
},
|
|
)
|
|
if all_overlay_video_filenames:
|
|
self._log_validation_video_artifacts(
|
|
all_overlay_video_filenames,
|
|
all_overlay_captions,
|
|
key=(f"validation_videos_{num_inference_steps}"
|
|
f"_steps_overlay"),
|
|
step=step,
|
|
fps=sp.fps,
|
|
)
|
|
else:
|
|
self.world_group.send_object(
|
|
local_video_filenames,
|
|
dst=0,
|
|
)
|
|
self.world_group.send_object(
|
|
local_captions,
|
|
dst=0,
|
|
)
|
|
self.world_group.send_object(
|
|
local_overlay_video_filenames,
|
|
dst=0,
|
|
)
|
|
self.world_group.send_object(
|
|
local_overlay_captions,
|
|
dst=0,
|
|
)
|
|
self.world_group.send_object(
|
|
local_metric_stats,
|
|
dst=0,
|
|
)
|
|
self.world_group.send_object(
|
|
local_videos.audio_video_count,
|
|
dst=0,
|
|
)
|
|
finally:
|
|
if was_training:
|
|
transformer.train()
|
|
self._release_metric_evaluator()
|
|
|
|
def _save_validation_videos(
|
|
self,
|
|
videos: list[list[np.ndarray]],
|
|
*,
|
|
output_dir: str,
|
|
step: int,
|
|
num_inference_steps: int,
|
|
fps: int,
|
|
suffix: str = "",
|
|
audio_waveforms: list[torch.Tensor | np.ndarray | None] | None = None,
|
|
audio_sample_rates: list[int | None] | None = None,
|
|
) -> _SavedValidationVideos:
|
|
"""Save aligned validation media and skip artifacts that fail encoding.
|
|
|
|
Audio and sample-rate metadata must pass the alignment checks before
|
|
encoding. Media writer failures are logged and omitted so validation
|
|
artifact creation does not interrupt the training loop.
|
|
"""
|
|
if (audio_waveforms is None) != (audio_sample_rates is None):
|
|
raise ValueError("Validation audio waveforms and sample rates must be provided together.")
|
|
if audio_waveforms is not None and len(audio_waveforms) != len(videos):
|
|
raise ValueError("Validation audio waveform count must match the video count.")
|
|
if audio_sample_rates is not None and len(audio_sample_rates) != len(videos):
|
|
raise ValueError("Validation audio sample-rate count must match the video count.")
|
|
|
|
saved = _SavedValidationVideos()
|
|
for i, video in enumerate(videos):
|
|
audio = audio_waveforms[i] if audio_waveforms is not None else None
|
|
audio_sample_rate = audio_sample_rates[i] if audio_sample_rates is not None else None
|
|
if (audio is None) != (audio_sample_rate is None):
|
|
raise ValueError("Each validation waveform must have one aligned sample rate.")
|
|
fname = os.path.join(
|
|
output_dir,
|
|
f"validation_step_{step}"
|
|
f"_inference_steps_{num_inference_steps}"
|
|
f"_rank_{self.global_rank}"
|
|
f"_video_{i}{suffix}.mp4",
|
|
)
|
|
try:
|
|
write_validation_mp4(
|
|
fname,
|
|
video,
|
|
fps=fps,
|
|
audio=audio,
|
|
audio_sample_rate=audio_sample_rate,
|
|
)
|
|
except Exception as exc:
|
|
# Validation media is diagnostic output, so one failed write
|
|
# must not terminate training or prevent later artifact writes.
|
|
logger.exception(
|
|
"Failed to save validation media %s on rank %s; skipping artifact: %s",
|
|
fname,
|
|
self.global_rank,
|
|
exc,
|
|
)
|
|
with contextlib.suppress(OSError):
|
|
os.remove(fname)
|
|
continue
|
|
saved.filenames.append(fname)
|
|
saved.indices.append(i)
|
|
if audio is not None:
|
|
# The media writer verifies requested streams before returning.
|
|
saved.audio_video_count += 1
|
|
return saved
|
|
|
|
@staticmethod
|
|
def _select_by_indices(
|
|
values: list[Any],
|
|
indices: list[int],
|
|
) -> list[Any]:
|
|
return [values[i] for i in indices if i < len(values)]
|
|
|
|
def _log_validation_video_artifacts(
|
|
self,
|
|
video_filenames: list[str],
|
|
captions: list[str],
|
|
*,
|
|
key: str,
|
|
step: int,
|
|
fps: int,
|
|
scalar_metrics: dict[str, float] | None = None,
|
|
) -> None:
|
|
"""Log validation media and its scalar verification data at one step."""
|
|
video_logs = []
|
|
for fname, cap in zip(
|
|
video_filenames,
|
|
captions,
|
|
strict=True,
|
|
):
|
|
art = self.tracker.video(
|
|
fname,
|
|
caption=cap,
|
|
fps=fps,
|
|
)
|
|
if art is not None:
|
|
video_logs.append(art)
|
|
if video_logs:
|
|
artifacts: dict[str, Any] = {key: video_logs}
|
|
if scalar_metrics:
|
|
artifacts.update(scalar_metrics)
|
|
self.tracker.log_artifacts(
|
|
artifacts,
|
|
step,
|
|
)
|
|
|
|
# ----------------------------------------------------------
|
|
# Metric evaluation
|
|
# ----------------------------------------------------------
|
|
|
|
def _metric_device(self) -> str:
|
|
device = self.metrics_config.device
|
|
if device == "cuda" and torch.cuda.is_available():
|
|
local_rank = int(getattr(self.world_group, "local_rank", 0))
|
|
return f"cuda:{local_rank}"
|
|
return device
|
|
|
|
def _get_metric_evaluator(self) -> Any:
|
|
if self._metric_evaluator is not None:
|
|
return self._metric_evaluator
|
|
from fastvideo.eval import Evaluator
|
|
|
|
cfg = self.metrics_config
|
|
self._metric_evaluator = Evaluator(
|
|
metrics=cfg.names,
|
|
device=self._metric_device(),
|
|
num_gpus=1,
|
|
loader_threads=cfg.loader_threads,
|
|
prefetch_factor=cfg.prefetch_factor,
|
|
pre_upload=False,
|
|
skip_missing_deps=cfg.skip_missing_deps,
|
|
)
|
|
logger.info(
|
|
"Initialized validation metrics: %s",
|
|
", ".join(self._metric_evaluator.metric_names),
|
|
)
|
|
return self._metric_evaluator
|
|
|
|
def _release_metric_evaluator(self) -> None:
|
|
if self._metric_evaluator is None:
|
|
return
|
|
if self.metrics_config.unload_after_validation:
|
|
self._metric_evaluator.unload()
|
|
self._metric_evaluator = None
|
|
gc.collect()
|
|
if torch.cuda.is_available():
|
|
torch.cuda.empty_cache()
|
|
|
|
def _evaluate_validation_metrics(
|
|
self,
|
|
*,
|
|
video_filenames: list[str],
|
|
captions: list[str],
|
|
ref_videos: list[str | None],
|
|
actions: list[dict[str, Any] | None],
|
|
mouse_pitch_signs: list[int | None],
|
|
fps: int,
|
|
output_dir: str,
|
|
step: int,
|
|
num_inference_steps: int,
|
|
) -> _ValidationMetricStats:
|
|
stats = _ValidationMetricStats()
|
|
if not self.metrics_config.enabled or not video_filenames:
|
|
return stats
|
|
|
|
try:
|
|
evaluator = self._get_metric_evaluator()
|
|
from fastvideo.eval import samples_from
|
|
|
|
samples = samples_from(
|
|
video=video_filenames,
|
|
reference=self._available_paths(ref_videos),
|
|
text_prompts=captions,
|
|
fps=float(fps),
|
|
extras=self._validation_metric_extras(
|
|
video_filenames=video_filenames,
|
|
actions=actions,
|
|
mouse_pitch_signs=mouse_pitch_signs,
|
|
),
|
|
)
|
|
results = evaluator.evaluate(samples=samples)
|
|
for filename, metric_results in zip(
|
|
video_filenames,
|
|
results,
|
|
strict=True,
|
|
):
|
|
row: dict[str, Any] = {"path": filename}
|
|
self._accumulate_metric_results(
|
|
stats,
|
|
row,
|
|
metric_results,
|
|
)
|
|
stats.per_video.append(row)
|
|
for metric_name, metric_result in results.corpus.items():
|
|
row = {"path": "<corpus>"}
|
|
self._accumulate_metric_results(
|
|
stats,
|
|
row,
|
|
{metric_name: metric_result},
|
|
)
|
|
stats.per_video.append(row)
|
|
except Exception as exc:
|
|
message = ("Validation metric evaluation failed on rank "
|
|
f"{self.global_rank}: {exc}")
|
|
logger.exception(message)
|
|
stats.errors.append(message)
|
|
if self.metrics_config.strict:
|
|
raise
|
|
finally:
|
|
if self._metric_evaluator is not None:
|
|
self._metric_evaluator.release_cuda_memory()
|
|
|
|
self._write_metric_summary(
|
|
stats,
|
|
output_dir=output_dir,
|
|
step=step,
|
|
num_inference_steps=num_inference_steps,
|
|
)
|
|
return stats
|
|
|
|
@staticmethod
|
|
def _accumulate_metric_results(
|
|
stats: _ValidationMetricStats,
|
|
row: dict[str, Any],
|
|
metric_results: dict[str, Any],
|
|
) -> None:
|
|
for metric_name, metric_result in metric_results.items():
|
|
details = getattr(metric_result, "details", {}) or {}
|
|
if metric_name == SYNTHETIC_OPTICAL_FLOW_METRIC:
|
|
if not isinstance(details, dict):
|
|
continue
|
|
for detail_name in SYNTHETIC_OPTICAL_FLOW_LOG_KEYS:
|
|
key = f"{metric_name}.{detail_name}"
|
|
ValidationCallback._accumulate_scalar(
|
|
stats,
|
|
row,
|
|
key,
|
|
details.get(detail_name),
|
|
)
|
|
continue
|
|
|
|
score = getattr(metric_result, "score", None)
|
|
ValidationCallback._accumulate_scalar(
|
|
stats,
|
|
row,
|
|
metric_name,
|
|
score,
|
|
)
|
|
if not isinstance(details, dict):
|
|
continue
|
|
for detail_name, value in details.items():
|
|
key = f"{metric_name}.{detail_name}"
|
|
ValidationCallback._accumulate_scalar(
|
|
stats,
|
|
row,
|
|
key,
|
|
value,
|
|
)
|
|
|
|
@staticmethod
|
|
def _accumulate_scalar(
|
|
stats: _ValidationMetricStats,
|
|
row: dict[str, Any],
|
|
key: str,
|
|
value: Any,
|
|
) -> None:
|
|
if not isinstance(value, float | int | np.floating | np.integer):
|
|
return
|
|
value_float = float(value)
|
|
if not np.isfinite(value_float):
|
|
return
|
|
row[key] = value_float
|
|
stats.sums[key] = stats.sums.get(key, 0.0) + value_float
|
|
stats.counts[key] = stats.counts.get(key, 0.0) + 1.0
|
|
|
|
@staticmethod
|
|
def _merge_metric_stats(
|
|
dst: _ValidationMetricStats,
|
|
src: _ValidationMetricStats,
|
|
) -> None:
|
|
for key, value in src.sums.items():
|
|
dst.sums[key] = dst.sums.get(key, 0.0) + float(value)
|
|
for key, value in src.counts.items():
|
|
dst.counts[key] = dst.counts.get(key, 0.0) + float(value)
|
|
dst.per_video.extend(src.per_video)
|
|
dst.errors.extend(src.errors)
|
|
|
|
def _log_validation_metrics(
|
|
self,
|
|
stats: _ValidationMetricStats,
|
|
*,
|
|
step: int,
|
|
) -> None:
|
|
if stats.errors:
|
|
message = "Validation metric evaluation failed:\n" + "\n".join(stats.errors)
|
|
if self.metrics_config.strict:
|
|
raise RuntimeError(message)
|
|
logger.warning(message)
|
|
|
|
logs: dict[str, float] = {}
|
|
for key, metric_sum in stats.sums.items():
|
|
metric_count = stats.counts.get(key, 0.0)
|
|
if metric_count <= 0:
|
|
continue
|
|
value = float(metric_sum / metric_count)
|
|
if not np.isfinite(value):
|
|
continue
|
|
logs[self._metric_log_name(key)] = value
|
|
if logs:
|
|
self.tracker.log(
|
|
logs,
|
|
step,
|
|
)
|
|
|
|
def _metric_log_name(
|
|
self,
|
|
key: str,
|
|
) -> str:
|
|
return f"{self.metrics_config.log_prefix}/{key.replace('.', '/')}"
|
|
|
|
def _write_metric_summary(
|
|
self,
|
|
stats: _ValidationMetricStats,
|
|
*,
|
|
output_dir: str,
|
|
step: int,
|
|
num_inference_steps: int,
|
|
) -> None:
|
|
if not self.metrics_config.enabled:
|
|
return
|
|
summary_dir = os.path.join(
|
|
output_dir,
|
|
"eval",
|
|
f"step_{step}",
|
|
)
|
|
os.makedirs(
|
|
summary_dir,
|
|
exist_ok=True,
|
|
)
|
|
metrics = {
|
|
key: float(stats.sums[key] / stats.counts[key])
|
|
for key in sorted(stats.sums) if stats.counts.get(key, 0.0) > 0
|
|
}
|
|
payload = {
|
|
"step": int(step),
|
|
"num_inference_steps": int(num_inference_steps),
|
|
"rank": int(self.global_rank),
|
|
"metrics": metrics,
|
|
"per_video": stats.per_video,
|
|
"errors": stats.errors,
|
|
}
|
|
path = os.path.join(
|
|
summary_dir,
|
|
f"inference_steps_{num_inference_steps}_rank_{self.global_rank}.json",
|
|
)
|
|
with open(
|
|
path,
|
|
"w",
|
|
encoding="utf-8",
|
|
) as f:
|
|
json.dump(
|
|
payload,
|
|
f,
|
|
indent=2,
|
|
)
|
|
|
|
def _validation_metric_extras(
|
|
self,
|
|
*,
|
|
video_filenames: list[str],
|
|
actions: list[dict[str, Any] | None],
|
|
mouse_pitch_signs: list[int | None],
|
|
) -> list[dict[str, Any]]:
|
|
extras: list[dict[str, Any]] = []
|
|
for i, _filename in enumerate(video_filenames):
|
|
extra: dict[str, Any] = {}
|
|
action = actions[i] if i < len(actions) else None
|
|
if action is not None:
|
|
extra["actions"] = action
|
|
if self.metrics_config.calibration_path is not None:
|
|
extra["calibration"] = self.metrics_config.calibration_path
|
|
mouse_pitch_sign = mouse_pitch_signs[i] if i < len(mouse_pitch_signs) else None
|
|
extra["mouse_pitch_sign"] = int(mouse_pitch_sign or self.metrics_config.mouse_pitch_sign)
|
|
extras.append(extra)
|
|
return extras
|
|
|
|
@staticmethod
|
|
def _available_paths(paths: list[str | None]) -> list[str] | None:
|
|
if not paths or any(path is None for path in paths):
|
|
return None
|
|
return [str(path) for path in paths]
|
|
|
|
@staticmethod
|
|
def _validation_actions(validation_batch: dict[str, Any]) -> dict[str, Any] | None:
|
|
keyboard = validation_batch.get("keyboard_cond")
|
|
mouse = validation_batch.get("mouse_cond")
|
|
if keyboard is not None and mouse is not None:
|
|
return {
|
|
"keyboard": np.asarray(keyboard),
|
|
"mouse": np.asarray(mouse),
|
|
}
|
|
|
|
action_path = validation_batch.get("action_path")
|
|
if not isinstance(action_path, str) or not os.path.isfile(action_path):
|
|
return None
|
|
try:
|
|
actions_obj = np.load(action_path, allow_pickle=True)
|
|
if isinstance(actions_obj, np.ndarray) and actions_obj.dtype == object:
|
|
actions_obj = actions_obj.item()
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Failed to load validation action file %s for metrics: %s",
|
|
action_path,
|
|
exc,
|
|
)
|
|
return None
|
|
if not isinstance(actions_obj, dict):
|
|
return None
|
|
keyboard = actions_obj.get("keyboard")
|
|
mouse = actions_obj.get("mouse")
|
|
if keyboard is None or mouse is None:
|
|
return None
|
|
return {
|
|
"keyboard": np.asarray(keyboard),
|
|
"mouse": np.asarray(mouse),
|
|
}
|
|
|
|
def _validation_mouse_pitch_sign(
|
|
self,
|
|
validation_batch: dict[str, Any],
|
|
) -> int:
|
|
if validation_batch.get("mouse_pitch_sign") is not None:
|
|
return int(validation_batch["mouse_pitch_sign"])
|
|
flipped = validation_batch.get("mouse_pitch_flipped")
|
|
if isinstance(flipped, str):
|
|
flipped = flipped.lower() in {"1", "true", "yes", "y"}
|
|
if bool(flipped):
|
|
return -1
|
|
return int(self.metrics_config.mouse_pitch_sign)
|
|
|
|
# ----------------------------------------------------------
|
|
# Pipeline management
|
|
# ----------------------------------------------------------
|
|
|
|
def _get_sampling_param(self) -> SamplingParam:
|
|
if self._sampling_param is None:
|
|
self._sampling_param = (SamplingParam.from_pretrained(self._pipeline_model_path()))
|
|
return self._sampling_param
|
|
|
|
def _pipeline_model_path(self) -> str:
|
|
tc = self.training_config
|
|
pipeline_config = getattr(tc, "pipeline_config", None)
|
|
model_path = getattr(pipeline_config, "_fastvideo_train_model_path", None)
|
|
return str(model_path or tc.model_path)
|
|
|
|
def _validation_pipeline_config(self, transformer: torch.nn.Module) -> Any:
|
|
tc = self.training_config
|
|
pipeline_config = deepcopy(tc.pipeline_config)
|
|
self._sync_runtime_dit_arch_config(
|
|
pipeline_config,
|
|
transformer,
|
|
)
|
|
return pipeline_config
|
|
|
|
@staticmethod
|
|
def _keep_loaded_encoder_widths(
|
|
validation_config: Any,
|
|
loaded_config: Any,
|
|
) -> None:
|
|
"""Carry the loader-populated encoder widths onto ``validation_config``.
|
|
|
|
Validation reaches the stages through two pipeline configs that never
|
|
pass through ``ModelConfig.update_model_arch``: the deep copy
|
|
``_validation_pipeline_config`` makes of the training-side config, and
|
|
``tc.pipeline_config`` itself, which ``make_inference_args`` hands to
|
|
``pipeline.forward`` by reference. Both still hold the encoder dataclass
|
|
defaults for whatever the checkpoint would have supplied.
|
|
|
|
Stages read ``hidden_size`` only when they have to synthesise an
|
|
embedding instead of measuring one: HunyuanVideo 1.5 sizes its
|
|
zero-length ByT5 placeholder from it, so the generic ``T5ArchConfig``
|
|
default of 512 collides with the checkpoint's real width of 1472.
|
|
|
|
Only ``hidden_size`` is copied. Training owns the rest: model plugins
|
|
set ``text_len`` from ``text_encoder_max_lengths`` to size the parquet
|
|
text padding, and ``tokenizer_kwargs`` carry run-specific settings, so
|
|
both keep their training values. A falsy width means the loader never
|
|
populated that encoder, so there is nothing to carry over.
|
|
"""
|
|
loaded_encoders = getattr(loaded_config, "text_encoder_configs", None)
|
|
validation_encoders = getattr(
|
|
validation_config,
|
|
"text_encoder_configs",
|
|
None,
|
|
)
|
|
if not loaded_encoders or not validation_encoders:
|
|
return
|
|
|
|
for validation_encoder, loaded_encoder in zip(
|
|
validation_encoders,
|
|
loaded_encoders,
|
|
strict=False,
|
|
):
|
|
hidden_size = getattr(
|
|
getattr(loaded_encoder, "arch_config", None),
|
|
"hidden_size",
|
|
None,
|
|
)
|
|
arch_config = getattr(validation_encoder, "arch_config", None)
|
|
if hidden_size and arch_config is not None:
|
|
arch_config.hidden_size = hidden_size
|
|
|
|
@staticmethod
|
|
def _sync_runtime_dit_arch_config(
|
|
pipeline_config: Any,
|
|
transformer: torch.nn.Module,
|
|
) -> None:
|
|
dit_config = getattr(
|
|
pipeline_config,
|
|
"dit_config",
|
|
None,
|
|
)
|
|
arch_config = getattr(
|
|
dit_config,
|
|
"arch_config",
|
|
None,
|
|
)
|
|
if arch_config is None:
|
|
return
|
|
|
|
for name in ("local_attn_size", "sink_size"):
|
|
if not hasattr(arch_config, name) or not hasattr(transformer, name):
|
|
continue
|
|
setattr(
|
|
arch_config,
|
|
name,
|
|
getattr(transformer, name),
|
|
)
|
|
|
|
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,
|
|
)
|
|
|
|
loaded_modules: dict[str, Any] = {"transformer": transformer}
|
|
# Distillation methods build the flow-match scheduler their few-step DMD
|
|
# sampler needs; inject it so the pipeline doesn't fall back to UniPC.
|
|
method_scheduler = getattr(self.method, "_sf_scheduler", None)
|
|
if method_scheduler is not None:
|
|
loaded_modules["scheduler"] = method_scheduler
|
|
|
|
kwargs: dict[str, Any] = {
|
|
"inference_mode": True,
|
|
"loaded_modules": loaded_modules,
|
|
"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": False,
|
|
"dit_layerwise_offload": False,
|
|
}
|
|
if flow_shift is not None:
|
|
kwargs["flow_shift"] = float(flow_shift)
|
|
kwargs.update(self.pipeline_kwargs)
|
|
|
|
# The pipeline class comes from a YAML target, so static analysis cannot
|
|
# infer the dynamically resolved ``from_pretrained`` class method.
|
|
self._pipeline = PipelineCls.from_pretrained( # type: ignore[attr-defined]
|
|
self._pipeline_model_path(),
|
|
**kwargs,
|
|
)
|
|
if tc.pipeline_config is not None:
|
|
loaded_config = self._pipeline.fastvideo_args.pipeline_config
|
|
validation_config = self._validation_pipeline_config(transformer)
|
|
self._keep_loaded_encoder_widths(
|
|
validation_config,
|
|
loaded_config,
|
|
)
|
|
self._pipeline.fastvideo_args.pipeline_config = validation_config
|
|
arch_config = self._pipeline.fastvideo_args.pipeline_config.dit_config.arch_config
|
|
logger.info(
|
|
"Validation pipeline runtime config: local_attn_size=%s sink_size=%s boundary_ratio=%s",
|
|
getattr(arch_config, "local_attn_size", None),
|
|
getattr(arch_config, "sink_size", None),
|
|
getattr(self._pipeline.fastvideo_args.pipeline_config.dit_config, "boundary_ratio", None),
|
|
)
|
|
|
|
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:
|
|
"""Build a pipeline batch with the recipe's output and conditioning policy."""
|
|
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
|
|
# Output multiplicity belongs in SamplingParam so pipeline stages
|
|
# allocate the same batch dimension that validation expects to log.
|
|
sampling_param.num_videos_per_prompt = self.num_videos_per_prompt
|
|
|
|
# SamplingParam is cached across records; clearing the path prevents a
|
|
# prior record's image or video from conditioning a later prompt.
|
|
sampling_param.image_path = None
|
|
if self.use_validation_media_conditioning:
|
|
# Image-to-video pipelines use an image or the first frame of a validation video.
|
|
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),
|
|
generator=self.validation_random_generator,
|
|
n_tokens=n_tokens,
|
|
eta=0.0,
|
|
VSA_sparsity=tc.vsa_sparsity,
|
|
timesteps=sampling_timesteps_tensor,
|
|
)
|
|
# shallow_asdict(sampling_param) copies list-typed fields by
|
|
# reference. sampling_param is cached and reused across every
|
|
# validation sample/step, so without this reset every ForwardBatch
|
|
# would share (and keep appending to) the same
|
|
# prompt_attention_mask/negative_attention_mask list forever --
|
|
# index [0] would then hold the *first-ever* validation sample's
|
|
# mask instead of the current one, mismatching prompt_embeds.
|
|
batch.prompt_attention_mask = []
|
|
batch.negative_attention_mask = []
|
|
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"]
|
|
|
|
self._attach_action_conditions(
|
|
batch,
|
|
validation_batch,
|
|
sampling_param.num_frames,
|
|
)
|
|
|
|
return batch
|
|
|
|
def _attach_action_conditions(
|
|
self,
|
|
batch: ForwardBatch,
|
|
validation_batch: dict[str, Any],
|
|
num_frames: int,
|
|
) -> None:
|
|
for name in ("keyboard_cond", "mouse_cond"):
|
|
value = validation_batch.get(name)
|
|
if value is None:
|
|
continue
|
|
array = np.asarray(value)
|
|
if array.size == 0:
|
|
continue
|
|
array = array[:num_frames]
|
|
tensor = torch.as_tensor(
|
|
array,
|
|
dtype=torch.bfloat16,
|
|
)
|
|
student = getattr(self.method, "student", None)
|
|
prepare_action = getattr(student, "prepare_validation_action_condition", None)
|
|
if prepare_action is not None:
|
|
tensor = prepare_action(
|
|
tensor,
|
|
name=name,
|
|
keyboard_value_scale=self.keyboard_value_scale,
|
|
)
|
|
tensor = tensor.unsqueeze(0)
|
|
setattr(
|
|
batch,
|
|
name,
|
|
tensor,
|
|
)
|
|
|
|
# ----------------------------------------------------------
|
|
# Validation loop
|
|
# ----------------------------------------------------------
|
|
|
|
def _run_validation_for_steps(
|
|
self,
|
|
num_inference_steps: int,
|
|
*,
|
|
transformer: torch.nn.Module,
|
|
) -> _ValidationStepResult:
|
|
"""Generate the prompts assigned to one sequence-parallel group.
|
|
|
|
``ValidationDataset`` pads the prompt count to the data-parallel degree
|
|
and assigns an equal prompt shard to each sequence-parallel group. All
|
|
ranks in a group execute each forward pass; the group leader retains
|
|
decoded media.
|
|
"""
|
|
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,
|
|
)
|
|
self._sync_runtime_dit_arch_config(
|
|
inference_args.pipeline_config,
|
|
transformer,
|
|
)
|
|
self._keep_loaded_encoder_widths(
|
|
inference_args.pipeline_config,
|
|
pipeline.fastvideo_args.pipeline_config,
|
|
)
|
|
|
|
# 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]] = []
|
|
audio_waveforms: list[torch.Tensor | np.ndarray | None] = []
|
|
audio_sample_rates: list[int | None] = []
|
|
overlay_videos: list[list[np.ndarray]] = []
|
|
captions: list[str] = []
|
|
overlay_captions: list[str] = []
|
|
ref_videos: list[str | None] = []
|
|
actions: list[dict[str, Any] | None] = []
|
|
mouse_pitch_signs: list[int | None] = []
|
|
|
|
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))
|
|
ref_video = validation_batch.get("ref_video")
|
|
action = self._validation_actions(validation_batch)
|
|
|
|
with torch.no_grad():
|
|
output_batch = pipeline.forward(
|
|
batch,
|
|
inference_args,
|
|
)
|
|
|
|
samples = output_batch.output.cpu()
|
|
if self.rank_in_sp_group != 0:
|
|
continue
|
|
|
|
# Append metadata only on the group leader so every list position
|
|
# describes the same decoded video throughout save and logging.
|
|
output_audio = output_batch.extra.get("audio")
|
|
output_audio_sample_rate = output_batch.extra.get("audio_sample_rate")
|
|
if (output_audio is None) != (output_audio_sample_rate is None):
|
|
raise ValueError("Validation pipeline outputs must provide audio and its sample rate together.")
|
|
if output_audio is not None and not isinstance(output_audio,
|
|
np.ndarray) and not torch.is_tensor(output_audio):
|
|
raise TypeError("Validation pipeline audio must be a torch.Tensor or numpy.ndarray; "
|
|
f"got {type(output_audio).__name__}.")
|
|
if torch.is_tensor(output_audio):
|
|
# The returned validation result stays on CPU so MP4 encoding
|
|
# does not retain a generation tensor on the GPU.
|
|
output_audio = output_audio.detach().cpu()
|
|
|
|
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(pixels_to_uint8(x).numpy())
|
|
videos.append(frames)
|
|
captions.append(batch.prompt)
|
|
audio_waveforms.append(output_audio)
|
|
audio_sample_rates.append(int(output_audio_sample_rate) if output_audio_sample_rate is not None else None)
|
|
ref_videos.append(ref_video if isinstance(ref_video, str) else None)
|
|
actions.append(action)
|
|
mouse_pitch_signs.append(self._validation_mouse_pitch_sign(validation_batch))
|
|
if self.overlay_actions:
|
|
overlay_frames = self._post_process_validation_frames(
|
|
frames,
|
|
action=action,
|
|
)
|
|
if overlay_frames is not None:
|
|
overlay_videos.append(overlay_frames)
|
|
overlay_captions.append(batch.prompt)
|
|
|
|
return _ValidationStepResult(
|
|
videos=videos,
|
|
captions=captions,
|
|
audio_waveforms=audio_waveforms,
|
|
audio_sample_rates=audio_sample_rates,
|
|
overlay_videos=overlay_videos,
|
|
overlay_captions=overlay_captions,
|
|
ref_videos=ref_videos,
|
|
actions=actions,
|
|
mouse_pitch_signs=mouse_pitch_signs,
|
|
)
|
|
|
|
def _post_process_validation_frames(
|
|
self,
|
|
frames: list[np.ndarray],
|
|
*,
|
|
action: dict[str, Any] | None,
|
|
) -> list[np.ndarray] | None:
|
|
student = getattr(self.method, "student", None)
|
|
post_process = getattr(student, "post_process_validation_frames", None)
|
|
if post_process is None:
|
|
return None
|
|
return post_process(
|
|
frames,
|
|
action=action,
|
|
)
|
|
|
|
# ----------------------------------------------------------
|
|
# 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)
|