#!/usr/bin/env python3
"""gc25d 双向 SFT(SPEC §6–§7):Wan2.2-TI2V-5B 全参数微调 + 零初始化 gc_sem_cond(逐像素 parts id 条件),latent 与文本都预计算。
ActionRoPE `actionrope/train.py` 的 fork(结构逐段对应,便于 diff):AropeTrainModule → GcTrainModule(model.gc_forward),
arope / 动作臂 / CNS / 画布 / 权重图全部去掉,换成 sem 条件;数据集换成 dataset.GcLatentDataset(三种 dropout 在 worker 里抽)。
# 单进程(smoke,不用 DeepSpeed;AdamW 直接更新 bf16 参数、无 fp32 主权重,数字不代表训练效果)
CUDA_VISIBLE_DEVICES=2 .venv/bin/python code/gc25d/train.py --limit 8 --max_steps 4 --output outputs/_smoke/x
# 多卡:accelerate + DeepSpeed ZeRO-2(启动器 scripts/train_sft.sh 选 / 生成 configs/accelerate_zero2*.yaml)
OUT=outputs/sft_v10 bash scripts/train_sft.sh
与 ActionRoPE 的差别(都在 GcTrainModule / main 里)
--------------------------------------------------
* 前向 = model.gc_forward(plain RoPE、逐 cell 文本、patch embedding 后加 gc_sem_cond(sem_ids)),aux 带回 sem_rms / x_rms
(sem 项与 patch embedding 的 RMS;proj_out 零初始化 ⇒ step 1 的 sem_rms 恰为 0,之后应单调长起来)。
* loss = MSE(帧 1..20) × training_weight(t)(权重图恒为 1,所以 ActionRoPE 的 loss_w 就是 mse,不再单列)。
* 两个 AdamW 参数组:`dit.gc_sem_cond.*` 用 --cond_lr(默认 1e-4 = 10×;零初始化的输入通道在 lr 1e-5 下学得极慢,
train_recipe §6),其余 --lr;wd 相同。LambdaLR 的 warmup 系数同时乘到两组上。
* 验证:固定 val clip × 固定 t × 固定噪声,每个 (clip, t) 跑三遍:cond(真 ids + 真文本)/ uncond(ids 全 255)/
nulltext(NULL_PROMPT × 21);记 mse 与两个 gap(uncond − cond、nulltext − cond)—— gap 是条件有没有被用上的直接读数。
* 导出 = gcdistill.gc_dit.save_gc(831 个 bf16 键 = 825 Wan + 6 gc_sem_cond,键不带 dit.,metadata 带 vocab 等),
写完用 check_gc_ckpt 体检;save_state 走 distill.runlib.save_state_rotating(state_A / state_B 两槽 + state 软链)。
* 热启动 --resume X.safetensors = model.load_gc_weights:gc25d 导出(词表必须相同)或普通 Wan / plain 导出
(缺全部 gc_sem_cond.* ⇒ 保持零初始化)。--resume
= accelerator.load_state 续训,**数据位置精确接上**:
trainer_state.json 记 (epoch, 本 epoch 已消费的 micro batch 数, 几何),续训用 skip_first_batches 跳过已消费的部分
(ActionRoPE 从 epoch 开头重放);导出 metadata 的 init_ckpt 沿用首次启动的热启动权重,续训另记 resumed_from / resumed_step。
* 分辨率 = geometry(env GC_RES,默认 832x480;scripts/train_sft.sh 透传):--data_root 的 res 必须等于它(dataset 构造时查),
导出 metadata / trainer_state 记 res;dir 续训的存档 res 必须相同;safetensors 热启动的 res 不同只打印提示(跨分辨率热启动合法)。
* 数据顺序用 accelerate 的 SeedableRandomSampler(data_seed = --seed):每个 epoch 的排列 = f(seed, epoch),各 rank 同一排列再分片;
不用它时排列来自 sampler generator 的累积状态,load_state 不存(只给 IterableDataset 存),续训没法接上。
* step_time 是整步墙钟(含等数据),data_time 单列等 DataLoader 的部分(epoch 开头重新 fork worker 也在这里)。
学习率调度不交给 accelerate.prepare:accelerate 包装后的 scheduler 每步会 step num_processes 次
(它假设 scheduler 是按样本数建的),warmup 会被悄悄压缩;这里手动 step,一步就是一步。
随机数:set_seed(seed, device_specific=True) 让每个 rank 的 torch / CUDA RNG 都不同 —— 噪声 ε(randn_like)各 rank 独立;
t 由各 rank 的 cpu_gen 抽;dropout 抽签按 (seed, epoch, index) 在 dataset 里定(与 rank / worker 无关)。数据分片不受影响:
SeedableRandomSampler 用显式 data_seed,不吃 device_specific 的种子。不加 device_specific 时 8 张卡每步抽到同一份 ε。
"""
from __future__ import annotations
import argparse
import json
import math
import os
import platform
import socket
import sys
import time
import warnings
import zlib
os.environ.setdefault("DIFFSYNTH_SKIP_DOWNLOAD", "True")
import torch
import torch.nn as nn
from accelerate import Accelerator
from accelerate.utils import DataLoaderConfiguration, DistributedType, set_seed
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE)) # maliang 根目录
AROPE_ROOT = os.environ.get("AROPE_ROOT", os.path.join(ROOT, "code", "arope"))
for _p in (HERE, AROPE_ROOT):
if _p not in sys.path and os.path.isdir(_p):
sys.path.append(_p)
import geometry as GEO # noqa: E402
import model as GM # noqa: E402
from dataset import CELLS, UNKNOWN_ID, GcLatentDataset, gc_collate # noqa: E402
from text import NULL_PROMPT # noqa: E402
from gcdistill.gc_dit import GC_DIT_KEYS, GC_META, check_gc_ckpt, cond_spec, save_gc # noqa: E402
from distill.runlib import CsvLog, save_state_rotating # noqa: E402
from diffsynth.diffusion.flow_match import FlowMatchScheduler # noqa: E402
MODEL_DIR = os.path.join(ROOT, "models/Wan2.2-TI2V-5B")
DATA_ROOT = os.path.join(ROOT, "data/gc25d_v10")
DIT_FILES = [os.path.join(MODEL_DIR, f"diffusion_pytorch_model-0000{i}-of-00003.safetensors") for i in (1, 2, 3)]
# 导出的 safetensors 只含 DiT(含 gc_sem_cond),键名与原模型一致:包装 module 里 DiT 挂在 .dit 下,导出时把这个前缀去掉
DIT_PREFIX = "dit."
COND_PREFIX = cond_spec("ids")["prefix"] # "dit.gc_sem_cond."
VAL_VARIANTS = ("cond", "uncond", "nulltext")
# ----------------------------------------------------------------------------
# 参数
# ----------------------------------------------------------------------------
def parse_args(argv=None):
ap = argparse.ArgumentParser(description="gc25d 双向 SFT(SPEC §7)")
ap.add_argument("--vocab", choices=tuple(GM.VOCABS), default="parts",
help="条件词表:parts(v10 默认,K=20)/ ids(旧 gc25d,K=10,parts 经 collapse_parts 折叠)")
ap.add_argument("--cond_type", choices=("ids",), default="ids", help="条件类型(gc25d v10 只有语义 id;记进 metadata)")
ap.add_argument("--cond_key", choices=("ids",), default="ids", help="cond/*.npz 读哪个键(记进 metadata)")
ap.add_argument("--text_dropout", type=float, default=0.1, help="逐样本:21 个 cell 全换成 NULL_PROMPT")
ap.add_argument("--cond_dropout", type=float, default=0.05, help="逐样本:ids 全 255(与 text dropout 独立)")
ap.add_argument("--mask_p", type=float, default=0.5, help="逐样本:世界锚定的 UNKNOWN 遮挡(cond 未 drop 时)")
ap.add_argument("--include_dropped", action="store_true", help="训练集保留 clips.jsonl 里 drop=true 的 clip(默认剔除)")
ap.add_argument("--first_frame_cond", action=argparse.BooleanOptionalAction, default=True)
ap.add_argument("--data_root", default=DATA_ROOT, help="latents/ cond/ meta/ index/clips.jsonl text_table_gc25d.pt")
ap.add_argument("--text_table", default=None, help="默认 /text_table_gc25d.pt")
ap.add_argument("--model_dir", default=MODEL_DIR)
ap.add_argument("--limit", type=int, default=None, help="smoke 用:只取这么多训练 clip")
ap.add_argument("--lr", type=float, default=1e-5)
ap.add_argument("--cond_lr", type=float, default=1e-4, help=f"{COND_PREFIX}* 参数组的 lr")
ap.add_argument("--weight_decay", type=float, default=0.01)
ap.add_argument("--warmup_steps", type=int, default=500, help="线性 warmup 步数(0 ⇒ 常数 lr),之后常数")
ap.add_argument("--grad_clip", type=float, default=1.0,
help="梯度裁剪阈值;DeepSpeed 下写进 deepspeed_config.gradient_clipping(覆盖 yaml),"
"日志里的 grad_norm 是裁剪前的全局范数")
ap.add_argument("--max_steps", type=int, default=20000)
ap.add_argument("--batch_size", type=int, default=1, help="每卡 micro batch(一个 micro batch 共用一个 t)")
ap.add_argument("--grad_accum", type=int, default=2,
help="梯度累积步数;step 计的是优化器更新数。DeepSpeed 下必须与 accelerate yaml 的 "
"gradient_accumulation_steps 一致(configs/accelerate_zero2_ga2.yaml 是 2)")
ap.add_argument("--num_workers", type=int, default=4)
ap.add_argument("--gradient_checkpointing", action=argparse.BooleanOptionalAction, default=True)
ap.add_argument("--save_every", type=int, default=1000)
ap.add_argument("--save_state", action=argparse.BooleanOptionalAction, default=True,
help="每次导出权重时同时 accelerator.save_state(含优化器分片,8 卡约 70 GB);"
"state_A / state_B 两个槽轮流写,