299 lines
9.4 KiB
Python
299 lines
9.4 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import torch
|
|
import torch.distributed as dist
|
|
|
|
from fastvideo.configs.models.dits.wanvideo import (
|
|
WanVideoArchConfig,
|
|
WanVideoConfig,
|
|
)
|
|
from fastvideo.distributed import (
|
|
cleanup_dist_env_and_memory,
|
|
maybe_init_distributed_environment_and_model_parallel,
|
|
)
|
|
from fastvideo.forward_context import set_forward_context
|
|
from fastvideo.models.dits.wanvideo import WanTransformer3DModel
|
|
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
|
|
|
|
SP_WORLD_SIZE = 2
|
|
SEED = 2026
|
|
|
|
|
|
def _free_port() -> int:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
sock.bind(("127.0.0.1", 0))
|
|
return int(sock.getsockname()[1])
|
|
|
|
|
|
def _seed_everything(seed: int) -> None:
|
|
torch.manual_seed(seed)
|
|
torch.cuda.manual_seed_all(seed)
|
|
torch.backends.cuda.matmul.allow_tf32 = False
|
|
torch.backends.cudnn.allow_tf32 = False
|
|
|
|
|
|
def _build_tiny_wan_config() -> WanVideoConfig:
|
|
arch_config = WanVideoArchConfig(
|
|
num_attention_heads=4,
|
|
attention_head_dim=8,
|
|
in_channels=4,
|
|
out_channels=4,
|
|
text_dim=16,
|
|
freq_dim=16,
|
|
ffn_dim=64,
|
|
num_layers=2,
|
|
patch_size=(1, 2, 2),
|
|
rope_max_seq_len=64,
|
|
)
|
|
return WanVideoConfig(arch_config=arch_config)
|
|
|
|
|
|
def _build_hf_config(config: WanVideoConfig) -> dict[str, object]:
|
|
return {
|
|
"num_attention_heads": config.num_attention_heads,
|
|
"attention_head_dim": config.attention_head_dim,
|
|
"in_channels": config.in_channels,
|
|
"out_channels": config.out_channels,
|
|
"text_dim": config.text_dim,
|
|
"freq_dim": config.freq_dim,
|
|
"ffn_dim": config.ffn_dim,
|
|
"num_layers": config.num_layers,
|
|
"patch_size": config.patch_size,
|
|
"text_len": config.text_len,
|
|
"rope_max_seq_len": config.rope_max_seq_len,
|
|
}
|
|
|
|
|
|
def _build_inputs(
|
|
config: WanVideoConfig,
|
|
device: torch.device,
|
|
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
|
generator = torch.Generator(device="cpu")
|
|
generator.manual_seed(SEED + 1)
|
|
|
|
hidden_states = torch.randn(
|
|
1,
|
|
config.in_channels,
|
|
4,
|
|
8,
|
|
8,
|
|
generator=generator,
|
|
dtype=torch.float32,
|
|
).to(device)
|
|
encoder_hidden_states = torch.randn(
|
|
1,
|
|
8,
|
|
config.text_dim,
|
|
generator=generator,
|
|
dtype=torch.float32,
|
|
).to(device)
|
|
timestep = torch.tensor([10], device=device, dtype=torch.long)
|
|
return hidden_states, encoder_hidden_states, timestep
|
|
|
|
|
|
def _collect_named_parameter_grads(model: torch.nn.Module, ) -> dict[str, torch.Tensor]:
|
|
named_grads: dict[str, torch.Tensor] = {}
|
|
for name, param in model.named_parameters():
|
|
grad = param.grad
|
|
if grad is None:
|
|
grad = torch.zeros_like(param)
|
|
named_grads[name] = grad.detach().float().cpu()
|
|
return named_grads
|
|
|
|
|
|
def _initialize_model_parameters(model: torch.nn.Module) -> None:
|
|
# ReplicatedLinear parameters are allocated with torch.empty and need an
|
|
# explicit initialization in tests to avoid undefined values.
|
|
torch.manual_seed(SEED + 3)
|
|
torch.cuda.manual_seed_all(SEED + 3)
|
|
with torch.no_grad():
|
|
for name, param in model.named_parameters():
|
|
if param.ndim <= 1:
|
|
if name.endswith("weight") and "norm" in name:
|
|
param.fill_(1.0)
|
|
else:
|
|
param.zero_()
|
|
continue
|
|
torch.nn.init.xavier_uniform_(param)
|
|
|
|
|
|
def _assert_finite(name: str, tensor: torch.Tensor) -> None:
|
|
if torch.isfinite(tensor).all():
|
|
return
|
|
nan_count = int(torch.isnan(tensor).sum().item())
|
|
inf_count = int(torch.isinf(tensor).sum().item())
|
|
raise RuntimeError(f"{name} contains non-finite values (nan={nan_count}, inf={inf_count})")
|
|
|
|
|
|
def _run_worker(mode: str, output_path: Path) -> None:
|
|
if mode not in {"single", "sp"}:
|
|
raise ValueError(f"Unsupported mode: {mode}")
|
|
|
|
local_rank = int(os.environ.get("LOCAL_RANK", "0"))
|
|
rank = int(os.environ.get("RANK", "0"))
|
|
sp_size = 1 if mode == "single" else SP_WORLD_SIZE
|
|
|
|
device = torch.device(f"cuda:{local_rank}")
|
|
torch.cuda.set_device(device)
|
|
_seed_everything(SEED)
|
|
|
|
try:
|
|
maybe_init_distributed_environment_and_model_parallel(1, sp_size)
|
|
|
|
config = _build_tiny_wan_config()
|
|
hf_config = _build_hf_config(config)
|
|
model = WanTransformer3DModel(config=config, hf_config=hf_config)
|
|
model = model.to(device=device, dtype=torch.float32)
|
|
_initialize_model_parameters(model)
|
|
model.train()
|
|
model.zero_grad(set_to_none=True)
|
|
|
|
hidden_states, encoder_hidden_states, timestep = _build_inputs(
|
|
config,
|
|
device,
|
|
)
|
|
|
|
forward_batch = ForwardBatch(data_type="dummy")
|
|
with set_forward_context(
|
|
current_timestep=0,
|
|
attn_metadata=None,
|
|
forward_batch=forward_batch,
|
|
):
|
|
output = model(
|
|
hidden_states=hidden_states,
|
|
encoder_hidden_states=encoder_hidden_states,
|
|
timestep=timestep,
|
|
)
|
|
|
|
_assert_finite("output", output)
|
|
loss = output.float().square().mean()
|
|
_assert_finite("loss", loss)
|
|
loss.backward()
|
|
for name, param in model.named_parameters():
|
|
if param.grad is not None:
|
|
_assert_finite(f"grad[{name}]", param.grad)
|
|
if mode == "sp":
|
|
for param in model.parameters():
|
|
if param.grad is not None:
|
|
dist.all_reduce(param.grad, op=dist.ReduceOp.AVG)
|
|
|
|
grads = _collect_named_parameter_grads(model)
|
|
if rank == 0:
|
|
torch.save({"grads": grads}, output_path)
|
|
|
|
dist.barrier()
|
|
finally:
|
|
cleanup_dist_env_and_memory()
|
|
|
|
|
|
def _run_torchrun(
|
|
script_path: Path,
|
|
mode: str,
|
|
nproc_per_node: int,
|
|
output_path: Path,
|
|
) -> None:
|
|
cmd = [
|
|
"torchrun",
|
|
"--nnodes",
|
|
"1",
|
|
"--nproc_per_node",
|
|
str(nproc_per_node),
|
|
"--master_port",
|
|
str(_free_port()),
|
|
str(script_path),
|
|
"--sp-grad-worker",
|
|
"--mode",
|
|
mode,
|
|
"--output",
|
|
str(output_path),
|
|
]
|
|
env = os.environ.copy()
|
|
env["FASTVIDEO_ATTENTION_BACKEND"] = "TORCH_SDPA"
|
|
process = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
|
if process.returncode != 0:
|
|
raise RuntimeError(f"{mode} worker failed with code {process.returncode}\n"
|
|
f"STDOUT:\n{process.stdout}\n"
|
|
f"STDERR:\n{process.stderr}")
|
|
|
|
|
|
def test_sp_gradient_matches_single_rank(tmp_path: Path) -> None:
|
|
if not torch.cuda.is_available():
|
|
pytest.skip("This test requires CUDA.")
|
|
if torch.cuda.device_count() < SP_WORLD_SIZE:
|
|
pytest.skip(f"This test requires at least {SP_WORLD_SIZE} CUDA devices.")
|
|
|
|
script_path = Path(__file__).resolve()
|
|
single_path = tmp_path / "single_rank_grads.pt"
|
|
sp_path = tmp_path / f"sp{SP_WORLD_SIZE}_grads.pt"
|
|
|
|
_run_torchrun(
|
|
script_path=script_path,
|
|
mode="single",
|
|
nproc_per_node=1,
|
|
output_path=single_path,
|
|
)
|
|
_run_torchrun(
|
|
script_path=script_path,
|
|
mode="sp",
|
|
nproc_per_node=SP_WORLD_SIZE,
|
|
output_path=sp_path,
|
|
)
|
|
|
|
single_grads: dict[str, torch.Tensor] = torch.load(
|
|
single_path,
|
|
map_location="cpu",
|
|
)["grads"]
|
|
sp_grads: dict[str, torch.Tensor] = torch.load(
|
|
sp_path,
|
|
map_location="cpu",
|
|
)["grads"]
|
|
single_names = set(single_grads)
|
|
sp_names = set(sp_grads)
|
|
single_only = sorted(single_names - sp_names)
|
|
sp_only = sorted(sp_names - single_names)
|
|
same_params: list[str] = []
|
|
different_params: list[str] = []
|
|
for name in sorted(single_names & sp_names):
|
|
single_grad = single_grads[name]
|
|
sp_grad = sp_grads[name]
|
|
if torch.allclose(single_grad, sp_grad, rtol=1e-4, atol=1e-5):
|
|
same_params.append(name)
|
|
continue
|
|
|
|
max_abs_diff = (single_grad - sp_grad).abs().max().item()
|
|
different_params.append(f"{name} (max_abs_diff={max_abs_diff:.3e})")
|
|
|
|
report = (f"single-only params ({len(single_only)}):\n" + "\n".join(single_only) +
|
|
f"\n\nsp-only params ({len(sp_only)}):\n" + "\n".join(sp_only) + "\n\n"
|
|
f"same params ({len(same_params)}):\n" + "\n".join(same_params) +
|
|
f"\n\ndifferent params ({len(different_params)}):\n" + "\n".join(different_params))
|
|
print(report)
|
|
assert not single_only and not sp_only and not different_params, report
|
|
|
|
|
|
def _parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--sp-grad-worker", action="store_true")
|
|
parser.add_argument("--mode", choices=["single", "sp"], default=None)
|
|
parser.add_argument("--output", type=str, default=None)
|
|
return parser.parse_args()
|
|
|
|
|
|
# pytest -sv fastvideo/tests/distributed/test_sp_wan.py
|
|
if __name__ == "__main__":
|
|
args = _parse_args()
|
|
if not args.sp_grad_worker:
|
|
raise SystemExit("This module is intended to be run by pytest.")
|
|
if args.mode is None or args.output is None:
|
|
raise SystemExit("--mode and --output are required in worker mode.")
|
|
_run_worker(mode=args.mode, output_path=Path(args.output))
|