Files
hao-ai-lab-FastVideo/gradient_flow_analysis.py
T
shaoxiongduan 92cc893cfc trackwan: 14B teacher pipeline, layer-wise track LR, and overfit tooling
Training:
- finetune.py: optional track/base param groups (WANTRACK_TRACK_GROUP) so the
  track pathway can take a different LR (WANTRACK_TRACK_LR_MULT); patch_embedding
  gradient masked to the track slot [:, 36:]. Raises if nothing matches.
- track_warmup.py callback: hold the pretrained DiT at lr 0 while the track
  pathway warms up, then ramp it back in.

14B configs + held-alloc launchers:
- finetune_wantrack_{synth_sparse_fixed,synth_sparse_random,openvid_sparse_merged_bias,
  synth_stage2}_14b + openvid_14b_{warmup,lwlr} + 1.3B stage2 configs.
- run_wan14b_held.sh (held alloc + auto-restart + rank cleanup, per-stage WANTRACK
  env), acquire_nodes.sh, chain_stepAB.sh, run_stepB_seed.sh, run_stepC_merge.sh,
  watch_gate.sh, run_synth_stage2_slurm.sh.

Init / diagnostics:
- convert_trackwan_init_v2.py: --pe-src to lift patch_embedding[:, 36:] and
  --track-src to lift track_encoder together (co-adapted merge); sharded-source support.
- check_gate_growth.py, gradient_flow_analysis.py, gradient_flow_summarize.py.

Data pipeline: segment_tracks.py sharding; decode/segment/tracks slurm scripts;
precompute_null_text, two_track_val, cfg_ablation, lwlr_experiment.

Inference: app_multi_trace.py updates.
2026-07-23 03:52:56 +00:00

471 lines
20 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
"""
Per-sample gradient / SNR analysis of the WanTrack DiT, comparing 4 init/checkpoint variants
(zero_init, random_init, merged, overfit_full) on real OpenVid parquet samples.
For each (model, layer) we record:
avg_per_sample_norm = mean_i ||g_i|| (typical bs=1 grad magnitude)
mean_grad_norm = ||mean_i g_i|| (grad magnitude after bs=N averaging)
snr = mean_grad_norm / avg_per_sample_norm
High SNR => signal survives averaging (universal component).
Low SNR => signal cancels (sample-specific / noise).
Layers tracked (custom-model names, not safetensors names):
patch_embedding.proj.weight [:, :36] and [:, 36:]
track_encoder.proj.weight
track_encoder.temporal_conv.weight
blocks.0.to_q.weight (early self-attn Q)
blocks.14.ffn.fc_in.weight (mid FFN in-proj)
blocks.29.to_out.weight (late self-attn out)
"""
from __future__ import annotations
import argparse
import glob
import json
import os
import random
import sys
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
import pyarrow.parquet as pq
import torch
import torch.nn.functional as F
from safetensors.torch import load_file as st_load
# Init distributed BEFORE any fastvideo import that touches SP world.
def _init_distributed_1gpu() -> None:
os.environ.setdefault("RANK", "0")
os.environ.setdefault("LOCAL_RANK", "0")
os.environ.setdefault("WORLD_SIZE", "1")
os.environ.setdefault("MASTER_ADDR", "127.0.0.1")
os.environ.setdefault("MASTER_PORT", "29511")
os.environ.setdefault("FASTVIDEO_ATTENTION_BACKEND", "TORCH_SDPA")
from fastvideo.distributed.parallel_state import (
maybe_init_distributed_environment_and_model_parallel,
)
maybe_init_distributed_environment_and_model_parallel(tp_size=1, sp_size=1)
_init_distributed_1gpu()
# Now safe to import model classes
from fastvideo.configs.models.dits.trackwan import ( # noqa: E402
TrackWanVideoArchConfig,
TrackWanVideoConfig,
)
from fastvideo.forward_context import set_forward_context # noqa: E402
from fastvideo.models.dits.trackwan.model import TrackWanTransformer3DModel # noqa: E402
from fastvideo.models.loader.utils import ( # noqa: E402
get_param_names_mapping,
hf_to_custom_state_dict,
)
# ------------------------------------------------------------------ #
# Model set-up
# ------------------------------------------------------------------ #
MODELS = {
"zero_init": "/mnt/lustre/vlm-s4duan/models/trackwan_1.3b_i2v_d64_nobias_init",
"random_init": "/mnt/lustre/vlm-s4duan/models/trackwan_1.3b_i2v_d64_nobias_random_init",
"merged": "/mnt/lustre/vlm-s4duan/models/trackwan_1.3b_i2v_d64_merged_from_overfit",
"overfit_full": "/mnt/lustre/vlm-s4duan/models/overfit_2000",
"fun_control": "/mnt/lustre/vlm-s4duan/models/trackwan_1.3b_i2v_d64_from_fun_control_init",
# 14B variants
"14b_zero_init": "/mnt/lustre/vlm-s4duan/models/trackwan_14b_i2v_d64_nobias_init",
"14b_random_init": "/mnt/lustre/vlm-s4duan/models/trackwan_14b_i2v_d64_random_init",
"14b_partial_merged": "/mnt/lustre/vlm-s4duan/models/trackwan_14b_i2v_d64_partial_merged",
# zero-gate init WITH bias (the base the step-A overfit started from — gate exactly 0).
"14b_zero_init_bias": "/mnt/lustre/vlm-s4duan/models/trackwan_14b_i2v_d64_zero_init_bias",
# fresh 14B base with the FIXED-overfit (step A, ckpt-2000) encoder + gate merged in — this
# is the candidate stage-1 init; its per-sample track grad decides whether it will train.
"14b_merged_fixed2000": "/mnt/lustre/vlm-s4duan/models/trackwan_14b_i2v_d64_merged_from_fixed2000_bias",
}
# Custom-model parameter names we snapshot per sample.
# Special slicing for patch_embedding is handled below.
TRACK_LAYERS = [
"patch_embedding.proj.weight", # will split into base=[:, :36] / track=[:, 36:]
"track_encoder.proj.weight",
"track_encoder.temporal_conv.weight",
"blocks.0.to_q.weight",
"blocks.14.ffn.fc_in.weight",
"blocks.29.to_out.weight",
]
def build_model_from_diffusers(model_dir: str, device: torch.device) -> TrackWanTransformer3DModel:
cfg_path = Path(model_dir) / "transformer" / "config.json"
with open(cfg_path, "r") as f:
hf_config = json.load(f)
arch = TrackWanVideoArchConfig(
num_attention_heads=int(hf_config["num_attention_heads"]),
attention_head_dim=int(hf_config["attention_head_dim"]),
in_channels=int(hf_config["in_channels"]),
out_channels=int(hf_config["out_channels"]),
text_dim=int(hf_config.get("text_dim", 4096)),
freq_dim=int(hf_config.get("freq_dim", 256)),
ffn_dim=int(hf_config["ffn_dim"]),
num_layers=int(hf_config["num_layers"]),
cross_attn_norm=bool(hf_config.get("cross_attn_norm", True)),
qk_norm=str(hf_config.get("qk_norm", "rms_norm_across_heads")),
eps=float(hf_config.get("eps", 1e-6)),
image_dim=int(hf_config.get("image_dim") or 0) or None,
added_kv_proj_dim=int(hf_config.get("added_kv_proj_dim") or 0) or None,
rope_max_seq_len=int(hf_config.get("rope_max_seq_len", 1024)),
track_config=dict(hf_config.get("track_config", {})),
)
# The trackwan config has an "in_channels: 52" default, but we still pass hf_config so
# TrackWanTransformer3DModel picks up the correct track_channels from track_config.
cfg = TrackWanVideoConfig(arch_config=arch)
# Ensure downstream consumers see the right in_channels regardless of default
cfg.arch_config.in_channels = int(hf_config["in_channels"])
cfg.arch_config.out_channels = int(hf_config["out_channels"])
model = TrackWanTransformer3DModel(config=cfg, hf_config=hf_config)
model = model.to(device=device, dtype=torch.bfloat16)
# --- load weights ---
st_files = sorted(glob.glob(str(Path(model_dir) / "transformer" / "*.safetensors")))
assert len(st_files) >= 1, f"no safetensors under {model_dir}/transformer"
hf_sd: dict[str, torch.Tensor] = {}
for p in st_files:
hf_sd.update(st_load(p))
mapping_fn = get_param_names_mapping(model.param_names_mapping)
custom_sd, _ = hf_to_custom_state_dict(hf_sd, mapping_fn)
# Load into model
model_sd = model.state_dict()
missing, unexpected = [], []
for k, v in custom_sd.items():
if k in model_sd:
model_sd[k].data.copy_(v.to(model_sd[k].dtype))
else:
unexpected.append(k)
for k in model_sd:
if k not in custom_sd:
missing.append(k)
print(f"[{model_dir}] missing={len(missing)} unexpected={len(unexpected)}")
if unexpected:
print(" unexpected head:", unexpected[:5])
if missing:
print(" missing head:", missing[:5])
# Gradient checkpointing needs the forward context to be re-established during
# backward recompute; we keep both fwd+bwd wrapped in set_forward_context in
# run_one_model, so grad_checkpointing is safe.
model.gradient_checkpointing = True
model._gradient_checkpointing_func = lambda fn, *args, **kwargs: torch.utils.checkpoint.checkpoint(
fn, *args, use_reentrant=False, **kwargs
)
# FastVideo's linear layers create Parameters with requires_grad=False (FSDP path).
# For gradient analysis we force all params trainable.
for p in model.parameters():
p.requires_grad_(True)
model.train() # ensure gradients flow
return model
# ------------------------------------------------------------------ #
# Parquet loader
# ------------------------------------------------------------------ #
@dataclass
class Sample:
vae_latent: torch.Tensor # [16, T, H, W]
first_frame_latent: torch.Tensor # [16, T, H, W]
clip_feature: torch.Tensor # [257, 1280]
text_embedding: torch.Tensor # [L, 4096]
track_points: torch.Tensor # [T_full, N, 2]
track_visibility: torch.Tensor # [T_full, N]
object_ids: torch.Tensor # [N]
track_weights: torch.Tensor # [N]
def _decode(row, name) -> torch.Tensor:
shape = row[f"{name}_shape"]
data = row[f"{name}_bytes"]
arr = np.frombuffer(data, dtype=np.float32).reshape(shape).copy()
return torch.from_numpy(arr)
def load_samples(pattern: str, n: int, seed: int = 0) -> list[Sample]:
files = sorted(glob.glob(pattern, recursive=True))
random.Random(seed).shuffle(files)
samples: list[Sample] = []
for f in files:
if len(samples) >= n:
break
try:
tbl = pq.read_table(f)
except Exception as e:
print("skip", f, e)
continue
for row in tbl.to_pylist():
try:
s = Sample(
vae_latent=_decode(row, "vae_latent"),
first_frame_latent=_decode(row, "first_frame_latent"),
clip_feature=_decode(row, "clip_feature"),
text_embedding=_decode(row, "text_embedding"),
track_points=_decode(row, "track_points"),
track_visibility=_decode(row, "track_visibility"),
object_ids=_decode(row, "object_ids"),
track_weights=_decode(row, "track_weights"),
)
except Exception as e:
print("skip row", e)
continue
samples.append(s)
if len(samples) >= n:
break
print(f"loaded {len(samples)} samples from {pattern}")
return samples
# ------------------------------------------------------------------ #
# I2V cond concat and flow-matching noise
# ------------------------------------------------------------------ #
def build_i2v_cond_concat(first_frame_latent: torch.Tensor,
vae_temporal_compression: int = 4) -> torch.Tensor:
"""[B,16,T,H,W] first-frame latent -> [B,20,T,H,W] (4 mask + 16 latent).
Mirrors WanTrackModel._build_i2v_cond_concat (mask=1 on first frame slot only).
"""
b, _, num_latent_t, h, w = first_frame_latent.shape
ratio = vae_temporal_compression
num_frames = (num_latent_t - 1) * ratio + 1
mask = torch.ones(b, 1, num_frames, h, w,
device=first_frame_latent.device, dtype=first_frame_latent.dtype)
mask[:, :, 1:] = 0
first = torch.repeat_interleave(mask[:, :, :1], dim=2, repeats=ratio)
mask = torch.cat([first, mask[:, :, 1:]], dim=2)
mask = mask.view(b, -1, ratio, h, w).transpose(1, 2) # [B, ratio, num_latent_t, H, W]
return torch.cat([mask, first_frame_latent], dim=1)
def flow_matching_noisy(clean: torch.Tensor, noise: torch.Tensor,
sigma: float, flow_shift: float = 6.0) -> tuple[torch.Tensor, float, float]:
"""(1-s)*x + s*n with shifted sigma. Returns noisy, shifted_sigma, timestep."""
shifted = flow_shift * sigma / (1 + (flow_shift - 1) * sigma)
noisy = (1.0 - shifted) * clean + shifted * noise
return noisy, shifted, shifted * 1000.0
# ------------------------------------------------------------------ #
# Per-sample gradient snapshot
# ------------------------------------------------------------------ #
def snapshot_layer_grads(model: torch.nn.Module) -> dict[str, torch.Tensor]:
out: dict[str, torch.Tensor] = {}
param_map = dict(model.named_parameters())
for name in TRACK_LAYERS:
if name not in param_map:
# Some models pack Q/K/V differently. Try nested attn1 name as fallback.
alt = name.replace("blocks.0.to_q", "blocks.0.attn1.to_q") \
.replace("blocks.29.to_out", "blocks.29.attn1.to_out") \
.replace(".ffn.fc_in.", ".ffn.net.0.proj.")
if alt in param_map:
p = param_map[alt]
else:
print(f"WARN param not found: {name}")
continue
else:
p = param_map[name]
if p.grad is None:
print(f"WARN no grad on {name}")
continue
out[name] = p.grad.detach().clone().float().cpu()
return out
def run_one_model(model_name: str, model_dir: str, samples: list[Sample],
out_dir: Path, device: torch.device,
flow_shift: float = 6.0) -> dict:
print(f"\n===== {model_name} =====")
torch.cuda.empty_cache()
model = build_model_from_diffusers(model_dir, device)
print("model params:",
sum(p.numel() for p in model.parameters()) / 1e9, "B")
# Store per-sample grads (may be large: patch_embedding.weight = 1536*52*4 = 320K params).
per_sample_grads: dict[str, list[torch.Tensor]] = {n: [] for n in TRACK_LAYERS}
for i, s in enumerate(samples):
model.zero_grad(set_to_none=True)
# Move tensors to device / bf16 dtype
vae_latent = s.vae_latent.unsqueeze(0).to(device, dtype=torch.bfloat16) # [1,16,T,H,W]
first_frame_latent = s.first_frame_latent.unsqueeze(0).to(device, dtype=torch.bfloat16)
clip_feature = s.clip_feature.unsqueeze(0).to(device, dtype=torch.bfloat16)
text_embedding = s.text_embedding.unsqueeze(0).to(device, dtype=torch.bfloat16)
# Note attention mask: use all ones over the raw text seq len
text_mask = torch.ones(1, s.text_embedding.shape[0], device=device, dtype=torch.bfloat16)
# Track tensors
track_points = s.track_points.unsqueeze(0).to(device, dtype=torch.bfloat16) # [1, T_full, N, 2]
track_visibility = s.track_visibility.unsqueeze(0).to(device, dtype=torch.bfloat16)
# Truncate track T to (num_latent_t - 1) * ratio + 1
num_latent_t = vae_latent.shape[2]
expected_T = (num_latent_t - 1) * 4 + 1
track_points = track_points[:, :expected_T]
track_visibility = track_visibility[:, :expected_T]
# Sample sigma ~ U(0,1)
sigma_u = float(torch.rand(1).item())
# Compute noise and noisy
noise = torch.randn_like(vae_latent, dtype=torch.float32)
clean_f = vae_latent.float()
noisy_f, shifted_sigma, timestep_val = flow_matching_noisy(clean_f, noise, sigma_u,
flow_shift=flow_shift)
noisy = noisy_f.to(torch.bfloat16) # [1, 16, T, H, W]
# Build I2V concat
cond20 = build_i2v_cond_concat(first_frame_latent, vae_temporal_compression=4) # [1,20,T,H,W]
hidden_states = torch.cat([noisy, cond20], dim=1) # [1, 36, T, H, W]
# Timestep tensor
timestep = torch.tensor([timestep_val], device=device, dtype=torch.bfloat16)
# Forward + backward BOTH inside set_forward_context so grad-checkpoint
# recompute finds the forward context.
with torch.autocast(device_type="cuda", dtype=torch.bfloat16), \
set_forward_context(current_timestep=timestep, attn_metadata=None):
pred = model(
hidden_states=hidden_states,
encoder_hidden_states=text_embedding,
encoder_attention_mask=text_mask,
timestep=timestep,
encoder_hidden_states_image=clip_feature,
track_points=track_points,
track_visibility=track_visibility,
return_dict=False,
)
# pred is [B, C, T, H, W]
# Loss: MSE(pred, noise - clean) computed in fp32
target = (noise - clean_f) # [B,16,T,H,W]
loss = F.mse_loss(pred.float(), target.float())
loss.backward()
# Snapshot
gs = snapshot_layer_grads(model)
for n, g in gs.items():
per_sample_grads[n].append(g)
if i < 3 or i % 8 == 0:
print(f" sample {i}: sigma_u={sigma_u:.3f} shifted={shifted_sigma:.3f} "
f"t={timestep_val:.1f} loss={loss.item():.4f} "
f"|g[patch_embed]|={gs.get('patch_embedding.proj.weight', torch.zeros(1)).norm().item():.4f}")
# free bookkeeping
del pred, hidden_states, noisy, noise, cond20, target, loss
torch.cuda.empty_cache()
# Save all grads
model_out = out_dir / model_name
model_out.mkdir(parents=True, exist_ok=True)
torch.save({n: torch.stack(gs, dim=0) for n, gs in per_sample_grads.items()
if len(gs) > 0},
model_out / "grads.pt")
# Compute stats
stats: dict = {}
for n in TRACK_LAYERS:
if n not in per_sample_grads or not per_sample_grads[n]:
continue
g_stack = torch.stack(per_sample_grads[n], dim=0) # [N, *param_shape]
# Special split for patch_embedding
if n == "patch_embedding.proj.weight":
# Shape [N, out, in, kD, kH, kW] with in=52
base_slice = g_stack[:, :, :36] # [N, out, 36, ...]
track_slice = g_stack[:, :, 36:] # [N, out, 16, ...]
for key, sub in [(f"{n}[:,:36]", base_slice), (f"{n}[:,36:]", track_slice)]:
per_sample_norms = sub.flatten(1).norm(dim=1) # [N]
mean_grad = sub.mean(0)
mean_grad_norm = mean_grad.flatten().norm().item()
avg_per_sample_norm = per_sample_norms.mean().item()
snr = mean_grad_norm / (avg_per_sample_norm + 1e-30)
stats[key] = {
"avg_per_sample_norm": avg_per_sample_norm,
"mean_grad_norm": mean_grad_norm,
"snr": snr,
"n_params": int(sub[0].numel()),
}
else:
per_sample_norms = g_stack.flatten(1).norm(dim=1)
mean_grad = g_stack.mean(0)
mean_grad_norm = mean_grad.flatten().norm().item()
avg_per_sample_norm = per_sample_norms.mean().item()
snr = mean_grad_norm / (avg_per_sample_norm + 1e-30)
stats[n] = {
"avg_per_sample_norm": avg_per_sample_norm,
"mean_grad_norm": mean_grad_norm,
"snr": snr,
"n_params": int(g_stack[0].numel()),
}
# Free model
del model
torch.cuda.empty_cache()
return stats
def print_table(all_stats: dict) -> None:
print("\n\n===== SNR TABLE =====")
print(f"{'model':<14} {'layer':<48} {'avg_per_sample':>16} {'mean_grad':>16} {'snr':>10}")
print("-" * 110)
for model, stats in all_stats.items():
for layer, s in stats.items():
print(f"{model:<14} {layer:<48} {s['avg_per_sample_norm']:>16.6f} "
f"{s['mean_grad_norm']:>16.6f} {s['snr']:>10.4f}")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--n-samples", type=int, default=32)
ap.add_argument("--parquet-glob", type=str,
default="/mnt/lustre/vlm-s4duan/openvid_1m/combined_parquet_dataset/shard_*/**/*.parquet")
ap.add_argument("--out-dir", type=str, default="/mnt/lustre/vlm-s4duan/gradient_analysis")
ap.add_argument("--flow-shift", type=float, default=6.0)
ap.add_argument("--seed", type=int, default=42)
ap.add_argument("--only", type=str, default="", help="comma list of models to run")
args = ap.parse_args()
torch.manual_seed(args.seed)
random.seed(args.seed)
np.random.seed(args.seed)
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
device = torch.device("cuda")
samples = load_samples(args.parquet_glob, args.n_samples, seed=args.seed)
assert len(samples) == args.n_samples, "not enough samples loaded"
all_stats: dict = {}
models_to_run = args.only.split(",") if args.only else list(MODELS.keys())
for name in models_to_run:
name = name.strip()
if not name:
continue
stats = run_one_model(name, MODELS[name], samples, out_dir, device,
flow_shift=args.flow_shift)
all_stats[name] = stats
# Save partial after each model
with open(out_dir / "summary.json", "w") as f:
json.dump(all_stats, f, indent=2)
print_table(all_stats)
print(f"\nsummary -> {out_dir / 'summary.json'}")
if __name__ == "__main__":
main()