Instructions to use teawhite/ActionRoPE-internal with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Wan2.2
How to use teawhite/ActionRoPE-internal with Wan2.2:
# No code snippets available yet for this library. # To use this model, check the repository files and the library's documentation. # Want to help? PRs adding snippets are welcome at: # https://github.com/huggingface/huggingface.js
- Notebooks
- Google Colab
- Kaggle
Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- backup_2026-09-14/code/baseline/SPEC.md +88 -0
- backup_2026-09-14/code/baseline/__init__.py +200 -0
- backup_2026-09-14/code/baseline/adaln.py +207 -0
- backup_2026-09-14/code/baseline/base.py +51 -0
- backup_2026-09-14/code/baseline/linear.py +97 -0
- backup_2026-09-14/code/baseline/prompt.py +267 -0
- backup_2026-09-14/code/baseline/xattn.py +288 -0
- backup_2026-09-14/code/configs/accelerate_zero2.yaml +26 -0
- backup_2026-09-14/code/configs/accelerate_zero2_4gpu.yaml +26 -0
- backup_2026-09-14/code/configs/accelerate_zero2_ga2.yaml +27 -0
- backup_2026-09-14/code/demo/engine.py +326 -0
- backup_2026-09-14/code/demo/index.html +96 -0
- backup_2026-09-14/code/demo/run.sh +24 -0
- backup_2026-09-14/code/demo/server.py +195 -0
- backup_2026-09-14/code/distill/AUDIT_CFPP_2026-09-10.md +128 -0
- backup_2026-09-14/code/distill/MANIFEST.md5 +63 -0
- backup_2026-09-14/code/distill/OPSD_OFFICIAL_NOTES.md +50 -0
- backup_2026-09-14/code/distill/PORT_SPEC.md +0 -0
- backup_2026-09-14/code/distill/RUNBOOK.md +248 -0
- backup_2026-09-14/code/distill/__init__.py +0 -0
- backup_2026-09-14/code/distill/arope_dit.py +1091 -0
- backup_2026-09-14/code/distill/common.sh +89 -0
- backup_2026-09-14/code/distill/configs/accelerate_fsdp_dmd_4gpu.yaml +47 -0
- backup_2026-09-14/code/distill/configs/accelerate_fsdp_dmd_8gpu.yaml +49 -0
- backup_2026-09-14/code/distill/configs/accelerate_zero2_4gpu_ga2.yaml +28 -0
- backup_2026-09-14/code/distill/configs/accelerate_zero2_4gpu_ga4.yaml +28 -0
- backup_2026-09-14/code/distill/configs/accelerate_zero2_8gpu_ga1.yaml +34 -0
- backup_2026-09-14/code/distill/configs/accelerate_zero2_8gpu_ga2.yaml +36 -0
- backup_2026-09-14/code/distill/data.py +399 -0
- backup_2026-09-14/code/distill/ema.py +160 -0
- backup_2026-09-14/code/distill/eval/anystep_probe.py +196 -0
- backup_2026-09-14/code/distill/eval/flowmap_liveness.py +183 -0
- backup_2026-09-14/code/distill/eval/loop_spec.txt +1 -0
- backup_2026-09-14/code/distill/eval/null_branch_probe.py +113 -0
- backup_2026-09-14/code/distill/eval/paired50.sh +39 -0
- backup_2026-09-14/code/distill/eval/photodrift.sh +39 -0
- backup_2026-09-14/code/distill/eval/rollout_eval.py +1105 -0
- backup_2026-09-14/code/distill/eval/sgf_parity_probe.py +112 -0
- backup_2026-09-14/code/distill/eval/sweep8.sh +73 -0
- backup_2026-09-14/code/distill/eval/sweep_photodrift.sh +46 -0
- backup_2026-09-14/code/distill/eval/tf_rollout_probe.py +135 -0
- backup_2026-09-14/code/distill/flow_map.py +11 -0
- backup_2026-09-14/code/distill/flow_pf.py +345 -0
- backup_2026-09-14/code/distill/flowmap.py +934 -0
- backup_2026-09-14/code/distill/longtraj.py +201 -0
- backup_2026-09-14/code/distill/lora.py +180 -0
- backup_2026-09-14/code/distill/onpolicy.py +378 -0
- backup_2026-09-14/code/distill/runlib.py +542 -0
- backup_2026-09-14/code/distill/stage1_ar/ar_tf.py +695 -0
- backup_2026-09-14/code/distill/stage1_ar/config.json +72 -0
backup_2026-09-14/code/baseline/SPEC.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Baseline 动作臂契约(五臂公平消融)
|
| 2 |
+
|
| 3 |
+
主实验:同一个 Wan2.2-TI2V-5B、同一份数据、同一套训练配方,**唯一自由变量是动作以什么方式进模型**:
|
| 4 |
+
|
| 5 |
+
| 臂 | 代表作 | 机制(要按上游代码移植,不是凭印象) | 上游代码入口 |
|
| 6 |
+
|---|---|---|---|
|
| 7 |
+
| `arope` | 本项目 | 世界坐标 RoPE + known/new 掩码,0 参数 | `actionrope/arope.py` |
|
| 8 |
+
| `linear` | ReactiveGWM | 每个 DiT block 一个线性层,把动作映射成 hidden 上的偏置 | `vender/ReactiveGWM/inference/models/dit.py`、`training/data/action_utils.py`、`inference/utils/actions.py` |
|
| 9 |
+
| `xattn` | Matrix-Game 3 | 键盘/动作 token 与视频 token 沿时间轴做 cross-attention(前若干 block) | `vender/Matrix-Game/Matrix-Game-3/wan/modules/action_module.py`、`model.py`、`utils/conditions.py` |
|
| 10 |
+
| `prompt` | Incantation | 动作走文本,逐 latent(逐 cell)的 prompt 控制 | `vender/Incantation/modules/causal_model.py`、`inference.py`、`prompts/` |
|
| 11 |
+
| `adaln` | AlayaWorld | 动作嵌入进 adaLN 调制(与时间步嵌入同路) | `vender/AlayaWorld/alaya/control/action.py`、`alaya/model/components.py`、`ltx2/modules/model.py`、`timestep_embedding.py` |
|
| 12 |
+
|
| 13 |
+
旧报告的口径(要对齐):四臂全部从零起训、同数据同 seed;**带动作的臂收到完全相同的控制信号(逐 cell 累计偏移)**;
|
| 14 |
+
文本一律剥除动作词(prompt 臂除外——文本就是它的通道)。Linear 移植自 ReactiveGWM 官方实现(30 个逐块线性偏置,+0.18M),
|
| 15 |
+
Cross-Attn 复刻 Matrix-Game 3.0 键盘通路(前 15 块时间轴交叉注意力,+98.6M)。
|
| 16 |
+
|
| 17 |
+
## 钩子接口(`actionrope/arope.py::arope_forward(..., arm=, action_inputs=)`)
|
| 18 |
+
|
| 19 |
+
`arm` 是一个 `nn.Module`(`baseline/base.py::ActionArm` 的子类),`arope_forward` 在固定位置调用它;
|
| 20 |
+
`arm=None` 时 `arope_forward` 与原实现逐位相同。调用顺序与形状:
|
| 21 |
+
|
| 22 |
+
```python
|
| 23 |
+
grid = dict(b, f=21, h=15, w=26, n_tok_per_frame=390) # token 网格
|
| 24 |
+
feats = arm.encode(action_inputs, grid) # 一次;返回任意张量/tuple(建议 [b, f, d]),会作为张量参数穿过梯度检查点
|
| 25 |
+
t_mod = arm.modify_t_mod(t_mod, feats, grid) # t_mod: [b, S, 6, dim](逐 token,S = f·h·w;帧 0 的 t 已置 0)
|
| 26 |
+
context = arm.extra_context(context, feats, grid) # 文本 context: [b, L, dim] 或 [(b f), L, dim](逐 cell)
|
| 27 |
+
x = arm.after_patch(x, feats, grid) # x: [b, S, dim],patch embedding 之后、blocks 之前
|
| 28 |
+
for i, block in enumerate(dit.blocks): # 每个 block 内部(在梯度检查点里):
|
| 29 |
+
x = arm.block_pre(i, x, feats, grid) # block 入口
|
| 30 |
+
... self_attn ...
|
| 31 |
+
x = arm.block_mid(i, x, feats, grid) # 自注意力之后、文本 cross-attn 之前
|
| 32 |
+
... cross_attn ...
|
| 33 |
+
x = arm.block_pre_ffn(i, x, feats, grid) # 文本 cross-attn 之后、FFN 之前(Matrix-Game 3 的注入位置)
|
| 34 |
+
... ffn ...
|
| 35 |
+
x = arm.block_post(i, x, feats, grid) # block 出口
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
token 排列是 `(f h w)`:token 序号 `s = f_idx * 390 + i_h * 26 + j_w`,所以 `[b, S, dim] → [b, f, h*w, dim]` 用 `x.view(b, f, -1, dim)`。
|
| 39 |
+
|
| 40 |
+
`ActionArm` 基类(`baseline/base.py`)所有钩子默认恒等、`encode` 默认返回 None;子类只覆盖自己用到的。
|
| 41 |
+
基类还提供:`install(dit)`(默认空;需要读 dit.dim / num_heads / 层数时用),`n_new_params()`,`zero_init_check()`。
|
| 42 |
+
|
| 43 |
+
### 动作输入 `action_inputs`(dataset 统一提供,臂自己选用)
|
| 44 |
+
|
| 45 |
+
```python
|
| 46 |
+
{
|
| 47 |
+
"offset_px": float32 [b, 21, 2], # 逐 cell 累计玩家屏幕投影位移 (dx, dy) px,cell 0 = 0;与 ARoPE 同一个信号(sidecar)
|
| 48 |
+
"offset_tok": float32 [b, 21, 2], # = offset_px / 32
|
| 49 |
+
"delta_tok": float32 [b, 21, 2], # 逐 cell 增量 = offset_tok[k] − offset_tok[k−1],cell 0 = 0(速度)
|
| 50 |
+
"action_idx": int64 [b, 21], # 9 类离散标签(0 idle,1 up … 按 prompts.ACTIONS),已翻回真实屏幕方向
|
| 51 |
+
}
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
**主信号是 `offset_px` / `offset_tok`(累计偏移),与旧报告一致。** 上游代码用离散按键的(Matrix-Game 键盘)
|
| 55 |
+
按其原样用 `action_idx`(one-hot / embedding),并在 docstring 里写明;若上游同时接受连续量(鼠标/相机),
|
| 56 |
+
连续量用 `delta_tok`。每个臂在 `baseline/<name>.py` 顶部 docstring 写清:上游是怎么做的(文件:行)、这里怎么对应、
|
| 57 |
+
用了哪个信号、新增参数量、初始化方式(**新增路径必须零初始化或等效地保证"装上瞬间输出与原模型相同"**)。
|
| 58 |
+
|
| 59 |
+
### 文本
|
| 60 |
+
|
| 61 |
+
`linear` / `xattn` / `adaln` / `arope`:`text_mode=scene`(动作词剥离,`text_table_actionrope.pt`)。
|
| 62 |
+
`prompt`:`text_mode=scene_action`(动作词翻回真实方向,`text_table_eybx_mirror.pt`),逐 cell 文本已经是
|
| 63 |
+
`arope_forward` 的原生能力(每帧 token 只 attend 自己 cell 的���本)。Incantation 若有别的逐 latent 做法
|
| 64 |
+
(例如逐帧 prompt 掩码、专门的 attention),按上游移植并说明差异。
|
| 65 |
+
|
| 66 |
+
### 训练 / 推理接入(integration 阶段做,臂的作者不要改这些文件)
|
| 67 |
+
|
| 68 |
+
* `actionrope/train.py --arm {arope,plain,linear,xattn,prompt,adaln}`:`plain` 保留(= prompt 臂的旧名,行为不变);
|
| 69 |
+
新臂:`install_arope(dit, mask_channel=False)`(普通 RoPE、无 mask、权重全 1)+ `arm = baseline.build_arm(name, dit)`,
|
| 70 |
+
arm 的参数与 DiT 一起训练(同 lr);ckpt 导出时 arm 参数以 `arm.` 前缀一起写进 safetensors;config.json 记 arm 名与新增参数量。
|
| 71 |
+
* `actionrope/infer.py --arm <name>`:按 ckpt 元数据/键集自动装 arm;动作脚本 → `action_inputs`(`offset_px` 同 ARoPE;
|
| 72 |
+
`action_idx` 由每 cell 速度方向得到,同 `velocity_to_label(mirror=False)`)。
|
| 73 |
+
* `baseline/__init__.py::build_arm(name, dit) -> ActionArm`、`ARMS = {...}`。
|
| 74 |
+
|
| 75 |
+
### 测试(每个臂 `tests/test_arm_<name>.py`,单卡,模型只加载一次)
|
| 76 |
+
|
| 77 |
+
1. 装上后 **输出与原模型逐位相同**(零初始化)——`arope_forward(arm=arm)` vs `arope_forward()`。
|
| 78 |
+
2. 给非零动作、把新增参数随机扰动后输出**有变化**且梯度能回到新增参数与 DiT(`use_gradient_checkpointing=True`)。
|
| 79 |
+
3. 新增参数量打印并与上游/旧报告对照(linear ≈ 0.18M,xattn ≈ 98.6M;差得远要解释)。
|
| 80 |
+
4. ckpt 键集:`arm.state_dict()` 键名无冲突、可 strict 加载。
|
| 81 |
+
5. 单卡 2 步真实训练 smoke(`--limit 8 --max_steps 2`)在 integration 阶段跑。
|
| 82 |
+
|
| 83 |
+
## 纪律
|
| 84 |
+
|
| 85 |
+
只在 `/opt/dlami/nvme/zhiyangdeng/ActionRoPE` 内读写;新文件只放 `baseline/`、`tests/`;`vender/` 只读(不改、不删、不跑它们的脚本、不下载它们的权重);
|
| 86 |
+
不改 `actionrope/*.py`(钩子已加好;确需改动写进 report 交 integration 决定);不动 data/、models/、DiffSynth-Studio/;
|
| 87 |
+
禁止 rm -rf、sudo、pip 卸载/升降级、杀非自己启动的进程、任何网络上传/下载(vender 已克隆好);不读凭据。
|
| 88 |
+
GPU:正在跑 8 卡训练(每卡 24 GB 已用,143 GB 总),单卡前向+反向 smoke 用分配给你的那一张,跑完立刻释放。
|
backup_2026-09-14/code/baseline/__init__.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""baseline 动作臂的注册表与装配入口(baseline/SPEC.md「训练 / 推理接入」)。
|
| 2 |
+
|
| 3 |
+
from baseline import ARMS, build_arm
|
| 4 |
+
arm = build_arm("linear", dit) # 建参数 → install(dit) → 搬到 dit 的 device / dtype
|
| 5 |
+
|
| 6 |
+
五臂里 `arope`(本项目)与 `plain`(= prompt 臂旧名)不走 ActionArm:arope 的动作在 RoPE 里,
|
| 7 |
+
plain 与 prompt 行为完全相同(train.py 里 plain 仍按旧路径 arm=None 跑,prompt 走 PromptArm 的零钩子,
|
| 8 |
+
两者前向逐位一致;保留 plain 是为了旧 ckpt / 旧脚本不改)。
|
| 9 |
+
|
| 10 |
+
ckpt 约定:arm 参数以 `arm.` 前缀与 DiT 权重写在同一个 safetensors 里(train.py::export_dit_weights),
|
| 11 |
+
safetensors metadata 里记 `arm=<name>`;`detect_arm_from_keys` 只看键集就能判断臂名与 xattn 的结构配置,
|
| 12 |
+
metadata 缺失(手工拼的 ckpt)时推理仍能自动装对。
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
from typing import Iterable
|
| 18 |
+
|
| 19 |
+
import torch
|
| 20 |
+
|
| 21 |
+
from baseline.base import ActionArm
|
| 22 |
+
|
| 23 |
+
# 各臂的类延迟导入:prompt.py 会把 data/code 加进 sys.path、xattn.py 依赖 einops,
|
| 24 |
+
# 不用某个臂时不必付这些 import 的代价,也避免一个臂坏了拖垮别的臂。
|
| 25 |
+
_ARM_MODULES = {
|
| 26 |
+
"linear": ("baseline.linear", "LinearArm"),
|
| 27 |
+
"xattn": ("baseline.xattn", "XAttnArm"),
|
| 28 |
+
"prompt": ("baseline.prompt", "PromptArm"),
|
| 29 |
+
"adaln": ("baseline.adaln", "AdaLNArm"),
|
| 30 |
+
}
|
| 31 |
+
# 不走 ActionArm 的两个臂(train.py / infer.py 的 --arm 也接受它们)
|
| 32 |
+
NATIVE_ARMS = ("arope", "plain")
|
| 33 |
+
# 全部合法臂名(CLI choices 用)
|
| 34 |
+
ARM_NAMES = tuple(NATIVE_ARMS) + tuple(_ARM_MODULES)
|
| 35 |
+
# 动作走文本的臂:text_mode=scene_action(动作词翻回真实方向);其余剥动作从句
|
| 36 |
+
TEXT_ARMS = ("plain", "prompt")
|
| 37 |
+
# ckpt 里 arm 参数的前缀
|
| 38 |
+
ARM_PREFIX = "arm."
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class _LazyArms(dict):
|
| 42 |
+
"""ARMS[name] → 臂类;第一次取时才 import 对应模块。"""
|
| 43 |
+
|
| 44 |
+
def __missing__(self, name):
|
| 45 |
+
if name not in _ARM_MODULES:
|
| 46 |
+
raise KeyError(f"未知动作臂 {name!r},可选 {list(_ARM_MODULES)}(arope / plain 不走 ActionArm)")
|
| 47 |
+
mod_name, cls_name = _ARM_MODULES[name]
|
| 48 |
+
import importlib
|
| 49 |
+
|
| 50 |
+
cls = getattr(importlib.import_module(mod_name), cls_name)
|
| 51 |
+
self[name] = cls
|
| 52 |
+
return cls
|
| 53 |
+
|
| 54 |
+
def __contains__(self, name):
|
| 55 |
+
return name in _ARM_MODULES
|
| 56 |
+
|
| 57 |
+
def __iter__(self):
|
| 58 |
+
return iter(_ARM_MODULES)
|
| 59 |
+
|
| 60 |
+
def __len__(self):
|
| 61 |
+
return len(_ARM_MODULES)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
ARMS = _LazyArms()
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def arm_text_mode(name: str) -> str:
|
| 68 |
+
"""臂 → dataset 的 text_mode:动作走文本的臂保留动作从句,其余剥掉。"""
|
| 69 |
+
return "scene_action" if name in TEXT_ARMS else "scene"
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def parse_arm_kwargs(spec) -> dict:
|
| 73 |
+
"""--arm_kwargs 的 JSON 串(或已是 dict / None)→ dict。"""
|
| 74 |
+
if spec is None or spec == "":
|
| 75 |
+
return {}
|
| 76 |
+
if isinstance(spec, dict):
|
| 77 |
+
return dict(spec)
|
| 78 |
+
kw = json.loads(spec)
|
| 79 |
+
if not isinstance(kw, dict):
|
| 80 |
+
raise ValueError(f"--arm_kwargs 应为 JSON 对象,得到 {spec!r}")
|
| 81 |
+
return kw
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def build_arm(name: str, dit, arm_kwargs: dict | str | None = None) -> ActionArm:
|
| 85 |
+
"""按名字建臂、install(dit)、搬到 dit 的 device / dtype。arope / plain 没有 ActionArm ⇒ 返回 None。
|
| 86 |
+
|
| 87 |
+
install 由各臂自己按 dit.dim / 层数建参数并 .to(dit);这里再统一 .to 一次是兜底:
|
| 88 |
+
某个臂的 install 若只建了参数没搬卡(例如 prompt 臂无参数),DeepSpeed 下混着 CPU 参数会炸。
|
| 89 |
+
"""
|
| 90 |
+
if name in NATIVE_ARMS:
|
| 91 |
+
return None
|
| 92 |
+
cls = ARMS[name]
|
| 93 |
+
arm = cls(**parse_arm_kwargs(arm_kwargs))
|
| 94 |
+
arm.install(dit)
|
| 95 |
+
ref = dit.patch_embedding.weight
|
| 96 |
+
arm.to(device=ref.device, dtype=ref.dtype)
|
| 97 |
+
return arm
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# --------------------------------------------------------------------------
|
| 101 |
+
# ckpt 键集 → 臂
|
| 102 |
+
# --------------------------------------------------------------------------
|
| 103 |
+
|
| 104 |
+
def split_arm_keys(sd: dict) -> tuple[dict, dict]:
|
| 105 |
+
"""safetensors 的 state_dict → (DiT 部分, arm 部分(已去掉 'arm.' 前缀))。"""
|
| 106 |
+
dit_sd, arm_sd = {}, {}
|
| 107 |
+
for k, v in sd.items():
|
| 108 |
+
if k.startswith(ARM_PREFIX):
|
| 109 |
+
arm_sd[k[len(ARM_PREFIX):]] = v
|
| 110 |
+
else:
|
| 111 |
+
dit_sd[k] = v
|
| 112 |
+
return dit_sd, arm_sd
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def detect_arm_from_keys(arm_keys: Iterable[str], shapes: dict | None = None) -> tuple[str | None, dict]:
|
| 116 |
+
"""去掉前缀后的 arm 键集 → (臂名, 构造 kwargs)。没有 arm 键 ⇒ (None, {}):可能是 arope / plain / prompt,需看 metadata。
|
| 117 |
+
|
| 118 |
+
各臂的键名互不重叠(linear: action_embedders.*;xattn: action_modules.*;adaln: embedder.* / proj.*),
|
| 119 |
+
xattn 的结构配置能从形状反推:keyboard_attn_kv.weight 的 in_features = hidden_size(128)·window_frames,
|
| 120 |
+
有无 mouse_mlp.* = enable_mouse,有无 keyboard_embed.* = enable_keyboard,blocks = action_modules.<i> 的 i 集合。
|
| 121 |
+
"""
|
| 122 |
+
keys = list(arm_keys)
|
| 123 |
+
if not keys:
|
| 124 |
+
return None, {}
|
| 125 |
+
heads = {k.split(".")[0] for k in keys}
|
| 126 |
+
if heads == {"action_embedders"}:
|
| 127 |
+
return "linear", {}
|
| 128 |
+
if heads == {"action_modules"}:
|
| 129 |
+
blocks = sorted({int(k.split(".")[1]) for k in keys})
|
| 130 |
+
kw: dict = {"blocks": blocks}
|
| 131 |
+
kw["enable_mouse"] = any(".mouse_mlp." in k for k in keys)
|
| 132 |
+
kw["enable_keyboard"] = any(".keyboard_embed." in k for k in keys)
|
| 133 |
+
if shapes is not None and kw["enable_keyboard"]:
|
| 134 |
+
kv_key = f"action_modules.{blocks[0]}.keyboard_attn_kv.weight"
|
| 135 |
+
emb_key = f"action_modules.{blocks[0]}.keyboard_embed.2.weight"
|
| 136 |
+
if kv_key in shapes and emb_key in shapes:
|
| 137 |
+
hidden = int(shapes[emb_key][0])
|
| 138 |
+
kw["window_frames"] = int(shapes[kv_key][1]) // hidden
|
| 139 |
+
kw["hidden_size"] = hidden
|
| 140 |
+
elif shapes is not None and kw["enable_mouse"]:
|
| 141 |
+
mlp_key = f"action_modules.{blocks[0]}.mouse_mlp.0.weight"
|
| 142 |
+
if mlp_key in shapes:
|
| 143 |
+
# in_features = 2·window_frames + dim;dim 从 proj_mouse 的 out_features 拿
|
| 144 |
+
proj_key = f"action_modules.{blocks[0]}.proj_mouse.weight"
|
| 145 |
+
dim = int(shapes[proj_key][0]) if proj_key in shapes else 3072
|
| 146 |
+
kw["window_frames"] = (int(shapes[mlp_key][1]) - dim) // 2
|
| 147 |
+
return "xattn", kw
|
| 148 |
+
if heads <= {"embedder", "proj"}:
|
| 149 |
+
kw = {}
|
| 150 |
+
if shapes is not None and "embedder.0.weight" in shapes:
|
| 151 |
+
n_in = int(shapes["embedder.0.weight"][1])
|
| 152 |
+
# n_in = n_axes · freq_dim_per_axis;默认 freq 32 ⇒ 4 轴 128 / 2 轴 64
|
| 153 |
+
kw["use_delta"] = n_in >= 4 * 32
|
| 154 |
+
return "adaln", kw
|
| 155 |
+
raise ValueError(f"无法从 arm 键集识别臂:顶层键 {sorted(heads)}")
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def detect_arm_from_ckpt(sd: dict, metadata: dict | None = None) -> tuple[str | None, dict]:
|
| 159 |
+
"""(state_dict, safetensors metadata) → (臂名, 构造 kwargs)。
|
| 160 |
+
|
| 161 |
+
metadata 里的 arm 名优先(它能区分 arope / plain / prompt 这三个无 arm 键的臂);有 arm 键时
|
| 162 |
+
以键集反推的结构配置为准,并核对与 metadata 一致。metadata 缺失且无 arm 键 ⇒ (None, {}),由调用方决定默认。
|
| 163 |
+
"""
|
| 164 |
+
_, arm_sd = split_arm_keys(sd)
|
| 165 |
+
shapes = {k: tuple(v.shape) for k, v in arm_sd.items()}
|
| 166 |
+
name_keys, kw = detect_arm_from_keys(arm_sd.keys(), shapes)
|
| 167 |
+
name_meta = (metadata or {}).get("arm")
|
| 168 |
+
if name_meta and name_keys and name_meta != name_keys:
|
| 169 |
+
raise ValueError(f"ckpt metadata 说 arm={name_meta!r},键集却是 {name_keys!r}")
|
| 170 |
+
if metadata and metadata.get("arm_kwargs"):
|
| 171 |
+
# 训练时的构造参数(JSON)比形状反推更完整(例如 heads_num)
|
| 172 |
+
try:
|
| 173 |
+
kw = {**parse_arm_kwargs(metadata["arm_kwargs"]), **kw}
|
| 174 |
+
except (ValueError, json.JSONDecodeError):
|
| 175 |
+
pass
|
| 176 |
+
return name_meta or name_keys, kw
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def make_action_inputs(offset_px: torch.Tensor, action_idx: torch.Tensor | None = None) -> dict:
|
| 180 |
+
"""offset_px [b, 21, 2] (+ action_idx [b, 21]) → SPEC 的 action_inputs dict(offset_tok / delta_tok 由此派生)。
|
| 181 |
+
|
| 182 |
+
dataset.py 与 infer.py 共用,保证两边 delta_tok 的定义一致(cell 0 = 0,其余为相邻 cell 之差)。
|
| 183 |
+
"""
|
| 184 |
+
from actionrope.arope import PX_PER_TOKEN
|
| 185 |
+
|
| 186 |
+
off_px = torch.as_tensor(offset_px, dtype=torch.float32)
|
| 187 |
+
off_tok = off_px / PX_PER_TOKEN
|
| 188 |
+
delta = torch.zeros_like(off_tok)
|
| 189 |
+
delta[..., 1:, :] = off_tok[..., 1:, :] - off_tok[..., :-1, :]
|
| 190 |
+
out = {"offset_px": off_px, "offset_tok": off_tok, "delta_tok": delta}
|
| 191 |
+
if action_idx is not None:
|
| 192 |
+
out["action_idx"] = torch.as_tensor(action_idx, dtype=torch.int64)
|
| 193 |
+
return out
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
__all__ = [
|
| 197 |
+
"ARMS", "ARM_NAMES", "NATIVE_ARMS", "TEXT_ARMS", "ARM_PREFIX", "ActionArm",
|
| 198 |
+
"build_arm", "arm_text_mode", "parse_arm_kwargs", "split_arm_keys",
|
| 199 |
+
"detect_arm_from_keys", "detect_arm_from_ckpt", "make_action_inputs",
|
| 200 |
+
]
|
backup_2026-09-14/code/baseline/adaln.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""adaln 臂:动作嵌进 adaLN 调制(AlayaWorld 的 Action-AdaLN 移植到 Wan2.2-TI2V-5B)。
|
| 2 |
+
|
| 3 |
+
上游机制(vender/AlayaWorld,基座是 LTX-2.3,不是 Wan)
|
| 4 |
+
------------------------------------------------------
|
| 5 |
+
alaya/model/loader.py:16,163 实际装的是 `ltx2/modules/model_ltx_2_3.py::LTX23Model`,
|
| 6 |
+
`_runtime_transformer_overrides` (loader.py:206) 把 `enable_action_control=cfg.control.uses("action")` 传进去。
|
| 7 |
+
(`ltx2/modules/model.py:969-997` 的 81 类离散 `DiscreteActionEmbedder` 是 LTX-2 旧路径,训练/推理都不走它。)
|
| 8 |
+
|
| 9 |
+
1. 动作编码(连续 6 维、逐 latent 帧)
|
| 10 |
+
* `alaya/control/action.py:78-115 c2w_to_adaln_action_vectors`:相机 c2w 按 latent 步长(8 帧)抽样,
|
| 11 |
+
相邻两个 latent 的相对位姿 `inv(c2w[i-1]) @ c2w[i]` → 平移 xyz + 欧拉角 xyz,共 6 维;latent 0 没有前驱 ⇒ 0。
|
| 12 |
+
**这是逐 latent 的速度(增量),不是累计位移。** 每轴再除以 `control.action_scale`
|
| 13 |
+
(schema.py:210,默认 "0.14,0.075,0.22,0.17,0.70,0.16")把量级归一到 ~[-1, 1]。
|
| 14 |
+
* `model_ltx_2_3.py:130-175 ActionAdaLNEmbedder`:每轴 `value * freq_scale(1000)` 做 32 维正弦编码
|
| 15 |
+
(schema.py:211-212 `action_freq_scale=1000, action_freq_dim_per_axis=32`;频率 10000^(-i/16),[cos, sin]),
|
| 16 |
+
6 轴拼成 192 维 → `Linear(192, dim) → SiLU → Linear(dim, dim)` ⇒ `a` [B, T, dim]。
|
| 17 |
+
2. 进 adaLN(与时间步嵌入同路,逐帧广播到该帧所有 token)
|
| 18 |
+
* `model_ltx_2_3.py:782-786`:`action_adaln_projection = Sequential(SiLU, Linear(dim, 6*dim))`,
|
| 19 |
+
与 `AdaLayerNormSingle.linear`(timestep_embedding.py:120-127:`linear(silu(embedded_timestep))`)同构。
|
| 20 |
+
* `model_ltx_2_3.py:1171-1176, 1252-1253`:`a0 = projection(a)`,`a`/`a0` 按 `tokens_per_frame` repeat_interleave
|
| 21 |
+
到逐 token,然后 `timestep_emb += a0`([B, N, 6*dim],所有 block 共用的 shift/scale/gate 六元组),
|
| 22 |
+
`embedded_timestep += a`([B, N, dim],只给输出头 `_process_output` 的 shift/scale 用,model_ltx_2_3.py:829-840)。
|
| 23 |
+
block 内 `get_ada_values` (547-555) = `scale_shift_table + timestep_emb` 切 6 段——与 Wan 的
|
| 24 |
+
`block.modulation + t_mod` 完全同构。条件(clean)帧的 token 也照加自己的动作(1153-1156 注释)。
|
| 25 |
+
* 不是每层一套:只有一套 embedder + 一套 projection,30 层共用(同时间步嵌入一样)。
|
| 26 |
+
3. 初始化:`model_ltx_2_3.py:785-786` 最后一层 `Linear(dim, 6*dim)` 权重 normal(std=1e-3)、偏置 0("近零");
|
| 27 |
+
embedder MLP 用 PyTorch 默认初始化。
|
| 28 |
+
|
| 29 |
+
本实现的对应关系
|
| 30 |
+
----------------
|
| 31 |
+
Wan 的时间步路径(arope.py::arope_forward):`t = time_embedding(sinusoid(t_tok))` [b, S, dim],
|
| 32 |
+
`t_mod = time_projection(t)` = `Linear(SiLU(t))` 展成 [b, S, 6, dim],每个 block `modulation + t_mod` 切 6 段。
|
| 33 |
+
所以上游 `timestep_emb += a0` 一一对应到钩子 `modify_t_mod`:
|
| 34 |
+
|
| 35 |
+
a = embedder(actions) [b, f, dim] (同上游 ActionAdaLNEmbedder,轴数改为 4)
|
| 36 |
+
a0 = proj(a) = Linear(SiLU(a)) [b, f, 6, dim] (同上游 action_adaln_projection)
|
| 37 |
+
t_mod[b, s, :, :] += a0[b, frame(s)] (帧 k 的 390 个 token 共用一份;token 排列 (f h w))
|
| 38 |
+
|
| 39 |
+
* 未移植的部分:上游还把 `a` 加进输出头的 `embedded_timestep`。Wan 的 `dit.head(x, t)` 用的 `t` 没有钩子可改
|
| 40 |
+
(SPEC 只给了 `modify_t_mod`),且那只是最后一个 LayerNorm 的 shift/scale,30 层 × 6 元组的主路径已完整,故省略;
|
| 41 |
+
若 integration 想补,只需在 arope_forward 里对 `t` 也加一条钩子。
|
| 42 |
+
* 帧 0 是条件帧:数据集保证 `offset_tok[:, 0] = delta_tok[:, 0] = 0`,与上游 latent 0 的零向量一致;
|
| 43 |
+
和上游一样,零向量也照走 embedder(得到一个常量"静止"嵌入),不额外屏蔽——上游对 clean 条件 token 也加动作。
|
| 44 |
+
* 帧数不等于 21 时按上游做线性插值到 f(`F.interpolate(mode='linear', align_corners=True)`)。
|
| 45 |
+
|
| 46 |
+
信号
|
| 47 |
+
----
|
| 48 |
+
上游用的是逐 latent 速度(相对位姿),本项目的主信号是累计偏移 `offset_tok`(SPEC:与 ARoPE 同一个信号)。
|
| 49 |
+
按 SPEC 的约定两者都给:动作向量 = `[offset_tok(dx, dy), delta_tok(dx, dy)]` 共 4 轴,每轴各做 32 维正弦编码
|
| 50 |
+
(4 × 32 = 128 维进 MLP)。每轴的归一化尺度 `ACTION_SCALE_TOK` 相当于上游的 `action_scale`,
|
| 51 |
+
取自 sidecar 实测(3000 个 valid clip):|offset_tok| 的 p99 ≈ (9.5, 6.4),|delta_tok| 的 p99 ≈ (0.59, 0.44),
|
| 52 |
+
取整为 (10, 6.5, 0.6, 0.45),归一后 ~[-1, 1],再乘 freq_scale=1000 落到与时间步同量级(正弦编码分辨得开)。
|
| 53 |
+
上游把动作先转成模型 dtype(bf16)再编码,这里正弦编码用 float32/float64 算完再转 bf16 进 MLP——
|
| 54 |
+
bf16 只有 3 位有效数字,累计偏移 10 token 会被量化到 0.06 token,没必要丢这个精度。
|
| 55 |
+
|
| 56 |
+
参数量(dim=3072)
|
| 57 |
+
-----------------
|
| 58 |
+
embedder: Linear(128→3072) 396,288 + Linear(3072→3072) 9,440,256 = 9,836,544
|
| 59 |
+
proj: Linear(3072→18432) 56,641,536
|
| 60 |
+
合计 66,478,080 ≈ 66.5M(上游 LTX-2.3 inner_dim=4096、6 轴时约 118M;旧报告 linear +0.18M、xattn +98.6M)。
|
| 61 |
+
|
| 62 |
+
初始化
|
| 63 |
+
------
|
| 64 |
+
上游最后一层是 std=1e-3 的"近零",装上会有 ~1e-3 量级的扰动。SPEC 要求装上瞬间输出与原模型逐位相同,
|
| 65 |
+
所以这里把 `proj` 的最后一层 Linear 权重和偏置都置 0(`t_mod + 0` 在 bf16 下逐位不变);
|
| 66 |
+
embedder 保持 PyTorch 默认初始化,与上游一致。第一步只有 proj 的权重有非零梯度,之后 embedder 才跟着动。
|
| 67 |
+
"""
|
| 68 |
+
from __future__ import annotations
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
import torch
|
| 72 |
+
import torch.nn as nn
|
| 73 |
+
import torch.nn.functional as F
|
| 74 |
+
|
| 75 |
+
from baseline.base import ActionArm
|
| 76 |
+
|
| 77 |
+
# 动作向量的轴顺序:[offset_dx, offset_dy, delta_dx, delta_dy],单位 token
|
| 78 |
+
ACTION_AXES = ("offset_dx", "offset_dy", "delta_dx", "delta_dy")
|
| 79 |
+
# 每轴归一化尺度(上游 action_scale 的对应物),sidecar 实测 p99 取整,见模块 docstring
|
| 80 |
+
ACTION_SCALE_TOK = (10.0, 6.5, 0.6, 0.45)
|
| 81 |
+
# 上游默认:schema.py:211-212
|
| 82 |
+
FREQ_SCALE = 1000.0
|
| 83 |
+
FREQ_DIM_PER_AXIS = 32
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def sinusoidal_per_axis(position: torch.Tensor, dim: int) -> torch.Tensor:
|
| 87 |
+
"""照抄 model_ltx_2_3.py:151-160 `ActionAdaLNEmbedder._sinusoidal_embedding`:
|
| 88 |
+
[..., ] 位置 → [..., dim],频率 10000^(-i/(dim//2)),输出顺序 [cos, sin],float64 算完转回输入 dtype。"""
|
| 89 |
+
orig_shape = position.shape
|
| 90 |
+
pos_flat = position.reshape(-1).to(torch.float64)
|
| 91 |
+
freqs = torch.pow(
|
| 92 |
+
10000,
|
| 93 |
+
-torch.arange(dim // 2, dtype=torch.float64, device=position.device).div(max(dim // 2, 1)),
|
| 94 |
+
)
|
| 95 |
+
sinusoid = torch.outer(pos_flat, freqs)
|
| 96 |
+
emb = torch.cat([torch.cos(sinusoid), torch.sin(sinusoid)], dim=-1)
|
| 97 |
+
return emb.view(*orig_shape, dim).to(position.dtype)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
class AdaLNArm(ActionArm):
|
| 101 |
+
"""AlayaWorld 式 Action-AdaLN:一套 embedder + 一套 6·dim 投影,加到逐 token 的 t_mod 上,30 层共用。"""
|
| 102 |
+
|
| 103 |
+
name = "adaln"
|
| 104 |
+
|
| 105 |
+
def __init__(
|
| 106 |
+
self,
|
| 107 |
+
freq_dim_per_axis: int = FREQ_DIM_PER_AXIS,
|
| 108 |
+
freq_scale: float = FREQ_SCALE,
|
| 109 |
+
action_scale: tuple[float, ...] = ACTION_SCALE_TOK,
|
| 110 |
+
use_delta: bool = True,
|
| 111 |
+
):
|
| 112 |
+
super().__init__()
|
| 113 |
+
self.freq_dim_per_axis = int(freq_dim_per_axis)
|
| 114 |
+
self.freq_scale = float(freq_scale)
|
| 115 |
+
self.use_delta = bool(use_delta)
|
| 116 |
+
n_axes = 4 if self.use_delta else 2
|
| 117 |
+
assert len(action_scale) >= n_axes, f"action_scale 至少要 {n_axes} 个值"
|
| 118 |
+
# 用 buffer 存尺度:随模块 .to() 走,不进 state_dict(persistent=False),ckpt 键集只有权重
|
| 119 |
+
self.register_buffer("action_scale", torch.tensor(action_scale[:n_axes], dtype=torch.float32), persistent=False)
|
| 120 |
+
self.n_axes = n_axes
|
| 121 |
+
self.dim: int | None = None
|
| 122 |
+
# 真正的参数在 install(dit) 里按 dit.dim 建
|
| 123 |
+
self.embedder: nn.Sequential | None = None
|
| 124 |
+
self.proj: nn.Sequential | None = None
|
| 125 |
+
|
| 126 |
+
# ---- 建参数 ----
|
| 127 |
+
def install(self, dit) -> None:
|
| 128 |
+
"""按 dit.dim 建 embedder / proj,放到 dit 的 device / dtype 上,proj 末层零初始化。"""
|
| 129 |
+
dim = int(dit.dim)
|
| 130 |
+
ref = dit.time_projection[-1].weight # Wan 的 Linear(dim, 6*dim),拿它的 device / dtype
|
| 131 |
+
self.dim = dim
|
| 132 |
+
# 同上游 ActionAdaLNEmbedder.mlp:Linear(n_axes*freq, dim) → SiLU → Linear(dim, dim),默认初始化
|
| 133 |
+
self.embedder = nn.Sequential(
|
| 134 |
+
nn.Linear(self.n_axes * self.freq_dim_per_axis, dim),
|
| 135 |
+
nn.SiLU(),
|
| 136 |
+
nn.Linear(dim, dim),
|
| 137 |
+
)
|
| 138 |
+
# 同上游 action_adaln_projection:SiLU → Linear(dim, 6*dim);上游 std=1e-3,这里置 0 保证装上不改输出
|
| 139 |
+
self.proj = nn.Sequential(nn.SiLU(), nn.Linear(dim, 6 * dim, bias=True))
|
| 140 |
+
nn.init.zeros_(self.proj[-1].weight)
|
| 141 |
+
nn.init.zeros_(self.proj[-1].bias)
|
| 142 |
+
self.to(device=ref.device, dtype=ref.dtype)
|
| 143 |
+
|
| 144 |
+
# ---- 动作 → 向量 ----
|
| 145 |
+
def action_vectors(self, action_inputs: dict, grid: dict) -> torch.Tensor:
|
| 146 |
+
"""action_inputs → 归一化后的 [b, f, n_axes] float32(单位 token / 尺度)。
|
| 147 |
+
|
| 148 |
+
delta_tok 缺省时由 offset_tok 差分得到(定义同 SPEC:cell 0 = 0)。帧数不等于 grid['f'] 时按上游线性插值。
|
| 149 |
+
"""
|
| 150 |
+
offset = torch.as_tensor(action_inputs["offset_tok"], dtype=torch.float32)
|
| 151 |
+
assert offset.dim() == 3 and offset.shape[-1] == 2, f"offset_tok 应为 [b, f, 2],得到 {tuple(offset.shape)}"
|
| 152 |
+
parts = [offset]
|
| 153 |
+
if self.use_delta:
|
| 154 |
+
if action_inputs.get("delta_tok") is not None:
|
| 155 |
+
delta = torch.as_tensor(action_inputs["delta_tok"], dtype=torch.float32, device=offset.device)
|
| 156 |
+
else:
|
| 157 |
+
delta = torch.zeros_like(offset)
|
| 158 |
+
delta[:, 1:] = offset[:, 1:] - offset[:, :-1]
|
| 159 |
+
parts.append(delta)
|
| 160 |
+
vec = torch.cat(parts, dim=-1) # [b, f_in, n_axes]
|
| 161 |
+
f = int(grid["f"])
|
| 162 |
+
if vec.shape[1] != f:
|
| 163 |
+
# 同 model_ltx_2_3.py:1164-1168:帧数不匹配就沿时间轴线性插值
|
| 164 |
+
vec = F.interpolate(vec.permute(0, 2, 1), size=f, mode="linear", align_corners=True).permute(0, 2, 1)
|
| 165 |
+
return vec / self.action_scale.to(vec.device)
|
| 166 |
+
|
| 167 |
+
# ---- 钩子 ----
|
| 168 |
+
def encode(self, action_inputs: dict | None, grid: dict):
|
| 169 |
+
"""动作 → a0 [b, f, 6, dim](模型 dtype),只算一次;没有动作输入 ⇒ None(钩子全部恒等)。"""
|
| 170 |
+
if action_inputs is None or self.proj is None:
|
| 171 |
+
return None
|
| 172 |
+
w = self.embedder[0].weight
|
| 173 |
+
vec = self.action_vectors(action_inputs, grid).to(w.device) # [b, f, n_axes] float32
|
| 174 |
+
# 逐轴正弦编码(× freq_scale 落到时间步量级),拼成 [b, f, n_axes*freq_dim]
|
| 175 |
+
emb = torch.cat(
|
| 176 |
+
[sinusoidal_per_axis(vec[..., k] * self.freq_scale, self.freq_dim_per_axis) for k in range(self.n_axes)],
|
| 177 |
+
dim=-1,
|
| 178 |
+
)
|
| 179 |
+
a = self.embedder(emb.to(w.dtype)) # [b, f, dim]
|
| 180 |
+
a0 = self.proj(a) # [b, f, 6*dim]
|
| 181 |
+
return a0.unflatten(-1, (6, self.dim))
|
| 182 |
+
|
| 183 |
+
def modify_t_mod(self, t_mod: torch.Tensor, feats, grid: dict) -> torch.Tensor:
|
| 184 |
+
"""t_mod [b, S, 6, dim] += a0 按帧广播(帧 k 的 n_tok_per_frame 个 token 共用一份)。"""
|
| 185 |
+
if feats is None:
|
| 186 |
+
return t_mod
|
| 187 |
+
b, s = t_mod.shape[:2]
|
| 188 |
+
f, n = int(grid["f"]), int(grid["n_tok_per_frame"])
|
| 189 |
+
assert s == f * n, f"S={s} 与 f*n_tok_per_frame={f * n} 不符"
|
| 190 |
+
assert feats.shape[0] in (1, b) and feats.shape[1] == f, f"feats 形状 {tuple(feats.shape)} 与 grid 不符"
|
| 191 |
+
# token 排列 (f h w):[b, f, 1, 6, dim] 广播到 [b, f, n, 6, dim] 再摊平成 S
|
| 192 |
+
add = feats.unsqueeze(2).expand(feats.shape[0], f, n, 6, self.dim).reshape(feats.shape[0], s, 6, self.dim)
|
| 193 |
+
return t_mod + add.to(dtype=t_mod.dtype, device=t_mod.device)
|
| 194 |
+
|
| 195 |
+
# ---- 工具 ----
|
| 196 |
+
def zero_init_check(self) -> bool:
|
| 197 |
+
"""proj 末层全零 ⇒ a0 ≡ 0 ⇒ t_mod 不变。"""
|
| 198 |
+
if self.proj is None:
|
| 199 |
+
return False
|
| 200 |
+
lin = self.proj[-1]
|
| 201 |
+
return bool((lin.weight == 0).all() and (lin.bias == 0).all())
|
| 202 |
+
|
| 203 |
+
@staticmethod
|
| 204 |
+
def expected_n_params(dim: int, n_axes: int = 4, freq_dim_per_axis: int = FREQ_DIM_PER_AXIS) -> int:
|
| 205 |
+
"""按结构算的参数量,测试用来对照 n_new_params()。"""
|
| 206 |
+
n_in = n_axes * freq_dim_per_axis
|
| 207 |
+
return (n_in * dim + dim) + (dim * dim + dim) + (dim * 6 * dim + 6 * dim)
|
backup_2026-09-14/code/baseline/base.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""动作臂基类:baseline/SPEC.md 里钩子接口的默认实现(全部恒等)。
|
| 2 |
+
|
| 3 |
+
各 baseline(linear / xattn / prompt / adaln)继承它,只覆盖自己用到的钩子。
|
| 4 |
+
`arope_forward(arm=None)` 时一个钩子都不会被调用,所以基类本身不影响 ARoPE 臂。
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
import torch.nn as nn
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ActionArm(nn.Module):
|
| 13 |
+
name = "base"
|
| 14 |
+
|
| 15 |
+
def install(self, dit) -> None:
|
| 16 |
+
"""需要按 dit 的 dim / num_heads / 层数建参数时在这里做(build_arm 会调用一次)。"""
|
| 17 |
+
|
| 18 |
+
# ---- 钩子:签名固定,见 baseline/SPEC.md ----
|
| 19 |
+
def encode(self, action_inputs: dict | None, grid: dict):
|
| 20 |
+
"""动作输入 → 特征(任意张量或 tuple),只算一次;None 表示这个臂不需要。"""
|
| 21 |
+
return None
|
| 22 |
+
|
| 23 |
+
def modify_t_mod(self, t_mod: torch.Tensor, feats, grid: dict) -> torch.Tensor:
|
| 24 |
+
return t_mod
|
| 25 |
+
|
| 26 |
+
def extra_context(self, context: torch.Tensor, feats, grid: dict) -> torch.Tensor:
|
| 27 |
+
return context
|
| 28 |
+
|
| 29 |
+
def after_patch(self, x: torch.Tensor, feats, grid: dict) -> torch.Tensor:
|
| 30 |
+
return x
|
| 31 |
+
|
| 32 |
+
def block_pre(self, idx: int, x: torch.Tensor, feats, grid: dict) -> torch.Tensor:
|
| 33 |
+
return x
|
| 34 |
+
|
| 35 |
+
def block_mid(self, idx: int, x: torch.Tensor, feats, grid: dict) -> torch.Tensor:
|
| 36 |
+
return x
|
| 37 |
+
|
| 38 |
+
def block_pre_ffn(self, idx: int, x: torch.Tensor, feats, grid: dict) -> torch.Tensor:
|
| 39 |
+
"""文本 cross-attn 之后、FFN 之前。"""
|
| 40 |
+
return x
|
| 41 |
+
|
| 42 |
+
def block_post(self, idx: int, x: torch.Tensor, feats, grid: dict) -> torch.Tensor:
|
| 43 |
+
return x
|
| 44 |
+
|
| 45 |
+
# ---- 工具 ----
|
| 46 |
+
def n_new_params(self) -> int:
|
| 47 |
+
return sum(p.numel() for p in self.parameters())
|
| 48 |
+
|
| 49 |
+
def zero_init_check(self) -> bool:
|
| 50 |
+
""""装上瞬间不改变输出"的粗检:至少有一条通向残差流的路径是零。子类按自己的结构覆盖。"""
|
| 51 |
+
return True
|
backup_2026-09-14/code/baseline/linear.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""`linear` 臂:ReactiveGWM 的逐块线性动作偏置(30 个 bias-free Linear,加在每个 DiT block 入口)。
|
| 2 |
+
|
| 3 |
+
上游机制(vender/ReactiveGWM,只读)
|
| 4 |
+
-----------------------------------
|
| 5 |
+
* 模型 `inference/models/dit.py::WanModelAction`(同一个 Wan2.2-TI2V-5B 基座,dim 3072 / 30 层):
|
| 6 |
+
- L207-209 `self.action_embedders = nn.ModuleList([nn.Linear(num_buttons, dim, bias=False) for _ in range(num_layers)])`
|
| 7 |
+
—— 每个 block 一个 **无偏置** 线性层,把动作向量投到 hidden 维;没有门控(docstring:"No gates")。
|
| 8 |
+
- L222-226 `_bin_action`:原始逐帧键盘 one-hot `[B, T_raw, K]` 用 `adaptive_max_pool1d` 压到 latent 帧数 `[B, f, K]`
|
| 9 |
+
(每个 latent 帧 = 4 个视频帧的"按过即 1")。
|
| 10 |
+
- L228-234 `_inject_action`:`emb = action_embedders[i](action[B,f,K].to(x.dtype))` → `[B, f, C]`,
|
| 11 |
+
沿 h·w 展开成逐 token 偏置 `[B, f·h·w, C]`,`x = x + bias`。
|
| 12 |
+
- L284-286 前向:`for i, block: x = _inject_action(x, action, i, f, h, w); x = block(x, ctx, t_mod, freqs)`
|
| 13 |
+
—— 偏置加在 **block 入口的残差流**(进 norm1/self-attn 之前),不是 adaLN、不是 cross-attn。
|
| 14 |
+
* 动作向量 `training/data/action_utils.py` / `inference/utils/actions.py`:parquet 的 10 个按键列
|
| 15 |
+
(`inference/constants.py::SF_BUTTON_COLS`,UP/DOWN/LEFT/RIGHT + 6 攻击键)0/1 值,`hold_last_upsample`(10 帧窗)
|
| 16 |
+
补成逐视频帧 dense one-hot,不做归一化。
|
| 17 |
+
* 初始化:`training/bidirectional/train.py` L17-18 / L134-137:"ActionModule keys stay at their default (zero / xavier) init",
|
| 18 |
+
其余权重从 Wan2.2-TI2V-5B 按形状拷贝(`_transfer_weights`)。训练时 action_embedders 与 DiT 全参数同 lr 训练;
|
| 19 |
+
推理有 `action_cfg_scale`(`inference/pipeline.py` L218-224,动作置零做无动作分支)。
|
| 20 |
+
* 上游参数量:30 × 10 × 3072 = 921,600(10 键)。
|
| 21 |
+
|
| 22 |
+
本实现的对应(baseline/SPEC.md 的钩子)
|
| 23 |
+
--------------------------------------
|
| 24 |
+
* `encode`:取 `action_inputs["offset_tok"]`(float32 `[b, 21, 2]`,逐 cell 累计屏幕位移 (dx, dy),单位 token,
|
| 25 |
+
与 ARoPE 臂**完全相同的控制信号**——旧报告口径"带动作的臂收到相同的逐 cell 累计偏移")。
|
| 26 |
+
上游的"逐帧 one-hot → adaptive_max_pool 到 f 个 latent 帧"这一步,在我们这边由 sidecar 的
|
| 27 |
+
`frames_to_cells`(cell 内逐帧偏移取均值)已经做完,dataset 直接给逐 cell 的 `[b, 21, 2]`,
|
| 28 |
+
所以这里不再池化,只做形状校验。不归一化(上游也不归一化;零初始化下量纲只影响学习动态,不影响装上瞬间的等价性)。
|
| 29 |
+
cell 0 的偏移恒为 0 ⇒ 无偏置 Linear 在条件帧上的偏置恒为 0,与"首帧是干净条件帧"一致。
|
| 30 |
+
* `block_pre(i, x)`:`x + expand(action_embedders[i](offset_tok))`,与上游 `_inject_action` 同位置同算式。
|
| 31 |
+
小矩阵乘在 fp32 里算再转回 x.dtype:offset_tok 最大约 ±11 token,bf16 在该量级的分辨率是 1/16 token = 2 px,
|
| 32 |
+
fp32 保住亚像素信息;上游输入是 0/1 one-hot,bf16 本来就无损,所以它直接 `.to(x.dtype)`。
|
| 33 |
+
* 参数量:30 × 2 × 3072 = **184,320 ≈ 0.18M**(与旧报告 +0.18M 一致:旧报告就是 2 维偏移输入)。
|
| 34 |
+
* 初始化:**全零**(SPEC 要求"装上瞬间输出与原模型逐位相同";上游 docstring 也把 zero 列为默认之一)。
|
| 35 |
+
零权重 ⇒ 偏置张量逐位为 0 ⇒ `x + 0 == x` 逐位相等。
|
| 36 |
+
* ckpt 键:`action_embedders.{i}.weight`(与上游键布局同名;integration 阶段导出时加 `arm.` 前缀,与 DiT 键无冲突)。
|
| 37 |
+
"""
|
| 38 |
+
from __future__ import annotations
|
| 39 |
+
|
| 40 |
+
import torch
|
| 41 |
+
import torch.nn as nn
|
| 42 |
+
import torch.nn.functional as F
|
| 43 |
+
|
| 44 |
+
from baseline.base import ActionArm
|
| 45 |
+
|
| 46 |
+
# 上游 SF_BUTTON_COLS 是 10 维 one-hot;这里换成 SPEC 的 (dx, dy) 累计偏移,2 维
|
| 47 |
+
ACTION_DIM = 2
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class LinearArm(ActionArm):
|
| 51 |
+
name = "linear"
|
| 52 |
+
|
| 53 |
+
def __init__(self, dim: int = 3072, num_layers: int = 30, in_dim: int = ACTION_DIM):
|
| 54 |
+
super().__init__()
|
| 55 |
+
self.dim, self.num_layers, self.in_dim = dim, num_layers, in_dim
|
| 56 |
+
self.action_embedders = self._build(dim, num_layers, in_dim)
|
| 57 |
+
|
| 58 |
+
@staticmethod
|
| 59 |
+
def _build(dim: int, num_layers: int, in_dim: int) -> nn.ModuleList:
|
| 60 |
+
# 与上游 dit.py L207-209 同构:每层一个 bias-free Linear;权重零初始化(见模块 docstring)
|
| 61 |
+
embedders = nn.ModuleList([nn.Linear(in_dim, dim, bias=False) for _ in range(num_layers)])
|
| 62 |
+
for lin in embedders:
|
| 63 |
+
nn.init.zeros_(lin.weight)
|
| 64 |
+
return embedders
|
| 65 |
+
|
| 66 |
+
def install(self, dit) -> None:
|
| 67 |
+
"""按 dit 的 dim / 层数建参数,并放到 dit 的 device / dtype(与 DiT 一起以 bf16 训练)。"""
|
| 68 |
+
dim, num_layers = int(dit.dim), len(dit.blocks)
|
| 69 |
+
if (dim, num_layers) != (self.dim, self.num_layers):
|
| 70 |
+
self.dim, self.num_layers = dim, num_layers
|
| 71 |
+
self.action_embedders = self._build(dim, num_layers, self.in_dim)
|
| 72 |
+
ref = dit.patch_embedding.weight
|
| 73 |
+
self.to(device=ref.device, dtype=ref.dtype)
|
| 74 |
+
|
| 75 |
+
# ---- 钩子 ----
|
| 76 |
+
def encode(self, action_inputs: dict | None, grid: dict):
|
| 77 |
+
"""offset_tok [b, f, 2] → 同一张量(float32)。None ⇒ 不注入(对应上游 keyboard_action=None 的分支)。"""
|
| 78 |
+
if action_inputs is None:
|
| 79 |
+
return None
|
| 80 |
+
off = action_inputs["offset_tok"]
|
| 81 |
+
b, f = grid["b"], grid["f"]
|
| 82 |
+
assert off.shape == (b, f, self.in_dim), f"offset_tok 形状应为 [{b}, {f}, {self.in_dim}],得到 {tuple(off.shape)}"
|
| 83 |
+
return off.to(torch.float32)
|
| 84 |
+
|
| 85 |
+
def block_pre(self, idx: int, x: torch.Tensor, feats, grid: dict) -> torch.Tensor:
|
| 86 |
+
"""上游 `_inject_action`:逐帧线性偏置沿帧内 h·w 个 token 广播后加到残差流。"""
|
| 87 |
+
if feats is None:
|
| 88 |
+
return x
|
| 89 |
+
b, f, n = grid["b"], grid["f"], grid["n_tok_per_frame"]
|
| 90 |
+
weight = self.action_embedders[idx].weight
|
| 91 |
+
emb = F.linear(feats.to(device=x.device), weight.to(torch.float32)).to(x.dtype) # [b, f, C]
|
| 92 |
+
bias = emb.unsqueeze(2).expand(b, f, n, self.dim).reshape(b, f * n, self.dim)
|
| 93 |
+
return x + bias
|
| 94 |
+
|
| 95 |
+
# ---- 工具 ----
|
| 96 |
+
def zero_init_check(self) -> bool:
|
| 97 |
+
return all(bool((lin.weight == 0).all()) for lin in self.action_embedders)
|
backup_2026-09-14/code/baseline/prompt.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prompt 臂(Incantation 移植):动作走文本,逐 latent 帧(逐 cell)的 prompt 控制。零新增参数、零钩子。
|
| 2 |
+
|
| 3 |
+
上游机制(vender/Incantation,只读)
|
| 4 |
+
--------------------------------------
|
| 5 |
+
Incantation 的动作接口**纯粹是自然语言**,模型里没有任何动作专用模块:
|
| 6 |
+
|
| 7 |
+
* 模型结构 `modules/causal_model.py`:`CausalWanModel` 与 Wan2.2-TI2V-5B 的 `WanModel` 参数名逐一对应
|
| 8 |
+
(:9 "Weight-compatible with WanModel"、:47-48、:238、:284-286、:386-389),子模块只有
|
| 9 |
+
patch_embedding / text_embedding / time_embedding / time_projection / blocks / head(:434-450),
|
| 10 |
+
没有 action embedding、action cross-attn、adaLN 动作支路。`modules/attention.py` 只有注意力后端、
|
| 11 |
+
RoPE、Norm,同样没有动作模块。它与教师的唯一差异是自注意力改成分块因果 + KV cache(:93-133、:135-224),
|
| 12 |
+
那是流式生成的效率设计,不是动作通路。
|
| 13 |
+
* 动作 → 文本 `inference.py:76-159 PromptGenerator`:动作脚本(`"Move forward"` 或 `["Move forward", 秒]`)
|
| 14 |
+
按 `TIME_STEP = 0.25 s`(:85)展开成时间线,每个 latent 帧一条 prompt:
|
| 15 |
+
`_steps = max(1, round(duration / 0.25))`(:133-134),没给时长的用 `DURATION_MAP`(:87-131,缺省 1.0 s);
|
| 16 |
+
两个实体的时间线取最长者、短的用最后一个动作补齐(:152-154);模板
|
| 17 |
+
`f"person_1 performs {p}. boss_1 performs {b}."`(:157)。0.25 s = 16 fps 下 4 帧 = 恰好 1 个 latent 帧,
|
| 18 |
+
与本项目"cell k = 帧 4k−3…4k"的粒度完全一致。
|
| 19 |
+
* 文本编码 `inference.py:247-260 encode_prompts`:每条**唯一**串过一次 UMT5 得 [512, 4096](去重,:250-258),
|
| 20 |
+
再按帧展开成 list(:259)。
|
| 21 |
+
* 逐帧对齐 `inference.py:318-353 generate`:第 fi 个 latent 帧用 `prompt_embeds_list[pi]` 做 context(:320-321),
|
| 22 |
+
每帧开始把 `crossattn_cache["is_init"]` 清掉(:323-324)重新算该帧文本的 k/v。也就是说**每帧 token 只
|
| 23 |
+
cross-attend 自己那帧的文本**——没有 attention mask 拼接,也没有跨帧的文本序列;逐帧对齐靠"一次只生成一帧"实现。
|
| 24 |
+
* 逐帧文本掩码 `causal_model.py:340-344`(`cross_out * cross_attn_mask`)+ :673-686 / :760-772
|
| 25 |
+
(`frame_mask = (t > 0)`):只有噪声帧(t > 0)收文本,干净帧(t = 0,含写 KV cache 的那一遍)不收,
|
| 26 |
+
注释说明是为了"文本信息不泄漏进自注意力 KV cache"(:760-764)。
|
| 27 |
+
* 已发布的训练前向 `causal_model.py:589-712 _forward_train` 收的 context 是**每个样本一条**(:650-655),
|
| 28 |
+
并不逐帧;逐帧文本只在流式推理里出现。训练配方(Self-Forcing 式蒸馏、逐帧 rollout)未开源
|
| 29 |
+
(README "Release Status")。
|
| 30 |
+
|
| 31 |
+
本实现的对应关系
|
| 32 |
+
----------------
|
| 33 |
+
本仓库 `arope_forward` 原生支持逐 cell 文本(`actionrope/arope.py::_embed_context / _block_forward`:
|
| 34 |
+
context `[b, f, L, 4096]` ⇒ q 重排成 `(b f) (h w) c`,k/v 为 `(b f) L c`,每帧 token 只 attend 自己 cell 的文本;
|
| 35 |
+
唯一串只算一次 text_embedding,`context_ids` 去重),这正是 Incantation "逐 latent 帧一条 prompt、每帧只看自己
|
| 36 |
+
文本" 的双向并行版本。所以这个臂**不需要任何钩子**(全部继承基类的恒等实现),只声明它要的数据契约:
|
| 37 |
+
|
| 38 |
+
* `text_mode = "scene_action"`:数据集不剥动作从句,动作词按 `prompts.mirror_action` 翻回真实屏幕方向
|
| 39 |
+
(`text_table_eybx_mirror.pt`)。这就是它的动作通道。
|
| 40 |
+
* `per_cell_context = True`:context 必须是 `[b, f, 512, 4096]`(dataset 默认如此)。`extra_context` 里做核对,
|
| 41 |
+
拿到整条 context 直接报错,避免"以为逐 cell 其实整条"的静默错误(`require_per_cell=False` 可放行)。
|
| 42 |
+
* `uses_offset_px = False`:`arope_forward(offset_px=None)`(普通 RoPE)、无 mask 通道、loss 权重全 1。
|
| 43 |
+
`action_inputs` 里的 `offset_px / offset_tok / delta_tok / action_idx` 一个都不用——Incantation 也没有任何
|
| 44 |
+
非文本的动作输入;`action_idx` 的信息已经以动作从句的形式在文本里(infer 侧由 `velocity_to_label(mirror=False)`
|
| 45 |
+
得到同一个标签再拼进 prompt)。
|
| 46 |
+
|
| 47 |
+
一致处 / 差异处(逐条)
|
| 48 |
+
----------------------
|
| 49 |
+
1. [一致] 动作只走文本,模型零改动、零新增参数(Incantation 的 student 与教师权重同构)。
|
| 50 |
+
2. [一致] 粒度:1 条 prompt / 1 个 latent 帧(0.25 s = 4 视频帧)= 本项目的 1 个 cell。
|
| 51 |
+
3. [一致] 逐帧对齐方式:每帧 token 只 cross-attend 自己那帧的文本 k/v,不是把各帧文本拼成长序列,
|
| 52 |
+
也不是 attention mask。Incantation 靠逐帧流式生成天然实现,这里靠 `(b f)` 分组的 cross-attn 并行实现;
|
| 53 |
+
所有 cell 文本相同时两者都退化成整条条件。
|
| 54 |
+
4. [一致] 文本编码去重:唯一串只编码一次(`encode_prompts` ↔ `_embed_context` + `context_ids`)。
|
| 55 |
+
5. [一致] 时间线展开规则:`round(秒 / 0.25)` 个 cell,缺省时长 1.0 s,脚本短于视频用最后一个动作补齐
|
| 56 |
+
(见下面 `script_to_timeline`)。
|
| 57 |
+
6. [差异] prompt 模板:Incantation 是多实体槽位 `person_1 performs {p}. boss_1 performs {b}.`,不含场景
|
| 58 |
+
(场景由首帧给);本数据集是单实体 + 场景从句 `In a 2.5D top down view, {scene}, the player is {action}.`
|
| 59 |
+
(`data/code/scenes.py::PROMPT_TMPL`)。不能换成 Incantation 模板:模型在本数据集模板上训练、推理时
|
| 60 |
+
查的是预计算文本表(不挂 T5),且转场语料要求场景从句逐 cell 变化。动作词表也不同
|
| 61 |
+
(Incantation 是 Elden Ring 的 11 个玩家动作 + 31 个 boss 动作;这里是 9 个方向标签)。
|
| 62 |
+
7. [差异,未复刻] `cross_attn_mask = (t > 0)`:Incantation 的干净帧不收文本。本实现里帧 0(TI2V 条件帧,t=0)
|
| 63 |
+
照常 cross-attend cell 0 的文本。原因:(a) 它是因果 student 的 KV-cache 设计(防文本泄漏进 cache),
|
| 64 |
+
不是动作接口本身;本模型双向、无 cache,不存在泄漏问题;(b) 钩子接口里 cross-attn 输出没有单独的
|
| 65 |
+
挂点(`block_mid` 在 cross-attn 之前、`block_post` 在 ffn 之后),且 `cross_attn.o` 带 bias,无法靠构造
|
| 66 |
+
context 让输出恰为 0,精确复刻要改 `actionrope/arope.py`(加一个逐帧 cross-attn 掩码参数);
|
| 67 |
+
(c) 复刻会让"装上瞬间输出与原模型相同"不成立,并且与其它四臂(帧 0 都 attend 场景文本)不对等。
|
| 68 |
+
若 integration 决定要它,见文末 `CROSS_ATTN_MASK_NOTE`。
|
| 69 |
+
8. [差异] 自注意力:Incantation 是分块因果(帧 f 只看 ≤ f 的帧)+ KV cache 流式;本实现与其它四臂一样是
|
| 70 |
+
Wan 原生的双向全注意力,一次并行出 21 个 cell。这是生成范式的差异,五臂共享,不属于动作通路。
|
| 71 |
+
9. [差异] Incantation 已发布的训练前向是整条 context;本实现训练即逐 cell(与其推理一致,比其发布代码更"逐帧")。
|
| 72 |
+
|
| 73 |
+
信号 / 参数量 / 初始化
|
| 74 |
+
---------------------
|
| 75 |
+
* 信号:文本(动作从句),不读 `action_inputs`。
|
| 76 |
+
* 新增参数:0(Incantation 同样为 0:其 student 的 state_dict 键集与教师一致)。
|
| 77 |
+
* 初始化:无新参数,装上瞬间与原模型**逐位**相同(`arope_forward(arm=PromptArm())` 只多了一次形状核对)。
|
| 78 |
+
* 旧报告口径:"Wan + Prompt" = `train.py --arm plain` 的行为,本臂与之完全相同,只是走 ActionArm 接口。
|
| 79 |
+
|
| 80 |
+
推理时"动作脚本 → 逐 cell prompt"
|
| 81 |
+
--------------------------------
|
| 82 |
+
对照 `vender/Incantation/prompts/prompts.txt`(手工模式:一行一条 prompt、逐帧)与
|
| 83 |
+
`examples/actions_example.json`(动作模式:`[["Move forward", 1.5], "Roll forward", ...]`),本臂建议:
|
| 84 |
+
|
| 85 |
+
* 动作模式(推荐,与 `infer.py --actions "right:1.0:10,left:1.0:11"` 同一套速度/方向约定):
|
| 86 |
+
`script_to_cell_prompts(scene, [["right", 2.5], "left", ["idle", 1.0]], trigger=None)` ⇒ 21 条
|
| 87 |
+
`PROMPT_TMPL` 串;`script_to_action_spec(script, mult)` ⇒ `--actions` 字符串,让 `offset_px`
|
| 88 |
+
(只用于实测位移对比、不进模型)与文本说的方向一致。动作词 = 真实屏幕方向(`mirror=False` 口径)。
|
| 89 |
+
* 手工模式:一行一条、共 21 行(cell 0 是条件帧也要给),串必须能在 `text_table_eybx_mirror.pt` 查到,
|
| 90 |
+
否则回退加载 UMT5(`infer.py::TextEncoder`)。
|
| 91 |
+
* CFG 负条件:逐 cell 裸动作串 `In a 2.5D top down view, the player is {action}.`
|
| 92 |
+
(训练 dropout 用的就是它,`infer.py::BARE_TMPL`),不是 `NULL_PROMPT`(不在 eybx 表里)。
|
| 93 |
+
"""
|
| 94 |
+
from __future__ import annotations
|
| 95 |
+
|
| 96 |
+
import os
|
| 97 |
+
import sys
|
| 98 |
+
|
| 99 |
+
import torch
|
| 100 |
+
|
| 101 |
+
from baseline.base import ActionArm
|
| 102 |
+
|
| 103 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 104 |
+
if os.path.join(ROOT, "data", "code") not in sys.path:
|
| 105 |
+
sys.path.insert(0, os.path.join(ROOT, "data", "code"))
|
| 106 |
+
from scenes import ACTIONS, PROMPT_TMPL, SCENE_NL # noqa: E402
|
| 107 |
+
|
| 108 |
+
# ---- Incantation 的时间线常量(inference.py:85, :133-134, :140) ----
|
| 109 |
+
TIME_STEP_S = 0.25 # 1 条 prompt 覆盖 0.25 s = 1 个 latent 帧 = 本项目 1 个 cell
|
| 110 |
+
DEFAULT_DURATION_S = 1.0 # DURATION_MAP 查不到时的缺省时长(inference.py:140)
|
| 111 |
+
CELLS = 21
|
| 112 |
+
|
| 113 |
+
# 动作脚本里的方向名 → scenes.ACTIONS 下标(与 actionrope/infer.py::DIR_NAMES 一致;不 import infer 是因为
|
| 114 |
+
# 它 import 时会带起 opencv / diffsynth 等推理依赖)
|
| 115 |
+
LABEL_NAMES = ["idle", "up", "up-left", "left", "down-left", "down", "down-right", "right", "up-right"]
|
| 116 |
+
DIR_NAMES = {
|
| 117 |
+
"idle": 0, "up": 1, "up-left": 2, "left": 3, "down-left": 4,
|
| 118 |
+
"down": 5, "down-right": 6, "right": 7, "up-right": 8,
|
| 119 |
+
"ul": 2, "dl": 4, "dr": 6, "ur": 8, "u": 1, "d": 5, "l": 3, "r": 7,
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
CROSS_ATTN_MASK_NOTE = (
|
| 123 |
+
"Incantation 的 (t > 0) 逐帧 cross-attn 掩码未复刻。要复刻需在 actionrope/arope.py::_block_forward 的 "
|
| 124 |
+
"cross_attn 之后乘一个 [b, S, 1] 的逐帧掩码(帧 0 为 0),并在 arope_forward 加参数透传;"
|
| 125 |
+
"这会改变帧 0 token 的表示(进而经自注意力影响全部帧),装上瞬间就不再与原模型逐位相同。"
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class PromptArm(ActionArm):
|
| 130 |
+
"""零钩子:动作全在逐 cell 文本里,模型本身一个参数都不加。"""
|
| 131 |
+
|
| 132 |
+
name = "prompt"
|
| 133 |
+
# ---- 数据 / 前向契约(integration 按这几个属性配 dataset 与 arope_forward)----
|
| 134 |
+
text_mode = "scene_action" # 动作从句保留并翻回真实方向(text_table_eybx_mirror.pt)
|
| 135 |
+
per_cell_context = True # context 必须是 [b, f, 512, 4096]
|
| 136 |
+
uses_offset_px = False # arope_forward(offset_px=None):普通 RoPE
|
| 137 |
+
mask_channel = False # 无 known/new 掩码通道
|
| 138 |
+
new_weight = 1.0 # loss 权重全 1
|
| 139 |
+
|
| 140 |
+
def __init__(self, require_per_cell: bool = True):
|
| 141 |
+
super().__init__()
|
| 142 |
+
self.require_per_cell = bool(require_per_cell)
|
| 143 |
+
|
| 144 |
+
def install(self, dit) -> None:
|
| 145 |
+
"""无参数可建;只记下几何信息供 extra_context 的核对用。"""
|
| 146 |
+
self._dim = int(getattr(dit, "dim", 0))
|
| 147 |
+
|
| 148 |
+
def encode(self, action_inputs, grid: dict):
|
| 149 |
+
"""不消费 action_inputs(Incantation 没有非文本的动作输入)。返回 None ⇒ 钩子收到 feats=None。"""
|
| 150 |
+
return None
|
| 151 |
+
|
| 152 |
+
def extra_context(self, context: torch.Tensor, feats, grid: dict) -> torch.Tensor:
|
| 153 |
+
"""恒等;只核对 context 真是逐 cell 的([(b f), L, dim]),否则这个臂根本没有动作通道。"""
|
| 154 |
+
if self.require_per_cell and grid is not None:
|
| 155 |
+
b, f = int(grid["b"]), int(grid["f"])
|
| 156 |
+
if context.shape[0] != b * f:
|
| 157 |
+
raise ValueError(
|
| 158 |
+
f"prompt 臂要求逐 cell 文本(context [b, f, 512, 4096] ⇒ 嵌入后 {b * f} 行),"
|
| 159 |
+
f"得到 {context.shape[0]} 行。请给 dataset text_mode='scene_action' 的 [b, 21, 512, 4096] context;"
|
| 160 |
+
f"确要整条 context 用 PromptArm(require_per_cell=False)。"
|
| 161 |
+
)
|
| 162 |
+
return context
|
| 163 |
+
|
| 164 |
+
# ---- 工具 ----
|
| 165 |
+
def n_new_params(self) -> int:
|
| 166 |
+
return 0
|
| 167 |
+
|
| 168 |
+
def zero_init_check(self) -> bool:
|
| 169 |
+
return True # 没有任何通向残差流的新路径
|
| 170 |
+
|
| 171 |
+
def describe(self) -> dict:
|
| 172 |
+
return dict(name=self.name, text_mode=self.text_mode, per_cell_context=self.per_cell_context,
|
| 173 |
+
uses_offset_px=self.uses_offset_px, mask_channel=self.mask_channel,
|
| 174 |
+
new_weight=self.new_weight, n_new_params=0, upstream="Incantation (text-only action interface)")
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
# --------------------------------------------------------------------------
|
| 178 |
+
# 推理:动作脚本 → 逐 cell prompt(对照 inference.py::PromptGenerator)
|
| 179 |
+
# --------------------------------------------------------------------------
|
| 180 |
+
|
| 181 |
+
def _steps(duration_s: float) -> int:
|
| 182 |
+
"""Incantation 的 `_steps`:max(1, round(秒 / 0.25))。"""
|
| 183 |
+
return max(1, int(round(float(duration_s) / TIME_STEP_S)))
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def script_to_timeline(script: list, n_cells: int = CELLS, default_duration_s: float = DEFAULT_DURATION_S) -> list[int]:
|
| 187 |
+
"""动作脚本 → 长度 n_cells 的动作标签时间线(scenes.ACTIONS 下标)。
|
| 188 |
+
|
| 189 |
+
脚本项:`"right"`(缺省时长)或 `["right", 秒]`(None 秒 ⇒ 缺省),方向名见 DIR_NAMES,也可直接给标签下标。
|
| 190 |
+
展开规则照 Incantation:每项 round(秒/0.25) 个 cell;总长不足 n_cells 用最后一个动作补齐
|
| 191 |
+
(脚本为空 ⇒ idle,对应其 "Standing");超出则截断(本项目视频固定 21 个 cell,Incantation 是按脚本定长)。
|
| 192 |
+
"""
|
| 193 |
+
tl: list[int] = []
|
| 194 |
+
for item in script:
|
| 195 |
+
if isinstance(item, (list, tuple)):
|
| 196 |
+
name, dur = item[0], (item[1] if len(item) > 1 and item[1] is not None else default_duration_s)
|
| 197 |
+
else:
|
| 198 |
+
name, dur = item, default_duration_s
|
| 199 |
+
if isinstance(name, str):
|
| 200 |
+
key = name.strip().lower()
|
| 201 |
+
if key not in DIR_NAMES:
|
| 202 |
+
raise ValueError(f"未知动作 {name!r},可选 {sorted(DIR_NAMES)} 或标签下标 0..8")
|
| 203 |
+
label = DIR_NAMES[key]
|
| 204 |
+
else:
|
| 205 |
+
label = int(name)
|
| 206 |
+
if not 0 <= label < len(ACTIONS):
|
| 207 |
+
raise ValueError(f"动作标签下标越界: {label}")
|
| 208 |
+
tl += [label] * _steps(dur)
|
| 209 |
+
if not tl:
|
| 210 |
+
tl = [0]
|
| 211 |
+
tl += [tl[-1]] * (n_cells - len(tl))
|
| 212 |
+
return tl[:n_cells]
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def scene_clause(scene: str) -> str:
|
| 216 |
+
"""scene key(tidal_flats)或完整从句("in a ...")。"""
|
| 217 |
+
if scene in SCENE_NL:
|
| 218 |
+
return SCENE_NL[scene]
|
| 219 |
+
if scene.lower().startswith("in a"):
|
| 220 |
+
return scene
|
| 221 |
+
raise ValueError(f"scene 应为 key {sorted(SCENE_NL)} 或 'in a ...' 从句,得到 {scene!r}")
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def timeline_to_cell_prompts(scene: str, timeline: list[int], trigger: str | None = None) -> list[str]:
|
| 225 |
+
"""标签时间线 → 21 条数据集模板 prompt(动作词 = 真实屏幕方向,配 text_table_eybx_mirror.pt)。
|
| 226 |
+
|
| 227 |
+
trigger "burn:<B>:<start>:<n>" / "cut:<B>:<start>:<n>":与 infer.py::compose_cell_prompts 同一约定,
|
| 228 |
+
cell [start, start+n) 用转场从句,之后切成纯 B 场景。
|
| 229 |
+
"""
|
| 230 |
+
if len(timeline) != CELLS:
|
| 231 |
+
raise ValueError(f"时间线应有 {CELLS} 个 cell,得到 {len(timeline)}")
|
| 232 |
+
clauses = [scene_clause(scene)] * CELLS
|
| 233 |
+
if trigger:
|
| 234 |
+
kind, b, start, n = trigger.split(":")
|
| 235 |
+
start, n = int(start), int(n)
|
| 236 |
+
tmpl = {"burn": "{a} burning away into {b}", "cut": "{a} cutting to {b}"}
|
| 237 |
+
if kind not in tmpl:
|
| 238 |
+
raise ValueError(f"trigger 类型应为 burn/cut,得到 {kind!r}")
|
| 239 |
+
if not (0 <= start < CELLS and n >= 1):
|
| 240 |
+
raise ValueError(f"trigger 的 start/n 越界: {start}/{n}")
|
| 241 |
+
b_nl = scene_clause(b)
|
| 242 |
+
trig = tmpl[kind].format(a=clauses[0], b=b_nl[3:] if b_nl.startswith("in ") else b_nl)
|
| 243 |
+
for k in range(start, CELLS):
|
| 244 |
+
clauses[k] = trig if k < start + n else b_nl
|
| 245 |
+
return [PROMPT_TMPL.format(scene=c, action=ACTIONS[l]) for c, l in zip(clauses, timeline)]
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def script_to_cell_prompts(scene: str, script: list, trigger: str | None = None) -> list[str]:
|
| 249 |
+
"""Incantation 风格动作脚本 → 21 条逐 cell prompt。例:
|
| 250 |
+
script_to_cell_prompts("tidal_flats", [["right", 2.5], "left", ["idle", 1.0]])
|
| 251 |
+
"""
|
| 252 |
+
return timeline_to_cell_prompts(scene, script_to_timeline(script), trigger)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def script_to_action_spec(script: list, mult: float = 1.0) -> str:
|
| 256 |
+
"""同一脚本 → infer.py 的 `--actions` 串("right:1.0:10,left:1.0:11"),让实测位移的参考指令与文本一致。
|
| 257 |
+
|
| 258 |
+
时间线里连续相同的标签合并成一段;idle 段速度为 0("idle:0:n")。
|
| 259 |
+
"""
|
| 260 |
+
tl = script_to_timeline(script)
|
| 261 |
+
segs: list[tuple[int, int]] = []
|
| 262 |
+
for lab in tl:
|
| 263 |
+
if segs and segs[-1][0] == lab:
|
| 264 |
+
segs[-1] = (lab, segs[-1][1] + 1)
|
| 265 |
+
else:
|
| 266 |
+
segs.append((lab, 1))
|
| 267 |
+
return ",".join(f"{LABEL_NAMES[lab]}:{0.0 if lab == 0 else mult}:{n}" for lab, n in segs)
|
backup_2026-09-14/code/baseline/xattn.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""xattn 臂:Matrix-Game 3 的动作模块(键盘 cross-attn + 鼠标时间轴 self-attn)移植到 Wan2.2-TI2V-5B。
|
| 2 |
+
|
| 3 |
+
上游机制(vender/Matrix-Game/Matrix-Game-3,只读)
|
| 4 |
+
------------------------------------------------
|
| 5 |
+
* `wan/modules/action_module.py::ActionModule`(L39-333,"action module from GameFactory"):
|
| 6 |
+
- 输入:`keyboard_condition [B, N_frames, C_key]`(逐视频帧、多热按键向量,`utils/conditions.py` L73-107
|
| 7 |
+
的 KEYBOARD_IDX = forward/back/left/right 各占一位,按下为 1;同时按两键 ⇒ 两位为 1);
|
| 8 |
+
`mouse_condition [B, N_frames, 2]`(逐视频帧的连续相机增量,CAM_VALUE=0.1 量级)。
|
| 9 |
+
N_frames = 4·(T−1)+1,T = latent 帧数(L192-198)。
|
| 10 |
+
- 时间窗口(L207-214 / L268-281):在帧序列前面补 `pad_t = 4·windows_size` 个帧 0 的拷贝,
|
| 11 |
+
latent 帧 i 取补齐后 `[4i, 4i+pad_t)` 这 12 帧(= 该 latent 之前 3 个 latent 跨度里的按键),
|
| 12 |
+
展平成一个 `windows·4·C` 向量 ⇒ 每个 latent 帧一条动作 token。
|
| 13 |
+
- 键盘分支(L267-332):`keyboard_embed`(Linear(C_key,128)-SiLU-Linear(128,128))逐帧嵌入后按窗口展平成 1536 维,
|
| 14 |
+
`keyboard_attn_kv: 1536 → 2·1024` 得到 k/v(每 latent 帧一条 token);视频 token 经 `mouse_attn_q: dim → 1024`
|
| 15 |
+
得到 q;q/k 各做无仿射 RMSNorm(head_dim 64,eps 1e-6;`WanRMSNorm.weight` 是死参数,forward 没用,L16-28);
|
| 16 |
+
q 重排成 `(B S) T H D`(S = h·w,同一空间位置的 T 帧作为一条序列),k/v 沿时间轴(T 条)与之做
|
| 17 |
+
**非因果** attention(`causal=False`),q/k 先加时间轴 RoPE(`rope_dim_list=[8,28,28]`,`theta=256`,
|
| 18 |
+
h/w 轴位置恒为 0,等价于只旋转每头前 8 维,L146-184 / L308-315);`proj_keyboard: 1024 → dim`,残差相加。
|
| 19 |
+
- 鼠标分支(L201-265):把视频 token 重排成 `(B S) T C`,与同窗口展平的鼠标量(12·2 = 24 维)在通道上拼接,
|
| 20 |
+
`mouse_mlp`(Linear(dim+24,1024)-GELU-Linear(1024,1024)-LayerNorm)后 `t_qkv: 1024 → 3·1024`,
|
| 21 |
+
在时间轴 T 上做 self-attention(同样的 RMSNorm + 时间 RoPE),`proj_mouse: 1024 → dim` 残差相加。
|
| 22 |
+
鼠标分支在键盘分支之前,键盘分支的 q 取的是加过鼠标输出之后的 hidden。
|
| 23 |
+
* `wan/modules/model.py`:L533-536 只有 `block_idx ∈ action_config['blocks']` 的 block 才建 ActionModule;
|
| 24 |
+
L617-662 在 block 内 **文本 cross-attn 之后、FFN 之前** 调 `self.action_model(x, T, h, w, mouse_cond, keyboard_cond)`;
|
| 25 |
+
L1097-1103 `init_weights` 把 `proj_mouse` / `proj_keyboard` 的权重和偏置置零(其余 Linear xavier_uniform、偏置 0)。
|
| 26 |
+
* 配置:Matrix-Game-3 仓库不带 config.json;`Matrix-Game-2/configs/distilled_model/universal/config.json` 给出
|
| 27 |
+
`blocks=[0..14]`(前 15 块)、`hidden_size=128, keyboard_hidden_dim=mouse_hidden_dim=1024, heads_num=16,
|
| 28 |
+
windows_size=3, vae_time_compression_ratio=4, rope_theta=256, rope_dim_list=[8,28,28], qk_norm=True, qkv_bias=False`,
|
| 29 |
+
与 ActionModule 的默认值一致;本实现沿用这组超参(`img_hidden_size` 换成本模型的 dim=3072)。
|
| 30 |
+
|
| 31 |
+
本实现的对应关系
|
| 32 |
+
----------------
|
| 33 |
+
* 钩子位置:用 `block_pre_ffn`(文本 cross-attn 之后、FFN 之前),与上游 model.py 的注入位置一致
|
| 34 |
+
(2026-09-05 arope_forward 补了这个钩子;之前用的 block_mid 比上游早一个子层)。
|
| 35 |
+
上游在 `mouse_cond is not None` 时还有 `x = self.norm3(x)` 直接替换残差流的写法(model.py L600-601),
|
| 36 |
+
这会在零初始化时就改变预训练模型的输出,与"装上不改输出"矛盾,这里不复刻。
|
| 37 |
+
* 信号:
|
| 38 |
+
- 键盘 ← `action_inputs["action_idx"]`([b, 21] 的 9 类标签),按上游 KEYBOARD_IDX 的键位编码映射成 4 位多热
|
| 39 |
+
[up, down, left, right](对角 = 两键同按,idle = 全 0;9 类 ↔ 9 个互异向量,与 one-hot 等价的信息量,
|
| 40 |
+
见 `KEY_TABLE`),即 `keyboard_dim_in=4`(Matrix-Game-2 配置的取值)。
|
| 41 |
+
- 鼠标 ← `action_inputs["delta_tok"]`([b, 21, 2] 逐 cell 速度,token 单位,连续量),对应上游的相机增量。
|
| 42 |
+
- 数据是逐 cell 的,上游是逐视频帧的:按 SPEC 的 cell↔帧映射(帧 0 = cell 0,帧 4k−3…4k = cell k)把每个 cell 的
|
| 43 |
+
值复制到它的 4 帧上,再走上游一模一样的补齐 + 12 帧窗口(`_window_cell_index`)。cell 内 4 帧相同,
|
| 44 |
+
窗口里会出现重复列——这是上游结构的直接后果,保留不动(也是参数量 1536→2048 的来源)。
|
| 45 |
+
窗口长度用视频帧数 `window_frames` 表示(默认 4·windows_size = 12;`window_frames=1` 退化成每 cell 一条嵌入)。
|
| 46 |
+
- 动作特征在 `encode` 里只算一次:`[b, f, 4·windows_size, 6]`(前 4 列按键、后 2 列鼠标),作为一个张量穿过梯度检查点;
|
| 47 |
+
各 block 的 ActionModule 自己做 embed / kv 投影(上游每块一份参数,不共享)。
|
| 48 |
+
* 上游 `k.repeat(S,1,1,1)` 与 `rearrange(q, "B (T S) ... -> (B S) T ...")` 的 batch 排列在 B>1 时对不上
|
| 49 |
+
(前者 s 外 b 内,后者 b 外 s 内),这里用 expand 按 (b, s) 正确对齐;B=1 时两者相同。
|
| 50 |
+
* attention 用 `F.scaled_dot_product_attention`(上游是 flash_attn_func,数学等价);RMSNorm 在 float32 里算再转回。
|
| 51 |
+
|
| 52 |
+
新增参数量(dim=3072,前 15 块,每块一份)
|
| 53 |
+
-----------------------------------------
|
| 54 |
+
* 键盘分支每块 9,454,336:keyboard_embed 17,152 + mouse_attn_q 3072·1024 + keyboard_attn_kv 1536·2048 + proj_keyboard 1024·3072
|
| 55 |
+
⇒ 15 块 141,815,040(≈141.8M)。
|
| 56 |
+
* 鼠标分支每块 10,514,432:mouse_mlp 4,222,976(含 LayerNorm 2,048)+ t_qkv 1024·3072 + proj_mouse 1024·3072
|
| 57 |
+
⇒ 15 块 157,716,480(≈157.7M)。
|
| 58 |
+
* 默认(键盘 + 鼠标)合计 299,531,520(≈299.5M)。
|
| 59 |
+
* 旧报告的 +98.6M 对应 `enable_mouse=False, window_frames=1`(每个 latent 帧只取一帧按键嵌入,kv 输入 128 → 2048,
|
| 60 |
+
每块 6,570,752,15 块 98,561,280):即"只复刻键盘通路、不做 12 帧窗口"。上游默认的 4·windows_size=12 帧窗口把 kv
|
| 61 |
+
输入放大到 1536,仅键盘通路就是 141.8M。本实现按上游代码保留窗口与鼠标分支,二者都可用构造参数关掉。
|
| 62 |
+
|
| 63 |
+
初始化
|
| 64 |
+
------
|
| 65 |
+
所有 Linear 权重 xavier_uniform、偏置 0(上游 `init_weights`),`proj_keyboard` / `proj_mouse` 权重置零且无偏置(qkv_bias=False),
|
| 66 |
+
LayerNorm 取默认 (1, 0)。两条分支都只经 proj 进入残差流,所以装上瞬间输出与原模型逐位相同(`zero_init_check`)。
|
| 67 |
+
"""
|
| 68 |
+
from __future__ import annotations
|
| 69 |
+
|
| 70 |
+
from typing import Iterable
|
| 71 |
+
|
| 72 |
+
import torch
|
| 73 |
+
import torch.nn as nn
|
| 74 |
+
import torch.nn.functional as F
|
| 75 |
+
from einops import rearrange
|
| 76 |
+
|
| 77 |
+
from baseline.base import ActionArm
|
| 78 |
+
|
| 79 |
+
# ActionModule 的时间压缩比:一个 latent 帧对应 4 个视频帧(与 SPEC 的 cell↔帧一致)
|
| 80 |
+
VAE_TIME_RATIO = 4
|
| 81 |
+
# 上游 rope_dim_list=[8,28,28] 里只有 t 轴那 8 维真的在旋转(h/w 位置恒 0)
|
| 82 |
+
ROPE_T_DIM = 8
|
| 83 |
+
ROPE_THETA = 256.0
|
| 84 |
+
|
| 85 |
+
# actionrope.prompts.ACTIONS 的 9 类 → 上游 KEYBOARD_IDX 风格的多热按键 [up(forward), down(back), left, right]
|
| 86 |
+
KEY_TABLE = torch.tensor([
|
| 87 |
+
[0, 0, 0, 0], # 0 standing idle
|
| 88 |
+
[1, 0, 0, 0], # 1 moving up
|
| 89 |
+
[1, 0, 1, 0], # 2 moving up-left
|
| 90 |
+
[0, 0, 1, 0], # 3 moving left
|
| 91 |
+
[0, 1, 1, 0], # 4 moving down-left
|
| 92 |
+
[0, 1, 0, 0], # 5 moving down
|
| 93 |
+
[0, 1, 0, 1], # 6 moving down-right
|
| 94 |
+
[0, 0, 0, 1], # 7 moving right
|
| 95 |
+
[1, 0, 0, 1], # 8 moving up-right
|
| 96 |
+
], dtype=torch.float32)
|
| 97 |
+
KEYBOARD_DIM_IN = KEY_TABLE.shape[1]
|
| 98 |
+
MOUSE_DIM_IN = 2
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _rms_norm(x: torch.Tensor, eps: float = 1e-6) -> torch.Tensor:
|
| 102 |
+
"""上游 WanRMSNorm(无仿射;它的 weight 在 forward 里没用到)。float32 里算,回到输入 dtype。"""
|
| 103 |
+
xf = x.float()
|
| 104 |
+
return (xf * torch.rsqrt(xf.pow(2).mean(dim=-1, keepdim=True) + eps)).to(x.dtype)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _temporal_rope(x: torch.Tensor) -> torch.Tensor:
|
| 108 |
+
"""上游 get_rotary_pos_embed + apply_rotary_emb 在 rope_sizes=[T,1,1] 下的等价形式。
|
| 109 |
+
|
| 110 |
+
x: [N, T, H, D]。只有每头前 ROPE_T_DIM 维按时间位置 0..T-1 旋转(相邻两维成对,theta=256),
|
| 111 |
+
其余维位置为 0(cos=1, sin=0)原样保留。上游在 float32 里算再 type_as 回去,这里一样。
|
| 112 |
+
"""
|
| 113 |
+
T, D = x.shape[1], x.shape[-1]
|
| 114 |
+
assert D >= ROPE_T_DIM
|
| 115 |
+
half = ROPE_T_DIM // 2
|
| 116 |
+
inv = 1.0 / (ROPE_THETA ** (torch.arange(0, ROPE_T_DIM, 2, device=x.device).float() / ROPE_T_DIM)) # [4]
|
| 117 |
+
ang = torch.arange(T, device=x.device).float()[:, None] * inv[None, :] # [T, 4]
|
| 118 |
+
cos = ang.cos().view(1, T, 1, half, 1)
|
| 119 |
+
sin = ang.sin().view(1, T, 1, half, 1)
|
| 120 |
+
xr = x[..., :ROPE_T_DIM].float().reshape(x.shape[0], T, x.shape[2], half, 2)
|
| 121 |
+
x_re, x_im = xr.unbind(-1)
|
| 122 |
+
rot = torch.stack([x_re * cos[..., 0] - x_im * sin[..., 0],
|
| 123 |
+
x_re * sin[..., 0] + x_im * cos[..., 0]], dim=-1).flatten(-2)
|
| 124 |
+
return torch.cat([rot.to(x.dtype), x[..., ROPE_T_DIM:]], dim=-1)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _window_cell_index(f: int, window_frames: int) -> torch.Tensor:
|
| 128 |
+
"""上游"补 pad_t 帧 + 每 latent 取 [4i, 4i+pad_t)"的窗口(pad_t = 4·windows_size),折算成 [f, pad_t] 的 cell 下标。
|
| 129 |
+
|
| 130 |
+
补齐后第 p 个视频帧 = 原视频帧 max(p − pad_t, 0),即 latent i 看的是原视频帧 [4i − pad_t, 4i);
|
| 131 |
+
原视频帧 j 属于 cell (j+3)//4(帧 0 = cell 0,帧 4k−3…4k = cell k)。这样 feats 直接按 cell gather,
|
| 132 |
+
不必真的展开成 81 帧。pad_t 这里放宽成任意 window_frames(=1 时 latent i 只看帧 4i−1,即 cell i 自己)。
|
| 133 |
+
"""
|
| 134 |
+
pad_t = window_frames
|
| 135 |
+
i = torch.arange(f).view(f, 1)
|
| 136 |
+
j = torch.arange(pad_t).view(1, pad_t)
|
| 137 |
+
frame = (VAE_TIME_RATIO * i + j - pad_t).clamp(min=0)
|
| 138 |
+
return (frame + VAE_TIME_RATIO - 1) // VAE_TIME_RATIO
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
class MG3ActionModule(nn.Module):
|
| 142 |
+
"""一个 block 的动作模块(上游 ActionModule 去掉 memory 分支,B>1 的 k/v 对齐修正)。"""
|
| 143 |
+
|
| 144 |
+
def __init__(self, img_hidden_size: int, *, hidden_size: int = 128, keyboard_hidden_dim: int = 1024,
|
| 145 |
+
mouse_hidden_dim: int = 1024, heads_num: int = 16, window_frames: int = 12,
|
| 146 |
+
enable_keyboard: bool = True, enable_mouse: bool = True, qkv_bias: bool = False):
|
| 147 |
+
super().__init__()
|
| 148 |
+
self.heads_num = heads_num
|
| 149 |
+
self.window_frames = window_frames
|
| 150 |
+
self.enable_keyboard = enable_keyboard
|
| 151 |
+
self.enable_mouse = enable_mouse
|
| 152 |
+
win = window_frames # 上游 = vae_time_compression_ratio · windows_size = 12
|
| 153 |
+
if enable_mouse:
|
| 154 |
+
c = mouse_hidden_dim
|
| 155 |
+
self.mouse_mlp = nn.Sequential(
|
| 156 |
+
nn.Linear(MOUSE_DIM_IN * win + img_hidden_size, c, bias=True),
|
| 157 |
+
nn.GELU(approximate="tanh"),
|
| 158 |
+
nn.Linear(c, c),
|
| 159 |
+
nn.LayerNorm(c),
|
| 160 |
+
)
|
| 161 |
+
self.t_qkv = nn.Linear(c, c * 3, bias=qkv_bias)
|
| 162 |
+
self.proj_mouse = nn.Linear(c, img_hidden_size, bias=qkv_bias)
|
| 163 |
+
if enable_keyboard:
|
| 164 |
+
self.keyboard_embed = nn.Sequential(
|
| 165 |
+
nn.Linear(KEYBOARD_DIM_IN, hidden_size, bias=True), nn.SiLU(), nn.Linear(hidden_size, hidden_size, bias=True))
|
| 166 |
+
self.mouse_attn_q = nn.Linear(img_hidden_size, keyboard_hidden_dim, bias=qkv_bias) # 名字照抄上游:其实是键盘分支的 q
|
| 167 |
+
self.keyboard_attn_kv = nn.Linear(hidden_size * win, keyboard_hidden_dim * 2, bias=qkv_bias)
|
| 168 |
+
self.proj_keyboard = nn.Linear(keyboard_hidden_dim, img_hidden_size, bias=qkv_bias)
|
| 169 |
+
self.reset_parameters()
|
| 170 |
+
|
| 171 |
+
def reset_parameters(self) -> None:
|
| 172 |
+
"""上游 init_weights:Linear xavier_uniform + 零偏置;proj_* 置零(进入残差流的唯一路径)。"""
|
| 173 |
+
for m in self.modules():
|
| 174 |
+
if isinstance(m, nn.Linear):
|
| 175 |
+
nn.init.xavier_uniform_(m.weight)
|
| 176 |
+
if m.bias is not None:
|
| 177 |
+
nn.init.zeros_(m.bias)
|
| 178 |
+
elif isinstance(m, nn.LayerNorm):
|
| 179 |
+
nn.init.ones_(m.weight)
|
| 180 |
+
nn.init.zeros_(m.bias)
|
| 181 |
+
for name in ("proj_mouse", "proj_keyboard"):
|
| 182 |
+
if hasattr(self, name):
|
| 183 |
+
nn.init.zeros_(getattr(self, name).weight)
|
| 184 |
+
|
| 185 |
+
def _attend(self, q: torch.Tensor, k: torch.Tensor, v: torch.Tensor) -> torch.Tensor:
|
| 186 |
+
"""q/k/v: [N, T, H, D];RMSNorm + 时间 RoPE + 非因果 SDPA,返回 [N, T, H·D]。"""
|
| 187 |
+
q = _temporal_rope(_rms_norm(q).to(v.dtype))
|
| 188 |
+
k = _temporal_rope(_rms_norm(k).to(v.dtype))
|
| 189 |
+
out = F.scaled_dot_product_attention(q.transpose(1, 2), k.transpose(1, 2), v.transpose(1, 2), is_causal=False)
|
| 190 |
+
return out.transpose(1, 2).flatten(2)
|
| 191 |
+
|
| 192 |
+
def forward(self, x: torch.Tensor, feats: torch.Tensor, grid: dict) -> torch.Tensor:
|
| 193 |
+
"""x: [b, (f h w), dim];feats: [b, f, window_frames, 4+2](按键多热 + 鼠标连续量,已按窗口展开)。"""
|
| 194 |
+
b, f, S = grid["b"], grid["f"], grid["n_tok_per_frame"]
|
| 195 |
+
assert x.shape[1] == f * S and feats.shape[:2] == (b, f)
|
| 196 |
+
H = self.heads_num
|
| 197 |
+
dtype = x.dtype
|
| 198 |
+
hidden = x
|
| 199 |
+
|
| 200 |
+
if self.enable_mouse:
|
| 201 |
+
# 上游 L201-265:同一空间位置的 T 帧作为序列,与窗口鼠标量拼接后做时间轴 self-attention
|
| 202 |
+
mouse_win = feats[..., KEYBOARD_DIM_IN:].reshape(b, f, -1).to(dtype) # [b, f, 24],(window d) 排列
|
| 203 |
+
h_t = rearrange(hidden, "b (f s) c -> (b s) f c", f=f)
|
| 204 |
+
m = mouse_win.unsqueeze(1).expand(b, S, f, mouse_win.shape[-1]).reshape(b * S, f, -1)
|
| 205 |
+
g = self.mouse_mlp(torch.cat([h_t, m], dim=-1))
|
| 206 |
+
q, k, v = rearrange(self.t_qkv(g), "n t (k h d) -> k n t h d", k=3, h=H)
|
| 207 |
+
attn = rearrange(self._attend(q, k, v), "(b s) f c -> b (f s) c", b=b)
|
| 208 |
+
hidden = hidden + self.proj_mouse(attn)
|
| 209 |
+
|
| 210 |
+
if self.enable_keyboard:
|
| 211 |
+
# 上游 L267-332:每 latent 帧一条键盘 token 作 k/v,视频 token 作 q,沿时间轴 cross-attention
|
| 212 |
+
key_win = feats[..., :KEYBOARD_DIM_IN].to(self.keyboard_embed[0].weight.dtype) # [b, f, 12, 4]
|
| 213 |
+
key_tok = self.keyboard_embed(key_win).reshape(b, f, -1) # [b, f, 12·128]
|
| 214 |
+
q = rearrange(self.mouse_attn_q(hidden), "b (f s) (h d) -> (b s) f h d", f=f, h=H)
|
| 215 |
+
k, v = rearrange(self.keyboard_attn_kv(key_tok), "b f (k h d) -> k b f h d", k=2, h=H)
|
| 216 |
+
# 上游 k.repeat(S) 在 B>1 时与 q 的 (b s) 排列错位,这里按 (b, s) 展开
|
| 217 |
+
k = k.unsqueeze(1).expand(b, S, *k.shape[1:]).reshape(b * S, *k.shape[1:])
|
| 218 |
+
v = v.unsqueeze(1).expand(b, S, *v.shape[1:]).reshape(b * S, *v.shape[1:])
|
| 219 |
+
attn = rearrange(self._attend(q, k, v), "(b s) f c -> b (f s) c", b=b)
|
| 220 |
+
hidden = hidden + self.proj_keyboard(attn)
|
| 221 |
+
return hidden
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
class XAttnArm(ActionArm):
|
| 225 |
+
"""Matrix-Game 3 动作模块臂:前 `blocks` 个 block 的 block_pre_ffn 钩子上各挂一个 MG3ActionModule。"""
|
| 226 |
+
|
| 227 |
+
name = "xattn"
|
| 228 |
+
|
| 229 |
+
def __init__(self, blocks: Iterable[int] = range(15), *, enable_keyboard: bool = True, enable_mouse: bool = True,
|
| 230 |
+
windows_size: int = 3, window_frames: int | None = None, hidden_size: int = 128,
|
| 231 |
+
keyboard_hidden_dim: int = 1024, mouse_hidden_dim: int = 1024, heads_num: int = 16):
|
| 232 |
+
"""windows_size 是上游的单位(latent 帧数,窗口 = 4·windows_size 个视频帧);window_frames 给了就直接按视频帧数
|
| 233 |
+
(旧报告口径 `enable_mouse=False, window_frames=1`)。"""
|
| 234 |
+
super().__init__()
|
| 235 |
+
self.blocks = sorted(int(i) for i in blocks)
|
| 236 |
+
if window_frames is None:
|
| 237 |
+
window_frames = VAE_TIME_RATIO * windows_size
|
| 238 |
+
assert window_frames >= 1
|
| 239 |
+
self.window_frames = window_frames
|
| 240 |
+
self.cfg = dict(enable_keyboard=enable_keyboard, enable_mouse=enable_mouse, window_frames=window_frames,
|
| 241 |
+
hidden_size=hidden_size, keyboard_hidden_dim=keyboard_hidden_dim,
|
| 242 |
+
mouse_hidden_dim=mouse_hidden_dim, heads_num=heads_num)
|
| 243 |
+
# ModuleDict 的键用 block 下标的字符串:state_dict 键形如 action_modules.3.proj_keyboard.weight
|
| 244 |
+
self.action_modules = nn.ModuleDict()
|
| 245 |
+
|
| 246 |
+
def install(self, dit) -> None:
|
| 247 |
+
assert len(self.action_modules) == 0, "XAttnArm 只能 install 一次"
|
| 248 |
+
assert max(self.blocks) < len(dit.blocks), f"blocks 超出 DiT 层数 {len(dit.blocks)}"
|
| 249 |
+
ref = dit.patch_embedding.weight
|
| 250 |
+
for i in self.blocks:
|
| 251 |
+
self.action_modules[str(i)] = MG3ActionModule(dit.dim, **self.cfg)
|
| 252 |
+
# 参数与 DiT 同 dtype / 同卡一起训练(ZeRO-2 下混合 dtype 会出问题)
|
| 253 |
+
self.action_modules.to(device=ref.device, dtype=ref.dtype)
|
| 254 |
+
|
| 255 |
+
# ---- 钩子 ----
|
| 256 |
+
def encode(self, action_inputs: dict | None, grid: dict):
|
| 257 |
+
"""action_idx [b, f] + delta_tok [b, f, 2] → 窗口特征 float32 [b, f, window_frames, 6]。"""
|
| 258 |
+
if action_inputs is None:
|
| 259 |
+
return None
|
| 260 |
+
b, f = grid["b"], grid["f"]
|
| 261 |
+
idx = torch.as_tensor(action_inputs["action_idx"]).long()
|
| 262 |
+
assert idx.shape == (b, f), f"action_idx 应为 [b={b}, f={f}],得到 {tuple(idx.shape)}"
|
| 263 |
+
device = idx.device
|
| 264 |
+
keys = KEY_TABLE.to(device)[idx] # [b, f, 4]
|
| 265 |
+
if self.cfg["enable_mouse"]:
|
| 266 |
+
delta = torch.as_tensor(action_inputs["delta_tok"]).to(device=device, dtype=torch.float32)
|
| 267 |
+
assert delta.shape == (b, f, 2), f"delta_tok 应为 [b={b}, f={f}, 2],得到 {tuple(delta.shape)}"
|
| 268 |
+
else:
|
| 269 |
+
delta = torch.zeros(b, f, 2, device=device)
|
| 270 |
+
cond = torch.cat([keys, delta], dim=-1) # [b, f, 6]
|
| 271 |
+
win = _window_cell_index(f, self.window_frames).to(device) # [f, window_frames]
|
| 272 |
+
return cond[:, win] # [b, f, window_frames, 6]
|
| 273 |
+
|
| 274 |
+
def block_pre_ffn(self, idx: int, x: torch.Tensor, feats, grid: dict) -> torch.Tensor:
|
| 275 |
+
key = str(idx)
|
| 276 |
+
if feats is None or key not in self.action_modules:
|
| 277 |
+
return x
|
| 278 |
+
return self.action_modules[key](x, feats, grid)
|
| 279 |
+
|
| 280 |
+
# ---- 工具 ----
|
| 281 |
+
def zero_init_check(self) -> bool:
|
| 282 |
+
ok = len(self.action_modules) > 0
|
| 283 |
+
for mod in self.action_modules.values():
|
| 284 |
+
for name in ("proj_mouse", "proj_keyboard"):
|
| 285 |
+
if hasattr(mod, name):
|
| 286 |
+
lin = getattr(mod, name)
|
| 287 |
+
ok = ok and bool((lin.weight == 0).all()) and (lin.bias is None or bool((lin.bias == 0).all()))
|
| 288 |
+
return ok
|
backup_2026-09-14/code/configs/accelerate_zero2.yaml
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 8 卡 DeepSpeed ZeRO-2,bf16,无 offload,梯度裁剪 1.0,梯度累积 1。
|
| 2 |
+
# train_micro_batch_size_per_gpu 由 accelerate 填 "auto" ⇒ 取 train.py 里 DataLoader 的 batch_size(默认 1)。
|
| 3 |
+
# 不用 deepspeed_config_file:accelerate 1.14 里它与 mixed_precision 等键互斥,yaml 原生字段更省事。
|
| 4 |
+
compute_environment: LOCAL_MACHINE
|
| 5 |
+
debug: false
|
| 6 |
+
deepspeed_config:
|
| 7 |
+
gradient_accumulation_steps: 1
|
| 8 |
+
gradient_clipping: 1.0
|
| 9 |
+
offload_optimizer_device: none
|
| 10 |
+
offload_param_device: none
|
| 11 |
+
zero3_init_flag: false
|
| 12 |
+
zero_stage: 2
|
| 13 |
+
distributed_type: DEEPSPEED
|
| 14 |
+
downcast_bf16: 'no'
|
| 15 |
+
enable_cpu_affinity: false
|
| 16 |
+
machine_rank: 0
|
| 17 |
+
main_training_function: main
|
| 18 |
+
mixed_precision: bf16
|
| 19 |
+
num_machines: 1
|
| 20 |
+
num_processes: 8
|
| 21 |
+
rdzv_backend: static
|
| 22 |
+
same_network: true
|
| 23 |
+
tpu_env: []
|
| 24 |
+
tpu_use_cluster: false
|
| 25 |
+
tpu_use_sudo: false
|
| 26 |
+
use_cpu: false
|
backup_2026-09-14/code/configs/accelerate_zero2_4gpu.yaml
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 4 卡(自测用,配合 CUDA_VISIBLE_DEVICES=0,1,2,3) DeepSpeed ZeRO-2,bf16,无 offload,梯度裁剪 1.0,梯度累积 1。
|
| 2 |
+
# train_micro_batch_size_per_gpu 由 accelerate 填 "auto" ⇒ 取 train.py 里 DataLoader 的 batch_size(默认 1)。
|
| 3 |
+
# 不用 deepspeed_config_file:accelerate 1.14 里它与 mixed_precision 等键互斥,yaml 原生字段更省事。
|
| 4 |
+
compute_environment: LOCAL_MACHINE
|
| 5 |
+
debug: false
|
| 6 |
+
deepspeed_config:
|
| 7 |
+
gradient_accumulation_steps: 1
|
| 8 |
+
gradient_clipping: 1.0
|
| 9 |
+
offload_optimizer_device: none
|
| 10 |
+
offload_param_device: none
|
| 11 |
+
zero3_init_flag: false
|
| 12 |
+
zero_stage: 2
|
| 13 |
+
distributed_type: DEEPSPEED
|
| 14 |
+
downcast_bf16: 'no'
|
| 15 |
+
enable_cpu_affinity: false
|
| 16 |
+
machine_rank: 0
|
| 17 |
+
main_training_function: main
|
| 18 |
+
mixed_precision: bf16
|
| 19 |
+
num_machines: 1
|
| 20 |
+
num_processes: 4
|
| 21 |
+
rdzv_backend: static
|
| 22 |
+
same_network: true
|
| 23 |
+
tpu_env: []
|
| 24 |
+
tpu_use_cluster: false
|
| 25 |
+
tpu_use_sudo: false
|
| 26 |
+
use_cpu: false
|
backup_2026-09-14/code/configs/accelerate_zero2_ga2.yaml
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 与 accelerate_zero2.yaml 相同,只把梯度累积改成 2(全局 batch 16);train.py 传 --grad_accum 2
|
| 2 |
+
# 8 卡 DeepSpeed ZeRO-2,bf16,无 offload,梯度裁剪 1.0,梯度累积 1。
|
| 3 |
+
# train_micro_batch_size_per_gpu 由 accelerate 填 "auto" ⇒ 取 train.py 里 DataLoader 的 batch_size(默认 1)。
|
| 4 |
+
# 不用 deepspeed_config_file:accelerate 1.14 里它与 mixed_precision 等键互斥,yaml 原生字段更省事。
|
| 5 |
+
compute_environment: LOCAL_MACHINE
|
| 6 |
+
debug: false
|
| 7 |
+
deepspeed_config:
|
| 8 |
+
gradient_accumulation_steps: 2
|
| 9 |
+
gradient_clipping: 1.0
|
| 10 |
+
offload_optimizer_device: none
|
| 11 |
+
offload_param_device: none
|
| 12 |
+
zero3_init_flag: false
|
| 13 |
+
zero_stage: 2
|
| 14 |
+
distributed_type: DEEPSPEED
|
| 15 |
+
downcast_bf16: 'no'
|
| 16 |
+
enable_cpu_affinity: false
|
| 17 |
+
machine_rank: 0
|
| 18 |
+
main_training_function: main
|
| 19 |
+
mixed_precision: bf16
|
| 20 |
+
num_machines: 1
|
| 21 |
+
num_processes: 8
|
| 22 |
+
rdzv_backend: static
|
| 23 |
+
same_network: true
|
| 24 |
+
tpu_env: []
|
| 25 |
+
tpu_use_cluster: false
|
| 26 |
+
tpu_use_sudo: false
|
| 27 |
+
use_cpu: false
|
backup_2026-09-14/code/demo/engine.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""交互式 rollout 引擎:常驻模型 + 滑窗 KV + 流式因果 VAE,一次生成一个 cell。
|
| 2 |
+
|
| 3 |
+
与 `distill/eval/rollout_eval.py:deploy_rollout_flowmap` 走**同一条**采样路径(同样的
|
| 4 |
+
`frame_cond` / `fm.euler_step` / t=0 提交),区别只有三点,都是为了交互:
|
| 5 |
+
|
| 6 |
+
1. `off_ext` 是**增长的**,不预先铺满 —— 动作是玩家现给的。`frame_cond` 只看
|
| 7 |
+
[k−window, k] 这一段历史窗口,所以往后追加不影响任何已提交的 cell。
|
| 8 |
+
2. VAE 逐 cell 解码而不是最后整片解码。Wan 的 3D VAE 在时间上是因果的(decoder 带
|
| 9 |
+
`_feat_map` 缓存),把这个缓存跨调用留住就能无缝流式出帧 —— 见 StreamVAE。
|
| 10 |
+
整片 tiled 解码 161 帧要 3.6 s;流式下每 cell 只解 1 个 latent frame。
|
| 11 |
+
3. 不跑到头就停:无限续走。世界坐标是窗口锚定的(rope_cap == sink+kv_window),
|
| 12 |
+
所以走多远都不会越界 —— 这正是 ARoPE 相对编码的性质。
|
| 13 |
+
|
| 14 |
+
一个 cell = 4 帧 = 16 fps 下的 250 ms。实测稳态 N=4 时 DiT 约 175 ms/cell,
|
| 15 |
+
所以生成比播放快,**缓冲深度是唯一的延迟旋钮**:见 server.py 的 lead_cells。
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import os
|
| 20 |
+
import sys
|
| 21 |
+
import threading
|
| 22 |
+
import time
|
| 23 |
+
from collections import deque
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
import torch
|
| 27 |
+
|
| 28 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 29 |
+
if ROOT not in sys.path:
|
| 30 |
+
sys.path.insert(0, ROOT)
|
| 31 |
+
os.environ.setdefault("DIFFSYNTH_SKIP_DOWNLOAD", "True")
|
| 32 |
+
|
| 33 |
+
from actionrope import geometry as G # noqa: E402
|
| 34 |
+
from actionrope import infer # noqa: E402
|
| 35 |
+
from actionrope.prompts import NULL_PROMPT # noqa: E402
|
| 36 |
+
from distill import flowmap as fm # noqa: E402
|
| 37 |
+
from safetensors import safe_open # noqa: E402
|
| 38 |
+
from safetensors.torch import load_file # noqa: E402
|
| 39 |
+
|
| 40 |
+
from distill.arope_dit import AropeDiT, unwrap # noqa: E402
|
| 41 |
+
from distill.eval.rollout_eval import _ckpt_metadata, frames_to_cells_n # noqa: E402
|
| 42 |
+
from distill.stage3_dmd.dmd_arope import frame_cond # noqa: E402
|
| 43 |
+
|
| 44 |
+
CELL_FRAMES = 4
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def safetensors_keys(path: str) -> list[str]:
|
| 48 |
+
"""只读 header 拿键名 —— 不为了数个数就把 10 GB 读进内存。"""
|
| 49 |
+
with safe_open(path, "pt") as f:
|
| 50 |
+
return list(f.keys())
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class StreamVAE:
|
| 54 |
+
"""持有 Wan VAE decoder 的因果时间缓存,逐 latent frame 解码。
|
| 55 |
+
|
| 56 |
+
`VideoVAE_.decode` / `VideoVAE38_.decode` 每次调用都 `clear_cache()` 再从头跑整段;我们要的
|
| 57 |
+
是跨调用保留,所以把循环体拆出来。`conv2` 是 CausalConv3d(kernel=1)、时间感受野为 1,可逐帧过。
|
| 58 |
+
|
| 59 |
+
★ Wan2.2 的 VAE 是 `VideoVAE38_`,与 2.1 的 `VideoVAE_` 有两处不同,漏掉任何一处都直接报错
|
| 60 |
+
或出错帧(实测:漏 first_chunk ⇒ "size of tensor a (2) must match b (4)"):
|
| 61 |
+
1. **整段的第一个** latent frame 要 `first_chunk=True` —— 它决定 avg_shortcut 出 1 帧还是 4 帧
|
| 62 |
+
2. 循环之后要 `unpatchify(out, patch_size=2)`
|
| 63 |
+
所以第 1 帧出 1 帧、之后每帧出 4 帧,正好是 (F−1)/4+1 的压缩率。
|
| 64 |
+
"""
|
| 65 |
+
|
| 66 |
+
def __init__(self, vae, device):
|
| 67 |
+
self.vae = vae
|
| 68 |
+
self.m = vae.model
|
| 69 |
+
self.device = device
|
| 70 |
+
for attr in ("decoder", "conv2", "clear_cache"):
|
| 71 |
+
assert hasattr(self.m, attr), f"VAE 内核没有 {attr},DiffSynth 版本变了"
|
| 72 |
+
self.is38 = type(self.m).__name__.endswith("38_")
|
| 73 |
+
self.first = True
|
| 74 |
+
|
| 75 |
+
def reset(self):
|
| 76 |
+
self.m.clear_cache()
|
| 77 |
+
self.first = True
|
| 78 |
+
|
| 79 |
+
@torch.no_grad()
|
| 80 |
+
def decode_one(self, z: torch.Tensor) -> np.ndarray:
|
| 81 |
+
"""z [1,48,1,h,w] → uint8 [n,H,W,3](整段首次 n=1,之后 n=4)。"""
|
| 82 |
+
scale = self.vae.scale
|
| 83 |
+
z = z.to(self.device)
|
| 84 |
+
if isinstance(scale[0], torch.Tensor):
|
| 85 |
+
s0 = scale[0].to(dtype=z.dtype, device=z.device).view(1, -1, 1, 1, 1)
|
| 86 |
+
s1 = scale[1].to(dtype=z.dtype, device=z.device).view(1, -1, 1, 1, 1)
|
| 87 |
+
z = z / s1 + s0
|
| 88 |
+
else:
|
| 89 |
+
z = z / scale[1] + scale[0]
|
| 90 |
+
x = self.m.conv2(z)
|
| 91 |
+
self.m._conv_idx = [0]
|
| 92 |
+
kw = {"first_chunk": True} if (self.is38 and self.first) else {}
|
| 93 |
+
out, self.m._feat_map, self.m._conv_idx = self.m.decoder(
|
| 94 |
+
x, feat_cache=self.m._feat_map, feat_idx=self.m._conv_idx, **kw)
|
| 95 |
+
self.first = False
|
| 96 |
+
if self.is38:
|
| 97 |
+
from diffsynth.models.wan_video_vae import unpatchify
|
| 98 |
+
out = unpatchify(out, patch_size=2)
|
| 99 |
+
out = out.clamp_(-1, 1).float() # [1,3,n,H,W]
|
| 100 |
+
arr = ((out[0].permute(1, 2, 3, 0) + 1) * 127.5).clamp_(0, 255)
|
| 101 |
+
return arr.to(torch.uint8).cpu().numpy()
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class TinyStreamVAE:
|
| 105 |
+
"""TAEHV(madebyollin/taehv)的流式解码器 —— demo 的默认 VAE。
|
| 106 |
+
|
| 107 |
+
Wan2.2 5B 用 `taew2_2`;`_super` 是同一架构的大解码器,README 明说是给「实时世界模型、
|
| 108 |
+
TAEHV 输出即最终��出」的场景(我们正是),而这个分辨率下两者都是 3 ms/cell,所以默认取 super。
|
| 109 |
+
|
| 110 |
+
实测(480×832、单 H200、稳态):全量 Wan VAE 流式 87 ms/cell → TAEHV 3 ms/cell,快 ~30×。
|
| 111 |
+
合上 DiT 的 167 ms,一个 cell(4 帧 = 16 fps 下 250 ms)总共 170 ms ⇒ 0.68× 实时。
|
| 112 |
+
|
| 113 |
+
两处容易搞错:
|
| 114 |
+
· 布局是 **NTCHW**,我们的 latent 是 NCTHW,要 permute
|
| 115 |
+
· TAEHV **不做** mean/std 反缩放("decodes exactly what diffusion models use"),
|
| 116 |
+
所以喂的是 DiT 直接输出的 latent,不是 WanVideoVAE.decode 里那个 z/std+mean
|
| 117 |
+
"""
|
| 118 |
+
|
| 119 |
+
def __init__(self, weights: str, device, dtype=torch.float16):
|
| 120 |
+
import sys as _s
|
| 121 |
+
tp = os.path.join(ROOT, "vendor", "taehv")
|
| 122 |
+
if tp not in _s.path:
|
| 123 |
+
_s.path.insert(0, tp)
|
| 124 |
+
from taehv import TAEHV, StreamingTAEHV
|
| 125 |
+
self.device, self.dtype = device, dtype
|
| 126 |
+
self.tae = TAEHV(weights).to(device, dtype).eval()
|
| 127 |
+
self.st = StreamingTAEHV(self.tae)
|
| 128 |
+
|
| 129 |
+
def reset(self):
|
| 130 |
+
self.st.reset()
|
| 131 |
+
|
| 132 |
+
@torch.no_grad()
|
| 133 |
+
def decode_one(self, z: torch.Tensor) -> np.ndarray:
|
| 134 |
+
x = z.to(self.device, self.dtype).permute(0, 2, 1, 3, 4) # NCTHW -> NTCHW
|
| 135 |
+
out, frames = self.st.decode(x), []
|
| 136 |
+
while out is not None:
|
| 137 |
+
frames.append(out)
|
| 138 |
+
out = self.st.decode()
|
| 139 |
+
if not frames:
|
| 140 |
+
return np.zeros((0, 1, 1, 3), np.uint8)
|
| 141 |
+
v = torch.cat(frames, dim=1)[0] # [t,3,H,W],值域 [0,1]
|
| 142 |
+
v = (v.permute(0, 2, 3, 1).clamp(0, 1) * 255).to(torch.uint8)
|
| 143 |
+
return v.cpu().numpy()
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
class Engine:
|
| 147 |
+
"""常驻 GPU 的交互式生成器。generate 循环跑在自己的线程里。"""
|
| 148 |
+
|
| 149 |
+
def __init__(self, ckpt: str, *, device: str = "cuda", steps: int = 4,
|
| 150 |
+
sink_size: int = 3, kv_window: int = 17, rope_cap: int = 20,
|
| 151 |
+
scene: str | None = None, seed: int = 0, vae: str = "taehv_super",
|
| 152 |
+
log=print):
|
| 153 |
+
self.log = log
|
| 154 |
+
self.device = torch.device(device)
|
| 155 |
+
self.steps = steps
|
| 156 |
+
self.sink_size, self.kv_window, self.rope_cap = sink_size, kv_window, rope_cap
|
| 157 |
+
assert rope_cap == sink_size + kv_window, \
|
| 158 |
+
f"rope_cap({rope_cap}) 必须 == sink({sink_size})+window({kv_window})"
|
| 159 |
+
self.seed = seed
|
| 160 |
+
|
| 161 |
+
t0 = time.time()
|
| 162 |
+
# 832 键喂不进 load_models 的 strict 加载(它只认 827),所以:基座 → 装壳 → 灌权重。
|
| 163 |
+
# 顺序不能反:install_flowmap 会把读出层零初始化,装完再 load 才拿得到训出来的 delta。
|
| 164 |
+
# 与 rollout_eval:720-727 同一条路径。
|
| 165 |
+
meta = _ckpt_metadata(ckpt)
|
| 166 |
+
n_keys = len(safetensors_keys(ckpt))
|
| 167 |
+
if n_keys < 830:
|
| 168 |
+
raise SystemExit(f"{ckpt} 只有 {n_keys} 个键,不是 flow map 学生 —— demo 要 832 键的。")
|
| 169 |
+
self.pipe, dit, _ = infer.load_models(None, True, device, need_vae=True, arm="arope")
|
| 170 |
+
self.dit = AropeDiT(dit=dit, mask_channel=True, causal=True)
|
| 171 |
+
unwrap(self.dit).install_flowmap(form=meta.get("flowmap_form", "residual"),
|
| 172 |
+
gate=float(meta.get("delta_gate", 1.0)))
|
| 173 |
+
unwrap(self.dit).dit.load_state_dict(load_file(ckpt), strict=True)
|
| 174 |
+
self.dit.eval().requires_grad_(False)
|
| 175 |
+
unwrap(self.dit).move_rope_table(self.device)
|
| 176 |
+
if vae == "full":
|
| 177 |
+
self.svae = StreamVAE(self.pipe.vae, self.device)
|
| 178 |
+
else:
|
| 179 |
+
name = "taew2_2_super" if vae == "taehv_super" else "taew2_2"
|
| 180 |
+
w = os.path.join(ROOT, "vendor", "taehv", f"{name}.pth")
|
| 181 |
+
if not os.path.exists(w):
|
| 182 |
+
raise SystemExit(f"没有 {w} —— git clone https://github.com/madebyollin/taehv vendor/taehv")
|
| 183 |
+
self.svae = TinyStreamVAE(w, self.device)
|
| 184 |
+
self.vae_kind = vae
|
| 185 |
+
|
| 186 |
+
sc = torch.load(infer.SIDECAR, map_location="cpu", weights_only=False)
|
| 187 |
+
self.walk_speed = np.array(sc["meta"]["walk_speed_px_s"], np.float64)
|
| 188 |
+
|
| 189 |
+
# 文本走查表(与 rollout_eval:742 同一条);表里没有的串会回退去加载 UMT5,
|
| 190 |
+
# 那要多占 ~11 GB,所以 demo 默认用 NULL_PROMPT —— 它一定在表里。
|
| 191 |
+
enc = infer.TextEncoder(infer.TEXT_TABLE["arope"], device)
|
| 192 |
+
prompts = [infer.strip_action(scene) if scene else NULL_PROMPT] * G.CELLS
|
| 193 |
+
ctx, ids = infer.build_context(enc, prompts, device, per_cell=True)
|
| 194 |
+
# ARoPE 臂的文本只含场景、动作已剥掉,所以 21 格必然逐位相同 —— 相同才能在无限
|
| 195 |
+
# rollout 里复用同一格(等价于 extend_context 的 "hold")。不同就是口径出问题了。
|
| 196 |
+
assert torch.allclose(ctx[:, 0], ctx[:, -1]), "逐 cell 文本不一致,不能复用单格上下文"
|
| 197 |
+
self.ctx1, self.ids1 = ctx[:, :1].contiguous(), ids[:, :1].contiguous()
|
| 198 |
+
self.log(f"[engine] 模型 {time.time() - t0:.0f}s · {n_keys} 键 flow map · "
|
| 199 |
+
f"N={steps} · sink={sink_size} window={kv_window} · vae={vae}")
|
| 200 |
+
|
| 201 |
+
self.sigmas = fm.anystep_sigmas(steps)
|
| 202 |
+
fm.assert_anystep_axis(torch.as_tensor(self.sigmas), terminal=True)
|
| 203 |
+
|
| 204 |
+
self._lock = threading.Lock()
|
| 205 |
+
self._vel = np.zeros(2, np.float64) # 当前速度指令 px/s
|
| 206 |
+
self.out = deque() # 待推的帧
|
| 207 |
+
self._out_lock = threading.Lock()
|
| 208 |
+
self._stop = threading.Event()
|
| 209 |
+
self._thread: threading.Thread | None = None
|
| 210 |
+
self.lead_cells = 1
|
| 211 |
+
self.stats = {"cells": 0, "gen_ms": 0.0, "dec_ms": 0.0}
|
| 212 |
+
self.cache = None
|
| 213 |
+
|
| 214 |
+
# ---- 会话 ----
|
| 215 |
+
|
| 216 |
+
def reset(self, first_frame: np.ndarray):
|
| 217 |
+
"""用一张首帧开一局。会丢弃旧的 KV / VAE 缓存。"""
|
| 218 |
+
with self._lock:
|
| 219 |
+
bare = unwrap(self.dit)
|
| 220 |
+
if self.cache is not None:
|
| 221 |
+
bare.release_kv_cache(self.cache)
|
| 222 |
+
z0 = infer.encode_first_frame(self.pipe, first_frame, tiled=False)
|
| 223 |
+
self.z0 = z0
|
| 224 |
+
self.lat_h, self.lat_w = z0.shape[-2], z0.shape[-1]
|
| 225 |
+
self.cache = bare.init_kv_cache(kv_window=self.kv_window,
|
| 226 |
+
sink_size=self.sink_size, rope_cap=self.rope_cap)
|
| 227 |
+
self.k = 0
|
| 228 |
+
self.frame_off = np.zeros((1, 2), np.float64) # 逐帧世界偏移,随 cell 增长
|
| 229 |
+
self._push_off()
|
| 230 |
+
ow, mk = frame_cond(self.off_ext, 0, self.sink_size, self.kv_window,
|
| 231 |
+
self.lat_h, self.lat_w)
|
| 232 |
+
zeros = torch.zeros(1, device=self.device)
|
| 233 |
+
with torch.no_grad():
|
| 234 |
+
self._call(z0, zeros, 0, True, ow, mk) # 真首帧 t=r=0 进缓存
|
| 235 |
+
self.svae.reset()
|
| 236 |
+
with self._out_lock:
|
| 237 |
+
self.out.clear()
|
| 238 |
+
for f in self.svae.decode_one(z0):
|
| 239 |
+
self.out.append(f)
|
| 240 |
+
self.g = torch.Generator("cpu").manual_seed(self.seed)
|
| 241 |
+
self.stats = {"cells": 0, "gen_ms": 0.0, "dec_ms": 0.0}
|
| 242 |
+
|
| 243 |
+
def _push_off(self):
|
| 244 |
+
cells = self.k + 1
|
| 245 |
+
cell_off = frames_to_cells_n(self.frame_off, cells)
|
| 246 |
+
self.off_ext = torch.tensor(cell_off, dtype=torch.float32,
|
| 247 |
+
device=self.device).unsqueeze(0)
|
| 248 |
+
|
| 249 |
+
def set_velocity(self, vx: float, vy: float):
|
| 250 |
+
with self._lock:
|
| 251 |
+
self._vel = np.array([vx, vy], np.float64)
|
| 252 |
+
|
| 253 |
+
def velocity_from_keys(self, dx: float, dy: float, mult: float = 1.0) -> np.ndarray:
|
| 254 |
+
"""方向单位向量 → 逐轴速度(横纵步速不同是透视造成的,与 infer.parse_actions 同口径)。"""
|
| 255 |
+
n = float(np.hypot(dx, dy))
|
| 256 |
+
if n < 1e-9:
|
| 257 |
+
return np.zeros(2)
|
| 258 |
+
return self.walk_speed * np.array([dx, dy]) / n * mult
|
| 259 |
+
|
| 260 |
+
# ---- 生成 ----
|
| 261 |
+
|
| 262 |
+
def _call(self, x, t, k, commit, ow, mk, r=None):
|
| 263 |
+
return self.dit(x, t, self.ctx1, context_ids=self.ids1,
|
| 264 |
+
r_timestep=(t if r is None else r),
|
| 265 |
+
first_frame_cond=False, frame_index=k, kv_cache=self.cache,
|
| 266 |
+
commit=commit, offset_win=ow, mask_in_k=mk)
|
| 267 |
+
|
| 268 |
+
@torch.no_grad()
|
| 269 |
+
def step_once(self):
|
| 270 |
+
"""按当前速度生成并解码一个 cell。"""
|
| 271 |
+
with self._lock:
|
| 272 |
+
vel = self._vel.copy()
|
| 273 |
+
t0 = time.time()
|
| 274 |
+
# 4 帧的逐帧偏移递推,与 action_offsets 同一条
|
| 275 |
+
last = self.frame_off[-1]
|
| 276 |
+
new = np.stack([last + vel * (i + 1) / G.FPS for i in range(CELL_FRAMES)], 0)
|
| 277 |
+
self.frame_off = np.concatenate([self.frame_off, new], 0)
|
| 278 |
+
self.k += 1
|
| 279 |
+
self._push_off()
|
| 280 |
+
k = self.k
|
| 281 |
+
ow, mk = frame_cond(self.off_ext, k, self.sink_size, self.kv_window,
|
| 282 |
+
self.lat_h, self.lat_w)
|
| 283 |
+
x = torch.randn(1, self.z0.shape[1], 1, self.lat_h, self.lat_w,
|
| 284 |
+
generator=self.g).to(dtype=self.z0.dtype, device=self.device)
|
| 285 |
+
for i in range(len(self.sigmas) - 1):
|
| 286 |
+
s_t, s_r = float(self.sigmas[i]), float(self.sigmas[i + 1])
|
| 287 |
+
tt = torch.full((1,), s_t * fm.NUM_TRAIN_TIMESTEPS, device=self.device)
|
| 288 |
+
rr = torch.full((1,), s_r * fm.NUM_TRAIN_TIMESTEPS, device=self.device)
|
| 289 |
+
x = fm.euler_step(x, self._call(x, tt, k, False, ow, mk, r=rr), s_t, s_r)
|
| 290 |
+
zeros = torch.zeros(1, device=self.device)
|
| 291 |
+
self._call(x.detach(), zeros, k, True, ow, mk) # 每 cell 恰好提交一次,t=0
|
| 292 |
+
t1 = time.time()
|
| 293 |
+
frames = self.svae.decode_one(x)
|
| 294 |
+
t2 = time.time()
|
| 295 |
+
with self._out_lock:
|
| 296 |
+
for f in frames:
|
| 297 |
+
self.out.append(f)
|
| 298 |
+
self.stats = {"cells": k, "gen_ms": (t1 - t0) * 1e3, "dec_ms": (t2 - t1) * 1e3}
|
| 299 |
+
|
| 300 |
+
def _loop(self):
|
| 301 |
+
while not self._stop.is_set():
|
| 302 |
+
with self._out_lock:
|
| 303 |
+
buffered = len(self.out)
|
| 304 |
+
# 只保持 lead_cells 个 cell 的缓冲:多了就是白白增加操作延迟
|
| 305 |
+
if buffered >= self.lead_cells * CELL_FRAMES:
|
| 306 |
+
time.sleep(0.004)
|
| 307 |
+
continue
|
| 308 |
+
try:
|
| 309 |
+
self.step_once()
|
| 310 |
+
except Exception as e: # noqa: BLE001
|
| 311 |
+
self.log(f"[engine] 生成失败:{type(e).__name__}: {e}")
|
| 312 |
+
self._stop.set()
|
| 313 |
+
|
| 314 |
+
def start(self):
|
| 315 |
+
self._stop.clear()
|
| 316 |
+
self._thread = threading.Thread(target=self._loop, daemon=True, name="arope-engine")
|
| 317 |
+
self._thread.start()
|
| 318 |
+
|
| 319 |
+
def stop(self):
|
| 320 |
+
self._stop.set()
|
| 321 |
+
if self._thread is not None:
|
| 322 |
+
self._thread.join(timeout=5)
|
| 323 |
+
|
| 324 |
+
def pop_frame(self):
|
| 325 |
+
with self._out_lock:
|
| 326 |
+
return self.out.popleft() if self.out else None
|
backup_2026-09-14/code/demo/index.html
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<meta charset="utf-8">
|
| 3 |
+
<title>ActionRoPE — 交互 rollout</title>
|
| 4 |
+
<style>
|
| 5 |
+
:root { color-scheme: dark; }
|
| 6 |
+
body { margin:0; background:#0b0d10; color:#d8dee9; font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;
|
| 7 |
+
display:flex; flex-direction:column; align-items:center; gap:12px; padding:18px; }
|
| 8 |
+
video { width:min(95vw,832px); aspect-ratio:832/480; background:#000; border-radius:8px;
|
| 9 |
+
image-rendering:auto; outline:1px solid #222831; }
|
| 10 |
+
.row { display:flex; gap:14px; align-items:center; flex-wrap:wrap; justify-content:center; }
|
| 11 |
+
.k { display:inline-block; min-width:22px; padding:2px 6px; border:1px solid #3b4252; border-radius:4px;
|
| 12 |
+
text-align:center; background:#181b20; transition:background .08s; }
|
| 13 |
+
.k.on { background:#5e81ac; color:#fff; border-color:#81a1c1; }
|
| 14 |
+
select,button { background:#181b20; color:#d8dee9; border:1px solid #3b4252; border-radius:5px; padding:5px 9px;
|
| 15 |
+
font:inherit; cursor:pointer; }
|
| 16 |
+
#stats { color:#7b8494; font-size:12px; white-space:pre; }
|
| 17 |
+
b { color:#a3be8c; font-weight:600; }
|
| 18 |
+
</style>
|
| 19 |
+
|
| 20 |
+
<h3 style="margin:4px 0;color:#88c0d0">ActionRoPE · any-step AR rollout</h3>
|
| 21 |
+
<video id="v" autoplay playsinline muted></video>
|
| 22 |
+
|
| 23 |
+
<div class="row">
|
| 24 |
+
<span>移动</span>
|
| 25 |
+
<span class="k" id="kw">W</span><span class="k" id="ka">A</span>
|
| 26 |
+
<span class="k" id="ks">S</span><span class="k" id="kd">D</span>
|
| 27 |
+
<span style="color:#616e88">(或方向键 · Shift 加速)</span>
|
| 28 |
+
</div>
|
| 29 |
+
|
| 30 |
+
<div class="row">
|
| 31 |
+
<select id="ff"></select>
|
| 32 |
+
<button id="rst">重开</button>
|
| 33 |
+
<span>缓冲</span>
|
| 34 |
+
<select id="lead"><option value="1" selected>1 cell(最低延迟)</option>
|
| 35 |
+
<option value="2">2 cell</option><option value="3">3 cell(最稳)</option></select>
|
| 36 |
+
</div>
|
| 37 |
+
<div id="stats">连接中…</div>
|
| 38 |
+
|
| 39 |
+
<script>
|
| 40 |
+
const V=document.getElementById('v'), S=document.getElementById('stats');
|
| 41 |
+
let ws=null, keys={w:0,a:0,s:0,d:0}, shift=false, lastSent='';
|
| 42 |
+
|
| 43 |
+
const KEYMAP={KeyW:'w',KeyA:'a',KeyS:'s',KeyD:'d',
|
| 44 |
+
ArrowUp:'w',ArrowLeft:'a',ArrowDown:'s',ArrowRight:'d'};
|
| 45 |
+
|
| 46 |
+
function paint(){ for(const k of ['w','a','s','d'])
|
| 47 |
+
document.getElementById('k'+k).classList.toggle('on', !!keys[k]); }
|
| 48 |
+
|
| 49 |
+
function send(){
|
| 50 |
+
if(!ws || ws.readyState!==1) return;
|
| 51 |
+
// 屏幕坐标:y 向下为正。按 W 是往「前/上」走,对应世界偏移 dy 为负。
|
| 52 |
+
const dx=(keys.d?1:0)-(keys.a?1:0), dy=(keys.s?1:0)-(keys.w?1:0);
|
| 53 |
+
const mult=shift?1.6:1.0, sig=`${dx},${dy},${mult}`;
|
| 54 |
+
if(sig===lastSent) return; lastSent=sig;
|
| 55 |
+
ws.send(JSON.stringify({type:'action',dx,dy,mult}));
|
| 56 |
+
}
|
| 57 |
+
addEventListener('keydown',e=>{ if(e.repeat) return;
|
| 58 |
+
if(e.key==='Shift'){shift=true;send();return;}
|
| 59 |
+
const k=KEYMAP[e.code]; if(!k) return; e.preventDefault(); keys[k]=1; paint(); send(); });
|
| 60 |
+
addEventListener('keyup',e=>{
|
| 61 |
+
if(e.key==='Shift'){shift=false;send();return;}
|
| 62 |
+
const k=KEYMAP[e.code]; if(!k) return; e.preventDefault(); keys[k]=0; paint(); send(); });
|
| 63 |
+
addEventListener('blur',()=>{ keys={w:0,a:0,s:0,d:0}; shift=false; paint(); send(); });
|
| 64 |
+
|
| 65 |
+
async function start(){
|
| 66 |
+
const pc=new RTCPeerConnection({sdpSemantics:'unified-plan'});
|
| 67 |
+
pc.addTransceiver('video',{direction:'recvonly'});
|
| 68 |
+
pc.ontrack=e=>{ V.srcObject=e.streams[0]; };
|
| 69 |
+
await pc.setLocalDescription(await pc.createOffer());
|
| 70 |
+
// 等 ICE 收集完再发 offer:这是本机/隧道场景,没有 TURN,一次性发全比 trickle 简单可靠
|
| 71 |
+
await new Promise(r=>{ if(pc.iceGatheringState==='complete') return r();
|
| 72 |
+
pc.onicegatheringstatechange=()=>pc.iceGatheringState==='complete'&&r(); });
|
| 73 |
+
const a=await fetch('/offer',{method:'POST',headers:{'Content-Type':'application/json'},
|
| 74 |
+
body:JSON.stringify({sdp:pc.localDescription.sdp,type:pc.localDescription.type})}).then(r=>r.json());
|
| 75 |
+
await pc.setRemoteDescription(a);
|
| 76 |
+
|
| 77 |
+
ws=new WebSocket(`ws://${location.host}/ws`);
|
| 78 |
+
ws.onopen=()=>{ lastSent=''; send(); setInterval(()=>ws.readyState===1&&
|
| 79 |
+
ws.send(JSON.stringify({type:'stats'})), 500); };
|
| 80 |
+
ws.onmessage=ev=>{ const m=JSON.parse(ev.data);
|
| 81 |
+
if(m.type==='stats'){
|
| 82 |
+
const budget=250; // 一个 cell = 4 帧 @16fps
|
| 83 |
+
S.textContent=`cell ${m.cells} · 生成 ${m.gen_ms.toFixed(0)} ms 解码 ${m.dec_ms.toFixed(0)} ms`
|
| 84 |
+
+` · ${((m.gen_ms+m.dec_ms)/budget).toFixed(2)}× 实时 · 缓冲 ${m.buffered} 帧`;
|
| 85 |
+
} };
|
| 86 |
+
}
|
| 87 |
+
fetch('/frames').then(r=>r.json()).then(fs=>{
|
| 88 |
+
const sel=document.getElementById('ff');
|
| 89 |
+
for(const f of fs){ const o=document.createElement('option'); o.value=o.textContent=f; sel.append(o); }
|
| 90 |
+
});
|
| 91 |
+
document.getElementById('rst').onclick=()=>ws&&ws.send(JSON.stringify(
|
| 92 |
+
{type:'reset',first_frame:document.getElementById('ff').value}));
|
| 93 |
+
document.getElementById('lead').onchange=e=>ws&&ws.send(JSON.stringify(
|
| 94 |
+
{type:'config',lead_cells:+e.target.value}));
|
| 95 |
+
start().catch(e=>S.textContent='连接失败: '+e);
|
| 96 |
+
</script>
|
backup_2026-09-14/code/demo/run.sh
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# ActionRoPE 交互 demo 启动器。默认只绑回环 —— 这是共用机器。
|
| 3 |
+
#
|
| 4 |
+
# bash demo/run.sh # 用 stage2-flowmap 的 7500
|
| 5 |
+
# CKPT=.../ema_step150.safetensors bash demo/run.sh # 换个 ckpt
|
| 6 |
+
# STEPS=2 bash demo/run.sh # 更快、质量略降
|
| 7 |
+
#
|
| 8 |
+
# 远程玩:在你自己电脑上 ssh -N -L 8420:127.0.0.1:8420 <这台机器>,再开 localhost:8420
|
| 9 |
+
set -euo pipefail
|
| 10 |
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
| 11 |
+
CKPT="${CKPT:-$ROOT/outputs/distill/stage2_flowmap/ema/ema_step7500.safetensors}"
|
| 12 |
+
PORT="${PORT:-8420}"; HOST="${HOST:-127.0.0.1}"; STEPS="${STEPS:-4}"; GPU="${GPU:-}"
|
| 13 |
+
[ -f "$CKPT" ] || { echo "ABORT: 没有 ckpt $CKPT"; exit 1; }
|
| 14 |
+
|
| 15 |
+
# 挑一张空卡;显式给 GPU= 就用那张。demo 要约 17 GB。
|
| 16 |
+
if [ -z "$GPU" ]; then
|
| 17 |
+
GPU="$(nvidia-smi --query-gpu=index,memory.used --format=csv,noheader,nounits \
|
| 18 |
+
| awk -F', *' '$2<2000 {print $1; exit}')"
|
| 19 |
+
[ -n "$GPU" ] || { echo "ABORT: 没有空闲的卡(都 >2 GB 占用)。别抢别人的。"; exit 1; }
|
| 20 |
+
fi
|
| 21 |
+
echo "[demo] GPU $GPU · ckpt $(basename "$CKPT") · N=$STEPS 步 · http://$HOST:$PORT/"
|
| 22 |
+
export PYTHONPATH="$ROOT" DIFFSYNTH_SKIP_DOWNLOAD=True CUDA_DEVICE_ORDER=PCI_BUS_ID CUDA_VISIBLE_DEVICES="$GPU"
|
| 23 |
+
exec "$ROOT/.venv/bin/python" "$ROOT/demo/server.py" \
|
| 24 |
+
--ckpt "$CKPT" --host "$HOST" --port "$PORT" --steps "$STEPS" --vae "${VAE:-taehv_super}" "$@"
|
backup_2026-09-14/code/demo/server.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ActionRoPE 交互 demo:WebRTC 视频 + WebSocket 控制。
|
| 2 |
+
|
| 3 |
+
bash demo/run.sh # 默认绑 127.0.0.1:8420,只有本机能连
|
| 4 |
+
ssh -N -L 8420:127.0.0.1:8420 <这台机器> # 在你自己电脑上开隧道,然后浏览器 localhost:8420
|
| 5 |
+
|
| 6 |
+
★ 默认**只绑回环**。这是共用机器,绑 0.0.0.0 等于把一个能跑 5B 模型的接口暴露给整个内网,
|
| 7 |
+
所以要暴露必须显式 --host 0.0.0.0,且会打印一条警告。别图省事。
|
| 8 |
+
|
| 9 |
+
延迟预算(单张 H200、N=4 步、实测):
|
| 10 |
+
DiT 生成 ~175 ms / cell
|
| 11 |
+
流式 VAE 解码 ~25 ms / cell(整片 tiled 解码是 3.6 s/161 帧,流式后不再是瓶颈)
|
| 12 |
+
一个 cell = 4 帧 = 16 fps 下 250 ms 的画面
|
| 13 |
+
所以生成比播放快,**延迟由缓冲深度决定,不由传输决定**:lead_cells=1 时,一次按键最坏
|
| 14 |
+
要等「当前 cell 播完 + 下一个 cell 生成」≈ 250 + 200 = 450 ms 才看得见效果。
|
| 15 |
+
WebRTC 相对 WS+JPEG 只省 20~50 ms —— 选它是因为它自带拥塞控制和帧率节拍,
|
| 16 |
+
不是因为它能解决这 450 ms。真要压延迟得动 cell 粒度(模型的时间压缩率是 4),那是模型的事。
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import argparse
|
| 21 |
+
import asyncio
|
| 22 |
+
import fractions
|
| 23 |
+
import json
|
| 24 |
+
import os
|
| 25 |
+
import sys
|
| 26 |
+
import time
|
| 27 |
+
|
| 28 |
+
import av
|
| 29 |
+
import numpy as np
|
| 30 |
+
from aiohttp import WSMsgType, web
|
| 31 |
+
from aiortc import RTCPeerConnection, RTCSessionDescription, VideoStreamTrack
|
| 32 |
+
|
| 33 |
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 34 |
+
ROOT = os.path.dirname(HERE)
|
| 35 |
+
if ROOT not in sys.path:
|
| 36 |
+
sys.path.insert(0, ROOT)
|
| 37 |
+
|
| 38 |
+
from demo.engine import CELL_FRAMES, Engine # noqa: E402
|
| 39 |
+
|
| 40 |
+
FPS = 16
|
| 41 |
+
TIME_BASE = fractions.Fraction(1, FPS)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class GameTrack(VideoStreamTrack):
|
| 45 |
+
"""把引擎的帧队列按 16 fps 节拍推出去。
|
| 46 |
+
|
| 47 |
+
队列空时**重复上一帧**而不是阻塞:WebRTC 的节拍一旦被拖住,浏览器侧的 jitter buffer
|
| 48 |
+
会加深,之后每一帧都多欠一份延迟,比丢一帧糟得多。
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
kind = "video"
|
| 52 |
+
|
| 53 |
+
def __init__(self, engine: Engine):
|
| 54 |
+
super().__init__()
|
| 55 |
+
self.engine = engine
|
| 56 |
+
self.last = None
|
| 57 |
+
self.n = 0
|
| 58 |
+
self.repeats = 0
|
| 59 |
+
|
| 60 |
+
async def recv(self):
|
| 61 |
+
pts, _ = await self.next_timestamp()
|
| 62 |
+
f = self.engine.pop_frame()
|
| 63 |
+
if f is None:
|
| 64 |
+
f = self.last
|
| 65 |
+
self.repeats += 1
|
| 66 |
+
if f is None: # 还没 reset,先给一块黑
|
| 67 |
+
f = np.zeros((480, 832, 3), np.uint8)
|
| 68 |
+
self.last = f
|
| 69 |
+
self.n += 1
|
| 70 |
+
frame = av.VideoFrame.from_ndarray(f, format="rgb24")
|
| 71 |
+
frame.pts, frame.time_base = pts, TIME_BASE
|
| 72 |
+
return frame
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def build_app(engine: Engine, first_frames: list[str], log=print) -> web.Application:
|
| 76 |
+
app = web.Application()
|
| 77 |
+
app["pcs"] = set()
|
| 78 |
+
|
| 79 |
+
async def index(_):
|
| 80 |
+
return web.FileResponse(os.path.join(HERE, "index.html"))
|
| 81 |
+
|
| 82 |
+
async def frames(_):
|
| 83 |
+
return web.json_response([os.path.basename(p) for p in first_frames])
|
| 84 |
+
|
| 85 |
+
async def offer(request):
|
| 86 |
+
params = await request.json()
|
| 87 |
+
pc = RTCPeerConnection()
|
| 88 |
+
app["pcs"].add(pc)
|
| 89 |
+
|
| 90 |
+
@pc.on("connectionstatechange")
|
| 91 |
+
async def on_state():
|
| 92 |
+
log(f"[rtc] {pc.connectionState}")
|
| 93 |
+
if pc.connectionState in ("failed", "closed"):
|
| 94 |
+
await pc.close()
|
| 95 |
+
app["pcs"].discard(pc)
|
| 96 |
+
|
| 97 |
+
pc.addTrack(GameTrack(engine))
|
| 98 |
+
await pc.setRemoteDescription(RTCSessionDescription(params["sdp"], params["type"]))
|
| 99 |
+
answer = await pc.createAnswer()
|
| 100 |
+
await pc.setLocalDescription(answer)
|
| 101 |
+
return web.json_response({"sdp": pc.localDescription.sdp, "type": pc.localDescription.type})
|
| 102 |
+
|
| 103 |
+
async def ws(request):
|
| 104 |
+
sock = web.WebSocketResponse(heartbeat=10)
|
| 105 |
+
await sock.prepare(request)
|
| 106 |
+
loop = asyncio.get_running_loop()
|
| 107 |
+
async for msg in sock:
|
| 108 |
+
if msg.type is not WSMsgType.TEXT:
|
| 109 |
+
continue
|
| 110 |
+
try:
|
| 111 |
+
m = json.loads(msg.data)
|
| 112 |
+
except Exception: # noqa: BLE001
|
| 113 |
+
continue
|
| 114 |
+
t = m.get("type")
|
| 115 |
+
if t == "action":
|
| 116 |
+
v = engine.velocity_from_keys(float(m.get("dx", 0)), float(m.get("dy", 0)),
|
| 117 |
+
float(m.get("mult", 1.0)))
|
| 118 |
+
engine.set_velocity(float(v[0]), float(v[1]))
|
| 119 |
+
elif t == "reset":
|
| 120 |
+
name = m.get("first_frame")
|
| 121 |
+
path = next((p for p in first_frames if os.path.basename(p) == name),
|
| 122 |
+
first_frames[0])
|
| 123 |
+
# 必须走 infer.load_first_frame:它按 26:15 居中裁剪再缩到 832×480。
|
| 124 |
+
# 裸 imread 会把 bg50 的 1536×1024 原图直接喂进去 ⇒ latent 从 30×52 变 64×96,
|
| 125 |
+
# token 数 4 倍,实测生成从 ~190 ms 涨到 768 ms,而且步速尺度也错了。
|
| 126 |
+
from actionrope import infer as _inf
|
| 127 |
+
img = _inf.load_first_frame(path)
|
| 128 |
+
# reset 会重建 KV 与 VAE 缓存,是阻塞的 GPU 操作 —— 丢到线程池,
|
| 129 |
+
# 否则 aiohttp 的事件循环会卡住,连带 WebRTC 的心跳一起停。
|
| 130 |
+
engine.stop()
|
| 131 |
+
await loop.run_in_executor(None, engine.reset, img)
|
| 132 |
+
engine.start()
|
| 133 |
+
await sock.send_json({"type": "reset_ok", "first_frame": os.path.basename(path)})
|
| 134 |
+
elif t == "config":
|
| 135 |
+
if "lead_cells" in m:
|
| 136 |
+
engine.lead_cells = max(1, min(4, int(m["lead_cells"])))
|
| 137 |
+
elif t == "stats":
|
| 138 |
+
await sock.send_json({"type": "stats", **engine.stats,
|
| 139 |
+
"buffered": len(engine.out),
|
| 140 |
+
"lead_cells": engine.lead_cells})
|
| 141 |
+
return sock
|
| 142 |
+
|
| 143 |
+
app.router.add_get("/", index)
|
| 144 |
+
app.router.add_get("/frames", frames)
|
| 145 |
+
app.router.add_post("/offer", offer)
|
| 146 |
+
app.router.add_get("/ws", ws)
|
| 147 |
+
|
| 148 |
+
async def on_shutdown(app):
|
| 149 |
+
for pc in list(app["pcs"]):
|
| 150 |
+
await pc.close()
|
| 151 |
+
engine.stop()
|
| 152 |
+
app.on_shutdown.append(on_shutdown)
|
| 153 |
+
return app
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def main(argv=None):
|
| 157 |
+
ap = argparse.ArgumentParser(description="ActionRoPE 交互 demo")
|
| 158 |
+
ap.add_argument("--ckpt", required=True, help="832 键的 flow map 学生(stage2/stage3 的 ema)")
|
| 159 |
+
ap.add_argument("--host", default="127.0.0.1", help="默认只绑回环。共用机器,绑 0.0.0.0 前先想清楚")
|
| 160 |
+
ap.add_argument("--port", type=int, default=8420)
|
| 161 |
+
ap.add_argument("--steps", type=int, default=4, help="any-step 采样步数;2 更快、4 更稳")
|
| 162 |
+
ap.add_argument("--sink_size", type=int, default=3)
|
| 163 |
+
ap.add_argument("--kv_window", type=int, default=17)
|
| 164 |
+
ap.add_argument("--rope_cap", type=int, default=20)
|
| 165 |
+
ap.add_argument("--scene", default=None, help="场景描述串;不给就用 NULL_PROMPT(bg50 口径)")
|
| 166 |
+
ap.add_argument("--first_frame_dir", default=os.path.join(ROOT, "data/eval/eybx_bg_50"))
|
| 167 |
+
ap.add_argument("--seed", type=int, default=0)
|
| 168 |
+
ap.add_argument("--vae", choices=["taehv_super", "taehv", "full"], default="taehv_super",
|
| 169 |
+
help="解码器。taehv* = TAEHV 流式(3 ms/cell),full = 全量 Wan VAE(87 ms/cell)")
|
| 170 |
+
args = ap.parse_args(argv)
|
| 171 |
+
|
| 172 |
+
import glob
|
| 173 |
+
firsts = sorted(glob.glob(os.path.join(args.first_frame_dir, "*.png")))
|
| 174 |
+
if not firsts:
|
| 175 |
+
raise SystemExit(f"{args.first_frame_dir} 里没有 png 首帧")
|
| 176 |
+
|
| 177 |
+
if args.host not in ("127.0.0.1", "localhost", "::1"):
|
| 178 |
+
print(f"!! 正在把 demo 绑到 {args.host} —— 这台机器是共用的,内网里任何人都能连上并占用这张卡。")
|
| 179 |
+
print("!! 想安全一点就用默认的 127.0.0.1 + ssh -N -L 隧道。")
|
| 180 |
+
|
| 181 |
+
engine = Engine(args.ckpt, steps=args.steps, sink_size=args.sink_size,
|
| 182 |
+
kv_window=args.kv_window, rope_cap=args.rope_cap,
|
| 183 |
+
scene=args.scene, seed=args.seed, vae=args.vae)
|
| 184 |
+
from actionrope import infer as _inf
|
| 185 |
+
t0 = time.time()
|
| 186 |
+
engine.reset(_inf.load_first_frame(firsts[0]))
|
| 187 |
+
engine.start()
|
| 188 |
+
print(f"[demo] 首局就绪 {time.time() - t0:.1f}s · {len(firsts)} 张首帧可选")
|
| 189 |
+
print(f"[demo] 打开 http://{args.host}:{args.port}/ "
|
| 190 |
+
f"(远程的话先 ssh -N -L {args.port}:127.0.0.1:{args.port} 这台机器)")
|
| 191 |
+
web.run_app(build_app(engine, firsts), host=args.host, port=args.port, print=None)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
if __name__ == "__main__":
|
| 195 |
+
main()
|
backup_2026-09-14/code/distill/AUDIT_CFPP_2026-09-10.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CF++ Stage2 CD + Stage3 DMD 移植审计报告(终版)
|
| 2 |
+
|
| 3 |
+
路径缩写:`$R=/opt/dlami/nvme/zhiyangdeng/ActionRoPE`,`$U=$R/hf/worldmind_ref/WorldMind/distill/code`,`$G=$R/hf/worldmind_ref/GraphGWM/train/code_distill`。未加前缀的 `distill/…` 均在 `$R` 下。
|
| 4 |
+
|
| 5 |
+
## 1. 结论
|
| 6 |
+
|
| 7 |
+
**CD 逻辑(代码+数学)正确。** `distill/stage2_cd/cd_arope.py` 对 `$U/stage2_cd/cd_gm.py:94-134` 是逐行忠实实现:σ/t/idx 配对、Euler 符号、cell 0 重钉、detach、cells 1: 上的 MSE、cond/clean_x 不变、v 空间 CFG、跨 rank 同步的 idx 流与按 micro 计数的快进、fp32 EMA 影子在真实优化器步之后更新一次、ema_start 硬拷贝窗口、ZeRO-2 下 zip 对齐——CPU 上 oracle 与上游转写位级一致(test_stage2_cd 9/9、test_stage23_upstream_diff 全过)。唯一实质弱点是结构性且已记录的:默认 4 节点部署轴上 σ=0.2516(部署终读点)从不是一致性 source,是固定点族的自由分量(杠杆为上游 σ=0.096 的 2.6×);docstring 的"等于教师一步估计"只在 step 0 成立。**DMD 逻辑(代码+数学)正确**:x0 = x_t − σv、共用一份噪声与 t、(pred_fake − pred_real)/‖gen − pred_real‖ 归一化+clamp+nan_to_num、0.5·MSE 代理梯度恰为 +grad 且锚 cell 为 0、[20,980) 索引钳制、x0 空间 CFG、双向教师/critic 无 clean_x、流式重放与融合梯度精确相等(rel<1e-6)、5:1 步序与优化器常量与 `$U/stage3_dmd/dmd_gm.py:193-246, 681-706` 一致,FSDP 习语(prepare 前 fp32、use_orig_params 断言、裸 bf16 教师、每模型 clip、get_state_dict 导出、`_KVRef`)在 torch 2.11/accelerate 1.14 源码层面无误。**一个设计层面的 major**(DMD-1):打分尾巴的 t=0 锚点是学生生成的 cell,且端口同时去掉了所有流形上参考(无真 cell 0、sink=0、VAE 重锚硬拒),其文档论证把 RoPE 坐标漂移与 latent 值漂移混为一谈,导致 DMD 目标对锚点携带的全局外观偏移无恢复力——这不是代码 bug,而是"已记录但论证不完整"的偏差。**另两个 major 是基础设施**:stage2 启动器无 8 卡分支(裸 `DISTILL_NGPU=8` 会以等效 batch 16 训练),以及部署评测脚本用 sink 3/w17 评估按 sink 0/w20 训练的学生。当前 live run(`outputs/distill/stage2_cd`,01:43:07 起,world 8/GA 1/train_batch_size 8,teacher=student=stage1_long_K3 step1000,start_step 0,~5.06 s/步)已由人工绕过了第一个 major,配方正确。
|
| 8 |
+
|
| 9 |
+
## 2. 已确认发现(按复核后严重度排序)
|
| 10 |
+
|
| 11 |
+
复核规则:3 位反驳者中 ≥2 未反驳即确认;表中级别为复核后共识。
|
| 12 |
+
|
| 13 |
+
| ID | 级别 | 位置(端口 ↔ 上游) | 机理 / 数学与后果 | 精确修复 |
|
| 14 |
+
|---|---|---|---|---|
|
| 15 |
+
| **INFRA-1** (CDINFRA-1≡R1) | **major** | 端口 `distill/stage2_cd/run.sh:42-43`(`GRAD_ACCUM:-2`,`ACC_CFG` 硬编码 `accelerate_zero2_4gpu_ga${GRAD_ACCUM}.yaml`,无 `DISTILL_NGPU` 分支)、`:93`(abort 文案"只有 2/4 两份")、`:132`(`--num_processes $DISTILL_NPROC`);`distill/common.sh:36-44`;`cd_arope.py:513-519`(GA 门只比 yaml GA==`--grad_accum`,文案只列 4gpu);`configs/accelerate_zero2_8gpu_ga1.yaml:4-5`("等效 batch 仍是 8…别改 GA")存在但从不被选;`stage2_cd/config.json:68-71`(4×1×2=8)。↔ 上游 `$U/../launch/stage2_v3_ext.sh:37-42`(8×1×1=8,"Reverted from an aborted accum=2 attempt")、`$U/stage2_cd/cd_gm.py:274`(GA 默认 1);本仓库正确范式 `distill/stage2_flowmap/run.sh:52-58`、`distill/stage1_long/run.sh:37-43`。 | eff = world×bs×GA。accelerate 1.14 `launch.py:1272-1277` 仅在 CLI 为 None 时采用 yaml 值 ⇒ `--num_processes 8` 覆盖 yaml 的 4,GA 仍 2 ⇒ **8×1×2=16**(文档/上游 8);DeepSpeed train_batch_size 同为 16(accelerator.py:2223-2227)。`GRAD_ACCUM=1` ⇒ `4gpu_ga1.yaml` 不存在 ⇒ 误导性 abort。续训禁改 GA(`cd_arope.py:587-590`)⇒ step 0 即锁死。后果:裸启动=未记录配方(2× 样本/步、wall-clock 不降、EMA 每样本更新减半),只有 `run.sh:124` 一行"等效 batch=16"提示,无守卫。live run 已手工传 `GRAD_ACCUM=1 ACC_CFG=…8gpu_ga1.yaml` 绕过。 | `run.sh:42-43` 改为:`if [ "$DISTILL_NGPU" = "8" ]; then GRAD_ACCUM="${GRAD_ACCUM:-1}"; ACC_CFG="${ACC_CFG:-$ROOT/distill/configs/accelerate_zero2_8gpu_ga${GRAD_ACCUM}.yaml}"; else GRAD_ACCUM="${GRAD_ACCUM:-2}"; ACC_CFG="${ACC_CFG:-$ROOT/distill/configs/accelerate_zero2_4gpu_ga${GRAD_ACCUM}.yaml}"; fi`;`:93` 后加 `[ $((DISTILL_NPROC*GRAD_ACCUM)) = 8 ]` 断言(`EFF_BATCH_OVERRIDE=1` 放行);`:93` 与 `cd_arope.py:515-518` 文案列出真实 4 份 yaml;`RUNBOOK.md:104` 附 8 卡形式;`config.json:69-71` 注 "4×GA2 或 8×GA1";同样修 `distill/stage1_ar/run.sh:39-40`;修 `configs/accelerate_zero2_8gpu_ga2.yaml:4-5,11` 过期头注释(写 GA1/eff 8,实为 GA2/eff 16)。 |
|
| 16 |
+
| **DMD-1** | **major** | 端口 `distill/stage3_dmd/dmd_arope.py:449-457`(`a=total-1-Keff; tail_sl=generated[:,:,a:]`��`first_frame_cond=True`)、`distill/flow_pf.py:328-331`(tail cell 0 不加噪 t=0)、`dmd_arope.py:209-211,222-233`(每 rollout 一个 exit_step;p=1/4 时锚点是 σ=1 的一步输出)、`:658-660`(`--reanchor_every` 硬拒)、`stage3_dmd/config.json:39,46`、`PORT_SPEC.md:375-382`、R10 `:556`、对抗评审 `:666-668`([MAJOR] 未闭合)。↔ 上游 `$U/stage3_dmd/dmd_gm.py:365-367`(sink=3:"frame0 is a REAL VAE-encoded latent → permanent on-manifold anchor")、`:674`(tail=cat(real frame0, last-K))、`:171-178`(decode→末帧→encode 重锚已实现);`DMD_WINDOWING_SPEC.md:61-64,85`(ReactiveGWM 连续尾巴**配** `dmd_tail_reanchor: vae`);`$G/stage3_dmd/dmd_gm.py:158-164`(重锚存在的原因:"darken/wash root cause");`diag_pull.py:5-12`(frankenstein 变暗仅"假设 B",无结果 ⇒ R10 前提未验证);SFT `actionrope/train.py:234-235`(cell 0 恒为真 x0)。 | 端口打分条件 c=(x_a^θ, off−off_a),x_a^θ~p_θ(a=N−20∈[1,7]),未加噪、不打分、detach。(i) 教师在其 cell-0 支撑外被查询(无高斯平滑)。(ii) 对整段窗口的全局外观变换 g(亮度/色偏):教师与 critic 都以 g·x̃_a 为条件 ⇒ pred_fake−pred_real≈0 ⇒ 对 g 零梯度;上游真 frame0 钉住 g=id。sink=0+窗口锚定坐标(off_win=off[j]−off[a])对 RoPE 精确,但与 latent 值离流形正交——`config.json:39` 的"抗漂移机制"论证是混淆。目标只惩罚窗内相对漂移**率**,对锚点携带的累积偏移无恢复力;dmd 带/critic/gnorm 全部看不到。复核者补充:训练时未约束段只有 cell 1..a≤7(真 cell 0 仍在缓存内),部署 sink=0 过 cell 20 后偏移为随机游走。 | (a) 文档:`config.json` `_reanchor_every/_scored_tail`、`PORT_SPEC` R3/R10 改为"sink=0+锚定仅解决 RoPE 坐标 OOD;尾锚是生成 cell(N−20∈[1,7]);目标对锚点携带的全局外观偏移盲",并显式闭合 `PORT_SPEC.md:666-668`。(b) **门**:把 `distill/eval/sweep_photodrift.sh`(41 cell 逐 cell std/mean vs stage2 基线,**按训练几何 sink 0/w20 跑**,见 GEO-1)列为 stage3 冒烟与 EMA 里程碑挑选的必做项,与 rollout_eval 位移一起。(c) 仅当出现窗恒定变暗/偏移:加 `--tail_reanchor vae`,仅对 tail cell a 做 `dmd_gm.py:171-178` 的 decode(6 latent)→末像素帧→encode,detach,不动学生缓存(修 (i),不修 g-pin);或按步 rank-sync 抽 a∈{0, N−K} 交替头/尾窗口(修 g-pin;代价 cell 21..27 后驱逐信号减半,一位复核者反对)。 |
|
| 17 |
+
| **GEO-1** (rollout F1) | **major**(原 blocker) | 端口训练 `distill/stage3_dmd/run.sh:57-59`(KV_WINDOW 20/SINK 0/ROPE_CAP 20)、`dmd_arope.py:543-549`;`distill/eval/rollout_eval.py:588-590` 默认 0/20/20 **但** `distill/eval/sweep8.sh:50`、`photodrift.sh:16`、`paired50.sh:23` 硬编码 `--sink_size 3 --kv_window 17 --rope_cap 20`;`longhorizon/README.md:47`;本机 42 份 `outputs/distill/eval/stage1_long_K3/*.json` 全记 sink 3/w17/121 cell;`distill/tests/preflight.py:100,119-125`(锚到第一个保留 cell=cell 0 ⇒ 绝对坐标);`rollout_eval.py:745` 仅为 flowmap 读元数据。↔ 上游 `$U/stage3_dmd/run.sh:46`(训练=部署 sink3/w17/cap20)、`DMD_WINDOWING_SPEC.md:48-50`(train/infer 同几何);`PORT_SPEC.md:359`、`RUNBOOK.md:170-174`(两几何仅 k≤20 重合,只说"可比较");`test_world_rope_window.py:19-28,323-345`(sink>0 = OOD 负控制)。 | k≤20 保留集相同;k=21 差 {0,3},k≥23 各差 3 cell。sink 3 下窗内跨度 357 px(k21)→426 px=1.14×包络(k25)→686 px=1.84×(k40) 线性增长(sink 0 恒 346 px);当前 cell 掩码 known 比例 0.58→0.17(k40)→0(k≥48,=README 的 12 s)。时间位置两者同为 0..20,差异全在 h/w RoPE 与掩码通道——恰是 test c2/d2 证明 stage3 rollout 从不产生的输入。后果:stage3 EMA 之间及 vs stage2 的 paired50/photodrift(cell>20)度量的是 OOD 外推,ckpt 选择轴错误;训练本身与默认 rollout_eval 路径一致(故降级 major)。 | 三个脚本删掉 `--sink_size 3 --kv_window 17 --rope_cap 20`(默认即训练几何);顺手替换死路径 `/tmp/claude-2011/…`(`sweep8.sh:17`、`photodrift.sh:12`、`paired50.sh:12`,本机无法运行)。`rollout_eval.py` 在 `_ckpt_metadata`(`:120-127`,`:745` 附近)读 do_save 写的 `kv_window/sink_size/rope_cap`(`dmd_arope.py:1090-1093`),存在且与 CLI 不等则拒绝,除非 `--allow_geometry_mismatch`(stage1/2 ckpt 无这些键,不受限)。sink3/w17 只作 RUNBOOK:174 的显式 A/B,不作唯一选择轴。stage1_long_K3 的 121-cell 基线需按 sink 0/w20 重跑。若产品几何确为 sink 3:改 `run.sh:57-58` 并重写 `PORT_SPEC.md:359`/`_geometry`——二选一。 |
|
| 18 |
+
| **CD-1** | minor | 端口 `distill/stage2_cd/cd_arope.py:118-122`(docstring:"学生在那里的行为等于 stage1 教师的一步估计")、`:741-743,:758`(`idx∈[0,K-2]`)、`:138-146`(`build_cd_axis` 只向下追加���、`:299-301`/`run.sh:52`/`config.json _cd_tail_t`(默认 0,"不改用户拍板的轴");部署终读同节点 `distill/eval/rollout_eval.py:212-214`、`dmd_arope.py:228`。↔ 上游 `$U/stage2_cd/cd_gm.py:65-69`(N=48,自由节点 σ=0.0962)、`:435`;`$U/stage2_cd/run.sh:41`(部署 1000,250);48 轴节点 45..47=[0.25,0.1786,0.0962](CPU 验证)⇒ 上游终读 σ=0.2516 落在 SOURCE 节点旁。 | f(x,σ)=x−σv(c_skip=1, c_out=−σ)。平稳解族 {f(·,σ_i)=f(·,σ_3)∘Φ_2∘…∘Φ_i},f(·,σ_3) 任意(含常数);CM 边界条件只在 σ→0 由参数化强制,此处 c_out=−0.2516(上游 −0.096 的 2.6×)。共享参数下 pair 0-2 的梯度会顺带移动 v_θ(·,σ_3),无恢复项。docstring 等式仅 step 0 成立(student==ema==teacher)。已验证 `CD_TAIL_T=100` ⇒ 轴 [1,0.75,0.5006,0.2516,0.1011],4 对,部署节点不变。后果有界:`cm_err/tgt_err` 已按 idx 记录(`:249,:716,:799-800`;idx==2 行的 tgt_err 即 ‖f_EMA(·,0.2516)−x0‖²,live run 目前 ~0.04-0.05),stage3 在 exit_step=3 时直接训练该节点(`dmd_arope.py:219-228`)。 | 改 `:120-121` docstring 为"初始时等于教师一步估计,此后无约束项维持(上游 σ=0.096 节点同理,仅杠杆小 2.6×)";RUNBOOK/PORT_SPEC §Stage2 注明哪个节点无约束。训练中盯 `train_log.csv` idx==2 行的 tgt_err 相对 step≤200 基线;仅当 cd 降、tgt_err 升时,以**新 OUT** 跑 `CD_TAIL_T=100` 消融(续训不能改轴;轴系用户拍板,不改默认;改则同步 `cd_arope.py:299-301`/`run.sh:52`/`config.json`/`test_stage2_cd.py::test_1`)。 |
|
| 19 |
+
| **INFRA-2** (CDINFRA-2) | minor | 端口 `cd_arope.py:575-597`(续训只查 step/grad_accum/axis_sigmas;`:594` 重指 student/ema,`args.teacher_ckpt` 不动,`:617` 原样加载)、`:738-740`(progress.json 无 ckpt 路径;`runlib.py:224-229`)、`:449-452`(meta gate 只比 tf/windowed,不比身份)、`run.sh:15`(`AR=… # 换一个 stage1 检查点`)、`:38/:76`(固定 OUT + RESUME=auto)、`:123`(横幅"teacher = student init = $AR"续训时为假);`_smoke_stage2_cd/progress.json` 实测键集无 ckpt 字段。↔ 上游 `$U/stage2_cd/cd_gm.py:326-337,472-473`(同洞,但 `stage2_v3_ext.sh:17-29,41-42` 硬编码 RD/TEACHER 并断言状态);`PORT_SPEC.md:249`(P1 同源要求)。 | 目标 cm_tn=lat_tn−σ_tn·EMA(lat_tn),lat_tn=lat_t+(σ_tn−σ_t)·T_B(lat_t)。student/EMA 若来自对 T_A 蒸馏的 run,链条锚在 f_A(·,0.25)∘Φ_{T_B},loss 有限且下降,无报错。后果:一旦 progress.json 出现(step 500),>10 min 后换 `AR=` 进默认 OUT 的重启静默续训旧权重。live run 已从 step 0 起。 | do_save `:738-740` 的 `write_progress` 加 `student_ckpt=args.student_ckpt, teacher_ckpt=args.teacher_ckpt`(CLI 值,`student_init` 续训后是 dits 路径);`:593` 后加 `for k in ("student_ckpt","teacher_ckpt"): old,new=prog.get(k),getattr(args,k); if old and new and realpath(old)!=realpath(new): raise SystemExit(...)`(dry_run 下两者为 None,需 None-safe);旧 progress.json 回退读 dits header 同名键(`:722-725` 已写);stage3 镜像 `dmd_arope.py:1112` 与 `:891-914`;修 `run.sh:123` 横幅;`RUNBOOK.md:180` 记"换 AR= ⇒ 换 OUT 或 RESUME=none"。**live run 结束后再改。** |
|
| 20 |
+
| **FSDP-CAST** (S3-FSDP-1≡dit-wrapper F1) | minor | 端口 `dmd_arope.py:193-195`(`student(..., offset_win=ow)` fp32)、`:456-457`(`cond_t` `offset_px` fp32 → critic `:370`)、`:968/:991-996`(教师裸 bf16 不 prepare)、`distill/arope_dit.py:755,:892`(forward **内**才 `.to(float64)/PX_PER_TOKEN`)、`_KVRef` docstring `:217-231`(只提容器重建)。↔ 库/上游:torch 2.11 `fsdp/_runtime_utils.py:610-627`+`distributed/utils.py:59-63`(root cast 所有浮点 kwargs→bf16)、accelerate 1.14 `utils/dataclasses.py:2118-2124`(未设 `cast_root_forward_inputs`,默认 True `fsdp/api.py:225`);`$U/stage3_dmd/dmd_gm.py:569-576`(教师也 FSDP 包 ⇒ 对称);部署 `actionrope/infer.py:606`、`rollout_eval.py:477-478` 传精确 fp32;stage2 DeepSpeed 无输入 cast。 | bf16 8 位尾数:|o|<256 px 误差≤0.5 px,[256,512) ≤1 px;锚定窗跨度≤373 px ⇒ ≤1 px=1/32 token=≤0.031 rad(顶频 1 rad/token,`arope.py:68-81`),两 cell 相对≤2 px。掩码由 fp32 在调用前算好,不受影响(`dmd_arope.py:151,457`)。**时间步半边不成立**:`_per_token_timestep_pf` 对所有调用方按 latent dtype 取整(`arope_dit.py:177`);复核者对教师侧 latent 是否 fp32 有分歧,属二阶。后果:学生/critic 与教师、与 SFT/部署间 ≤1/32 token 坐标不一致;量级远低于 DMD 信号,但未记录;step-0 "dmd 恰为 0"(`:473-474,:921,:1173`)在 FSDP 下只 ≈0;`tests/test_world_rope_window.py:176-181` 的"f32 ⇒ 训练中精确"在 FSDP 下不成立。 | 首选(局部):偏移以 int64 定点传入——`frame_cond :150-152`、`:456-457`、probe `:487` 用 `(off*1024).round().to(torch.int64)`,`arope_dit.py:755/:892` 解码 `.to(float64)/1024/PX_PER_TOKEN`;两入口断言 `offset.dtype not in (bf16, fp16)`。备选:prepare 前 `plugin.mixed_precision_policy = dataclasses.replace(plugin.mixed_precision_policy, cast_root_forward_inputs=False)`(须重验 latent/timestep dtype 路径;一位复核者反对)。无论如何:PORT_SPEC 风险表新增一条;step-0 注释改"≈0";`test_stage3_dmd.py:257` 的 `==0.0` 不要变成 FSDP 运行时断言。 |
|
| 21 |
+
| **S3-GUARD-3** | minor | 端口 `dmd_arope.py:1237-1238`(`gng=_clip(...)` 直接 `opt_g.step()`,gng 只进日志 `:1261,:1271,:1280`)、`:1161-1172`(唯一 abort=预跑 probe,一次 backward,不覆盖 `stream_grad_replay :273-340` 的 20 次交错 backward)、`:1259-1263`(LOG_EVERY=10 ⇒ step 1 的 gnorm_g 不打印);承诺处 `PORT_SPEC.md:540-541`(R5)、`:586`(S7a)、`configs/accelerate_fsdp_dmd_8gpu.yaml:13`、`_4gpu.yaml:11`("从第 1 步就打印 grad_norm,恰为 0 立刻中止")。↔ 上游 `$G/stage3_dmd/dmd_gm.py:1193-1208`(gnorm_g 是 FSDP 下唯一可信信号,仅打印)、`$U/stage3_dmd/dmd_gm.py:641-648`;`_scheme2_docs/docs/交接_蒸馏_STAGE2_NEXT_SESSION.md:283`(真实发生过:gnorm_g=0.00 每步、critic 正常、生成器没学)。 | step 0:critic==teacher(cfg=1)⇒ dmd 恰 0 合法(`test_stage3_dmd.py:256`)。step≥resume_step+1:critic 已走 critic_steps=5 步 ⇒ pred_fake≠pred_real ⇒ 代理 d/dg=grad/n≠0 ⇒ gng>0 是硬不变量;恰为 0.0 只能是 autograd/FSDP 路径断裂。后果:无学习 run 烧完 500 步而 critic loss 动、dmd 打印"健康"。 | `:1237` 与 `:1238` 之间加 `if step > resume_step and (gng == 0.0 or math.isnan(gng)): raise SystemExit(f"[gnorm] generator grad norm {gng} at step {step}: student receives no gradient (check FSDP backward setup / fsdp_use_orig_params / autocast cache / stray detach)")`(FSDP `clip_grad_norm_` 是集合通信,各 rank 同值,不会挂;inf 不 abort,clip 已归零);同步 `config.json:109 _gate`、PORT_SPEC R5、两份 yaml 头注释;可选 CPU 测试 monkeypatch `_clip` 返回 0 断言 SystemExit。 |
|
| 22 |
+
| **GEO-2** (rollout F2) | minor | 端口 `arope_dit.py:875-877`(mask_emb 加进残差流)、`:338-339,:353-357`(缓存 pre-RoPE K/V,掩码内容已在 x 中,读时只重做旋转)、`dmd_arope.py:150-151`(cell j 的掩码锚在 `max(0,j−20)`)、`:449-457`(打分掩码锚在 N−20);过度表述 `PORT_SPEC.md:366`("bit-for-bit")`,368`、`dmd_arope.py:145-146`、`arope_dit.py:325-326`、`data.py:127-131`;`test_world_rope_window.py` d1 仅 a=0(`:352-359`)、a3 故意固定掩码(`:198`);`test_stage3_dmd.py:186-209` 只查 shape。↔ 上游 `$U/gamemaster/models/gamemaster_dit.py:212-241` 无空间掩码通道,无对应物。 | 等价训练 clip 需 M(off[j]−off[a]),缓存持 M(off[j]−off[a_j]),a_j<a。CPU 测量(69/46 px/s,30×52):窗均不一致 ~3%(k21)→~25%(k30)→~31-33%(k≥40),最老 cell ~69%(等价 clip 全 known,缓存 31% known);当前 cell 恒 0%,k=20→21 连续无翻转。打分:N∈[21,27] 时 20/21 个 tail cell 的 c_score≠c_gen(每 cell 最大 6-30%)。后果:学生侧 train==deploy 成立(自强迫穿过它);是 stage2→stage3 的输入分布移位,k>27 稳态模式未训练;打分用尾锚掩码是教师 SFT 分布内的**正确**目标条件(3 位复核者中 2 位),与上游 `dmd_gm.py:674-677` 用打分器原生表示一致——不是 bug。 | 只改文档(上述 5 处:"RoPE 重锚定精确(a1/a3)、查询 cell 掩码精确(d1);缓存 cell 的掩码通道按提交时锚点固化,k>20 的历史与锚在 k−20 的 clip 不同——stage2→stage3 输入移位,自强迫训练穿过");加 test d3 记录数值(扩到 k=61)并断言查询 cell 0%;**不要**给教师/critic 喂混合锚掩码(会把双向教师推出分布);可选 `--mask_zero` 消融(须同时作用 rollout 与打分,否则制造新的 c_gen≠c_score);注明 k>27 稳态未训练或按显存抬 rollout_max。 |
|
| 23 |
+
| **GEO-3** (rollout F3) | minor | 端口 `arope_dit.py:853-857`(`n_cached==min(frame_index,cap)`)、`:309-316`(`_evict` 使 n_cached==cap 成为提交的不动点)、`:833-835`(docstring 声称能拦重复 commit)、`:801,:923`(`frames_cached` 只写不读;库内无读者)、`tests/test_kv_equiv.py:276-286`(test_5 仅饱和前)。↔ 上游 `gamemaster_dit.py:631-721`(无任何 n_cached 检查,`frames_cached :721` 同样无读者);`$G/graphgwm/models/graph_dit.py:855-857`(记录同一"饱和后形状对内容错")。 | k≥cap 时谓词恒为 cap==cap。CPU 复现(kv_window=3):cell 4 重复提交与跳过 cell 5 均不报错,重复提交改变后续 K/V(max|Δv|=2.5e-2)。现行调用方均每 k 恰提交一次(`dmd_arope.py:194/246,:306/318,:489/495`;`rollout_eval.py:287/300,:334`),`test_stage3_dmd.py:99-135` 的 spy 会拦。后果:无现行错算;未来编辑(重开 re-anchor、提前 `continue`��在 stage3 专门训练的驱逐区静默损坏窗口。 | forward_frame 入口加 `assert kv_cache["frames_cached"] == frame_index, f"frame_index={frame_index} 但缓存最后提交的是 cell {kv_cache['frames_cached']-1} —— 漏 commit 或重复 commit 了"`(消息须含 "frame_index" 与 "commit" 字样,保 test_4 `match="frame_index"`、test_5 `match="commit"`);改 `:833-835` docstring;test_5 扩饱和后重复/跳过用例(kv_window=2,commit 0..3 后重提 3;跳过 4 读 5)。 |
|
| 24 |
+
| **GEO-5** (rollout F5) | minor | 端口 `tests/test_world_rope_window.py:45`(`ROOT="/data/zhiyangdeng/ActionRoPE"`)、`:67-68,:91-93`(sidecar 缺失即 skip);同硬编码 `test_kv_equiv.py:42`(仅 sys.path,实测无影响)、`test_bitparity.py:28`、`test_flowmap_model.py:35`(`:548` STAGE1B_CKPT 亦错)、`test_iface_fidelity.py:34`;正确写法 `tests/preflight.py:45`、`test_tf_port.py:32`;`RUNBOOK.md:30`(`cd /data/…`)、`:34`(容忍计数漂移)。↔ 无上游对应;`PORT_SPEC.md:45,359,535,582`(把包络门定为 sink=0 前置门)。 | 本机跑该文件 = 3 passed / 7 skipped(b1/b2/b3/c1/c2/d1/d2);sidecar 实际在 `$R/data/latent/arope_sidecar.pt`;改路径后 10 passed(包络 372.97 px=11.655 tok=23.311 latent;b2 1.0056×/0.2%;c2 sink=1→1.50×)。后果:sink=0 包络内证据门在本机静默不跑(跑起来会过);无 run.sh 调 pytest,不阻塞启动。 | 5 个文件改为 `ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))`;corpus fixture 加 `AROPE_REQUIRE_SIDECAR=1` 时 `pytest.fail`(RUNBOOK 启动门用该变量);修 `RUNBOOK.md:30/34`。 |
|
| 25 |
+
| **R3** | minor | 端口 `tests/test_stage2_flowmap.py:445-466`(唯一三处一致性测试;`:453` parse_args 无 `--dry_run` ⇒ `stage2_flowmap/fm_arope.py:443-444` 因 stage1b ckpt 缺失 SystemExit,本机红);`test_stage2_cd.py`/`test_stage3_dmd.py` 无此测试;`cd_arope.py:354-362`、`dmd_arope.py:676-687` 同样需 `--dry_run`。↔ `RUNBOOK.md:15-17`、`PORT_SPEC.md:559`(R11) 只陈述规则。 | CPU 复刻:stage2_cd 23 常量 0 不一致;stage3_dmd 29 常量仅 `clamp` 假阳(`'20,980'` vs `dmd_arope.py:669-672` 派生 tuple);flowmap 加 `--dry_run` 后 36/0。今日无漂移;未来单处改动无自动拦截。 | `test_stage2_flowmap.py:453` 加 `"--dry_run"`;克隆到 cd/dmd 测试(`parse_args(["--output",p,"--dry_run"])`,skip 派生键 betas/denoise/clamp 与 ckpt 路径,`checked>=20`),加 run.sh 别名映射(cd: DISCRETE_N→discrete_cd_N、SOLVER_STEPS→teacher_solver_steps、CFG_SCALE→real_guidance_scale、MIN_RT、WINDOW(_SINK)、KEEP_*;dmd: SINK→sink_size、SCORE_FRAMES、OFFSET_CHUNK、CLAMP、KEEP_*)并断言硬编码字面量(`--opt8bit 0 --batch_size 1 --context_extend hold --reanchor_every 0 --frankenstein 0 --scene_dropout 0.0 --use_ema 1`)。 |
|
| 26 |
+
| **R4** | minor | 端口 `distill/stage3_dmd/run.sh:52`(硬编码 `accelerate_fsdp_dmd_4gpu.yaml`)、`:158`、`:147`(echo "4gpu.yaml 8 卡"自相矛盾)、`stage3_dmd/config.json:83-85,104-106`、`PORT_SPEC.md:469`(world 4/eff 4)、`dmd_arope.py:746`(`GIB_STATIC_4GPU` 在 8 卡下保守);`configs/accelerate_fsdp_dmd_8gpu.yaml` 与 4gpu 仅 `num_processes` 不同,`stage3_onpolicy/run.sh:42` 在用。↔ 上游 `$U/../launch/stage3_cd15k.sh:10-11`(NPROC=8,部署线)、`$U/stage3_dmd/run.sh:29`(默认 3)。 | stage3 无 GA 旋钮 ⇒ eff=world=8(上游部署线亦 8,非害);运行快照 `env.world_size` 正确(`dmd_arope.py:1035`)。仅文档漂移。 | `run.sh:52` 加 DISTILL_NGPU 分支(模板 `stage3_onpolicy/run.sh:41-45`,**不是** stage2_cd);`:147` 打印 `等效 batch=$DISTILL_NPROC`;`config.json` 加 `_world_size` 注、`PORT_SPEC.md:469` 记 "processes=DISTILL_NGPU,eff=processes"。 |
|
| 27 |
+
| **R2** | info | 端口 `stage2_cd/run.sh:32-33,:97`(`AR` 单变量喂 teacher+student)、`:80-88`(默认 `stage1_ar_tf/dits/dit_step12000` 本机不存在 ⇒ abort;`:85` 的 `AR=` 提示在 `-d $STAGE1_OUT/dits` 分支内,本机不打印);TEACHER/STUDENT 属 stage3(`stage3_dmd/run.sh:41,43`),INIT 属 stage1_long(`:28`)。↔ 上游 `$U/stage2_cd/run.sh:24,34`(`AR_CKPT` 同一约定)。 | 失败响亮(abort),非静默;override 名已在 `run.sh:15`、`PORT_SPEC.md:301` 记录。 | 用 `AR=`;把 `:85` 提示移出分支;`RUNBOOK.md:104` 写明 `AR=` 与长教师。**不要**加 TEACHER 别名:stage3 的双向 SFT ckpt(`outputs/arope_10k_ga2/step-10000`)无 tf/windowed 元数据键,`cd_arope.py:452` 只比存在的键 ⇒ 陈旧 `export TEACHER` 会静默通过 P1 门。 |
|
| 28 |
+
| **R5** | info | 端口 `stage2_cd/run.sh:76,:116-118`(RESUME=auto)、`cd_arope.py:575-597,:646-652`(不看 `finished` 标志;`:827` 写 `finished=true` 全库无读者);过期前缀 `/data/zhiyangdeng/ActionRoPE`:`stage2_cd/run.sh:13`、`stage3_dmd/run.sh:16`、`stage1_ar/run.sh:12`、`RUNBOOK.md:30/95/128`、`stage1b_adapt/run.sh:34`、`stage3_onpolicy/run.sh:13` 及多���测试 docstring。↔ 上游 `stage2_v3_ext.sh:42`(依赖续 finished run 延长)。 | 同 OUT 冒烟后全量 = 从 step 20 续;数值无害(decay=0 硬拷到 200、warmup 正确续、beta1=0)。本机冒烟已用 `_smoke_stage2_cd`,live run start_step 0。照抄过期路径的真实后果是 `mkdir -p` 到 `/data`(系统盘)并写 ~40 GB ckpt。 | 文档:过期前缀改为 `$R` 或 `$(pwd)`;RUNBOOK 加 `_smoke_stage2_cd` 示例。可选:`:579-580` 对 `finished=true 且 step<max_steps` 打显眼日志。保持 RESUME=auto。 |
|
| 29 |
+
|
| 30 |
+
### 已驳回(3/3 反驳,仅供参考)
|
| 31 |
+
- **S3-AUTOCAST-2**(accelerate 自带 autocast 使 `cache_enabled=False` 失效):torch 2.11 的 `cached_cast` 条目保留 autograd 边,实测 71/71 参数有梯度且位级等同;且所有启动路径都是 FSDP+bf16(参数为 bf16 视图,从不 cast)。`dmd_arope.py:43-47,1063-1064` 的注释在本 torch 上是保险带而非必需。
|
| 32 |
+
- **F4**(resample 可超包络 11%):需 20 个合成 cell 在一个窗内(N≥40),而 rollout_max=27 ⇒ 最多 7 个;全语料实测最大 1.033×(+0.39 token),0.05%;`config.json:35`/`dmd_arope.py:556-560` 已量化记录为 1.006×/0.2%;钳制乘子也无法做到 0%。
|
| 33 |
+
- **R6**(stage3 只靠 `/dits/` 子串守卫):这正是 `PORT_SPEC.md:562` R12 规定的缓解,且严于上游(`$U/stage3_dmd/dmd_gm.py:338` 无任何检查);所提 `role` 门对无 role 键的 stage1 ckpt 反而默认放行。
|
| 34 |
+
|
| 35 |
+
## 3. 计划运行所需的最小精确操作
|
| 36 |
+
|
| 37 |
+
调用方 shell 里 `$ROOT` 为空(`run.sh` 头注释),**所有路径写绝对**。
|
| 38 |
+
|
| 39 |
+
### 3a. Stage2 CD(8×H200,teacher==student==stage1_long_K3/dit_step1000)
|
| 40 |
+
**当前状态**:`outputs/distill/stage2_cd` 已在跑(01:43:07 起;world 8 / GA 1 / train_batch_size 8;`--cd_tail_t 0 --teacher_solver_steps 1 --resume auto`;step 160 @ 01:56:58,5.06 s/步 ⇒ 8000 步 ≈ 11.3 h;尚无 progress.json,首存 step 500)。冒烟已完成(`_smoke_stage2_cd`,01:37-01:41,step 20 finished)。**配方正确,无需改动,不要触碰。**
|
| 41 |
+
|
| 42 |
+
若需重启/续训(崩溃后),必须原样传同一组变量(GA 变了 `cd_arope.py:587-590` 会拒;`AR=` 不得更换,见 INFRA-2):
|
| 43 |
+
```
|
| 44 |
+
DISTILL_NGPU=8 GRAD_ACCUM=1 \
|
| 45 |
+
ACC_CFG=/opt/dlami/nvme/zhiyangdeng/ActionRoPE/distill/configs/accelerate_zero2_8gpu_ga1.yaml \
|
| 46 |
+
AR=/opt/dlami/nvme/zhiyangdeng/ActionRoPE/outputs/distill/stage1_long_K3/dits/dit_step1000.safetensors \
|
| 47 |
+
bash distill/stage2_cd/run.sh # OUT 默认 outputs/distill/stage2_cd,RESUME=auto 续
|
| 48 |
+
```
|
| 49 |
+
(冒烟形式同上,另加 `MAX_STEPS=20 SAVE_EVERY=10 LOG_EVERY=1 PORT=29542 OUT=/opt/dlami/nvme/zhiyangdeng/ActionRoPE/outputs/distill/_smoke_stage2_cd`。)
|
| 50 |
+
|
| 51 |
+
运行中监控:`train_log.csv` 中 idx==2 行的 `tgt_err` 相对 step≤200 基线(CD-1 漂移表);`cd` 降而 `cm_err` 升 = 塌成自洽糊图(`cd_arope.py:265-268`)。
|
| 52 |
+
|
| 53 |
+
需用户拍板、**只能用于新 OUT 的重跑**(不改 live run):`CD_TAIL_T=100`(补 σ=0.1011 节点,部署节点不变,4 对;改后同步 argparse/run.sh/config/test_1);`SOLVER_STEPS=4`(默认 1 下 Euler 截断预算 Σdσ²=0.187,为上游 48 轴的 5.5×,已在 `config.json _teacher_solver_steps` 记录)。
|
| 54 |
+
|
| 55 |
+
### 3b. Stage3 DMD 之前必须做的编辑(不影响 live run)
|
| 56 |
+
1. **必做** `distill/eval/sweep8.sh:50`、`photodrift.sh:16`、`paired50.sh:23`:删除 `--sink_size 3 --kv_window 17 --rope_cap 20`;替换 `sweep8.sh:17`/`photodrift.sh:12`/`paired50.sh:12` 的 `/tmp/claude-2011/...` 死路径(GEO-1)。
|
| 57 |
+
2. **必做** `rollout_eval.py`:读 ckpt 元数据 `kv_window/sink_size/rope_cap` 并在不一致时拒绝(GEO-1)。
|
| 58 |
+
3. **强烈建议** `dmd_arope.py:1237` 后加 gnorm==0/NaN 中止(S3-GUARD-3)。
|
| 59 |
+
4. **建议** `stage2_cd/run.sh:42-43`、`stage1_ar/run.sh:39-40`、`stage3_dmd/run.sh:52` 加 DISTILL_NGPU 分支 + eff-batch 断言(INFRA-1/R4)。
|
| 60 |
+
5. **建议** 文档:`stage3_dmd/config.json:39,43-46`、`PORT_SPEC` R3/R10/`:666-668`(DMD-1);`cd_arope.py:120-121`(CD-1);GEO-2 五处措辞;5 个测试文件 ROOT(GEO-5)。
|
| 61 |
+
6. **stage2 结束后** 再合入 INFRA-2 的 progress.json 身份校验(stage2+stage3)。
|
| 62 |
+
|
| 63 |
+
### 3c. Stage3 DMD 冒烟(stage2 出 `ema/ema_step8000` 后)
|
| 64 |
+
```
|
| 65 |
+
DISTILL_NGPU=8 \
|
| 66 |
+
ACC_CFG=/opt/dlami/nvme/zhiyangdeng/ActionRoPE/distill/configs/accelerate_fsdp_dmd_8gpu.yaml \
|
| 67 |
+
MAX_STEPS=20 SAVE_EVERY=10 LOG_EVERY=1 PROFILE_MEM=1 PORT=29541 \
|
| 68 |
+
OUT=/opt/dlami/nvme/zhiyangdeng/ActionRoPE/outputs/distill/_smoke_stage3_dmd \
|
| 69 |
+
STUDENT=/opt/dlami/nvme/zhiyangdeng/ActionRoPE/outputs/distill/stage2_cd/ema/ema_step8000.safetensors \
|
| 70 |
+
bash distill/stage3_dmd/run.sh
|
| 71 |
+
```
|
| 72 |
+
(`ACC_CFG` 仅为文档一致,功能上 4gpu yaml + `--num_processes 8` 等价;`TEACHER` 默认 `outputs/arope_10k_ga2/step-10000.safetensors` 存在;`STUDENT` 绝不能是 `dits/`。)
|
| 73 |
+
冒烟门:step 0 dmd≈0(FSDP 下 ~1e-3,非恰 0);**step≥1 每步 gnorm_g 有限且 >0**(这是 streamed 重放在 FSDP 多 rank 上的���次执行,S8 仍开,必须人工看);dmd 进 +0.15…+0.68 带;critic 下降;`_budget_gate` 通过;对 `ema_step10/20` 跑 `sweep_photodrift.sh`(已改成训练几何)确认无立即变暗。
|
| 74 |
+
|
| 75 |
+
### 3d. Stage3 DMD 全量
|
| 76 |
+
```
|
| 77 |
+
DISTILL_NGPU=8 \
|
| 78 |
+
ACC_CFG=/opt/dlami/nvme/zhiyangdeng/ActionRoPE/distill/configs/accelerate_fsdp_dmd_8gpu.yaml \
|
| 79 |
+
OUT=/opt/dlami/nvme/zhiyangdeng/ActionRoPE/outputs/distill/stage3_dmd \
|
| 80 |
+
STUDENT=/opt/dlami/nvme/zhiyangdeng/ActionRoPE/outputs/distill/stage2_cd/ema/ema_step<N>.safetensors \
|
| 81 |
+
bash distill/stage3_dmd/run.sh # 500 步,eff batch 8(=上游 cd15k 的 8 rank),save_every 50,keep_emas 25
|
| 82 |
+
```
|
| 83 |
+
里程碑挑选:`rollout_eval`(位移/动作服从)+ `sweep_photodrift`(41 cell std/mean vs stage2 基线),**均按 sink 0/w20/cap 20**;sink 3/w17 只作显式 A/B。
|
| 84 |
+
|
| 85 |
+
## 4. 已验证正确(摘要)
|
| 86 |
+
|
| 87 |
+
**CD 数学与循环**
|
| 88 |
+
- 配对 σ_t=sig[idx]>σ_tn=sig[idx+1],idx∈[0,K−2](`cd_arope.py:758,764-766` ≡ `cd_gm.py:105-106,435`);部署轴 idx [0,625,833,937],σ [1,0.75,0.5006,0.251597],t=σ·1000 位级对齐(`flow_pf.py:263-288`,DiffSynth `flow_match.py:52`)。
|
| 89 |
+
- 加噪 `(1−s)x0+sε` cell 0 钉住(`:212` ≡ `cd_gm.py:79-83,111`);教师 Euler `lat_t+(s_tn−s_t)v`,v=ε−x0,每子步重钉(`:233-234` ≡ `:122-123`;DiffSynth `flow_match.py:391,409-411`);cm_t 带梯度、cm_tn 在 no_grad 下由 EMA 算(`:237-245` ≡ `:126-131`);loss=MSE(cells 1:),目标 detach(`:248` ≡ `:133`);oracle loss==0、上游转写位级一致(12/12 pass)。
|
| 90 |
+
- cond/clean_x 在 teacher/student/EMA 三次前向中是同一对象,EMA 取 x=lat_tn 但 clean_x=x0(`:202-206,225,238,244` ≡ `:100-102,117-131`);`first_frame_cond=True`+标量 t 复现 `_per_frame_t`(`arope_dit.py:167-194`)。
|
| 91 |
+
- v 空间 CFG 只作用于冻结教师,uncond=NULL_PROMPT(stage1 scene_dropout=0.1 ⇒ 分布内),cfg==1 跳过(`:206,227-231,666-682` ≡ `:117-121`)。
|
| 92 |
+
- idx 流:CPU 生成器 seed 1234+seed 全 rank 同、每 micro 一抽、按 micro 计数快进,续训拒改 GA/轴(`:741-743,758,573-593` ≡ `:417-422,435`)。
|
| 93 |
+
- 仅一个 nn.Module 进 prepare(`:656`;accelerate 只保留最后一个 Module);教师 bf16 冻结 eval(`:622`);EMA fp32 影子非 nn.Module(`ema.py:66-86`),在 DeepSpeed 真实 step 之后每优化器步更新一次(accelerate `utils/deepspeed.py:264-284`;`:777-793`);ema_start 语义与上游一致(步 1..200 硬拷,201 起衰减);ZeRO-2 下参数全量非分片,zip 顺序/数量/形状断言(`ema.py:49-55`,DeepSpeed `stage_1_and_2.py:805-808`)。
|
| 94 |
+
- AdamW(2e-6, wd 0, betas (0,0.999))、warmup 100 LambdaLR、clip 10 注入 DS 并生效(`:641-645,511-512`;live train.log 印证);GA 门关闭 accelerate 静默采纳路径(`:513-519`);`_ckpt_meta_gate` 接受 stage1_long_K3 step1000(header:teacher_forcing=True、windowed 0/0、827 BF16 张量,额外键 long_seg_dir 被忽略)。
|
| 95 |
+
- 无隐藏 F=61 依赖:F 取自 latent shape,RoPE 表预算到 1024(`cd_arope.py:171,185-193`,DiffSynth `wan_video_dit.py:405-407`)。
|
| 96 |
+
- 三处配方一致:stage2 22 常量、stage3 27 常量 run.sh==argparse==config.json(上游 A 的 6 处 run.sh/argparse 不一致均按 run.sh 侧解决)。
|
| 97 |
+
|
| 98 |
+
**调度器**
|
| 99 |
+
- 1000 格网格、权重、`training_target=noise−x0`、`add_noise_pf` 逐 cell 广播(fp32 算 1−σ 再 cast,优于上游 bf16)、`idx_of_t/t_of_idx` 往返、`cd_axis(N)` 与 `set_timesteps(4)` 陷阱规避、`ti2v_noise` 钳制 [20,980)=σ∈(0.0969,0.9959]——全部与 DiffSynth `flow_match.py` 及 `$U/gamemaster/flow_match.py` `torch.equal`(33 tests pass)。
|
| 100 |
+
- bf16 时间步取整(750→752 等)是 SFT/stage1/2/3/部署共用的约定,无 train/infer 网格错配。
|
| 101 |
+
|
| 102 |
+
**DMD/critic 数学、步序、FSDP**
|
| 103 |
+
- `x0=noisy−σ·v`,共用一份噪声与 t(`dmd_arope.py:358-370` ≡ `dmd_gm.py:204-219`);归一化 `|clip−pred_real|` 在 eval_idx 上均值 clamp 1e-2 + nan_to_num(`:371-377` ≡ `:221-224`);代理 `0.5·mse(g,(g−grad).detach())` 梯度=grad/n,锚 cell 0(`:378,:391`;CPU autograd 验证);cfg 1.0/3.0 与上游转写位级一致。
|
| 104 |
+
- eval_idx=tail 1..20(`grad_start_range :260-262`,`:353`,`:454` 断言锚不打分);critic 目标 noise−clip 无时间步权重(`:408-413` ≡ `:244-245`);教师/critic `causal=False`、无 clean_x/kv_cache、`first_frame_cond=True` 与 SFT 一致(`:935-936,:131-132,:457`)。
|
| 105 |
+
- 步序 rollout→DMD loss→student backward→clip→opt_g→slr_g→EMA→critic×5→slr_c(`:1189-1257` ≡ `dmd_gm.py:681-706`);AdamW betas (0,0.999) wd 0.01、lr 2e-6/1e-6、warmup 100、clip 10 每模型经 FSDP 自身 `clip_grad_norm_`(`:1054-1059`)。
|
| 106 |
+
- prepare 前全部 `.float()` 并断言(`:957-962`);`fsdp_use_orig_params` 断言(`:836-842`);教师不 prepare;EMA FSDP 包 fp32 冻结、zip 更新(`ema.py:41-63`);`get_state_dict`+safetensors bf16 827 键(`runlib.py:297-353`);`dit.freqs` 在��个裸模块上搬到 device(`:997-1000`);accelerate 1.14 对 FSDP1 多模型+多优化器无限制。
|
| 107 |
+
- 流式重放精确:提交在 no_grad 下且缓存 K/V detach(`arope_dit.py:356-357`),逐帧代理 `0.5·mse/n_eval` 求和=融合代理(`:325`;test_7 rel<1e-6,`config.json _measured cos 1.0`);梯度检查点永不触及 rollout(`arope_dit.py:689-692`)。
|
| 108 |
+
- N/grad_start/exit_step 从共享种子抽并由 rank 0 广播(`:1120-1148`)⇒ 各 rank 前向/反向次数一致,无集合通信错配;`_budget_gate` 在 8 rank 下保守通过;`_KVRef` 在 torch 2.11 `_apply_to_tensors` 下按身份返回(`torch/distributed/utils.py:252-253`)。
|
| 109 |
+
|
| 110 |
+
**Rollout 几何与 DiT 包装**
|
| 111 |
+
- RoPE 重锚定精确:q·k 只依赖位置差,f64 下窗口平移 rel 2.3e-15(`build_world_freqs`、DiffSynth `rope_apply :94-100`;test a1/a3);`retained_cells`/`window_offsets_for_frame`/`forward_frame` 的 tpos_j=j−a、off_win_j=off[j]−off[a] 自洽(`preflight.py:77-131`,`arope_dit.py:893-908`);`rope_cap==sink+kv_window` 四处强制。
|
| 112 |
+
- 训练 rollout 与 `rollout_eval.deploy_rollout` 同一代码路径(`rollout_eval.py:79` 导入 `frame_cond`;`:308-358`):同 renoise 链 `den=x−σ_i v; x_next=(1−σ_{i+1})den+σ_{i+1}ε`、t=0 detach 提交每 k 一次;exit_step 语义 ≡ `dmd_gm.py:140-184`(部署跑满表为已记录差异)。
|
| 113 |
+
- 驱逐被训练到:`rollout_min≥sink+kv_window+1=21`,`ROLLOUT 21..27` ≡ 上游;`preflight_sliding_kv` 断言驱逐改变输出(`preflight.py:245-287`)。
|
| 114 |
+
- 掩码:块因果 [S,S] 与 TF [2S,2S] 谓词 ≡ `gamemaster_dit.py:536-567`,所有 local/sink/drop_anchor 组合闭式+探针零泄漏;SDPA bool 语义 True=可见;窗口化 TF 同时约束 clean 半边(已记录,且比上游 `:549-554` 更正确);两半共享 f_start=0 RoPE、per-cell 文本、head 只读 noisy 半边、mask_emb 两半都加(≡ `:496-586`)。
|
| 115 |
+
- KV 路径缓存 pre-RoPE K、读时重旋转、最老非 sink 驱逐、commit 标志/detach、`_evict` 为 while 循环(上游单 if 的超集)、n_sink 钳制(GraphGWM 修的 bug 已含);绝对模式 f_start 与上游 post-RoPE 缓存等价;`forward()` 的 kv 路径守卫(first_frame_cond False、无 clean_x/offset_px/mask_input、无 gc、frame_index 必填)。
|
| 116 |
+
- 偏移/文本外推对齐(`extend_context` hold ≡ `dmd_gm.py:632-637`;`extend_offsets` 积分与 `parse_actions` 一致;`assert_rollout_aligned` 每次前调用);resample 步长≤语料 per-cell p99.9;当前 cell 掩码在 k=20→21 连续(test d1)。
|
| 117 |
+
- 无重复提交:`self_forcing_rollout`、`stream_grad_replay`、`deploy_rollout`、`probe_student_grad` 均每 k 恰一次(spy 测试 `test_stage3_dmd.py:99-135`、`test_kv_equiv.py` test_7/8)。
|
| 118 |
+
|
| 119 |
+
**启动器/环境**
|
| 120 |
+
- `common.sh:36-44` 8 卡路径正确(CUDA_VISIBLE_DEVICES 0-7、NCCL_P2P_LEVEL=NVL、OMP 16×8≤160 核);端口无冲突(29532/29536/29541+/29545);磁盘 26 TB 空闲,全量 stage2 ≈180 GB;数据前置全部存在(train_eybx 20983、val_eybx 1175、sidecar、text_table);stage3 `STUDENT` 路径含 `/dits/` 双重拒绝;`_assert_run_dir_safe` 拒外阶段目录/10 min 内活 train.log。
|
| 121 |
+
|
| 122 |
+
### 信息级备注(非发现)
|
| 123 |
+
- `PORT_SPEC.md` 仍写 "Expect loss ≈ 0 at step 0" / S7(e),与 `config.json _expect`(部署轴 step 0 loss 非 ≈0)矛盾,会误导冒烟判读。
|
| 124 |
+
- `deploy_schedule` 吸附容差用全局 0.5·max(Δt)(≈2.47),t=3.0 会被静默吸到 idx 999 而非拒绝;固定 4 步表不受影响。
|
| 125 |
+
- 窗口化 TF 的 `local` 含查询 cell 自身:匹配部署 kv_window=20 需 local=21(F=21 下=全因果);配置 0/0,无影响。
|
| 126 |
+
- F=61 TF 掩码 [2S,2S] S=23790 ⇒ 2.26 GB bool 常驻 `_mask_cache` + SDPA 转 bias ~4.5 GB 瞬时;混 21/61 cell 会同时保留两份。
|
| 127 |
+
- `_KVRef` 恢复只在根 `forward()`;依赖嵌套 FSDP 单元不 cast/重建输入(accelerate 留 `cast_forward_inputs=False`)。
|
| 128 |
+
- streamed 重放在 FSDP **≥2 rank** 上从未在任何参考树中执行过(GraphGWM 生产用 fused;端口 streamed==fused 检查为单卡;S8 未闭合)——3c 冒烟是首次,务必逐步看 gnorm_g。
|
backup_2026-09-14/code/distill/MANIFEST.md5
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# distill/ 状态快照 —— 生成于 2026-09-07 14:59:44 stage3-onpolicy 起训前
|
| 2 |
+
# 本版通过:339 tests + stage2-flowmap 8000 步产物 + stage3-onpolicy 20 步 8 卡 FSDP 冒烟
|
| 3 |
+
# (horizon=4、~11 s/step、峰值 75-119 GB/卡、832/827/832 存档键数正确)
|
| 4 |
+
# 起训前: md5sum -c distill/MANIFEST.md5
|
| 5 |
+
|
| 6 |
+
d41d8cd98f00b204e9800998ecf8427e distill/__init__.py
|
| 7 |
+
f4f75f84ac79b1d03519f2e204959546 distill/arope_dit.py
|
| 8 |
+
5584bc8392d100d475c187b1d95d56d0 distill/common.sh
|
| 9 |
+
e6de64ae254088f01625e19221111add distill/configs/accelerate_fsdp_dmd_4gpu.yaml
|
| 10 |
+
e2ce1fd7ca6d187cacbfa81e78535055 distill/configs/accelerate_fsdp_dmd_8gpu.yaml
|
| 11 |
+
0d2bb6efe49b050aea306541b4a34d65 distill/configs/accelerate_zero2_4gpu_ga2.yaml
|
| 12 |
+
85ee449b05bf9f686f14c45196455523 distill/configs/accelerate_zero2_4gpu_ga4.yaml
|
| 13 |
+
6a34ea5734cf13f06e924d9754a70c12 distill/configs/accelerate_zero2_8gpu_ga1.yaml
|
| 14 |
+
7548174929fd59a62a8a1d194b088575 distill/data.py
|
| 15 |
+
3c4bb6e458ae3b9fe4d4ca812e581a18 distill/ema.py
|
| 16 |
+
efacdb167135b5bc106b1cadc8bd997c distill/eval/anystep_probe.py
|
| 17 |
+
d34dc0469f49fc5826f8094062adf385 distill/eval/flowmap_liveness.py
|
| 18 |
+
33963f63c251db48899df3c550c38e2e distill/eval/null_branch_probe.py
|
| 19 |
+
86f7ccdda50e7027e88cb0550863f451 distill/eval/rollout_eval.py
|
| 20 |
+
908f04c018f2ce1d0e207fd1f88933bd distill/eval/sweep8.sh
|
| 21 |
+
dac8e1258f3cbf34db5d2b419ef61aa9 distill/eval/tf_rollout_probe.py
|
| 22 |
+
a3324266dd8f3e1ff437a53fa16cd993 distill/flow_map.py
|
| 23 |
+
7707dd7f57bd3aa3415c96b9baf99f88 distill/flow_pf.py
|
| 24 |
+
d67ba664053d958506178a2d9fe3a470 distill/flowmap.py
|
| 25 |
+
166cbbbda1d4bd5fa792cc8f75900a64 distill/onpolicy.py
|
| 26 |
+
94fe49fa6b1688529dd563351197b6f8 distill/runlib.py
|
| 27 |
+
0ab413868394a1467be20ec317be4ae9 distill/stage1_ar/ar_tf.py
|
| 28 |
+
369dcf3e22a04fa74fb8502e2e8e99d7 distill/stage1_ar/config.json
|
| 29 |
+
cff6e9ea64e3648ccef727ccebebac62 distill/stage1_ar/run.sh
|
| 30 |
+
cf14a9aa4c9f52f15111ac06e6cb3936 distill/stage1b_adapt/run.sh
|
| 31 |
+
45f9f10b4ab8a6299e43c291ab4f4b91 distill/stage2_cd/cd_arope.py
|
| 32 |
+
2402bf288b7f78d7a30bf65e0aa3868c distill/stage2_cd/config.json
|
| 33 |
+
1f635a72fb0e938028afa356b28479cd distill/stage2_cd/run.sh
|
| 34 |
+
67f689be9600a8bd540480bb8585a0ea distill/stage2_flowmap/config.json
|
| 35 |
+
06875f5dee35b8cc28ff90e456545cf1 distill/stage2_flowmap/fm_arope.py
|
| 36 |
+
22507adaec0ef9339b292a24095b3c9f distill/stage2_flowmap/run.sh
|
| 37 |
+
043f7d0fcce74f87fd185c3cca6ff9fb distill/stage3_dmd/config.json
|
| 38 |
+
20e550d8b05ac9f20412979332400797 distill/stage3_dmd/dmd_arope.py
|
| 39 |
+
9de618ffe59f845e52f421665a4adf55 distill/stage3_dmd/run.sh
|
| 40 |
+
dfa2de24921c087c842529242e1aeb10 distill/stage3_onpolicy/config.json
|
| 41 |
+
6361310acf172703a94a804bf230483f distill/stage3_onpolicy/onpolicy_arope.py
|
| 42 |
+
6373a4762e4958eb01dab45b4a1aaa4c distill/stage3_onpolicy/run.sh
|
| 43 |
+
0a5c95ab6dfc326869d8185efb816eae distill/tests/preflight.py
|
| 44 |
+
0c42f9fed1bf89a626a313750cb97398 distill/tests/test_bitparity.py
|
| 45 |
+
644e50542cba2c5accda10b496616cc0 distill/tests/test_cond_dropout.py
|
| 46 |
+
8645b069fa5870292e9e80768c56085b distill/tests/test_data.py
|
| 47 |
+
b4424fe522987570e086b3c247baab74 distill/tests/test_flow_pf.py
|
| 48 |
+
781f3bdeafff7cc4373f5391460ad87a distill/tests/test_flowmap_model.py
|
| 49 |
+
6f8c6eb2dda143c914e863879f838a92 distill/tests/test_flowmap_objective.py
|
| 50 |
+
61d614833748b9d1fdf4a6ad369daba3 distill/tests/test_flowmap_upstream_diff.py
|
| 51 |
+
56f50b9ce20f2f8a756cdbe140bafbc5 distill/tests/test_fm_arope_wiring.py
|
| 52 |
+
70abc01e9dece1d8c1ac349bacf65e23 distill/tests/test_iface_fidelity.py
|
| 53 |
+
2bb100531f06f83e6c2ac2df864b995a distill/tests/test_kv_equiv.py
|
| 54 |
+
f3cd288f4db613489a9cb753816ff1b7 distill/tests/test_onpolicy.py
|
| 55 |
+
fdb6bfa9b7e4d365fed7e71fa8b5ce71 distill/tests/test_runlib.py
|
| 56 |
+
ae80f988c038cf2a1e5558aecbe51024 distill/tests/test_stage23_upstream_diff.py
|
| 57 |
+
0090187c8363f55c0e406499a211c367 distill/tests/test_stage2_cd.py
|
| 58 |
+
a45eb617da973973aa8037dba48155b8 distill/tests/test_stage2_flowmap.py
|
| 59 |
+
2d41d3760508df0b4608967ba0a5b366 distill/tests/test_stage3_dmd.py
|
| 60 |
+
a8ac99c5a2ec7a64637cc8a46b551340 distill/tests/test_tf_port.py
|
| 61 |
+
436730aa47115a5d9166f2426c659827 distill/tests/test_world_rope_window.py
|
| 62 |
+
d317cfcb9b10cd1ad326ca9dca1cd41c distill/eval/photodrift.sh
|
| 63 |
+
a6d75b49e766cedc15b1ebc9e23119c2 distill/eval/sweep_photodrift.sh
|
backup_2026-09-14/code/distill/OPSD_OFFICIAL_NOTES.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# OPSD-V 官方实现(vendor/OPSD-V,commit 8ecb193,2026-08-31)与 GraphGWM 移植(hf/worldmind_ref/GraphGWM/train/code_distill/stage4_opsd)的差异笔记
|
| 2 |
+
|
| 3 |
+
读的是 `vendor/OPSD-V/model/opsd_streaming_training.py::compute_opsd_generator_loss / setup_opsd_sequence / _replace_cache_chunk_with_gt`、
|
| 4 |
+
`trainer/opsd_streaming.py::fwdbwd_one_step_opsd / _ema_update_lora_teacher_adapter`、`configs/train_self_forcing_lora.yaml`。
|
| 5 |
+
|
| 6 |
+
## 官方机制(逐条)
|
| 7 |
+
1. **部署表**:`denoising_step_list: [1000, 750, 500, 250]` + `warp_denoising_step: true`(把 t 映射到 scheduler 的 shift 网格)——与本仓库的 4 步部署表 + `deploy_schedule` 吸附到 1000 网格同构。chunk = 3 个 latent 帧(`num_frame_per_block: 3`);本仓库 chunk = 1 cell。
|
| 8 |
+
2. **两条缓存都从 GT 首 chunk 起**(`opsd_use_gt_first_chunk: true`,t=0 写入)。窗口起点 `window_start` 在 GT 长视频里**随机**(按 block 对齐;`window_start_override: 0` 时固定从 0 起),rollout 长度 `opsd_loss_frames`(180 帧)≤ `opsd_max_loss_frames`(243)。
|
| 9 |
+
3. **每个 chunk、每个去噪步**:同一份 `noise_chunk` / 同一 `noisy_input` 先喂教师(no_grad,权重换成 EMA/teacher adapter),再喂学生(需要 loss 的步 enable_grad)。`opsd_teacher_trajectory_mode: student` ⇒ `teacher_noisy_input = noisy_input`(教师走学生访问的 noisy 状态)。
|
| 10 |
+
4. **loss**:`flow` ⇒ `mean((student_flow − teacher_flow.detach())²)`;`opsd_loss_step_mode: all` ⇒ 每个去噪步都算;`backward_per_chunk: true` 时**逐步** `(step_loss / total_loss_terms / grad_accum).backward()`(不是逐 chunk 累加后再反传),反传后 detach 两条缓存里的张量;`total_loss_terms` = 全窗口被打分的 (chunk, step) 对数。
|
| 11 |
+
5. **重新加噪**:`noisy_input = add_noise(student_chunk.detach(), transition_noise, next_t)`(学生自己的 x0 + 新噪声;stop-gradient 过渡),与本仓库的 renoise 链一致。
|
| 12 |
+
6. **chunk 结束后写缓存**(`context_noise: 0` ⇒ t=0 干净写入):学生缓存写**学生**的最终 x0;教师缓存写**教师自己**的最终 x0(`teacher_chunk`,用 EMA 权重 `_write_chunk_to_cache(use_teacher_ema=True)`)。
|
| 13 |
+
7. **教师缓存的 GT 替换**(`opsd_teacher_context_mode: gt_kv`):写完之后,若已生成 ≥2 个 chunk,把教师缓存里**倒数第二个** chunk 原位替换成 GT(`_replace_cache_chunk_with_gt`:暂存 `global_end_index/local_end_index`,在该位置用 t=0 前向重写 K/V,再恢复 end index)。于是打分第 k 个 chunk 时教师缓存 = **GT 0..k−2 + 教师自己的 k−1**。
|
| 14 |
+
8. **教师权重**:`lora_ema_enabled: true, ema_weight: 0.9999, ema_start_step: 0` ⇒ 教师 = 学生(LoRA)的 EMA,每步更新;`max_iters` 3300(longlive)/ 见 self_forcing 配置。LoRA rank 256,lr 1e-5,`max_grad_norm_generator: 10.0`,gradient checkpointing 开。
|
| 15 |
+
9. `opsd_loss_start_frame: 21`(前 7 个 3 帧 chunk 不打分);`opsd_teacher_use_future_gt_context: false`(教师不看未来 GT,默认关)。
|
| 16 |
+
|
| 17 |
+
## GraphGWM 移植与官方的差异(本仓库移植时要显式选择)
|
| 18 |
+
| 项 | 官方 | GraphGWM | 本仓库建议 |
|
| 19 |
+
|---|---|---|---|
|
| 20 |
+
| 教师缓存里最近一个 chunk | **教师自己**的 x0(`teacher_chunk`) | **学生**的 k−1(`--teacher_keep_last 1`) | 加 `--teacher_last_chunk {teacher,student,none}`,默认 `teacher`(官方) |
|
| 21 |
+
| 反传粒度 | 逐去噪步 `(step_loss/total_terms).backward()` | 逐 chunk(chunk 内各步求和 / 步数) | 逐步反传(显存更小),归一化按全窗口 total_terms(官方) |
|
| 22 |
+
| 教师权重 | 学生 EMA 0.9999(每步更新) | 冻结(`--ema_decay 0`),理由:全参微调 + 短跑下 EMA 会漂向学生 | 默认冻结,`--ema_decay` 可开(0.9999) |
|
| 23 |
+
| loss_start | 21 latent 帧 | 12 | 默认 12,可调;文档写明官方是 21 |
|
| 24 |
+
| 窗口起点 | GT 长视频里随机(block 对齐) | 段首(cell 0) | 默认段首(61 cell 全用);`--rollout_cells < 61` 时随机起点 |
|
| 25 |
+
| chunk | 3 latent 帧 | 1 | 1 cell(部署也是逐 cell) |
|
| 26 |
+
| 缓存写入 | 官方在**原位**替换倒数第二 chunk 为 GT(cache 是固定长度数组 + end index)。**写入顺序**:先 `_write_chunk_to_cache(teacher_chunk)`(此刻缓存里还是 GT 0..k−2 + 教师的 k−1),再把 k−1 换成 GT、**不重算最近一格** ⇒ 打分 k+1 时最近一格(教师的 k)第 1 层起的 K/V 嵌着"教师的 k−1",不是全 GT 历史 | 追加式滑窗缓存做不到原位替换 ⇒ 真实缓存**滞后一帧** + 每 chunk 快照再把学生的 k−1 commit 进快照(K/V 在全 GT 历史上算 —— latent 层面同官方,K/V 层面不同) | 快照法 + 官方写入顺序:cell k 打分完教师把自己的 x0 commit 进**它打分用的那条快照**(上下文正是官方的),取走那格 raw K/V(`last_kv_frame`),cell k+1 用 `append_kv_frame` 接到 GT 0..k−1 的新快照末尾。latent 与逐层 K/V 都与官方相��(test_stage4_opsd::test_13 逐位核对)。student 模式保留 GraphGWM 口径 |
|
| 27 |
+
| 教师前向实现 | 换权重(summon_full_params 拷贝 EMA) | 单独一份冻结 bf16 教师模块 | 单独教师模块(同 stage3 real-score 教师) |
|
| 28 |
+
| RNG | 教师前向前后保存/恢复 RNG state | 无 | 无 dropout,可略;但打分状态的噪声必须显式共享 |
|
| 29 |
+
|
| 30 |
+
## 补充核对(trainer/opsd_streaming.py)
|
| 31 |
+
- 训练循环顺序:`zero_grad → [GA 次 fwdbwd_one_step_opsd(逐步 backward,grad 捕获到 deferred buffer)] → 恢复 grad → clip(10.0) → optimizer.step → EMA 更新(LoRA 模式:teacher adapter ← decay·teacher + (1−decay)·student,decay=0.9999)→ step+=1`。
|
| 32 |
+
- 教师 adapter 起始 = 学生 adapter 的拷贝(训练前两者相同 ⇒ 教师起点 = 未 OPSD 的基座);3300 步后 0.9999^3300≈0.72 的权重仍是初始权重 ⇒ 官方教师本质上是「几乎冻结、缓慢跟随学生」。GraphGWM 用完全冻结教师是它的 decay→1 极限,差异可忽略;本仓库默认冻结,可选 EMA。
|
| 33 |
+
- 每个去噪步前保存 CPU/CUDA RNG state,教师前向后恢复,再跑学生:保证学生前向的随机数(若有 dropout 等)与「没有教师前向」时一致;本仓库 DiT 推理无 dropout,可略。
|
| 34 |
+
- `context_noise=0` ⇒ `add_noise(x0, ε, t=0)` = x0(`context_noisy` 名义上加噪,实际是干净写入),与本仓库 t=0 commit 一致。
|
| 35 |
+
- `generated_spans` 不含 GT 首 chunk(只记生成的 chunk)⇒ 第一个生成 chunk 打分时教师缓存 = GT 首 chunk;第 k 个生成 chunk 打分时教师缓存 = GT 首 chunk + GT 生成段 1..k−2 + 教师自己的 k−1(写缓存后再把倒数第二个替换成 GT,最近一个保留)。
|
| 36 |
+
|
| 37 |
+
## 本仓库 `distill/stage4_opsd/opsd_arope.py` 的最终选择(2026-09-10)
|
| 38 |
+
| 项 | 选择 | 开关 |
|
| 39 |
+
|---|---|---|
|
| 40 |
+
| 教师缓存最近一格 | **官方**:教师自己在最后一个去噪步、学生的 noisy 上算出的 x0(未打分 cell 也跑一次教师最后一步) | `--teacher_last_chunk teacher` / `student`(GraphGWM) |
|
| 41 |
+
| loss 归一化 | **官方**:整窗所有打分 (cell, step) 的平均(逐 cell backward 时每 cell 均值 ÷ 打分 cell 数) | `--loss_norm window` / `cell`(GraphGWM) |
|
| 42 |
+
| 教师权重 | 冻结 init_ckpt(官方 EMA 0.9999 ≈ 冻结;FSDP 下未实现 EMA)。开 EMA 时教师留 **fp32**:bf16 半 ulp ~2e-3 吃不进 1e-4 的增量,bf16 教师上 EMA 会静默退化成冻结(trainer 在加载处断言) | `--ema_decay`(仅单进程) |
|
| 43 |
+
| 反传粒度 | 逐 cell(与官方逐步等价) | — |
|
| 44 |
+
| grad_clip | **官方** 10.0(`max_grad_norm_generator`;stage3 同值)。曾是 1.0(未记录的偏离;window 归一化下会每步触发,把 AdamW 变成归一化 SGD) | `GRAD_CLIP` |
|
| 45 |
+
| Adam betas | **官方** (0.0, 0.999)(`train_self_forcing_lora.yaml` beta1/beta2;stage3 同值)。曾是 (0.9, 0.95)(无出处) | `ADAM_BETAS` |
|
| 46 |
+
| loss_start | 12(GraphGWM;官方 21) | `LOSS_START` |
|
| 47 |
+
| 窗口起点 | 段首 cell 0(VAE 首 latent 是 TI2V 锚点,不能随机) | — |
|
| 48 |
+
| 缓存写法 | 滞后一格的真值缓存 + 每 cell 浅快照 + **官方写入顺序**:最近一格是教师在上一格打分完 commit 进它打分用的快照后取走的 raw K/V(`last_kv_frame` / `append_kv_frame`,arope_dit),不是在全 GT 历史上重新 commit(那样 latent 同、第 1 层起的 K/V 不同,教师轨迹从 cell 3 起与官方分道) | — |
|
| 49 |
+
| RNG | 不需要(DiT 推理无 dropout;噪声显式共享) | — |
|
| 50 |
+
| 条件 dtype | offset_win / mask 按 latent dtype 量化一次后给两个模型(FSDP 根前向 cast 变空操作) | — |
|
backup_2026-09-14/code/distill/PORT_SPEC.md
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
backup_2026-09-14/code/distill/RUNBOOK.md
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ActionRoPE 三阶段蒸馏 —— 串联手册
|
| 2 |
+
|
| 3 |
+
因果强制(causal forcing)三阶段:**stage1 teacher forcing → stage2 CD 初始化 → stage3 DMD**。
|
| 4 |
+
移植自 `teawhite/worldmind` 的 `WorldMind/distill/code/`(正典),`stream_grad_replay` 与
|
| 5 |
+
`preflight_sliding_kv` 反向移植自 `GraphGWM/train/code_distill/`。
|
| 6 |
+
|
| 7 |
+
设计与逐条对抗评审结论在 `PORT_SPEC.md`。本文件只讲**怎么跑**。
|
| 8 |
+
|
| 9 |
+
## 铁律
|
| 10 |
+
|
| 11 |
+
* **只用 GPU 0-3。** 4-7 是别人的卡。`common.sh` 会在启动前检查 0-3 是否空闲,
|
| 12 |
+
查不到占用(nvidia-smi 出错、返回行数不对)就拒绝启动 —— fail-closed,不是 fail-open。
|
| 13 |
+
阈值只能调严不能调松(`GPU_BUSY_MIB=999999` 不再是偷卡后门)。
|
| 14 |
+
* **一次只跑一个阶段。** 三个阶段都要满 4 张卡。
|
| 15 |
+
* 每个阶段的配方常量在**三处**重复:`run.sh` 的默认值 / `<stage>.py` 的 argparse 默认值 /
|
| 16 |
+
`config.json`。上游 WorldMind 这三处有六项不一致,每一项都对应一次已知崩溃。
|
| 17 |
+
**改配方 = 同时改这三处。**
|
| 18 |
+
|
| 19 |
+
## 用户已拍板
|
| 20 |
+
|
| 21 |
+
| 项 | 值 | 理由 |
|
| 22 |
+
|---|---|---|
|
| 23 |
+
| teacher | `outputs/arope_10k_ga2/step-10000.safetensors` | 不再续 SFT,直接开蒸 |
|
| 24 |
+
| stage1 等效 batch | 8(4 卡 × GA2),lr 1e-5 | 同 lr 下微调阶段更多 optimizer step 划算 |
|
| 25 |
+
| 部署采样表 | 4 步 `1000,750,500,250` | stage2 的 CD 轴与 stage3 的 rollout 都按这张表训;**训练与推理必须逐点相同** |
|
| 26 |
+
|
| 27 |
+
## 跑之前
|
| 28 |
+
|
| 29 |
+
```bash
|
| 30 |
+
cd /opt/dlami/nvme/zhiyangdeng/ActionRoPE # 本机;旧机是 /data/zhiyangdeng/ActionRoPE
|
| 31 |
+
export PYTHONPATH=$PWD DIFFSYNTH_SKIP_DOWNLOAD=True
|
| 32 |
+
|
| 33 |
+
# 全量测试(CPU 即可,不抢训练卡)—— 310 项通过 + 2 项 skip(两项 skip 是下面那两道真权重闸)
|
| 34 |
+
# 计数会随 flow map 的文件继续落地而变;红了才是问题,数字对不上先看是不是刚加了测试。
|
| 35 |
+
AROPE_REQUIRE_SIDECAR=1 CUDA_VISIBLE_DEVICES="" .venv/bin/python -m pytest distill/tests/ -q # 有 AROPE_REQUIRE_SIDECAR 时语料包络门不许 skip
|
| 36 |
+
|
| 37 |
+
# ★ 最吃重的两项默认都是 skip 的,都要**真实 5B 权重**,每次动过 arope_dit.py 都要跑。
|
| 38 |
+
# 它们一旦失败,后面所有阶段都在量一个错的模型。
|
| 39 |
+
|
| 40 |
+
# (1) AropeDiT 与 arope_forward 逐位一致(回归锚点本体)
|
| 41 |
+
AROPE_BITPARITY_REAL=1 \
|
| 42 |
+
AROPE_BITPARITY_CKPT=$PWD/outputs/arope_10k_ga2/step-10000.safetensors \
|
| 43 |
+
CUDA_VISIBLE_DEVICES=0 .venv/bin/python -m pytest distill/tests/test_bitparity.py -q -k real
|
| 44 |
+
|
| 45 |
+
# (2) flow map 装上去的那一刻,stage1b 教师逐位没被动过(对任意 r,不只是 r == t)
|
| 46 |
+
AROPE_FLOWMAP_REAL=1 \
|
| 47 |
+
CUDA_VISIBLE_DEVICES=0 .venv/bin/python -m pytest distill/tests/test_flowmap_model.py -q -s -k real_weights
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
## flow map(stage2-flowmap 的模型侧)
|
| 51 |
+
|
| 52 |
+
`AropeDiT.install_flowmap(form="residual", gate=1.0)` 给模型加第二个时间步 `r`
|
| 53 |
+
(`forward(..., r_timestep=)` / `forward_frame(..., r_k=)`),学均速度 `u(x_t, t, r)`。
|
| 54 |
+
|
| 55 |
+
* **默认 `residual` 形式对任意 (t, r) 与装之前逐位相同**(真权重实测 max|Δ| = 0.000e+00):
|
| 56 |
+
`delta_embedding = deepcopy(time_embedding)` 但**第二个 Linear 清零**。上游 AnyFlow 的
|
| 57 |
+
`convex` 形式只在 `r == t` 的对角线上恒等,off-diagonal 差 1.2e-01 —— 我们 50 % 的样本按构造
|
| 58 |
+
就在 off-diagonal 上,所以它只留作消融(`--flowmap_form convex`)。
|
| 59 |
+
* **初始化顺序**(写反了不报错,只是隐表示变垃圾,数值闸门看不见):
|
| 60 |
+
新 run `AropeDiT() → load_dit_ckpt(stage1b)【strict 827】→ install_flowmap()`;
|
| 61 |
+
续训 `AropeDiT() → install_flowmap() → load_dit_ckpt(dit_stepN)【strict 832】`。
|
| 62 |
+
非要反着来就得显式 `load_dit_ckpt(..., allow_missing=runlib.FLOWMAP_KEYS)`,别设成默认。
|
| 63 |
+
* **checkpoint**:多 5 个平铺键 `delta_embedding.{0,2}.{weight,bias}` + `delta_gate`,
|
| 64 |
+
827 → **832**(`runlib.AROPE_DIT_KEYS_FLOWMAP`,+20.5 MB),格式其余部分一字未变。
|
| 65 |
+
存的时候 `save_dit(..., expect_keys=runlib.AROPE_DIT_KEYS_FLOWMAP)` 并写 metadata
|
| 66 |
+
`flowmap=1 / flowmap_form / deltatime_type=r / delta_gate`;读的时候用
|
| 67 |
+
`runlib.is_flowmap_ckpt(path)` 选采样器。**没有精确 827 键的导出**:
|
| 68 |
+
`u(x,t,t) = head(E(t) + g·D(P(t)))` 是两条 Sequential 的和,折不回一条 —— 于是
|
| 69 |
+
`actionrope/infer.py`、`tests/check_ckpt.py`、`eval_bg50.py` 会拒收 flow-map 权重,这是对的。
|
| 70 |
+
* **没装的时候 `r_timestep` 必须是 None 或与 `timestep` 完全相同**,否则直接断言失败。
|
| 71 |
+
上游那个 stock embedder 收下 r 然后忽略它,接错线会退化成普通扩散模型且不报错。
|
| 72 |
+
|
| 73 |
+
## stage1 —— teacher forcing 因果化
|
| 74 |
+
|
| 75 |
+
把双向 SFT 检查点在因果掩码下继续训,得到因果 AR 扩散模型。
|
| 76 |
+
|
| 77 |
+
```bash
|
| 78 |
+
bash distill/stage1_ar/run.sh
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
4 卡 DeepSpeed ZeRO-2,12000 步,实测 **7.0 s/step ⇒ 约 23 h**,每卡 37 GB。
|
| 82 |
+
产物 `outputs/distill/stage1_ar_tf/dits/dit_step12000.safetensors`(827 张量 / 10.00 GB)。
|
| 83 |
+
|
| 84 |
+
`grad_clip` 默认 **10.0**,不是 SFT 名义上的 1.0:SFT 实测 5000 步 `grad_norm`
|
| 85 |
+
p50=0.072 / max=0.481,阈值 1.0 一次都没触发;stage1 的 gnorm 约 2.5,1.0 会**步步都裁**,
|
| 86 |
+
等于把优化器换成归一化 SGD、等效 lr 掉到约 1/3。10.0 同时对齐 WorldMind stage1 与 SFT 的实际行为。
|
| 87 |
+
|
| 88 |
+
健康信号:`loss_ema` 与 `--val_every 500` 的曲线。**不要**把单次 `grad_norm` 尖峰当发散 ——
|
| 89 |
+
它逐 run 不可复现、同样输入能摆动约 10×。
|
| 90 |
+
|
| 91 |
+
20 步实机冒烟(`OUT` 必须写绝对路径,`$ROOT` 在调用方 shell 里是空的):
|
| 92 |
+
|
| 93 |
+
```bash
|
| 94 |
+
MAX_STEPS=20 SAVE_EVERY=10 LOG_EVERY=1 VAL_EVERY=10 VAL_N=16 PORT=29541 \
|
| 95 |
+
OUT=/data/zhiyangdeng/ActionRoPE/outputs/distill/_smoke_stage1 \
|
| 96 |
+
bash distill/stage1_ar/run.sh
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
## stage2 —— 因果一致性蒸馏(CD)
|
| 100 |
+
|
| 101 |
+
在**部署轴**上做一致性蒸馏;产物是 **EMA**(不是裸 dit),它才是 stage3 的初始化。
|
| 102 |
+
|
| 103 |
+
```bash
|
| 104 |
+
bash distill/stage2_cd/run.sh # 默认吃 stage1 的 dit_step12000(4 卡 GA2)
|
| 105 |
+
DISTILL_NGPU=8 AR=/opt/dlami/nvme/zhiyangdeng/ActionRoPE/outputs/distill/stage1_long_K3/dits/dit_step1000.safetensors \
|
| 106 |
+
bash distill/stage2_cd/run.sh # 8 卡形式:GA 自动选 1,run.sh 断言等效 batch == 8;换了 AR= 就换 OUT 或 RESUME=none
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
4 卡 ZeRO-2(或 8 卡 GA1,等效 batch 都是 8),8000 步,lr 2e-6,betas `0.0,0.999`(CF 约定:目标随 EMA 漂移,动量会追旧目标)。本机 8 卡实测 5.06 s/步 ⇒ 约 11.3 h。
|
| 110 |
+
`KEEP_EMAS=25` —— stage3 的 init 要从这些里挑,别按 `dits/` 的口径只留 2 个。
|
| 111 |
+
产物 `outputs/distill/stage2_cd/ema/ema_step8000.safetensors`。
|
| 112 |
+
|
| 113 |
+
CD 目标:教师走**一个** Euler 步,学生与 EMA 各自在**自己的** σ 上取 x0 估计后求 MSE。
|
| 114 |
+
(`cm_tn` 必须用 `σ_tn` 而不是 `σ_t` —— 这里曾经有过一个字符的 bug,现在与上游逐位一致。)
|
| 115 |
+
|
| 116 |
+
**哪个节点没被约束(CD-1)。** 默认 4 节点部署轴 [1, 0.75, 0.5006, 0.2516] 上,部署终读点
|
| 117 |
+
σ=0.2516 只当过一致性对的目标、从不是 source,没有任何一项 loss 约束它:学生在那里等于 stage1
|
| 118 |
+
教师的一步估计**只在 step 0 成立**(student == ema == teacher),此后它是固定点族的自由分量,
|
| 119 |
+
共享参数下 pair 0-2 的梯度会顺带移动它而无恢复项。上游 N=48 轴的 σ=0.096 节点结构相同,
|
| 120 |
+
只是 c_out=−σ 的杠杆小 2.6×。训练中盯 `train_log.csv` 里 **idx==2 行的 `tgt_err`**
|
| 121 |
+
(= ‖f_EMA(·, 0.2516) − x0‖²)相对 step≤200 的基线;`cd` 降而 `tgt_err` 升才是问题。
|
| 122 |
+
`CD_TAIL_T=100` 会在轴末再挂一个 σ=0.1011 节点(4 对,部署节点不变)—— 轴是 EMA 目标的定义,
|
| 123 |
+
**只能用于新 OUT 的新 run**,续训不能改轴;默认 0 是用户拍板的轴,不改。
|
| 124 |
+
|
| 125 |
+
## stage3 —— self-forcing DMD
|
| 126 |
+
|
| 127 |
+
```bash
|
| 128 |
+
bash distill/stage3_dmd/run.sh # 默认吃 stage2 的 ema_step8000
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
4 卡 **FSDP1**(不是 ZeRO-2),500 步,`SAVE_EVERY=50` —— 部署权重要逐个里程碑人眼挑。
|
| 132 |
+
四个模型:因果学生 / 双向 critic(fake-score)/ 冻结的双向 real-score 教师 / EMA。
|
| 133 |
+
|
| 134 |
+
`SCORE_FRAMES=GRAD_HORIZON=20`:连续打分尾巴取 1+20 = 21 个 cell,正好等于教师与 critic
|
| 135 |
+
训练时的片长。**首次跑必须先量显存**:
|
| 136 |
+
|
| 137 |
+
```bash
|
| 138 |
+
MAX_STEPS=20 SAVE_EVERY=10 LOG_EVERY=1 PROFILE_MEM=1 PORT=29541 \
|
| 139 |
+
OUT=/data/zhiyangdeng/ActionRoPE/outputs/distill/_smoke_stage3 \
|
| 140 |
+
bash distill/stage3_dmd/run.sh
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
预期每卡约 66 GiB(140 GiB 的卡)。`GRAD_PASS=streamed` 是默认且不要改成 `fused` ——
|
| 144 |
+
fused 在 horizon=20 时要 189 GiB/卡,必 OOM;streamed 的梯度与 fused 逐位等价。
|
| 145 |
+
(`_budget_gate` 会在读 40 GB 基座权重**之前**就拦住必炸的组合。)
|
| 146 |
+
|
| 147 |
+
`dmd` loss 应进入 +0.15…+0.68 区间而 `critic` 下降。**这是 min-max,单调收缩的 `dmd` 不是目标。**
|
| 148 |
+
|
| 149 |
+
## 评测
|
| 150 |
+
|
| 151 |
+
```bash
|
| 152 |
+
# 少步学生是否还听动作 —— 背景位移 vs 指令位移
|
| 153 |
+
CUDA_VISIBLE_DEVICES=0 .venv/bin/python -m distill.eval.rollout_eval \
|
| 154 |
+
--ckpt outputs/distill/stage3_dmd/ema/ema_step400.safetensors \
|
| 155 |
+
--denoise_list 1000,750,500,250 --actions right:1.0:21
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
`rollout_eval.py` 是 `distill/` 里唯一不走 `common.sh` 的入口,它自带显存守卫;
|
| 159 |
+
训练在跑时它会拒绝上卡(`--allow_busy_gpu` 会把自己硬顶在空闲量减 8 GB,撑死只炸自己)。
|
| 160 |
+
纯 CPU 自检:`--self_test --device cpu`。
|
| 161 |
+
|
| 162 |
+
### 崩了要先分清是路径还是曝光偏差
|
| 163 |
+
|
| 164 |
+
```bash
|
| 165 |
+
CUDA_VISIBLE_DEVICES=0 .venv/bin/python distill/eval/tf_rollout_probe.py \
|
| 166 |
+
--ckpt <任意阶段的 ckpt> --clip clip_Eybx_100000092_000000
|
| 167 |
+
```
|
| 168 |
+
|
| 169 |
+
同一条 KV-cache 路径跑两遍,唯一差别是提交进缓存的是**真值 latent** 还是**模型自己的 x0**:
|
| 170 |
+
|
| 171 |
+
* **gt 平稳、self 衰减** ⇒ 部署路径没问题,崩的是误差累积(曝光偏差)。这正是 stage3
|
| 172 |
+
self-forcing 要修的东西,不是 bug。
|
| 173 |
+
* **gt 也衰减** ⇒ 部署路径与训练不匹配(t=0 提交 / 窗口锚定的 RoPE 或 mask),是移植 bug,
|
| 174 |
+
别往下走。
|
| 175 |
+
|
| 176 |
+
**stage1@9000 的实测**(2026-09-06,供后续对比):gt 全程 22.7–31.8 dB 平稳;
|
| 177 |
+
self 从 cell 1 的 24.39 单调掉到 cell 17 的 16.20,同时 `|x0|` 从 1.111 涨到 1.404
|
| 178 |
+
(真值一直在 1.2 附近)——能量正反馈,像素上表现为亮区从边缘往里收缩、cell 20 全黑。
|
| 179 |
+
路径已排除,符合预期。
|
| 180 |
+
|
| 181 |
+
### sink / kv_window 的对比在 stage1 上问不出来
|
| 182 |
+
|
| 183 |
+
`sink=3 + window=17` 与 `sink=0 + window=20` 在 **21 个 cell 之内保留集完全相同**
|
| 184 |
+
(`[0,3) ∪ [3,20) == [0,20)`,没有驱逐),所以两者数学上等价、PSNR 逐位一样。
|
| 185 |
+
它们只在 cell 21 开始驱逐后才分叉。要比这两个配置,得等模型能活过 cell 21 —— 即 stage3 之后。
|
| 186 |
+
|
| 187 |
+
## 出问题时
|
| 188 |
+
|
| 189 |
+
* **拒绝启动,说 GPU 有占用** —— 先 `nvidia-smi` 看是不是自己上一个 job 没退干净。
|
| 190 |
+
别去动 4-7。确认上一次真的死了再用 `FORCE_RUN_DIR=1`。
|
| 191 |
+
* **续训** —— 三个阶段都默认 `RESUME=auto`(有 `progress.json` 就续)。
|
| 192 |
+
stage1 有完整的 DeepSpeed state 双槽轮转;stage2/3 只热启动权重,优化器不落盘。
|
| 193 |
+
* **loss 恒 0 且不报错** —— 检查 `grad_horizon ≤ dmd_score_frames`(argparse 会拦),
|
| 194 |
+
以及 `rope_cap == sink + kv_window == F−1 == 20` 这条几何铁律。
|
| 195 |
+
|
| 196 |
+
## 还没做的两件事
|
| 197 |
+
|
| 198 |
+
1. **S8:2 卡 FSDP 分片一致性。** stage3 的 `arope_forward` 会直接伸手取 `dit.blocks[i].norm1`
|
| 199 |
+
这类子模块;FSDP 的 `TRANSFORMER_BASED_WRAP` 只在被包模块的 `forward()` 里 all-gather,
|
| 200 |
+
伸手取到的是分片、**不报错**。`AropeDiT` 的 monkeypatch 就是为了让每个 block 都从
|
| 201 |
+
`block(...)` 进入,但"FSDP 真的 all-gather 对了"这件事只能在 2 卡上验。
|
| 202 |
+
卡一空出来就跑,在 stage3 首launch之前。
|
| 203 |
+
2. **滑动 KV 的吞吐实测。** 每层都在 float64 重算整段历史的 RoPE。
|
| 204 |
+
`PROFILE_MEM=1` 的冒烟同时会给出 s/step,量到了再信任何 wall-clock 估计。
|
| 205 |
+
|
| 206 |
+
## stage4 —— OPSD(On-Policy Self-Distillation,长视野后训练)
|
| 207 |
+
|
| 208 |
+
MeiGen-AI OPSD-V 经 GraphGWM `stage4_opsd/opsd_gm.py` 移植(`distill/stage4_opsd/opsd_arope.py`),并对照
|
| 209 |
+
官方实现 `vendor/OPSD-V/model/opsd_streaming_training.py` 逐行核对(差异表 `distill/OPSD_OFFICIAL_NOTES.md`)。
|
| 210 |
+
后训练 stage3 的 `ema/ema_step300`,**不换采样器、不改推理**。学生用部署的 4 步 renoise 表 on-policy
|
| 211 |
+
rollout 61 个 cell(`data/latent_long/K3` 的长段)并提交自己的 x0;一份**冻结在初值**的教师在同一个
|
| 212 |
+
(noisy, t) 上被求值,但它的缓存里是真值 0..k−2 + 最近生成的 k−1(默认官方口径:教师自己在最后一步算出的 x0,
|
| 213 |
+
`TEACHER_LAST_CHUNK=student` 换成 GraphGWM 的学生 x0;`snapshot_kv_cache` 分叉滞后一格的真值缓存);
|
| 214 |
+
loss = 两者速度场的 MSE,整窗所有打分 (cell, step) 取平均(`LOSS_NORM=window`,官方;`cell` = GraphGWM),逐 cell backward。
|
| 215 |
+
几何 sink 3 / window 17 / cap 20 / absolute = stage3 训练与部署的几何。**训练面 = 官方的 LoRA rank 256 / lr 1e-5**
|
| 216 |
+
(`LORA_RANK`,distill/lora.py;教师 = 同一模型关补丁,存盘折回 827 键)。`LORA_RANK=0` 是全量微调,2026-09-10 实测 lr 1e-6 下
|
| 217 |
+
step50 起就糊、step100 更糊(`outputs/distill/eval/stage4_opsd/ema{50,100}_60s_indist/`),别用。与官方仍不同的:教师冻结
|
| 218 |
+
(官方 EMA adapter 0.9999 ≈ 冻结)、窗口起点固定在段首(VAE 首 latent 是锚点)、loss_start 12(官方 21,可 `LOSS_START=20`)。
|
| 219 |
+
|
| 220 |
+
```bash
|
| 221 |
+
# 三处配方(run.sh 默认 == argparse 默认 == config.json)由 tests/test_stage4_opsd.py 逐项比
|
| 222 |
+
CUDA_VISIBLE_DEVICES="" .venv/bin/python distill/stage4_opsd/opsd_arope.py --smoke # 六条 CPU 不变量,~10 s
|
| 223 |
+
CUDA_VISIBLE_DEVICES="" .venv/bin/python distill/stage4_opsd/opsd_arope.py --dry_run \
|
| 224 |
+
--output /opt/dlami/nvme/zhiyangdeng/ActionRoPE/outputs/distill/_dry_opsd --max_steps 3 # tiny 三模型端到端
|
| 225 |
+
CUDA_VISIBLE_DEVICES="" .venv/bin/python -m pytest -q -p no:cacheprovider \
|
| 226 |
+
distill/tests/test_stage4_opsd.py distill/tests/test_kv_equiv.py
|
| 227 |
+
|
| 228 |
+
# ★ 墙钟与显存都没量过(config.json 的 _budget 全是外推:60–160 s/步、峰值 ≈ 55–60 GiB/卡)。
|
| 229 |
+
# 先跑 20 步冒烟(OUT 必须写绝对路径),看 [mem step*] 的峰值与 step_time,再开 300 步:
|
| 230 |
+
DISTILL_NGPU=8 MAX_STEPS=20 SAVE_EVERY=10 LOG_EVERY=1 PROFILE_MEM=1 PORT=29547 \
|
| 231 |
+
OUT=/opt/dlami/nvme/zhiyangdeng/ActionRoPE/outputs/distill/_smoke_stage4 \
|
| 232 |
+
bash distill/stage4_opsd/run.sh
|
| 233 |
+
DISTILL_NGPU=8 bash distill/stage4_opsd/run.sh # 正式:300 步,每 50 步存 dits/ + ema/,全留
|
| 234 |
+
```
|
| 235 |
+
|
| 236 |
+
**开跑后要读的两个启动信号**(run.sh 与 `train.log` 里都有):
|
| 237 |
+
|
| 238 |
+
1. `[first-step] opsd … gnorm …` —— opsd 必须**小但非零**(bf16 5B 上量级 1e-3…1e-1):恰好 0 = ���师在读学生
|
| 239 |
+
自己的上下文、没有任何信号(上游第一次 launch 就这样静默跑了 6 小时);很大 = 两者不在同一个状态上。
|
| 240 |
+
gnorm 必须有限且 > 0。trainer 自己也拦:恰好 0.0 / NaN ⇒ SystemExit(ZERO-LOSS / gnorm 守卫,从第一步起就拦,
|
| 241 |
+
OPSD 没有 stage3 那种"第 0 步恰好 0"的配方性质)。
|
| 242 |
+
2. `[model] mask-channel ||W||` 学生与教师都非零(known 掩码通道 = 空间条件;0 = 那个模型对它盲)。
|
| 243 |
+
|
| 244 |
+
教师冻结 ⇒ loss 缓慢下降是正常的;跌到起点的 15% 以下是 COLLAPSE(学生学会了无视自己的缓存,目标的退化极小值),
|
| 245 |
+
trainer 会在日志里打 `[warn] COLLAPSE?`(不中止)。
|
| 246 |
+
**质量判据不是 loss**:`distill/eval/rollout_eval.py` 在 60+ cell 上把 `dits/dit_step{N}`(上游的部署产物,学生本体)
|
| 247 |
+
与 `ema/ema_step{N}`(我们额外的 fp32 EMA 影子)逐个里程碑与 pre-OPSD 的 `stage3_dmd/ema/ema_step300` 比远端漂移 ——
|
| 248 |
+
OPSD 的全部主张都在 rollout 的远端,那个对比才是交付物。`--teacher_context student` 只是自检(loss 恒 0),run.sh 拒绝用它训练。
|
backup_2026-09-14/code/distill/__init__.py
ADDED
|
File without changes
|
backup_2026-09-14/code/distill/arope_dit.py
ADDED
|
@@ -0,0 +1,1091 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""蒸馏用的 DiT 包装:`AropeDiT` = DiffSynth `WanModel` + ARoPE(世界坐标 RoPE / known-mask 通道 /
|
| 3 |
+
逐 cell 文本 cross-attn),三种前向模式共用一套权重。
|
| 4 |
+
|
| 5 |
+
为什么要包一层 nn.Module
|
| 6 |
+
-----------------------
|
| 7 |
+
`actionrope.arope.arope_forward` 是自由函数,直接伸手拿 `dit.patch_embedding` / `dit.time_embedding`
|
| 8 |
+
/ `dit.blocks[i].norm1` / `dit.head` 的参数。stage3 用 FSDP1 + TRANSFORMER_BASED_WRAP(DiTBlock):
|
| 9 |
+
block 的参数只在 **该 block 自己的 forward() 里** 才被 all-gather,在外面伸手拿到的是分片 ——
|
| 10 |
+
不报错,结果是垃圾(PORT_SPEC risk 2,全项目第一号静默腐化风险)。所以:
|
| 11 |
+
|
| 12 |
+
1. 整个前向搬进 `AropeDiT.forward`(根模块的 forward,根上的 patch/time/head 参数在这里被 gather);
|
| 13 |
+
2. block 一律用 `block(...)` 调用以触发 FSDP 的 pre-forward hook —— 为此在 import 时把
|
| 14 |
+
`diffsynth.models.wan_video_dit.DiTBlock.forward` 打成 `arope_block_forward`
|
| 15 |
+
(= `actionrope/arope.py:277-318` 的 `_block_forward` 逐行照抄,只把 `self_attn(input_x, freqs)`
|
| 16 |
+
换成带 attn_mask / KV 缓存的 `arope_self_attn`;`arm` 钩子去掉,ARoPE 没有臂)。
|
| 17 |
+
|
| 18 |
+
三种前向模式
|
| 19 |
+
-----------
|
| 20 |
+
1. **并行双向**(`causal=False, clean_x=None, kv_cache=None`)—— 与 `arope_forward` **逐位相同**,
|
| 21 |
+
这是整个移植的回归锚点(`distill/tests/test_bitparity.py`)。
|
| 22 |
+
2. **并行块因果**(`causal=True`):21 个 cell 之间因果、cell 内空间双向;`clean_x` 非 None 时再加
|
| 23 |
+
teacher-forcing 双流 `[2S,2S]` 掩码(clean 流 + noisy 流;noisy cell f 只看自己 + **严格之前** 的
|
| 24 |
+
clean cell,绝不看 clean cell f —— 那就是答案,泄漏 = loss 掉到 0 而 rollout 是坏的)。
|
| 25 |
+
3. **流式单 cell**(`kv_cache` 非 None):滑窗 KV 缓存。缓存 **RoPE 之前的 K**,读的时候按
|
| 26 |
+
**窗口锚定** 的坐标重新 RoPE(时间位 j-a、世界坐标 offset[j]-offset[a]),驱逐超出 kv_window 的最老 cell。
|
| 27 |
+
|
| 28 |
+
三条 BLOCKER(PORT_SPEC 对抗验证,违反任何一条都是"能跑、loss 会降、模型是错的")
|
| 29 |
+
-----------------------------------------------------------------------------
|
| 30 |
+
* **B1 first_frame_cond 绝不能进单 cell 路径。** `actionrope/arope.py:227` 的 `t_tok[:, 0] = 0` 抹的是
|
| 31 |
+
"这个张量的第 0 帧";单 cell 前向里 f==1,于是 rollout 的每一个 cell 都被告知 t=0。
|
| 32 |
+
`forward()` 在 kv_cache 非 None 时硬断言 `first_frame_cond is False`,`forward_frame` 无条件按 `t_k` 建时间步。
|
| 33 |
+
* **B2 build_world_freqs 需要 f_start。** 上游签名固定查 `f_table[:f]`,单 cell 永远拿到时间位 0。
|
| 34 |
+
这里给出 distill 本地的 `build_world_freqs(..., f_start=0)` 变体(**不改** `actionrope/arope.py`),
|
| 35 |
+
f_start=0 时与上游逐位相同。
|
| 36 |
+
* **B3 逐 cell 时间步。** 上游 `_per_token_timestep` 断言 `timestep.numel() == b`,stage1 要传 `[b, 21]`。
|
| 37 |
+
这里的 `_per_token_timestep_pf` 接受 标量 / `[b]` / `[b, F]`,前两种与上游逐位相同。
|
| 38 |
+
|
| 39 |
+
flow map(stage2-flowmap):第二个时间步 r
|
| 40 |
+
----------------------------------------
|
| 41 |
+
`install_flowmap()` 之后模型学的是 **均速度** `u(x_t, t, r)`,`forward(..., r_timestep=...)` /
|
| 42 |
+
`forward_frame(..., r_k=...)` 多收一个 r。默认(`residual` + 读出层零初始化)**对任意 (t, r)**
|
| 43 |
+
都与装之前逐位相同,所以它同时是 flow map 的初值和回归锚点。没装的时候 r 这条路径完全不存在,
|
| 44 |
+
`r_timestep` 只准是 None 或与 `timestep` 相同 —— 上游那个不带 delta 分支的 embedder 会 **收下 r
|
| 45 |
+
然后忽略它**(transformer_far_wan_model.py:241),于是接错线会退化成普通扩散模型且不报错。
|
| 46 |
+
新增的 5 个键(`delta_embedding.{0,2}.{weight,bias}` + `delta_gate`)挂在 **dit 上**,827 → 832。
|
| 47 |
+
|
| 48 |
+
另外两条硬规矩
|
| 49 |
+
-------------
|
| 50 |
+
* **绝不对 KV rollout 做梯度检查点**(risk 6):滑窗 commit 是破坏性的(cat + 驱逐),checkpoint 重算时
|
| 51 |
+
读到的是 **驱逐之后** 的缓存 —— 缓存没满时报 `CheckpointError`,满了以后形状对、内容错、**不报错**。
|
| 52 |
+
`forward()` 在 kv_cache 非 None 时断言 `use_gradient_checkpointing` 为假;`forward_frame` 里 block 直接调用。
|
| 53 |
+
* **注意力只走 SDPA**(risk 8):import 时断言 flash-attn / sageattention 都没装 —— 装了的话
|
| 54 |
+
`flash_attention()` 会改走别的 kernel,`arope_forward` 与本文件就悄悄分叉,回归锚点失效。
|
| 55 |
+
"""
|
| 56 |
+
from __future__ import annotations
|
| 57 |
+
|
| 58 |
+
import copy
|
| 59 |
+
import functools
|
| 60 |
+
import os
|
| 61 |
+
import sys
|
| 62 |
+
|
| 63 |
+
os.environ.setdefault("DIFFSYNTH_SKIP_DOWNLOAD", "True")
|
| 64 |
+
|
| 65 |
+
import torch
|
| 66 |
+
import torch.nn as nn
|
| 67 |
+
import torch.nn.functional as F
|
| 68 |
+
from einops import rearrange
|
| 69 |
+
|
| 70 |
+
from diffsynth.core.gradient import gradient_checkpoint_forward
|
| 71 |
+
from diffsynth.models import wan_video_dit as _wvd
|
| 72 |
+
from diffsynth.models.wan_video_dit import WanModel, modulate, rope_apply, sinusoidal_embedding_1d
|
| 73 |
+
|
| 74 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 75 |
+
if ROOT not in sys.path:
|
| 76 |
+
sys.path.insert(0, ROOT)
|
| 77 |
+
|
| 78 |
+
# _axis_freqs / _embed_context 直接复用上游实现:逐位等价是硬要求,不能各写一份
|
| 79 |
+
from actionrope.arope import ( # noqa: E402
|
| 80 |
+
MASK_EMBEDDING_NAME,
|
| 81 |
+
PX_PER_TOKEN,
|
| 82 |
+
_axis_freqs,
|
| 83 |
+
_embed_context,
|
| 84 |
+
install_arope,
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
MODEL_DIR = os.path.join(ROOT, "models/Wan2.2-TI2V-5B")
|
| 88 |
+
DIT_FILES = [os.path.join(MODEL_DIR, f"diffusion_pytorch_model-0000{i}-of-00003.safetensors") for i in (1, 2, 3)]
|
| 89 |
+
LAT_H, LAT_W = 30, 52
|
| 90 |
+
|
| 91 |
+
# ---- flow map(stage2-flowmap):第二个时间步 r 的分支 ----------------------------------
|
| 92 |
+
# 两个名字都挂在 **dit 上**(`dit.add_module` / `dit.register_buffer`),不是挂在 AropeDiT 包装上:
|
| 93 |
+
# runlib.strip_dit_prefix 只认 `dit.` / `arm.` 前缀,挂在包装上会被当成"包装层自己的 buffer"静默丢掉。
|
| 94 |
+
# 这是 actionrope/arope.py:196-206 给 arope_mask_embedding 用的同一条先例。
|
| 95 |
+
DELTA_EMBEDDING_NAME = "delta_embedding"
|
| 96 |
+
DELTA_GATE_NAME = "delta_gate"
|
| 97 |
+
FLOWMAP_FORMS = ("residual", "convex")
|
| 98 |
+
|
| 99 |
+
# risk 8:本文件的自注意力恒走 F.scaled_dot_product_attention;上游 flash_attention() 在这三个标志
|
| 100 |
+
# 全为 False 时也落到 SDPA,两条路径才逐位相同。哪天有人 pip install flash-attn,这个断言先炸,
|
| 101 |
+
# 而不是让 test_bitparity 之外的所有下游悄悄测在另一个模型上。
|
| 102 |
+
assert not (_wvd.FLASH_ATTN_3_AVAILABLE or _wvd.FLASH_ATTN_2_AVAILABLE or _wvd.SAGE_ATTN_AVAILABLE), (
|
| 103 |
+
"检测到 flash_attn / sageattention:DiffSynth 的 flash_attention() 会改走它们,"
|
| 104 |
+
"而 distill/arope_dit.py 恒走 SDPA,两条路径不再逐位相同。卸载它们,或先修 arope_self_attn。"
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# --------------------------------------------------------------------------
|
| 109 |
+
# 世界坐标 RoPE(distill 本地变体:多一个 f_start)
|
| 110 |
+
# --------------------------------------------------------------------------
|
| 111 |
+
|
| 112 |
+
def rot2d(vec: torch.Tensor, ang: torch.Tensor) -> torch.Tensor:
|
| 113 |
+
"""把 (dx, dy)(屏幕 u 向右、v 向下,单位任意)绕原点转 ang 弧度。vec [..., 2]、ang 可广播到 [...]。
|
| 114 |
+
|
| 115 |
+
u' = u·cos − v·sin,v' = u·sin + v·cos。v 轴朝下 ⇒ 正角在屏幕上是顺时针,纯约定。
|
| 116 |
+
"""
|
| 117 |
+
c, s_ = torch.cos(ang), torch.sin(ang)
|
| 118 |
+
u, v = vec[..., 0], vec[..., 1]
|
| 119 |
+
return torch.stack([u * c - v * s_, u * s_ + v * c], dim=-1)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def world_pos_maps(offset_tok: torch.Tensor, angle, h: int, w: int, scale=None):
|
| 123 |
+
"""逐 token 的世界坐标 (pos_h, pos_w),各 [b, f, h, w]。
|
| 124 |
+
|
| 125 |
+
cell j 的 token (i_h, j_w) 在**当前相机系**里的位置 = 绕画幅中心转 angle[b,j] 之后再加该 cell 的偏移:
|
| 126 |
+
(u, v) = R(angle) · (j_w − ctr_w, i_h − ctr_h) + (dx, dy) + (ctr_w, ctr_h)
|
| 127 |
+
angle 全 0 ⇒ 退化成可分离的 pos_h = i + dy / pos_w = j + dx(与旋转前的实现逐位相同)。
|
| 128 |
+
"""
|
| 129 |
+
b, f = offset_tok.shape[:2]
|
| 130 |
+
dev, dt = offset_tok.device, torch.float64
|
| 131 |
+
gi = torch.arange(h, device=dev, dtype=dt).view(1, 1, h, 1).expand(b, f, h, w)
|
| 132 |
+
gj = torch.arange(w, device=dev, dtype=dt).view(1, 1, 1, w).expand(b, f, h, w)
|
| 133 |
+
ctr_h, ctr_w = (h - 1) / 2.0, (w - 1) / 2.0
|
| 134 |
+
local = torch.stack([gj - ctr_w, gi - ctr_h], dim=-1) # [b, f, h, w, 2] = (u, v)
|
| 135 |
+
ang = angle.to(device=dev, dtype=dt).view(b, f, 1, 1)
|
| 136 |
+
rot = rot2d(local, ang)
|
| 137 |
+
if scale is not None:
|
| 138 |
+
# 位置整体等比缩放(判决实验):位置铺得越开 ⇒ 这一帧名义上覆盖的世界越大。
|
| 139 |
+
# 若模型把"位置场变大"读成"视野变大",画面内容就该按 1/scale 缩小。
|
| 140 |
+
rot = rot * torch.as_tensor(scale, device=dev, dtype=dt).view(b, f, 1, 1, 1)
|
| 141 |
+
off = offset_tok.to(dt).view(b, f, 1, 1, 2)
|
| 142 |
+
pos_w = rot[..., 0] + off[..., 0] + ctr_w
|
| 143 |
+
pos_h = rot[..., 1] + off[..., 1] + ctr_h
|
| 144 |
+
return pos_h, pos_w
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def build_world_freqs(dit: WanModel, f: int, h: int, w: int, offset_tok=None, device=None,
|
| 148 |
+
f_start: int = 0, angle=None, scale=None) -> torch.Tensor:
|
| 149 |
+
"""`actionrope.arope.build_world_freqs` + `f_start`(BLOCKER B2)。
|
| 150 |
+
|
| 151 |
+
f_start:这一段 token 的时间轴 **绝对起点**,查 `f_table[f_start:f_start+f]`。滑窗 KV 路径靠它把
|
| 152 |
+
单个 cell 放到时间位 tpos(否则 f==1 永远拿到位置 0,整条历史与当前帧的相对时间全塌成 0)。
|
| 153 |
+
f_start=0 时与上游函数逐位相同(`f_table[0:f]` 就是 `f_table[:f]`)。
|
| 154 |
+
|
| 155 |
+
offset_tok: [b, f, 2] = (dx_tok, dy_tok),token 单位;None ⇒ 普通查表 RoPE,返回 [f*h*w, 1, 64],
|
| 156 |
+
否则返回 [b, f*h*w, 1, 64]。dtype 跟随 dit.freqs(complex128)。
|
| 157 |
+
|
| 158 |
+
angle: [b, f] 弧度,每个 cell 的相机朝向**相对当前 cell**(当前 cell 恒为 0)。None / 全 0 ⇒ 走可分离
|
| 159 |
+
路径(逐位等于旋转支持加入之前的实现)。非零时改走逐 token 的位置图:h/w 两轴被耦合,位置不再可分离。
|
| 160 |
+
RoPE 本身对小数位置是解析的(`polar(1, pos·inv_freq)`),所以旋转出来的小数坐标天然合法;
|
| 161 |
+
模型没见过的是"位移随 token 位置变化"这种场,而不是某个没见过的数值。
|
| 162 |
+
"""
|
| 163 |
+
f_table, h_table, w_table = dit.freqs
|
| 164 |
+
if device is None:
|
| 165 |
+
device = offset_tok.device if offset_tok is not None else f_table.device
|
| 166 |
+
d_h, d_w = h_table.shape[1] * 2, w_table.shape[1] * 2 # 42, 42
|
| 167 |
+
assert f_start >= 0 and f_start + f <= f_table.shape[0], (
|
| 168 |
+
f"时间轴 RoPE 表只有 {f_table.shape[0]} 行,要不到 [{f_start}, {f_start + f})"
|
| 169 |
+
)
|
| 170 |
+
ft = f_table[f_start:f_start + f]
|
| 171 |
+
|
| 172 |
+
if offset_tok is None:
|
| 173 |
+
# 走与原函数一样的查表,保证等价性;不重新算 h/w 轴
|
| 174 |
+
return torch.cat([
|
| 175 |
+
ft.view(f, 1, 1, -1).expand(f, h, w, -1),
|
| 176 |
+
h_table[:h].view(1, h, 1, -1).expand(f, h, w, -1),
|
| 177 |
+
w_table[:w].view(1, 1, w, -1).expand(f, h, w, -1),
|
| 178 |
+
], dim=-1).reshape(f * h * w, 1, -1).to(device)
|
| 179 |
+
|
| 180 |
+
offset_tok = offset_tok.to(device=device, dtype=torch.float64)
|
| 181 |
+
b = offset_tok.shape[0]
|
| 182 |
+
assert offset_tok.shape == (b, f, 2), f"offset_tok 形状应为 [b, {f}, 2],得到 {tuple(offset_tok.shape)}"
|
| 183 |
+
dx = offset_tok[..., 0] # [b, f]
|
| 184 |
+
dy = offset_tok[..., 1]
|
| 185 |
+
has_rot = angle is not None and bool(torch.any(torch.as_tensor(angle) != 0))
|
| 186 |
+
has_scale = scale is not None and bool(torch.any(torch.as_tensor(scale) != 1))
|
| 187 |
+
if has_rot or has_scale:
|
| 188 |
+
ang = (torch.zeros(b, f, device=device, dtype=torch.float64) if angle is None
|
| 189 |
+
else torch.as_tensor(angle, device=device, dtype=torch.float64))
|
| 190 |
+
assert ang.shape == (b, f), f"angle 形状应为 [b={b}, f={f}],得到 {tuple(ang.shape)}"
|
| 191 |
+
pos_h, pos_w = world_pos_maps(offset_tok, ang, h, w, scale) # 各 [b, f, h, w]
|
| 192 |
+
fh = _axis_freqs(pos_h, d_h) # [b, f, h, w, 21]
|
| 193 |
+
fw = _axis_freqs(pos_w, d_w)
|
| 194 |
+
freqs = torch.cat([
|
| 195 |
+
ft.to(device).view(1, f, 1, 1, -1).expand(b, f, h, w, -1), fh, fw,
|
| 196 |
+
], dim=-1)
|
| 197 |
+
return freqs.reshape(b, f * h * w, 1, -1).to(f_table.dtype)
|
| 198 |
+
|
| 199 |
+
pos_h = torch.arange(h, device=device, dtype=torch.float64).view(1, 1, h) + dy.view(b, f, 1) # [b, f, h]
|
| 200 |
+
pos_w = torch.arange(w, device=device, dtype=torch.float64).view(1, 1, w) + dx.view(b, f, 1) # [b, f, w]
|
| 201 |
+
fh = _axis_freqs(pos_h, d_h) # [b, f, h, 21]
|
| 202 |
+
fw = _axis_freqs(pos_w, d_w) # [b, f, w, 21]
|
| 203 |
+
freqs = torch.cat([
|
| 204 |
+
ft.to(device).view(1, f, 1, 1, -1).expand(b, f, h, w, -1),
|
| 205 |
+
fh.view(b, f, h, 1, -1).expand(b, f, h, w, -1),
|
| 206 |
+
fw.view(b, f, 1, w, -1).expand(b, f, h, w, -1),
|
| 207 |
+
], dim=-1)
|
| 208 |
+
return freqs.reshape(b, f * h * w, 1, -1).to(f_table.dtype)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def _freqs_cat_dim(freqs: torch.Tensor) -> int:
|
| 212 |
+
"""freqs 的 token 轴:无 batch 的 [S,1,64] 是 0,逐样本的 [b,S,1,64] 是 1。"""
|
| 213 |
+
assert freqs.dim() in (3, 4), f"freqs 应为 [S,1,64] 或 [b,S,1,64],得到 {tuple(freqs.shape)}"
|
| 214 |
+
return 0 if freqs.dim() == 3 else 1
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# --------------------------------------------------------------------------
|
| 218 |
+
# 时间步:标量 / [b] / [b, F]
|
| 219 |
+
# --------------------------------------------------------------------------
|
| 220 |
+
|
| 221 |
+
def _per_token_timestep_pf(latents: torch.Tensor, timestep, n_tok_per_frame: int,
|
| 222 |
+
first_frame_cond: bool) -> torch.Tensor:
|
| 223 |
+
"""逐 token 时间步。标量 / [b] 与 `actionrope.arope._per_token_timestep` 逐位相同;
|
| 224 |
+
多出的 `[b, F]` 分支是 stage1 的逐 cell 加噪要的(BLOCKER B3)。
|
| 225 |
+
|
| 226 |
+
first_frame_cond 在展开之后才作用(把 cell 0 的 t 置 0),所以 `[b, F]` 传进来时
|
| 227 |
+
cell 0 那一列会被覆盖成 0 —— 这正是 ActionRoPE 的 TI2V 契约(cell 0 是干净锚点)。
|
| 228 |
+
返回 [b, f * n_tok_per_frame],dtype 跟 latents。
|
| 229 |
+
"""
|
| 230 |
+
b, f = latents.shape[0], latents.shape[2]
|
| 231 |
+
timestep = torch.as_tensor(timestep, device=latents.device).to(latents.dtype)
|
| 232 |
+
# 上游 _per_token_timestep 无条件先 reshape(-1),所以 [b,1] / [1,1] 在它那里是合法的"逐样本"写法。
|
| 233 |
+
# 只在形状 **不可能** 是逐 cell(不是 [b,f])且元素数为 1 或 b 时压平:接受的输入集合是上游的严格超集,
|
| 234 |
+
# 且 f==1 时两种解释本来就同解,不存在歧义。
|
| 235 |
+
if timestep.dim() > 1 and tuple(timestep.shape) != (b, f) and timestep.numel() in (1, b):
|
| 236 |
+
timestep = timestep.reshape(-1)
|
| 237 |
+
if timestep.dim() <= 1:
|
| 238 |
+
timestep = timestep.reshape(-1)
|
| 239 |
+
if timestep.numel() == 1:
|
| 240 |
+
timestep = timestep.expand(b)
|
| 241 |
+
assert timestep.numel() == b, f"timestep 应为标量 / [b={b}] / [b={b}, f={f}],得到 {timestep.numel()} 个"
|
| 242 |
+
t_tok = timestep.view(b, 1, 1).expand(b, f, n_tok_per_frame).clone()
|
| 243 |
+
else:
|
| 244 |
+
assert timestep.shape == (b, f), f"逐 cell timestep 形状应为 [b={b}, f={f}],得到 {tuple(timestep.shape)}"
|
| 245 |
+
t_tok = timestep.view(b, f, 1).expand(b, f, n_tok_per_frame).clone()
|
| 246 |
+
if first_frame_cond:
|
| 247 |
+
t_tok[:, 0] = 0
|
| 248 |
+
return t_tok.reshape(b, f * n_tok_per_frame)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def _ts_equal(a, b) -> bool:
|
| 252 |
+
"""两个时间步参数(标量 / [b] / [b,F] / python float)在数值上是否完全相同。
|
| 253 |
+
|
| 254 |
+
只在"模型没装 flow map 却收到了 r"这条断言路径上用(正常训练/推理都不走),所以允许它做
|
| 255 |
+
一次 device→CPU 的同步。同一个对象直接返回 True,不做任何拷贝。
|
| 256 |
+
"""
|
| 257 |
+
if a is b:
|
| 258 |
+
return True
|
| 259 |
+
ta = torch.as_tensor(a).detach().to(device="cpu", dtype=torch.float64).reshape(-1)
|
| 260 |
+
tb = torch.as_tensor(b).detach().to(device="cpu", dtype=torch.float64).reshape(-1)
|
| 261 |
+
if ta.numel() == 1 and tb.numel() > 1:
|
| 262 |
+
ta = ta.expand_as(tb)
|
| 263 |
+
elif tb.numel() == 1 and ta.numel() > 1:
|
| 264 |
+
tb = tb.expand_as(ta)
|
| 265 |
+
return ta.shape == tb.shape and bool(torch.equal(ta, tb))
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
# --------------------------------------------------------------------------
|
| 269 |
+
# 注意力掩码
|
| 270 |
+
# --------------------------------------------------------------------------
|
| 271 |
+
|
| 272 |
+
class _KVRef:
|
| 273 |
+
"""指回 KV cache 本体的自引用句柄 —— 用来绕开 FSDP 对 forward 输入容器的重建。
|
| 274 |
+
|
| 275 |
+
★ 这是一个必须记住的 FSDP 行为:混合精度下 FSDP 会 cast forward 的输入,
|
| 276 |
+
`_apply_to_tensors` 会**遍历并重建** dict / list / tuple,于是 `forward_frame` 写进去的
|
| 277 |
+
K/V 落在一份**拷贝**上,调用方的 cache 永远是空的 —— 而且不报错(2 卡实测:
|
| 278 |
+
`外层 dict is 内层 dict → False`,commit 之后外层 cell 数恒为 0)。
|
| 279 |
+
|
| 280 |
+
但 FSDP 只 cast 张量,**非张量值按引用原样保留**(同一实测:放进去的哨兵对象 `is` 相同)。
|
| 281 |
+
所以在 cache 里塞一个指回自己的普通对象,就能在 forward 里把原始 dict 找回来。
|
| 282 |
+
|
| 283 |
+
为什么不改成"把 cache 挂成模块属性":调用必须走包装模块的 forward(参数 all-gather 挂在
|
| 284 |
+
那里,直接调 forward_frame 拿到的是分片、同样不报错),而 34 处调用点与全部测试都按
|
| 285 |
+
`kv_cache=` 这个 kwarg 写的。用自引用句柄两边都不用动。
|
| 286 |
+
"""
|
| 287 |
+
|
| 288 |
+
__slots__ = ("cache",)
|
| 289 |
+
|
| 290 |
+
def __init__(self, cache: dict):
|
| 291 |
+
self.cache = cache
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def build_causal_mask(f: int, hw: int, device, local: int = 0, sink: int = 0,
|
| 295 |
+
drop_anchor: bool = False) -> torch.Tensor:
|
| 296 |
+
"""块因果 [S, S] bool(True=可见):cell 之间因果,cell 内空间双向。S = f*hw。
|
| 297 |
+
|
| 298 |
+
local>0 ⇒ 只看最近 local 个 cell(外加 sink 个恒可见的开头 cell),对角线恒保留。
|
| 299 |
+
stage1/2 都是 local=0 —— F=21 下"窗口 20 + sink 1"与全因果等价,而 sink=0 的 W=20 会把
|
| 300 |
+
cell 0(ActionRoPE 的干净锚点)从 cell 20 的视野里踢掉,是致命的。
|
| 301 |
+
"""
|
| 302 |
+
fidx = torch.arange(f * hw, device=device) // hw # [S] 每个 token 属于哪个 cell
|
| 303 |
+
causal = fidx.unsqueeze(1) >= fidx.unsqueeze(0)
|
| 304 |
+
if drop_anchor: # 见 build_tf_mask 的同名参数
|
| 305 |
+
causal = causal & ((fidx.unsqueeze(0) != 0) | (fidx.unsqueeze(1) == 0))
|
| 306 |
+
if local and local > 0:
|
| 307 |
+
in_win = fidx.unsqueeze(0) > (fidx.unsqueeze(1) - local)
|
| 308 |
+
if sink and sink > 0:
|
| 309 |
+
in_win = in_win | (fidx.unsqueeze(0) < sink)
|
| 310 |
+
return (causal & in_win) | (fidx.unsqueeze(1) == fidx.unsqueeze(0))
|
| 311 |
+
return causal
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def build_tf_mask(f: int, hw: int, device, local: int = 0, sink: int = 0,
|
| 315 |
+
drop_anchor: bool = False) -> torch.Tensor:
|
| 316 |
+
"""teacher-forcing 双流掩码 [2S, 2S] bool。token [0,S) = clean 流,[S,2S) = noisy 流。
|
| 317 |
+
|
| 318 |
+
* clean q:在 clean 半流内块因果(看 clean 0..f)。
|
| 319 |
+
* noisy q(cell f):看自己那一个 noisy cell + **严格之前** 的 clean cell(fk < fq)。
|
| 320 |
+
绝不看 clean cell f(那就是答案),也绝不看别的 noisy cell。
|
| 321 |
+
对角线(eye)恒保留。
|
| 322 |
+
|
| 323 |
+
local>0 时同时约束 **两个半流**——上游 `gamemaster_dit.py:546 vs :549-554` 只约束了 noisy_ctx,
|
| 324 |
+
clean 半流仍是全块因果,于是窗口化训练会从 clean 半流泄漏无界历史。这里顺手修掉(虽然我们不用窗口)。
|
| 325 |
+
"""
|
| 326 |
+
S = f * hw
|
| 327 |
+
tok = torch.arange(2 * S, device=device)
|
| 328 |
+
cln = tok < S
|
| 329 |
+
fr = torch.where(cln, tok // hw, (tok - S) // hw) # 两个半流各自的 cell 序号 0..f-1
|
| 330 |
+
cq, ck = cln[:, None], cln[None, :]
|
| 331 |
+
fq, fk = fr[:, None], fr[None, :]
|
| 332 |
+
clean_mask = cq & ck & (fk <= fq)
|
| 333 |
+
noise_self = (~cq) & (~ck) & (fq == fk)
|
| 334 |
+
noise_ctx = (~cq) & ck & (fk < fq)
|
| 335 |
+
# drop_anchor:把 clean cell 0(TI2V 锚点)从**每一个** noisy cell 的视野里摘掉,其余历史照旧。
|
| 336 |
+
# 这是「只 drop 场景参考、保留历史」那条 null —— 与 local/sink 拼不出来:local=F−1 sink=0 只能
|
| 337 |
+
# 把 cell 0 挤出**最后一格**的窗口,cell 19 的 20 格窗口里它还在。
|
| 338 |
+
# Δv_anchor = v(全) − v(drop_anchor) 才是"锚点相对于历史的话语权",正是外观漂移要压的那一项。
|
| 339 |
+
if drop_anchor:
|
| 340 |
+
noise_ctx = noise_ctx & (fk != 0)
|
| 341 |
+
if local and local > 0:
|
| 342 |
+
in_win = fk > (fq - local)
|
| 343 |
+
if sink and sink > 0:
|
| 344 |
+
in_win = in_win | (fk < sink)
|
| 345 |
+
noise_ctx = noise_ctx & in_win
|
| 346 |
+
clean_mask = clean_mask & in_win
|
| 347 |
+
eye = tok[:, None] == tok[None, :]
|
| 348 |
+
return eye | clean_mask | noise_self | noise_ctx
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
# --------------------------------------------------------------------------
|
| 352 |
+
# 自注意力:并行(可带 mask) / 流式(滑窗 KV 缓存)
|
| 353 |
+
# --------------------------------------------------------------------------
|
| 354 |
+
|
| 355 |
+
def _heads(x: torch.Tensor, n: int) -> torch.Tensor:
|
| 356 |
+
return rearrange(x, "b s (n d) -> b n s d", n=n)
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
def _unheads(x: torch.Tensor, n: int) -> torch.Tensor:
|
| 360 |
+
return rearrange(x, "b n s d -> b s (n d)", n=n)
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def _evict(t: torch.Tensor, kv: dict) -> torch.Tensor:
|
| 364 |
+
"""滑窗驱逐:缓存超过 sink + kv_window 个 cell 就丢掉最老的 **非 sink** cell。t: [b, n*hw, dim]。"""
|
| 365 |
+
kw = kv["kv_window"]
|
| 366 |
+
if kw is None:
|
| 367 |
+
return t
|
| 368 |
+
hw, sink = kv["hw"], kv["sink_size"]
|
| 369 |
+
while t.shape[1] // hw > sink + kw:
|
| 370 |
+
t = torch.cat([t[:, :sink * hw], t[:, (sink + 1) * hw:]], dim=1)
|
| 371 |
+
return t
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def arope_self_attn(sa, x: torch.Tensor, freqs: torch.Tensor, attn_mask=None, kv=None) -> torch.Tensor:
|
| 375 |
+
"""`SelfAttention.forward` 的替身。attn_mask=None 且 kv=None 时与上游 **逐位相同**
|
| 376 |
+
(上游 `flash_attention()` 在没装 flash/sage 时同样落到 SDPA,见文件头的 import 断言)。
|
| 377 |
+
|
| 378 |
+
kv 非 None ⇒ 流式:缓存 **RoPE 之前** 的 K,读的时候按窗口锚定坐标重算
|
| 379 |
+
(`kv["freqs_hist"]` 给历史、`kv["freqs_cur"]` 给当前 cell)。RoPE 是相对编码,所以重锚定对
|
| 380 |
+
注意力分数是**精确**的;但缓存的 K/V 是从带掩码通道的 x 算出来的,掩码按各 cell **提交时**的
|
| 381 |
+
锚点固化,k>20 之后的历史与一段锚在 k−20 的训练片段并不逐位相同(stage2→stage3 输入移位,
|
| 382 |
+
自强迫训练穿过,GEO-2)。
|
| 383 |
+
"""
|
| 384 |
+
n = sa.num_heads
|
| 385 |
+
if kv is None:
|
| 386 |
+
q = rope_apply(sa.norm_q(sa.q(x)), freqs, n)
|
| 387 |
+
k = rope_apply(sa.norm_k(sa.k(x)), freqs, n)
|
| 388 |
+
v = sa.v(x)
|
| 389 |
+
o = F.scaled_dot_product_attention(_heads(q, n), _heads(k, n), _heads(v, n), attn_mask=attn_mask)
|
| 390 |
+
return sa.o(_unheads(o, n))
|
| 391 |
+
|
| 392 |
+
# ---- 流式单 cell ----
|
| 393 |
+
li = kv["layer"]
|
| 394 |
+
k_raw = sa.norm_k(sa.k(x)) # 进缓存的是这个(RoPE 之前)
|
| 395 |
+
v = sa.v(x)
|
| 396 |
+
q = rope_apply(sa.norm_q(sa.q(x)), kv["freqs_cur"], n)
|
| 397 |
+
k_hist, v_hist = kv["k"][li], kv["v"][li]
|
| 398 |
+
if k_hist is None:
|
| 399 |
+
k_cat, v_cat = rope_apply(k_raw, kv["freqs_cur"], n), v
|
| 400 |
+
else:
|
| 401 |
+
fh = kv["freqs_hist"]
|
| 402 |
+
assert fh is not None and fh.shape[_freqs_cat_dim(fh)] == k_hist.shape[1], (
|
| 403 |
+
f"历史 freqs 与缓存长度不符:freqs {tuple(fh.shape)} vs k_hist {tuple(k_hist.shape)}"
|
| 404 |
+
)
|
| 405 |
+
k_cat = torch.cat([rope_apply(k_hist, fh, n), rope_apply(k_raw, kv["freqs_cur"], n)], dim=1)
|
| 406 |
+
v_cat = torch.cat([v_hist, v], dim=1)
|
| 407 |
+
o = F.scaled_dot_product_attention(_heads(q, n), _heads(k_cat, n), _heads(v_cat, n), attn_mask=None)
|
| 408 |
+
if kv["commit"]:
|
| 409 |
+
nk = k_raw if k_hist is None else torch.cat([k_hist, k_raw], dim=1)
|
| 410 |
+
nv = v if v_hist is None else torch.cat([v_hist, v], dim=1)
|
| 411 |
+
# detach:缓存是跨帧的常量,梯度绝不能穿过它(stage3 的 streamed replay 正是靠这一点才是精确的)
|
| 412 |
+
kv["k"][li] = _evict(nk, kv).detach()
|
| 413 |
+
kv["v"][li] = _evict(nv, kv).detach()
|
| 414 |
+
return sa.o(_unheads(o, n))
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
# --------------------------------------------------------------------------
|
| 418 |
+
# DiTBlock.forward 的猴补丁(FSDP all-gather 必须经过 block(...) 这个入口)
|
| 419 |
+
# --------------------------------------------------------------------------
|
| 420 |
+
|
| 421 |
+
def arope_block_forward(self, x, context, t_mod, freqs, *, f: int | None = None,
|
| 422 |
+
attn_mask=None, kv=None):
|
| 423 |
+
"""= `actionrope/arope.py:277-318` 的 `_block_forward`(arm 去掉),唯一改动:
|
| 424 |
+
`self.self_attn(input_x, freqs)` → `arope_self_attn(self.self_attn, input_x, freqs, attn_mask, kv)`。
|
| 425 |
+
|
| 426 |
+
f:逐 cell 文本 cross-attn 的 cell 数(TF 双流时是 2*Fl)。context 是整条 [b,L,dim] 时用不到,
|
| 427 |
+
默认 None —— 这样 DiffSynth 自己的 `model_fn_wan_video`(`block(x, context, t_mod, freqs)`,
|
| 428 |
+
四个位置参数、整条 context)在打完补丁之后照样能跑,且逐位不变。
|
| 429 |
+
"""
|
| 430 |
+
has_seq = len(t_mod.shape) == 4
|
| 431 |
+
chunk_dim = 2 if has_seq else 1
|
| 432 |
+
shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = (
|
| 433 |
+
self.modulation.to(dtype=t_mod.dtype, device=t_mod.device) + t_mod).chunk(6, dim=chunk_dim)
|
| 434 |
+
if has_seq:
|
| 435 |
+
shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = (
|
| 436 |
+
shift_msa.squeeze(2), scale_msa.squeeze(2), gate_msa.squeeze(2),
|
| 437 |
+
shift_mlp.squeeze(2), scale_mlp.squeeze(2), gate_mlp.squeeze(2),
|
| 438 |
+
)
|
| 439 |
+
input_x = modulate(self.norm1(x), shift_msa, scale_msa)
|
| 440 |
+
x = self.gate(x, gate_msa, arope_self_attn(self.self_attn, input_x, freqs, attn_mask, kv))
|
| 441 |
+
|
| 442 |
+
q_in = self.norm3(x)
|
| 443 |
+
if context.shape[0] == x.shape[0]:
|
| 444 |
+
x = x + self.cross_attn(q_in, context)
|
| 445 |
+
else:
|
| 446 |
+
b = x.shape[0]
|
| 447 |
+
assert f is not None, "逐 cell context 需要显式传 f(block 的 cell 数)"
|
| 448 |
+
assert context.shape[0] == b * f, f"逐 cell context 应有 b*f={b * f} 行,得到 {context.shape[0]}"
|
| 449 |
+
q_in = rearrange(q_in, "b (f n) c -> (b f) n c", f=f)
|
| 450 |
+
y = self.cross_attn(q_in, context)
|
| 451 |
+
x = x + rearrange(y, "(b f) n c -> b (f n) c", f=f)
|
| 452 |
+
|
| 453 |
+
input_x = modulate(self.norm2(x), shift_mlp, scale_mlp)
|
| 454 |
+
x = self.gate(x, gate_mlp, self.ffn(input_x))
|
| 455 |
+
return x
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
# 全局、一次性:所有 DiTBlock 实例(含 DiffSynth 自己 pipeline 里的)都换成上面这个 forward。
|
| 459 |
+
_wvd.DiTBlock.forward = arope_block_forward
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
# --------------------------------------------------------------------------
|
| 463 |
+
# 模型加载
|
| 464 |
+
# --------------------------------------------------------------------------
|
| 465 |
+
|
| 466 |
+
def load_dit(model_dir: str = MODEL_DIR, device: str = "cpu") -> WanModel:
|
| 467 |
+
"""只加载 DiT(不挂 T5 / VAE),bf16,先落 CPU —— 与 `actionrope/train.py:163-174` 一致。"""
|
| 468 |
+
from diffsynth.pipelines.wan_video import ModelConfig, WanVideoPipeline
|
| 469 |
+
|
| 470 |
+
files = [os.path.join(model_dir, f"diffusion_pytorch_model-0000{i}-of-00003.safetensors") for i in (1, 2, 3)]
|
| 471 |
+
pipe = WanVideoPipeline.from_pretrained(
|
| 472 |
+
torch_dtype=torch.bfloat16, device=device,
|
| 473 |
+
model_configs=[ModelConfig(path=files)],
|
| 474 |
+
tokenizer_config=None, redirect_common_files=False,
|
| 475 |
+
)
|
| 476 |
+
return pipe.dit
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
def unwrap(model: nn.Module) -> "AropeDiT":
|
| 480 |
+
"""拿到裸的 AropeDiT(DeepSpeed / FSDP / DDP 包装之后 `init_kv_cache`、`dit.freqs` 都要走裸模块)。"""
|
| 481 |
+
while hasattr(model, "module"):
|
| 482 |
+
model = model.module
|
| 483 |
+
return model
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
# --------------------------------------------------------------------------
|
| 487 |
+
# AropeDiT
|
| 488 |
+
# --------------------------------------------------------------------------
|
| 489 |
+
|
| 490 |
+
class AropeDiT(nn.Module):
|
| 491 |
+
"""WanModel + ARoPE 的包装。三种前向模式见文件头。
|
| 492 |
+
|
| 493 |
+
causal:False = 双向教师 / critic;True = 因果学生(块因果掩码 + 允许 TF 双流 + 允许流式 KV)。
|
| 494 |
+
train_local_attn_size / train_sink_size:窗口化训练(stage1/2 都是 0,即全因果)。
|
| 495 |
+
"""
|
| 496 |
+
|
| 497 |
+
def __init__(self, model_dir: str = MODEL_DIR, mask_channel: bool = True, causal: bool = False,
|
| 498 |
+
train_local_attn_size: int = 0, train_sink_size: int = 0, dit: WanModel | None = None,
|
| 499 |
+
flowmap_form: str | None = None):
|
| 500 |
+
super().__init__()
|
| 501 |
+
if dit is None:
|
| 502 |
+
dit = load_dit(model_dir, device="cpu")
|
| 503 |
+
# mask 通道零初始化:装上的瞬间输出与原模型完全相同
|
| 504 |
+
self.dit = install_arope(dit, mask_channel=mask_channel)
|
| 505 |
+
self.mask_channel = bool(mask_channel)
|
| 506 |
+
self.causal = bool(causal)
|
| 507 |
+
self.train_local_attn_size = int(train_local_attn_size)
|
| 508 |
+
self.train_sink_size = int(train_sink_size)
|
| 509 |
+
# 训练期把 cell 0(TI2V 锚点)从 noisy 的视野里摘掉,其余历史保留 —— Δv_anchor 的 null
|
| 510 |
+
self.train_drop_anchor = False
|
| 511 |
+
# flow map(stage2-flowmap)的第二时间步分支:None = 没装(默认,与装之前逐位相同)。
|
| 512 |
+
# 装了以后是 "residual" / "convex"。__init__ **绝不自己装**,只有 install_flowmap() 会。
|
| 513 |
+
# 传进来的 dit 已经带着 delta 分支(deepcopy / 再包一层 / 从 832 键装载过)时必须显式说明是
|
| 514 |
+
# 哪一种形式:两种形式的融合公式不同,猜错了就是"能跑、数值是另一个模型"。
|
| 515 |
+
assert flowmap_form is None or flowmap_form in FLOWMAP_FORMS, (
|
| 516 |
+
f"flowmap_form 只能是 {FLOWMAP_FORMS} 或 None,得到 {flowmap_form!r}")
|
| 517 |
+
if hasattr(self.dit, DELTA_EMBEDDING_NAME):
|
| 518 |
+
assert flowmap_form is not None, (
|
| 519 |
+
"传进来的 dit 上已经有 delta_embedding 了,请显式给 AropeDiT(..., flowmap_form=...):"
|
| 520 |
+
"residual 与 convex 的融合公式不同,猜错了不会报错,只会安静地跑成另一个模型。")
|
| 521 |
+
self.flowmap_form: str | None = flowmap_form
|
| 522 |
+
# 模块级默认;每次 forward 还可以单独传。KV rollout 上两者都必须是假(risk 6)。
|
| 523 |
+
self.use_gradient_checkpointing = False
|
| 524 |
+
self.use_gradient_checkpointing_offload = False
|
| 525 |
+
# [16380,16380] 的 bool 掩码 268 MB,按 (f, hw, tf, 窗口, device) 缓存,别每步重建
|
| 526 |
+
self._mask_cache: dict = {}
|
| 527 |
+
|
| 528 |
+
# ---- 小工具 ----
|
| 529 |
+
|
| 530 |
+
def move_rope_table(self, device) -> "AropeDiT":
|
| 531 |
+
"""`dit.freqs` 是普通 tuple 属性(不是 buffer),`.to(device)` / prepare 都搬不动它。
|
| 532 |
+
DeepSpeed / FSDP prepare 之���必须在 **裸模块** 上调一次。"""
|
| 533 |
+
self.dit.freqs = tuple(t.to(device) for t in self.dit.freqs)
|
| 534 |
+
return self
|
| 535 |
+
|
| 536 |
+
def _get_mask(self, f: int, hw: int, device, tf: bool):
|
| 537 |
+
if not self.causal:
|
| 538 |
+
return None
|
| 539 |
+
key = (f, hw, bool(tf), self.train_local_attn_size, self.train_sink_size,
|
| 540 |
+
bool(self.train_drop_anchor), str(device))
|
| 541 |
+
m = self._mask_cache.get(key)
|
| 542 |
+
if m is None:
|
| 543 |
+
build = build_tf_mask if tf else build_causal_mask
|
| 544 |
+
m = build(f, hw, device, self.train_local_attn_size, self.train_sink_size,
|
| 545 |
+
drop_anchor=bool(self.train_drop_anchor))
|
| 546 |
+
self._mask_cache[key] = m
|
| 547 |
+
return m
|
| 548 |
+
|
| 549 |
+
def _mask_embed(self, mask_input, ref: torch.Tensor):
|
| 550 |
+
"""mask 通道的 embedding。ref 是 patch_embedding 的 **输出**(不是权重)——
|
| 551 |
+
autocast 下输出是 bf16 而权重可能是 fp32,拿错参照物就与 arope.py:372 不再逐位相同。"""
|
| 552 |
+
if mask_input is None:
|
| 553 |
+
return None
|
| 554 |
+
if not hasattr(self.dit, MASK_EMBEDDING_NAME):
|
| 555 |
+
raise ValueError("给了 mask_input 但 dit 没有 arope_mask_embedding,请先 install_arope(dit, mask_channel=True)")
|
| 556 |
+
return getattr(self.dit, MASK_EMBEDDING_NAME)(mask_input.to(device=ref.device, dtype=ref.dtype))
|
| 557 |
+
|
| 558 |
+
# ---- flow map:第二个时间步 r ----
|
| 559 |
+
|
| 560 |
+
@property
|
| 561 |
+
def has_flowmap(self) -> bool:
|
| 562 |
+
"""装了 delta 分支没有。**以 dit 上的模块为准**,不看包装上的 flowmap_form ——
|
| 563 |
+
续训是"先 install 再 strict load",权重里有没有这五个键才是唯一事实。"""
|
| 564 |
+
return hasattr(self.dit, DELTA_EMBEDDING_NAME)
|
| 565 |
+
|
| 566 |
+
def install_flowmap(self, form: str = "residual", gate: float = 1.0) -> "AropeDiT":
|
| 567 |
+
"""装上第二时间步 r 的 delta 分支。**绝不从 __init__ 调**(见下面的初始化顺序)。
|
| 568 |
+
|
| 569 |
+
form="residual"(默认,本项目用的)
|
| 570 |
+
t = E(t) + g · D(r),`D = deepcopy(E)` 但把 **第二个 Linear** 的 weight/bias 清零。
|
| 571 |
+
于是 D(·) ≡ 0,对 **任意** (t, r) 都与装之前逐位相同 —— 这是硬要求:我们 50 % 的样本
|
| 572 |
+
按构造就在 r ≠ t 的off-diagonal 上,而初值是 24 GPU-hours 换来的 stage1b 教师。
|
| 573 |
+
只清 **读出层**、保留第一层学好的 256→3072:分支一上来就有 r 的有意义隐表示,
|
| 574 |
+
∂L/∂delta_embedding[2] 在第 0 步就非零,立刻开始学(arope_mask_embedding 零初始化 Conv3d 的同一套路)。
|
| 575 |
+
form="convex"(上游 AnyFlow,transformer_far_wan_model.py:330)
|
| 576 |
+
t = (1−g)·E(t) + g·D(r),D 是 E 的原样 deepcopy。**只在 r == t 那条对角线上**与教师相同;
|
| 577 |
+
off-diagonal 是两个不同时间嵌入的 0.75/0.25 混合(CPU 实测 max|Δ| ≈ 1.1e-01)。
|
| 578 |
+
只留作"上游保真度"消融,别指望它是恒等的。
|
| 579 |
+
⚠ 千万别在 convex 里"顺手零初始化":那给出 t = 0.75·E(t),把教师的时间嵌入缩水 25 %,
|
| 580 |
+
比两种做法都糟,却长得像一个安全的零初始化。
|
| 581 |
+
|
| 582 |
+
gate:`dit.delta_gate`,**persistent=True 的 buffer**,跟着权重走。上游是 persistent=False
|
| 583 |
+
(从 config 重建),于是训练与推理可以悄悄用不同的门而不报错。residual 形式下门乘的是一个
|
| 584 |
+
本来就是 0 的读出,第 0 步冗余,之后只等价于给 delta_embedding[2] 换一个有效 lr。
|
| 585 |
+
|
| 586 |
+
导出格式(runlib):两个名字都挂在 dit 上 ⇒ state_dict 里是 `dit.delta_embedding.{0,2}.{weight,bias}`
|
| 587 |
+
和 `dit.delta_gate`,strip_dit_prefix 剥掉 `dit.` 之后正好是 5 个新键,827 → 832
|
| 588 |
+
(runlib.AROPE_DIT_KEYS_FLOWMAP / runlib.FLOWMAP_KEYS)。挂在 AropeDiT 包装上会被静默丢掉。
|
| 589 |
+
|
| 590 |
+
初始化顺序(写错了不会报错,会静默丢掉教师):
|
| 591 |
+
新 run : AropeDiT(...) → load_dit_ckpt(m, stage1b)【strict 827】→ install_flowmap()
|
| 592 |
+
续训 : AropeDiT(...) → install_flowmap() → load_dit_ckpt(m, dit_stepN)【strict 832】
|
| 593 |
+
反过来(先 install 再从 827 的 ckpt 装)会 deepcopy 到 **随机初始化** 的 time_embedding,
|
| 594 |
+
之后 load 又把 time_embedding 覆盖成教师的 —— delta 分支第一层从此与 time_embedding 不一致。
|
| 595 |
+
residual 形式下输出仍然是恒等的(读出是 0),但分支的隐表示是垃圾。所以装完立刻自检
|
| 596 |
+
(见下面的两个 assert),训练脚本再另外跑一次真前向的 max|Δ| == 0.0 闸门。
|
| 597 |
+
"""
|
| 598 |
+
assert form in FLOWMAP_FORMS, f"flowmap_form 只能是 {FLOWMAP_FORMS},得到 {form!r}"
|
| 599 |
+
# ★ convex 形式下门必须落在开区间 (0, 1)。上游 gate_value = 0.25。
|
| 600 |
+
# g == 1.0(本方法的 residual 默认值)会让 t = 0·E(t) + 1·D(r) —— 时间嵌入被 r 嵌入**整个替换**,
|
| 601 |
+
# 模型对 t 再无任何��赖(实测 |u(t=900,r=100) − u(t=100,r=100)| = 0.000e+00)。
|
| 602 |
+
# 而 r == t 时 D(t) == E(t)(D 是 E 的原样 deepcopy),所以 trainer 那道
|
| 603 |
+
# _assert_teacher_identity(convex 只查对角线)会**给出绿灯**。静默、灾难性,
|
| 604 |
+
# 只要有人写了 `--flowmap_form convex` 而忘了跟上 `--delta_gate 0.25` 就会中招。
|
| 605 |
+
# g == 0.0 则是另一个极端:delta 分支恒被乘没,r 完全不起作用,退化成普通扩散模型。
|
| 606 |
+
if form == "convex":
|
| 607 |
+
assert 0.0 < float(gate) < 1.0, (
|
| 608 |
+
f"convex 形式的 delta_gate 必须落在开区间 (0, 1),得到 {gate}(上游 gate_value = 0.25)。\n"
|
| 609 |
+
f" t = (1−g)·E(t) + g·D(r):g=1 把时间嵌入整个换成 r 嵌入(模型对 t 零依赖),"
|
| 610 |
+
f"g=0 把 r 分支整个乘没(退化成普通扩散)。两种都能骗过只查 r==t 对角线的同一性闸。\n"
|
| 611 |
+
f" 想用上游保真度消融请写 --flowmap_form convex --delta_gate 0.25。"
|
| 612 |
+
)
|
| 613 |
+
assert not self.has_flowmap, (
|
| 614 |
+
"已经装过 flow map 了。重复 install 会用当前的 time_embedding 重新 deepcopy 一份,"
|
| 615 |
+
"把已经训练过的 delta 分支整个丢掉(residual 形式下还会把它清零)—— 静默、无报错。"
|
| 616 |
+
)
|
| 617 |
+
dit = self.dit
|
| 618 |
+
emb = dit.time_embedding
|
| 619 |
+
assert isinstance(emb, nn.Sequential) and len(emb) == 3, (
|
| 620 |
+
f"time_embedding 应是 Sequential(Linear, SiLU, Linear),得到 {emb}"
|
| 621 |
+
)
|
| 622 |
+
delta = copy.deepcopy(emb)
|
| 623 |
+
if form == "residual":
|
| 624 |
+
nn.init.zeros_(delta[2].weight)
|
| 625 |
+
nn.init.zeros_(delta[2].bias)
|
| 626 |
+
dit.add_module(DELTA_EMBEDDING_NAME, delta)
|
| 627 |
+
|
| 628 |
+
ref = emb[0].weight
|
| 629 |
+
g = torch.tensor([float(gate)], dtype=ref.dtype, device=ref.device)
|
| 630 |
+
assert float(g.item()) == float(gate), (
|
| 631 |
+
f"gate={gate} 在 {ref.dtype} 下不可精确表示(存回来是 {float(g.item())}):"
|
| 632 |
+
"门要跟着 bf16 权重一起存盘,不精确就意味着训练用的门和装载回来的门不是同一个数"
|
| 633 |
+
)
|
| 634 |
+
dit.register_buffer(DELTA_GATE_NAME, g, persistent=True)
|
| 635 |
+
self.flowmap_form = form
|
| 636 |
+
|
| 637 |
+
if form == "residual":
|
| 638 |
+
assert torch.equal(delta[0].weight, emb[0].weight) and torch.equal(delta[0].bias, emb[0].bias), (
|
| 639 |
+
"delta_embedding 第一层应当是 time_embedding 的逐位副本"
|
| 640 |
+
)
|
| 641 |
+
assert float(delta[2].weight.abs().max()) == 0.0 and float(delta[2].bias.abs().max()) == 0.0, (
|
| 642 |
+
"residual 形式的 delta_embedding 读出层必须是全 0"
|
| 643 |
+
)
|
| 644 |
+
return self
|
| 645 |
+
|
| 646 |
+
def _embed_time(self, t_tok: torch.Tensor, r_tok: torch.Tensor | None) -> torch.Tensor:
|
| 647 |
+
"""[b, S] 的逐 token 时间步 → [b, S, dim] 的时间嵌入。
|
| 648 |
+
|
| 649 |
+
没装 flow map ⇒ 与改动之前 **逐字相同** 的一行(回归锚点靠这个)。
|
| 650 |
+
装了 ⇒ 按 flowmap_form 融合。三个要点:
|
| 651 |
+
* 融合发生在 **time_projection 之前**,而 time_projection[0] 就是 SiLU ——
|
| 652 |
+
`dit.time_projection(blended)` 逐字等于上游的 `time_proj(act_fn(rt_emb))`。
|
| 653 |
+
* 两个分支共用 **同一个** 正弦编码和同一个 0..1000 量纲。r 用 [0,1] 的 σ 而 t 用 [0,1000]
|
| 654 |
+
的话,正弦编码会悄悄塌成近似常数。
|
| 655 |
+
* 门与 delta 分支的输出统一 cast 到 **主分支的 dtype**,绝不让 fp32 的门把 bf16 的和提升成
|
| 656 |
+
fp32(上游 :326 把门 cast 成第三种 dtype,混合精度提升进 time_proj,bf16 下 r==t 的恒等性
|
| 657 |
+
在 1e-3 量级就破了)。
|
| 658 |
+
"""
|
| 659 |
+
dit = self.dit
|
| 660 |
+
b, S = t_tok.shape
|
| 661 |
+
e_t = sinusoidal_embedding_1d(dit.freq_dim, t_tok.reshape(-1)).view(b, S, -1)
|
| 662 |
+
base = dit.time_embedding(e_t)
|
| 663 |
+
if r_tok is None:
|
| 664 |
+
return base
|
| 665 |
+
assert self.flowmap_form in FLOWMAP_FORMS, (
|
| 666 |
+
f"dit 上有 delta 分支但包装的 flowmap_form 是 {self.flowmap_form!r} —— "
|
| 667 |
+
"谁把两者拆开了(直接 add_module?绕过 install_flowmap?)")
|
| 668 |
+
e_r = sinusoidal_embedding_1d(dit.freq_dim, r_tok.reshape(-1)).view(b, S, -1)
|
| 669 |
+
delta = getattr(dit, DELTA_EMBEDDING_NAME)(e_r).to(base.dtype)
|
| 670 |
+
g = getattr(dit, DELTA_GATE_NAME).to(base.dtype)
|
| 671 |
+
if self.flowmap_form == "convex":
|
| 672 |
+
return (1.0 - g) * base + g * delta
|
| 673 |
+
return base + g * delta
|
| 674 |
+
|
| 675 |
+
def _resolve_r(self, timestep, r_timestep):
|
| 676 |
+
"""r 的准入闸门,返回真正要用的 r(没装 flow map 时恒为 None)。
|
| 677 |
+
|
| 678 |
+
上游那个不带 flow map 的 embedder **收下 r_timestep 然后忽略它**
|
| 679 |
+
(transformer_far_wan_model.py:241),于是"忘了接 delta 分支"会退化成一个普通扩散模型,
|
| 680 |
+
不报错、loss 照降。这里两边都硬断言:装了就必须给 r;没装就只准不给、或者给一个与 t 相��的 r。
|
| 681 |
+
"""
|
| 682 |
+
if self.has_flowmap:
|
| 683 |
+
assert r_timestep is not None, (
|
| 684 |
+
"模型装了 flow map(dit.delta_embedding 存在),r_timestep / r_k 是必填的。"
|
| 685 |
+
"扩散槽请显式传 r_timestep=timestep,别省略。"
|
| 686 |
+
)
|
| 687 |
+
return r_timestep
|
| 688 |
+
if r_timestep is None:
|
| 689 |
+
return None
|
| 690 |
+
assert _ts_equal(timestep, r_timestep), (
|
| 691 |
+
"模型没装 flow map(没有 dit.delta_embedding),r_timestep 只能是 None 或与 timestep 完全相同;"
|
| 692 |
+
"给了一个不同的 r 说明调用方以为自己在跑 flow map —— 那会静默地跑成普通扩散。"
|
| 693 |
+
"先 install_flowmap()。"
|
| 694 |
+
)
|
| 695 |
+
return None
|
| 696 |
+
|
| 697 |
+
# ---- 前向 ----
|
| 698 |
+
|
| 699 |
+
def forward(self, x: torch.Tensor, timestep, context: torch.Tensor, *,
|
| 700 |
+
context_ids=None, offset_px=None, mask_input=None, first_frame_cond: bool = True,
|
| 701 |
+
r_timestep=None, clean_x=None, aug_t=None,
|
| 702 |
+
frame_index=None, kv_cache=None, commit: bool = True,
|
| 703 |
+
offset_win=None, mask_in_k=None, angle_win=None, angle_px=None, scale_px=None,
|
| 704 |
+
use_gradient_checkpointing: bool = False,
|
| 705 |
+
use_gradient_checkpointing_offload: bool = False) -> torch.Tensor:
|
| 706 |
+
"""x [b, 48, f, 30, 52] → [b, 48, f, 30, 52](TF 模式只返回 noisy 半流,形状同样是 f 个 cell)。
|
| 707 |
+
|
| 708 |
+
并行路径
|
| 709 |
+
timestep 标量 / [b] / [b, f](逐 cell)
|
| 710 |
+
context [b, 512, 4096](整条)或 [b, f, 512, 4096](逐 cell)
|
| 711 |
+
context_ids [b, f] 整数,同 id ⇒ 同串,用于逐 cell 去重
|
| 712 |
+
offset_px [b, f, 2] px;None ⇒ 普通查表 RoPE
|
| 713 |
+
mask_input [b, 1, f, 30, 52] float,known=1 / new=0
|
| 714 |
+
first_frame_cond cell 0 是干净锚点,其 timestep 与 r 都置 0
|
| 715 |
+
r_timestep flow map 的第二个时间步(0..1000,与 timestep 同量纲、同形状规则)。
|
| 716 |
+
装了 flow map ⇒ **必填**(扩散槽显式传 r_timestep=timestep);
|
| 717 |
+
没装 ⇒ 只能是 None 或与 timestep 完全相同。first_frame_cond 对 r **同样**
|
| 718 |
+
生效 —— 否则 TI2V 锚点那一格会掉到对角线之外,而那正是最不能变的一格。
|
| 719 |
+
clean_x [b, 48, f, 30, 52] 干净历史流 ⇒ 打开 teacher forcing 双流(要求 causal=True)
|
| 720 |
+
aug_t clean 半流的时间步(None ⇒ 0,即精确干净)。clean 半流的 r **硬接** r := t
|
| 721 |
+
(= 上游把 clean_timestep 同时当两个参数传,trainer:359):干净流恒在对角线上。
|
| 722 |
+
|
| 723 |
+
流式路径(kv_cache 非 None):转发给 `forward_frame`。这个转发是故意的 ——
|
| 724 |
+
FSDP 的参数 all-gather 挂在 **包装模块的 forward** 上,**绝不要**在被包装的模型上直接调
|
| 725 |
+
`forward_frame`。反过来 `init_kv_cache` 必须在裸模块(`unwrap(m)`)上调。
|
| 726 |
+
"""
|
| 727 |
+
gc = bool(use_gradient_checkpointing or self.use_gradient_checkpointing)
|
| 728 |
+
gc_off = bool(use_gradient_checkpointing_offload or self.use_gradient_checkpointing_offload)
|
| 729 |
+
r_eff = self._resolve_r(timestep, r_timestep) # 没装 flow map ⇒ None,下面整条 r 路径不存在
|
| 730 |
+
|
| 731 |
+
if kv_cache is not None:
|
| 732 |
+
# ★ FSDP 会重建 forward 的输入容器(见 _KVRef 的 docstring):拿 ref 把**调用方的
|
| 733 |
+
# 那一份** dict 找回来,否则 commit 写进拷贝、缓存永远是空的且不报错。
|
| 734 |
+
ref = kv_cache.get("_ref")
|
| 735 |
+
if ref is not None and ref.cache is not kv_cache:
|
| 736 |
+
kv_cache = ref.cache
|
| 737 |
+
# BLOCKER B1:first_frame_cond 抹的是"这个张量的第 0 帧",单 cell 前向里就是全部
|
| 738 |
+
assert first_frame_cond is False, (
|
| 739 |
+
"流式路径必须 first_frame_cond=False:单 cell 前向 f==1,"
|
| 740 |
+
"arope.py:227 的 t_tok[:,0]=0 会把 rollout 里每一个 cell 的时间步都抹成 0"
|
| 741 |
+
)
|
| 742 |
+
assert clean_x is None, "teacher forcing 是训练路径,不能与 KV 缓存同用"
|
| 743 |
+
assert angle_px is None, "流式路径的相机朝向走 angle_win([b, n_cached+1],相对当前 cell)"
|
| 744 |
+
assert offset_px is None, "流式路径的世界坐标走 offset_win([b, n_cached+1, 2],窗口锚定)"
|
| 745 |
+
assert mask_input is None, "流式路径的 known 掩码走 mask_in_k([b, 1, 1, 30, 52])"
|
| 746 |
+
assert not gc, (
|
| 747 |
+
"绝不能对 KV rollout 做梯度检查点:滑窗 commit 是破坏性的,重算时读到的是驱逐后的缓存 —— "
|
| 748 |
+
"缓存满了以后形状对、内容错、不报错(PORT_SPEC risk 6)"
|
| 749 |
+
)
|
| 750 |
+
assert frame_index is not None, "流式路径必须给 frame_index(绝对 cell 序号)"
|
| 751 |
+
return self.forward_frame(x, timestep, context, frame_index, kv_cache, commit,
|
| 752 |
+
offset_win=offset_win, mask_in_k=mask_in_k, context_ids=context_ids,
|
| 753 |
+
r_k=r_eff, angle_win=angle_win)
|
| 754 |
+
|
| 755 |
+
dit = self.dit
|
| 756 |
+
b, _, f_in, lat_h, lat_w = x.shape
|
| 757 |
+
p_f, p_h, p_w = dit.patch_size
|
| 758 |
+
n_tok = (lat_h // p_h) * (lat_w // p_w)
|
| 759 |
+
tf = clean_x is not None
|
| 760 |
+
if tf:
|
| 761 |
+
assert self.causal, "teacher forcing 双流只对因果学生有意义(causal=True)"
|
| 762 |
+
assert clean_x.shape == x.shape, f"clean_x {tuple(clean_x.shape)} 应与 x {tuple(x.shape)} 同形"
|
| 763 |
+
|
| 764 |
+
# ---- 时间步:逐 token ----
|
| 765 |
+
ts_noisy_tok = _per_token_timestep_pf(x, timestep, n_tok, first_frame_cond) # [b, f*n_tok]
|
| 766 |
+
# r 与 t 走 **同一个** 展开函数、同一个 first_frame_cond:cell 0 的 t 和 r 一起被抹成 0
|
| 767 |
+
rs_noisy_tok = (None if r_eff is None
|
| 768 |
+
else _per_token_timestep_pf(x, r_eff, n_tok, first_frame_cond))
|
| 769 |
+
if tf:
|
| 770 |
+
# clean 半流:aug_t=None ⇒ 全 0(精确干净)。给了 aug_t 就整条按 aug_t,
|
| 771 |
+
# 不再套 first_frame_cond —— clean cell 0 本来就是锚点,与其它 clean cell 同一个噪声级别。
|
| 772 |
+
ts_clean_tok = (torch.zeros_like(ts_noisy_tok) if aug_t is None
|
| 773 |
+
else _per_token_timestep_pf(clean_x, aug_t, n_tok, False))
|
| 774 |
+
t_tok = torch.cat([ts_clean_tok, ts_noisy_tok], dim=1) # [b, 2*f*n_tok]
|
| 775 |
+
# clean 半流恒在 r == t 的对角线上(--tf_aug > 0 时就是 r_clean = aug_t,不是 0)
|
| 776 |
+
r_tok = None if rs_noisy_tok is None else torch.cat([ts_clean_tok, rs_noisy_tok], dim=1)
|
| 777 |
+
else:
|
| 778 |
+
t_tok = ts_noisy_tok
|
| 779 |
+
r_tok = rs_noisy_tok
|
| 780 |
+
t = self._embed_time(t_tok, r_tok)
|
| 781 |
+
t_mod = dit.time_projection(t).unflatten(2, (6, dit.dim))
|
| 782 |
+
|
| 783 |
+
# ---- 文本:TF 时在 4D 层面复制(clean / noisy 两半共用同一条 cell 文本)----
|
| 784 |
+
if tf and context.dim() == 4:
|
| 785 |
+
context = torch.cat([context, context], dim=1) # [b, 2f, L, 4096]
|
| 786 |
+
if context_ids is not None:
|
| 787 |
+
context_ids = torch.cat([context_ids, context_ids], dim=1) # [b, 2f]
|
| 788 |
+
ctx = _embed_context(dit, context, context_ids)
|
| 789 |
+
|
| 790 |
+
# ---- patch embedding(+ 零初始化的 mask 通道)----
|
| 791 |
+
xt = dit.patch_embedding(x)
|
| 792 |
+
mask_emb = self._mask_embed(mask_input, xt)
|
| 793 |
+
if mask_emb is not None:
|
| 794 |
+
xt = xt + mask_emb
|
| 795 |
+
f, h, w = xt.shape[2:]
|
| 796 |
+
assert f * h * w == ts_noisy_tok.shape[1], (
|
| 797 |
+
f"patch 之后的 token 数 {f * h * w} 与时间步 {ts_noisy_tok.shape[1]} 不符(temporal patch 必须是 1)"
|
| 798 |
+
)
|
| 799 |
+
tokens = rearrange(xt, "b c f h w -> b (f h w) c").contiguous()
|
| 800 |
+
s_half = f * h * w
|
| 801 |
+
if tf:
|
| 802 |
+
# clean 半流用同一份 mask embedding(数值上与重算一遍完全一样)
|
| 803 |
+
xc = dit.patch_embedding(clean_x)
|
| 804 |
+
if mask_emb is not None:
|
| 805 |
+
xc = xc + mask_emb
|
| 806 |
+
tokens = torch.cat([rearrange(xc, "b c f h w -> b (f h w) c").contiguous(), tokens], dim=1)
|
| 807 |
+
|
| 808 |
+
# ---- RoPE:TF 时两半各自从 f_start=0 起算(cat 两份 21 帧的表,而不是造一张 42 帧的表)----
|
| 809 |
+
if offset_px is None:
|
| 810 |
+
assert angle_px is None, "给了 angle_px 就必须给 offset_px(坐标要一起旋)"
|
| 811 |
+
freqs = build_world_freqs(dit, f, h, w, None, device=x.device)
|
| 812 |
+
else:
|
| 813 |
+
# angle_px [b, f] 弧度:**绝对**朝向,锚在 cell 0(并行路径里 cell 0 是干净的 TI2V 锚点,
|
| 814 |
+
# 它的格点必须保持轴对齐)。None ⇒ 逐位走旋转支持加入之前的老路。
|
| 815 |
+
freqs = build_world_freqs(dit, f, h, w, offset_px.to(torch.float64) / PX_PER_TOKEN,
|
| 816 |
+
device=x.device, angle=angle_px, scale=scale_px)
|
| 817 |
+
if tf:
|
| 818 |
+
freqs = torch.cat([freqs, freqs], dim=_freqs_cat_dim(freqs))
|
| 819 |
+
|
| 820 |
+
# ---- 掩码 + blocks ----
|
| 821 |
+
attn_mask = self._get_mask(f, h * w, tokens.device, tf)
|
| 822 |
+
f_cross = 2 * f if tf else f
|
| 823 |
+
for block in dit.blocks:
|
| 824 |
+
# 用 block(...) 而不是伸手拿 block 的子模块:FSDP 的 all-gather 挂在这个入口上
|
| 825 |
+
tokens = gradient_checkpoint_forward(
|
| 826 |
+
functools.partial(block, f=f_cross, attn_mask=attn_mask, kv=None),
|
| 827 |
+
gc, gc_off,
|
| 828 |
+
tokens, ctx, t_mod, freqs,
|
| 829 |
+
)
|
| 830 |
+
|
| 831 |
+
# ---- head:TF 时只取 noisy 半流(clean 前缀连同它的时间调制一起丢掉)----
|
| 832 |
+
if tf:
|
| 833 |
+
tokens = tokens[:, s_half:]
|
| 834 |
+
t = t[:, s_half:]
|
| 835 |
+
tokens = dit.head(tokens, t)
|
| 836 |
+
return dit.unpatchify(tokens, (f, h, w))
|
| 837 |
+
|
| 838 |
+
# ---- 滑窗 KV 缓存 ----
|
| 839 |
+
|
| 840 |
+
def init_kv_cache(self, kv_window: int | None = 20, sink_size: int = 0, rope_cap: int = 20,
|
| 841 |
+
num_layers: int | None = None) -> dict:
|
| 842 |
+
"""每条 rollout 一份新缓存(换 clip 必须重建)。**在裸模块上调**(`unwrap(m).init_kv_cache(...)`)。
|
| 843 |
+
|
| 844 |
+
kv_window=None ⇒ 绝对模式:缓存无限增长,时间位就是 frame_index(等价性测试 / 短 rollout 用)。
|
| 845 |
+
kv_window 给了 ⇒ 滑窗模式:缓存 raw K,读的时候按窗口锚定坐标重算 RoPE,
|
| 846 |
+
超出 sink + kv_window 就驱逐最老的非 sink cell。
|
| 847 |
+
|
| 848 |
+
ActionRoPE 的部署配置是 sink_size=0:WorldMind 的 sink 把开头几帧永久钉在 RoPE 位 0..sink-1,
|
| 849 |
+
但这里 h/w 的 RoPE 位是 **一直在漂的世界坐标**(实测 21 个 cell 内最大位移 373 px = 11.66 token),
|
| 850 |
+
钉住的 sink 帧在 rollout 几十个 cell 之后会落在世界坐标几十个 token 之外,模型没见过。
|
| 851 |
+
sink=0 + 窗口锚定 ⇒ 每个注意力窗口的 RoPE 坐标精确等于一段训练片段的坐标(只解决坐标 OOD;
|
| 852 |
+
缓存 cell 的掩码通道按提交时锚点固化,见 arope_self_attn;latent 值离流形见 PORT_SPEC R10)。
|
| 853 |
+
"""
|
| 854 |
+
n = num_layers if num_layers is not None else len(self.dit.blocks)
|
| 855 |
+
assert self.causal, "KV 缓存是因果学生的流式路径(causal=True)"
|
| 856 |
+
if kv_window is not None:
|
| 857 |
+
assert kv_window >= 1 and sink_size >= 0
|
| 858 |
+
assert rope_cap == sink_size + kv_window, (
|
| 859 |
+
f"rope_cap 必须 == sink_size + kv_window({rope_cap} != {sink_size} + {kv_window}):"
|
| 860 |
+
"时间位 tpos 恒等于窗口内已缓存的 cell 数,超过 cap 就是训练时没见过的相对距离"
|
| 861 |
+
)
|
| 862 |
+
cache = {
|
| 863 |
+
"k": [None] * n, "v": [None] * n, "frames_cached": 0,
|
| 864 |
+
"kv_window": kv_window, "sink_size": int(sink_size), "rope_cap": int(rope_cap),
|
| 865 |
+
"hw": None, "layer": None, "commit": False, "freqs_cur": None, "freqs_hist": None,
|
| 866 |
+
}
|
| 867 |
+
# 自引用句柄:FSDP 会重建 forward 的输入容器,靠它在 forward 里找回**这一份** dict。
|
| 868 |
+
# 非张量值 FSDP 按引用保留,所以 ref 穿得过去(见 _KVRef 的 docstring 与 2 卡实测)。
|
| 869 |
+
cache["_ref"] = _KVRef(cache)
|
| 870 |
+
return cache
|
| 871 |
+
|
| 872 |
+
@staticmethod
|
| 873 |
+
def release_kv_cache(cache: dict) -> None:
|
| 874 |
+
"""用完一条 rollout 必须调 —— `_ref` 与 cache 互相引用,构成**引用环**。
|
| 875 |
+
|
| 876 |
+
引用计数放不掉环,只能等循环 GC,而 GC 是惰性的:一条 21 cell 的 rollout 缓存十几 GB
|
| 877 |
+
的 K/V,几步之内显存就爬满(实测 98 → 120 → 126 GB 然后 OOM)。
|
| 878 |
+
`clear()` 把 dict 清空即断环,张量当场释放。
|
| 879 |
+
"""
|
| 880 |
+
if cache is not None:
|
| 881 |
+
cache.clear()
|
| 882 |
+
|
| 883 |
+
@staticmethod
|
| 884 |
+
def snapshot_kv_cache(cache: dict) -> dict:
|
| 885 |
+
"""浅快照:分叉一条历史给别人读写(stage4 OPSD 的教师上下文),**原缓存一个字节不动**。
|
| 886 |
+
|
| 887 |
+
为什么浅拷贝就是正确的分支(与上游 GraphGWM `KVCache.clone_shallow` 同一条依据):
|
| 888 |
+
`arope_self_attn` 的提交趟写的是 `kv["k"][li] = _evict(torch.cat([k_hist, k_raw]), kv).detach()`
|
| 889 |
+
—— `torch.cat` 造**新**张量、`_evict` 要么原样返回它要么再 cat 一个新的,然后**重新绑定**
|
| 890 |
+
列表槽位;缓存里已有的张量从不被就地写。`frames_cached` 是 int、`freqs_*` / `hw` / `layer` /
|
| 891 |
+
`commit` 也都是每次前向重新赋值的瞬态字段。所以:新 dict + 拷贝过的 'k'/'v' 列表 + 指向新 dict
|
| 892 |
+
的 `_KVRef`,就是一条独立分支 —— 往快照里 commit 只改快照的槽位,原缓存的槽位仍指着旧张量。
|
| 893 |
+
|
| 894 |
+
与 `forward_frame` 入口断言 `frames_cached == frame_index` 的配合(stage4 的用法):
|
| 895 |
+
真实历史 0..k−2 的快照 frames_cached == k−1;把学生的 cell k−1 以 frame_index=k−1 commit 进快照
|
| 896 |
+
⇒ frames_cached == k;随后给 cell k 打分(frame_index=k)正好通过。用完必须
|
| 897 |
+
`release_kv_cache(snapshot)`:快照自己也带 `_ref` 引用环(原缓存不受影响,共享的张量仍被它持有)。
|
| 898 |
+
"""
|
| 899 |
+
snap = {k: v for k, v in cache.items() if k != "_ref"}
|
| 900 |
+
snap["k"] = list(cache["k"])
|
| 901 |
+
snap["v"] = list(cache["v"])
|
| 902 |
+
snap["_ref"] = _KVRef(snap)
|
| 903 |
+
return snap
|
| 904 |
+
|
| 905 |
+
@staticmethod
|
| 906 |
+
def last_kv_frame(cache: dict) -> tuple[list, list]:
|
| 907 |
+
"""取**最近一次 commit** 的那个 cell 的 raw K/V(每层 [b, hw, dim],RoPE 之前,clone 过、detach 过)。
|
| 908 |
+
|
| 909 |
+
提交趟把新 cell cat 在末尾、`_evict` 只从 sink 之后丢最老的,所以最近一格恒是每层最后 hw 个 token。
|
| 910 |
+
clone:调用方通常紧接着 `release_kv_cache` 这份缓存,切片视图会拖着整条 [b, n*hw, dim] 不放。
|
| 911 |
+
stage4 OPSD 用它复现官方的写入顺序(见 `append_kv_frame`)。"""
|
| 912 |
+
hw = cache["hw"]
|
| 913 |
+
assert hw is not None and cache["k"][0] is not None, "缓存还没提交过任何 cell"
|
| 914 |
+
ks = [t[:, -hw:].detach().clone() for t in cache["k"]]
|
| 915 |
+
vs = [t[:, -hw:].detach().clone() for t in cache["v"]]
|
| 916 |
+
return ks, vs
|
| 917 |
+
|
| 918 |
+
@staticmethod
|
| 919 |
+
def append_kv_frame(cache: dict, k_slices: list, v_slices: list, frame_index: int) -> None:
|
| 920 |
+
"""把一格**已经算好的** raw K/V 按提交趟的规则追加进缓存:逐层 cat + `_evict` + frames_cached 推进。
|
| 921 |
+
|
| 922 |
+
与 `arope_self_attn` 的 commit 路径逐字相同(cat 造新张量、重新绑定槽位、原张量不动),所以对
|
| 923 |
+
`snapshot_kv_cache` 的快照调用同样不碰原缓存。用途(stage4 OPSD,官方 gt_kv 的写入顺序):官方教师把
|
| 924 |
+
自己的 chunk k 写进缓存时缓存里还是 [GT 0..k−2, 教师的 k−1],**之后**才把 k−1 原位换成 GT;于是打分
|
| 925 |
+
k+1 时最近一格的 K/V(第 1 层起)是在"教师的 k−1"上下文里算出来的,不是在全 GT 历史上重算的。
|
| 926 |
+
我们的缓存 append-only,只能:cell k 打分完把教师的 x0 commit 进它打分用的那条快照(上下文正是官方的),
|
| 927 |
+
`last_kv_frame` 取走那格 raw K/V,cell k+1 时用本函数把它接到真值 0..k−1 的新快照末尾。
|
| 928 |
+
raw K/V 与位置无关(RoPE 读时按 (tpos, offset_win) 重算),搬到另一条同长度的缓存末尾是精确的。"""
|
| 929 |
+
assert cache["frames_cached"] == frame_index, (
|
| 930 |
+
f"append_kv_frame:frame_index={frame_index} 但缓存最后提交的是 cell {cache['frames_cached'] - 1}")
|
| 931 |
+
assert len(k_slices) == len(cache["k"]) == len(v_slices), "层数不符"
|
| 932 |
+
hw = k_slices[0].shape[1]
|
| 933 |
+
assert cache["hw"] in (None, hw), (cache["hw"], hw)
|
| 934 |
+
k0 = cache["k"][0]
|
| 935 |
+
n_cached = 0 if k0 is None else k0.shape[1] // hw
|
| 936 |
+
if cache["kv_window"] is not None:
|
| 937 |
+
cap = cache["sink_size"] + cache["kv_window"]
|
| 938 |
+
assert n_cached == min(frame_index, cap), (n_cached, frame_index, cap)
|
| 939 |
+
else:
|
| 940 |
+
assert n_cached == frame_index, (n_cached, frame_index)
|
| 941 |
+
cache["hw"] = hw
|
| 942 |
+
for li in range(len(cache["k"])):
|
| 943 |
+
kh, vh = cache["k"][li], cache["v"][li]
|
| 944 |
+
kn, vn = k_slices[li], v_slices[li]
|
| 945 |
+
assert kn.shape[1] == hw and vn.shape[1] == hw, (kn.shape, vn.shape)
|
| 946 |
+
nk = kn if kh is None else torch.cat([kh, kn], dim=1)
|
| 947 |
+
nv = vn if vh is None else torch.cat([vh, vn], dim=1)
|
| 948 |
+
cache["k"][li] = _evict(nk, cache).detach()
|
| 949 |
+
cache["v"][li] = _evict(nv, cache).detach()
|
| 950 |
+
cache["frames_cached"] = frame_index + 1
|
| 951 |
+
|
| 952 |
+
def forward_frame(self, x_k: torch.Tensor, t_k, ctx_k: torch.Tensor, frame_index: int,
|
| 953 |
+
kv_cache: dict, commit: bool = True, *,
|
| 954 |
+
offset_win=None, mask_in_k=None, context_ids=None, r_k=None,
|
| 955 |
+
angle_win=None) -> torch.Tensor:
|
| 956 |
+
"""单 cell 前向,注意力打到缓存里的历史 + 自己。**不要直接调**(走 `forward(..., kv_cache=...)`,
|
| 957 |
+
否则 FSDP 不 all-gather 根模块的参数)。
|
| 958 |
+
|
| 959 |
+
x_k: [b, 48, 1, 30, 52]
|
| 960 |
+
t_k: 标量 / [b] / [b, 1],**原样使用**,绝不套 first_frame_cond(BLOCKER B1)
|
| 961 |
+
r_k: flow map 的第二时间步,规则同 t_k:**同样原样使用、同样不套 first_frame_cond**
|
| 962 |
+
(B1 对 r 一字不差地成立)。装了 flow map ⇒ 必填;没装 ⇒ None 或与 t_k 相同。
|
| 963 |
+
ctx_k: [b, 1, 512, 4096](逐 cell)或 [b, 512, 4096](整条)
|
| 964 |
+
frame_index 绝对 cell 序号 k
|
| 965 |
+
commit True = 把这一帧的 K/V 写进缓存(干净/最终那一趟);False = 中间去噪趟,只读不写。
|
| 966 |
+
滑窗 commit 是 append-only 的:同一个 frame_index commit 两次会把这帧塞进去两遍,
|
| 967 |
+
n_cached / 驱逐全乱。拦它的是入口的 `frames_cached == frame_index` 断言(每次
|
| 968 |
+
commit 把 frames_cached 推到 frame_index+1);下面的 n_cached 断言只在缓存饱和
|
| 969 |
+
之前有效 —— k ≥ cap 后 `_evict` 让 n_cached 恒等于 cap,重复 / 漏 commit 形状对、
|
| 970 |
+
内容错、不报错(GEO-3)。
|
| 971 |
+
offset_win [b, n_cached+1, 2] px,**窗口锚定**:offset_win[:,0] 恒为 0(窗口里最老那个 cell),
|
| 972 |
+
offset_win[:,-1] 是当前 cell。None ⇒ 普通查表 RoPE。
|
| 973 |
+
mask_in_k [b, 1, 1, 30, 52],用 **锚定之后** 的 offset 算的 known 掩码。
|
| 974 |
+
"""
|
| 975 |
+
dit = self.dit
|
| 976 |
+
assert self.causal, "forward_frame 是因果学生的流式路径(causal=True)"
|
| 977 |
+
b, _, f1, lat_h, lat_w = x_k.shape
|
| 978 |
+
assert f1 == 1, f"forward_frame 一次只处理一个 cell,得到 f={f1}"
|
| 979 |
+
p_f, p_h, p_w = dit.patch_size
|
| 980 |
+
h, w = lat_h // p_h, lat_w // p_w
|
| 981 |
+
hw = h * w
|
| 982 |
+
|
| 983 |
+
# ★ GEO-3:每个 frame_index 恰好 commit 一次。缓存饱和后 n_cached 恒 == cap,下面���条断言
|
| 984 |
+
# 拦不住重复 / 漏 commit(形状对、内容错、不报错),只有这个计数器能。commit=False 的去噪趟
|
| 985 |
+
# 不推进它,所以同一 frame_index 上反复 commit=False 再 commit=True 一次照样通过。
|
| 986 |
+
assert kv_cache["frames_cached"] == frame_index, (
|
| 987 |
+
f"frame_index={frame_index} 但缓存最后提交的是 cell {kv_cache['frames_cached'] - 1} "
|
| 988 |
+
"—— 漏 commit 或重复 commit 了"
|
| 989 |
+
)
|
| 990 |
+
|
| 991 |
+
# 缓存长度以第 0 层为准(各层同步推进)
|
| 992 |
+
k0 = kv_cache["k"][0]
|
| 993 |
+
n_cached = 0 if k0 is None else k0.shape[1] // hw
|
| 994 |
+
sliding = kv_cache["kv_window"] is not None
|
| 995 |
+
if sliding:
|
| 996 |
+
cap = kv_cache["sink_size"] + kv_cache["kv_window"]
|
| 997 |
+
assert n_cached == min(frame_index, cap), (
|
| 998 |
+
f"缓存里有 {n_cached} 个 cell,按 frame_index={frame_index} / 窗口 {cap} 应为 "
|
| 999 |
+
f"{min(frame_index, cap)} —— 漏 commit 或重复 commit 了"
|
| 1000 |
+
)
|
| 1001 |
+
else:
|
| 1002 |
+
assert n_cached == frame_index, (
|
| 1003 |
+
f"绝对模式下缓存长度必须等于 frame_index({n_cached} != {frame_index})"
|
| 1004 |
+
)
|
| 1005 |
+
tpos = n_cached # 当前 cell 的时间位;滑窗下恒等于 min(frame_index, rope_cap)
|
| 1006 |
+
|
| 1007 |
+
# ---- 时间步:原样用 t_k / r_k,**没有** first_frame_cond ----
|
| 1008 |
+
r_eff = self._resolve_r(t_k, r_k)
|
| 1009 |
+
t_tok = _per_token_timestep_pf(x_k, t_k, hw, first_frame_cond=False) # [b, hw]
|
| 1010 |
+
r_tok = (None if r_eff is None
|
| 1011 |
+
else _per_token_timestep_pf(x_k, r_eff, hw, first_frame_cond=False))
|
| 1012 |
+
t = self._embed_time(t_tok, r_tok)
|
| 1013 |
+
t_mod = dit.time_projection(t).unflatten(2, (6, dit.dim))
|
| 1014 |
+
|
| 1015 |
+
ctx = _embed_context(dit, ctx_k, context_ids)
|
| 1016 |
+
|
| 1017 |
+
xt = dit.patch_embedding(x_k)
|
| 1018 |
+
mask_emb = self._mask_embed(mask_in_k, xt)
|
| 1019 |
+
if mask_emb is not None:
|
| 1020 |
+
xt = xt + mask_emb
|
| 1021 |
+
assert xt.shape[2] == 1 and xt.shape[3] == h and xt.shape[4] == w
|
| 1022 |
+
tokens = rearrange(xt, "b c f h w -> b (f h w) c").contiguous()
|
| 1023 |
+
|
| 1024 |
+
# ---- 窗口锚定的 RoPE ----
|
| 1025 |
+
if offset_win is None:
|
| 1026 |
+
off_win_tok = None
|
| 1027 |
+
else:
|
| 1028 |
+
assert offset_win.shape == (b, n_cached + 1, 2), (
|
| 1029 |
+
f"offset_win 形状应为 [b={b}, n_cached+1={n_cached + 1}, 2],得到 {tuple(offset_win.shape)}"
|
| 1030 |
+
)
|
| 1031 |
+
assert bool(torch.all(offset_win[:, 0] == 0)), (
|
| 1032 |
+
"offset_win[:, 0] 必须是 0:世界坐标要锚在窗口里最老那个 cell 上"
|
| 1033 |
+
"(off_win = offset_px[a:k+1] - offset_px[a])"
|
| 1034 |
+
)
|
| 1035 |
+
off_win_tok = offset_win.to(torch.float64) / PX_PER_TOKEN
|
| 1036 |
+
if angle_win is None:
|
| 1037 |
+
ang_win = None
|
| 1038 |
+
else:
|
| 1039 |
+
assert off_win_tok is not None, "给了 angle_win 就必须给 offset_win(坐标要一起进当前相机系)"
|
| 1040 |
+
ang_win = angle_win.to(device=tokens.device, dtype=torch.float64)
|
| 1041 |
+
assert ang_win.shape == (b, n_cached + 1), (
|
| 1042 |
+
f"angle_win 形状应为 [b={b}, n_cached+1={n_cached + 1}],得到 {tuple(ang_win.shape)}"
|
| 1043 |
+
)
|
| 1044 |
+
assert bool(torch.all(ang_win[:, -1] == 0)), (
|
| 1045 |
+
"angle_win[:, -1] 必须是 0:一切都表达在**当前 cell 的相机系**里,"
|
| 1046 |
+
"当前帧自己的格点必须保持轴对齐(模型唯一见过的帧内结构)"
|
| 1047 |
+
)
|
| 1048 |
+
cur_off = None if off_win_tok is None else off_win_tok[:, n_cached:n_cached + 1]
|
| 1049 |
+
cur_ang = None if ang_win is None else ang_win[:, n_cached:n_cached + 1]
|
| 1050 |
+
freqs_cur = build_world_freqs(dit, 1, h, w, cur_off, device=tokens.device, f_start=tpos,
|
| 1051 |
+
angle=cur_ang)
|
| 1052 |
+
|
| 1053 |
+
freqs_hist = None
|
| 1054 |
+
if n_cached > 0:
|
| 1055 |
+
n_sink = min(kv_cache["sink_size"], n_cached)
|
| 1056 |
+
R = n_cached - n_sink
|
| 1057 |
+
parts = []
|
| 1058 |
+
if n_sink > 0:
|
| 1059 |
+
# sink 段永久钉在时间位 0..n_sink-1(sink_size=0 时这一段不存在)
|
| 1060 |
+
off_s = None if off_win_tok is None else off_win_tok[:, :n_sink]
|
| 1061 |
+
ang_s = None if ang_win is None else ang_win[:, :n_sink]
|
| 1062 |
+
parts.append(build_world_freqs(dit, n_sink, h, w, off_s, device=tokens.device, f_start=0,
|
| 1063 |
+
angle=ang_s))
|
| 1064 |
+
if R > 0:
|
| 1065 |
+
off_r = None if off_win_tok is None else off_win_tok[:, n_sink:n_cached]
|
| 1066 |
+
ang_r = None if ang_win is None else ang_win[:, n_sink:n_cached]
|
| 1067 |
+
parts.append(build_world_freqs(dit, R, h, w, off_r, device=tokens.device, f_start=tpos - R,
|
| 1068 |
+
angle=ang_r))
|
| 1069 |
+
freqs_hist = parts[0] if len(parts) == 1 else torch.cat(parts, dim=_freqs_cat_dim(parts[0]))
|
| 1070 |
+
|
| 1071 |
+
kv_cache["hw"] = hw
|
| 1072 |
+
kv_cache["commit"] = bool(commit)
|
| 1073 |
+
kv_cache["freqs_cur"] = freqs_cur
|
| 1074 |
+
kv_cache["freqs_hist"] = freqs_hist
|
| 1075 |
+
|
| 1076 |
+
# 直接调 block,**绝不** gradient_checkpoint_forward(risk 6:滑窗 commit 是破坏性的,
|
| 1077 |
+
# 重算时读到的是驱逐之后的缓存,缓存满了以后形状对内容错、不报错)
|
| 1078 |
+
for li, block in enumerate(dit.blocks):
|
| 1079 |
+
kv_cache["layer"] = li
|
| 1080 |
+
tokens = block(tokens, ctx, t_mod, freqs_cur, f=1, attn_mask=None, kv=kv_cache)
|
| 1081 |
+
|
| 1082 |
+
tokens = dit.head(tokens, t)
|
| 1083 |
+
if commit:
|
| 1084 |
+
kv_cache["frames_cached"] = frame_index + 1
|
| 1085 |
+
return dit.unpatchify(tokens, (1, h, w))
|
| 1086 |
+
|
| 1087 |
+
|
| 1088 |
+
# 模块级别名(与 AropeDiT.release_kv_cache 一样是静态的;stage4 OPSD 直接 import 它们)
|
| 1089 |
+
snapshot_kv_cache = AropeDiT.snapshot_kv_cache
|
| 1090 |
+
last_kv_frame = AropeDiT.last_kv_frame
|
| 1091 |
+
append_kv_frame = AropeDiT.append_kv_frame
|
backup_2026-09-14/code/distill/common.sh
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# 三阶段蒸馏所有 launcher 的公共前置:source 它,不要执行它。
|
| 3 |
+
# source "$(dirname "${BASH_SOURCE[0]}")/../common.sh" # 从 distill/stageN_*/run.sh 里
|
| 4 |
+
# 干四件事:
|
| 5 |
+
# 1) 从自身位置推 ROOT(不写死路径 —— 脚本随仓库搬走也不会指向别人的盘)
|
| 6 |
+
# 2) 统一环境变量(PYTHONPATH / DIFFSYNTH_SKIP_DOWNLOAD / 分配器 / NCCL / OMP),并 unset WorldMind 的三个 env hook
|
| 7 |
+
# 3) 钉死 CUDA_VISIBLE_DEVICES=0,1,2,3 —— 4-7 卡是别人的,1-3 是正式训练预留,任何情况下都不碰
|
| 8 |
+
# 4) GPU 占用守卫:0-3 里任意一张 >500 MiB 就拒绝启动
|
| 9 |
+
set -euo pipefail
|
| 10 |
+
|
| 11 |
+
# ---- 1) ROOT:由本文件所在目录(<ROOT>/distill)上溯一级得到 ----
|
| 12 |
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
| 13 |
+
export ROOT
|
| 14 |
+
PY="$ROOT/.venv/bin/python"
|
| 15 |
+
ACC="$ROOT/.venv/bin/accelerate"
|
| 16 |
+
export PY ACC
|
| 17 |
+
[ -x "$PY" ] || { echo "ABORT: 找不到 $PY"; exit 1; }
|
| 18 |
+
[ -x "$ACC" ] || { echo "ABORT: 找不到 $ACC"; exit 1; }
|
| 19 |
+
|
| 20 |
+
# ---- 2) 环境 ----
|
| 21 |
+
export PYTHONPATH="$ROOT${PYTHONPATH:+:$PYTHONPATH}"
|
| 22 |
+
export DIFFSYNTH_SKIP_DOWNLOAD=True
|
| 23 |
+
export PYTORCH_CUDA_ALLOC_CONF="${PYTORCH_CUDA_ALLOC_CONF:-expandable_segments:True}"
|
| 24 |
+
export NCCL_P2P_LEVEL="${NCCL_P2P_LEVEL:-NVL}" # 0-3 在同一张 NV18 NVLink mesh 上
|
| 25 |
+
export OMP_NUM_THREADS="${OMP_NUM_THREADS:-16}" # 本机 160 逻辑核;不限线程会让 4 个 rank 互相抖动
|
| 26 |
+
export TOKENIZERS_PARALLELISM=false
|
| 27 |
+
unset GM_XCACHE GM_COMPILE GM_SAGEATTN || true # WorldMind 的 env hook:XCACHE 切断 autograd、COMPILE 会挂、SAGE 更慢
|
| 28 |
+
|
| 29 |
+
# ---- 3) 卡的隔离:只用 0,1,2,3,且不接受外部覆盖 ----
|
| 30 |
+
# CUDA 默认按 FASTEST_FIRST 枚举,理论上可以与 nvidia-smi 的 PCI 顺序不同;钉成 PCI_BUS_ID 之后
|
| 31 |
+
# CUDA_VISIBLE_DEVICES=0,1,2,3 与守卫里的 `nvidia-smi -i 0,1,2,3` 一定指同样这四张物理卡。
|
| 32 |
+
export CUDA_DEVICE_ORDER=PCI_BUS_ID
|
| 33 |
+
# DISTILL_NGPU:用前几张卡。**默认仍是 4** —— 这是共用机器,4-7 默认留给别人。
|
| 34 |
+
# 要用 8 必须显式 DISTILL_NGPU=8,而且先跟人确认:守卫只能看出「现在有没有人在用」,
|
| 35 |
+
# 看不出「别人正打算用」。
|
| 36 |
+
DISTILL_NGPU="${DISTILL_NGPU:-4}"
|
| 37 |
+
case "$DISTILL_NGPU" in
|
| 38 |
+
1|2|4|8) : ;;
|
| 39 |
+
*) echo "ABORT: DISTILL_NGPU 只接受 1/2/4/8,得到 $DISTILL_NGPU" >&2; return 1 2>/dev/null || exit 1 ;;
|
| 40 |
+
esac
|
| 41 |
+
DISTILL_GPUS="$(seq -s, 0 $((DISTILL_NGPU - 1)))"
|
| 42 |
+
DISTILL_NPROC="$DISTILL_NGPU"
|
| 43 |
+
export CUDA_VISIBLE_DEVICES="$DISTILL_GPUS"
|
| 44 |
+
export DISTILL_GPUS DISTILL_NPROC DISTILL_NGPU
|
| 45 |
+
|
| 46 |
+
# ---- 4) GPU 占用守卫 ----
|
| 47 |
+
# 注意:`nvidia-smi -i` 用的是物理编号,不受 CUDA_VISIBLE_DEVICES 影响,所以这里明确只查 0-3。
|
| 48 |
+
# 上游 WorldMind 的写法(launch/stage3_cd15k.sh:25-28)不带 -i、查全部 8 张、阈值 20000 MiB —— 别人用 4-7 时它会误报中止,
|
| 49 |
+
# 正好和我们要的隔离相反;且没有 FORCE=1 偷卡的后门。
|
| 50 |
+
require_idle_gpus() {
|
| 51 |
+
local thresh="${1:-500}" out rc busy seen want
|
| 52 |
+
command -v nvidia-smi >/dev/null 2>&1 || { echo "ABORT: 没有 nvidia-smi" >&2; return 1; }
|
| 53 |
+
# ★ 必须显式收 nvidia-smi 的退出码,不能写成 busy="$(nvidia-smi ... | awk ...)"。
|
| 54 |
+
# 本函数是以 `require_idle_gpus ... || exit 1` 调用的,bash 在 `||` 左侧会**关掉 errexit**(连函数体一起),
|
| 55 |
+
# 所以命令替换失败不会中止脚本,只会让 busy 变成空串 —— 守卫就会打印"空闲"然后照样去抢卡。
|
| 56 |
+
# 驱动半死 / GPU 掉总线 / -i 编号非法 都会走到这条路上,宁可不跑也不能查不到就放行。
|
| 57 |
+
out="$(timeout 30 nvidia-smi -i "$DISTILL_GPUS" --query-gpu=index,memory.used --format=csv,noheader,nounits 2>&1)"
|
| 58 |
+
rc=$?
|
| 59 |
+
if [ "$rc" -ne 0 ]; then
|
| 60 |
+
echo "ABORT: nvidia-smi -i $DISTILL_GPUS 查询失败(退出码 $rc):" >&2
|
| 61 |
+
printf ' %s\n' "$out" >&2
|
| 62 |
+
echo " 查不到占用就不许启动 —— 1-3 是正式训练预留、4-7 是别人的卡。" >&2
|
| 63 |
+
return 1
|
| 64 |
+
fi
|
| 65 |
+
# 必须恰好回来 0,1,2,3 这四行:驱动半死 / MIG 分区时 nvidia-smi 可能只吐一部分还 exit 0,
|
| 66 |
+
# 漏掉的那几张等于根本没查过。
|
| 67 |
+
seen="$(printf '%s\n' "$out" | awk -F', *' 'NF>=2 {printf "%s,", $1}')"
|
| 68 |
+
want="$DISTILL_GPUS,"
|
| 69 |
+
if [ "$seen" != "$want" ]; then
|
| 70 |
+
echo "ABORT: nvidia-smi 只回了 [${seen%,}],期望 [$DISTILL_GPUS];有卡查不到,不启动。" >&2
|
| 71 |
+
return 1
|
| 72 |
+
fi
|
| 73 |
+
# $2+0 强制数值比较;memory.used 不是纯数字([N/A] / [Unknown Error])也当成占用
|
| 74 |
+
busy="$(printf '%s\n' "$out" | awk -F', *' -v t="$thresh" \
|
| 75 |
+
'$2 !~ /^[0-9]+$/ || $2+0 > t+0 {printf "GPU%s=%sMiB ", $1, $2}')"
|
| 76 |
+
if [ -n "$busy" ]; then
|
| 77 |
+
echo "ABORT: GPU $DISTILL_GPUS 上有占用(>${thresh} MiB): $busy" >&2
|
| 78 |
+
echo " 另一个蒸馏/训练任务还在跑?先停掉它。不要去动 GPU 4-7(别人的卡)。" >&2
|
| 79 |
+
return 1
|
| 80 |
+
fi
|
| 81 |
+
echo "[guard] GPU $DISTILL_GPUS 空闲(每张 <= ${thresh} MiB),可以启动。"
|
| 82 |
+
return 0
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
# 阈值只允许调**严**,不允许调松:GPU_BUSY_MIB=999999 曾经等于一个偷卡后门 ——
|
| 86 |
+
# 能让第二个 launcher 直接盖在正在跑的训练上(两个 job 抢同四张卡 = 双双 OOM 或龟速)。
|
| 87 |
+
# 上面第 41 行说"没有偷卡的后门",这一行是让那句话成立的地方。
|
| 88 |
+
GPU_BUSY_MIB_EFF=$(( ${GPU_BUSY_MIB:-500} < 500 ? ${GPU_BUSY_MIB:-500} : 500 ))
|
| 89 |
+
require_idle_gpus "$GPU_BUSY_MIB_EFF" || exit 1
|
backup_2026-09-14/code/distill/configs/accelerate_fsdp_dmd_4gpu.yaml
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 蒸馏 stage3(self-forcing DMD)专用:4 卡(GPU 0,1,2,3) FSDP1 FULL_SHARD,bf16。
|
| 2 |
+
# 派生自 _wm/WorldMind/distill/code/common/accelerate_fsdp_dmd.yaml(那份是 num_processes: 3,被 run.sh 的
|
| 3 |
+
# --num_processes 覆盖;这里直接写死 4)。stage1/2 用 ZeRO-2,只有 stage3 有两个可训模型(student + critic),
|
| 4 |
+
# 而 accelerate 的 DeepSpeed 分支只保留传给 prepare 的最后一个 nn.Module(accelerator.py:2229-2236),故必须换 FSDP。
|
| 5 |
+
#
|
| 6 |
+
# fsdp_version: 1 —— 显式选 FSDP1;accelerate 1.14 的 v1 分支读 fsdp_reshard_after_forward(utils/dataclasses.py:1848),
|
| 7 |
+
# fsdp_sharding_strategy 是已废弃的旧拼写(:1836)。FULL_SHARD ⇒ 参数/梯度/优化器状态全切 4 份。
|
| 8 |
+
# fsdp_use_orig_params: true —— 【风险 5,致命】两处吃紧:优化器在 prepare 之前按 model.parameters() 建好且此后不再重映射,
|
| 9 |
+
# 若为 false,FSDP1 换上 FlatParameter,优化器 step 的是过期张量、永远收不到梯度 —— 模型静默地完全不学习,无报错;
|
| 10 |
+
# 同时 zip-EMA 也要求 Parameter 的身份/顺序/分片完全一致。启动时断言
|
| 11 |
+
# accelerator.state.fsdp_plugin.use_orig_params 为真、开跑前 probe_student_grad 量一次梯度可达性;
|
| 12 |
+
# 并且(已实现,S3-GUARD-3)dmd_arope.py 在每个生成器步的 opt_g.step() 之前检查 gnorm_g:
|
| 13 |
+
# step > resume_step 后恰为 0.0 或 NaN 直接 SystemExit(inf 不拦,clip 已归零;step 0 的 dmd 梯度
|
| 14 |
+
# 恰为 0 是配方性质、由 probe 覆盖、不拦)。
|
| 15 |
+
# fsdp_sync_module_states: false + fsdp_cpu_ram_efficient_loading: false —— 每个 rank 自己确定性地读同一份 safetensors,
|
| 16 |
+
# 不需要 rank0 广播;开了 sync 会让 rank0 一次性实体化全部模型,prepare 阶段直接 OOM。
|
| 17 |
+
# fsdp_state_dict_type: SHARDED_STATE_DICT —— 存 optimizer state 用分片;导权重走 accelerator.get_state_dict + safetensors。
|
| 18 |
+
# fsdp_activation_checkpointing: false —— 梯度检查点在模型侧按路径开(student 只在并行路径上开、critic 必须开),
|
| 19 |
+
# 不交给 FSDP 全局包,否则 rollout 的单帧图也会被包进去。
|
| 20 |
+
compute_environment: LOCAL_MACHINE
|
| 21 |
+
debug: false
|
| 22 |
+
distributed_type: FSDP
|
| 23 |
+
downcast_bf16: 'no'
|
| 24 |
+
enable_cpu_affinity: false
|
| 25 |
+
fsdp_config:
|
| 26 |
+
fsdp_version: 1
|
| 27 |
+
fsdp_reshard_after_forward: FULL_SHARD
|
| 28 |
+
fsdp_auto_wrap_policy: TRANSFORMER_BASED_WRAP
|
| 29 |
+
fsdp_transformer_layer_cls_to_wrap: DiTBlock
|
| 30 |
+
fsdp_backward_prefetch: BACKWARD_PRE
|
| 31 |
+
fsdp_state_dict_type: SHARDED_STATE_DICT
|
| 32 |
+
fsdp_offload_params: false
|
| 33 |
+
fsdp_use_orig_params: true
|
| 34 |
+
fsdp_sync_module_states: false
|
| 35 |
+
fsdp_cpu_ram_efficient_loading: false
|
| 36 |
+
fsdp_activation_checkpointing: false
|
| 37 |
+
machine_rank: 0
|
| 38 |
+
main_training_function: main
|
| 39 |
+
mixed_precision: bf16
|
| 40 |
+
num_machines: 1
|
| 41 |
+
num_processes: 4
|
| 42 |
+
rdzv_backend: static
|
| 43 |
+
same_network: true
|
| 44 |
+
tpu_env: []
|
| 45 |
+
tpu_use_cluster: false
|
| 46 |
+
tpu_use_sudo: false
|
| 47 |
+
use_cpu: false
|
backup_2026-09-14/code/distill/configs/accelerate_fsdp_dmd_8gpu.yaml
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 8 卡版:由 accelerate_fsdp_dmd_4gpu.yaml 只改 num_processes 4 -> 8 得到。
|
| 2 |
+
# ⚠️ 共用机器,4-7 默认不归我们;用它前先确认没人要用(common.sh 需要 DISTILL_NGPU=8)。
|
| 3 |
+
# 蒸馏 stage3(on-policy DMD)专用:8 卡 FSDP1 FULL_SHARD,bf16。
|
| 4 |
+
# 派生自 _wm/WorldMind/distill/code/common/accelerate_fsdp_dmd.yaml(那份是 num_processes: 3,被 run.sh 的
|
| 5 |
+
# --num_processes 覆盖;这里直接写死 4)。stage1/2 用 ZeRO-2,只有 stage3 有两个可训模型(student + critic),
|
| 6 |
+
# 而 accelerate 的 DeepSpeed 分支只保留传给 prepare 的最后一个 nn.Module(accelerator.py:2229-2236),故必须换 FSDP。
|
| 7 |
+
#
|
| 8 |
+
# fsdp_version: 1 —— 显式选 FSDP1;accelerate 1.14 的 v1 分支读 fsdp_reshard_after_forward(utils/dataclasses.py:1848),
|
| 9 |
+
# fsdp_sharding_strategy 是已废弃的旧拼写(:1836)。FULL_SHARD ⇒ 参数/梯度/优化器状态全切 4 份。
|
| 10 |
+
# fsdp_use_orig_params: true —— 【风险 5,致命】两处吃紧:优化器在 prepare 之前按 model.parameters() 建好且此后不再重映射,
|
| 11 |
+
# 若为 false,FSDP1 换上 FlatParameter,优化器 step 的是过期张量、永远收不到梯度 —— 模型静默地完全不学习,无报错;
|
| 12 |
+
# 同时 zip-EMA 也要求 Parameter 的身份/顺序/分片完全一致。启动时断言
|
| 13 |
+
# accelerator.state.fsdp_plugin.use_orig_params 为真、开跑前 probe_student_grad 量一次梯度可达性;
|
| 14 |
+
# 并且(已实现,S3-GUARD-3)dmd_arope.py 在每个生成器步的 opt_g.step() 之前检查 gnorm_g:
|
| 15 |
+
# step > resume_step 后恰为 0.0 或 NaN 直接 SystemExit(inf 不拦,clip 已归零;step 0 的 dmd 梯度
|
| 16 |
+
# 恰为 0 是配方性质、由 probe 覆盖、不拦)。
|
| 17 |
+
# fsdp_sync_module_states: false + fsdp_cpu_ram_efficient_loading: false —— 每个 rank 自己确定性地读同一份 safetensors,
|
| 18 |
+
# 不需要 rank0 广播;开了 sync 会让 rank0 一次性实体化全部模型,prepare 阶段直接 OOM。
|
| 19 |
+
# fsdp_state_dict_type: SHARDED_STATE_DICT —— 存 optimizer state 用分片;导权重走 accelerator.get_state_dict + safetensors。
|
| 20 |
+
# fsdp_activation_checkpointing: false —— 梯度检查点在模型侧按路径开(student 只在并行路径上开、critic 必须开),
|
| 21 |
+
# 不交给 FSDP 全局包,否则 rollout 的单帧图也会被包进去。
|
| 22 |
+
compute_environment: LOCAL_MACHINE
|
| 23 |
+
debug: false
|
| 24 |
+
distributed_type: FSDP
|
| 25 |
+
downcast_bf16: 'no'
|
| 26 |
+
enable_cpu_affinity: false
|
| 27 |
+
fsdp_config:
|
| 28 |
+
fsdp_version: 1
|
| 29 |
+
fsdp_reshard_after_forward: FULL_SHARD
|
| 30 |
+
fsdp_auto_wrap_policy: TRANSFORMER_BASED_WRAP
|
| 31 |
+
fsdp_transformer_layer_cls_to_wrap: DiTBlock
|
| 32 |
+
fsdp_backward_prefetch: BACKWARD_PRE
|
| 33 |
+
fsdp_state_dict_type: SHARDED_STATE_DICT
|
| 34 |
+
fsdp_offload_params: false
|
| 35 |
+
fsdp_use_orig_params: true
|
| 36 |
+
fsdp_sync_module_states: false
|
| 37 |
+
fsdp_cpu_ram_efficient_loading: false
|
| 38 |
+
fsdp_activation_checkpointing: false
|
| 39 |
+
machine_rank: 0
|
| 40 |
+
main_training_function: main
|
| 41 |
+
mixed_precision: bf16
|
| 42 |
+
num_machines: 1
|
| 43 |
+
num_processes: 8
|
| 44 |
+
rdzv_backend: static
|
| 45 |
+
same_network: true
|
| 46 |
+
tpu_env: []
|
| 47 |
+
tpu_use_cluster: false
|
| 48 |
+
tpu_use_sudo: false
|
| 49 |
+
use_cpu: false
|
backup_2026-09-14/code/distill/configs/accelerate_zero2_4gpu_ga2.yaml
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 蒸馏 stage1/stage2 用:4 卡(GPU 0,1,2,3) DeepSpeed ZeRO-2,bf16,无 offload,梯度裁剪 1.0,梯度累积 2。
|
| 2 |
+
# 派生自 configs/accelerate_zero2_4gpu.yaml,只改 gradient_accumulation_steps: 1 -> 2(等效 batch = 4 卡 × 1 × 2 = 8)。
|
| 3 |
+
# train_micro_batch_size_per_gpu 由 accelerate 填 "auto" ⇒ 取训练脚本里 DataLoader 的 batch_size(默认 1)。
|
| 4 |
+
# 训练脚本的 --grad_accum 必须等于这里的 2,否则 SystemExit(train.py:399 那条守卫要原样带进新 trainer)。
|
| 5 |
+
# 不用 deepspeed_config_file:accelerate 1.14 里它与 mixed_precision 等键互斥,yaml 原生字段更省事。
|
| 6 |
+
compute_environment: LOCAL_MACHINE
|
| 7 |
+
debug: false
|
| 8 |
+
deepspeed_config:
|
| 9 |
+
gradient_accumulation_steps: 2
|
| 10 |
+
gradient_clipping: 1.0
|
| 11 |
+
offload_optimizer_device: none
|
| 12 |
+
offload_param_device: none
|
| 13 |
+
zero3_init_flag: false
|
| 14 |
+
zero_stage: 2
|
| 15 |
+
distributed_type: DEEPSPEED
|
| 16 |
+
downcast_bf16: 'no'
|
| 17 |
+
enable_cpu_affinity: false
|
| 18 |
+
machine_rank: 0
|
| 19 |
+
main_training_function: main
|
| 20 |
+
mixed_precision: bf16
|
| 21 |
+
num_machines: 1
|
| 22 |
+
num_processes: 4
|
| 23 |
+
rdzv_backend: static
|
| 24 |
+
same_network: true
|
| 25 |
+
tpu_env: []
|
| 26 |
+
tpu_use_cluster: false
|
| 27 |
+
tpu_use_sudo: false
|
| 28 |
+
use_cpu: false
|
backup_2026-09-14/code/distill/configs/accelerate_zero2_4gpu_ga4.yaml
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 蒸馏 stage1/stage2 用:4 卡(GPU 0,1,2,3) DeepSpeed ZeRO-2,bf16,无 offload,梯度裁剪 1.0,梯度累积 4。
|
| 2 |
+
# 派生自 configs/accelerate_zero2_4gpu.yaml,只改 gradient_accumulation_steps: 1 -> 4(等效 batch = 4 卡 × 1 × 4 = 16,stage1 的 eff-batch-16 备选)。
|
| 3 |
+
# train_micro_batch_size_per_gpu 由 accelerate 填 "auto" ⇒ 取训练脚本里 DataLoader 的 batch_size(默认 1)。
|
| 4 |
+
# 训练脚本的 --grad_accum 必须等于这里的 4,否则 SystemExit(train.py:399 那条守卫要原样带进新 trainer)。
|
| 5 |
+
# 不用 deepspeed_config_file:accelerate 1.14 里它与 mixed_precision 等键互斥,yaml 原生字段更省事。
|
| 6 |
+
compute_environment: LOCAL_MACHINE
|
| 7 |
+
debug: false
|
| 8 |
+
deepspeed_config:
|
| 9 |
+
gradient_accumulation_steps: 4
|
| 10 |
+
gradient_clipping: 1.0
|
| 11 |
+
offload_optimizer_device: none
|
| 12 |
+
offload_param_device: none
|
| 13 |
+
zero3_init_flag: false
|
| 14 |
+
zero_stage: 2
|
| 15 |
+
distributed_type: DEEPSPEED
|
| 16 |
+
downcast_bf16: 'no'
|
| 17 |
+
enable_cpu_affinity: false
|
| 18 |
+
machine_rank: 0
|
| 19 |
+
main_training_function: main
|
| 20 |
+
mixed_precision: bf16
|
| 21 |
+
num_machines: 1
|
| 22 |
+
num_processes: 4
|
| 23 |
+
rdzv_backend: static
|
| 24 |
+
same_network: true
|
| 25 |
+
tpu_env: []
|
| 26 |
+
tpu_use_cluster: false
|
| 27 |
+
tpu_use_sudo: false
|
| 28 |
+
use_cpu: false
|
backup_2026-09-14/code/distill/configs/accelerate_zero2_8gpu_ga1.yaml
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 蒸馏 stage1/stage2 用:8 卡(GPU 0-7) DeepSpeed ZeRO-2,bf16,无 offload,梯度裁剪 1.0,梯度累积 1。
|
| 2 |
+
# 派生自 accelerate_zero2_4gpu_ga2.yaml,只改两处:num_processes 4 -> 8、gradient_accumulation_steps 2 -> 1。
|
| 3 |
+
#
|
| 4 |
+
# **等效 batch 仍然是 8**(8 卡 × micro 1 × GA 1 = 8),与 4 卡 GA2 完全相同 —— 换句话说这是同一个
|
| 5 |
+
# 配方跑得快一倍,而不是另一个配方。想要等效 batch 16 得另建 ga2 的 8 卡版,别在这里改 GA。
|
| 6 |
+
#
|
| 7 |
+
# ⚠️ 这台是共用机器。4-7 默认不归我们;用这份配置前先确认没人要用。
|
| 8 |
+
# common.sh 需要 DISTILL_NGPU=8 才会放开 CUDA_VISIBLE_DEVICES 和守卫的检查范围。
|
| 9 |
+
#
|
| 10 |
+
# train_micro_batch_size_per_gpu 由 accelerate 填 "auto" ⇒ 取训练脚本里 DataLoader 的 batch_size(默认 1)。
|
| 11 |
+
# 训练脚本的 --grad_accum 必须等于这里的 1,否则 SystemExit。
|
| 12 |
+
compute_environment: LOCAL_MACHINE
|
| 13 |
+
debug: false
|
| 14 |
+
deepspeed_config:
|
| 15 |
+
gradient_accumulation_steps: 1
|
| 16 |
+
gradient_clipping: 1.0
|
| 17 |
+
offload_optimizer_device: none
|
| 18 |
+
offload_param_device: none
|
| 19 |
+
zero3_init_flag: false
|
| 20 |
+
zero_stage: 2
|
| 21 |
+
distributed_type: DEEPSPEED
|
| 22 |
+
downcast_bf16: 'no'
|
| 23 |
+
enable_cpu_affinity: false
|
| 24 |
+
machine_rank: 0
|
| 25 |
+
main_training_function: main
|
| 26 |
+
mixed_precision: bf16
|
| 27 |
+
num_machines: 1
|
| 28 |
+
num_processes: 8
|
| 29 |
+
rdzv_backend: static
|
| 30 |
+
same_network: true
|
| 31 |
+
tpu_env: []
|
| 32 |
+
tpu_use_cluster: false
|
| 33 |
+
tpu_use_sudo: false
|
| 34 |
+
use_cpu: false
|
backup_2026-09-14/code/distill/configs/accelerate_zero2_8gpu_ga2.yaml
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 长视野续训用(distill/stage1_long/run.sh 在 DISTILL_NGPU=8 时选它):8 卡(GPU 0-7) DeepSpeed ZeRO-2,
|
| 2 |
+
# bf16,无 offload,梯度裁剪 1.0,梯度累积 2。
|
| 3 |
+
# 派生自 accelerate_zero2_8gpu_ga1.yaml,只改 gradient_accumulation_steps 1 -> 2(longhorizon/README 建议等效 batch 16)。
|
| 4 |
+
#
|
| 5 |
+
# **等效 batch 是 16**(8 卡 × micro 1 × GA 2 = 16),与 4 卡 GA4(accelerate_zero2_4gpu_ga4.yaml)相同 ——
|
| 6 |
+
# 这是 stage1_long 的配方,**不是** stage1/stage2 的等效 batch 8:那两条在 8 卡下用
|
| 7 |
+
# accelerate_zero2_8gpu_ga1.yaml(各自的 run.sh 按 DISTILL_NGPU 自动选,并断言 卡数×GA == 8)。
|
| 8 |
+
#
|
| 9 |
+
# ⚠️ 这台是共用机器。4-7 默认不归我们;用这份配置前先确认没人要用。
|
| 10 |
+
# common.sh 需要 DISTILL_NGPU=8 才会放开 CUDA_VISIBLE_DEVICES 和守卫的检查范围。
|
| 11 |
+
#
|
| 12 |
+
# train_micro_batch_size_per_gpu 由 accelerate 填 "auto" ⇒ 取训练脚本里 DataLoader 的 batch_size(默认 1)。
|
| 13 |
+
# 训练脚本的 --grad_accum 必须等于这里的 2,否则 SystemExit。
|
| 14 |
+
compute_environment: LOCAL_MACHINE
|
| 15 |
+
debug: false
|
| 16 |
+
deepspeed_config:
|
| 17 |
+
gradient_accumulation_steps: 2
|
| 18 |
+
gradient_clipping: 1.0
|
| 19 |
+
offload_optimizer_device: none
|
| 20 |
+
offload_param_device: none
|
| 21 |
+
zero3_init_flag: false
|
| 22 |
+
zero_stage: 2
|
| 23 |
+
distributed_type: DEEPSPEED
|
| 24 |
+
downcast_bf16: 'no'
|
| 25 |
+
enable_cpu_affinity: false
|
| 26 |
+
machine_rank: 0
|
| 27 |
+
main_training_function: main
|
| 28 |
+
mixed_precision: bf16
|
| 29 |
+
num_machines: 1
|
| 30 |
+
num_processes: 8
|
| 31 |
+
rdzv_backend: static
|
| 32 |
+
same_network: true
|
| 33 |
+
tpu_env: []
|
| 34 |
+
tpu_use_cluster: false
|
| 35 |
+
tpu_use_sudo: false
|
| 36 |
+
use_cpu: false
|
backup_2026-09-14/code/distill/data.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""蒸馏用的 batch 工具:mask / 权重图 / 偏移与文本的延长和开窗。
|
| 2 |
+
|
| 3 |
+
数据集本身**一行不改**:`actionrope.dataset.AropeLatentDataset` + `arope_collate` 已经给齐
|
| 4 |
+
了三个阶段需要的一切(`input_latents [b,48,21,30,52] bf16`、`context [b,21,512,4096] bf16`、
|
| 5 |
+
`context_ids [b,21] i64`、`offset_px [b,21,2] f32`、`offset_tok`、`delta_tok`、`action_idx`、
|
| 6 |
+
`clip_id`、`kind`)。本文件只做 batch 之上的纯函数,不碰 IO、不碰随机数据增强。
|
| 7 |
+
|
| 8 |
+
四类工具
|
| 9 |
+
--------
|
| 10 |
+
1. `to_device` —— 与 `actionrope/train.py:344` 同一份语义(张量搬走,字符串 list 原样)。
|
| 11 |
+
2. `make_mask_input` / `make_weight_map` —— stage1/2 的 `mask_input` 与 loss 权重图。
|
| 12 |
+
`mask_input` 直接用 `known_mask(offset_px / 16, 30, 52)`,**不能**写成
|
| 13 |
+
`loss_weight_map(...).eq(1)`:那样一来模型的输入通道就被 loss 配方绑架了——
|
| 14 |
+
`new_weight=1.0`(想关掉 new 区加权时的自然写法)会让权重图恒等于 1,掩码整幅变成
|
| 15 |
+
"全 known",模型输入被悄悄改掉而不报错;`sigma`/`ramp_latent` 也各自能把这条等价关系
|
| 16 |
+
推到 float32 的舍入边缘(dist 模式下 `d == 0 ⟺ known` 只是巧合,紧贴边界的
|
| 17 |
+
`1 + 5e-10` 在 float32 里就是 1.0)。掩码是**模型输入**,权重图是**loss 配方**,
|
| 18 |
+
两件事必须各自从 offset 现算。
|
| 19 |
+
3. `window_offsets` / `window_context` —— stage3 滑窗 KV 的**锚定切片**。绝对 cell k、
|
| 20 |
+
窗口最老保留 cell a 时,窗口内的世界坐标一律减去 `offset_px[a]`,于是整段窗口在 RoPE
|
| 21 |
+
看来就是一条以自身 cell 0 为原点的 21-cell 训练 clip(RoPE 是相对编码,整体平移不变)。
|
| 22 |
+
两者索引口径完全一致:`[:, a:k+1]`。
|
| 23 |
+
4. `extend_offsets` / `extend_context` —— rollout 超过 21 cell 时的延长。
|
| 24 |
+
`--rollout_max 26` 意味着 cell 21..26 在数据里根本不存在:`parse_actions` 硬要求 21 个
|
| 25 |
+
cell(`infer.py:127`),`AropeLatentDataset.__getitem__` 硬要求 21 条 prompt。两条流必须
|
| 26 |
+
**同时**延长而且逐下标对齐,否则要么在 `context.shape[1] == 1+N` 的断言处崩,要么更糟:
|
| 27 |
+
几何往前走、文本停在 cell 20,rollout 照跑、loss 照降,但训的是错的东西。
|
| 28 |
+
|
| 29 |
+
两条流的延长策略**故意不同**,这不是笔误:
|
| 30 |
+
* 文本可以"保持"最后一条(`_wm/WorldMind/distill/code/stage3_dmd/dmd_gm.py:628-637`
|
| 31 |
+
的 `last = context[:, Fl-1:Fl]; pad = last.expand(...)`)。ARoPE 臂的 prompt 经
|
| 32 |
+
`prompts.strip_action` 剥掉了动作从句,只剩场景描述,而 rollout 不换场景,所以
|
| 33 |
+
"保持最后一条" 在这里是**语义正确**的,不是凑合。
|
| 34 |
+
* 偏移不能"保持"。偏移保持不动 = "玩家站住了",那是**另一个动作**,不是中性动作
|
| 35 |
+
(V Rising 的 null action 陷阱,GraphGWM/README 记过一次)。所以偏移必须继续积分一个
|
| 36 |
+
速度:`continue`(沿用最后一段速度)或 `resample`(默认,每 5 个 cell 换一次方向,
|
| 37 |
+
方向取 `geometry.ACTION_DIRS` 9 类、速度取 sidecar 实测的 `walk_speed_px_s`)。
|
| 38 |
+
|
| 39 |
+
速度 → 逐 cell 偏移的换算(不是拍脑袋的 v/4)
|
| 40 |
+
------------------------------------------
|
| 41 |
+
Wan2.2 因果 VAE 的分组是 cell 0 = 帧 0、cell k = 帧 4k−3…4k(`geometry.cell_frames`),
|
| 42 |
+
逐 cell 偏移是 cell 内各帧偏移的**均值**(`geometry.frames_to_cells`),而逐帧偏移按
|
| 43 |
+
`frame_off[i] = frame_off[i−1] + v[cell(i)] / FPS` 积分(`infer.py::parse_actions`)。
|
| 44 |
+
把这两步合起来,逐 cell 增量是(v 单位 px/s,FPS=16):
|
| 45 |
+
|
| 46 |
+
delta[1] = 2.5 · v[1] / FPS (cell 0 只有帧 0,起步项不同)
|
| 47 |
+
delta[k] = (1.5 · v[k−1] + 2.5 · v[k]) / FPS (k ≥ 2)
|
| 48 |
+
|
| 49 |
+
匀速时退化成 4·v/FPS = v/4 px per cell(一个 cell = 4 帧 = 0.25 s)。换方向的那个 cell 上
|
| 50 |
+
1.5/2.5 的加权才是真值,直接写 v/4 会在每个 chunk 边界差 1.5·|Δv|/16(最大 ~13 px ≈ 0.8 个
|
| 51 |
+
latent 格)。`test_data.py::test_extend_matches_frame_integration` 用帧级积分逐位核对了这条。
|
| 52 |
+
"""
|
| 53 |
+
from __future__ import annotations
|
| 54 |
+
|
| 55 |
+
import math
|
| 56 |
+
|
| 57 |
+
import numpy as np
|
| 58 |
+
import torch
|
| 59 |
+
|
| 60 |
+
from actionrope.arope import known_mask, loss_weight_map
|
| 61 |
+
from actionrope.geometry import ACTION_DIRS, CELLS, FPS, LAT_H, LAT_W, PX_PER_LATENT
|
| 62 |
+
|
| 63 |
+
# 一个 cell 覆盖 4 帧(cell 0 除外),见 geometry.cell_frames
|
| 64 |
+
CELL_FRAMES = 4
|
| 65 |
+
# 帧级积分 + cell 内均值合出来的两个权重(推导见模块 docstring)
|
| 66 |
+
_W_PREV, _W_CUR = 1.5, 2.5
|
| 67 |
+
|
| 68 |
+
# sidecar 实测步速(data/latent/arope_sidecar.pt 的 meta["walk_speed_px_s"],已乘 |gain|)。
|
| 69 |
+
# 横纵不同是透视造成的,不是噪声;`infer.py::parse_actions` 也是按轴分别乘这个数。
|
| 70 |
+
# 只作为默认值:真跑的时候优先用 walk_speed_from_sidecar() 从当前 sidecar 现读。
|
| 71 |
+
WALK_SPEED_PX_S = (69.24106392992304, 46.012630371093756)
|
| 72 |
+
|
| 73 |
+
# rollout 延长的默认档位,��� stage3 的 --offset_chunk_cells 5 对齐
|
| 74 |
+
DEFAULT_CHUNK_CELLS = 5
|
| 75 |
+
DEFAULT_SPEED_MULT = (0.8, 1.2)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# --------------------------------------------------------------------------
|
| 79 |
+
# batch 搬运
|
| 80 |
+
# --------------------------------------------------------------------------
|
| 81 |
+
|
| 82 |
+
def to_device(batch: dict, device, non_blocking: bool = True) -> dict:
|
| 83 |
+
"""张量搬到 device,其余(clip_id / kind 这种 list[str])原样带过去。
|
| 84 |
+
|
| 85 |
+
与 `actionrope/train.py:344-345` 同一份实现,蒸馏侧不再各写一遍。
|
| 86 |
+
"""
|
| 87 |
+
return {
|
| 88 |
+
k: (v.to(device, non_blocking=non_blocking) if isinstance(v, torch.Tensor) else v)
|
| 89 |
+
for k, v in batch.items()
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# --------------------------------------------------------------------------
|
| 94 |
+
# mask 通道 / loss 权重图
|
| 95 |
+
# --------------------------------------------------------------------------
|
| 96 |
+
|
| 97 |
+
def make_mask_input(offset_px: torch.Tensor, lat_h: int = LAT_H, lat_w: int = LAT_W,
|
| 98 |
+
angle=None) -> torch.Tensor:
|
| 99 |
+
"""known/new 掩码通道 → float32 [b, 1, f, lat_h, lat_w],喂给 `dit.arope_mask_embedding`。
|
| 100 |
+
|
| 101 |
+
offset_px [b, f, 2] px。除以 PX_PER_LATENT=16 换成 latent 格单位,与
|
| 102 |
+
`actionrope/train.py:243-245` 逐位一致(known=1、new=0)。
|
| 103 |
+
stage3 传的是**锚定后**的窗口偏移(见 window_offsets),于是 "known" 的含义变成
|
| 104 |
+
"落在窗口最老那一 cell 的足迹内"——正是模型训练时见过的那种。
|
| 105 |
+
"""
|
| 106 |
+
off = _as_bf2(offset_px, "offset_px")
|
| 107 |
+
if angle is not None and bool(torch.any(torch.as_tensor(angle) != 0)):
|
| 108 |
+
known = _known_rotated(off / PX_PER_LATENT, angle, lat_h, lat_w)
|
| 109 |
+
else:
|
| 110 |
+
known = known_mask(off / PX_PER_LATENT, lat_h, lat_w) # bool [b, f, h, w]
|
| 111 |
+
return known.to(torch.float32).unsqueeze(1)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _known_rotated(off: torch.Tensor, angle, lat_h: int, lat_w: int) -> torch.Tensor:
|
| 115 |
+
"""相机在锚点与当前 cell 之间转了 angle 弧度时的 known 掩码(angle = 锚点朝向 − 当前朝向)。
|
| 116 |
+
|
| 117 |
+
当前格点 p 先按原口径加上偏移,再绕画幅中心转 −angle 回到**锚点相机系**,落在锚点足迹
|
| 118 |
+
[−0.5, h−0.5] × [−0.5, w−0.5] 内 ⇒ known。angle=0 时逐位退化成 `known_mask`(旋转成恒等)。
|
| 119 |
+
旋转之后 known 区是一块**斜的**多边形 —— 训练里只出现过轴对齐的边界,这一条是已知的分布外。
|
| 120 |
+
"""
|
| 121 |
+
from distill.arope_dit import rot2d
|
| 122 |
+
b, f = off.shape[:2]
|
| 123 |
+
dev, dt = off.device, torch.float64
|
| 124 |
+
ang = torch.as_tensor(angle, device=dev, dtype=dt).view(b, f, 1, 1)
|
| 125 |
+
gi = torch.arange(lat_h, device=dev, dtype=dt).view(1, 1, lat_h, 1).expand(b, f, lat_h, lat_w)
|
| 126 |
+
gj = torch.arange(lat_w, device=dev, dtype=dt).view(1, 1, 1, lat_w).expand(b, f, lat_h, lat_w)
|
| 127 |
+
ctr_h, ctr_w = (lat_h - 1) / 2.0, (lat_w - 1) / 2.0
|
| 128 |
+
o = off.to(dt).view(b, f, 1, 1, 2)
|
| 129 |
+
p = torch.stack([gj + o[..., 0] - ctr_w, gi + o[..., 1] - ctr_h], dim=-1)
|
| 130 |
+
q = rot2d(p, -ang)
|
| 131 |
+
wj, wi = q[..., 0] + ctr_w, q[..., 1] + ctr_h
|
| 132 |
+
return (wi >= -0.5) & (wi <= lat_h - 0.5) & (wj >= -0.5) & (wj <= lat_w - 0.5)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def make_weight_map(offset_px: torch.Tensor, lat_h: int = LAT_H, lat_w: int = LAT_W,
|
| 136 |
+
new_weight: float = 2.0, ramp_latent: float = 0.0, sigma=None) -> torch.Tensor:
|
| 137 |
+
"""loss 权重图 → float32 [b, 1, f, lat_h, lat_w](known=1,new=new_weight)。
|
| 138 |
+
|
| 139 |
+
直接转手 `actionrope.arope.loss_weight_map`,把 10k 检查点训练时用的 new 区 ×2 权重
|
| 140 |
+
原样带进 stage1。stage2 的 CD loss **故意不用**这张图(见 PORT_SPEC)。
|
| 141 |
+
"""
|
| 142 |
+
off = _as_bf2(offset_px, "offset_px")
|
| 143 |
+
return loss_weight_map(off, lat_h, lat_w, new_weight=new_weight,
|
| 144 |
+
ramp_latent=ramp_latent, sigma=sigma)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# --------------------------------------------------------------------------
|
| 148 |
+
# 滑窗锚定
|
| 149 |
+
# --------------------------------------------------------------------------
|
| 150 |
+
|
| 151 |
+
def window_offsets(offset_px: torch.Tensor, a: int, k: int) -> torch.Tensor:
|
| 152 |
+
"""窗口 [a, k] 的锚定偏移 → [b, k−a+1, 2],第 0 项恒为 0。
|
| 153 |
+
|
| 154 |
+
a = max(0, k − kv_window) 是窗口里最老的保留 cell。RoPE 是相对编码,整窗同时平移
|
| 155 |
+
(t, offset) 不改变任何注意力分数,所以 RoPE 重锚定是精确的(`tests/test_world_rope_window.py` a1/a3),
|
| 156 |
+
当前查询 cell 的 known 掩码也精确(d1)。但**缓存里各 cell 的 known 掩码通道是按它们各自提交时的锚点
|
| 157 |
+
a_j = max(0, j−20) < a 固化的**,所以 k>20 的历史与一条锚在 k−20 的训练 clip 并不逐位相同 ——
|
| 158 |
+
这是 stage2→stage3 的输入分布移位,靠 self-forcing 训练穿过(审计 GEO-2)。
|
| 159 |
+
"""
|
| 160 |
+
off = _as_bf2(offset_px, "offset_px")
|
| 161 |
+
f = off.shape[1]
|
| 162 |
+
if not 0 <= a <= k < f:
|
| 163 |
+
raise ValueError(f"窗口下标越界: a={a}, k={k}, f={f}")
|
| 164 |
+
return off[:, a:k + 1] - off[:, a:a + 1]
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def window_context(context: torch.Tensor, context_ids: torch.Tensor, a: int, k: int):
|
| 168 |
+
"""`window_offsets` 的文本孪生:同一段 [a, k] 的 context / context_ids。
|
| 169 |
+
|
| 170 |
+
单独写出来只有一个理由——把 "偏移窗口" 和 "文本窗口" 的下标口径钉在同一个函数里,
|
| 171 |
+
stage3 的 scored tail 才不会两边差一格。
|
| 172 |
+
"""
|
| 173 |
+
ctx, ids = _check_context(context, context_ids)
|
| 174 |
+
f = ctx.shape[1]
|
| 175 |
+
if not 0 <= a <= k < f:
|
| 176 |
+
raise ValueError(f"窗口下标越界: a={a}, k={k}, f={f}")
|
| 177 |
+
return ctx[:, a:k + 1], ids[:, a:k + 1]
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
# --------------------------------------------------------------------------
|
| 181 |
+
# rollout 延长:偏移
|
| 182 |
+
# --------------------------------------------------------------------------
|
| 183 |
+
|
| 184 |
+
def walk_speed_from_sidecar(sidecar) -> tuple[float, float]:
|
| 185 |
+
"""从 sidecar(路径或已加载的 dict)取实测步速 (px/s)。默认常量只是它的冻结副本。"""
|
| 186 |
+
from actionrope.dataset import load_sidecar
|
| 187 |
+
|
| 188 |
+
meta = load_sidecar(sidecar)["meta"]
|
| 189 |
+
ws = meta["walk_speed_px_s"]
|
| 190 |
+
if len(ws) != 2:
|
| 191 |
+
raise ValueError(f"sidecar meta 的 walk_speed_px_s 应为 2 个数,得到 {ws!r}")
|
| 192 |
+
return float(ws[0]), float(ws[1])
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def append_cell_velocity(offset_px: torch.Tensor, vel: torch.Tensor) -> torch.Tensor:
|
| 196 |
+
"""把逐 cell 速度 vel [b, m, 2](px/s)接到 offset_px [b, F0, 2] 后面 → [b, F0+m, 2]。
|
| 197 |
+
|
| 198 |
+
用模块 docstring 的 (1.5·v_prev + 2.5·v_cur)/FPS 增量积分,即帧级积分 + cell 内均值的
|
| 199 |
+
闭式解。第一个新 cell 需要"上一 cell 的速度",而数据只给了偏移,所以从最后一段已知增量
|
| 200 |
+
反推匀速等效值 v_prev(匀速时精确;变速时那一格的误差 ≤ 1.5·|Δv|/16)。
|
| 201 |
+
"""
|
| 202 |
+
off = _as_bf2(offset_px, "offset_px")
|
| 203 |
+
if vel.dim() != 3 or vel.shape[0] != off.shape[0] or vel.shape[2] != 2:
|
| 204 |
+
raise ValueError(f"vel 形状应为 [{off.shape[0]}, m, 2],得到 {tuple(vel.shape)}")
|
| 205 |
+
b, f0 = off.shape[:2]
|
| 206 |
+
if f0 < 1:
|
| 207 |
+
raise ValueError("offset_px 至少要有 cell 0(形状 [b, ≥1, 2])")
|
| 208 |
+
m = vel.shape[1]
|
| 209 |
+
if m == 0:
|
| 210 |
+
return off.clone()
|
| 211 |
+
vel = vel.to(device=off.device, dtype=off.dtype)
|
| 212 |
+
|
| 213 |
+
v_prev = _implied_last_velocity(off) # [b, 2] px/s
|
| 214 |
+
out = [off]
|
| 215 |
+
cur = off[:, -1] # [b, 2] 最后一个已知 cell 的累计偏移
|
| 216 |
+
k = f0 # 即将写入的绝对 cell 下标
|
| 217 |
+
for j in range(m):
|
| 218 |
+
v = vel[:, j]
|
| 219 |
+
if k == 0:
|
| 220 |
+
raise ValueError("cell 0 的偏移恒为 0,不能由速度生成")
|
| 221 |
+
if k == 1:
|
| 222 |
+
# cell 0 只有帧 0,起步项只有 2.5 那一半
|
| 223 |
+
delta = (_W_CUR * v) / FPS
|
| 224 |
+
else:
|
| 225 |
+
delta = (_W_PREV * v_prev + _W_CUR * v) / FPS
|
| 226 |
+
cur = cur + delta
|
| 227 |
+
out.append(cur.unsqueeze(1))
|
| 228 |
+
v_prev = v
|
| 229 |
+
k += 1
|
| 230 |
+
return torch.cat(out, dim=1)
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def extend_offsets(offset_px: torch.Tensor, N: int, mode: str = "resample",
|
| 234 |
+
chunk: int = DEFAULT_CHUNK_CELLS, rng: torch.Generator | None = None,
|
| 235 |
+
walk_speed=None, speed_mult: tuple[float, float] = DEFAULT_SPEED_MULT,
|
| 236 |
+
include_idle: bool = True) -> torch.Tensor:
|
| 237 |
+
"""把 [b, F0, 2] 的逐 cell 偏移延长/裁剪到 rollout 需要的 [b, N+1, 2]。
|
| 238 |
+
|
| 239 |
+
N = rollout 生成的 cell 数(不含 cell 0),所以长度是 1+N,与
|
| 240 |
+
`dmd_gm.py:628-637` 的 `prompts.shape[1] == 1 + N` 口径一致。
|
| 241 |
+
1+N ≤ F0 时就是切片(原样保留真值),不会引入任何合成运动。
|
| 242 |
+
|
| 243 |
+
mode:
|
| 244 |
+
* "resample"(默认)—— 每 `chunk` 个 cell 抽一次动作:方向取 `geometry.ACTION_DIRS`
|
| 245 |
+
的 9 类之一(含 idle),速度按轴取 walk_speed × 倍率([0.8, 1.2] 均匀),
|
| 246 |
+
与 `infer.py::parse_actions` 的 `ACTION_DIRS[i] * walk_speed * mult` 完全同构,
|
| 247 |
+
对应 ReactiveGWM 的 `dmd_action_extend_mode: preset_chunks`。逐样本独立抽。
|
| 248 |
+
* "continue" —— 沿用最后一段已知增量隐含的匀速(等价于把 delta_tok[-1] 一路重复)。
|
| 249 |
+
|
| 250 |
+
rng: CPU 上的 torch.Generator。抽样一律在 CPU 上做再搬到 offset_px.device,
|
| 251 |
+
这样 4 张卡上的种子行为与设备无关;各 rank 抽到不同方向没关系(数据本来就不同),
|
| 252 |
+
**形状**由 rank-synced 的 N 决定,FSDP 的 all-gather 不会错位。
|
| 253 |
+
"""
|
| 254 |
+
off = _as_bf2(offset_px, "offset_px")
|
| 255 |
+
b, f0 = off.shape[:2]
|
| 256 |
+
if N < 0:
|
| 257 |
+
raise ValueError(f"N 应 ≥ 0,得到 {N}")
|
| 258 |
+
want = N + 1
|
| 259 |
+
if want <= f0:
|
| 260 |
+
return off[:, :want].contiguous()
|
| 261 |
+
m = want - f0
|
| 262 |
+
|
| 263 |
+
if mode == "continue":
|
| 264 |
+
v = _implied_last_velocity(off).unsqueeze(1).expand(b, m, 2) # [b, m, 2]
|
| 265 |
+
elif mode == "resample":
|
| 266 |
+
v = _sample_chunk_velocity(b, m, chunk=chunk, rng=rng, walk_speed=walk_speed,
|
| 267 |
+
speed_mult=speed_mult, include_idle=include_idle)
|
| 268 |
+
v = v.to(device=off.device, dtype=off.dtype)
|
| 269 |
+
else:
|
| 270 |
+
raise ValueError(f"未知 mode {mode!r},可选 'resample' | 'continue'")
|
| 271 |
+
ext = append_cell_velocity(off, v)
|
| 272 |
+
assert ext.shape == (b, want, 2), f"延长失败: {tuple(ext.shape)} != {(b, want, 2)}"
|
| 273 |
+
return ext
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
# --------------------------------------------------------------------------
|
| 277 |
+
# rollout 延长:文本
|
| 278 |
+
# --------------------------------------------------------------------------
|
| 279 |
+
|
| 280 |
+
def extend_context(context: torch.Tensor, context_ids: torch.Tensor, N: int, mode: str = "hold"):
|
| 281 |
+
"""把 [b, F0, L, C] 的逐 cell 文本与 [b, F0] 的 id 延长/裁剪到 1+N 个 cell。
|
| 282 |
+
|
| 283 |
+
返回 (context_ext [b, 1+N, L, C], ids_ext [b, 1+N]),与 `extend_offsets` 逐下标对齐。
|
| 284 |
+
|
| 285 |
+
mode="hold"(唯一实现):cell ≥ F0 一律复制**最后一条已写好的** cell 文本及其 id,
|
| 286 |
+
照抄 `_wm/WorldMind/distill/code/stage3_dmd/dmd_gm.py:628-637`。ARoPE 臂的 prompt 已被
|
| 287 |
+
`prompts.strip_action` 剥成"只剩场景",rollout 又不换场景,所以保持是语义正确的;
|
| 288 |
+
id 必须跟着一起保持,`arope_forward` 靠它去重 text_embedding,id 错了就会拿错一行文本。
|
| 289 |
+
转场语料(burn / hardcut)的 cell 20 已经是烧完之后的目标场景,保持它同样正确。
|
| 290 |
+
|
| 291 |
+
注意:文本"保持"而偏移"继续走",两者策略不同是**有意的**(见模块 docstring),
|
| 292 |
+
不是这里漏了一种模式。
|
| 293 |
+
"""
|
| 294 |
+
ctx, ids = _check_context(context, context_ids)
|
| 295 |
+
b, f0 = ctx.shape[:2]
|
| 296 |
+
if N < 0:
|
| 297 |
+
raise ValueError(f"N 应 ≥ 0,得到 {N}")
|
| 298 |
+
if mode != "hold":
|
| 299 |
+
raise ValueError(f"未知 mode {mode!r}:文本只实现 'hold'(保持最后一条已写好的 cell 文本)")
|
| 300 |
+
want = N + 1
|
| 301 |
+
if want <= f0:
|
| 302 |
+
return ctx[:, :want].contiguous(), ids[:, :want].contiguous()
|
| 303 |
+
pad_n = want - f0
|
| 304 |
+
ctx_pad = ctx[:, f0 - 1:f0].expand(b, pad_n, *ctx.shape[2:])
|
| 305 |
+
ids_pad = ids[:, f0 - 1:f0].expand(b, pad_n)
|
| 306 |
+
ctx_ext = torch.cat([ctx, ctx_pad], dim=1)
|
| 307 |
+
ids_ext = torch.cat([ids, ids_pad], dim=1)
|
| 308 |
+
assert ctx_ext.shape[1] == want and ids_ext.shape[1] == want, (
|
| 309 |
+
f"文本延长失败: {ctx_ext.shape[1]}/{ids_ext.shape[1]} != {want}")
|
| 310 |
+
return ctx_ext, ids_ext
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
def assert_rollout_aligned(offset_ext: torch.Tensor, context_ext: torch.Tensor,
|
| 314 |
+
ids_ext: torch.Tensor, N: int, latent=None) -> None:
|
| 315 |
+
"""rollout 前的对齐闸:三条(可选四条)流的 cell 数必须都是 1+N,且 batch 一致。
|
| 316 |
+
|
| 317 |
+
stage3 每次 rollout 之前、每次 teacher/critic forward 之前都调一下——这类错位
|
| 318 |
+
不会报错、只会让 loss 照降而训错东西,所以要用断言把它变响。
|
| 319 |
+
"""
|
| 320 |
+
want = N + 1
|
| 321 |
+
b = offset_ext.shape[0]
|
| 322 |
+
if offset_ext.shape != (b, want, 2):
|
| 323 |
+
raise AssertionError(f"offset 形状 {tuple(offset_ext.shape)} != {(b, want, 2)}")
|
| 324 |
+
if context_ext.shape[:2] != (b, want):
|
| 325 |
+
raise AssertionError(f"context 形状 {tuple(context_ext.shape)} 前两维 != {(b, want)}")
|
| 326 |
+
if tuple(ids_ext.shape) != (b, want):
|
| 327 |
+
raise AssertionError(f"context_ids 形状 {tuple(ids_ext.shape)} != {(b, want)}")
|
| 328 |
+
if latent is not None and (latent.shape[0] != b or latent.shape[2] != want):
|
| 329 |
+
raise AssertionError(f"latent 形状 {tuple(latent.shape)} 与 [b={b}, ·, f={want}, ·, ·] 不符")
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
# --------------------------------------------------------------------------
|
| 333 |
+
# 内部
|
| 334 |
+
# --------------------------------------------------------------------------
|
| 335 |
+
|
| 336 |
+
def _as_bf2(off: torch.Tensor, name: str) -> torch.Tensor:
|
| 337 |
+
if not isinstance(off, torch.Tensor):
|
| 338 |
+
raise TypeError(f"{name} 应为 Tensor,得到 {type(off)}")
|
| 339 |
+
if off.dim() != 3 or off.shape[-1] != 2:
|
| 340 |
+
raise ValueError(f"{name} 形状应为 [b, f, 2],得到 {tuple(off.shape)}")
|
| 341 |
+
return off
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def _check_context(context: torch.Tensor, context_ids: torch.Tensor):
|
| 345 |
+
if context.dim() != 4:
|
| 346 |
+
raise ValueError(f"context 形状应为 [b, f, L, C],得到 {tuple(context.shape)}")
|
| 347 |
+
if context_ids.dim() != 2:
|
| 348 |
+
raise ValueError(f"context_ids 形状应为 [b, f],得到 {tuple(context_ids.shape)}")
|
| 349 |
+
if context.shape[:2] != context_ids.shape:
|
| 350 |
+
raise ValueError(f"context {tuple(context.shape)[:2]} 与 context_ids "
|
| 351 |
+
f"{tuple(context_ids.shape)} 的 (b, f) 不一致")
|
| 352 |
+
return context, context_ids
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
def _implied_last_velocity(off: torch.Tensor) -> torch.Tensor:
|
| 356 |
+
"""从最后一段已知增量反推匀速等效速度 [b, 2](px/s)。
|
| 357 |
+
|
| 358 |
+
匀速时 delta[k] = 4·v/FPS(k ≥ 2),delta[1] = 2.5·v/FPS;F0 = 1(只有 cell 0)时
|
| 359 |
+
没有任何增量可用,只能取 0(此时 'continue' 退化成"站住不动",调用方应当用 'resample')。
|
| 360 |
+
"""
|
| 361 |
+
b, f0 = off.shape[:2]
|
| 362 |
+
if f0 < 2:
|
| 363 |
+
return off.new_zeros(b, 2)
|
| 364 |
+
delta = off[:, -1] - off[:, -2]
|
| 365 |
+
w = _W_CUR if f0 == 2 else (_W_PREV + _W_CUR) # 2.5(cell 1) / 4.0(cell ≥ 2)
|
| 366 |
+
return delta * (FPS / w)
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def _sample_chunk_velocity(b: int, m: int, chunk: int, rng: torch.Generator | None,
|
| 370 |
+
walk_speed, speed_mult: tuple[float, float],
|
| 371 |
+
include_idle: bool) -> torch.Tensor:
|
| 372 |
+
"""逐样本、每 chunk 个 cell 抽一次动作 → 逐 cell 速度 [b, m, 2](px/s,float32 CPU)。"""
|
| 373 |
+
if chunk < 1:
|
| 374 |
+
raise ValueError(f"chunk 应 ≥ 1,得到 {chunk}")
|
| 375 |
+
if rng is not None and rng.device.type != "cpu":
|
| 376 |
+
raise ValueError("rng 必须是 CPU 上的 torch.Generator(抽样固定在 CPU 做,保证跨设备一致)")
|
| 377 |
+
lo, hi = float(speed_mult[0]), float(speed_mult[1])
|
| 378 |
+
if not 0.0 <= lo <= hi:
|
| 379 |
+
raise ValueError(f"speed_mult 应为 0 ≤ lo ≤ hi,得到 {speed_mult!r}")
|
| 380 |
+
ws = WALK_SPEED_PX_S if walk_speed is None else walk_speed
|
| 381 |
+
ws = torch.tensor([float(ws[0]), float(ws[1])], dtype=torch.float32)
|
| 382 |
+
dirs = torch.from_numpy(np.asarray(ACTION_DIRS, dtype=np.float32)) # [9, 2] 单位方向,0 = idle
|
| 383 |
+
n_dir = dirs.shape[0]
|
| 384 |
+
lo_idx = 0 if include_idle else 1
|
| 385 |
+
|
| 386 |
+
n_chunk = math.ceil(m / chunk)
|
| 387 |
+
idx = torch.randint(lo_idx, n_dir, (b, n_chunk), generator=rng) # [b, n_chunk]
|
| 388 |
+
mult = torch.rand((b, n_chunk, 1), generator=rng) * (hi - lo) + lo # [b, n_chunk, 1]
|
| 389 |
+
v_chunk = dirs[idx] * ws.view(1, 1, 2) * mult # [b, n_chunk, 2]
|
| 390 |
+
# 每个 chunk 的速度铺满它覆盖的 cell(最后一个 chunk 可能不满,截断)
|
| 391 |
+
return v_chunk.repeat_interleave(chunk, dim=1)[:, :m].contiguous()
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
__all__ = [
|
| 395 |
+
"ACTION_DIRS", "CELLS", "CELL_FRAMES", "DEFAULT_CHUNK_CELLS", "DEFAULT_SPEED_MULT",
|
| 396 |
+
"WALK_SPEED_PX_S", "append_cell_velocity", "assert_rollout_aligned", "extend_context",
|
| 397 |
+
"extend_offsets", "make_mask_input", "make_weight_map", "to_device",
|
| 398 |
+
"walk_speed_from_sidecar", "window_context", "window_offsets",
|
| 399 |
+
]
|
backup_2026-09-14/code/distill/ema.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""生成器 EMA:fp32 影子权重(stage2 CD 的目标网络 / stage2·stage3 的部署权重)。
|
| 3 |
+
|
| 4 |
+
from distill.ema import EmaShadow, ema_update
|
| 5 |
+
ema = EmaShadow(build_arope_dit(...), device="cuda") # 不进 accelerator.prepare
|
| 6 |
+
...
|
| 7 |
+
ema.update(student, decay=(0.0 if step < args.ema_start else args.ema_decay))
|
| 8 |
+
runlib.save_dit(accel, ema, runlib.ema_path(run_dir, step), {"step": step, "ema_decay": decay})
|
| 9 |
+
|
| 10 |
+
★ 影子必须是 fp32。bf16 只有 8 位尾数,相对精度 ~2^-8 ≈ 0.4%:decay=0.99 时
|
| 11 |
+
`pe = 0.99*pe + 0.01*p`,那 1% 的增量落在 pe 尾数之下会被舍入直接吃掉 —— 影子权重在数值上
|
| 12 |
+
纹丝不动,loss 却一切正常(CD 的 loss 本来就从 0 开始涨)。这不是精度损失,是目标网络失效。
|
| 13 |
+
tests/test_runlib.py::test_bf16_shadow_drops_the_update 把这条钉成了断言。
|
| 14 |
+
fp32 影子只是存储;前向照样在 autocast(bf16) 下跑,算力开销不变(多 20 GB 显存,见 PORT_SPEC 预算表)。
|
| 15 |
+
|
| 16 |
+
★ EmaShadow 故意**不是** nn.Module。accelerate 的 prepare 会 `for obj in result: if isinstance(obj, nn.Module): model = obj`
|
| 17 |
+
(accelerator.py:2229-2236)—— 最后一个 nn.Module 静默胜出,其余一个警告都没有就没被包装。
|
| 18 |
+
影子不是 nn.Module 就绝不可能被误传进 prepare、被卷进优化器的参数表、或者混进上层模块的 state_dict。
|
| 19 |
+
它照样能当模型调用(__call__ 转发给内部 module)。
|
| 20 |
+
|
| 21 |
+
stage3 的 EMA 是 FSDP 包装的 AropeDiT(要按分片 zip),不走 EmaShadow,直接用模块级的
|
| 22 |
+
ema_update(ema, student, decay);两条路的数学完全一样。
|
| 23 |
+
"""
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import copy
|
| 27 |
+
|
| 28 |
+
import torch
|
| 29 |
+
import torch.nn as nn
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _bare(model: nn.Module) -> nn.Module:
|
| 33 |
+
"""剥掉 DDP / DeepSpeedEngine / EmaShadow 的外壳。"""
|
| 34 |
+
seen = 0
|
| 35 |
+
while hasattr(model, "module") and isinstance(getattr(model, "module"), nn.Module) and seen < 8:
|
| 36 |
+
model = model.module
|
| 37 |
+
seen += 1
|
| 38 |
+
return model
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@torch.no_grad()
|
| 42 |
+
def ema_update(ema: nn.Module, student, decay: float) -> None:
|
| 43 |
+
"""逐参数 EMA:`pe = decay*pe + (1-decay)*p`,decay <= 0 ⇒ 硬拷贝。
|
| 44 |
+
|
| 45 |
+
zip 成立的前提:两边暴露的是**同一组、同一顺序、同一分片方式**的 Parameter。
|
| 46 |
+
ZeRO-2 不分片参数,天然成立;FSDP 要 fsdp_use_orig_params=true(PORT_SPEC R5)。
|
| 47 |
+
这里额外核对个数与逐个形状,顺序错位在绝大多数情况下会当场炸出来,而不是静默训歪。
|
| 48 |
+
student 可以是 DeepSpeedEngine / DDP —— nn.Module 外壳的 parameters() 顺序与内部一致。"""
|
| 49 |
+
pe_list = list(ema.parameters())
|
| 50 |
+
ps_list = list(student.parameters())
|
| 51 |
+
if len(pe_list) != len(ps_list):
|
| 52 |
+
raise RuntimeError(f"EMA 参数个数不匹配:影子 {len(pe_list)} vs 学生 {len(ps_list)}")
|
| 53 |
+
for i, (pe, p) in enumerate(zip(pe_list, ps_list)):
|
| 54 |
+
if pe.shape != p.shape:
|
| 55 |
+
raise RuntimeError(f"EMA 第 {i} 个参数形状不匹配:影子 {tuple(pe.shape)} vs 学生 {tuple(p.shape)}")
|
| 56 |
+
src = p.data
|
| 57 |
+
if src.device != pe.device:
|
| 58 |
+
src = src.to(pe.device, non_blocking=True)
|
| 59 |
+
if decay <= 0.0:
|
| 60 |
+
pe.data.copy_(src) # 硬拷贝(step < ema_start)
|
| 61 |
+
else:
|
| 62 |
+
# dtype 由 TensorIterator 就地提升:src 是 bf16 也不会额外分配一份 fp32
|
| 63 |
+
pe.data.mul_(decay).add_(src, alpha=1.0 - decay)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class EmaShadow:
|
| 67 |
+
"""fp32 影子权重。不是 nn.Module(见文件头),但能当模型直接调用。
|
| 68 |
+
|
| 69 |
+
EmaShadow(module) —— deepcopy 一份,源模型归调用方(多占一份显存/内存)
|
| 70 |
+
EmaShadow(module, own=True) —— 直接接管这个 module(stage2 已经单独建了第三个模型时用这个,
|
| 71 |
+
不再多复制一份 5B 权重)
|
| 72 |
+
device="cpu" 可以把影子放在主存里(ReactiveGWM 的 *_ema_shadow_device: cpu),
|
| 73 |
+
update 会自动把学生的分片搬过来;但前向就得先 .to("cuda"),stage2 默认放卡上。
|
| 74 |
+
"""
|
| 75 |
+
|
| 76 |
+
def __init__(self, module: nn.Module, *, device=None, own: bool = False, dtype: torch.dtype = torch.float32):
|
| 77 |
+
if dtype != torch.float32:
|
| 78 |
+
raise ValueError("影子必须是 fp32:bf16 在 decay=0.99 下会把 1% 的增量整个舍掉(见文件头)")
|
| 79 |
+
src = _bare(module)
|
| 80 |
+
self.module = src if own else copy.deepcopy(src)
|
| 81 |
+
self.module.to(dtype=dtype)
|
| 82 |
+
if device is not None:
|
| 83 |
+
self.module.to(device=device)
|
| 84 |
+
self.module.requires_grad_(False).eval()
|
| 85 |
+
self.dtype = dtype
|
| 86 |
+
self._assert_fp32()
|
| 87 |
+
|
| 88 |
+
# ---- 数值 ----
|
| 89 |
+
def _assert_fp32(self) -> None:
|
| 90 |
+
bad = [n for n, p in self.module.named_parameters() if p.dtype is not torch.float32]
|
| 91 |
+
if bad:
|
| 92 |
+
raise RuntimeError(f"影子权重不是 fp32:{bad[:5]}…({len(bad)})")
|
| 93 |
+
|
| 94 |
+
@torch.no_grad()
|
| 95 |
+
def update(self, student, decay: float) -> None:
|
| 96 |
+
"""一个 optimizer step 调一次(不是每个 micro-batch)。decay <= 0 ⇒ 硬拷贝。"""
|
| 97 |
+
ema_update(self.module, student, decay)
|
| 98 |
+
|
| 99 |
+
@torch.no_grad()
|
| 100 |
+
def hard_copy(self, student) -> None:
|
| 101 |
+
"""影子 ← 学生。step < ema_start 期间每步都调,等价于 update(student, 0.0)。"""
|
| 102 |
+
ema_update(self.module, student, 0.0)
|
| 103 |
+
|
| 104 |
+
# ---- 当模型用 ----
|
| 105 |
+
def __call__(self, *args, **kwargs):
|
| 106 |
+
return self.module(*args, **kwargs)
|
| 107 |
+
|
| 108 |
+
def forward(self, *args, **kwargs):
|
| 109 |
+
return self.module(*args, **kwargs)
|
| 110 |
+
|
| 111 |
+
def parameters(self, recurse: bool = True):
|
| 112 |
+
return self.module.parameters(recurse=recurse)
|
| 113 |
+
|
| 114 |
+
def named_parameters(self, *args, **kwargs):
|
| 115 |
+
return self.module.named_parameters(*args, **kwargs)
|
| 116 |
+
|
| 117 |
+
def buffers(self, recurse: bool = True):
|
| 118 |
+
return self.module.buffers(recurse=recurse)
|
| 119 |
+
|
| 120 |
+
def to(self, *args, **kwargs):
|
| 121 |
+
"""只搬设备。dtype 不许动:.to(torch.bfloat16) 会当场毁掉目标网络。
|
| 122 |
+
|
| 123 |
+
注意 dit.freqs 是普通的 tuple 属性(不是 buffer),Module.to 搬不动它 ——
|
| 124 |
+
影子搬完设备后调用方要照 train.py:461 那样补一句
|
| 125 |
+
`ema.module.dit.freqs = tuple(t.to(device) for t in ema.module.dit.freqs)`。"""
|
| 126 |
+
if any(isinstance(a, torch.dtype) for a in args) or kwargs.get("dtype") is not None:
|
| 127 |
+
raise RuntimeError("影子权重的 dtype 不许改(fp32),只搬设备")
|
| 128 |
+
self.module.to(*args, **kwargs)
|
| 129 |
+
return self
|
| 130 |
+
|
| 131 |
+
def eval(self):
|
| 132 |
+
self.module.eval()
|
| 133 |
+
return self
|
| 134 |
+
|
| 135 |
+
def train(self, mode: bool = True):
|
| 136 |
+
"""影子永远是 eval —— 显式挡住 train(),免得被跟着学生一起 .train() 打开 dropout。"""
|
| 137 |
+
if mode:
|
| 138 |
+
raise RuntimeError("EMA 影子不训练,别对它调 train()")
|
| 139 |
+
return self.eval()
|
| 140 |
+
|
| 141 |
+
# ---- 导出 ----
|
| 142 |
+
def state_dict(self, *args, **kwargs):
|
| 143 |
+
"""直接透传内部 module 的键集(不带 "module." 前缀),
|
| 144 |
+
这样 runlib.save_dit(accel, ema, ...) 剥掉 `dit.` 之后得到的就是标准的 827 键。"""
|
| 145 |
+
return self.module.state_dict(*args, **kwargs)
|
| 146 |
+
|
| 147 |
+
def load_state_dict(self, *args, **kwargs):
|
| 148 |
+
out = self.module.load_state_dict(*args, **kwargs)
|
| 149 |
+
self._assert_fp32()
|
| 150 |
+
return out
|
| 151 |
+
|
| 152 |
+
def export_state_dict(self, dtype: torch.dtype = torch.bfloat16) -> dict:
|
| 153 |
+
"""给不走 runlib.save_dit 的场合用:CPU + contiguous + 指定 dtype。"""
|
| 154 |
+
return {k: v.detach().to(device="cpu", dtype=dtype).contiguous()
|
| 155 |
+
for k, v in self.module.state_dict().items()}
|
| 156 |
+
|
| 157 |
+
def __repr__(self):
|
| 158 |
+
n = sum(p.numel() for p in self.module.parameters())
|
| 159 |
+
dev = next(self.module.parameters()).device if n else "?"
|
| 160 |
+
return f"EmaShadow({type(self.module).__name__}, {n:,} params, fp32, {dev})"
|
backup_2026-09-14/code/distill/eval/anystep_probe.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""stage2-flowmap 的**必跑前置门**:单卡量一个真实 micro batch。
|
| 2 |
+
|
| 3 |
+
`distill/stage2_flowmap/config.json` 的 `_gate` 把这个探针列为 launch 前的必要条件。理由很直接:
|
| 4 |
+
`~75 GB/卡` 和 `~14 s/step` 到此为止都是**推算**,从没有任何代码在真实几何(f=21、16380 token、
|
| 5 |
+
5B 权重、4 次前向 + 1 次反向)上量过。用一张卡量到,好过用 4 张(或 8 张)卡去撞 OOM。
|
| 6 |
+
|
| 7 |
+
三件事,都在 GPU 0 上、都不写权重:
|
| 8 |
+
|
| 9 |
+
S-FM10 真权重恒等 —— 装上 flow map 之后,对 r ∈ {t, 0, 250, 1000} 与装之前逐位相同。
|
| 10 |
+
这是整条链的回归锚点:它一旦不成立,后面训的就不是 stage1b 那个教师了。
|
| 11 |
+
S-FM11 一个真实 micro batch 的峰值显存与墙钟(走 **fm_loss 本体**,不是复刻一份)。
|
| 12 |
+
S-FM12 中心差分的 bf16 噪声底 —— 实测 (u_p − u_m) 的信噪比。fidelity 评审记录过:
|
| 13 |
+
探针在生产里被量化到 bf16,实际间隔是 8–12 而非名义的 10,分母却用字面 2·eps,
|
| 14 |
+
于是 dF/dt 有平均 16.6 % 的系统性尺度误差。这个是**继承自上游、故意不改**的,
|
| 15 |
+
但它到底有多大,得有个数。
|
| 16 |
+
|
| 17 |
+
CUDA_VISIBLE_DEVICES=0 PYTHONPATH=/data/zhiyangdeng/ActionRoPE DIFFSYNTH_SKIP_DOWNLOAD=True \
|
| 18 |
+
.venv/bin/python distill/eval/anystep_probe.py
|
| 19 |
+
|
| 20 |
+
单卡跑不出 4/8 卡的 ZeRO-2 分片行为(优化器状态是分片的),所以这里报的是**单进程上界**:
|
| 21 |
+
参数 + 梯度 + 激活 + Adam 全在一张卡上。多卡只会更省。反过来说,**单卡装不下不代表多卡装不下**,
|
| 22 |
+
所以 OOM 时脚本会把分片后的估算一并打出来,而不是直接判死刑。
|
| 23 |
+
"""
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import argparse
|
| 27 |
+
import os
|
| 28 |
+
import sys
|
| 29 |
+
import time
|
| 30 |
+
|
| 31 |
+
os.environ.setdefault("DIFFSYNTH_SKIP_DOWNLOAD", "True")
|
| 32 |
+
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
| 33 |
+
|
| 34 |
+
import torch
|
| 35 |
+
|
| 36 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 37 |
+
if ROOT not in sys.path:
|
| 38 |
+
sys.path.insert(0, ROOT)
|
| 39 |
+
|
| 40 |
+
from actionrope.dataset import AropeLatentDataset, arope_collate, load_sidecar # noqa: E402
|
| 41 |
+
from distill import flowmap as fm # noqa: E402
|
| 42 |
+
from distill import runlib # noqa: E402
|
| 43 |
+
from distill.arope_dit import AropeDiT, unwrap # noqa: E402
|
| 44 |
+
from distill.data import to_device # noqa: E402
|
| 45 |
+
from distill.stage2_flowmap.fm_arope import _tf_probe, fm_loss # noqa: E402
|
| 46 |
+
|
| 47 |
+
GB = 1024 ** 3
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _mem(dev) -> float:
|
| 51 |
+
return torch.cuda.max_memory_allocated(dev) / GB if dev.type == "cuda" else 0.0
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def main():
|
| 55 |
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 56 |
+
ap.add_argument("--init_ckpt", default=f"{ROOT}/outputs/distill/stage1b_adapt/dits/dit_step3000.safetensors")
|
| 57 |
+
ap.add_argument("--train_dir", default=f"{ROOT}/data/latent/train_eybx")
|
| 58 |
+
ap.add_argument("--sidecar", default=f"{ROOT}/data/latent/arope_sidecar.pt")
|
| 59 |
+
ap.add_argument("--text_table", default=f"{ROOT}/data/latent/text_table_actionrope.pt")
|
| 60 |
+
ap.add_argument("--flowmap_form", default="residual", choices=["residual", "convex"])
|
| 61 |
+
ap.add_argument("--delta_gate", type=float, default=1.0)
|
| 62 |
+
ap.add_argument("--flowmap_epsilon", type=float, default=5.0)
|
| 63 |
+
ap.add_argument("--cfg_scale", type=float, default=3.0, help="与 run.sh 默认一致:开着文本 fuse 才量得到真实前向数")
|
| 64 |
+
ap.add_argument("--iters", type=int, default=3, help="计时取后 iters-1 次(第 1 次含 cudnn/分配器预热)")
|
| 65 |
+
ap.add_argument("--device", default="cuda")
|
| 66 |
+
ap.add_argument("--skip_backward", action="store_true", help="只量前向(调试用;默认量 fwd+bwd)")
|
| 67 |
+
args = ap.parse_args()
|
| 68 |
+
|
| 69 |
+
dev = torch.device(args.device)
|
| 70 |
+
log = print
|
| 71 |
+
|
| 72 |
+
# ---------------- S-FM10 真权重恒等 ----------------
|
| 73 |
+
log("=" * 70)
|
| 74 |
+
log("S-FM10 真权重恒等(装 flow map 前后,对任意 r 逐位相同)")
|
| 75 |
+
from safetensors.torch import load_file
|
| 76 |
+
model = AropeDiT(causal=True, mask_channel=True).to(dev).eval()
|
| 77 |
+
unwrap(model).dit.load_state_dict(load_file(args.init_ckpt), strict=True)
|
| 78 |
+
model = model.to(torch.bfloat16)
|
| 79 |
+
unwrap(model).move_rope_table(dev)
|
| 80 |
+
n_before = len(unwrap(model).dit.state_dict())
|
| 81 |
+
|
| 82 |
+
pre = _tf_probe(unwrap(model), 500.0, None) # 装之前的参照
|
| 83 |
+
unwrap(model).install_flowmap(form=args.flowmap_form, gate=args.delta_gate)
|
| 84 |
+
unwrap(model).to(dev)
|
| 85 |
+
n_after = len(unwrap(model).dit.state_dict())
|
| 86 |
+
|
| 87 |
+
worst = 0.0
|
| 88 |
+
for r in (500.0, 0.0, 250.0, 1000.0):
|
| 89 |
+
post = _tf_probe(unwrap(model), 500.0, r)
|
| 90 |
+
d = (post.float() - pre.float()).abs().max().item()
|
| 91 |
+
worst = max(worst, d)
|
| 92 |
+
log(f" t=500 r={r:7.1f} max|Δ| = {d:.3e}")
|
| 93 |
+
log(f" 键数 {n_before} → {n_after} 最大偏差 {worst:.3e}")
|
| 94 |
+
if args.flowmap_form == "residual" and worst != 0.0:
|
| 95 |
+
raise SystemExit(f"S-FM10 失败:residual 形式必须对任意 r ��位相同,实测 {worst:.3e}。"
|
| 96 |
+
f"这说明 delta 分支的读出层没有真的零初始化 —— 别往下跑。")
|
| 97 |
+
log(" ✅ 通过" if worst == 0.0 else f" ⚠️ convex 形式 off-diagonal 有偏差(预期内)")
|
| 98 |
+
|
| 99 |
+
# ---------------- 真实 micro batch ----------------
|
| 100 |
+
log("=" * 70)
|
| 101 |
+
log("S-FM11 一个真实 micro batch 的显存与墙钟(走 fm_loss 本体)")
|
| 102 |
+
sc = load_sidecar(args.sidecar)
|
| 103 |
+
ds = AropeLatentDataset(args.train_dir, sc, args.text_table, text_mode="scene",
|
| 104 |
+
scene_dropout=0.0, limit=8, min_rt_ratio=0.8, verbose=False)
|
| 105 |
+
batch = to_device(arope_collate([ds[0]]), dev)
|
| 106 |
+
log(f" latent {tuple(batch['input_latents'].shape)} context {tuple(batch['context'].shape)}")
|
| 107 |
+
|
| 108 |
+
uncond = None
|
| 109 |
+
if abs(args.cfg_scale - 1.0) >= 1e-6:
|
| 110 |
+
from actionrope.prompts import NULL_PROMPT
|
| 111 |
+
table = torch.load(args.text_table, map_location="cpu", weights_only=False)
|
| 112 |
+
row = table[NULL_PROMPT].to(torch.bfloat16)
|
| 113 |
+
uncond = row.view(1, 1, *row.shape).to(dev).expand(1, batch["context"].shape[1], -1, -1)
|
| 114 |
+
gains = {"text": args.cfg_scale - 1.0, "anchor": 0.0, "action": 0.0, "hist": 0.0, "ctx": 0.0}
|
| 115 |
+
|
| 116 |
+
model.train()
|
| 117 |
+
for p in unwrap(model).parameters():
|
| 118 |
+
p.requires_grad_(True)
|
| 119 |
+
|
| 120 |
+
# 全局 batch 必须按真实值抽:G=1 时 round(0.5*1)=0,diffusion 槽为空,sample_tr 会(正确地)断言。
|
| 121 |
+
# 真实跑法是 4 卡 GA2 或 8 卡 GA1,两者全局 batch 都是 8。这里用 world_size=8 复现,
|
| 122 |
+
# 并遍历 rank 覆盖三种角色 —— 三者代价不同:diffusion 槽(r==t)的中心差分不进 loss,
|
| 123 |
+
# 自由 (t,r) 槽最贵。报告取**最贵的那个**,因为 OOM 只需要发生一次。
|
| 124 |
+
WORLD = 8
|
| 125 |
+
gen = torch.Generator(device="cpu").manual_seed(0)
|
| 126 |
+
split_gen = fm.make_split_generator(0)
|
| 127 |
+
per_role: dict[int, list] = {}
|
| 128 |
+
ROLE_NAME = {fm.ROLE_DIFFUSION: "diffusion(r==t)", fm.ROLE_CONSISTENCY: "consistency(r=0)",
|
| 129 |
+
fm.ROLE_RANDOM: "random(t,r)"}
|
| 130 |
+
n_iter = max(args.iters, 1)
|
| 131 |
+
for i in range(n_iter * WORLD):
|
| 132 |
+
rank = i % WORLD
|
| 133 |
+
g_i = torch.Generator(device="cpu").manual_seed(1000 + i)
|
| 134 |
+
sg_i = fm.make_split_generator(i)
|
| 135 |
+
tr = fm.sample_tr(1, world_size=WORLD, rank=rank, gen=g_i, split_gen=sg_i,
|
| 136 |
+
diffusion_ratio=0.5, consistency_ratio=0.25, rotate=True, device=dev)
|
| 137 |
+
role = int(tr.role[0]) if hasattr(tr, "role") else fm.ROLE_RANDOM
|
| 138 |
+
torch.cuda.reset_peak_memory_stats(dev)
|
| 139 |
+
unwrap(model).zero_grad(set_to_none=True)
|
| 140 |
+
torch.cuda.synchronize(dev)
|
| 141 |
+
t0 = time.time()
|
| 142 |
+
loss_b, stats = fm_loss(model, batch, tr, gains=gains,
|
| 143 |
+
uncond=uncond,
|
| 144 |
+
uncond_ids=(None if uncond is None else torch.zeros_like(batch["context_ids"])),
|
| 145 |
+
epsilon=args.flowmap_epsilon, eps_clamp=True,
|
| 146 |
+
use_gradient_checkpointing=True)
|
| 147 |
+
loss = loss_b.mean()
|
| 148 |
+
if not args.skip_backward:
|
| 149 |
+
loss.backward()
|
| 150 |
+
torch.cuda.synchronize(dev)
|
| 151 |
+
dt, pk = time.time() - t0, _mem(dev)
|
| 152 |
+
per_role.setdefault(role, []).append((dt, pk, float(loss)))
|
| 153 |
+
if i < WORLD: # 第一轮是预热,只打印不计入
|
| 154 |
+
log(f" 预热 rank{rank} {ROLE_NAME.get(role, role):18s} {dt:5.2f}s {pk:5.1f} GB")
|
| 155 |
+
|
| 156 |
+
log(" —— 稳态(去掉第一轮预热)——")
|
| 157 |
+
t_micro, pk = 0.0, 0.0
|
| 158 |
+
for role, rows in sorted(per_role.items()):
|
| 159 |
+
warm = rows[1:] or rows
|
| 160 |
+
tt = sum(r[0] for r in warm) / len(warm)
|
| 161 |
+
pp = max(r[1] for r in warm)
|
| 162 |
+
ll = sum(r[2] for r in warm) / len(warm)
|
| 163 |
+
log(f" {ROLE_NAME.get(role, role):18s} n={len(warm)} {tt:5.2f}s 峰值 {pp:5.1f} GB loss {ll:.4f}")
|
| 164 |
+
t_micro, pk = max(t_micro, tt), max(pk, pp)
|
| 165 |
+
log(f" 最贵角色: {t_micro:.2f}s / micro 峰值显存 {pk:.1f} GB(单进程,未分片)")
|
| 166 |
+
|
| 167 |
+
# ---------------- 换算 ----------------
|
| 168 |
+
log("=" * 70)
|
| 169 |
+
log("换算(micro → step;等效 batch 恒为 8)")
|
| 170 |
+
for ngpu, ga in ((4, 2), (8, 1)):
|
| 171 |
+
s_step = t_micro * ga
|
| 172 |
+
log(f" {ngpu} 卡 × GA{ga}: {s_step:5.2f} s/step 8000 步 ≈ {s_step * 8000 / 3600:5.1f} h")
|
| 173 |
+
log(" 注:显存是单进程上界。ZeRO-2 把 Adam 状态按卡分片,实际每卡应低于这个数;")
|
| 174 |
+
log(" 反过来,这里 OOM 也不代表多卡装不下。")
|
| 175 |
+
|
| 176 |
+
# ---------------- S-FM12 bf16 噪声底 ----------------
|
| 177 |
+
log("=" * 70)
|
| 178 |
+
log("S-FM12 中心差分的 bf16 噪声底")
|
| 179 |
+
log(" 已知问题(继承自上游、故意不改):探针时间步在生产里被量化到 bf16,")
|
| 180 |
+
log(" 实际间隔不是名义的 2·eps,分母却按 2·eps 除 ⇒ dF/dt 有系统性尺度误差。")
|
| 181 |
+
eps = args.flowmap_epsilon
|
| 182 |
+
for t_val in (100.0, 500.0, 900.0):
|
| 183 |
+
tt = torch.full((1,), t_val, device=dev)
|
| 184 |
+
tp = (tt + eps).to(torch.bfloat16).float()
|
| 185 |
+
tm = (tt - eps).to(torch.bfloat16).float()
|
| 186 |
+
real = float((tp - tm).item())
|
| 187 |
+
log(f" t={t_val:6.1f} 名义间隔 {2 * eps:.1f} bf16 实际 {real:5.2f} "
|
| 188 |
+
f"尺度误差 {100 * (real - 2 * eps) / (2 * eps):+6.1f}%")
|
| 189 |
+
log(" (--eps_clamp 1 会按**实际**步长除,把这一项消掉;--eps_clamp 0 复现上游)")
|
| 190 |
+
|
| 191 |
+
log("=" * 70)
|
| 192 |
+
log(f"探针通过。stage2-flowmap 可以起训。峰值 {pk:.1f} GB / micro {t_micro:.2f}s")
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
if __name__ == "__main__":
|
| 196 |
+
main()
|
backup_2026-09-14/code/distill/eval/flowmap_liveness.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""flow map 到底有没有在学 —— 「一晚上空跑」的直接排查。
|
| 2 |
+
|
| 3 |
+
零初始化的代价:装上 flow map 的那一刻,`delta_embedding` 的读出层全零,于是
|
| 4 |
+
`u(x, t, r) ≡ u(x, t, t)` 对**任意** r 成立。这正是教师同一性门要的性质,但它也意味着
|
| 5 |
+
**此刻模型对 r 完全没有依赖**。r 依赖必须靠训练长出来。
|
| 6 |
+
|
| 7 |
+
如果梯度进不去那条分支,训练不会报错、loss 照样下降(因为 50 % 的样本是 r==t 的
|
| 8 |
+
diffusion 槽,那本来就是普通扩散 loss),最后得到的只是一个普通扩散模型 —— 花了一晚上
|
| 9 |
+
8 张卡,any-step 这件事一步没学到。
|
| 10 |
+
|
| 11 |
+
这个脚本用**真权重、真 fm_loss**跑几步真优化,然后回答三个问题:
|
| 12 |
+
|
| 13 |
+
L1 delta_embedding 的读出层还是不是全零?(是 ⇒ 梯度根本没进去,死的)
|
| 14 |
+
L2 梯度范数有没有真的落在 delta 分支上?(与主干比,量级对不对)
|
| 15 |
+
L3 几步之后 u(x,t,r) 对不同 r 是否已经**分开**?(分不开 ⇒ 学不到任何 r 依赖)
|
| 16 |
+
|
| 17 |
+
CUDA_VISIBLE_DEVICES=0 PYTHONPATH=/data/zhiyangdeng/ActionRoPE DIFFSYNTH_SKIP_DOWNLOAD=True \
|
| 18 |
+
.venv/bin/python distill/eval/flowmap_liveness.py --steps 12
|
| 19 |
+
|
| 20 |
+
也可以直接查一个已经存下来的 checkpoint(训练跑起来之后用这个,最省事、也最有说服力):
|
| 21 |
+
|
| 22 |
+
... .venv/bin/python distill/eval/flowmap_liveness.py \
|
| 23 |
+
--ckpt outputs/distill/stage2_flowmap/ema/ema_step500.safetensors
|
| 24 |
+
"""
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import argparse
|
| 28 |
+
import os
|
| 29 |
+
import sys
|
| 30 |
+
|
| 31 |
+
os.environ.setdefault("DIFFSYNTH_SKIP_DOWNLOAD", "True")
|
| 32 |
+
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
| 33 |
+
|
| 34 |
+
import torch
|
| 35 |
+
|
| 36 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 37 |
+
if ROOT not in sys.path:
|
| 38 |
+
sys.path.insert(0, ROOT)
|
| 39 |
+
|
| 40 |
+
from actionrope.dataset import AropeLatentDataset, arope_collate, load_sidecar # noqa: E402
|
| 41 |
+
from distill import flowmap as fm # noqa: E402
|
| 42 |
+
from distill.arope_dit import AropeDiT, unwrap # noqa: E402
|
| 43 |
+
from distill.data import to_device # noqa: E402
|
| 44 |
+
from distill.stage2_flowmap.fm_arope import _tf_probe, fm_loss # noqa: E402
|
| 45 |
+
|
| 46 |
+
DELTA_KEYS = ["delta_embedding.0.weight", "delta_embedding.0.bias",
|
| 47 |
+
"delta_embedding.2.weight", "delta_embedding.2.bias"]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _delta_stats(dit) -> dict:
|
| 51 |
+
sd = dit.state_dict()
|
| 52 |
+
out = {}
|
| 53 |
+
for k in DELTA_KEYS:
|
| 54 |
+
if k in sd:
|
| 55 |
+
v = sd[k].float()
|
| 56 |
+
out[k] = (float(v.abs().max()), float(v.abs().mean()))
|
| 57 |
+
if "delta_gate" in sd:
|
| 58 |
+
out["delta_gate"] = (float(sd["delta_gate"].float().abs().max()), 0.0)
|
| 59 |
+
return out
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _r_spread(module, t_val: float = 500.0) -> float:
|
| 63 |
+
"""同一个 (x, t) 下,不同 r 的输出最大分离度。0 ⇒ 模型对 r 毫无依赖。"""
|
| 64 |
+
base = _tf_probe(module, t_val, t_val)
|
| 65 |
+
worst = 0.0
|
| 66 |
+
for r in (0.0, 250.0, 750.0, 1000.0):
|
| 67 |
+
d = (_tf_probe(module, t_val, r).float() - base.float()).abs().max().item()
|
| 68 |
+
worst = max(worst, d)
|
| 69 |
+
return worst
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def main():
|
| 73 |
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 74 |
+
ap.add_argument("--ckpt", default=None, help="直接查一个已存的 flow-map ckpt(832 键),不跑优化")
|
| 75 |
+
ap.add_argument("--init_ckpt", default=f"{ROOT}/outputs/distill/stage1b_adapt/dits/dit_step3000.safetensors")
|
| 76 |
+
ap.add_argument("--train_dir", default=f"{ROOT}/data/latent/train_eybx")
|
| 77 |
+
ap.add_argument("--sidecar", default=f"{ROOT}/data/latent/arope_sidecar.pt")
|
| 78 |
+
ap.add_argument("--text_table", default=f"{ROOT}/data/latent/text_table_actionrope.pt")
|
| 79 |
+
ap.add_argument("--steps", type=int, default=12)
|
| 80 |
+
ap.add_argument("--lr", type=float, default=1e-5)
|
| 81 |
+
ap.add_argument("--device", default="cuda")
|
| 82 |
+
args = ap.parse_args()
|
| 83 |
+
|
| 84 |
+
dev = torch.device(args.device)
|
| 85 |
+
log = print
|
| 86 |
+
from safetensors.torch import load_file
|
| 87 |
+
|
| 88 |
+
# ---------------- 只查 checkpoint ----------------
|
| 89 |
+
if args.ckpt:
|
| 90 |
+
sd = load_file(args.ckpt)
|
| 91 |
+
log(f"checkpoint: {args.ckpt}")
|
| 92 |
+
log(f" 键数 {len(sd)}")
|
| 93 |
+
dead = True
|
| 94 |
+
for k in DELTA_KEYS + ["delta_gate"]:
|
| 95 |
+
if k not in sd:
|
| 96 |
+
log(f" ✗ 缺 {k} —— 这不是 flow-map checkpoint")
|
| 97 |
+
continue
|
| 98 |
+
v = sd[k].float()
|
| 99 |
+
mx, mn = float(v.abs().max()), float(v.abs().mean())
|
| 100 |
+
log(f" {k:32s} |max| {mx:.3e} |mean| {mn:.3e}")
|
| 101 |
+
if k == "delta_embedding.2.weight":
|
| 102 |
+
dead = (mx == 0.0)
|
| 103 |
+
log("")
|
| 104 |
+
if dead:
|
| 105 |
+
log(" ❌ delta_embedding.2.weight 仍然全零 ⇒ r 分支是死的,训练没在学 any-step")
|
| 106 |
+
raise SystemExit(1)
|
| 107 |
+
log(" ✅ 读出层已非零 ⇒ 梯度确实进了 delta 分支,模型在学 r 依赖")
|
| 108 |
+
return
|
| 109 |
+
|
| 110 |
+
# ---------------- 跑真优化 ----------------
|
| 111 |
+
model = AropeDiT(causal=True, mask_channel=True).to(dev).eval()
|
| 112 |
+
unwrap(model).dit.load_state_dict(load_file(args.init_ckpt), strict=True)
|
| 113 |
+
model = model.to(torch.bfloat16)
|
| 114 |
+
unwrap(model).move_rope_table(dev)
|
| 115 |
+
unwrap(model).install_flowmap(form="residual", gate=1.0)
|
| 116 |
+
unwrap(model).to(dev)
|
| 117 |
+
bare = unwrap(model)
|
| 118 |
+
|
| 119 |
+
log("=" * 72)
|
| 120 |
+
log("L1 装上之后(训练前)")
|
| 121 |
+
for k, (mx, mn) in _delta_stats(bare.dit).items():
|
| 122 |
+
log(f" {k:32s} |max| {mx:.3e}")
|
| 123 |
+
spread0 = _r_spread(bare)
|
| 124 |
+
log(f" 不同 r 的输出分离度 = {spread0:.3e} (0 是**对的**:零初始化 ⇒ 此刻没有 r 依赖)")
|
| 125 |
+
|
| 126 |
+
sc = load_sidecar(args.sidecar)
|
| 127 |
+
ds = AropeLatentDataset(args.train_dir, sc, args.text_table, text_mode="scene",
|
| 128 |
+
scene_dropout=0.0, limit=8, min_rt_ratio=0.8, verbose=False)
|
| 129 |
+
from actionrope.prompts import NULL_PROMPT
|
| 130 |
+
table = torch.load(args.text_table, map_location="cpu", weights_only=False)
|
| 131 |
+
row = table[NULL_PROMPT].to(torch.bfloat16)
|
| 132 |
+
|
| 133 |
+
model.train()
|
| 134 |
+
for p in bare.parameters():
|
| 135 |
+
p.requires_grad_(True)
|
| 136 |
+
opt = torch.optim.AdamW(bare.parameters(), lr=args.lr, betas=(0.9, 0.999), weight_decay=0.0)
|
| 137 |
+
|
| 138 |
+
delta_names = [n for n, _ in bare.dit.named_parameters() if n.startswith("delta_embedding")]
|
| 139 |
+
log("=" * 72)
|
| 140 |
+
log(f"L2 跑 {args.steps} 步真 fm_loss(lr={args.lr}),看梯度落在哪")
|
| 141 |
+
split_gen = fm.make_split_generator(0)
|
| 142 |
+
for i in range(args.steps):
|
| 143 |
+
batch = to_device(arope_collate([ds[i % len(ds)]]), dev)
|
| 144 |
+
uncond = row.view(1, 1, *row.shape).to(dev).expand(1, batch["context"].shape[1], -1, -1)
|
| 145 |
+
g_i = torch.Generator(device="cpu").manual_seed(500 + i)
|
| 146 |
+
tr = fm.sample_tr(1, world_size=8, rank=i % 8, gen=g_i, split_gen=fm.make_split_generator(i),
|
| 147 |
+
diffusion_ratio=0.5, consistency_ratio=0.25, rotate=True, device=dev)
|
| 148 |
+
opt.zero_grad(set_to_none=True)
|
| 149 |
+
loss_b, stats = fm_loss(model, batch, tr, gains={"text": 2.0}, uncond=uncond,
|
| 150 |
+
uncond_ids=torch.zeros_like(batch["context_ids"]),
|
| 151 |
+
epsilon=5.0, eps_clamp=True, use_gradient_checkpointing=True)
|
| 152 |
+
loss_b.mean().backward()
|
| 153 |
+
gd = torch.sqrt(sum((p.grad.float() ** 2).sum() for n, p in bare.dit.named_parameters()
|
| 154 |
+
if n.startswith("delta_embedding") and p.grad is not None))
|
| 155 |
+
ga = torch.sqrt(sum((p.grad.float() ** 2).sum() for p in bare.parameters() if p.grad is not None))
|
| 156 |
+
role = ["diffusion", "consistency", "random"][int(tr.role[0])]
|
| 157 |
+
log(f" step {i + 1:2d} {role:12s} t={float(tr.t[0]):6.1f} r={float(tr.r[0]):6.1f} "
|
| 158 |
+
f"loss {float(loss_b.mean()):.4f} |g_delta| {float(gd):.3e} |g_all| {float(ga):.3e}")
|
| 159 |
+
opt.step()
|
| 160 |
+
|
| 161 |
+
log("=" * 72)
|
| 162 |
+
log("L1' 训练之后")
|
| 163 |
+
dead = False
|
| 164 |
+
for k, (mx, mn) in _delta_stats(bare.dit).items():
|
| 165 |
+
log(f" {k:32s} |max| {mx:.3e}")
|
| 166 |
+
if k == "delta_embedding.2.weight" and mx == 0.0:
|
| 167 |
+
dead = True
|
| 168 |
+
log("=" * 72)
|
| 169 |
+
log("L3 r 依赖有没有长出来")
|
| 170 |
+
spread1 = _r_spread(bare)
|
| 171 |
+
log(f" 训练前 {spread0:.3e} → 训练后 {spread1:.3e}")
|
| 172 |
+
|
| 173 |
+
log("=" * 72)
|
| 174 |
+
if dead or spread1 <= spread0:
|
| 175 |
+
log("❌ 机制没通:delta 分支没动 / 不同 r 的输出仍然一样。")
|
| 176 |
+
log(" 继续跑下去只会得到一个普通扩散模型,any-step 一步没学到。停机排查。")
|
| 177 |
+
raise SystemExit(1)
|
| 178 |
+
log("✅ 机制通了:梯度进了 delta 分支,读出层已非零,不同 r 的输出已经分开。")
|
| 179 |
+
log(f" 注意 r 依赖是**从零长出来**的,前期分离度小是正常的(这几步就有 {spread1:.1e})。")
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
if __name__ == "__main__":
|
| 183 |
+
main()
|
backup_2026-09-14/code/distill/eval/loop_spec.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
idle:1.0:1,right:1.0:10,down:1.0:10,left:1.0:10,up:1.0:10
|
backup_2026-09-14/code/distill/eval/null_branch_probe.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""四条 null 分支到底训出来了没有 —— stage1b 适配微调的验收探针。
|
| 2 |
+
|
| 3 |
+
适配微调的**唯一目的**是把 text 之外的三条 null 分支变成分布内的合法输入。val 不掉只说明
|
| 4 |
+
主目标没被破坏,说明不了 null 分支能不能用。这里直接量:
|
| 5 |
+
|
| 6 |
+
同一批 clip、同一份噪声、同一组噪声档,对每条分支各算一次 loss。
|
| 7 |
+
|
| 8 |
+
微调前(stage1 的 ckpt) null 分支从没训过 ⇒ loss 应显著高、甚至爆掉
|
| 9 |
+
微调后(stage1b 的 ckpt) null 分支已训 ⇒ loss 明显下降,而 full 那条基本不动
|
| 10 |
+
|
| 11 |
+
`Δloss = loss(null) − loss(full)` 还有第二重意义:它就是"这条条件带来多少信息量"。
|
| 12 |
+
Δ 越大说明该条件越重要 —— 于是能直接回答"该给哪条轴多大的 guidance 权重"。
|
| 13 |
+
|
| 14 |
+
CUDA_VISIBLE_DEVICES=0 PYTHONPATH=/data/zhiyangdeng/ActionRoPE DIFFSYNTH_SKIP_DOWNLOAD=True \
|
| 15 |
+
.venv/bin/python distill/eval/null_branch_probe.py \
|
| 16 |
+
--before outputs/distill/stage1_ar_tf/dits/dit_step10000.safetensors \
|
| 17 |
+
--after outputs/distill/stage1b_adapt/dits/dit_step3000.safetensors
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import argparse
|
| 22 |
+
import os
|
| 23 |
+
import sys
|
| 24 |
+
|
| 25 |
+
import torch
|
| 26 |
+
|
| 27 |
+
os.environ.setdefault("DIFFSYNTH_SKIP_DOWNLOAD", "True")
|
| 28 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 29 |
+
if ROOT not in sys.path:
|
| 30 |
+
sys.path.insert(0, ROOT)
|
| 31 |
+
|
| 32 |
+
from actionrope.dataset import AropeLatentDataset, arope_collate, load_sidecar # noqa: E402
|
| 33 |
+
from distill.arope_dit import AropeDiT, unwrap # noqa: E402
|
| 34 |
+
from distill.data import to_device # noqa: E402
|
| 35 |
+
from distill.flow_pf import sample_pf_timestep_ids # noqa: E402
|
| 36 |
+
from distill.stage1_ar.ar_tf import ar_tf_loss # noqa: E402
|
| 37 |
+
|
| 38 |
+
# 四条分支:名字 → 传给 ar_tf_loss 的 kwargs(p=1.0 ⇒ 强制命中)
|
| 39 |
+
BRANCHES = {
|
| 40 |
+
"full": {},
|
| 41 |
+
"anchor": {"anchor_dropout": 1.0}, # ★ 摘掉 cell 0、历史保留
|
| 42 |
+
"action": {"action_dropout": 1.0}, # offset ≡ 0
|
| 43 |
+
"hist": {"hist_dropout": 1.0}, # 只剩 cell 0
|
| 44 |
+
"ctx": {"ctx_dropout": 1.0}, # 什么都看不到
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@torch.no_grad()
|
| 49 |
+
def probe(ckpt: str, batches, seeds, device: str) -> dict[str, float]:
|
| 50 |
+
from safetensors.torch import load_file
|
| 51 |
+
m = AropeDiT(causal=True, mask_channel=True).to(device).eval()
|
| 52 |
+
unwrap(m).dit.load_state_dict(load_file(ckpt), strict=True)
|
| 53 |
+
m = m.to(torch.bfloat16)
|
| 54 |
+
unwrap(m).move_rope_table(device)
|
| 55 |
+
|
| 56 |
+
out = {}
|
| 57 |
+
for name, kw in BRANCHES.items():
|
| 58 |
+
tot = 0.0
|
| 59 |
+
for batch, seed in zip(batches, seeds):
|
| 60 |
+
b = to_device(batch, device)
|
| 61 |
+
x0 = b["input_latents"]
|
| 62 |
+
g = torch.Generator(device="cpu").manual_seed(seed)
|
| 63 |
+
noise = torch.randn(x0.shape, generator=g).to(dtype=x0.dtype, device=device)
|
| 64 |
+
idx = sample_pf_timestep_ids(x0.shape[0], x0.shape[2], device,
|
| 65 |
+
torch.Generator(device="cpu").manual_seed(seed + 7))
|
| 66 |
+
loss, _ = ar_tf_loss(m, b, noise=noise, idx_pf=idx, **kw)
|
| 67 |
+
tot += float(loss)
|
| 68 |
+
out[name] = tot / len(batches)
|
| 69 |
+
del m
|
| 70 |
+
torch.cuda.empty_cache()
|
| 71 |
+
return out
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def main():
|
| 75 |
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 76 |
+
ap.add_argument("--before", required=True, help="适配微调**前**的 ckpt")
|
| 77 |
+
ap.add_argument("--after", required=True, help="适配微调**后**的 ckpt")
|
| 78 |
+
ap.add_argument("--n", type=int, default=8, help="用多少条 val clip")
|
| 79 |
+
ap.add_argument("--val_dir", default=f"{ROOT}/data/latent/val_eybx")
|
| 80 |
+
ap.add_argument("--sidecar", default=f"{ROOT}/data/latent/arope_sidecar.pt")
|
| 81 |
+
ap.add_argument("--text_table", default=f"{ROOT}/data/latent/text_table_actionrope.pt")
|
| 82 |
+
ap.add_argument("--device", default="cuda")
|
| 83 |
+
args = ap.parse_args()
|
| 84 |
+
|
| 85 |
+
sc = load_sidecar(args.sidecar)
|
| 86 |
+
ds = AropeLatentDataset(args.val_dir, sc, args.text_table, text_mode="scene",
|
| 87 |
+
scene_dropout=0.0, limit=args.n, verbose=False)
|
| 88 |
+
batches = [arope_collate([ds[i]]) for i in range(min(args.n, len(ds)))]
|
| 89 |
+
seeds = list(range(len(batches)))
|
| 90 |
+
print(f"用 {len(batches)} 条 val clip,固定噪声/噪声档,每条分支各算一次\n")
|
| 91 |
+
|
| 92 |
+
res = {}
|
| 93 |
+
for tag, ck in (("before", args.before), ("after", args.after)):
|
| 94 |
+
res[tag] = probe(ck, batches, seeds, args.device)
|
| 95 |
+
print(f" {tag}: {os.path.basename(ck)} 已量完")
|
| 96 |
+
|
| 97 |
+
print(f"\n{'分支':<8} {'微调前':>10} {'微调后':>10} {'变化':>10} {'Δ vs full(后)':>14}")
|
| 98 |
+
print("-" * 62)
|
| 99 |
+
full_after = res["after"]["full"]
|
| 100 |
+
for name in BRANCHES:
|
| 101 |
+
b, a = res["before"][name], res["after"][name]
|
| 102 |
+
d = a - full_after
|
| 103 |
+
note = "" if name == "full" else f"{d:+.4f}"
|
| 104 |
+
print(f"{name:<8} {b:10.4f} {a:10.4f} {a - b:+10.4f} {note:>14}")
|
| 105 |
+
|
| 106 |
+
print("\n读法:")
|
| 107 |
+
print(" · full 那行「变化」应接近 0 —— 补 null 分支不该破���主目标")
|
| 108 |
+
print(" · 其余三行「变化」应显著为负 —— 说明这些从没训过的输入现在训出来了")
|
| 109 |
+
print(" · 「Δ vs full」= 该条件的信息量,越大越重要,可直接用来定 guidance 权重的相对大小")
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
if __name__ == "__main__":
|
| 113 |
+
main()
|
backup_2026-09-14/code/distill/eval/paired50.sh
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# 50 张首帧的配对评测:同首帧、同动作脚本、同种子,只换权重。
|
| 3 |
+
#
|
| 4 |
+
# bash distill/eval/paired50.sh <ckptA> <ckptB> [tagA] [tagB] [maxjobs]
|
| 5 |
+
#
|
| 6 |
+
# 为什么必须 50 张而不是 4 张:同一份权重在不同首帧上的净漂移散布是 9~36 px(sd≈11),
|
| 7 |
+
# 而各 stage3 变体之间的差异只有 1~3 px。n=4 时均值标准误 5.5 px —— 判据分辨不出效应,
|
| 8 |
+
# 实测四个 ‖Δ‖ 差几个量级的权重全部落在 22~26。n=50 时标准误降到 ~1.6 px,才谈得上比较。
|
| 9 |
+
set -euo pipefail
|
| 10 |
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
| 11 |
+
A="${1:?ckptA}"; B="${2:?ckptB}"; TA="${3:-A}"; TB="${4:-B}"; MAXJOBS="${5:-6}"
|
| 12 |
+
SPEC_FILE="${SPEC_FILE:-$ROOT/distill/eval/loop_spec.txt}" # 41 cell 净零闭环(重建,见 sweep8.sh 文件头)
|
| 13 |
+
SPEC="$(cat "$SPEC_FILE")"
|
| 14 |
+
# stage3 学生必须按**训练几何**评(sink 0 / window 20 / cap 20 = rollout_eval.py 默认,GEO-1);
|
| 15 |
+
# sink 3 / w17 只作显式 A/B:SINK=3 KV_WINDOW=17 ROPE_CAP=20 bash distill/eval/paired50.sh ...
|
| 16 |
+
# 几何旗标只在环境变量显式给出时才追加;默认一个都不传 ⇒ rollout_eval.py 的默认 sink 0 / window 20 /
|
| 17 |
+
# cap 20 = stage3 训练几何。三者必须满足 rope_cap == sink + window(rollout_eval 会核)。
|
| 18 |
+
GEO=()
|
| 19 |
+
if [ -n "${SINK:-}" ]; then GEO+=(--sink_size "$SINK"); fi
|
| 20 |
+
if [ -n "${KV_WINDOW:-}" ]; then GEO+=(--kv_window "$KV_WINDOW"); fi
|
| 21 |
+
if [ -n "${ROPE_CAP:-}" ]; then GEO+=(--rope_cap "$ROPE_CAP"); fi
|
| 22 |
+
OUT="$ROOT/outputs/distill/eval/paired"; mkdir -p "$OUT"
|
| 23 |
+
export PYTHONPATH="$ROOT" DIFFSYNTH_SKIP_DOWNLOAD=True CUDA_DEVICE_ORDER=PCI_BUS_ID
|
| 24 |
+
i=0
|
| 25 |
+
for f in "$ROOT"/data/eval/eybx_bg_50/*.png; do
|
| 26 |
+
n="$(basename "$f" .png)"
|
| 27 |
+
for tc in "$TA:$A" "$TB:$B"; do
|
| 28 |
+
t="${tc%%:*}"; c="${tc#*:}"
|
| 29 |
+
[ -s "$OUT/${t}_${n}.log" ] && continue
|
| 30 |
+
CUDA_VISIBLE_DEVICES=$(( i % 8 )) nohup "$ROOT/.venv/bin/python" "$ROOT/distill/eval/rollout_eval.py" \
|
| 31 |
+
--ckpt "$c" --mode rollout --cells 41 "${GEO[@]}" \
|
| 32 |
+
--num_inference_steps 4 --actions "$SPEC" --allow_busy_gpu --seed 0 \
|
| 33 |
+
--first_frame "$f" --tiled --no-save_video \
|
| 34 |
+
--out "$OUT/${t}_${n}.mp4" > "$OUT/${t}_${n}.log" 2>&1 &
|
| 35 |
+
i=$(( i + 1 )); (( i % MAXJOBS == 0 )) && wait
|
| 36 |
+
done
|
| 37 |
+
done
|
| 38 |
+
wait
|
| 39 |
+
echo "[paired] 完成,结果在 $OUT"
|
backup_2026-09-14/code/distill/eval/photodrift.sh
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# 一个 ckpt → 41-cell rollout → 逐 cell 的像素 std/mean 表。
|
| 3 |
+
#
|
| 4 |
+
# stage3 的失败模式是**光度漂移**(正式 run 烧白 std 39→2.7 mean 60→171;冻 critic 过饱和
|
| 5 |
+
# std 39→80),不是结构噪声,所以判据就是这两条曲线平不平。基线 stage2-7500 是 std 37~40、
|
| 6 |
+
# mean 60.7→42.9。sift 能量出位移就更好,量不出(sift=None)本身就是「已经坏了」。
|
| 7 |
+
# ⚠ 上面的基线数字是在旧几何(sink 3 / w17)+ 原始 loop spec 下量的;脚本现已默认训练几何(sink 0 / w20)
|
| 8 |
+
# + 重建的 loop_spec.txt,严格说不可比 —— stage2 跑完后用新默认重测 stage2 EMA 并更新这里(GEO-1)。
|
| 9 |
+
#
|
| 10 |
+
# bash distill/eval/photodrift.sh <ckpt> <tag> [gpu] [steps]
|
| 11 |
+
set -euo pipefail
|
| 12 |
+
CK="$1"; TAG="$2"; GPU="${3:-0}"; N="${4:-4}"
|
| 13 |
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
| 14 |
+
SPEC_FILE="${SPEC_FILE:-$ROOT/distill/eval/loop_spec.txt}" # 41 cell 净零闭环(重建,见 sweep8.sh 文件头)
|
| 15 |
+
[ -f "$SPEC_FILE" ] || { echo "ABORT: 没有动作脚本 $SPEC_FILE"; exit 1; }
|
| 16 |
+
# stage3 学生必须按**训练几何**评(sink 0 / window 20 / cap 20 = rollout_eval.py 默认,GEO-1);
|
| 17 |
+
# sink 3 / w17 只作显式 A/B:SINK=3 KV_WINDOW=17 ROPE_CAP=20 bash distill/eval/photodrift.sh ...
|
| 18 |
+
# 几何旗标只在环境变量显式给出时才追加;默认一个都不传 ⇒ rollout_eval.py 的默认 sink 0 / window 20 /
|
| 19 |
+
# cap 20 = stage3 训练几何。三者必须满足 rope_cap == sink + window(rollout_eval 会核)。
|
| 20 |
+
GEO=()
|
| 21 |
+
if [ -n "${SINK:-}" ]; then GEO+=(--sink_size "$SINK"); fi
|
| 22 |
+
if [ -n "${KV_WINDOW:-}" ]; then GEO+=(--kv_window "$KV_WINDOW"); fi
|
| 23 |
+
if [ -n "${ROPE_CAP:-}" ]; then GEO+=(--rope_cap "$ROPE_CAP"); fi
|
| 24 |
+
OUT="$ROOT/outputs/distill/eval/deploy"; mkdir -p "$OUT"
|
| 25 |
+
export PYTHONPATH="$ROOT" DIFFSYNTH_SKIP_DOWNLOAD=True CUDA_DEVICE_ORDER=PCI_BUS_ID
|
| 26 |
+
CUDA_VISIBLE_DEVICES=$GPU "$ROOT/.venv/bin/python" "$ROOT/distill/eval/rollout_eval.py" \
|
| 27 |
+
--ckpt "$CK" --mode rollout --cells 41 "${GEO[@]}" \
|
| 28 |
+
--num_inference_steps "$N" --actions "$(cat "$SPEC_FILE")" --allow_raw_student \
|
| 29 |
+
--first_frame "$ROOT/data/eval/eybx_bg_50/19_medieval_castle.png" --tiled \
|
| 30 |
+
--out "$OUT/$TAG.mp4" > "$OUT/$TAG.log" 2>&1
|
| 31 |
+
grep -E "实测位移" "$OUT/$TAG.log" || true
|
| 32 |
+
"$ROOT/.venv/bin/python" - "$OUT/$TAG.mp4" "$TAG" <<'PY'
|
| 33 |
+
import sys, imageio.v3 as iio, numpy as np
|
| 34 |
+
g = iio.imread(sys.argv[1]).astype(np.float32).mean(-1)
|
| 35 |
+
idx = [0,4,8,20,40,80,120,160]
|
| 36 |
+
print(f"{sys.argv[2]:22s} 帧 " + " ".join(f"{i:>5d}" for i in idx))
|
| 37 |
+
print(f"{' std':22s} " + " ".join(f"{g[i].std():5.1f}" for i in idx))
|
| 38 |
+
print(f"{' mean':22s} " + " ".join(f"{g[i].mean():5.1f}" for i in idx))
|
| 39 |
+
PY
|
backup_2026-09-14/code/distill/eval/rollout_eval.py
ADDED
|
@@ -0,0 +1,1105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""蒸馏产物的部署路径评测:按 4 步部署表生成一条 clip,实测背景位移 vs 指令位移。
|
| 3 |
+
|
| 4 |
+
# stage3 学生:因果 AR rollout(滑窗 KV),就是它训练时那条路
|
| 5 |
+
CUDA_VISIBLE_DEVICES=0 PYTHONPATH=/data/zhiyangdeng/ActionRoPE DIFFSYNTH_SKIP_DOWNLOAD=True \\
|
| 6 |
+
.venv/bin/python distill/eval/rollout_eval.py \\
|
| 7 |
+
--ckpt outputs/distill/stage3_dmd/ema/ema_step400.safetensors \\
|
| 8 |
+
--replay <clip_id> \\
|
| 9 |
+
--out outputs/distill/stage3_dmd/eval/ema400_replay.mp4
|
| 10 |
+
|
| 11 |
+
# stage2 EMA 自己的部署路径:整片并行、2 步
|
| 12 |
+
... --mode parallel --denoise_list 1000,250 \\
|
| 13 |
+
--first_frame <clip_id|png|mp4> --scene <scene key> --actions "right:1.0:21"
|
| 14 |
+
|
| 15 |
+
# 挑部署权重:一次扫完所有 ema(只加载一次 10 GB pipeline,之后只换 DiT 权重)
|
| 16 |
+
... --sweep 'outputs/distill/stage3_dmd/ema/ema_step*.safetensors' --replay <clip_id> \\
|
| 17 |
+
--out_dir outputs/distill/stage3_dmd/eval/sweep
|
| 18 |
+
|
| 19 |
+
# 不碰真权重的自检(tiny 模型 + 合成条件,无 VAE)
|
| 20 |
+
... --self_test
|
| 21 |
+
|
| 22 |
+
为什么需要这个文件
|
| 23 |
+
-----------------
|
| 24 |
+
蒸馏的两个 loss 都不是质量指标:CD 的 loss 从 0 开始涨,DMD 是 min-max 目标(单调下降反而是坏消息)。
|
| 25 |
+
ActionRoPE 唯一有意义的判据是**动作服从性**:命令背景往右走 373 px,画面是不是真的往左挪了 373 px。
|
| 26 |
+
这里把 `actionrope/infer.py` 的 SIFT+RANSAC 实测(measure_displacement)与动作脚本解析(parse_actions)
|
| 27 |
+
原样接过来,只把采样器换成蒸馏后的少步部署表 —— 于是「少步学生还听不听指令」这个问题有了数字答案。
|
| 28 |
+
上游 WorldMind 判质量用的是亮度(brightness_eval.py),那是它的失败模式;这里的失败模式是动作漂移。
|
| 29 |
+
|
| 30 |
+
两条部署路径(`--mode`)
|
| 31 |
+
----------------------
|
| 32 |
+
rollout(默认):滑窗 KV、逐 cell 自回归,**与 stage3 训练时的 self_forcing_rollout 逐行同构**
|
| 33 |
+
—— 条件(窗口锚定的 offset_win / mask_in_k)直接 import 训练侧的 frame_cond,
|
| 34 |
+
提交趟同样是 detach 过的 x0 + t=0。差别只有一条:推理不抽退出档,每个 cell 把整张表走完。
|
| 35 |
+
它能超过 21 个 cell(这正是 stage3 要练的东西)。
|
| 36 |
+
parallel:整片块因果、一次去噪全部 cell,cell 0 每步写回 —— stage2 EMA 自己的部署路径。
|
| 37 |
+
|
| 38 |
+
采样规则(两条路一致,也与训练一致)
|
| 39 |
+
--------------------------------
|
| 40 |
+
den = noisy − sigma_i · v(noisy, t_i) # x0 估计
|
| 41 |
+
noisy = (1 − sigma_{i+1})·den + sigma_{i+1}·ε # 重新加噪到下一档(**新**噪声)
|
| 42 |
+
最后一档直接取 den。这**不是** `infer.sample` 的 Euler 步(`x + (σ_next − σ)·v`)——
|
| 43 |
+
那是 50 步扩散采样器;少步一致性/自强制学生训练时见到的就是上面这条 x0→重新加噪的链。
|
| 44 |
+
`--ref_steps 50` 会额外跑一遍 `infer.sample`,给出同一条件下的 50 步参照(PORT_SPEC 的 S9)。
|
| 45 |
+
|
| 46 |
+
一个必须守住的坑
|
| 47 |
+
---------------
|
| 48 |
+
部署权重是 `ema/`,不是 `dits/`。`dits/` 与 `critic/` 只为续训存在;拿 `dits/` 上线不会报错,
|
| 49 |
+
只会静默降质。所以路径里带 `/dits/` 时本脚本直接拒跑,除非显式 `--allow_raw_student`(risk 12)。
|
| 50 |
+
"""
|
| 51 |
+
from __future__ import annotations
|
| 52 |
+
|
| 53 |
+
import argparse
|
| 54 |
+
import re
|
| 55 |
+
import glob
|
| 56 |
+
import json
|
| 57 |
+
import math
|
| 58 |
+
import os
|
| 59 |
+
import subprocess
|
| 60 |
+
import sys
|
| 61 |
+
import time
|
| 62 |
+
|
| 63 |
+
os.environ.setdefault("DIFFSYNTH_SKIP_DOWNLOAD", "True")
|
| 64 |
+
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
| 65 |
+
|
| 66 |
+
import numpy as np
|
| 67 |
+
import torch
|
| 68 |
+
|
| 69 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 70 |
+
if ROOT not in sys.path:
|
| 71 |
+
sys.path.insert(0, ROOT)
|
| 72 |
+
|
| 73 |
+
from actionrope import geometry as G # noqa: E402
|
| 74 |
+
from actionrope import infer # noqa: E402
|
| 75 |
+
from actionrope.prompts import NULL_PROMPT # noqa: E402
|
| 76 |
+
from distill import flowmap as fm # noqa: E402
|
| 77 |
+
from distill.arope_dit import AropeDiT, unwrap # noqa: E402
|
| 78 |
+
from distill.data import extend_context, extend_offsets, make_mask_input, window_offsets # noqa: E402
|
| 79 |
+
from distill.flow_pf import DEPLOY_DENOISE_LIST, deploy_schedule # noqa: E402
|
| 80 |
+
from distill import runlib # noqa: E402
|
| 81 |
+
from distill.arope_dit import rot2d # noqa: E402
|
| 82 |
+
from distill.stage3_dmd.dmd_arope import frame_cond # noqa: E402 ← 训练侧同一份逐 cell 条件
|
| 83 |
+
from distill.tests.preflight import retained_cells, window_offsets_for_frame # noqa: E402
|
| 84 |
+
|
| 85 |
+
CELL_FRAMES = 4 # cell k(≥1) = 帧 4k−3 … 4k(Wan2.2 因果 VAE 的分组)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ----------------------------------------------------------------------------
|
| 89 |
+
# 指令 → 逐帧 / 逐 cell 偏移
|
| 90 |
+
# ----------------------------------------------------------------------------
|
| 91 |
+
|
| 92 |
+
def cell_of_frames(cells: int) -> np.ndarray:
|
| 93 |
+
"""每一帧属于哪个 cell(cell 0 = 帧 0;cell k = 帧 4k−3 … 4k)。= geometry.cell_frames 的逆,
|
| 94 |
+
只是把 21 的硬编码换成 cells —— rollout 要跑到 21 个 cell 以外,而 geometry 硬要求正好 21。"""
|
| 95 |
+
n_frames = CELL_FRAMES * cells - (CELL_FRAMES - 1) # cells=21 ⇒ 81 = G.FRAMES
|
| 96 |
+
cell_of = np.zeros(n_frames, int)
|
| 97 |
+
for k in range(1, cells):
|
| 98 |
+
cell_of[CELL_FRAMES * k - 3: CELL_FRAMES * k + 1] = k
|
| 99 |
+
return cell_of
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def frames_to_cells_n(frame_off: np.ndarray, cells: int) -> np.ndarray:
|
| 103 |
+
"""逐帧偏移 → 逐 cell 偏移(cell 内取均值)。cells == 21 时与 geometry.frames_to_cells 逐位相同。"""
|
| 104 |
+
cell_of = cell_of_frames(cells)
|
| 105 |
+
assert len(frame_off) == len(cell_of), f"帧数 {len(frame_off)} 与 {cells} cell 要求的 {len(cell_of)} 不符"
|
| 106 |
+
return np.stack([frame_off[cell_of == k].mean(0) for k in range(cells)], 0)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def action_offsets(vel: np.ndarray, cells: int) -> tuple[np.ndarray, np.ndarray]:
|
| 110 |
+
"""逐 cell 速度 (px/s) [cells,2] → (frame_offset_px [4·cells−3, 2], cell_offset_px [cells,2])。
|
| 111 |
+
|
| 112 |
+
与 `infer.parse_actions` + `geometry.frames_to_cells` 用的是**同一条**递推
|
| 113 |
+
(`frame_off[i] = frame_off[i−1] + v[cell(i)]/FPS`,cell 内取均值),只是把 21 的硬编码
|
| 114 |
+
换成了 cells —— cells == 21 时逐位相同(`_assert_matches_parse_actions` 会核对)。
|
| 115 |
+
"""
|
| 116 |
+
assert vel.shape == (cells, 2), f"vel 形状应为 ({cells}, 2),得到 {vel.shape}"
|
| 117 |
+
cell_of = cell_of_frames(cells)
|
| 118 |
+
frame_off = np.zeros((len(cell_of), 2), np.float64)
|
| 119 |
+
for i in range(1, len(cell_of)):
|
| 120 |
+
frame_off[i] = frame_off[i - 1] + vel[cell_of[i]] / G.FPS
|
| 121 |
+
return frame_off, frames_to_cells_n(frame_off, cells)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _ckpt_metadata(path: str) -> dict:
|
| 125 |
+
"""读 safetensors 头里的 __metadata__(不加载权重)。save_dit 在这里写 flowmap 的形式与 gate,
|
| 126 |
+
stage3 的 do_save 还写训练几何(kv_window / sink_size / rope_cap,见 _check_ckpt_geometry)。"""
|
| 127 |
+
import json as _json
|
| 128 |
+
import struct
|
| 129 |
+
with open(path, "rb") as f:
|
| 130 |
+
n = struct.unpack("<Q", f.read(8))[0]
|
| 131 |
+
hdr = _json.loads(f.read(n))
|
| 132 |
+
return hdr.get("__metadata__", {}) or {}
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# stage3 do_save(distill/stage3_dmd/dmd_arope.py)写进 safetensors 头的训练几何键;runlib.save_dit 一律 str()。
|
| 136 |
+
# stage1 / stage2 的 ckpt 没有这三个键。
|
| 137 |
+
GEOMETRY_META_KEYS = ("kv_window", "sink_size", "rope_cap")
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _check_ckpt_geometry(path: str, args, log=print) -> None:
|
| 141 |
+
"""stage3 学生必须按**训练几何**评(GEO-1):ckpt 元数据里 kv_window / sink_size / rope_cap 三键齐全
|
| 142 |
+
且任一与命令行不等 ⇒ 拒绝,除非 --allow_geometry_mismatch(只作显式 A/B,RUNBOOK 的口径)。
|
| 143 |
+
|
| 144 |
+
为什么硬拒而不是警告:sink 3 / w17 与训练的 sink 0 / w20 只在 k≤20 的保留集相同,之后的 h/w RoPE
|
| 145 |
+
与掩码通道是训练 rollout 从未产生过的输入 —— 拿它比 EMA、比 stage2 都只是在比 OOD 外推,ckpt
|
| 146 |
+
选择轴就错了。三键不全(stage1 / stage2 的 ckpt)不受限。"""
|
| 147 |
+
meta = _ckpt_metadata(path)
|
| 148 |
+
if not all(k in meta for k in GEOMETRY_META_KEYS):
|
| 149 |
+
return
|
| 150 |
+
try:
|
| 151 |
+
ck = {k: int(meta[k]) for k in GEOMETRY_META_KEYS}
|
| 152 |
+
except ValueError as e:
|
| 153 |
+
raise SystemExit(f"{path} 的几何元数据不是整数:{ {k: meta[k] for k in GEOMETRY_META_KEYS} }({e})")
|
| 154 |
+
cli = {k: int(getattr(args, k)) for k in GEOMETRY_META_KEYS}
|
| 155 |
+
diff = {k: (ck[k], cli[k]) for k in GEOMETRY_META_KEYS if ck[k] != cli[k]}
|
| 156 |
+
if "sink_anchor" in meta and str(meta["sink_anchor"]) != str(args.sink_anchor):
|
| 157 |
+
diff["sink_anchor"] = (str(meta["sink_anchor"]), str(args.sink_anchor))
|
| 158 |
+
if not diff:
|
| 159 |
+
log(f"[geometry] {os.path.basename(path)} 训练几何 sink {ck['sink_size']} / window {ck['kv_window']} / "
|
| 160 |
+
f"cap {ck['rope_cap']} == 命令行")
|
| 161 |
+
return
|
| 162 |
+
msg = (f"{path} 的训练几何与命令行不符:" + ", ".join(f"{k} ckpt={a} cli={b}" for k, (a, b) in diff.items())
|
| 163 |
+
+ "。\n stage3 学生只在训练几何下才是分布内的(GEO-1);请用 --sink_size/--kv_window/--rope_cap "
|
| 164 |
+
"对齐 ckpt,或加 --allow_geometry_mismatch(只作显式 A/B,不作 ckpt 选择轴)。")
|
| 165 |
+
if args.allow_geometry_mismatch:
|
| 166 |
+
log(f"[geometry] 放行(--allow_geometry_mismatch):{msg}")
|
| 167 |
+
return
|
| 168 |
+
raise SystemExit(msg)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _assert_matches_parse_actions(spec: str, walk_speed: np.ndarray) -> None:
|
| 172 |
+
"""cells=21 时本文件的递推必须与 infer.parse_actions / frames_to_cells 逐位相同。"""
|
| 173 |
+
fo, vel = infer.parse_actions(spec, walk_speed)
|
| 174 |
+
fo2, co2 = action_offsets(vel, G.CELLS)
|
| 175 |
+
assert np.array_equal(fo, fo2), "action_offsets 与 infer.parse_actions 的逐帧偏移不一致"
|
| 176 |
+
assert np.array_equal(G.frames_to_cells(fo), co2), "逐 cell 偏移与 geometry.frames_to_cells 不一致"
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def parse_actions_n(spec: str, walk_speed: np.ndarray, cells: int) -> np.ndarray:
|
| 180 |
+
"""与 `infer.parse_actions` 同一套语法,但总 cell 数是 `cells` 而不是硬写的 21。
|
| 181 |
+
|
| 182 |
+
上游那个函数在 `len(vel) != G.CELLS` 时直接抛错(`actionrope/infer.py:127`),而超出训练
|
| 183 |
+
视野的多段指令("绕一圈"、"第 30 格突然转向")正是 AR rollout 唯一值得测的东西 ——
|
| 184 |
+
直线走到底用 `extend_velocity` 重复末段就够了,不需要这个函数。
|
| 185 |
+
|
| 186 |
+
分段解析是从上游抄来的,所以两份实现可能漂移;`cells == 21` 时本函数会直接调上游核对,
|
| 187 |
+
逐位不同就抛。不动 `actionrope/`:那是训练与推理的正典,评测不该改它。
|
| 188 |
+
"""
|
| 189 |
+
toks = [t.strip() for t in spec.split(",") if t.strip()]
|
| 190 |
+
segs, i = [], 0
|
| 191 |
+
while i < len(toks):
|
| 192 |
+
if ":" not in toks[i]: # "dx,dy:n" 被逗号切开了,粘回去
|
| 193 |
+
if i + 1 >= len(toks):
|
| 194 |
+
raise ValueError(f"动作段不完整: {toks[i]!r}")
|
| 195 |
+
segs.append(toks[i] + ";" + toks[i + 1]); i += 2
|
| 196 |
+
else:
|
| 197 |
+
segs.append(toks[i]); i += 1
|
| 198 |
+
vel = []
|
| 199 |
+
for seg in segs:
|
| 200 |
+
parts = seg.split(":")
|
| 201 |
+
if len(parts) == 3:
|
| 202 |
+
name, mult, n = parts[0].strip().lower(), float(parts[1]), int(parts[2])
|
| 203 |
+
if name not in infer.DIR_NAMES:
|
| 204 |
+
raise ValueError(f"未知方向 {name!r},可选 {sorted(infer.DIR_NAMES)}")
|
| 205 |
+
v = G.ACTION_DIRS[infer.DIR_NAMES[name]] * walk_speed * mult
|
| 206 |
+
elif len(parts) == 2 and ";" in parts[0]:
|
| 207 |
+
dx, dy = (float(t) for t in parts[0].split(";"))
|
| 208 |
+
v, n = np.array([dx, dy], np.float64), int(parts[1])
|
| 209 |
+
else:
|
| 210 |
+
raise ValueError(f"动作段格式应为 dir:mult:cells 或 dx,dy:cells,得到 {seg!r}")
|
| 211 |
+
vel += [v] * n
|
| 212 |
+
if len(vel) != cells:
|
| 213 |
+
raise ValueError(f"动作脚本的 cell 数合计应为 {cells}(--cells),得到 {len(vel)}")
|
| 214 |
+
vel = np.stack(vel, 0)
|
| 215 |
+
if cells == G.CELLS: # 与上游对表,防止两份解析漂移
|
| 216 |
+
_, vel_up = infer.parse_actions(spec, walk_speed)
|
| 217 |
+
assert np.array_equal(vel, vel_up), "parse_actions_n 与 infer.parse_actions 解析结果不一致"
|
| 218 |
+
return vel
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def extend_velocity(vel: np.ndarray, cells: int) -> np.ndarray:
|
| 222 |
+
"""把动作脚本的逐 cell 速度延长到 cells 个 cell:重复最后一段的速度。
|
| 223 |
+
|
| 224 |
+
评测**故意**不用训练侧默认的 `resample`:那是给训练喂多样性的,随机重抽方向之后
|
| 225 |
+
「指令」就不再是一个可核对的东西了。重复最后一段速度 = 一条明确的、可以拿来比对的指令,
|
| 226 |
+
在 cell 层面与 `data.extend_offsets(mode="continue")` 同解。
|
| 227 |
+
"""
|
| 228 |
+
if cells <= len(vel):
|
| 229 |
+
return vel[:cells]
|
| 230 |
+
return np.concatenate([vel, np.repeat(vel[-1:], cells - len(vel), axis=0)], 0)
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
# ----------------------------------------------------------------------------
|
| 234 |
+
# 两条部署采样路径
|
| 235 |
+
# ----------------------------------------------------------------------------
|
| 236 |
+
|
| 237 |
+
ROT_MASK = ("rot", "plain") # rot = 斜的 known 多边形(几何正确);plain = 老的轴对齐掩码(消融)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def parallel_mask(off_t: torch.Tensor, ang_t, lh: int, lw: int, rot_mask: str = "rot"):
|
| 241 |
+
"""并行路径的 known 掩码。ang_t [b, f] 是**绝对**朝向(cell 0 = 0):cell k 自己的格点先转 φ_k
|
| 242 |
+
进世界系再判定是否落在 cell 0 的足迹内。`_known_rotated` 的口径是"偏移在该 cell 自己的相机系里、
|
| 243 |
+
角度取锚点相对该 cell",所以这里传 R(−φ_k)·off_k 与 −φ_k。ang_t=None ⇒ 老路。"""
|
| 244 |
+
if ang_t is None or rot_mask == "plain":
|
| 245 |
+
return make_mask_input(off_t, lh, lw)
|
| 246 |
+
off_local = rot2d(off_t, -ang_t)
|
| 247 |
+
return make_mask_input(off_local, lh, lw, angle=-ang_t)
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
@torch.no_grad()
|
| 251 |
+
def deploy_parallel(model: AropeDiT, z0: torch.Tensor, context, context_ids, off_t: torch.Tensor,
|
| 252 |
+
deploy: tuple, *, seed: int = 0, log=print, ang_t=None,
|
| 253 |
+
rot_mask: str = "rot", scale_t=None) -> torch.Tensor:
|
| 254 |
+
"""整片并行的少步部署(stage2 EMA 自己的路径)。cell 0 每一步都写回首帧 latent。"""
|
| 255 |
+
_, sig, t_grid = deploy
|
| 256 |
+
b, c, _, lh, lw = z0.shape[0], z0.shape[1], 0, z0.shape[3], z0.shape[4]
|
| 257 |
+
cells = off_t.shape[1]
|
| 258 |
+
g = torch.Generator("cpu").manual_seed(seed)
|
| 259 |
+
latents = torch.randn(b, c, cells, lh, lw, generator=g).to(dtype=z0.dtype, device=z0.device)
|
| 260 |
+
latents[:, :, 0:1] = z0
|
| 261 |
+
mask_in = parallel_mask(off_t, ang_t, lh, lw, rot_mask)
|
| 262 |
+
t0 = time.time()
|
| 263 |
+
for i in range(len(t_grid)):
|
| 264 |
+
ts = torch.full((b,), float(t_grid[i]), device=z0.device)
|
| 265 |
+
v = model(latents, ts, context, context_ids=context_ids, offset_px=off_t,
|
| 266 |
+
mask_input=mask_in, first_frame_cond=True, angle_px=ang_t, scale_px=scale_t)
|
| 267 |
+
den = latents - float(sig[i]) * v # x0 估计
|
| 268 |
+
if i + 1 == len(t_grid):
|
| 269 |
+
latents = den
|
| 270 |
+
else:
|
| 271 |
+
ns = float(sig[i + 1])
|
| 272 |
+
latents = (1.0 - ns) * den + ns * torch.randn(den.shape, generator=g).to(den)
|
| 273 |
+
latents[:, :, 0:1] = z0 # TI2V 锚点,每步写回
|
| 274 |
+
log(f" parallel step {i + 1}/{len(t_grid)} t={float(t_grid[i]):.0f} σ={float(sig[i]):.4f} "
|
| 275 |
+
f"· {time.time() - t0:.1f}s")
|
| 276 |
+
return latents
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
@torch.no_grad()
|
| 280 |
+
def deploy_parallel_flowmap(model: AropeDiT, z0: torch.Tensor, context, context_ids,
|
| 281 |
+
off_t: torch.Tensor, sigmas, *, seed: int = 0, log=print) -> torch.Tensor:
|
| 282 |
+
"""整片并行的 **flow map** 部署(stage2-flowmap 的 EMA 走这条)。
|
| 283 |
+
|
| 284 |
+
与 renoise 版的两处**本质**差别,接错任何一处都不会报错、只会安静地产出另一个模型:
|
| 285 |
+
|
| 286 |
+
1. 模型要吃**两个**时间步。每步喂 (t_i, r_i) = (σ_i, σ_{i+1})·1000,它预测的是区间
|
| 287 |
+
[r, t] 上的**均**速度,不是 t 处的瞬时速度。
|
| 288 |
+
2. **全程不重新加噪**。每步就是 `x ← x − (σ_t − σ_r)·u`;轴的末位是 0.0,所以最后一步
|
| 289 |
+
直接落在干净估计上,不需要再单独读一次 x0。renoise 版每步都要重采一份新噪声,
|
| 290 |
+
那是一致性/扩散学生的采样方式,拿来采 flow map 会多注入 N 份噪声。
|
| 291 |
+
|
| 292 |
+
`sigmas` 长度 N+1、严格降序、末位 0.0(`flowmap.anystep_sigmas(N)`)。N 可以任意取 ——
|
| 293 |
+
这正是 any-step 的主张本身,4 步和 24 步用的是同一份权重。
|
| 294 |
+
"""
|
| 295 |
+
fm.assert_anystep_axis(sigmas if torch.is_tensor(sigmas) else torch.as_tensor(sigmas), terminal=True)
|
| 296 |
+
b, c, _, lh, lw = z0.shape[0], z0.shape[1], 0, z0.shape[3], z0.shape[4]
|
| 297 |
+
cells = off_t.shape[1]
|
| 298 |
+
g = torch.Generator("cpu").manual_seed(seed)
|
| 299 |
+
x = torch.randn(b, c, cells, lh, lw, generator=g).to(dtype=z0.dtype, device=z0.device)
|
| 300 |
+
x[:, :, 0:1] = z0
|
| 301 |
+
mask_in = make_mask_input(off_t, lh, lw)
|
| 302 |
+
t0 = time.time()
|
| 303 |
+
N = len(sigmas) - 1
|
| 304 |
+
for i in range(N):
|
| 305 |
+
s_t, s_r = float(sigmas[i]), float(sigmas[i + 1])
|
| 306 |
+
tt = torch.full((b,), s_t * fm.NUM_TRAIN_TIMESTEPS, device=z0.device)
|
| 307 |
+
rr = torch.full((b,), s_r * fm.NUM_TRAIN_TIMESTEPS, device=z0.device)
|
| 308 |
+
u = model(x, tt, context, context_ids=context_ids, r_timestep=rr,
|
| 309 |
+
offset_px=off_t, mask_input=mask_in, first_frame_cond=True)
|
| 310 |
+
x = fm.euler_step(x, u, s_t, s_r)
|
| 311 |
+
x[:, :, 0:1] = z0 # TI2V 锚点,每步写回
|
| 312 |
+
log(f" flowmap parallel {i + 1}/{N} σ {s_t:.4f}→{s_r:.4f} · {time.time() - t0:.1f}s")
|
| 313 |
+
return x
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
@torch.no_grad()
|
| 317 |
+
def deploy_rollout_flowmap(model: AropeDiT, z0: torch.Tensor, ctx_ext, ids_ext, off_ext: torch.Tensor,
|
| 318 |
+
cells: int, sigmas, *, kv_window: int = 20, sink_size: int = 0,
|
| 319 |
+
rope_cap: int = 20, seed: int = 0, log=print, sink_anchor: str = "absolute") -> torch.Tensor:
|
| 320 |
+
"""滑窗 KV 的逐 cell 自回归 **flow map** 部署。
|
| 321 |
+
|
| 322 |
+
结构与 `deploy_rollout` 逐行对应,只把每个 cell 内部的采样链换成 flow-map Euler:
|
| 323 |
+
每个 cell 沿整条 any-step 轴走完(无重新加噪),然后**照旧**用 `t=0` 提交 detach 过的
|
| 324 |
+
结果进缓存 —— 提交趟的 t=0 与 flow map 无关,它是 TF clean 流的同分布要求,不能改。
|
| 325 |
+
"""
|
| 326 |
+
fm.assert_anystep_axis(sigmas if torch.is_tensor(sigmas) else torch.as_tensor(sigmas), terminal=True)
|
| 327 |
+
b, c, _, lh, lw = z0.shape
|
| 328 |
+
dev = z0.device
|
| 329 |
+
bare = unwrap(model)
|
| 330 |
+
cache = bare.init_kv_cache(kv_window=kv_window, sink_size=sink_size, rope_cap=rope_cap)
|
| 331 |
+
g = torch.Generator("cpu").manual_seed(seed)
|
| 332 |
+
N = len(sigmas) - 1
|
| 333 |
+
|
| 334 |
+
def _call(x, t, k, commit, ow, mk, r=None):
|
| 335 |
+
return model(x, t, ctx_ext[:, k:k + 1], context_ids=ids_ext[:, k:k + 1],
|
| 336 |
+
r_timestep=(t if r is None else r),
|
| 337 |
+
first_frame_cond=False, frame_index=k, kv_cache=cache, commit=commit,
|
| 338 |
+
offset_win=ow, mask_in_k=mk)
|
| 339 |
+
|
| 340 |
+
ow0, mk0 = frame_cond(off_ext, 0, sink_size, kv_window, lh, lw, sink_anchor)
|
| 341 |
+
zeros = torch.zeros(b, device=dev)
|
| 342 |
+
_call(z0, zeros, 0, True, ow0, mk0) # 真实首帧,t=r=0 进缓存
|
| 343 |
+
frames = [z0]
|
| 344 |
+
t0 = time.time()
|
| 345 |
+
for k in range(1, cells):
|
| 346 |
+
ow, mk = frame_cond(off_ext, k, sink_size, kv_window, lh, lw, sink_anchor)
|
| 347 |
+
x = torch.randn(b, c, 1, lh, lw, generator=g).to(dtype=z0.dtype, device=dev)
|
| 348 |
+
for i in range(N):
|
| 349 |
+
s_t, s_r = float(sigmas[i]), float(sigmas[i + 1])
|
| 350 |
+
tt = torch.full((b,), s_t * fm.NUM_TRAIN_TIMESTEPS, device=dev)
|
| 351 |
+
rr = torch.full((b,), s_r * fm.NUM_TRAIN_TIMESTEPS, device=dev)
|
| 352 |
+
x = fm.euler_step(x, _call(x, tt, k, False, ow, mk, r=rr), s_t, s_r)
|
| 353 |
+
frames.append(x)
|
| 354 |
+
_call(x.detach(), zeros, k, True, ow, mk) # 每个 cell 恰好提交一次,t=0
|
| 355 |
+
if k % 5 == 0 or k == cells - 1:
|
| 356 |
+
log(f" flowmap rollout cell {k}/{cells - 1} · {time.time() - t0:.1f}s")
|
| 357 |
+
out = torch.cat(frames, dim=2)
|
| 358 |
+
bare.release_kv_cache(cache) # cache 与 _ref 成环,不显���清会拖着十几 GB 不放
|
| 359 |
+
return out
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
@torch.no_grad()
|
| 363 |
+
def build_angles(args, cells: int, device, log=print):
|
| 364 |
+
"""--rotate <每 cell 度数> / --rotate_spec "<deg>:<n>,..." ⇒ [1, cells] 的逐 cell 相机朝向(弧度)。
|
| 365 |
+
|
| 366 |
+
cell 0 恒为 0(首帧定义世界系);朝向是**累计**的:φ_k = φ_{k−1} + 该段的每格角速度。
|
| 367 |
+
都没给 ⇒ None,整条 rollout 逐位走旋转之前的老路。
|
| 368 |
+
"""
|
| 369 |
+
spec = getattr(args, "rotate_spec", None)
|
| 370 |
+
rate = float(getattr(args, "rotate", 0.0) or 0.0)
|
| 371 |
+
if not spec and rate == 0.0:
|
| 372 |
+
return None
|
| 373 |
+
per_cell = [0.0] * cells
|
| 374 |
+
if spec:
|
| 375 |
+
k = 1
|
| 376 |
+
for seg in [t.strip() for t in spec.split(",") if t.strip()]:
|
| 377 |
+
deg, n = seg.split(":")
|
| 378 |
+
for _ in range(int(n)):
|
| 379 |
+
if k < cells:
|
| 380 |
+
per_cell[k] = float(deg)
|
| 381 |
+
k += 1
|
| 382 |
+
if k < cells: # 没铺满 ⇒ 末段的角速度续到底
|
| 383 |
+
last = float(spec.split(",")[-1].split(":")[0])
|
| 384 |
+
for j in range(k, cells):
|
| 385 |
+
per_cell[j] = last
|
| 386 |
+
else:
|
| 387 |
+
for j in range(1, cells):
|
| 388 |
+
per_cell[j] = rate
|
| 389 |
+
ang = torch.tensor(per_cell, dtype=torch.float32, device=device).cumsum(0).unsqueeze(0)
|
| 390 |
+
ang = ang * math.pi / 180.0
|
| 391 |
+
deg = (ang[0] * 180.0 / math.pi).tolist()
|
| 392 |
+
log(f" --rotate:相机朝向 cell0={deg[0]:.1f}° → cell{cells - 1}={deg[-1]:.1f}°"
|
| 393 |
+
f"(每格最大 {max(per_cell):.2f}°;画幅角点离中心 ~15 token,1° 约合角点 0.26 token 位移)")
|
| 394 |
+
return ang
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def build_scales(args, cells: int, device, log=print):
|
| 398 |
+
"""--zoom <每 cell 的位置缩放率>:位置整体等比缩放 s_k = (1+rate)^k(cell 0 = 1)。
|
| 399 |
+
|
| 400 |
+
判决实验用:位置铺得越开,这一帧名义上覆盖的世界就越大。若模型把"位置场变大"读成"视野变大",
|
| 401 |
+
内容应当按 1/s 缩小 —— 那就说明旋转时看到的缩小也是同一条通道,靠推理端补不回来。
|
| 402 |
+
"""
|
| 403 |
+
rate = float(getattr(args, "zoom", 0.0) or 0.0)
|
| 404 |
+
if rate == 0.0:
|
| 405 |
+
return None
|
| 406 |
+
s = torch.tensor([(1.0 + rate) ** k for k in range(cells)], dtype=torch.float32, device=device).unsqueeze(0)
|
| 407 |
+
log(f" --zoom:位置缩放 cell0={float(s[0, 0]):.3f} → cell{cells - 1}={float(s[0, -1]):.3f}"
|
| 408 |
+
f"(若模型按视野读,内容应缩小到 1/{float(s[0, -1]):.3f} = {1 / float(s[0, -1]):.3f})")
|
| 409 |
+
return s
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def frame_cond_rot(off_ext: torch.Tensor, ang_ext: torch.Tensor, k: int, sink_size: int,
|
| 413 |
+
kv_window: int, lat_h: int, lat_w: int, sink_anchor: str = "absolute",
|
| 414 |
+
rot_mask: str = "rot"):
|
| 415 |
+
"""带相机旋转的逐 cell 条件:返回 (offset_win, mask_in_k, angle_win)。
|
| 416 |
+
|
| 417 |
+
一切表达在**当前 cell 的相机系**里(`angle_win[:, -1] == 0`,当前帧格点保持轴对齐):
|
| 418 |
+
· 偏移:先把全部世界偏移转 −φ_k 进当前相机系,再走与 frame_cond 完全相同的窗口锚定;
|
| 419 |
+
· 角度:φ_j − φ_k(历史 cell 相对当前相机转了多少);
|
| 420 |
+
· 掩码:known 的判据仍是"落在锚点 cell 的足迹内",锚点相对当前转了 φ_a − φ_k,于是足迹是斜的。
|
| 421 |
+
ang_ext 全 0 时逐位退化成 `frame_cond`(本函数自带断言核对)。
|
| 422 |
+
"""
|
| 423 |
+
idx = retained_cells(k, sink_size, kv_window) + [k]
|
| 424 |
+
sel = torch.as_tensor(idx, device=off_ext.device, dtype=torch.long)
|
| 425 |
+
phi_k = ang_ext[:, k] # [b]
|
| 426 |
+
ang_win = ang_ext.index_select(1, sel) - phi_k.unsqueeze(1) # [b, n+1],最后一项恒 0
|
| 427 |
+
off_rot = rot2d(off_ext, -phi_k.unsqueeze(1)) # 整条转进当前相机系
|
| 428 |
+
ow = window_offsets_for_frame(off_rot, k, sink_size, kv_window, sink_anchor=sink_anchor)
|
| 429 |
+
# 锚点 = ow 里被减成 0 的那一格(absolute ⇒ idx[0];window 且缓存饱和 ⇒ idx[sink_size])
|
| 430 |
+
saturated = kv_window is not None and k > sink_size + kv_window
|
| 431 |
+
a = sink_size if (sink_anchor == "window" and sink_size > 0 and saturated) else 0
|
| 432 |
+
mk = (make_mask_input(ow[:, -1:], lat_h, lat_w) if rot_mask == "plain"
|
| 433 |
+
else make_mask_input(ow[:, -1:], lat_h, lat_w, angle=ang_win[:, a:a + 1]))
|
| 434 |
+
return ow, mk, ang_win
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
def deploy_rollout(model: AropeDiT, z0: torch.Tensor, ctx_ext, ids_ext, off_ext: torch.Tensor,
|
| 438 |
+
cells: int, deploy: tuple, *, kv_window: int = 20, sink_size: int = 0,
|
| 439 |
+
rope_cap: int = 20, seed: int = 0, log=print, euler=None, sink_anchor: str = "absolute",
|
| 440 |
+
ang_ext: torch.Tensor | None = None, rot_mask: str = "rot") -> torch.Tensor:
|
| 441 |
+
"""滑窗 KV 的逐 cell 自回归部署 —— stage3 的 self_forcing_rollout 的 no-grad 孪生。
|
| 442 |
+
|
| 443 |
+
euler 非 None ⇒ 每个 cell 不走 renoise 链,而是用这个 FlowMatchScheduler 的 Euler ODE
|
| 444 |
+
(`sched.step`,与 `infer.sample` 同一条路;longhorizon/README 的「ODE 16 步」口径),
|
| 445 |
+
从纯噪声 σ=1 一路���分到 σ=0。KV 提交与条件完全不变。
|
| 446 |
+
|
| 447 |
+
与训练时的唯一差别:推理不抽退出档,每个 cell 把整张部署表走完再提交。
|
| 448 |
+
条件(窗口锚定的 offset_win / 当前 cell 的 known 掩码)直接用训练侧的 frame_cond,
|
| 449 |
+
提交趟同样是 **detach 过的 x0 + t=0**(这个 0 让缓存历史与 TF 的 clean 流同分布)。
|
| 450 |
+
"""
|
| 451 |
+
_, sig, t_grid = deploy
|
| 452 |
+
b, c, _, lh, lw = z0.shape
|
| 453 |
+
dev = z0.device
|
| 454 |
+
bare = unwrap(model)
|
| 455 |
+
cache = bare.init_kv_cache(kv_window=kv_window, sink_size=sink_size, rope_cap=rope_cap)
|
| 456 |
+
g = torch.Generator("cpu").manual_seed(seed)
|
| 457 |
+
|
| 458 |
+
def _call(x, t, k, commit, ow, mk, aw=None):
|
| 459 |
+
return model(x, t, ctx_ext[:, k:k + 1], context_ids=ids_ext[:, k:k + 1],
|
| 460 |
+
first_frame_cond=False, frame_index=k, kv_cache=cache, commit=commit,
|
| 461 |
+
offset_win=ow, mask_in_k=mk, angle_win=aw)
|
| 462 |
+
|
| 463 |
+
def _cond(k):
|
| 464 |
+
# ang_ext=None ⇒ 完全走旋转之前的那条路(frame_cond),逐位不变
|
| 465 |
+
if ang_ext is None:
|
| 466 |
+
ow_, mk_ = frame_cond(off_ext, k, sink_size, kv_window, lh, lw, sink_anchor)
|
| 467 |
+
return ow_, mk_, None
|
| 468 |
+
return frame_cond_rot(off_ext, ang_ext, k, sink_size, kv_window, lh, lw, sink_anchor, rot_mask)
|
| 469 |
+
|
| 470 |
+
ow0, mk0, aw0 = _cond(0)
|
| 471 |
+
_call(z0, torch.zeros(b, device=dev), 0, True, ow0, mk0, aw0) # 真实首帧,t=0 进缓存
|
| 472 |
+
frames = [z0]
|
| 473 |
+
t0 = time.time()
|
| 474 |
+
for k in range(1, cells):
|
| 475 |
+
ow, mk, aw = _cond(k)
|
| 476 |
+
noisy = torch.randn(b, c, 1, lh, lw, generator=g).to(dtype=z0.dtype, device=dev)
|
| 477 |
+
den = None
|
| 478 |
+
if euler is not None:
|
| 479 |
+
x = noisy
|
| 480 |
+
for i, t in enumerate(euler.timesteps):
|
| 481 |
+
ts = torch.full((b,), float(t), device=dev)
|
| 482 |
+
x = euler.step(_call(x, ts, k, False, ow, mk, aw), euler.timesteps[i], x)
|
| 483 |
+
den = x
|
| 484 |
+
else:
|
| 485 |
+
for i in range(len(t_grid)):
|
| 486 |
+
ts = torch.full((b,), float(t_grid[i]), device=dev)
|
| 487 |
+
den = noisy - float(sig[i]) * _call(noisy, ts, k, False, ow, mk, aw)
|
| 488 |
+
if i + 1 < len(t_grid):
|
| 489 |
+
ns = float(sig[i + 1])
|
| 490 |
+
noisy = (1.0 - ns) * den + ns * torch.randn(den.shape, generator=g).to(den)
|
| 491 |
+
frames.append(den)
|
| 492 |
+
_call(den.detach(), torch.zeros(b, device=dev), k, True, ow, mk, aw) # 每个 cell 恰好提交一次
|
| 493 |
+
if k % 5 == 0 or k == cells - 1:
|
| 494 |
+
log(f" rollout cell {k}/{cells - 1} · {time.time() - t0:.1f}s")
|
| 495 |
+
return torch.cat(frames, dim=2)
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
# ----------------------------------------------------------------------------
|
| 499 |
+
# 一次评测
|
| 500 |
+
# ----------------------------------------------------------------------------
|
| 501 |
+
|
| 502 |
+
def measure(frames: np.ndarray, frame_off: np.ndarray, chain: int = 16) -> dict:
|
| 503 |
+
"""SIFT+RANSAC 实测背景位移 vs 指令。指令背景位移 = −frame_offset_px[最后一帧]。"""
|
| 504 |
+
k = len(frames) - 1
|
| 505 |
+
meas = infer.measure_displacement(infer.to_gray(frames), k=k, chain=chain)
|
| 506 |
+
expect = (-frame_off[k]).tolist()
|
| 507 |
+
err = None if meas["sift"] is None else [meas["sift"][0] - expect[0], meas["sift"][1] - expect[1]]
|
| 508 |
+
out = {"k": k, "measured": meas, "expected_bg_shift": expect, "error_px": err}
|
| 509 |
+
if err is not None:
|
| 510 |
+
out["error_norm_px"] = float(np.hypot(*err))
|
| 511 |
+
out["expected_norm_px"] = float(np.hypot(*expect))
|
| 512 |
+
out["error_rel"] = (out["error_norm_px"] / out["expected_norm_px"]
|
| 513 |
+
if out["expected_norm_px"] > 1e-6 else None)
|
| 514 |
+
return out
|
| 515 |
+
|
| 516 |
+
|
| 517 |
+
def run_one(model, pipe, z0, cond, args, deploy, log=print, sigmas=None) -> dict:
|
| 518 |
+
"""生成 + 解码 + 实测。cond 由 build_condition() 给出。
|
| 519 |
+
|
| 520 |
+
`sigmas` 非 None ⇒ 走 flow-map 的 any-step Euler(无重新加噪,模型吃 (t, r) 两个时间步);
|
| 521 |
+
None ⇒ 走原来的 renoise 链。两者接错都不报错,所以分流在 main() 里按 checkpoint 决定。
|
| 522 |
+
"""
|
| 523 |
+
t0 = time.time()
|
| 524 |
+
if args.mode == "rollout":
|
| 525 |
+
if sigmas is not None:
|
| 526 |
+
if cond.get("ang_ext") is not None:
|
| 527 |
+
raise SystemExit("--rotate 只支持 renoise / euler 的 rollout 路径,flowmap 分支没接")
|
| 528 |
+
lat = deploy_rollout_flowmap(model, z0, cond["ctx_ext"], cond["ids_ext"], cond["off_ext"],
|
| 529 |
+
cond["cells"], sigmas, kv_window=args.kv_window,
|
| 530 |
+
sink_size=args.sink_size, rope_cap=args.rope_cap,
|
| 531 |
+
seed=args.seed, log=log, sink_anchor=args.sink_anchor)
|
| 532 |
+
else:
|
| 533 |
+
lat = deploy_rollout(model, z0, cond["ctx_ext"], cond["ids_ext"], cond["off_ext"],
|
| 534 |
+
cond["cells"], deploy, kv_window=args.kv_window,
|
| 535 |
+
sink_size=args.sink_size, rope_cap=args.rope_cap, seed=args.seed, log=log,
|
| 536 |
+
euler=getattr(args, "euler_sched", None), sink_anchor=args.sink_anchor,
|
| 537 |
+
ang_ext=cond.get("ang_ext"), rot_mask=args.rotate_mask)
|
| 538 |
+
else:
|
| 539 |
+
if sigmas is not None:
|
| 540 |
+
lat = deploy_parallel_flowmap(model, z0, cond["context"], cond["context_ids"],
|
| 541 |
+
cond["off_par"], sigmas, seed=args.seed, log=log)
|
| 542 |
+
else:
|
| 543 |
+
ang_par = cond.get("ang_ext")
|
| 544 |
+
if ang_par is not None:
|
| 545 |
+
ang_par = ang_par[:, :cond["off_par"].shape[1]]
|
| 546 |
+
lat = deploy_parallel(model, z0, cond["context"], cond["context_ids"], cond["off_par"],
|
| 547 |
+
deploy, seed=args.seed, log=log, ang_t=ang_par,
|
| 548 |
+
rot_mask=args.rotate_mask, scale_t=cond.get("scale_ext"))
|
| 549 |
+
torch.cuda.synchronize() if z0.is_cuda else None
|
| 550 |
+
sample_sec = time.time() - t0
|
| 551 |
+
assert torch.isfinite(lat).all(), "采样输出里有 NaN/Inf"
|
| 552 |
+
t0 = time.time()
|
| 553 |
+
frames = infer.decode_video(pipe, lat, tiled=args.tiled)
|
| 554 |
+
dec_sec = time.time() - t0
|
| 555 |
+
res = measure(frames, cond["frame_off"], chain=args.chain)
|
| 556 |
+
res.update(sample_sec=sample_sec, decode_sec=dec_sec, frames=int(frames.shape[0]),
|
| 557 |
+
cells=cond["cells"], mode=args.mode)
|
| 558 |
+
return res, frames, lat
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
def apply_late_trigger(prompts21: list[str], spec: str, cells: int) -> list[str]:
|
| 562 |
+
"""把 21 条(已 strip_action 的)逐 cell 场景串延到 cells 条,并在 cell ≥ 21 处再切一次场景。
|
| 563 |
+
|
| 564 |
+
spec "burn:<C>:<start>:<n>"(或 cut:):cell [start, start+n) 用转场从句「B burning away into C」,
|
| 565 |
+
之后的 cell 切成纯 C;start 之前保持 cell 20 的场景 B(= extend_context 的 hold)。start 必须 ≥ 21,
|
| 566 |
+
21 以内的切换走 --trigger(那是训练语料的口径)。串必须在文本表里(14 个源场景 × 13 个目的都有)。
|
| 567 |
+
"""
|
| 568 |
+
kind, c, start, n = spec.split(":")
|
| 569 |
+
start, n = int(start), int(n)
|
| 570 |
+
if kind not in infer.TRIG_TMPL:
|
| 571 |
+
raise ValueError(f"--late_trigger 类型应为 burn/cut,得到 {kind!r}")
|
| 572 |
+
if not (G.CELLS <= start < cells and n >= 1 and start + n <= cells):
|
| 573 |
+
raise ValueError(f"--late_trigger 的 start/n 越界:start={start} n={n} cells={cells}(start 须 ≥ {G.CELLS})")
|
| 574 |
+
m = re.match(r"^In a 2\.5D top down view, the player is (.+)\.$", prompts21[-1])
|
| 575 |
+
if not m:
|
| 576 |
+
raise ValueError(f"cell 20 没有场景从句({prompts21[-1]!r}),无法构造转场;请给 --scene")
|
| 577 |
+
a, c_nl = m.group(1), infer.scene_clause(c)
|
| 578 |
+
trig = infer.TRIG_TMPL[kind].format(a=a, b=c_nl[3:] if c_nl.startswith("in ") else c_nl)
|
| 579 |
+
tmpl = "In a 2.5D top down view, the player is {scene}."
|
| 580 |
+
out = list(prompts21) + [prompts21[-1]] * (start - G.CELLS)
|
| 581 |
+
out += [tmpl.format(scene=trig)] * n + [tmpl.format(scene=c_nl)] * (cells - start - n)
|
| 582 |
+
assert len(out) == cells
|
| 583 |
+
return out
|
| 584 |
+
|
| 585 |
+
|
| 586 |
+
def apply_switches(scene_a: str, spec: str, cells: int) -> list[str]:
|
| 587 |
+
"""从 --scene A 起,按 spec "burn:B:10:2,burn:C:20:2,cut:D:30:2" 在任意 cell 处连续切换多次场景,
|
| 588 |
+
直接生成整条 cells 长的逐 cell 场景串(与 apply_late_trigger 同一套转场从句与模板;串都在文本表里)。
|
| 589 |
+
每段:[start, start+n) 用「上一场景 burning away into 新场景」,之后是纯新场景,直到下一次切换。"""
|
| 590 |
+
tmpl = "In a 2.5D top down view, the player is {scene}."
|
| 591 |
+
cur = infer.scene_clause(scene_a)
|
| 592 |
+
segs = []
|
| 593 |
+
for tok in [t.strip() for t in spec.split(",") if t.strip()]:
|
| 594 |
+
kind, c, start, n = tok.split(":")
|
| 595 |
+
start, n = int(start), int(n)
|
| 596 |
+
if kind not in infer.TRIG_TMPL:
|
| 597 |
+
raise ValueError(f"--switches 类型应为 burn/cut,得到 {kind!r}")
|
| 598 |
+
segs.append((start, n, kind, c))
|
| 599 |
+
segs.sort()
|
| 600 |
+
out: list[str] = []
|
| 601 |
+
for start, n, kind, c in segs:
|
| 602 |
+
if not (len(out) <= start and 1 <= start and start + n <= cells):
|
| 603 |
+
raise ValueError(f"--switches 段越界或重叠:start={start} n={n} cells={cells},已生成 {len(out)} 条")
|
| 604 |
+
out += [tmpl.format(scene=cur)] * (start - len(out))
|
| 605 |
+
c_nl = infer.scene_clause(c)
|
| 606 |
+
trig = infer.TRIG_TMPL[kind].format(a=cur, b=c_nl[3:] if c_nl.startswith("in ") else c_nl)
|
| 607 |
+
out += [tmpl.format(scene=trig)] * n
|
| 608 |
+
cur = c_nl
|
| 609 |
+
out += [tmpl.format(scene=cur)] * (cells - len(out))
|
| 610 |
+
assert len(out) == cells
|
| 611 |
+
return out
|
| 612 |
+
|
| 613 |
+
|
| 614 |
+
def build_condition(args, enc, device, log=print) -> dict:
|
| 615 |
+
"""指令 → 逐帧偏移 / 逐 cell 偏移 / 逐 cell 文本 / 首帧规格。"""
|
| 616 |
+
sc = torch.load(infer.SIDECAR, map_location="cpu", weights_only=False)
|
| 617 |
+
walk_speed = np.array(sc["meta"]["walk_speed_px_s"], np.float64)
|
| 618 |
+
cells = args.cells
|
| 619 |
+
if args.replay:
|
| 620 |
+
row = infer.load_meta_row(args.replay)
|
| 621 |
+
c = sc["clips"][args.replay]
|
| 622 |
+
frame_off21 = c["frame_offset_px"].numpy().astype(np.float64) # [81,2] 真值
|
| 623 |
+
prompts = [infer.strip_action(p) for p in row["prompts"]] # ARoPE 臂只看场景
|
| 624 |
+
first_spec = args.replay
|
| 625 |
+
co21 = G.frames_to_cells(frame_off21)
|
| 626 |
+
assert np.array_equal(co21, frames_to_cells_n(frame_off21, G.CELLS)), "逐 cell 归约口径分叉了"
|
| 627 |
+
if cells == G.CELLS:
|
| 628 |
+
frame_off, cell_off = frame_off21, co21
|
| 629 |
+
elif cells < G.CELLS:
|
| 630 |
+
# 截短(冒烟/计时用):cell k 覆盖帧 4k−3…4k,所以前 cells 个 cell 用前 4·cells−3 帧。
|
| 631 |
+
# 不能整段拿 81 帧去配 cells 个 cell —— frames_to_cells_n 会直接断言失败。
|
| 632 |
+
n_keep = CELL_FRAMES * cells - (CELL_FRAMES - 1)
|
| 633 |
+
frame_off = frame_off21[:n_keep]
|
| 634 |
+
cell_off = frames_to_cells_n(frame_off, cells)
|
| 635 |
+
assert np.array_equal(cell_off, co21[:cells]), "截短不该改动前 cells 个 cell 的真值"
|
| 636 |
+
log(f" replay 截到 {cells} cell({n_keep} 帧)")
|
| 637 |
+
else:
|
| 638 |
+
# 21 个 cell 之内**原样用真值逐帧偏移**(sidecar 的真实轨迹不是分段匀速,重建一遍会失真),
|
| 639 |
+
# 21 之外按末段隐含的匀速续走(= data.extend_offsets("continue"))
|
| 640 |
+
v_last = (co21[-1] - co21[-2]) * G.FPS / CELL_FRAMES
|
| 641 |
+
n_ext = CELL_FRAMES * (cells - G.CELLS)
|
| 642 |
+
ext = frame_off21[-1] + np.outer(np.arange(1, n_ext + 1) / G.FPS, v_last)
|
| 643 |
+
frame_off = np.concatenate([frame_off21, ext], 0)
|
| 644 |
+
cell_off = frames_to_cells_n(frame_off, cells)
|
| 645 |
+
assert np.array_equal(cell_off[:G.CELLS], co21), "延长不该改动前 21 个 cell 的真值"
|
| 646 |
+
log(f" replay 延到 {cells} cell:末段速度 ({v_last[0]:+.1f},{v_last[1]:+.1f}) px/s 续走")
|
| 647 |
+
else:
|
| 648 |
+
if not (args.first_frame and args.actions):
|
| 649 |
+
raise SystemExit("非 --replay 模式需要 --first_frame 和 --actions")
|
| 650 |
+
n_spec = sum(int(s.split(":")[-1]) for s in args.actions.split(",") if ":" in s)
|
| 651 |
+
if n_spec == cells and cells != G.CELLS:
|
| 652 |
+
# 多段指令铺满整个 rollout("绕一圈"、"第 30 格转向")—— 超出训练视野的指令
|
| 653 |
+
# 才是 AR rollout 值得测的东西。直线到底仍走下面那条 21+extend 的路。
|
| 654 |
+
vel = parse_actions_n(args.actions, walk_speed, cells)
|
| 655 |
+
log(f" 动作脚本铺满 {cells} cell(不是 21+续走)")
|
| 656 |
+
else:
|
| 657 |
+
_assert_matches_parse_actions(args.actions, walk_speed) # 与 infer.py 同一条递推
|
| 658 |
+
_, vel21 = infer.parse_actions(args.actions, walk_speed)
|
| 659 |
+
vel = extend_velocity(vel21, cells)
|
| 660 |
+
frame_off, cell_off = action_offsets(vel, cells)
|
| 661 |
+
# 文本只含场景(ARoPE 臂走 strip_action),动作走 RoPE 不走文本,所以标签取前 21 格即可
|
| 662 |
+
labels21 = [infer.velocity_to_label(v, mirror=False) for v in vel[:G.CELLS]]
|
| 663 |
+
if args.scene:
|
| 664 |
+
prompts = [infer.strip_action(p) for p in
|
| 665 |
+
infer.compose_cell_prompts(args.scene, args.trigger, labels21)]
|
| 666 |
+
else:
|
| 667 |
+
# bg50 的 50 张背景不属于 18 个训练场景,配不出场景从句 —— eval_bg50.py:189-192
|
| 668 |
+
# 的默认口径就是空条件串,这里对齐它,别硬编一个模型没见过的场景描述。
|
| 669 |
+
prompts = [NULL_PROMPT] * G.CELLS
|
| 670 |
+
log(f" 未给 --scene,用空条件串 {NULL_PROMPT!r}(bg50 的默认口径)")
|
| 671 |
+
first_spec = args.first_frame
|
| 672 |
+
assert len(prompts) == G.CELLS, f"prompt 应为 {G.CELLS} 条,得到 {len(prompts)}"
|
| 673 |
+
context, context_ids = infer.build_context(enc, prompts, device, per_cell=True) # [1,21,512,4096]
|
| 674 |
+
off21 = torch.tensor(cell_off[:G.CELLS], dtype=torch.float32).unsqueeze(0).to(device)
|
| 675 |
+
off_full = torch.tensor(cell_off, dtype=torch.float32).unsqueeze(0).to(device)
|
| 676 |
+
# 逐 cell 文本延到 1+N(cell ≥ 21 保持最后一条,与训练侧 extend_context 同一函数)
|
| 677 |
+
ctx_ext, ids_ext = extend_context(context, context_ids, cells - 1, mode="hold")
|
| 678 |
+
if getattr(args, "late_trigger", None):
|
| 679 |
+
prompts_n = apply_late_trigger(prompts, args.late_trigger, cells)
|
| 680 |
+
ctx_ext, ids_ext = infer.build_context(enc, prompts_n, device, per_cell=True) # [1, cells, 512, 4096]
|
| 681 |
+
sw = [k for k in range(1, cells) if prompts_n[k] != prompts_n[k - 1]]
|
| 682 |
+
log(f" --late_trigger {args.late_trigger}:cell {sw} 处切换,{len(set(prompts_n))} 条不同的串")
|
| 683 |
+
prompts = prompts_n
|
| 684 |
+
if getattr(args, "switches", None):
|
| 685 |
+
if not args.scene:
|
| 686 |
+
raise SystemExit("--switches 需要 --scene 作为起始场景")
|
| 687 |
+
prompts_n = apply_switches(args.scene, args.switches, cells)
|
| 688 |
+
ctx_ext, ids_ext = infer.build_context(enc, prompts_n, device, per_cell=True) # [1, cells, 512, 4096]
|
| 689 |
+
sw = [k for k in range(1, cells) if prompts_n[k] != prompts_n[k - 1]]
|
| 690 |
+
log(f" --switches {args.switches}:cell {sw} 处切换,{len(set(prompts_n))} 条不同的串")
|
| 691 |
+
prompts = prompts_n
|
| 692 |
+
off_ext = off_full if cells > G.CELLS else off21
|
| 693 |
+
# 整片并行路径喂的是绝对世界坐标(cell 0 恒为 0,本来就锚定);rollout 路径在每个 cell 现算窗口锚定
|
| 694 |
+
off_par = window_offsets(off_ext[:, :min(cells, off_ext.shape[1])], 0, min(cells, off_ext.shape[1]) - 1)
|
| 695 |
+
return {"frame_off": frame_off, "cell_off": cell_off, "cells": cells, "prompts": prompts,
|
| 696 |
+
"context": ctx_ext[:, :cells], "context_ids": ids_ext[:, :cells],
|
| 697 |
+
"ctx_ext": ctx_ext, "ids_ext": ids_ext, "off_ext": off_ext, "off_par": off_par,
|
| 698 |
+
"ang_ext": build_angles(args, cells, off_ext.device, log=log),
|
| 699 |
+
"scale_ext": build_scales(args, cells, off_ext.device, log=log),
|
| 700 |
+
"first_spec": first_spec, "walk_speed": walk_speed.tolist()}
|
| 701 |
+
|
| 702 |
+
|
| 703 |
+
# ----------------------------------------------------------------------------
|
| 704 |
+
# 自检(tiny 模型,不碰真权重 / 不需要 VAE)
|
| 705 |
+
# ----------------------------------------------------------------------------
|
| 706 |
+
|
| 707 |
+
def self_test(log=print, device: str | None = None) -> None:
|
| 708 |
+
from diffsynth.models.wan_video_dit import WanModel
|
| 709 |
+
tiny = dict(dim=128, in_dim=48, ffn_dim=256, out_dim=48, text_dim=4096, freq_dim=256, eps=1e-6,
|
| 710 |
+
patch_size=(1, 2, 2), num_heads=4, num_layers=2, has_image_input=False)
|
| 711 |
+
# 尊重 --device:--self_test 不需要 GPU,别在训练用的卡上白建一个 CUDA 上下文
|
| 712 |
+
dev = device if device else ("cuda" if torch.cuda.is_available() else "cpu")
|
| 713 |
+
if str(dev).startswith("cuda") and not torch.cuda.is_available():
|
| 714 |
+
dev = "cpu"
|
| 715 |
+
torch.manual_seed(0)
|
| 716 |
+
model = AropeDiT(dit=WanModel(**tiny).to(torch.bfloat16), mask_channel=True, causal=True)
|
| 717 |
+
model.eval().requires_grad_(False).to(dev)
|
| 718 |
+
unwrap(model).move_rope_table(torch.device(dev))
|
| 719 |
+
deploy = deploy_schedule(DEPLOY_DENOISE_LIST)
|
| 720 |
+
log(f"1. 部署表 t={[round(float(x), 1) for x in deploy[2]]} σ={[round(float(x), 4) for x in deploy[1]]}")
|
| 721 |
+
|
| 722 |
+
sc = torch.load(infer.SIDECAR, map_location="cpu", weights_only=False)
|
| 723 |
+
ws = np.array(sc["meta"]["walk_speed_px_s"], np.float64)
|
| 724 |
+
_assert_matches_parse_actions("right:1.0:21", ws)
|
| 725 |
+
fo, vel = infer.parse_actions("right:1.0:21", ws)
|
| 726 |
+
log(f"2. 指令递推 action_offsets(vel,21) 与 infer.parse_actions / frames_to_cells 逐位相同;"
|
| 727 |
+
f"frame_offset_px[80]=({fo[80, 0]:+.1f},{fo[80, 1]:+.1f}) ⇒ 期望背景位移 ({-fo[80, 0]:+.1f},{-fo[80, 1]:+.1f})")
|
| 728 |
+
N = 30
|
| 729 |
+
vel_ext = extend_velocity(vel, N)
|
| 730 |
+
fo_ext, co_ext = action_offsets(vel_ext, N)
|
| 731 |
+
assert np.allclose(fo_ext[:81], fo), "延长之后前 81 帧必须与 21 cell 的结果完全一致"
|
| 732 |
+
off_ext_t = torch.tensor(co_ext, dtype=torch.float32).unsqueeze(0).to(dev)
|
| 733 |
+
ref = extend_offsets(torch.tensor(G.frames_to_cells(fo), dtype=torch.float32).unsqueeze(0).to(dev),
|
| 734 |
+
N - 1, mode="continue")
|
| 735 |
+
d = float((off_ext_t - ref).abs().max())
|
| 736 |
+
log(f"3. 延长口径 action_offsets(continue) vs data.extend_offsets('continue') max|Δ| {d:.3e} px "
|
| 737 |
+
f"({N} cell,都是重复末段匀速)")
|
| 738 |
+
assert d < 1e-3, d
|
| 739 |
+
|
| 740 |
+
b, c, lh, lw, L = 1, 48, 8, 10, 4
|
| 741 |
+
z0 = torch.randn(b, c, 1, lh, lw, device=dev, dtype=torch.bfloat16)
|
| 742 |
+
ctx = torch.randn(b, G.CELLS, L, 4096, device=dev, dtype=torch.bfloat16)
|
| 743 |
+
ids = torch.zeros(b, G.CELLS, dtype=torch.int64, device=dev)
|
| 744 |
+
ctx_ext, ids_ext = extend_context(ctx, ids, N - 1, mode="hold")
|
| 745 |
+
lat = deploy_rollout(model, z0, ctx_ext, ids_ext, off_ext_t, N, deploy,
|
| 746 |
+
kv_window=20, sink_size=0, rope_cap=20, seed=0, log=lambda *a: None)
|
| 747 |
+
assert lat.shape == (b, c, N, lh, lw) and torch.isfinite(lat).all()
|
| 748 |
+
log(f"4. rollout {N} cell(窗口 20 ⇒ 驱逐 {max(0, N - 1 - 20)} 次)输出 {tuple(lat.shape)} 全部有限;"
|
| 749 |
+
f"|lat| 均值 {float(lat.float().abs().mean()):.4f}")
|
| 750 |
+
|
| 751 |
+
off_par = window_offsets(off_ext_t[:, :G.CELLS], 0, G.CELLS - 1)
|
| 752 |
+
lat_p = deploy_parallel(model, z0, ctx, ids, off_par, deploy, seed=0, log=lambda *a: None)
|
| 753 |
+
assert lat_p.shape == (b, c, G.CELLS, lh, lw) and torch.isfinite(lat_p).all()
|
| 754 |
+
assert torch.equal(lat_p[:, :, 0:1], z0), "parallel 路径必须每步把 cell 0 写回首帧 latent"
|
| 755 |
+
log(f"5. parallel {G.CELLS} cell 输出 {tuple(lat_p.shape)} 全部有限、cell 0 == 首帧 latent;"
|
| 756 |
+
f"|lat| 均值 {float(lat_p.float().abs().mean()):.4f}")
|
| 757 |
+
|
| 758 |
+
# 6. 用一段**已知平移**的合成画面把 measure() 的接线和符号约定钉死。
|
| 759 |
+
# measure_displacement 的 sift 是**画面内容**在图像坐标里的位移;指令一侧比的是
|
| 760 |
+
# −frame_offset_px(玩家往右走 ⇒ 背景往左挪),所以两者必须差一个负号。
|
| 761 |
+
rng = np.random.default_rng(0)
|
| 762 |
+
pad = 200
|
| 763 |
+
base = (rng.random((G.H + 2 * pad, G.W + 2 * pad)) * 255).astype(np.uint8)
|
| 764 |
+
d_content = np.array([6.0, -2.0]) # 每帧内容位移(px)
|
| 765 |
+
syn = np.stack([np.stack([base[pad - int(round(d_content[1] * k)): pad - int(round(d_content[1] * k)) + G.H,
|
| 766 |
+
pad - int(round(d_content[0] * k)): pad - int(round(d_content[0] * k)) + G.W]] * 3,
|
| 767 |
+
-1) for k in range(G.CELLS)], 0)
|
| 768 |
+
# 指令 = 内容位移的相反数 ⇒ 误差应当 ≈ 0
|
| 769 |
+
fo_syn = np.stack([-d_content * k for k in range(G.CELLS)], 0)
|
| 770 |
+
res = measure(syn, fo_syn, chain=8)
|
| 771 |
+
log(f"6. 实测接线 帧0→{res['k']} 合成平移真值 {(d_content * res['k']).tolist()} · "
|
| 772 |
+
f"sift={infer.fmt(res['measured']['sift'])}({res['measured']['sift_source']},"
|
| 773 |
+
f"{res['measured']['sift_direct_inliers']} 内点)· 指令 {infer.fmt(res['expected_bg_shift'])} · "
|
| 774 |
+
f"误差 {infer.fmt(res['error_px'])} ⇒ |误差| {res['error_norm_px']:.3f} px")
|
| 775 |
+
assert res["error_norm_px"] < 1.0, res
|
| 776 |
+
log("self_test OK")
|
| 777 |
+
|
| 778 |
+
|
| 779 |
+
# ----------------------------------------------------------------------------
|
| 780 |
+
# 主流程
|
| 781 |
+
# ----------------------------------------------------------------------------
|
| 782 |
+
|
| 783 |
+
def parse_args(argv=None):
|
| 784 |
+
ap = argparse.ArgumentParser(description="蒸馏产物的部署路径评测(少步 / AR rollout + 背景位移实测)")
|
| 785 |
+
ap.add_argument("--ckpt", default=None, help="蒸馏产物 safetensors;部署权重是 ema/,不是 dits/")
|
| 786 |
+
ap.add_argument("--sweep", default=None,
|
| 787 |
+
help="glob(如 '.../ema/ema_step*.safetensors'):按 step 依次评测。"
|
| 788 |
+
"只加载一次 10 GB pipeline,之后只换 DiT 权重")
|
| 789 |
+
ap.add_argument("--allow_raw_student", action="store_true",
|
| 790 |
+
help="放行路径里带 /dits/ 的 checkpoint。dits/ 只为续训存在,拿它上线是静默降质(risk 12)")
|
| 791 |
+
ap.add_argument("--mode", choices=["rollout", "parallel"], default="rollout",
|
| 792 |
+
help="rollout:滑窗 KV 逐 cell 自回归(stage3 的部署路径)。"
|
| 793 |
+
"parallel:整片块因果一次去噪(stage2 EMA 的部署路径)")
|
| 794 |
+
ap.add_argument("--causal", action=argparse.BooleanOptionalAction, default=True,
|
| 795 |
+
help="蒸馏产物都是因果的。评双向 SFT 基线时用 --no-causal --mode parallel")
|
| 796 |
+
ap.add_argument("--denoise_list", default=",".join(str(int(x)) for x in DEPLOY_DENOISE_LIST),
|
| 797 |
+
help="部署采样表(原始 t 值,严格降序)。默认就是用户拍板的 4 步 1000,750,500,250;"
|
| 798 |
+
"stage2 EMA 单独部署时用 1000,250")
|
| 799 |
+
ap.add_argument("--cells", type=int, default=G.CELLS,
|
| 800 |
+
help="生成多少个 latent cell。>21 只在 --mode rollout 下有意义(那正是 stage3 练的东西)")
|
| 801 |
+
ap.add_argument("--kv_window", type=int, default=20)
|
| 802 |
+
ap.add_argument("--sink_size", type=int, default=0)
|
| 803 |
+
ap.add_argument("--rope_cap", type=int, default=20)
|
| 804 |
+
ap.add_argument("--sink_anchor", choices=("absolute", "window"), default="absolute",
|
| 805 |
+
help="sink_size>0 时世界坐标锚点:absolute = cell 0(当前 RoPE 位无界增长);window = 滑动窗最老 cell,"
|
| 806 |
+
"sink 段偏移钉 0(内容可见、RoPE 在包络内)。stage3 ckpt 元数据里有这个键时必须一致")
|
| 807 |
+
ap.add_argument("--allow_geometry_mismatch", action="store_true",
|
| 808 |
+
help="ckpt 元数据里的训练几何(stage3 do_save 写的 kv_window/sink_size/rope_cap)与命令行"
|
| 809 |
+
"不等时也跑。默认拒绝:stage3 学生只在训练几何下才是分布内的(GEO-1),"
|
| 810 |
+
"sink 3 / w17 之类只作显式 A/B,不作 ckpt 选择轴。stage1/2 的 ckpt 没有这些键,不受限")
|
| 811 |
+
ap.add_argument("--ref_steps", type=int, default=0,
|
| 812 |
+
help=">0 ⇒ 额外跑一遍 infer.sample 的 N 步扩散采样作参照(PORT_SPEC 的 S9,一般填 50)")
|
| 813 |
+
ap.add_argument("--sampler", choices=["auto", "flowmap", "renoise", "euler"], default="auto",
|
| 814 |
+
help="auto(默认)= 按 checkpoint 自己判(832 键 + delta_embedding ⇒ flowmap)。"
|
| 815 |
+
"两者接错都不会报错、只会安静地产出另一个模型的结果,所以显式指定时与权重不符会被拒。"
|
| 816 |
+
"euler = 非 flow-map 权重 + --mode rollout 时,每个 cell 走 FlowMatchScheduler 的 "
|
| 817 |
+
"Euler ODE(--euler_steps / --euler_shift,与 infer.sample 同一条 sched.step),不重新加噪")
|
| 818 |
+
ap.add_argument("--euler_steps", type=int, default=16, help="--sampler euler 的步数(longhorizon/README 用 16)")
|
| 819 |
+
ap.add_argument("--euler_shift", type=float, default=5.0, help="--sampler euler 的 shift(infer.sample 用 5)")
|
| 820 |
+
ap.add_argument("--num_inference_steps", type=int, default=4,
|
| 821 |
+
help="flow-map 采样的步数 N(--sampler flowmap/auto 命中时才用)。"
|
| 822 |
+
"同一份权重 4 步和 24 步都能采 —— 这正是 any-step 的主张,验收就看这两个的一致性。"
|
| 823 |
+
"renoise 路径不看这个参数,它用 --denoise_list")
|
| 824 |
+
|
| 825 |
+
ap.add_argument("--replay", default=None, help="clip_id:用 sidecar 真值偏移 + 该 clip 首帧 + 原 prompt")
|
| 826 |
+
ap.add_argument("--first_frame", default=None, help="png / mp4 / clip_id")
|
| 827 |
+
ap.add_argument("--scene", default=None)
|
| 828 |
+
ap.add_argument("--trigger", default=None, help="burn:<B>:<start>:<n>(或 cut:),start < 21:训练语料口径的转场")
|
| 829 |
+
ap.add_argument("--rotate", type=float, default=0.0,
|
| 830 |
+
help="相机每 cell 旋转多少**度**(累计;cell 0 恒为 0)。一切表达在当前相机系里:当前帧格点"
|
| 831 |
+
"保持轴对齐,历史 token 按 φ_j−φ_k 转进来。0 = 不转(逐位走老路)")
|
| 832 |
+
ap.add_argument("--zoom", type=float, default=0.0,
|
| 833 |
+
help="每 cell 的**位置缩放**率(判决实验,只接并行路径):位置乘 (1+rate)^k。"
|
| 834 |
+
"模型若把位置场的尺度读成视野大小,内容就会按 1/(1+rate)^k 缩小")
|
| 835 |
+
ap.add_argument("--rotate_mask", choices=sorted(ROT_MASK), default="rot",
|
| 836 |
+
help="旋转时 known 掩码怎么算:rot = 跟着转的斜多边形(几何正确,四角变 new);"
|
| 837 |
+
"plain = 老的轴对齐掩码(消融:看画面缩小/摆动是不是掩码通道闹的)")
|
| 838 |
+
ap.add_argument("--rotate_spec", default=None,
|
| 839 |
+
help='分段角速度 "2:20,0:20,-2:20"(每格度数:格数,铺不满就把末段续到底),与 --rotate 二选一')
|
| 840 |
+
ap.add_argument("--switches", default=None,
|
| 841 |
+
help='多次场景切换脚本 "burn:B:10:2,burn:C:20:2,cut:D:30:2"(从 --scene 起,任意 cell,整条 rollout '
|
| 842 |
+
'的逐 cell 串直接生成;与 --trigger/--late_trigger 互斥)')
|
| 843 |
+
ap.add_argument("--late_trigger", default=None,
|
| 844 |
+
help="burn:<C>:<start>:<n>(或 cut:),start ≥ 21:rollout 视野外再切一次场景(cell 20 的场景 → C)")
|
| 845 |
+
ap.add_argument("--actions", default=None,
|
| 846 |
+
help='动作脚本,如 "right:1.0:21"。cell 合计 == --cells 时铺满整条 rollout(如 loop_spec.txt 的 41 cell),'
|
| 847 |
+
'否则必须正好 21 个 cell、cell ≥21 按末段速度续走')
|
| 848 |
+
ap.add_argument("--text_table", default=None, help="默认用 arope 臂的纯场景表")
|
| 849 |
+
ap.add_argument("--chain", type=int, default=16, help="SIFT 链式测量的分段长度")
|
| 850 |
+
ap.add_argument("--tiled", action="store_true", help="VAE 分块解码(长 rollout 显存吃紧时用)")
|
| 851 |
+
ap.add_argument("--seed", type=int, default=0)
|
| 852 |
+
ap.add_argument("--device", default="cuda")
|
| 853 |
+
ap.add_argument("--out", default=None, help="输出 mp4(同名 _frame0.png / .json 一并写出)")
|
| 854 |
+
ap.add_argument("--out_dir", default=None, help="--sweep 时的输出目录")
|
| 855 |
+
ap.add_argument("--save_video", action=argparse.BooleanOptionalAction, default=True,
|
| 856 |
+
help="--sweep 扫很多个 ckpt 时可以 --no-save_video 只留 json")
|
| 857 |
+
ap.add_argument("--gpu_headroom_gb", type=float, default=40.0,
|
| 858 |
+
help="目标卡至少要有这么多空闲显存才肯跑(权重 9.3 GB + rollout + VAE 解码)")
|
| 859 |
+
ap.add_argument("--allow_busy_gpu", action="store_true",
|
| 860 |
+
help="卡上有别的任务时也跑;会把本进程的显存**硬顶**在空闲量减 8 GB,"
|
| 861 |
+
"撑死只炸自己,不会把同卡的训练挤 OOM")
|
| 862 |
+
ap.add_argument("--self_test", action="store_true", help="tiny 模型自检,不碰真权重、不需要 VAE")
|
| 863 |
+
args = ap.parse_args(argv)
|
| 864 |
+
if args.self_test:
|
| 865 |
+
return args
|
| 866 |
+
if not (args.ckpt or args.sweep):
|
| 867 |
+
raise SystemExit("要么 --ckpt,要么 --sweep")
|
| 868 |
+
if args.sweep and not args.out_dir:
|
| 869 |
+
raise SystemExit("--sweep 需要 --out_dir")
|
| 870 |
+
if args.ckpt and not args.out:
|
| 871 |
+
raise SystemExit("--ckpt 需要 --out")
|
| 872 |
+
if args.cells < 2:
|
| 873 |
+
raise SystemExit(f"--cells 至少 2,得到 {args.cells}")
|
| 874 |
+
if args.cells != G.CELLS and args.mode != "rollout":
|
| 875 |
+
raise SystemExit(f"--cells {args.cells} != {G.CELLS} 只在 --mode rollout 下成立"
|
| 876 |
+
f"(parallel 是整片一次去噪,长度由文本/偏移的 cell 数决定,"
|
| 877 |
+
f"超出 21 就没有真实训练分布可言)")
|
| 878 |
+
if args.rope_cap != args.sink_size + args.kv_window:
|
| 879 |
+
raise SystemExit(f"--rope_cap({args.rope_cap}) 必须 == sink({args.sink_size}) + window({args.kv_window})")
|
| 880 |
+
args.denoise = [float(t) for t in args.denoise_list.split(",") if t.strip()]
|
| 881 |
+
args.euler_sched = None
|
| 882 |
+
if args.sampler == "euler":
|
| 883 |
+
if args.mode != "rollout":
|
| 884 |
+
raise SystemExit("--sampler euler 只实现了 --mode rollout(parallel 请用 --ref_steps 走 infer.sample)")
|
| 885 |
+
from diffsynth.diffusion.flow_match import FlowMatchScheduler
|
| 886 |
+
args.euler_sched = FlowMatchScheduler("Wan")
|
| 887 |
+
args.euler_sched.set_timesteps(args.euler_steps, shift=args.euler_shift)
|
| 888 |
+
return args
|
| 889 |
+
|
| 890 |
+
|
| 891 |
+
def _ckpt_list(args) -> list[str]:
|
| 892 |
+
paths = sorted(glob.glob(args.sweep), key=lambda p: int("".join(ch for ch in os.path.basename(p)
|
| 893 |
+
if ch.isdigit()) or 0)) if args.sweep \
|
| 894 |
+
else [args.ckpt]
|
| 895 |
+
if not paths:
|
| 896 |
+
raise SystemExit(f"--sweep 没匹配到任何文件:{args.sweep}")
|
| 897 |
+
for p in paths:
|
| 898 |
+
if "/dits/" in p.replace(os.sep, "/") and not args.allow_raw_student:
|
| 899 |
+
raise SystemExit(
|
| 900 |
+
f"拒绝评测 {p}:dits/ 是续训用的原始学生权重,部署权重是 ema/。\n"
|
| 901 |
+
" 拿 dits/ 上线不会报错,只会静默降质(PORT_SPEC risk 12)。"
|
| 902 |
+
"确实想看原始学生就加 --allow_raw_student。")
|
| 903 |
+
if not os.path.isfile(p):
|
| 904 |
+
raise SystemExit(f"checkpoint 不存在:{p}")
|
| 905 |
+
return paths
|
| 906 |
+
|
| 907 |
+
|
| 908 |
+
def _require_gpu_headroom(device: str, need_gb: float, allow_busy: bool, log=print) -> None:
|
| 909 |
+
"""评测默认**不许**跟正在跑的训练抢同一张卡。
|
| 910 |
+
|
| 911 |
+
为什么:本脚本要 9.3 GB 权重 + rollout(实测 N=26 峰值 12.6 GB)+ VAE 解码 81~101 帧,
|
| 912 |
+
几十 GB 的量级;蒸馏训练是 6~40 h 的长任务,被一次评测挤 OOM 的代价远大于等它跑完。
|
| 913 |
+
而文件头的示例命令写的正是 `CUDA_VISIBLE_DEVICES=0` —— 那恰好是训练在用的卡。
|
| 914 |
+
|
| 915 |
+
用 nvidia-smi 查(不是 torch.cuda.mem_get_info):后者要先在目标卡上建 CUDA 上下文,
|
| 916 |
+
光是"看一眼"就先占几百 MB。查不到就拒绝启动,与 distill/common.sh 的 require_idle_gpus 同一套 fail-closed 口径。
|
| 917 |
+
--allow_busy_gpu 时不再拒绝,但把本进程钉死在 (空闲 − 8 GB):共享可以,但必须是**有界**的共享。
|
| 918 |
+
"""
|
| 919 |
+
if not str(device).startswith("cuda") or not torch.cuda.is_available():
|
| 920 |
+
return
|
| 921 |
+
idx = torch.device(device).index
|
| 922 |
+
if idx is None: # "cuda" ⇒ 当前默认卡(CUDA_VISIBLE_DEVICES 里的第 0 张)
|
| 923 |
+
idx = 0
|
| 924 |
+
phys = os.environ.get("CUDA_VISIBLE_DEVICES")
|
| 925 |
+
phys = phys.split(",")[idx].strip() if phys else str(idx)
|
| 926 |
+
try:
|
| 927 |
+
out = subprocess.run(["nvidia-smi", "-i", phys, "--query-gpu=memory.used,memory.total",
|
| 928 |
+
"--format=csv,noheader,nounits"], capture_output=True, text=True,
|
| 929 |
+
timeout=30, check=True).stdout.strip().splitlines()
|
| 930 |
+
used, total = (float(x) for x in out[0].split(","))
|
| 931 |
+
except Exception as e: # 驱动半死 / 编号非法 / 超时 —— 查不到就不跑
|
| 932 |
+
raise SystemExit(f"ABORT: 查不到 GPU {phys} 的占用({type(e).__name__}: {e})。"
|
| 933 |
+
"查不到就不许启动;要绕过请显式 --device cpu 或 --allow_busy_gpu。")
|
| 934 |
+
free = (total - used) / 1024.0 # MiB -> GiB
|
| 935 |
+
log(f"[gpu] 物理 GPU {phys}:已用 {used / 1024:.1f} / {total / 1024:.0f} GiB,空闲 {free:.1f} GiB")
|
| 936 |
+
if used / 1024.0 > 1.0 and not allow_busy:
|
| 937 |
+
raise SystemExit(
|
| 938 |
+
f"ABORT: GPU {phys} 上已经有 {used / 1024:.1f} GiB 被占用 —— 多半是蒸馏训练还在跑。"
|
| 939 |
+
f"评测要 ~{need_gb:.0f} GiB,别去挤它。等它跑完,或换一张空卡"
|
| 940 |
+
f"(CUDA_VISIBLE_DEVICES=<空闲卡>,**不要**动别人的卡),"
|
| 941 |
+
f"或 --allow_busy_gpu(会把本进程硬顶在 {max(free - 8, 4):.0f} GiB,只炸自己)。")
|
| 942 |
+
if free < need_gb and not allow_busy:
|
| 943 |
+
raise SystemExit(f"ABORT: GPU {phys} 只剩 {free:.1f} GiB 空闲 < --gpu_headroom_gb {need_gb:.0f}。")
|
| 944 |
+
if allow_busy:
|
| 945 |
+
cap = max(free - 8.0, 4.0) # 留 8 GiB 给同卡的训练做碎片/峰值余量
|
| 946 |
+
torch.cuda.set_per_process_memory_fraction(min(cap / (total / 1024.0), 0.95), idx)
|
| 947 |
+
log(f"[gpu] --allow_busy_gpu:本进程显存硬顶 {cap:.0f} GiB(超了只 OOM 我自己)")
|
| 948 |
+
|
| 949 |
+
|
| 950 |
+
def main(argv=None):
|
| 951 |
+
args = parse_args(argv)
|
| 952 |
+
if args.self_test:
|
| 953 |
+
self_test(device=args.device)
|
| 954 |
+
return 0
|
| 955 |
+
paths = _ckpt_list(args)
|
| 956 |
+
for p in paths: # 只读 safetensors 头:训练几何 vs 命令行(GEO-1),权重还没碰
|
| 957 |
+
_check_ckpt_geometry(p, args)
|
| 958 |
+
# ★ 在加载 10 GB pipeline 之前先问一句这张卡是不是别人的(fail-closed,见函数注释)
|
| 959 |
+
_require_gpu_headroom(args.device, args.gpu_headroom_gb, args.allow_busy_gpu)
|
| 960 |
+
deploy = deploy_schedule(args.denoise)
|
| 961 |
+
print(f"部署表 {args.denoise} ⇒ 网格 t={[round(float(x), 1) for x in deploy[2]]} "
|
| 962 |
+
f"σ={[round(float(x), 4) for x in deploy[1]]} · mode={args.mode} · cells={args.cells}")
|
| 963 |
+
|
| 964 |
+
# ---- 采样器分流:按 **checkpoint 本身**(832 键 + flowmap metadata)决定,不看命令行心情 ----
|
| 965 |
+
# 拿 renoise 采样器去采 flow-map 权重不会报错,只会安静地产出另一个模型的结果 ——
|
| 966 |
+
# 所以默认 auto 从权重里读,而不是让调用方记得加旗标。
|
| 967 |
+
is_fm = [runlib.is_flowmap_ckpt(p) for p in paths]
|
| 968 |
+
if args.sampler == "auto":
|
| 969 |
+
use_flowmap = is_fm[0]
|
| 970 |
+
else:
|
| 971 |
+
use_flowmap = (args.sampler == "flowmap") # renoise / euler 都是非 flow-map 权重的采样器
|
| 972 |
+
if use_flowmap != is_fm[0]:
|
| 973 |
+
raise SystemExit(
|
| 974 |
+
f"--sampler {args.sampler} 与 checkpoint 不符:{paths[0]} "
|
| 975 |
+
f"{'是' if is_fm[0] else '不是'} flow-map 权重(832 键 + delta_embedding)。\n"
|
| 976 |
+
f" 两者接错都不会报错,只会安静地产出另一个模型的结果,所以这里直接拒绝。")
|
| 977 |
+
if len(set(is_fm)) > 1:
|
| 978 |
+
raise SystemExit("--sweep 里混了 flow-map 与非 flow-map 的 checkpoint;分开扫。")
|
| 979 |
+
|
| 980 |
+
from safetensors.torch import load_file
|
| 981 |
+
t0 = time.time()
|
| 982 |
+
if use_flowmap:
|
| 983 |
+
# 832 键喂不进 load_models 的 strict 加载(它只认 827),所以:基座 → 装壳 → 灌权重。
|
| 984 |
+
# 顺序不能反:install_flowmap 会把读出层零初始化,装完再 load 才拿得到训出来的 delta。
|
| 985 |
+
meta = _ckpt_metadata(paths[0])
|
| 986 |
+
pipe, dit, _ = infer.load_models(None, True, args.device, need_vae=True, arm="arope")
|
| 987 |
+
model = AropeDiT(dit=dit, mask_channel=True, causal=args.causal)
|
| 988 |
+
unwrap(model).install_flowmap(form=meta.get("flowmap_form", "residual"),
|
| 989 |
+
gate=float(meta.get("delta_gate", 1.0)))
|
| 990 |
+
unwrap(model).dit.load_state_dict(load_file(paths[0]), strict=True)
|
| 991 |
+
has_mask = True
|
| 992 |
+
else:
|
| 993 |
+
pipe, dit, _ = infer.load_models(paths[0], None, args.device, need_vae=True, arm="arope")
|
| 994 |
+
has_mask = hasattr(dit, "arope_mask_embedding")
|
| 995 |
+
model = AropeDiT(dit=dit, mask_channel=has_mask, causal=args.causal)
|
| 996 |
+
model.eval().requires_grad_(False)
|
| 997 |
+
unwrap(model).move_rope_table(torch.device(args.device))
|
| 998 |
+
sig_any = fm.anystep_sigmas(args.num_inference_steps) if use_flowmap else None
|
| 999 |
+
print(f"模型加载 {time.time() - t0:.0f}s · causal={args.causal} · mask_channel={has_mask} · "
|
| 1000 |
+
f"采样器={'flowmap' if use_flowmap else ('euler' if args.euler_sched is not None else 'renoise')}"
|
| 1001 |
+
+ (f" · Euler {args.euler_steps} 步 shift {args.euler_shift} t={[round(float(t)) for t in args.euler_sched.timesteps]}"
|
| 1002 |
+
if args.euler_sched is not None else "")
|
| 1003 |
+
+ (f" · N={args.num_inference_steps} 步 σ={[round(float(x), 4) for x in sig_any]}"
|
| 1004 |
+
if use_flowmap else ""))
|
| 1005 |
+
|
| 1006 |
+
table_key = "arope"
|
| 1007 |
+
enc = infer.TextEncoder(args.text_table or infer.TEXT_TABLE[table_key], args.device)
|
| 1008 |
+
cond = build_condition(args, enc, args.device)
|
| 1009 |
+
fo = cond["frame_off"]
|
| 1010 |
+
print(f"指令:{cond['cells']} cell · frame_offset_px[末帧]=({fo[-1, 0]:+.1f},{fo[-1, 1]:+.1f}) "
|
| 1011 |
+
f"⇒ 期望背景位移 ({-fo[-1, 0]:+.1f},{-fo[-1, 1]:+.1f})")
|
| 1012 |
+
img = infer.load_first_frame(cond["first_spec"])
|
| 1013 |
+
z0 = infer.encode_first_frame(pipe, img, tiled=args.tiled)
|
| 1014 |
+
|
| 1015 |
+
from safetensors.torch import load_file
|
| 1016 |
+
rows = []
|
| 1017 |
+
for p in paths:
|
| 1018 |
+
if p != paths[0]:
|
| 1019 |
+
# 只换 DiT 权重,不重建 10 GB pipeline(sweep 的全部意义)
|
| 1020 |
+
unwrap(model).dit.load_state_dict(load_file(p), strict=True)
|
| 1021 |
+
step = "".join(ch for ch in os.path.basename(p) if ch.isdigit())
|
| 1022 |
+
stem = (os.path.splitext(args.out)[0] if args.out
|
| 1023 |
+
else os.path.join(args.out_dir, os.path.splitext(os.path.basename(p))[0]))
|
| 1024 |
+
os.makedirs(os.path.dirname(os.path.abspath(stem)), exist_ok=True)
|
| 1025 |
+
print(f"\n=== {os.path.basename(p)} ===")
|
| 1026 |
+
torch.cuda.reset_peak_memory_stats() if args.device.startswith("cuda") else None
|
| 1027 |
+
res, frames, lat = run_one(model, pipe, z0, cond, args, deploy, sigmas=sig_any)
|
| 1028 |
+
info = {"ckpt": p, "step": int(step) if step else None, "mode": args.mode,
|
| 1029 |
+
"sampler": ("flowmap" if use_flowmap else ("euler" if args.euler_sched is not None else "renoise")),
|
| 1030 |
+
"euler": ({"steps": args.euler_steps, "shift": args.euler_shift} if args.euler_sched is not None else None),
|
| 1031 |
+
"num_inference_steps": (args.num_inference_steps if use_flowmap else None),
|
| 1032 |
+
"anystep_sigmas": ([round(float(x), 6) for x in sig_any] if use_flowmap else None),
|
| 1033 |
+
"denoise_list": (None if use_flowmap else args.denoise),
|
| 1034 |
+
"cells": cond["cells"], "seed": args.seed,
|
| 1035 |
+
"kv_window": args.kv_window, "sink_size": args.sink_size, "rope_cap": args.rope_cap,
|
| 1036 |
+
"prompts": sorted(set(cond["prompts"])), "walk_speed_px_s": cond["walk_speed"],
|
| 1037 |
+
"frame_offset_px_last": fo[-1].tolist(), **res}
|
| 1038 |
+
if args.device.startswith("cuda"):
|
| 1039 |
+
info["peak_mem_gb"] = torch.cuda.max_memory_allocated() / 1e9
|
| 1040 |
+
m = res["measured"]
|
| 1041 |
+
print(f"实测位移 帧0→{res['k']}:sift={infer.fmt(m['sift'])}({m['sift_source']})"
|
| 1042 |
+
f" · 指令 {infer.fmt(res['expected_bg_shift'])} · 误差 {infer.fmt(res['error_px'])}"
|
| 1043 |
+
+ (f" · |误差| {res['error_norm_px']:.1f} px = {100 * res['error_rel']:.1f}%"
|
| 1044 |
+
if res.get("error_rel") is not None else ""))
|
| 1045 |
+
|
| 1046 |
+
if args.ref_steps > 0:
|
| 1047 |
+
# PORT_SPEC 的 S9:同一条件下的 N 步扩散参照(infer.sample 的 Euler 步,不是少步表)
|
| 1048 |
+
lat_ref = infer.sample(unwrap(model).dit, z0, cond["context"][:, :G.CELLS],
|
| 1049 |
+
cond["context_ids"][:, :G.CELLS],
|
| 1050 |
+
cond["off_par"][:, :G.CELLS],
|
| 1051 |
+
make_mask_input(cond["off_par"][:, :G.CELLS], z0.shape[3], z0.shape[4]),
|
| 1052 |
+
steps=args.ref_steps, shift=5.0, cfg=1.0, seed=args.seed)
|
| 1053 |
+
fr_ref = infer.decode_video(pipe, lat_ref, tiled=args.tiled)
|
| 1054 |
+
info["ref"] = {"steps": args.ref_steps, **measure(fr_ref, fo[:len(fr_ref)], chain=args.chain)}
|
| 1055 |
+
rm = info["ref"]["measured"]
|
| 1056 |
+
print(f"{args.ref_steps} 步参照:sift={infer.fmt(rm['sift'])} · "
|
| 1057 |
+
f"误差 {infer.fmt(info['ref']['error_px'])}")
|
| 1058 |
+
|
| 1059 |
+
if args.replay:
|
| 1060 |
+
gt = infer.read_frames(infer.find_clip_mp4(args.replay))
|
| 1061 |
+
n = min(len(gt), len(frames))
|
| 1062 |
+
per = [infer.psnr(frames[i], gt[i]) for i in range(n)]
|
| 1063 |
+
info["psnr_frame0"] = per[0]
|
| 1064 |
+
info["psnr_mean_rest"] = float(np.mean(per[1:])) if n > 1 else None
|
| 1065 |
+
info["gt_measured"] = infer.measure_displacement(infer.to_gray(gt[:n]), k=n - 1, chain=args.chain)
|
| 1066 |
+
print(f"与 GT 比:帧 0 PSNR {per[0]:.1f} dB · 其余平均 {info['psnr_mean_rest']:.1f} dB · "
|
| 1067 |
+
f"GT 实测 sift={infer.fmt(info['gt_measured']['sift'])}")
|
| 1068 |
+
|
| 1069 |
+
if args.save_video:
|
| 1070 |
+
from PIL import Image
|
| 1071 |
+
from diffsynth.utils.data import save_video
|
| 1072 |
+
save_video(list(frames), stem + ".mp4", fps=int(G.FPS), quality=9)
|
| 1073 |
+
Image.fromarray(img).save(stem + "_frame0.png")
|
| 1074 |
+
info["out"] = stem + ".mp4"
|
| 1075 |
+
with open(stem + ".json", "w", encoding="utf-8") as fh:
|
| 1076 |
+
json.dump(info, fh, ensure_ascii=False, indent=1, default=float)
|
| 1077 |
+
rows.append(info)
|
| 1078 |
+
del frames, lat
|
| 1079 |
+
|
| 1080 |
+
if len(rows) > 1:
|
| 1081 |
+
print("\n=== sweep 汇总(按 |误差| 从小到大;部署权重就从这里挑)===")
|
| 1082 |
+
ok = [r for r in rows if r.get("error_norm_px") is not None]
|
| 1083 |
+
for r in sorted(ok, key=lambda r: r["error_norm_px"]):
|
| 1084 |
+
print(f" step {str(r['step']):>6} |误差| {r['error_norm_px']:7.1f} px "
|
| 1085 |
+
f"({100 * r['error_rel']:5.1f}%) sift={infer.fmt(r['measured']['sift'])} "
|
| 1086 |
+
f"{os.path.basename(r['ckpt'])}")
|
| 1087 |
+
for r in rows:
|
| 1088 |
+
if r.get("error_norm_px") is None:
|
| 1089 |
+
print(f" step {str(r['step']):>6} 实测失败(SIFT 匹配不上;画面可能已经崩了) "
|
| 1090 |
+
f"{os.path.basename(r['ckpt'])}")
|
| 1091 |
+
csv = os.path.join(args.out_dir, "sweep.csv")
|
| 1092 |
+
with open(csv, "w", encoding="utf-8") as fh:
|
| 1093 |
+
fh.write("step,ckpt,err_px,err_rel,sift_dx,sift_dy,exp_dx,exp_dy,sample_sec\n")
|
| 1094 |
+
for r in rows:
|
| 1095 |
+
s = r["measured"]["sift"] or [float("nan")] * 2
|
| 1096 |
+
e = r["expected_bg_shift"]
|
| 1097 |
+
fh.write(f"{r['step']},{os.path.basename(r['ckpt'])},"
|
| 1098 |
+
f"{r.get('error_norm_px', float('nan'))},{r.get('error_rel', float('nan'))},"
|
| 1099 |
+
f"{s[0]},{s[1]},{e[0]},{e[1]},{r['sample_sec']:.1f}\n")
|
| 1100 |
+
print(f"汇总写到 {csv}")
|
| 1101 |
+
return 0
|
| 1102 |
+
|
| 1103 |
+
|
| 1104 |
+
if __name__ == "__main__":
|
| 1105 |
+
sys.exit(main())
|
backup_2026-09-14/code/distill/eval/sgf_parity_probe.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SGF 两趟 replay 的几何等价性自检 —— 上 SGF 之前必须过的那一关。
|
| 2 |
+
|
| 3 |
+
SGF 的 `self_gradient_forcing_match_context` 要求 pass 2 喂的上下文与 pass 1 **真正提交进
|
| 4 |
+
KV 缓存**的东西一致。在 ARoPE 下这不是"传同一个张量"就完事的:
|
| 5 |
+
pass 1 每个 cell 锚在**自己窗口里最老那一格**(frame_cond → window_offsets_for_frame)
|
| 6 |
+
pass 2 整片并行、first_frame_cond=True、锚在**片自己的 cell 0**
|
| 7 |
+
两者只在整窗平移上不同,而 RoPE 是相对编码、平移不改分数 —— 但这个论证只在
|
| 8 |
+
**rollout 长度 == 打分片长**(缓存跨度与并行片完全重合)时成立。
|
| 9 |
+
|
| 10 |
+
所以判据是数值的:pass 1 流式在退出档算出的 x0,与 pass 2 并行 TF 在同一档算出的 x0,
|
| 11 |
+
逐 cell 比对。对不上就说明几何/掩码分叉了,这时候去训练只会得到"loss 照跑、就是不涨"。
|
| 12 |
+
|
| 13 |
+
python distill/eval/sgf_parity_probe.py --ckpt <832 键 flow map> [--cells 21] [--steps 4]
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import argparse
|
| 18 |
+
import os
|
| 19 |
+
import sys
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
import torch
|
| 23 |
+
|
| 24 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 25 |
+
sys.path.insert(0, ROOT)
|
| 26 |
+
os.environ.setdefault("DIFFSYNTH_SKIP_DOWNLOAD", "True")
|
| 27 |
+
|
| 28 |
+
from actionrope import geometry as G # noqa: E402
|
| 29 |
+
from actionrope import infer # noqa: E402
|
| 30 |
+
from actionrope.prompts import NULL_PROMPT # noqa: E402
|
| 31 |
+
from distill import flowmap as fm # noqa: E402
|
| 32 |
+
from distill import onpolicy as op # noqa: E402
|
| 33 |
+
from distill.arope_dit import AropeDiT, unwrap # noqa: E402
|
| 34 |
+
from distill.data import make_mask_input, window_context, window_offsets # noqa: E402
|
| 35 |
+
from distill.eval.rollout_eval import _ckpt_metadata # noqa: E402
|
| 36 |
+
from distill.stage3_dmd.dmd_arope import frame_cond # noqa: E402
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def main(argv=None):
|
| 40 |
+
ap = argparse.ArgumentParser(description="SGF 两趟 replay 的几何等价性自检")
|
| 41 |
+
ap.add_argument("--ckpt", required=True)
|
| 42 |
+
ap.add_argument("--cells", type=int, default=21, help="1+K;必须与训练时的片长一致")
|
| 43 |
+
ap.add_argument("--steps", type=int, default=4)
|
| 44 |
+
ap.add_argument("--kv_window", type=int, default=20)
|
| 45 |
+
ap.add_argument("--sink_size", type=int, default=0)
|
| 46 |
+
ap.add_argument("--rope_cap", type=int, default=20)
|
| 47 |
+
ap.add_argument("--device", default="cuda")
|
| 48 |
+
ap.add_argument("--tol", type=float, default=0.05, help="逐 cell 相对误差上限")
|
| 49 |
+
args = ap.parse_args(argv)
|
| 50 |
+
|
| 51 |
+
from safetensors.torch import load_file
|
| 52 |
+
meta = _ckpt_metadata(args.ckpt)
|
| 53 |
+
pipe, dit, _ = infer.load_models(None, True, args.device, need_vae=False, arm="arope")
|
| 54 |
+
model = AropeDiT(dit=dit, mask_channel=True, causal=True)
|
| 55 |
+
unwrap(model).install_flowmap(form=meta.get("flowmap_form", "residual"),
|
| 56 |
+
gate=float(meta.get("delta_gate", 1.0)))
|
| 57 |
+
unwrap(model).dit.load_state_dict(load_file(args.ckpt), strict=True)
|
| 58 |
+
model.eval().requires_grad_(False)
|
| 59 |
+
dev = torch.device(args.device)
|
| 60 |
+
unwrap(model).move_rope_table(dev)
|
| 61 |
+
|
| 62 |
+
enc = infer.TextEncoder(infer.TEXT_TABLE["arope"], args.device)
|
| 63 |
+
ctx, ids = infer.build_context(enc, [NULL_PROMPT] * G.CELLS, args.device, per_cell=True)
|
| 64 |
+
N = args.cells - 1
|
| 65 |
+
ctx_ext = ctx[:, :1].repeat(1, args.cells, 1, 1)
|
| 66 |
+
ids_ext = ids[:, :1].repeat(1, args.cells)
|
| 67 |
+
|
| 68 |
+
# 一条往右匀速的轨迹(内容不重要,重要的是每个 cell 的世界坐标都不同)
|
| 69 |
+
sc = torch.load(infer.SIDECAR, map_location="cpu", weights_only=False)
|
| 70 |
+
ws = np.array(sc["meta"]["walk_speed_px_s"], np.float64)
|
| 71 |
+
cell_off = np.stack([np.array([ws[0], 0.0]) * k * 4 / G.FPS for k in range(args.cells)], 0)
|
| 72 |
+
off_ext = torch.tensor(cell_off, dtype=torch.float32, device=dev).unsqueeze(0)
|
| 73 |
+
|
| 74 |
+
g = torch.Generator("cpu").manual_seed(0)
|
| 75 |
+
z0 = torch.randn(1, 48, 1, G.LAT_H, G.LAT_W, generator=g).to(dev, torch.bfloat16)
|
| 76 |
+
sig = fm.anystep_sigmas(args.steps)
|
| 77 |
+
|
| 78 |
+
print(f"片长 {args.cells} cell · N={args.steps} 步 · kv_window={args.kv_window} "
|
| 79 |
+
f"sink={args.sink_size} · σ={[round(float(x),4) for x in sig]}")
|
| 80 |
+
ok_all = True
|
| 81 |
+
for exit_idx in range(args.steps):
|
| 82 |
+
with torch.autocast("cuda", torch.bfloat16, enabled=(dev.type == "cuda")), torch.no_grad():
|
| 83 |
+
noisy, x0_cache = op.sgf_rollout(
|
| 84 |
+
model, z0, ctx_ext, ids_ext, off_ext, N, sig, exit_idx,
|
| 85 |
+
kv_window=args.kv_window, rope_cap=args.rope_cap, sink_size=args.sink_size,
|
| 86 |
+
gen=torch.Generator("cpu").manual_seed(1), unwrap_fn=unwrap, frame_cond_fn=frame_cond)
|
| 87 |
+
off_t = window_offsets(off_ext, 0, N)
|
| 88 |
+
ctx_t, ids_t = window_context(ctx_ext, ids_ext, 0, N)
|
| 89 |
+
cond = dict(context=ctx_t, context_ids=ids_t, offset_px=off_t,
|
| 90 |
+
mask_input=make_mask_input(off_t, G.LAT_H, G.LAT_W), first_frame_cond=True)
|
| 91 |
+
x0_p2 = op.sgf_replay(model, noisy, x0_cache, cond, sig, exit_idx, context_noise=0.0)
|
| 92 |
+
|
| 93 |
+
a = x0_cache[:, :, 1:].float()
|
| 94 |
+
b = x0_p2[:, :, 1:].float()
|
| 95 |
+
# 逐 cell 相对 L2:把 (C,H,W) 摊平再取范数(torch.norm 的 dim 只吃 2-tuple)
|
| 96 |
+
af = a[0].permute(1, 0, 2, 3).flatten(1) # [cells, C*H*W]
|
| 97 |
+
bf = b[0].permute(1, 0, 2, 3).flatten(1)
|
| 98 |
+
rel = (af - bf).norm(dim=1) / (af.norm(dim=1) + 1e-9)
|
| 99 |
+
ok = bool((rel < args.tol).all())
|
| 100 |
+
ok_all &= ok
|
| 101 |
+
print(f" exit_idx={exit_idx} (σ {float(sig[exit_idx]):.4f}→{float(sig[exit_idx+1]):.4f}) "
|
| 102 |
+
f"逐 cell 相对误差 中位 {rel.median():.4f} 最大 {rel.max():.4f} "
|
| 103 |
+
f"(cell {int(rel.argmax())+1}) {'✓' if ok else '✗ 分叉'}")
|
| 104 |
+
print("\n" + ("通过:pass 1 的流式几何与 pass 2 的并行几何一致,match_context 成立。"
|
| 105 |
+
if ok_all else
|
| 106 |
+
"★ 未通过:两趟看到的不是同一段历史。别在这个状态下训 SGF —— "
|
| 107 |
+
"loss 会照跑,模型学到的却是另一条上下文。"))
|
| 108 |
+
return 0 if ok_all else 1
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
if __name__ == "__main__":
|
| 112 |
+
raise SystemExit(main())
|
backup_2026-09-14/code/distill/eval/sweep8.sh
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# stage2-flowmap 的里程碑扫描:把 (ckpt × 步数) 的组合按卡分片,8 张卡并行。
|
| 3 |
+
#
|
| 4 |
+
# rollout_eval.py 自己是单卡的(一次只加载一份 10 GB pipeline),所以并行靠分片而不是靠它。
|
| 5 |
+
# 每个组合一个独立进程、钉死一张卡;重复加载 pipeline 的 11 s 相对 rollout 的 24~82 s 可以忽略。
|
| 6 |
+
#
|
| 7 |
+
# bash distill/eval/sweep8.sh [OUTDIR]
|
| 8 |
+
#
|
| 9 |
+
# 判据两个(都不是 loss —— flow map 的 loss 是自举目标,平的):
|
| 10 |
+
# 净漂移 px 绕一圈回到起点的误差,指令是 0。衡量动作服从性。
|
| 11 |
+
# 4 vs 24 同一份权重两个步数的一致性。这是 any-step 主张本身。
|
| 12 |
+
#
|
| 13 |
+
# 几何:默认**不传** --sink_size/--kv_window/--rope_cap ⇒ 用 rollout_eval.py 的默认 sink 0 / window 20 /
|
| 14 |
+
# cap 20 = stage3 的训练几何(distill/stage3_dmd/run.sh:57-59)。stage3 学生只在训练几何下才是
|
| 15 |
+
# 分布内的(GEO-1:sink 3 / w17 与它只在 k≤20 的保留集相同,之后的 h/w RoPE 与掩码通道是训练里
|
| 16 |
+
# 从未出现过的输入,拿它挑 EMA 只是在比 OOD 外推)。sink 3 / w17 只作**显式 A/B**,走环境变量:
|
| 17 |
+
# SINK=3 KV_WINDOW=17 ROPE_CAP=20 bash distill/eval/sweep8.sh
|
| 18 |
+
# rollout_eval.py 会拿 stage3 ckpt 元数据里的训练几何核对命令行,不等就拒(--allow_geometry_mismatch 放行)。
|
| 19 |
+
# 动作脚本:loop_spec.txt 是丢失的原始脚本的**重建**——41 cell 的闭环,净期望背景位移恰为 0:
|
| 20 |
+
# idle 放在 cell 0(那是首帧锚点,cell 0 的速度永远不生效),之后 右/下/左/上 各 10 cell = 各 40 帧,
|
| 21 |
+
# 横纵各自抵消。若把 idle 放末尾,right 段只剩 9 个生效 cell,净偏 −walk_speed_x/4 ≈ −17 px。
|
| 22 |
+
set -euo pipefail
|
| 23 |
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
| 24 |
+
ROOT="$(cd "$HERE/../.." && pwd)"
|
| 25 |
+
OUTDIR="${1:-$ROOT/outputs/distill/eval/sweep_stage2fm}"
|
| 26 |
+
EMA_DIR="${EMA_DIR:-$ROOT/outputs/distill/stage2_flowmap/ema}"
|
| 27 |
+
SPEC_FILE="${SPEC_FILE:-$ROOT/distill/eval/loop_spec.txt}"
|
| 28 |
+
FIRST_FRAME="${FIRST_FRAME:-$ROOT/data/eval/eybx_bg_50/19_medieval_castle.png}"
|
| 29 |
+
STEPS_LIST="${STEPS_LIST:-4 24}"
|
| 30 |
+
CELLS="${CELLS:-41}"
|
| 31 |
+
NGPU="${NGPU:-8}"
|
| 32 |
+
|
| 33 |
+
export PYTHONPATH="$ROOT" DIFFSYNTH_SKIP_DOWNLOAD=True CUDA_DEVICE_ORDER=PCI_BUS_ID
|
| 34 |
+
PY="$ROOT/.venv/bin/python"
|
| 35 |
+
mkdir -p "$OUTDIR"
|
| 36 |
+
[ -f "$SPEC_FILE" ] || { echo "ABORT: 没有动作脚本 $SPEC_FILE"; exit 1; }
|
| 37 |
+
SPEC="$(cat "$SPEC_FILE")"
|
| 38 |
+
# 几何旗标只在环境变量显式给出时才追加;默认一个都不传 ⇒ rollout_eval.py 的默认 sink 0 / window 20 /
|
| 39 |
+
# cap 20 = stage3 训练几何。三者必须满足 rope_cap == sink + window(rollout_eval 会核)。
|
| 40 |
+
GEO=()
|
| 41 |
+
if [ -n "${SINK:-}" ]; then GEO+=(--sink_size "$SINK"); fi
|
| 42 |
+
if [ -n "${KV_WINDOW:-}" ]; then GEO+=(--kv_window "$KV_WINDOW"); fi
|
| 43 |
+
if [ -n "${ROPE_CAP:-}" ]; then GEO+=(--rope_cap "$ROPE_CAP"); fi
|
| 44 |
+
|
| 45 |
+
# 先确认这 8 张卡是空的 —— 与 common.sh 同一套 fail-closed 口径(这里不 source 它,因为
|
| 46 |
+
# rollout_eval 是单卡工具、不走 accelerate;但"别人在用就别抢"这条规矩一样适用)。
|
| 47 |
+
busy="$(nvidia-smi -i "$(seq -s, 0 $((NGPU-1)))" --query-gpu=index,memory.used \
|
| 48 |
+
--format=csv,noheader,nounits 2>&1)" || { echo "ABORT: nvidia-smi 查询失败:$busy"; exit 1; }
|
| 49 |
+
echo "$busy" | awk -F', *' '$2>500 {print " GPU"$1"="$2"MiB"; f=1} END{exit f+0}' || {
|
| 50 |
+
echo "ABORT: 上面这些卡上有占用(>500 MiB)。别抢别人的卡。"; exit 1; }
|
| 51 |
+
echo "[guard] GPU 0-$((NGPU-1)) 空闲"
|
| 52 |
+
|
| 53 |
+
jobs_list=()
|
| 54 |
+
for ck in $(ls "$EMA_DIR"/ema_step*.safetensors | sort -t_ -k3 -n); do
|
| 55 |
+
for n in $STEPS_LIST; do jobs_list+=("$ck|$n"); done
|
| 56 |
+
done
|
| 57 |
+
echo "[sweep] ${#jobs_list[@]} 个组合 → $NGPU 张卡,输出 $OUTDIR"
|
| 58 |
+
|
| 59 |
+
i=0
|
| 60 |
+
for j in "${jobs_list[@]}"; do
|
| 61 |
+
ck="${j%|*}"; n="${j#*|}"
|
| 62 |
+
gpu=$(( i % NGPU )); i=$(( i + 1 ))
|
| 63 |
+
tag="$(basename "$ck" .safetensors)_n${n}"
|
| 64 |
+
CUDA_VISIBLE_DEVICES=$gpu nohup "$PY" "$HERE/rollout_eval.py" \
|
| 65 |
+
--ckpt "$ck" --mode rollout --cells "$CELLS" "${GEO[@]}" \
|
| 66 |
+
--num_inference_steps "$n" --actions "$SPEC" \
|
| 67 |
+
--first_frame "$FIRST_FRAME" --tiled --no-save_video \
|
| 68 |
+
--out "$OUTDIR/$tag.mp4" > "$OUTDIR/$tag.log" 2>&1 &
|
| 69 |
+
# 每张卡同时最多一个(一个进程 ~17 GB,同卡叠两个也放得下,但会互相拖慢且不好读数)
|
| 70 |
+
if (( i % NGPU == 0 )); then wait; fi
|
| 71 |
+
done
|
| 72 |
+
wait
|
| 73 |
+
echo "[sweep] 全部完成"
|
backup_2026-09-14/code/distill/eval/sweep_photodrift.sh
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# stage3 里程碑扫描:把一个 run 的所有 ema_step*.safetensors 按卡分片,每张卡跑一个
|
| 3 |
+
# 41-cell rollout,汇总成一张逐 cell 的 std/mean 表。
|
| 4 |
+
#
|
| 5 |
+
# bash distill/eval/sweep_photodrift.sh <run_dir> [steps] [ngpu]
|
| 6 |
+
#
|
| 7 |
+
# 为什么判据是 std/mean 而不是 loss:DMD 的 loss 按构造就没有数值意义(它只是把预先算好的
|
| 8 |
+
# grad 塞进 autograd 的载体),flow-map 的 loss 是自举目标、是平的。stage3 的失败模式是
|
| 9 |
+
# **光度漂移被自回归递推放大**,只在长 rollout 上显形 —— 所以只能这么量。
|
| 10 |
+
#
|
| 11 |
+
# 基线(stage2-flowmap ema_step7500,同样 41 cell / N=4):
|
| 12 |
+
# std 39.4 37.9 37.8 38.9 36.9 40.4 39.1 28.9
|
| 13 |
+
# mean 60.7 57.2 56.5 55.4 54.5 51.3 63.5 42.9 净漂移 9.3 px
|
| 14 |
+
# 及格 = 不比它差;成功 = 净漂移 < 9.3 px。
|
| 15 |
+
# ⚠ 基线是旧几何(sink 3 / w17)+ 原始 loop spec 下量的;现默认训练几何(sink 0 / w20)+ 重建 loop_spec.txt,
|
| 16 |
+
# stage2 跑完后重测 stage2 EMA 并更新上面 4 行(GEO-1)。
|
| 17 |
+
set -euo pipefail
|
| 18 |
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
| 19 |
+
ROOT="$(cd "$HERE/../.." && pwd)"
|
| 20 |
+
RUN="${1:?用法: sweep_photodrift.sh <run_dir> [steps] [ngpu]}"
|
| 21 |
+
N="${2:-4}"; NGPU="${3:-8}"
|
| 22 |
+
# ★ 并发上限不是卡数:每个 rollout_eval 进程要先把 10 GB 权重读进**宿主内存**再上卡,
|
| 23 |
+
# 8 个同时起会打出瞬时 RAM 尖峰,实测被 OOM killer 全部收走(4 个输出文件都是 0 字节,
|
| 24 |
+
# 看起来像"跑完了没结果")。3~4 个足够把 8 张卡喂饱,因为瓶颈是 rollout 不是加载。
|
| 25 |
+
MAXJOBS="${MAXJOBS:-3}"
|
| 26 |
+
EMA_DIR="$RUN/ema"
|
| 27 |
+
[ -d "$EMA_DIR" ] || { echo "ABORT: 没有 $EMA_DIR"; exit 1; }
|
| 28 |
+
|
| 29 |
+
busy="$(nvidia-smi -i "$(seq -s, 0 $((NGPU-1)))" --query-gpu=index,memory.used \
|
| 30 |
+
--format=csv,noheader,nounits 2>&1)" || { echo "ABORT: nvidia-smi 查询失败:$busy"; exit 1; }
|
| 31 |
+
echo "$busy" | awk -F', *' '$2>500 {print " GPU"$1"="$2"MiB"; f=1} END{exit f+0}' || {
|
| 32 |
+
echo "ABORT: 上面这些卡上有占用(>500 MiB)。别抢别人的卡。"; exit 1; }
|
| 33 |
+
|
| 34 |
+
i=0
|
| 35 |
+
for ck in $(ls "$EMA_DIR"/ema_step*.safetensors | sort -t_ -k3 -n); do
|
| 36 |
+
tag="$(basename "$RUN")_$(basename "$ck" .safetensors)_n$N"
|
| 37 |
+
bash "$HERE/photodrift.sh" "$ck" "$tag" $(( i % NGPU )) "$N" \
|
| 38 |
+
> "$ROOT/outputs/distill/eval/deploy/$tag.txt" 2>&1 &
|
| 39 |
+
i=$(( i + 1 ))
|
| 40 |
+
if (( i % MAXJOBS == 0 )); then wait; fi
|
| 41 |
+
done
|
| 42 |
+
wait
|
| 43 |
+
echo "=========== 汇总(基线 std 39.4→28.9 / mean 60.7→42.9 / 漂移 9.3 px)==========="
|
| 44 |
+
for f in "$ROOT/outputs/distill/eval/deploy/$(basename "$RUN")"_ema_step*_n$N.txt; do
|
| 45 |
+
[ -f "$f" ] && cat "$f"
|
| 46 |
+
done
|
backup_2026-09-14/code/distill/eval/tf_rollout_probe.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""教师强制 rollout 探针 —— 把「部署路径有 bug」和「曝光偏差」分开。
|
| 2 |
+
|
| 3 |
+
同一条 KV-cache 自回归路径,只改提交进缓存的东西:
|
| 4 |
+
|
| 5 |
+
self —— 提交模型自己的 x0 估计(= 真正的部署路径 / deploy_rollout)
|
| 6 |
+
gt —— 提交**真值 latent**(= 训练时 TF 的 clean 流)
|
| 7 |
+
|
| 8 |
+
两者的差别只有历史的来源。所以:
|
| 9 |
+
gt 干净、self 崩 ⇒ 路径没问题,崩的是误差累积(曝光偏差),正是 stage3 self-forcing 要修的
|
| 10 |
+
gt 也崩 ⇒ 部署路径与训练不匹配(t=0 提交 / 窗口锚定的 RoPE 或 mask),是移植 bug
|
| 11 |
+
|
| 12 |
+
每个 cell 都拿它的 x0 估计与真值算 PSNR,于是能看出崩塌是从哪一个 cell 开始的。
|
| 13 |
+
|
| 14 |
+
CUDA_VISIBLE_DEVICES=0 PYTHONPATH=/data/zhiyangdeng/ActionRoPE DIFFSYNTH_SKIP_DOWNLOAD=True \
|
| 15 |
+
.venv/bin/python distill/eval/tf_rollout_probe.py \
|
| 16 |
+
--ckpt outputs/distill/stage1_ar_tf/dits/dit_step9000.safetensors \
|
| 17 |
+
--clip clip_Eybx_100000092_000000
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import argparse
|
| 22 |
+
import os
|
| 23 |
+
import sys
|
| 24 |
+
|
| 25 |
+
import torch
|
| 26 |
+
|
| 27 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 28 |
+
if ROOT not in sys.path:
|
| 29 |
+
sys.path.insert(0, ROOT)
|
| 30 |
+
|
| 31 |
+
from diffsynth.diffusion.flow_match import FlowMatchScheduler # noqa: E402
|
| 32 |
+
|
| 33 |
+
from actionrope import geometry as G # noqa: E402
|
| 34 |
+
from actionrope import infer # noqa: E402
|
| 35 |
+
from distill.arope_dit import AropeDiT, unwrap # noqa: E402
|
| 36 |
+
from distill.stage3_dmd.dmd_arope import frame_cond # noqa: E402
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def psnr(a: torch.Tensor, b: torch.Tensor) -> float:
|
| 40 |
+
"""latent 空间的 PSNR(同一把尺子量所有 cell,只用来看相对趋势)。"""
|
| 41 |
+
mse = (a.float() - b.float()).pow(2).mean().item()
|
| 42 |
+
if mse <= 0:
|
| 43 |
+
return float("inf")
|
| 44 |
+
rng = (b.float().max() - b.float().min()).item()
|
| 45 |
+
return 20.0 * torch.log10(torch.tensor(rng / (mse ** 0.5))).item()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@torch.no_grad()
|
| 49 |
+
def probe(model, gt_lat, ctx, ids, off, t_grid, sig, *, commit_source: str,
|
| 50 |
+
kv_window: int, sink_size: int, rope_cap: int, seed: int = 0):
|
| 51 |
+
dev = gt_lat.device
|
| 52 |
+
b, c, cells, lh, lw = gt_lat.shape
|
| 53 |
+
bare = unwrap(model)
|
| 54 |
+
cache = bare.init_kv_cache(kv_window=kv_window, sink_size=sink_size, rope_cap=rope_cap)
|
| 55 |
+
g = torch.Generator("cpu").manual_seed(seed)
|
| 56 |
+
|
| 57 |
+
def call(x, t, k, commit, ow, mk):
|
| 58 |
+
return model(x, t, ctx[:, k:k + 1], context_ids=ids[:, k:k + 1],
|
| 59 |
+
first_frame_cond=False, frame_index=k, kv_cache=cache,
|
| 60 |
+
commit=commit, offset_win=ow, mask_in_k=mk)
|
| 61 |
+
|
| 62 |
+
ow0, mk0 = frame_cond(off, 0, sink_size, kv_window, lh, lw)
|
| 63 |
+
call(gt_lat[:, :, 0:1], torch.zeros(b, device=dev), 0, True, ow0, mk0) # cell 0 = 真首帧
|
| 64 |
+
|
| 65 |
+
rows = []
|
| 66 |
+
for k in range(1, cells):
|
| 67 |
+
ow, mk = frame_cond(off, k, sink_size, kv_window, lh, lw)
|
| 68 |
+
x = torch.randn(b, c, 1, lh, lw, generator=g).to(dtype=gt_lat.dtype, device=dev)
|
| 69 |
+
den = None
|
| 70 |
+
for i in range(len(t_grid)):
|
| 71 |
+
ts = torch.full((b,), float(t_grid[i]), device=dev)
|
| 72 |
+
v = call(x, ts, k, False, ow, mk)
|
| 73 |
+
den = x - float(sig[i]) * v
|
| 74 |
+
if i + 1 < len(t_grid):
|
| 75 |
+
ns = float(sig[i + 1])
|
| 76 |
+
x = (1.0 - ns) * den + ns * torch.randn(den.shape, generator=g).to(den)
|
| 77 |
+
gt_k = gt_lat[:, :, k:k + 1]
|
| 78 |
+
rows.append((k, psnr(den, gt_k), den.float().abs().mean().item(), gt_k.float().abs().mean().item()))
|
| 79 |
+
# ★ 唯一的差别:提交谁的历史
|
| 80 |
+
commit_lat = gt_k if commit_source == "gt" else den.detach()
|
| 81 |
+
call(commit_lat, torch.zeros(b, device=dev), k, True, ow, mk)
|
| 82 |
+
return rows
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def main():
|
| 86 |
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 87 |
+
ap.add_argument("--ckpt", required=True)
|
| 88 |
+
ap.add_argument("--clip", required=True)
|
| 89 |
+
ap.add_argument("--steps", type=int, default=50)
|
| 90 |
+
ap.add_argument("--kv_window", type=int, default=20)
|
| 91 |
+
ap.add_argument("--sink_size", type=int, default=0)
|
| 92 |
+
ap.add_argument("--rope_cap", type=int, default=None)
|
| 93 |
+
ap.add_argument("--device", default="cuda")
|
| 94 |
+
args = ap.parse_args()
|
| 95 |
+
if args.rope_cap is None:
|
| 96 |
+
args.rope_cap = args.sink_size + args.kv_window
|
| 97 |
+
|
| 98 |
+
dev = args.device
|
| 99 |
+
lat = torch.load(f"{ROOT}/data/latent/val_eybx/{args.clip}.pt", map_location="cpu",
|
| 100 |
+
weights_only=False)["latent"][None].to(dev) # [1,48,21,30,52]
|
| 101 |
+
sc = torch.load(infer.SIDECAR, map_location="cpu", weights_only=False)
|
| 102 |
+
off = sc["clips"][args.clip]["offset_px"][None].float().to(dev) # [1,21,2]
|
| 103 |
+
|
| 104 |
+
row = infer.load_meta_row(args.clip)
|
| 105 |
+
prompts = [infer.strip_action(p) for p in row["prompts"]]
|
| 106 |
+
table = torch.load(f"{ROOT}/data/latent/text_table_actionrope.pt", map_location="cpu",
|
| 107 |
+
weights_only=False)
|
| 108 |
+
keys = sorted(set(prompts)) # context_ids 才是权威的分组依据
|
| 109 |
+
kmap = {p: i for i, p in enumerate(keys)}
|
| 110 |
+
ctx = torch.stack([table[p] for p in prompts])[None].to(dev).to(torch.bfloat16)
|
| 111 |
+
ids = torch.tensor([kmap[p] for p in prompts])[None].to(dev)
|
| 112 |
+
|
| 113 |
+
from safetensors.torch import load_file
|
| 114 |
+
model = AropeDiT(causal=True, mask_channel=True).to(dev).eval()
|
| 115 |
+
unwrap(model).dit.load_state_dict(load_file(args.ckpt), strict=True)
|
| 116 |
+
model = model.to(torch.bfloat16)
|
| 117 |
+
|
| 118 |
+
s = FlowMatchScheduler("Wan")
|
| 119 |
+
s.set_timesteps(args.steps, training=False)
|
| 120 |
+
t_grid = [float(t) for t in s.timesteps]
|
| 121 |
+
sig = [float(x) for x in s.sigmas]
|
| 122 |
+
|
| 123 |
+
print(f"clip={args.clip} cells={lat.shape[2]} steps={args.steps} "
|
| 124 |
+
f"window={args.kv_window} sink={args.sink_size}")
|
| 125 |
+
for src in ("gt", "self"):
|
| 126 |
+
rows = probe(model, lat, ctx, ids, off, t_grid, sig, commit_source=src,
|
| 127 |
+
kv_window=args.kv_window, sink_size=args.sink_size, rope_cap=args.rope_cap)
|
| 128 |
+
print(f"\n=== 提交 {src} 历史 ===")
|
| 129 |
+
print(" cell PSNR(x0 vs GT) |x0| |GT|")
|
| 130 |
+
for k, p, a, b_ in rows:
|
| 131 |
+
print(f" {k:4d} {p:12.2f} {a:6.3f} {b_:6.3f}")
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
if __name__ == "__main__":
|
| 135 |
+
main()
|
backup_2026-09-14/code/distill/flow_map.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""**别名转发**。规范模块是 `distill/flowmap.py`(任务书点名的文件名);设计文档的
|
| 3 |
+
target_layout 里写的是 `flow_map.py`。两个名字都能 import,免得 trainer / sampler 按另一个
|
| 4 |
+
名字写就在启动时 ImportError。
|
| 5 |
+
|
| 6 |
+
新代码请一律 `from distill import flowmap`。这个文件不加任何逻辑,改动只发生在 flowmap.py。
|
| 7 |
+
"""
|
| 8 |
+
from distill.flowmap import * # noqa: F401,F403
|
| 9 |
+
from distill import flowmap as _fm # noqa: E402
|
| 10 |
+
|
| 11 |
+
__all__ = list(_fm.__all__)
|
backup_2026-09-14/code/distill/flow_pf.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""逐 cell(per-frame)的 flow-matching 工具,全部建在 DiffSynth 的 FlowMatchScheduler("Wan") 上。
|
| 3 |
+
|
| 4 |
+
ActionRoPE 的 SFT(actionrope/train.py:218-219)用的就是 `FlowMatchScheduler("Wan")` +
|
| 5 |
+
`set_timesteps(1000, training=True)`,网格是:
|
| 6 |
+
|
| 7 |
+
s = linspace(1, 0, 1001)[:-1] # 1000 个点
|
| 8 |
+
sigma = 5·s / (1 + 4·s) # Wan shift=5
|
| 9 |
+
t = sigma · 1000 # 与 sigma 一一对应
|
| 10 |
+
x_t = (1 − sigma)·x0 + sigma·eps
|
| 11 |
+
target = eps − x0
|
| 12 |
+
weight = bsmntw 钟形(set_training_weight)
|
| 13 |
+
|
| 14 |
+
蒸馏要的东西 DiffSynth 没有:它的 `add_noise` / `training_weight` 只吃**标量** timestep(内部
|
| 15 |
+
argmin 反查下标),没有 `[B,F]` 的逐 cell 版本。这里补上,并且一律**按下标**索引,不再 argmin
|
| 16 |
+
反查(浮点反查在 1000 点网格上是无谓的风险)。
|
| 17 |
+
|
| 18 |
+
方向陷阱(写反了不会报错,只会悄悄训错噪声档)
|
| 19 |
+
------------------------------------------------
|
| 20 |
+
sigmas[0] = 1.0 纯噪声 t = 1000
|
| 21 |
+
sigmas[999] ≈ 0.00498 近干净 t ≈ 4.98
|
| 22 |
+
即 **越噪 == sigma 越大 == 下标越小**。WorldMind 的 `--tf_noise_aug_max_idx` 就是把这个搞反了
|
| 23 |
+
(近干净档应该是 `randint(N-k, N)`,不是 `randint(0, k)`)。
|
| 24 |
+
|
| 25 |
+
模块级 scheduler 的不变量(对抗评审 [MINOR],必须遵守)
|
| 26 |
+
--------------------------------------------------------
|
| 27 |
+
DiffSynth 的 `set_timesteps` 是**破坏性**的:它就地覆写 `self.sigmas` / `self.timesteps`,而且
|
| 28 |
+
只在 `training=True` 时重建 `linear_timesteps_weights`(flow_match.py:365-375)。WorldMind 自己
|
| 29 |
+
那份写进独立的 `infer_sigmas` / `infer_timesteps`,不碰训练网格 —— 移植时**不能**照抄那个用法。
|
| 30 |
+
所以这里:
|
| 31 |
+
|
| 32 |
+
* 模块级 `_SCHED` 只在 import 时 `set_timesteps(1000, training=True)` 一次;
|
| 33 |
+
* 之后 `_SCHED.set_timesteps` 被实例级替换成一个直接抛异常的桩,物理上堵死第二次调用;
|
| 34 |
+
* `cd_axis()` / `deploy_schedule()` 这类需要别的点数的,一律**新建一个临时 scheduler**;
|
| 35 |
+
* 每个 public 函数开头都跑 `_assert_sched()`:断言 `len(timesteps)==1000`、
|
| 36 |
+
`linear_timesteps_weights is not None`,并且三个张量与 import 时的快照逐位相等。
|
| 37 |
+
"""
|
| 38 |
+
from __future__ import annotations
|
| 39 |
+
|
| 40 |
+
import os
|
| 41 |
+
import sys
|
| 42 |
+
|
| 43 |
+
os.environ.setdefault("DIFFSYNTH_SKIP_DOWNLOAD", "True")
|
| 44 |
+
|
| 45 |
+
import torch
|
| 46 |
+
|
| 47 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 48 |
+
if ROOT not in sys.path:
|
| 49 |
+
sys.path.insert(0, ROOT)
|
| 50 |
+
from diffsynth.diffusion.flow_match import FlowMatchScheduler # noqa: E402
|
| 51 |
+
|
| 52 |
+
__all__ = [
|
| 53 |
+
"NUM_TRAIN_TIMESTEPS", "WAN_SHIFT", "DMD_TIMESTEP_CLAMP", "DEPLOY_DENOISE_LIST",
|
| 54 |
+
"sched", "sigmas", "timesteps", "weights",
|
| 55 |
+
"sigma_of_idx", "t_of_idx", "idx_of_t", "sigma_of_t",
|
| 56 |
+
"sample_pf_timestep_ids", "add_noise_pf", "training_target", "training_weight_pf",
|
| 57 |
+
"cd_axis", "deploy_schedule", "assert_deploy_covered",
|
| 58 |
+
"ti2v_noise", "assert_ti2v",
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
NUM_TRAIN_TIMESTEPS = 1000
|
| 62 |
+
WAN_SHIFT = 5.0
|
| 63 |
+
# stage3 DMD 抽 t 的下标区间(WorldMind dmd_gm.py:47-50 的 int(0.02·n)/int(0.98·n),n=1000)。
|
| 64 |
+
# 是**下标**区间不是 t 值区间:idx∈[20,980) ⇒ sigma∈(0.0969, 0.9959] ⇒ t∈(96.9, 995.9]。
|
| 65 |
+
DMD_TIMESTEP_CLAMP = (20, 980)
|
| 66 |
+
# 用户拍板的部署采样表:4 步,原始 t 值(不是下标)。stage2 的 CD 轴和 stage3 都按这个训。
|
| 67 |
+
# 用显式 t 值而不是 set_timesteps(4):后者在 shift=5 下给出 [1000, 833, 625, 357],最后一步在
|
| 68 |
+
# sigma=0.357 处读 x0,读出来是条件均值(软/糊)。见 WorldMind dmd_gm.py:461-467 的同一坑。
|
| 69 |
+
DEPLOY_DENOISE_LIST = (1000.0, 750.0, 500.0, 250.0)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# ----------------------------------------------------------------------------
|
| 73 |
+
# 模块级 scheduler —— 建一次,之后只读
|
| 74 |
+
# ----------------------------------------------------------------------------
|
| 75 |
+
|
| 76 |
+
def _forbidden_set_timesteps(*a, **kw):
|
| 77 |
+
raise RuntimeError(
|
| 78 |
+
"distill.flow_pf: 模块级 scheduler 是 1000 点训练网格,set_timesteps 会就地覆写它。"
|
| 79 |
+
"需要别的点数请用 cd_axis(N) / deploy_schedule()(它们新建临时实例)。"
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
_SCHED = FlowMatchScheduler("Wan")
|
| 84 |
+
_SCHED.set_timesteps(NUM_TRAIN_TIMESTEPS, training=True)
|
| 85 |
+
_SCHED.set_timesteps = _forbidden_set_timesteps # 实例级遮蔽类方法,堵死第二次调用
|
| 86 |
+
|
| 87 |
+
# import 时的快照:_assert_sched 拿它逐位比对,任何外部改写都当场炸而不是悄悄算错
|
| 88 |
+
_SNAP_SIGMAS = _SCHED.sigmas.detach().clone()
|
| 89 |
+
_SNAP_TIMESTEPS = _SCHED.timesteps.detach().clone()
|
| 90 |
+
_SNAP_WEIGHTS = _SCHED.linear_timesteps_weights.detach().clone()
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _assert_sched() -> FlowMatchScheduler:
|
| 94 |
+
"""每个 public 函数开头都调:确认 1000 点训练网格和钟形权重都还在、且没被人改过。"""
|
| 95 |
+
assert getattr(_SCHED, "timesteps", None) is not None, "flow_pf: scheduler 未初始化"
|
| 96 |
+
assert len(_SCHED.timesteps) == NUM_TRAIN_TIMESTEPS, \
|
| 97 |
+
f"flow_pf: 训练网格被覆写了,len(timesteps)={len(_SCHED.timesteps)} != {NUM_TRAIN_TIMESTEPS}"
|
| 98 |
+
assert len(_SCHED.sigmas) == NUM_TRAIN_TIMESTEPS, \
|
| 99 |
+
f"flow_pf: 训练网格被覆写了,len(sigmas)={len(_SCHED.sigmas)} != {NUM_TRAIN_TIMESTEPS}"
|
| 100 |
+
assert getattr(_SCHED, "linear_timesteps_weights", None) is not None, \
|
| 101 |
+
"flow_pf: linear_timesteps_weights 丢了(set_timesteps(training=False) 不重建它)"
|
| 102 |
+
assert torch.equal(_SCHED.sigmas, _SNAP_SIGMAS) and torch.equal(_SCHED.timesteps, _SNAP_TIMESTEPS) \
|
| 103 |
+
and torch.equal(_SCHED.linear_timesteps_weights, _SNAP_WEIGHTS), \
|
| 104 |
+
"flow_pf: 模块级 scheduler 的张量被就地改写过"
|
| 105 |
+
return _SCHED
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def sched() -> FlowMatchScheduler:
|
| 109 |
+
"""取模块级(唯一的)训练 scheduler,取之前先验不变量。"""
|
| 110 |
+
return _assert_sched()
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def sigmas(device=None, dtype=torch.float32) -> torch.Tensor:
|
| 114 |
+
"""1000 点 sigma 网格 [1000],降序:sigmas[0]=1.0(纯噪声)→ sigmas[999]≈0.00498(近干净)。"""
|
| 115 |
+
return _assert_sched().sigmas.to(device=device, dtype=dtype)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def timesteps(device=None, dtype=torch.float32) -> torch.Tensor:
|
| 119 |
+
"""1000 点 t 网格 [1000] = sigma·1000,降序:t[0]=1000 → t[999]≈4.98。"""
|
| 120 |
+
return _assert_sched().timesteps.to(device=device, dtype=dtype)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def weights(device=None, dtype=torch.float32) -> torch.Tensor:
|
| 124 |
+
"""bsmntw 钟形训练权重 [1000](DiffSynth set_training_weight,len==1000 时不走经验缩放分支)。"""
|
| 125 |
+
return _assert_sched().linear_timesteps_weights.to(device=device, dtype=dtype)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# ----------------------------------------------------------------------------
|
| 129 |
+
# 下标 ↔ sigma ↔ t
|
| 130 |
+
# ----------------------------------------------------------------------------
|
| 131 |
+
|
| 132 |
+
def _as_long(idx, device=None) -> torch.Tensor:
|
| 133 |
+
if not torch.is_tensor(idx):
|
| 134 |
+
idx = torch.as_tensor(idx)
|
| 135 |
+
if idx.dtype not in (torch.int32, torch.int64):
|
| 136 |
+
assert not idx.is_floating_point(), "flow_pf: 下标必须是整型(浮点 t 值请走 idx_of_t)"
|
| 137 |
+
idx = idx.long()
|
| 138 |
+
idx = idx.long()
|
| 139 |
+
return idx if device is None else idx.to(device)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _check_idx(idx: torch.Tensor) -> torch.Tensor:
|
| 143 |
+
lo, hi = int(idx.min()), int(idx.max())
|
| 144 |
+
assert 0 <= lo and hi < NUM_TRAIN_TIMESTEPS, \
|
| 145 |
+
f"flow_pf: 下标越界 [{lo},{hi}],合法区间 [0,{NUM_TRAIN_TIMESTEPS - 1}]"
|
| 146 |
+
return idx
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _gather(grid: torch.Tensor, idx, device, dtype) -> torch.Tensor:
|
| 150 |
+
idx = _check_idx(_as_long(idx))
|
| 151 |
+
out = grid.to(device=idx.device, dtype=torch.float32)[idx]
|
| 152 |
+
if device is not None:
|
| 153 |
+
out = out.to(device)
|
| 154 |
+
return out.to(dtype)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def sigma_of_idx(idx, device=None, dtype=torch.float32) -> torch.Tensor:
|
| 158 |
+
"""下标 → sigma,形状原样保留(标量 int 给回 0 维张量);device=None 表示跟 idx 走。"""
|
| 159 |
+
return _gather(_assert_sched().sigmas, idx, device, dtype)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def t_of_idx(idx, device=None, dtype=torch.float32) -> torch.Tensor:
|
| 163 |
+
"""下标 → t 值(= sigma·1000),形状原样保留。stage1 的 ts_pf 就是 t_of_idx(idx_pf)。"""
|
| 164 |
+
return _gather(_assert_sched().timesteps, idx, device, dtype)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def idx_of_t(t) -> torch.Tensor:
|
| 168 |
+
"""原始 t 值 → 1000 点网格上最近的下标(WorldMind dmd_gm.py:104 的 `_sigma_of` 反查)。
|
| 169 |
+
|
| 170 |
+
形状原样保留;标量 float 给回 0 维 long 张量。部署表 [1000,750,500,250] 就是靠这个落到
|
| 171 |
+
sigma [1.0, 0.75, 0.5, 0.25](750 在网格上是精确点,500/250 差 <5e-4)。"""
|
| 172 |
+
s = _assert_sched()
|
| 173 |
+
if not torch.is_tensor(t):
|
| 174 |
+
t = torch.as_tensor(t, dtype=torch.float32)
|
| 175 |
+
t = t.to(torch.float32)
|
| 176 |
+
grid = s.timesteps.to(device=t.device, dtype=torch.float32)
|
| 177 |
+
return (grid - t.unsqueeze(-1)).abs().argmin(dim=-1)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def sigma_of_t(t, device=None, dtype=torch.float32) -> torch.Tensor:
|
| 181 |
+
"""原始 t 值 → sigma(先 idx_of_t 再查表)。stage3 rollout 的 `_sigma_of`。"""
|
| 182 |
+
return sigma_of_idx(idx_of_t(t), device=device, dtype=dtype)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# ----------------------------------------------------------------------------
|
| 186 |
+
# 逐 cell 加噪(Diffusion Forcing / stage1 teacher forcing)
|
| 187 |
+
# ----------------------------------------------------------------------------
|
| 188 |
+
|
| 189 |
+
def sample_pf_timestep_ids(B: int, F: int, dev, gen: torch.Generator | None = None) -> torch.Tensor:
|
| 190 |
+
"""[B,F] 独立同分布的逐 cell 噪声档下标,**严格均匀** randint(0,1000)。
|
| 191 |
+
|
| 192 |
+
不要换成 `round((1-u)·999)` 那种写法:它把下标 0 和 999 的概率各砍一半。"""
|
| 193 |
+
_assert_sched()
|
| 194 |
+
assert B > 0 and F > 0
|
| 195 |
+
g_dev = dev if gen is None else gen.device
|
| 196 |
+
idx = torch.randint(0, NUM_TRAIN_TIMESTEPS, (B, F), device=g_dev, generator=gen)
|
| 197 |
+
return idx.to(dev)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def add_noise_pf(x0: torch.Tensor, noise: torch.Tensor, idx_pf: torch.Tensor) -> torch.Tensor:
|
| 201 |
+
"""逐 cell 加噪:x_t = (1−sigma)·x0 + sigma·eps,sigma 按 [B,1,F,1,1] 广播。
|
| 202 |
+
|
| 203 |
+
x0 / noise: [B,C,F,H,W];idx_pf: [B,F] long。返回 x0.dtype。
|
| 204 |
+
|
| 205 |
+
为什么系数在 fp32 里算完再 cast:sigma 接近 1 时 `1 − bf16(0.99593) = 0.00391`,而真值是
|
| 206 |
+
0.00407 —— bf16 直接相减是灾难性抵消,4% 的相对误差直接打在 x0 的系数上。fp32 里先算
|
| 207 |
+
`1−sigma` 再 cast 成 bf16 是精确的(bf16 表示小数本身没问题),这也正是 DiffSynth 标量
|
| 208 |
+
`add_noise` 的实际行为(0 维 fp32 标量参与类型提升,先算差再降位)。"""
|
| 209 |
+
_assert_sched()
|
| 210 |
+
assert x0.dim() == 5, f"flow_pf: x0 需要 [B,C,F,H,W],得到 {tuple(x0.shape)}"
|
| 211 |
+
assert noise.shape == x0.shape, f"flow_pf: noise {tuple(noise.shape)} != x0 {tuple(x0.shape)}"
|
| 212 |
+
assert noise.dtype == x0.dtype, f"flow_pf: noise dtype {noise.dtype} != x0 {x0.dtype}"
|
| 213 |
+
idx_pf = _check_idx(_as_long(idx_pf, x0.device))
|
| 214 |
+
assert idx_pf.dim() == 2 and idx_pf.shape[0] == x0.shape[0] and idx_pf.shape[1] == x0.shape[2], \
|
| 215 |
+
f"flow_pf: idx_pf 需要 [B,F]=[{x0.shape[0]},{x0.shape[2]}],得到 {tuple(idx_pf.shape)}"
|
| 216 |
+
b, f = idx_pf.shape
|
| 217 |
+
sig = _SCHED.sigmas.to(device=x0.device, dtype=torch.float32)[idx_pf].view(b, 1, f, 1, 1)
|
| 218 |
+
return (1.0 - sig).to(x0.dtype) * x0 + sig.to(x0.dtype) * noise
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def training_target(x0: torch.Tensor, noise: torch.Tensor) -> torch.Tensor:
|
| 222 |
+
"""flow-matching 目标:eps − x0(与噪声档无关,DiffSynth training_target 同)。"""
|
| 223 |
+
assert noise.shape == x0.shape
|
| 224 |
+
return noise - x0
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def training_weight_pf(idx_pf: torch.Tensor) -> torch.Tensor:
|
| 228 |
+
"""逐 cell 的 bsmntw 钟形权重,形状与 idx_pf 相同,fp32。中段 t≈500 最重、两端最轻。"""
|
| 229 |
+
_assert_sched()
|
| 230 |
+
idx = _check_idx(_as_long(idx_pf))
|
| 231 |
+
w = _SCHED.linear_timesteps_weights.to(device=idx.device, dtype=torch.float32)
|
| 232 |
+
return w[idx]
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
# ----------------------------------------------------------------------------
|
| 236 |
+
# stage2 的一致性轴 / 部署采样表(都用**临时** scheduler,绝不碰模块级那个)
|
| 237 |
+
# ----------------------------------------------------------------------------
|
| 238 |
+
|
| 239 |
+
def cd_axis(N: int = 48, device=None, check_deploy: bool = True):
|
| 240 |
+
"""stage2 CD 的离散一致性轴:`FlowMatchScheduler("Wan").set_timesteps(N)` 的 (sigmas, timesteps)。
|
| 241 |
+
|
| 242 |
+
N=48 时 sigmas = [1.0, 0.99576, …, 0.17857, 0.09615],t = sigma·1000,都是**降序**(下标越大越干净)。
|
| 243 |
+
轴永远到不了 sigma=0(最低一对是 0.17857 → 0.09615),所以部署采样表必须结束在低噪端。
|
| 244 |
+
|
| 245 |
+
临时实例:DiffSynth 的 set_timesteps 就地覆写 sigmas/timesteps,在模块级 scheduler 上调一次
|
| 246 |
+
就把 1000 点训练网格换成 N 点的,之后每次 idx 抽样要么读错 sigma 要么直接越界(对抗评审 [MINOR])。
|
| 247 |
+
|
| 248 |
+
check_deploy:顺带断言这条轴的 sigma 区间覆盖 DEPLOY_DENOISE_LIST 的所有读点。"""
|
| 249 |
+
_assert_sched()
|
| 250 |
+
assert N >= 2, f"flow_pf: cd_axis 需要 N>=2,得到 {N}"
|
| 251 |
+
tmp = FlowMatchScheduler("Wan") # 一次性实例,出了这个函数就被回收
|
| 252 |
+
tmp.set_timesteps(N) # training=False:轴上不需要钟形权重
|
| 253 |
+
sig = tmp.sigmas.detach().clone().to(device=device, dtype=torch.float32)
|
| 254 |
+
tst = tmp.timesteps.detach().clone().to(device=device, dtype=torch.float32)
|
| 255 |
+
assert sig.shape == (N,) and tst.shape == (N,), f"flow_pf: cd_axis 点数不对 {tuple(sig.shape)}"
|
| 256 |
+
assert bool((sig[:-1] > sig[1:]).all()), "flow_pf: cd_axis 的 sigma 必须严格降序"
|
| 257 |
+
if check_deploy:
|
| 258 |
+
assert_deploy_covered(sig)
|
| 259 |
+
_assert_sched() # 再验一次:临时实例没污染模块级的
|
| 260 |
+
return sig, tst
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def deploy_schedule(denoise_list=None, device=None):
|
| 264 |
+
"""部署采样表(原始 t 值)→ 落到 1000 点训练网格上的 (idx[K], sigma[K], t_grid[K])。
|
| 265 |
+
|
| 266 |
+
默认就是用户拍板的 4 步 `DEPLOY_DENOISE_LIST = (1000, 750, 500, 250)`。
|
| 267 |
+
返回的 t_grid 是网格上的实际值(1000 / 750 / 500.0? / 250.0? 见下),采样时应当用它而不是
|
| 268 |
+
原始输入值,这样 sigma 与 t 严格一一对应。"""
|
| 269 |
+
_assert_sched()
|
| 270 |
+
ds = tuple(DEPLOY_DENOISE_LIST if denoise_list is None else denoise_list)
|
| 271 |
+
assert len(ds) >= 1, "flow_pf: 部署表不能为空"
|
| 272 |
+
t_req = torch.as_tensor([float(x) for x in ds], dtype=torch.float32)
|
| 273 |
+
if len(ds) > 1:
|
| 274 |
+
assert bool((t_req[:-1] > t_req[1:]).all()), \
|
| 275 |
+
f"flow_pf: 部署表必须严格降序(从噪到净),得到 {ds}"
|
| 276 |
+
idx = idx_of_t(t_req)
|
| 277 |
+
sig = sigma_of_idx(idx)
|
| 278 |
+
t_grid = t_of_idx(idx)
|
| 279 |
+
# 落点误差不该超过半个格。注意网格在 t 轴上**不等距**(sigma 等距、t=sigma·1000 也等距在
|
| 280 |
+
# sigma 上,但相邻 sigma 之间的间隔是 5/(1+4s)²·0.001,从噪端 0.2 涨到净端 ~4.98),
|
| 281 |
+
# 所以阈值取全局最大间隔的一半:请求 t=250 落到 251.6 是正常的,请求 t=1.0(低于网格下界
|
| 282 |
+
# 4.98)才会被拦下。
|
| 283 |
+
gap = float((_SCHED.timesteps[:-1] - _SCHED.timesteps[1:]).max())
|
| 284 |
+
assert bool((t_grid - t_req).abs().max() <= 0.5 * gap + 1e-3), \
|
| 285 |
+
f"flow_pf: 部署 t 值 {ds} 落不到网格上(最近点 {t_grid.tolist()},半格 {0.5 * gap:.3f})"
|
| 286 |
+
if device is not None:
|
| 287 |
+
idx, sig, t_grid = idx.to(device), sig.to(device), t_grid.to(device)
|
| 288 |
+
return idx, sig, t_grid
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def assert_deploy_covered(sig_axis: torch.Tensor, denoise_list=None) -> None:
|
| 292 |
+
"""断言 CD 一致性轴的 sigma 区间覆盖部署表的每个读点。
|
| 293 |
+
|
| 294 |
+
覆盖不到意味着:学生在部署要读 x0 的那个噪声档上,从来没被一致性目标约束过。
|
| 295 |
+
N=48 轴是 [0.09615, 1.0],部署表最低读点 sigma=0.25 —— 覆盖住。"""
|
| 296 |
+
_, sig_dep, _ = deploy_schedule(denoise_list)
|
| 297 |
+
lo, hi = float(sig_axis.min()), float(sig_axis.max())
|
| 298 |
+
d_lo, d_hi = float(sig_dep.min()), float(sig_dep.max())
|
| 299 |
+
assert d_lo >= lo - 1e-6, \
|
| 300 |
+
f"flow_pf: 部署表最低 sigma {d_lo:.5f} 低于 CD 轴下界 {lo:.5f},该档没被一致性约束过"
|
| 301 |
+
assert d_hi <= hi + 1e-6, \
|
| 302 |
+
f"flow_pf: 部署表最高 sigma {d_hi:.5f} 高于 CD 轴上界 {hi:.5f}"
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
# ----------------------------------------------------------------------------
|
| 306 |
+
# stage3 DMD 的原生 TI2V 加噪
|
| 307 |
+
# ----------------------------------------------------------------------------
|
| 308 |
+
|
| 309 |
+
def ti2v_noise(clip: torch.Tensor, clamp=DMD_TIMESTEP_CLAMP, gen: torch.Generator | None = None):
|
| 310 |
+
"""原生 TI2V 加噪(= ActionRoPE SFT / infer 的条件形式):cell 0 干净且 t=0,cell 1..F−1
|
| 311 |
+
共享**一个**抽出来的 t。WorldMind dmd_gm.py:53-66 的移植。
|
| 312 |
+
|
| 313 |
+
clip: [B,C,F,H,W](可以带梯度:cell 0 用 torch.where 写回,不做原地赋值)
|
| 314 |
+
clamp: **下标**区间 [lo, hi),默认 (20, 980) —— 即 WorldMind 的 [0.02, 0.98]·1000。
|
| 315 |
+
返回 (noisy, ts_pf[B,F] fp32, sigma[B] fp32, noise);noise 与 clip 同 dtype/shape。"""
|
| 316 |
+
_assert_sched()
|
| 317 |
+
assert clip.dim() == 5, f"flow_pf: clip 需要 [B,C,F,H,W],得到 {tuple(clip.shape)}"
|
| 318 |
+
b, _, f = clip.shape[:3]
|
| 319 |
+
lo, hi = int(clamp[0]), int(clamp[1])
|
| 320 |
+
assert 0 <= lo < hi <= NUM_TRAIN_TIMESTEPS, f"flow_pf: clamp 下标区间不合法 {clamp}"
|
| 321 |
+
dev = clip.device
|
| 322 |
+
g_dev = dev if gen is None else gen.device
|
| 323 |
+
idx = torch.randint(lo, hi, (b,), device=g_dev, generator=gen).to(dev)
|
| 324 |
+
noise = torch.randn(clip.shape, device=g_dev, dtype=clip.dtype, generator=gen).to(dev)
|
| 325 |
+
sig = _SCHED.sigmas.to(device=dev, dtype=torch.float32)[idx] # [B] fp32
|
| 326 |
+
s5 = sig.view(b, 1, 1, 1, 1)
|
| 327 |
+
noisy = (1.0 - s5).to(clip.dtype) * clip + s5.to(clip.dtype) * noise
|
| 328 |
+
ts_pf = _SCHED.timesteps.to(device=dev, dtype=torch.float32)[idx][:, None].expand(b, f).clone()
|
| 329 |
+
ts_pf[:, 0] = 0.0
|
| 330 |
+
f0 = (torch.arange(f, device=dev)[None, :] == 0).view(1, 1, f, 1, 1)
|
| 331 |
+
noisy = torch.where(f0, clip, noisy) # cell 0 = 干净 GT
|
| 332 |
+
return noisy, ts_pf, sig, noise
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
def assert_ti2v(noisy: torch.Tensor, clip: torch.Tensor, ts_pf: torch.Tensor) -> None:
|
| 336 |
+
"""teacher / critic 每次前向前的 fail-fast 门(WorldMind dmd_gm.py:31-42 的输入部分):
|
| 337 |
+
cell 0 必须干净且 t=0,cell 1.. 必须共享同一个 t。"""
|
| 338 |
+
assert noisy.shape == clip.shape and noisy.dim() == 5
|
| 339 |
+
assert ts_pf.shape == (clip.shape[0], clip.shape[2]), \
|
| 340 |
+
f"[gate] ts_pf 需要 [B,F]={(clip.shape[0], clip.shape[2])},得到 {tuple(ts_pf.shape)}"
|
| 341 |
+
assert bool((ts_pf[:, 0] == 0).all()), "[gate] cell 0 必须是干净条件帧(t=0)"
|
| 342 |
+
assert torch.equal(noisy[:, :, 0], clip[:, :, 0]), "[gate] cell 0 的 latent 必须原样是干净 GT"
|
| 343 |
+
if ts_pf.shape[1] > 1:
|
| 344 |
+
assert torch.allclose(ts_pf[:, 1:], ts_pf[:, 1:2].expand_as(ts_pf[:, 1:])), \
|
| 345 |
+
"[gate] cell 1..F−1 必须共享同一个 t(原生 TI2V)"
|
backup_2026-09-14/code/distill/flowmap.py
ADDED
|
@@ -0,0 +1,934 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""AnyFlow 式 **flow map**(any-step)蒸馏的调度器件 + 目标函数,全部建在 `distill/flow_pf.py` 的
|
| 3 |
+
1000 点 Wan 训练网格上,**不引入 diffusers**。
|
| 4 |
+
|
| 5 |
+
本模块只负责数学;训练循环(run 目录 / ZeRO-2 / EMA / 日志 / 保存)在
|
| 6 |
+
`distill/stage2_flowmap/flowmap_arope.py`,采样器在 `distill/eval/rollout_eval.py`。
|
| 7 |
+
|
| 8 |
+
一句话的算法
|
| 9 |
+
------------
|
| 10 |
+
把速度场 `v(x_t, t)` 换成**平均速度场** `u(x_t, t, r)`,它是 `[r, t]` 区间上的平均速度:
|
| 11 |
+
|
| 12 |
+
x_r = x_t − (σ_t − σ_r)·u(x_t, t, r) (一步就能从 t 跳到任意 r)
|
| 13 |
+
|
| 14 |
+
训练目标(AnyFlow `trainer_far_wan_anyflow_pretrain.py:232-390`):
|
| 15 |
+
|
| 16 |
+
target = (ε − x0) − (t − r)·dF/dt (t, r 都是 0..1000 单位)
|
| 17 |
+
dF/dt ≈ [u(x_t + v·ε_h/1000, t+ε_h, r) − u(x_t − v·ε_h/1000, t−ε_h, r)] / (2·ε_h·den)
|
| 18 |
+
|
| 19 |
+
`r == t` 时修正项恒为 0,目标退化成普通 rectified flow 的 `ε − x0` —— 这就是 50% 的
|
| 20 |
+
diffusion 槽为什么是"免费的锚"。
|
| 21 |
+
|
| 22 |
+
上游三个来源
|
| 23 |
+
------------
|
| 24 |
+
* `vendor/AnyFlow/far/schedulers/scheduling_flowmap_euler_discrete.py:44-112`
|
| 25 |
+
set_train_weight('beta08') / set_timesteps / step
|
| 26 |
+
* `vendor/AnyFlow/far/trainers/trainer_far_wan_anyflow_pretrain.py:232-390`
|
| 27 |
+
sample_timestep / compute_central_difference / train_causal
|
| 28 |
+
* `vendor/AnyFlow/options/train/anyflow/farwan_causal/pretrain/…4k_b16.yml:53-64`
|
| 29 |
+
shift 5.0 / weight_type beta08 / diffusion_ratio 0.5 / consistency_ratio 0.25 / epsilon 5
|
| 30 |
+
|
| 31 |
+
★★★ flow-map 约定与 flow_pf 的差异(写错都**不报错**,只是悄悄训另一根轴)★★★
|
| 32 |
+
================================================================================
|
| 33 |
+
1. **网格公式相同,点数含义不同。**
|
| 34 |
+
flow_pf 的模块级网格是 `FlowMatchScheduler("Wan").set_timesteps(1000, training=True)`,即
|
| 35 |
+
`σ = apply_shift(linspace(1,0,1001)[:-1])`,shift=5。上游 `FlowMapDiscreteScheduler.set_timesteps(n)`
|
| 36 |
+
是**同一个公式** `apply_shift(linspace(1,0,n+1)[:-1])`,只是 n 取推理步数。所以:
|
| 37 |
+
- 训练轴(抽 t)用 flow_pf 的 1000 点网格 —— 与上游 n=1000 数学同一(fp32 vs fp64,
|
| 38 |
+
max|Δ| = 1.2e-7,见 `test_flowmap_objective.py::test_grid_matches_upstream_formula`);
|
| 39 |
+
- 推理轴(N=4/24)用 `anystep_sigmas(N)`,它内部新建**临时** FlowMatchScheduler。
|
| 40 |
+
**绝不要**把 `linspace(1,0,n+1)[:-1]` 直接当成 σ:少了 apply_shift 就是 shift=1 的另一根轴。
|
| 41 |
+
|
| 42 |
+
2. **`anystep_sigmas` 末尾补 0.0,`flow_pf.cd_axis` 不补。**
|
| 43 |
+
上游 `set_timesteps` 出来的轴**没有终点 0**(N=4 是 [1, .9375, .8333, .625])。flow map 的
|
| 44 |
+
Euler 链最后一步必须走到 σ_r = 0 才落在干净估计上;不补 0 就停在 σ=0.625,读出来是糊的
|
| 45 |
+
条件均值(与 flow_pf:66-68 记的 DEPLOY_DENOISE_LIST 那个坑同源)。
|
| 46 |
+
反过来 CD 轴**故意**不补 0(一致性目标在低噪端读 x0),所以这两个函数返回的东西长度差 1、
|
| 47 |
+
语义不同,别互相代入。
|
| 48 |
+
|
| 49 |
+
3. **权重曲线不同。** flow_pf.weights() 是 **bsmntw**(高斯钟形,峰 2.076 @ σ≈0.5005);
|
| 50 |
+
flow map 默认 **beta08** = σ·(1−σ)^0.5 归一化到 sum=1000(峰 1.408 @ σ=0.6670)。
|
| 51 |
+
两条曲线的峰位置差 0.17 个 σ。默认**不要**用 `flow_pf.training_weight_pf`。
|
| 52 |
+
|
| 53 |
+
4. **r 是原始 float,不是下标。** consistency 槽要的是**精确的 0.0**,而 1000 点网格
|
| 54 |
+
`linspace(1,0,1001)[:-1]` 按构造不含 0(最低点 σ=0.00498, t=4.98)。
|
| 55 |
+
`flow_pf.idx_of_t(0.0)` 会返回 **999**(σ=0.00498)—— 把它当 r 用就把"一致性槽"悄悄变成
|
| 56 |
+
"近一致性槽",(t−r) 少了 4.98/1000 的量。所以本模块的 r 一律走原始值,σ_r = r/1000。
|
| 57 |
+
注意 `t/1000` 与 `flow_pf.sigmas()` 在 fp32 下**不逐位相等**(max|Δ| = 6e-8):在网格上一律
|
| 58 |
+
用 `flow_pf.sigma_of_idx(idx)`,只有 off-grid 的 r 才用 `sigma_of_t_exact`。
|
| 59 |
+
|
| 60 |
+
5. **方向。** flow_pf 的 σ 是**降序**(下标 0 = σ 1.0 = 最噪)。上游 `t = max(t1,t2)`、
|
| 61 |
+
`r = min(t1,t2)` 是在**值**上取的 ⇒ 在**下标**上是 `i_t = min`、`i_r = max`。反了的话
|
| 62 |
+
(t−r) 变号,修正项整条反向,loss 照样下降。
|
| 63 |
+
|
| 64 |
+
6. **单位。** ε = 5.0 是 **0..1000 的 t 单位**:latent 扰动除以 1000(换算成 σ 单位),
|
| 65 |
+
时间扰动不除,(t−r) 也在 1000 单位,三者精确相消。把 latent 扰动写成 `v·ε` 会让 dF/dt
|
| 66 |
+
偏大 ~1000×,训练指标全程健康而流形是错的(见 `test_flowmap_objective.py` 的注入实验 I-3)。
|
| 67 |
+
|
| 68 |
+
7. **逐 cell vs 逐样本 t。** `flow_pf.sample_pf_timestep_ids` 给的是 [B,F] 逐 cell 独立
|
| 69 |
+
(stage1 的 diffusion forcing)。flow map 默认是**一个标量 (t,r) 广播到 21 个 cell** +
|
| 70 |
+
cell 0 钉成干净 GT —— 这才对应部署路径(每个 cell 对着已提交的干净历史去噪)。
|
| 71 |
+
|
| 72 |
+
8. **没有 `assert_deploy_covered`。** CD 必须让一致性轴覆盖部署读点(flow_pf:291-302);
|
| 73 |
+
flow map 的训练轴与部署表**解耦**,这正是换掉 stage2 的理由。`anystep_sigmas` 不做这个断言。
|
| 74 |
+
|
| 75 |
+
模块级不变量
|
| 76 |
+
------------
|
| 77 |
+
本模块**只读** flow_pf 的访问器,从不调用模块级 scheduler 的 set_timesteps(flow_pf 已经把它
|
| 78 |
+
换成抛异常的桩)。需要 N 点轴时新建临时 `FlowMatchScheduler("Wan")`,与 `flow_pf.cd_axis` 同一个惯用法。
|
| 79 |
+
"""
|
| 80 |
+
from __future__ import annotations
|
| 81 |
+
|
| 82 |
+
import contextlib
|
| 83 |
+
import os
|
| 84 |
+
import sys
|
| 85 |
+
from dataclasses import dataclass, field
|
| 86 |
+
|
| 87 |
+
os.environ.setdefault("DIFFSYNTH_SKIP_DOWNLOAD", "True")
|
| 88 |
+
|
| 89 |
+
import torch
|
| 90 |
+
|
| 91 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 92 |
+
if ROOT not in sys.path:
|
| 93 |
+
sys.path.insert(0, ROOT)
|
| 94 |
+
|
| 95 |
+
from diffsynth.diffusion.flow_match import FlowMatchScheduler # noqa: E402
|
| 96 |
+
|
| 97 |
+
from distill import flow_pf # noqa: E402
|
| 98 |
+
|
| 99 |
+
__all__ = [
|
| 100 |
+
# 常量
|
| 101 |
+
"NUM_TRAIN_TIMESTEPS", "T_MAX", "T_MIN", "FLOWMAP_EPSILON",
|
| 102 |
+
"DIFFUSION_RATIO", "CONSISTENCY_RATIO", "FUSE_GUIDANCE_SCALE",
|
| 103 |
+
"SPLIT_SEED_BASE", "NULL_AXES", "ROLE_NAMES",
|
| 104 |
+
"ROLE_DIFFUSION", "ROLE_CONSISTENCY", "ROLE_RANDOM",
|
| 105 |
+
# 网格 / 权重
|
| 106 |
+
"sigma_of_t_exact", "t_of_sigma_exact",
|
| 107 |
+
"beta08_weights", "flowmap_weights", "weight_of_idx", "weight_of_t",
|
| 108 |
+
# any-step 轴
|
| 109 |
+
"anystep_shift", "anystep_sigmas", "anystep_timesteps", "deploy_flowmap_sigmas", "assert_anystep_axis",
|
| 110 |
+
# (t, r) 抽样与跨 rank 划分
|
| 111 |
+
"TRSample", "split_counts", "make_split_generator", "advance_split_generator",
|
| 112 |
+
"split_slots", "roles_of_slots", "sample_tr",
|
| 113 |
+
# 加噪
|
| 114 |
+
"keep_frame0", "add_noise_scalar",
|
| 115 |
+
# 目标
|
| 116 |
+
"fuse_denominator", "active_gains", "fuse_predictions",
|
| 117 |
+
"probes_needed", "central_difference", "flowmap_target",
|
| 118 |
+
"flowmap_loss", "adaptive_balance",
|
| 119 |
+
# guidance 钩子
|
| 120 |
+
"null_axis", "NULL_AXIS_TABLE",
|
| 121 |
+
# 采样
|
| 122 |
+
"euler_step", "euler_chain",
|
| 123 |
+
]
|
| 124 |
+
|
| 125 |
+
# ----------------------------------------------------------------------------
|
| 126 |
+
# 常量(= 上游 pretrain 配方;三处副本规则见 RUNBOOK,这里是 python 侧唯一一份)
|
| 127 |
+
# ----------------------------------------------------------------------------
|
| 128 |
+
|
| 129 |
+
NUM_TRAIN_TIMESTEPS = flow_pf.NUM_TRAIN_TIMESTEPS # 1000
|
| 130 |
+
T_MAX = 1000.0 # σ=1,每条采样轨迹的起点;t 不许超过它(σ>1 离分布)
|
| 131 |
+
T_MIN = 0.0 # σ=0,干净;cell 0 与 TF clean 半流用的就是 t=0,在分布内
|
| 132 |
+
FLOWMAP_EPSILON = 5.0 # 中心差分步长,**0..1000 的 t 单位**(yml:64 `epsilon: 5`)
|
| 133 |
+
DIFFUSION_RATIO = 0.5 # yml:62
|
| 134 |
+
CONSISTENCY_RATIO = 0.25 # yml:63
|
| 135 |
+
FUSE_GUIDANCE_SCALE = 3.0 # yml:53;g_text = w − 1 = 2.0
|
| 136 |
+
SPLIT_SEED_BASE = 4321 # split 用的 CPU 生成器种子基数(各 rank 必须一致)
|
| 137 |
+
|
| 138 |
+
NULL_AXES = ("text", "anchor", "action", "hist", "ctx")
|
| 139 |
+
|
| 140 |
+
ROLE_DIFFUSION, ROLE_CONSISTENCY, ROLE_RANDOM = 0, 1, 2
|
| 141 |
+
ROLE_NAMES = ("diffusion", "consistency", "random")
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# ----------------------------------------------------------------------------
|
| 145 |
+
# σ ↔ t(网格上一律走 flow_pf;只有 off-grid 的 r 才用下面这两个)
|
| 146 |
+
# ----------------------------------------------------------------------------
|
| 147 |
+
|
| 148 |
+
def sigma_of_t_exact(t):
|
| 149 |
+
"""t → σ,**按定义** σ = t/1000,不做网格反查。
|
| 150 |
+
|
| 151 |
+
Wan 网格上 `timesteps ≡ sigmas · 1000` 是构造式(flow_match.py:43-52),所以这个换算在数学上
|
| 152 |
+
精确;但 fp32 下 `t/1000` 与 `flow_pf.sigmas()` **不逐位相等**(max|Δ| = 5.96e-8)。
|
| 153 |
+
所以:**在网格上取 σ 请用 `flow_pf.sigma_of_idx(idx)`**,这个函数只给 off-grid 的 r 用
|
| 154 |
+
(典型就是 consistency 槽的 r = 0.0 —— 网格里根本没有 0)。
|
| 155 |
+
"""
|
| 156 |
+
if torch.is_tensor(t):
|
| 157 |
+
return t.to(torch.float32) / float(NUM_TRAIN_TIMESTEPS)
|
| 158 |
+
return float(t) / float(NUM_TRAIN_TIMESTEPS)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def t_of_sigma_exact(sigma):
|
| 162 |
+
"""σ → t = σ·1000。同上:网格上请用 `flow_pf.t_of_idx`。"""
|
| 163 |
+
if torch.is_tensor(sigma):
|
| 164 |
+
return sigma.to(torch.float32) * float(NUM_TRAIN_TIMESTEPS)
|
| 165 |
+
return float(sigma) * float(NUM_TRAIN_TIMESTEPS)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _assert_t_range(name: str, v, lo: float = T_MIN, hi: float = T_MAX) -> None:
|
| 169 |
+
"""API 边界的硬断言:t / r 必须落在 [0, 1000]。单位搞错(喂 σ 或喂下标)在这里当场炸。"""
|
| 170 |
+
if torch.is_tensor(v):
|
| 171 |
+
mn, mx = float(v.min()), float(v.max())
|
| 172 |
+
else:
|
| 173 |
+
mn = mx = float(v)
|
| 174 |
+
assert lo - 1e-4 <= mn and mx <= hi + 1e-4, \
|
| 175 |
+
(f"flowmap: {name} 越界 [{mn:.4f}, {mx:.4f}],合法区间 [{lo}, {hi}](0..1000 的 t 单位)。"
|
| 176 |
+
f"喂进来的是 σ(0..1)还是下标(0..999)?")
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
# ----------------------------------------------------------------------------
|
| 180 |
+
# 训练权重
|
| 181 |
+
# ----------------------------------------------------------------------------
|
| 182 |
+
|
| 183 |
+
_W_CACHE: dict = {}
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def beta08_weights(device=None, dtype=torch.float32) -> torch.Tensor:
|
| 187 |
+
"""上游 `set_train_weight('beta08')`(scheduling_flowmap_euler_discrete.py:53-56)在我们
|
| 188 |
+
1000 点网格上的复刻:
|
| 189 |
+
|
| 190 |
+
t = σ (上游是 timesteps/1000,Wan 网格上就等于 σ)
|
| 191 |
+
y = t^1.0 · (1 − t)^0.5
|
| 192 |
+
w = y · (1000 / Σy)
|
| 193 |
+
|
| 194 |
+
返回 [1000] 降序对齐 `flow_pf.sigmas()`:w[0] = 0.0(σ=1)、w[1] = 0.0517、
|
| 195 |
+
峰 1.4076 @ σ=0.66698(下标 714)、w[999] = 0.0182。
|
| 196 |
+
|
| 197 |
+
⚠ w[0] 恰好是 **0.0** —— 而 σ=1 是每条采样轨迹的起点。上游连续抽 t 时这是零测度,我们在
|
| 198 |
+
1000 点网格上以 1/1000 的概率抽中它,那一步梯度全被乘没。这是**继承**的性质(bsmntw 在
|
| 199 |
+
σ=1 处同样是 0,stage1 就这么训的),要压请用 `flowmap_weights(weight_floor=...)`。
|
| 200 |
+
|
| 201 |
+
与上游的**已知且有意**的差异(实测,钉在 test_beta08_matches_upstream_formula 里):
|
| 202 |
+
上游在 fp64 里建网格,我们从 flow_pf 的 fp32 网格算 —— 单一真相源优先于最后一位小数。
|
| 203 |
+
差异集中在 σ→1 那一端的 `(1−σ)^0.5`:σ=0.99979979 处 `1−σ ≈ 2.0e-4` 是灾难性抵消,
|
| 204 |
+
fp32 网格 1e-7 的绝对误差被放大成 **相对 1.295e-4**;但那格的权重只有 0.0517,所以
|
| 205 |
+
**绝对**误差是 6.70e-06(下标 1,全网格最大值),相对峰值 1.408 是 5e-6。其余 999 格更小。
|
| 206 |
+
归一化的 Σ 在 fp64 里做,避免 1000 项累加的误差。
|
| 207 |
+
"""
|
| 208 |
+
key = ("beta08", str(device), str(dtype))
|
| 209 |
+
if key not in _W_CACHE:
|
| 210 |
+
s = flow_pf.sigmas().double() # [1000] 降序,只读
|
| 211 |
+
y = (s ** 1.0) * ((1.0 - s) ** 0.5)
|
| 212 |
+
w = y * (float(NUM_TRAIN_TIMESTEPS) / y.sum())
|
| 213 |
+
_W_CACHE[key] = w.to(device=device, dtype=dtype)
|
| 214 |
+
return _W_CACHE[key]
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def flowmap_weights(weight_type: str = "beta08", weight_floor: float = 0.0,
|
| 218 |
+
device=None, dtype=torch.float32) -> torch.Tensor:
|
| 219 |
+
"""按名字取 [1000] 训练权重。
|
| 220 |
+
|
| 221 |
+
beta08 上游 flow map 的默认(本 stage 的默认)
|
| 222 |
+
bsmntw/gaussian flow_pf.weights(),即 stage1/SFT 用的高斯钟形(消融用)
|
| 223 |
+
uniform 全 1(上游 'uniform')
|
| 224 |
+
|
| 225 |
+
weight_floor:归一化**之后**做 `clamp_min`,把 σ=1 那一格从 0 抬起来。默认 0.0 = 上游逐位一致;
|
| 226 |
+
非 0 会让 Σw 略微超过 1000(这是有意的,别再归一化回去,否则中段会被压低)。
|
| 227 |
+
"""
|
| 228 |
+
wt = str(weight_type).lower()
|
| 229 |
+
if wt == "beta08":
|
| 230 |
+
w = beta08_weights(device=device, dtype=dtype)
|
| 231 |
+
elif wt in ("bsmntw", "gaussian"):
|
| 232 |
+
# flow_pf.weights() == DiffSynth set_training_weight() == 上游 'gaussian' 分支
|
| 233 |
+
w = flow_pf.weights(device=device, dtype=dtype)
|
| 234 |
+
elif wt == "uniform":
|
| 235 |
+
w = torch.ones(NUM_TRAIN_TIMESTEPS, device=device, dtype=dtype)
|
| 236 |
+
else:
|
| 237 |
+
raise ValueError(f"flowmap: 未知 weight_type={weight_type!r}(beta08 / bsmntw / uniform)")
|
| 238 |
+
floor = float(weight_floor)
|
| 239 |
+
assert floor >= 0.0, f"flowmap: weight_floor 必须 >=0,得到 {floor}"
|
| 240 |
+
return w.clamp_min(floor) if floor > 0.0 else w
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def weight_of_idx(idx, weight_type: str = "beta08", weight_floor: float = 0.0) -> torch.Tensor:
|
| 244 |
+
"""按**下标**取权重(形状原样保留)。上游 `get_train_weight` 是对 t 做 argmin 反查;我们的 t
|
| 245 |
+
本来就是从下标查出来的,直接索引与反查结果恒等(见 test_weight_lookup_matches_argmin)。"""
|
| 246 |
+
idx = idx if torch.is_tensor(idx) else torch.as_tensor(idx)
|
| 247 |
+
assert not idx.is_floating_point(), "flowmap: weight_of_idx 要的是整型下标(浮点 t 请用 weight_of_t)"
|
| 248 |
+
w = flowmap_weights(weight_type, weight_floor, device=idx.device)
|
| 249 |
+
return w[idx.long()]
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def weight_of_t(t, weight_type: str = "beta08", weight_floor: float = 0.0) -> torch.Tensor:
|
| 253 |
+
"""按**原始 t 值**取权重(走 flow_pf.idx_of_t 的 argmin 反查)= 上游 `get_train_weight`。"""
|
| 254 |
+
_assert_t_range("t", t)
|
| 255 |
+
t = t if torch.is_tensor(t) else torch.as_tensor(t, dtype=torch.float32)
|
| 256 |
+
return weight_of_idx(flow_pf.idx_of_t(t).to(t.device), weight_type, weight_floor)
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
# ----------------------------------------------------------------------------
|
| 260 |
+
# any-step 采样轴(每次都新建**临时** scheduler,绝不碰 flow_pf 的模块级那个)
|
| 261 |
+
# ----------------------------------------------------------------------------
|
| 262 |
+
|
| 263 |
+
def anystep_shift(u):
|
| 264 |
+
"""上游 `FlowMapDiscreteScheduler.apply_shift`(scheduling_flowmap_euler_discrete.py:76-80):
|
| 265 |
+
|
| 266 |
+
σ = shift·u / (1 + (shift−1)·u) shift = flow_pf.WAN_SHIFT = 5
|
| 267 |
+
|
| 268 |
+
u ∈ [0,1] 是**未 shift** 的均匀参数,σ 是真正的噪声水平。这两个东西差别很大:
|
| 269 |
+
u=0.5 ⇒ σ=0.833。把 u 直接当 σ 用不会报错,只会把噪声档整体压到低噪端。
|
| 270 |
+
on-policy 侧 generator 抽均匀 u、critic 抽 logit-normal u,都要经过这里。
|
| 271 |
+
"""
|
| 272 |
+
u = u if torch.is_tensor(u) else torch.as_tensor(u, dtype=torch.float32)
|
| 273 |
+
sh = flow_pf.WAN_SHIFT
|
| 274 |
+
return sh * u / (1.0 + (sh - 1.0) * u)
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def anystep_sigmas(N: int, device=None, dtype=torch.float32, terminal: bool = True) -> torch.Tensor:
|
| 278 |
+
"""N 步 any-step 采样轴的 σ,**长度 N+1、严格降序、末位是 0.0**。
|
| 279 |
+
|
| 280 |
+
前 N 个点 = `FlowMatchScheduler("Wan").set_timesteps(N).sigmas`
|
| 281 |
+
= 上游 `apply_shift(linspace(1,0,N+1)[:-1])`,shift=5。
|
| 282 |
+
末位补的 0.0 是 flow map 的终点:Euler 链的最后一步 `x −= (σ_{N-1} − 0)·u` 直接落在干净估计上,
|
| 283 |
+
不需要再单独读一次 `x0 = x − σ·v`。
|
| 284 |
+
|
| 285 |
+
N=4 ⇒ [1.0, 0.9375, 0.83333, 0.625, 0.0]
|
| 286 |
+
N=24 ⇒ [1.0, 0.99138, …, 0.3125, 0.17857, 0.0]
|
| 287 |
+
这一对(4 与 24)就是 any-step 结论的验收点(上游 yml:100 `num_inference_steps: [4, 24]`)。
|
| 288 |
+
|
| 289 |
+
terminal=False 用来和上游 `set_timesteps` 逐位对拍,正常采样**不要**关。
|
| 290 |
+
"""
|
| 291 |
+
flow_pf.sched() # 先验模块级不变量
|
| 292 |
+
assert int(N) >= 1, f"flowmap: anystep_sigmas 需要 N>=1,得到 {N}"
|
| 293 |
+
tmp = FlowMatchScheduler("Wan") # 一次性实例,出了函数就回收
|
| 294 |
+
tmp.set_timesteps(int(N)) # training=False:推理轴不需要钟形权重
|
| 295 |
+
sig = tmp.sigmas.detach().clone().to(torch.float32)
|
| 296 |
+
assert sig.shape == (int(N),), f"flowmap: anystep 轴点数不对 {tuple(sig.shape)}"
|
| 297 |
+
if terminal:
|
| 298 |
+
sig = torch.cat([sig, sig.new_zeros(1)])
|
| 299 |
+
assert_anystep_axis(sig, terminal=terminal)
|
| 300 |
+
flow_pf.sched() # 再验一次:临时实例没污染模块级的
|
| 301 |
+
return sig.to(device=device, dtype=dtype)
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def anystep_timesteps(N: int, device=None, dtype=torch.float32, terminal: bool = True) -> torch.Tensor:
|
| 305 |
+
"""同上,返回 t = σ·1000(喂给模型的就是它)。"""
|
| 306 |
+
return anystep_sigmas(N, device=device, dtype=dtype, terminal=terminal) * float(NUM_TRAIN_TIMESTEPS)
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def deploy_flowmap_sigmas(denoise_list=None, device=None, dtype=torch.float32) -> torch.Tensor:
|
| 310 |
+
"""把用户拍板的部署表(默认 `flow_pf.DEPLOY_DENOISE_LIST = (1000, 750, 500, 250)`)落到 1000 点
|
| 311 |
+
网格,再补一个终点 0.0 ⇒ [1.0, 0.75, 0.50060, 0.25161, 0.0]。
|
| 312 |
+
|
| 313 |
+
flow map 对采样表没有任何约束(训练轴与部署轴解耦),所以这里**不**调 `assert_deploy_covered`。
|
| 314 |
+
"""
|
| 315 |
+
_, sig, _ = flow_pf.deploy_schedule(denoise_list)
|
| 316 |
+
sig = torch.cat([sig.to(torch.float32), torch.zeros(1)])
|
| 317 |
+
assert_anystep_axis(sig, terminal=True)
|
| 318 |
+
return sig.to(device=device, dtype=dtype)
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def assert_anystep_axis(sig: torch.Tensor, terminal: bool = True) -> None:
|
| 322 |
+
"""采样轴的 fail-fast 门:严格降序、σ ∈ [0,1]、起点 ≤1,terminal 时末位恰为 0.0。"""
|
| 323 |
+
assert sig.dim() == 1 and sig.numel() >= 2, f"flowmap: 采样轴形状不对 {tuple(sig.shape)}"
|
| 324 |
+
assert bool((sig[:-1] > sig[1:]).all()), f"flowmap: 采样轴必须严格降序,得到 {sig.tolist()}"
|
| 325 |
+
assert float(sig.max()) <= 1.0 + 1e-6 and float(sig.min()) >= -1e-9, \
|
| 326 |
+
f"flowmap: 采样轴 σ 越界 [{float(sig.min())}, {float(sig.max())}]"
|
| 327 |
+
if terminal:
|
| 328 |
+
assert float(sig[-1]) == 0.0, \
|
| 329 |
+
(f"flowmap: 采样轴末位必须是精确的 0.0,得到 {float(sig[-1])} —— "
|
| 330 |
+
f"不补 0 的话最后一步停在中间噪声档,读出来是糊的条件均值")
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
# ----------------------------------------------------------------------------
|
| 334 |
+
# (t, r) 抽样 + 跨 rank 的 diffusion / consistency / random 划分
|
| 335 |
+
# ----------------------------------------------------------------------------
|
| 336 |
+
|
| 337 |
+
@dataclass
|
| 338 |
+
class TRSample:
|
| 339 |
+
"""一次 micro batch 里**本 rank** 的 (t, r) 与角色。所有张量都是 [b]。"""
|
| 340 |
+
idx_t: torch.Tensor # [b] long,t 在 1000 点网格上的下标(越小越噪)
|
| 341 |
+
idx_r: torch.Tensor # [b] long,r 的下标;consistency 槽是 -1(off-grid,见下)
|
| 342 |
+
t: torch.Tensor # [b] fp32,0..1000
|
| 343 |
+
r: torch.Tensor # [b] fp32,0..1000;consistency 槽恰为 0.0
|
| 344 |
+
sigma_t: torch.Tensor # [b] fp32,= flow_pf.sigma_of_idx(idx_t)
|
| 345 |
+
sigma_r: torch.Tensor # [b] fp32
|
| 346 |
+
is_diffusion: torch.Tensor # [b] bool,r == t
|
| 347 |
+
role: torch.Tensor # [b] long,ROLE_DIFFUSION / _CONSISTENCY / _RANDOM
|
| 348 |
+
slot: torch.Tensor # [b] long,本样本在全局 batch 里拿到的槽位
|
| 349 |
+
perm: torch.Tensor = field(default_factory=lambda: torch.empty(0, dtype=torch.long))
|
| 350 |
+
n_diffusion: int = 0
|
| 351 |
+
n_consistency: int = 0
|
| 352 |
+
|
| 353 |
+
@property
|
| 354 |
+
def role_names(self) -> list[str]:
|
| 355 |
+
return [ROLE_NAMES[int(x)] for x in self.role]
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def split_counts(world_batch: int, diffusion_ratio: float = DIFFUSION_RATIO,
|
| 359 |
+
consistency_ratio: float = CONSISTENCY_RATIO) -> tuple[int, int]:
|
| 360 |
+
"""上游 trainer:243-245 的 `round(ratio · total_global_bsz)`。
|
| 361 |
+
|
| 362 |
+
G = world_size · batch_size。G=4 / 0.5 / 0.25 ⇒ (2, 1),剩 1 个 random 槽。
|
| 363 |
+
注意 python 的 round 是 banker's rounding(round(0.5)==0),与上游同一函数,照抄。
|
| 364 |
+
"""
|
| 365 |
+
G = int(world_batch)
|
| 366 |
+
assert G >= 1, f"flowmap: world_batch 必须 >=1,得到 {G}"
|
| 367 |
+
assert 0.0 <= diffusion_ratio <= 1.0 and 0.0 <= consistency_ratio <= 1.0
|
| 368 |
+
n_d = int(round(float(diffusion_ratio) * G))
|
| 369 |
+
n_c = int(round(float(consistency_ratio) * G))
|
| 370 |
+
assert n_d + n_c <= G, \
|
| 371 |
+
f"flowmap: diffusion_ratio+consistency_ratio 的槽位 {n_d}+{n_c} 超过全局 batch {G}"
|
| 372 |
+
assert n_d >= 1, \
|
| 373 |
+
(f"flowmap: 全局 batch {G} 下 diffusion 槽为 0 —— adaptive_balance 的分母会是空集,"
|
| 374 |
+
f"loss 直接 NaN。调大 --diffusion_ratio 或加大全局 batch")
|
| 375 |
+
return n_d, n_c
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
def make_split_generator(seed: int = 0) -> torch.Generator:
|
| 379 |
+
"""划分用的 **CPU** 生成器。所有 rank 必须用同一个种子 —— 它决定每个 micro batch 的槽位轮换,
|
| 380 |
+
rank 之间不一致就会出现"两个 rank 都以为自己是 diffusion 槽",全局比例失衡且不报错。
|
| 381 |
+
|
| 382 |
+
与逐 rank 抽 (t,r) 的生成器(`set_seed(seed, device_specific=True)` 那条)严格分开。
|
| 383 |
+
"""
|
| 384 |
+
g = torch.Generator(device="cpu")
|
| 385 |
+
g.manual_seed(int(SPLIT_SEED_BASE) + int(seed))
|
| 386 |
+
return g
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
def advance_split_generator(gen: torch.Generator, n_micro: int, world_batch: int) -> torch.Generator:
|
| 390 |
+
"""resume 时把划分流快进 n_micro 次抽取(cd_arope.py:745-747 的 idx_rng 惯用法)。
|
| 391 |
+
|
| 392 |
+
⚠ 快进的次数是 **micro batch 数**(= step · grad_accum),不是优化步数。所以 resume 时
|
| 393 |
+
**不许改 --grad_accum**:改了以后同一个 step 对应的 micro 计数变了,各 rank 的流会错位。
|
| 394 |
+
"""
|
| 395 |
+
G = int(world_batch)
|
| 396 |
+
for _ in range(int(n_micro)):
|
| 397 |
+
torch.randperm(G, generator=gen)
|
| 398 |
+
return gen
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def split_slots(world_batch: int, gen: torch.Generator | None = None,
|
| 402 |
+
rotate: bool = True) -> torch.Tensor:
|
| 403 |
+
"""返回 [G] 的槽位分配:全局位置 i(= rank·bsz + j)拿到的槽位 `slots[i]`。
|
| 404 |
+
|
| 405 |
+
rotate=True(默认,本移植的**有意偏离**):所有 rank 用同一个 CPU 生成器抽一个
|
| 406 |
+
`randperm(G)`,每个 micro batch 换一次。全局比例每个 micro 都精确等于上游,但角色在 rank
|
| 407 |
+
之间轮转。
|
| 408 |
+
|
| 409 |
+
rotate=False:`arange(G)`,即上游 `global_start_idx = rank·bsz + j` 的**静态**分配。
|
| 410 |
+
G=4 时那意味着 rank0/1 永远 diffusion、rank2 永远 consistency、rank3 永远 random,
|
| 411 |
+
整整 8000 步不变 —— 而且 adaptive_balance 的分母永远是同两个 rank 的 2 样本均值。
|
| 412 |
+
留着它是为了跑上游逐位差分门禁。
|
| 413 |
+
"""
|
| 414 |
+
G = int(world_batch)
|
| 415 |
+
assert G >= 1
|
| 416 |
+
if not rotate:
|
| 417 |
+
return torch.arange(G, dtype=torch.long)
|
| 418 |
+
assert gen is not None, "flowmap: rotate=True 必须给一个各 rank 一致的 CPU 生成器(make_split_generator)"
|
| 419 |
+
assert gen.device.type == "cpu", "flowmap: 划分生成器必须在 CPU 上(CUDA 生成器各 rank 不同步)"
|
| 420 |
+
return torch.randperm(G, generator=gen)
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
def roles_of_slots(slots: torch.Tensor, n_diffusion: int, n_consistency: int) -> torch.Tensor:
|
| 424 |
+
"""槽位 → 角色(上游 trainer:248-256 的三段 if/elif/else)。"""
|
| 425 |
+
slots = slots.long()
|
| 426 |
+
role = torch.full_like(slots, ROLE_RANDOM)
|
| 427 |
+
role = torch.where(slots < int(n_diffusion) + int(n_consistency), torch.full_like(slots, ROLE_CONSISTENCY), role)
|
| 428 |
+
role = torch.where(slots < int(n_diffusion), torch.full_like(slots, ROLE_DIFFUSION), role)
|
| 429 |
+
return role
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def sample_tr(batch_size: int, world_size: int = 1, rank: int = 0, *,
|
| 433 |
+
gen: torch.Generator | None = None,
|
| 434 |
+
split_gen: torch.Generator | None = None,
|
| 435 |
+
diffusion_ratio: float = DIFFUSION_RATIO,
|
| 436 |
+
consistency_ratio: float = CONSISTENCY_RATIO,
|
| 437 |
+
rotate: bool = True, device="cpu") -> TRSample:
|
| 438 |
+
"""抽本 rank 这一个 micro batch 的 (t, r)。
|
| 439 |
+
|
| 440 |
+
抽法(上游 trainer:232-241 在 1000 点网格上的离散化):
|
| 441 |
+
i1, i2 ~ randint(0, 1000) 逐 rank 独立(gen 是 device-specific 的那条)
|
| 442 |
+
i_t = min(i1, i2) i_r = max(i1, i2) σ 降序 ⇒ 下标小 = σ 大 = t 大
|
| 443 |
+
t = flow_pf.t_of_idx(i_t) 已经过 shift 变换,(4.98, 1000]
|
| 444 |
+
角色覆写:
|
| 445 |
+
diffusion r := t (精确相等,后面 probes_needed 靠 `==` 判定)
|
| 446 |
+
consistency r := 0.0(**off-grid**,idx_r 记 −1)
|
| 447 |
+
random 保留抽出来的一对
|
| 448 |
+
|
| 449 |
+
与上游的差异:上游 `t1,t2 ~ U(0,1)` 连续、再 apply_shift;我们在 1000 点网格上均匀抽下标,
|
| 450 |
+
正是同一个分布的离散化(网格本身就是 `apply_shift(linspace(1,0,1001)[:-1])`)。
|
| 451 |
+
"""
|
| 452 |
+
b, W = int(batch_size), int(world_size)
|
| 453 |
+
assert b >= 1 and W >= 1 and 0 <= int(rank) < W
|
| 454 |
+
G = W * b
|
| 455 |
+
n_d, n_c = split_counts(G, diffusion_ratio, consistency_ratio)
|
| 456 |
+
|
| 457 |
+
perm = split_slots(G, gen=split_gen, rotate=rotate) # [G] CPU
|
| 458 |
+
slot = perm[int(rank) * b: int(rank) * b + b].to(device) # [b] 本 rank 的槽位
|
| 459 |
+
role = roles_of_slots(slot, n_d, n_c)
|
| 460 |
+
|
| 461 |
+
g_dev = device if gen is None else gen.device
|
| 462 |
+
pair = torch.randint(0, NUM_TRAIN_TIMESTEPS, (b, 2), device=g_dev, generator=gen).to(device)
|
| 463 |
+
idx_t = pair.min(dim=1).values.long() # 下标小 ⇒ σ 大 ⇒ t 大
|
| 464 |
+
idx_r = pair.max(dim=1).values.long()
|
| 465 |
+
|
| 466 |
+
t = flow_pf.t_of_idx(idx_t, device=device) # [b] fp32
|
| 467 |
+
sig_t = flow_pf.sigma_of_idx(idx_t, device=device)
|
| 468 |
+
r = flow_pf.t_of_idx(idx_r, device=device)
|
| 469 |
+
sig_r = flow_pf.sigma_of_idx(idx_r, device=device)
|
| 470 |
+
|
| 471 |
+
is_role_d = role == ROLE_DIFFUSION
|
| 472 |
+
is_d = is_role_d
|
| 473 |
+
is_c = role == ROLE_CONSISTENCY
|
| 474 |
+
# diffusion:r := t(`t.clone()` 保证逐位相等,probes_needed 的 `==` 才成立)
|
| 475 |
+
r = torch.where(is_d, t, r)
|
| 476 |
+
sig_r = torch.where(is_d, sig_t, sig_r)
|
| 477 |
+
idx_r = torch.where(is_d, idx_t, idx_r)
|
| 478 |
+
# consistency:r := 精确 0.0,idx 记 −1(网格上没有 0,见文件头差异 #4)
|
| 479 |
+
zero = torch.zeros_like(r)
|
| 480 |
+
r = torch.where(is_c, zero, r)
|
| 481 |
+
sig_r = torch.where(is_c, zero, sig_r)
|
| 482 |
+
idx_r = torch.where(is_c, torch.full_like(idx_r, -1), idx_r)
|
| 483 |
+
|
| 484 |
+
# ★ is_diffusion 由**值**派生,不由角色派生:random 槽偶然抽到 i1 == i2 时也落在对角线上
|
| 485 |
+
# (1000 点网格上 p = 1/1000;上游连续抽 t 是零测度,所以它没遇到过这个 case)。
|
| 486 |
+
# 语义要的是"修正项是否消失",而不是"抽签抽到了哪个槽":这两个用途都跟着值走 ——
|
| 487 |
+
# probes_needed 靠它省两次前向,adaptive_balance 靠它决定谁进分母。
|
| 488 |
+
# 派生只会**增加**对角线样本,不会减少,所以 split_counts 的 n_diffusion>=1 保证仍然成立。
|
| 489 |
+
# role 保留为抽样出身的标签,只用于日志(角色直方图与 diffusion mask 可以不等,这是对的)。
|
| 490 |
+
is_d = (r == t)
|
| 491 |
+
_assert_t_range("t", t)
|
| 492 |
+
_assert_t_range("r", r)
|
| 493 |
+
assert bool((t >= r - 1e-6).all()), f"flowmap: 必须 t >= r,得到 t={t.tolist()} r={r.tolist()}"
|
| 494 |
+
assert bool(is_d[is_role_d].all()), "flowmap: diffusion 槽必须逐位落在 r==t 对角线上"
|
| 495 |
+
assert not bool(is_d[is_c].any()), "flowmap: consistency 槽的 r=0 不可能等于 t(网格最低 t=4.98)"
|
| 496 |
+
return TRSample(idx_t=idx_t, idx_r=idx_r, t=t, r=r, sigma_t=sig_t, sigma_r=sig_r,
|
| 497 |
+
is_diffusion=is_d, role=role, slot=slot, perm=perm,
|
| 498 |
+
n_diffusion=n_d, n_consistency=n_c)
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
# ----------------------------------------------------------------------------
|
| 502 |
+
# 加噪(标量 t 广播到 21 个 cell + cell 0 钉成干净 GT)
|
| 503 |
+
# ----------------------------------------------------------------------------
|
| 504 |
+
|
| 505 |
+
def keep_frame0(x: torch.Tensor, clip: torch.Tensor) -> torch.Tensor:
|
| 506 |
+
"""cell 0 换回 clip 的 cell 0(out-of-place,clip 带梯度时也安全)。
|
| 507 |
+
|
| 508 |
+
与 `stage2_cd/cd_arope.py:_keep_frame0` 逐字相同;这里再写一份是为了让 flowmap.py 不反向依赖
|
| 509 |
+
某个 stage 目录。中心差分的两个探针输入**必须**过这个:v·ε/1000 打到 cell 0 上就破坏了
|
| 510 |
+
TI2V 干净锚点,而整个条件形式都建在那个锚点上。
|
| 511 |
+
"""
|
| 512 |
+
f0 = (torch.arange(x.shape[2], device=x.device) == 0).view(1, 1, -1, 1, 1)
|
| 513 |
+
return torch.where(f0, clip, x)
|
| 514 |
+
|
| 515 |
+
|
| 516 |
+
def add_noise_scalar(x0: torch.Tensor, noise: torch.Tensor, idx_t: torch.Tensor,
|
| 517 |
+
pin_first_cell: bool = True) -> torch.Tensor:
|
| 518 |
+
"""x_t = (1−σ_t)·x0 + σ_t·ε,**一个标量档广播到所有 cell**,再把 cell 0 钉回干净 GT。
|
| 519 |
+
|
| 520 |
+
x0 / noise: [b,C,F,H,W];idx_t: [b] long(每个样本一个网格下标)。
|
| 521 |
+
走 `flow_pf.add_noise_pf`(fp32 里算系数再降位,避免 σ→1 时 `1−bf16(σ)` 的灾难性抵消)。
|
| 522 |
+
"""
|
| 523 |
+
assert x0.dim() == 5, f"flowmap: x0 需要 [b,C,F,H,W],得到 {tuple(x0.shape)}"
|
| 524 |
+
idx_t = idx_t.long().to(x0.device)
|
| 525 |
+
assert idx_t.dim() == 1 and idx_t.shape[0] == x0.shape[0], \
|
| 526 |
+
f"flowmap: idx_t 需要 [b]={x0.shape[0]},得到 {tuple(idx_t.shape)}"
|
| 527 |
+
idx_pf = idx_t[:, None].expand(x0.shape[0], x0.shape[2]).contiguous() # [b,F] 同一个档
|
| 528 |
+
x_t = flow_pf.add_noise_pf(x0, noise, idx_pf)
|
| 529 |
+
return keep_frame0(x_t, x0) if pin_first_cell else x_t
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
# ----------------------------------------------------------------------------
|
| 533 |
+
# guidance 蒸馏(多轴 null 的融合)
|
| 534 |
+
# ----------------------------------------------------------------------------
|
| 535 |
+
|
| 536 |
+
NULL_AXIS_TABLE = {
|
| 537 |
+
# axis 来源 干预点
|
| 538 |
+
"text": ("stage1b --scene_dropout", "cross-attn:context ← NULL_PROMPT 行,context_ids ← 0"),
|
| 539 |
+
"anchor": ("stage1b --anchor_dropout", "TF 掩码:bare.train_drop_anchor=True,干净 cell 0 从每个 noisy cell 视野里摘掉"),
|
| 540 |
+
"action": ("stage1b --action_dropout", "世界 RoPE + known 掩码:offset_px ← 0,mask_input 按它重建"),
|
| 541 |
+
"hist": ("stage1b --hist_dropout", "因果掩码:train_local_attn_size, train_sink_size = 1, 1(只看得到 cell 0)"),
|
| 542 |
+
"ctx": ("stage1b --ctx_dropout", "因果掩码:train_local_attn_size, train_sink_size = 1, 0(什么都看不到)"),
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
|
| 546 |
+
def active_gains(gains: dict, dropped: set | tuple | list | None = None) -> dict:
|
| 547 |
+
"""把**本 micro batch 已经在主前向里被 drop 掉的轴**的 g 归零,并丢掉 g==0 的项。
|
| 548 |
+
|
| 549 |
+
这是对上游的一处**有意修正**:上游在 `pixel_loss:307-309` 里以 10% 概率把 text 换成负条件,
|
| 550 |
+
那时 `cond == uncond`,融合在算术上是恒等 —— 但 `guidance` 仍然是 3.0 并被塞进 `dF_dt` 的
|
| 551 |
+
分母(trainer:279),于是**恰好在这批样本上**修正项小了 3×。我们按实际生效的轴算 den,
|
| 552 |
+
顺带省掉那一次冗余前向。
|
| 553 |
+
"""
|
| 554 |
+
dropped = set(dropped or ())
|
| 555 |
+
out = {}
|
| 556 |
+
for k, g in gains.items():
|
| 557 |
+
assert k in NULL_AXES, f"flowmap: 未知 null 轴 {k!r},合法值 {NULL_AXES}"
|
| 558 |
+
g = float(g)
|
| 559 |
+
assert g >= 0.0, f"flowmap: g_{k} 必须 >=0(负值等于把 null 方向放大,语义未定义),得到 {g}"
|
| 560 |
+
if k not in dropped and g != 0.0:
|
| 561 |
+
out[k] = g
|
| 562 |
+
return out
|
| 563 |
+
|
| 564 |
+
|
| 565 |
+
def fuse_denominator(gains: dict) -> float:
|
| 566 |
+
"""den = 1 + Σ_j g_j。text-only、g_text = w−1 = 2 时 den == w == 3(上游的 `guidance`)。"""
|
| 567 |
+
return 1.0 + sum(float(g) for g in gains.values())
|
| 568 |
+
|
| 569 |
+
|
| 570 |
+
def fuse_predictions(f_full: torch.Tensor, nulls: dict, gains: dict,
|
| 571 |
+
clip_k: float = 0.0) -> tuple[torch.Tensor, float]:
|
| 572 |
+
"""多轴 CFG 融合:
|
| 573 |
+
|
| 574 |
+
pred = (F_full + Σ_j g_j · F_{∅_j}) / (1 + Σ_j g_j)
|
| 575 |
+
|
| 576 |
+
text-only、g_text = 2 时,与上游 `(noise_pred − (1 − w)·noise_pred_uncond) / w`(w=3)是
|
| 577 |
+
**同一个 IEEE 值**(fp32 / fp64 下 `torch.equal` 均为 True)—— 所以多轴代码路径能过逐位差分门禁。
|
| 578 |
+
|
| 579 |
+
最优点上这会把 `F_full → v* + Σ_j g_j (v* − v_{∅_j})`,即多轴 CFG 放大后的场,永久烤进权重:
|
| 580 |
+
推理时**一次前向都不用多做**,代价是强度也再也调不回来了。
|
| 581 |
+
|
| 582 |
+
clip_k > 0:把 `|pred − F_full|` 截断到 `clip_k · std(F_full)` —— 打开额外轴时的熔断器
|
| 583 |
+
(那几条轴是**掩码差**不是 classifier-free 的分数差,放大它是外推;而且参照物是学生自己,
|
| 584 |
+
是正反馈回路)。默认 0 = 不截断 = 上游行为。
|
| 585 |
+
|
| 586 |
+
返回 (pred, den)。所有 F_{∅_j} 都应当是 `torch.no_grad()` 下算出来的。
|
| 587 |
+
"""
|
| 588 |
+
assert set(nulls.keys()) >= set(gains.keys()), \
|
| 589 |
+
f"flowmap: gains 里有 {set(gains) - set(nulls)} 但 nulls 没给对应的前向"
|
| 590 |
+
den = fuse_denominator(gains)
|
| 591 |
+
assert den > 0.0, f"flowmap: den={den} 必须 >0"
|
| 592 |
+
pred = f_full
|
| 593 |
+
for k in NULL_AXES: # 固定顺序 ⇒ 浮点结果可复现
|
| 594 |
+
if k in gains:
|
| 595 |
+
g = float(gains[k])
|
| 596 |
+
fn = nulls[k]
|
| 597 |
+
assert fn.shape == f_full.shape, f"flowmap: null[{k}] 形状 {tuple(fn.shape)} != {tuple(f_full.shape)}"
|
| 598 |
+
pred = pred + g * fn
|
| 599 |
+
pred = pred / den
|
| 600 |
+
if float(clip_k) > 0.0:
|
| 601 |
+
with torch.no_grad():
|
| 602 |
+
lim = float(clip_k) * f_full.float().std()
|
| 603 |
+
d = (pred - f_full).clamp(min=-lim, max=lim)
|
| 604 |
+
pred = f_full + d
|
| 605 |
+
return pred, den
|
| 606 |
+
|
| 607 |
+
|
| 608 |
+
@contextlib.contextmanager
|
| 609 |
+
def null_axis(axis: str, cond: dict, *, bare=None,
|
| 610 |
+
null_context=None, null_context_ids=None, mask_input_fn=None):
|
| 611 |
+
"""产出某一条 null 轴的 cond(并在退出时把裸模块属性原样还回去)。
|
| 612 |
+
|
| 613 |
+
用法(trainer 侧):
|
| 614 |
+
|
| 615 |
+
with flowmap.null_axis("anchor", cond, bare=unwrap(model)) as cond_j:
|
| 616 |
+
with torch.no_grad(), autocast:
|
| 617 |
+
nulls["anchor"] = bare(x_t, t, r_timestep=r, **cond_j)
|
| 618 |
+
|
| 619 |
+
为什么属性存取包在这里而不是抄到 trainer:三条掩码轴改的是**裸模块属性**
|
| 620 |
+
(`ar_tf.py:200-210` 的惯用法),中途异常没还原的话,之后所有前向都在错误的注意力窗口上跑
|
| 621 |
+
且不报错。放在一个 contextmanager 里,`finally` 只写一次。
|
| 622 |
+
|
| 623 |
+
text 需要 null_context([b,L,4096] 或 [b,f,L,4096],NULL_PROMPT 那一行)+ null_context_ids(全 0)
|
| 624 |
+
action 把 offset_px 归零并按它重建 mask_input(默认惰性 import `distill.data.make_mask_input`)
|
| 625 |
+
anchor/hist/ctx 只改 bare 的属性,cond 原样
|
| 626 |
+
"""
|
| 627 |
+
assert axis in NULL_AXES, f"flowmap: 未知 null 轴 {axis!r},合法值 {NULL_AXES}"
|
| 628 |
+
saved = None
|
| 629 |
+
try:
|
| 630 |
+
if axis == "text":
|
| 631 |
+
assert null_context is not None, "flowmap: text null 需要 null_context(NULL_PROMPT 的嵌入)"
|
| 632 |
+
ids = null_context_ids
|
| 633 |
+
if ids is None and cond.get("context_ids") is not None:
|
| 634 |
+
ids = torch.zeros_like(cond["context_ids"])
|
| 635 |
+
yield dict(cond, context=null_context, context_ids=ids)
|
| 636 |
+
return
|
| 637 |
+
if axis == "action":
|
| 638 |
+
off = cond.get("offset_px")
|
| 639 |
+
assert off is not None, "flowmap: action null 需要 cond['offset_px']"
|
| 640 |
+
off0 = torch.zeros_like(off)
|
| 641 |
+
c = dict(cond, offset_px=off0)
|
| 642 |
+
mi = cond.get("mask_input")
|
| 643 |
+
if mi is not None:
|
| 644 |
+
if mask_input_fn is None:
|
| 645 |
+
from distill.data import make_mask_input as _mk # 惰性 import
|
| 646 |
+
c["mask_input"] = _mk(off0, int(mi.shape[-2]), int(mi.shape[-1])).to(
|
| 647 |
+
device=mi.device, dtype=mi.dtype)
|
| 648 |
+
else:
|
| 649 |
+
c["mask_input"] = mask_input_fn(off0)
|
| 650 |
+
yield c
|
| 651 |
+
return
|
| 652 |
+
# anchor / hist / ctx:掩码干预,只动裸模块属性
|
| 653 |
+
assert bare is not None, f"flowmap: {axis} null 是注意力掩码干预,必须给 bare=unwrap(model)"
|
| 654 |
+
saved = (bare.train_local_attn_size, bare.train_sink_size, bare.train_drop_anchor)
|
| 655 |
+
if axis == "ctx":
|
| 656 |
+
bare.train_local_attn_size, bare.train_sink_size = 1, 0 # 谁都看不到
|
| 657 |
+
elif axis == "hist":
|
| 658 |
+
bare.train_local_attn_size, bare.train_sink_size = 1, 1 # 只看得到 cell 0
|
| 659 |
+
else: # anchor
|
| 660 |
+
bare.train_drop_anchor = True # 只摘掉 cell 0
|
| 661 |
+
yield dict(cond)
|
| 662 |
+
finally:
|
| 663 |
+
if saved is not None:
|
| 664 |
+
bare.train_local_attn_size, bare.train_sink_size, bare.train_drop_anchor = saved
|
| 665 |
+
|
| 666 |
+
|
| 667 |
+
# ----------------------------------------------------------------------------
|
| 668 |
+
# 中心差分 dF/dt
|
| 669 |
+
# ----------------------------------------------------------------------------
|
| 670 |
+
|
| 671 |
+
def probes_needed(t, r) -> bool:
|
| 672 |
+
"""t 与 r **逐位相等**时不用跑探针(dF/dt 恒为 0)。
|
| 673 |
+
|
| 674 |
+
不只是省两次前向:`0 × NaN = NaN` 而不是 0,所以这个跳过同时是数值熔断器 ——
|
| 675 |
+
diffusion 槽上哪怕探针炸了也进不了 loss。判定用 `==`(`sample_tr` 里 diffusion 槽是
|
| 676 |
+
`torch.where(is_d, t, r)`,逐位相等有保证),不用容差。
|
| 677 |
+
"""
|
| 678 |
+
if torch.is_tensor(t) or torch.is_tensor(r):
|
| 679 |
+
tt = t if torch.is_tensor(t) else torch.as_tensor(t)
|
| 680 |
+
rr = r if torch.is_tensor(r) else torch.as_tensor(r)
|
| 681 |
+
return bool((tt != rr).any())
|
| 682 |
+
return float(t) != float(r)
|
| 683 |
+
|
| 684 |
+
|
| 685 |
+
def _hi(x: torch.Tensor) -> torch.Tensor:
|
| 686 |
+
"""提升到至少 fp32 做系数/残差运算;已经是 fp64 的**保持 fp64**。
|
| 687 |
+
|
| 688 |
+
不要写成 `.float()`:那会把 fp64 降成 fp32,解析门禁(fp64 闭式核对)就只剩 1e-8 的分辨率,
|
| 689 |
+
量纲 bug 的注入实验会被浮点噪声盖住。生产里 latent 是 bf16,这个函数返回 fp32,行为不变。
|
| 690 |
+
"""
|
| 691 |
+
return x if x.dtype in (torch.float32, torch.float64) else x.float()
|
| 692 |
+
|
| 693 |
+
|
| 694 |
+
def _hi_dtype(x: torch.Tensor) -> torch.dtype:
|
| 695 |
+
"""时间标量(t / r / width)该用的 dtype:fp64 输入配 fp64,其余一律 fp32。"""
|
| 696 |
+
return torch.float64 if x.dtype == torch.float64 else torch.float32
|
| 697 |
+
|
| 698 |
+
|
| 699 |
+
def _bvec(v, b: int, device, dtype=torch.float32) -> torch.Tensor:
|
| 700 |
+
if torch.is_tensor(v):
|
| 701 |
+
out = v.to(device=device, dtype=dtype).reshape(-1)
|
| 702 |
+
else:
|
| 703 |
+
out = torch.full((b,), float(v), device=device, dtype=dtype)
|
| 704 |
+
if out.numel() == 1 and b > 1:
|
| 705 |
+
out = out.expand(b).contiguous()
|
| 706 |
+
assert out.shape == (b,), f"flowmap: 期望 [b]={b} 的标量向量,得到 {tuple(out.shape)}"
|
| 707 |
+
return out
|
| 708 |
+
|
| 709 |
+
|
| 710 |
+
@torch.no_grad()
|
| 711 |
+
def central_difference(u_fn, x_t: torch.Tensor, x0: torch.Tensor, noise: torch.Tensor,
|
| 712 |
+
t, r, *, epsilon: float = FLOWMAP_EPSILON, den: float = 1.0,
|
| 713 |
+
eps_clamp: bool = True, t_min: float = T_MIN, t_max: float = T_MAX,
|
| 714 |
+
pin_first_cell: bool = True) -> torch.Tensor:
|
| 715 |
+
"""dF/dt 的中心差分(上游 `compute_central_difference`, trainer:260-277)。
|
| 716 |
+
|
| 717 |
+
单位(★ 最容易写错的一处,写错了 1000× 也不会报错):
|
| 718 |
+
v = ε − x0 速度(σ 单位,dx/dσ)
|
| 719 |
+
t_p = min(t + ε_h, 1000) t_m = max(t − ε_h, 0) **时间**扰动不除 1000
|
| 720 |
+
x_p = keep_frame0(x_t + v·(t_p − t)/1000, x0) **latent** 扰动除 1000
|
| 721 |
+
x_m = keep_frame0(x_t − v·(t − t_m)/1000, x0)
|
| 722 |
+
dF/dt = (u_p − u_m) / (width · den) width = t_p − t_m
|
| 723 |
+
|
| 724 |
+
闭式核对(`u = A·x + b·t/1000 + c·r/1000`,轨迹 x(t) = x0 + (t/1000)·v):
|
| 725 |
+
真值 dF/dt = A·v/1000 + b/1000,中心差分恰好给出同一个值(test 里到 8e-18)。
|
| 726 |
+
|
| 727 |
+
`den`:上游把探针**只用条件分支**跑(`u_func` 传的是 prompt_embeds,不做融合),再整体除以
|
| 728 |
+
`guidance`。所以 dF/dt 是**条件场**的导数除以 den,**不是**融合场的导数(那还要再跑 2·|axes|
|
| 729 |
+
次 null 探针)。这是上游的选择,照抄。
|
| 730 |
+
|
| 731 |
+
`eps_clamp=True`(默认,本移植的偏离):把探针钉在 [t_min, t_max] 内���并用**实际**步长做分母。
|
| 732 |
+
t=1000(σ=1,每条轨迹的起点,我们有 1/1000 的概率抽到)时 t_p 本来会是 1005 ⇒ σ>1 离分布。
|
| 733 |
+
不触边时 width 直接取字面量 `2·ε_h`:fp32 下 `(t+5)−(t−5)` 在 1000 个网格点里有 5 个
|
| 734 |
+
不精确等于 10.0(max|Δ| = 3.05e-5),取字面量既更准也与上游逐位一致。
|
| 735 |
+
`eps_clamp=False` 复现上游(不夹、width ≡ 2·ε_h),给差分门禁用。
|
| 736 |
+
|
| 737 |
+
⚠ bf16 噪声地板(**实测**,非推测):latent 扰动是 `v·ε/1000 = 0.005·v`,而 bf16 的相对
|
| 738 |
+
精度是 2^-8 ≈ 0.0039。在 [4,48,21,30,52] 的随机 latent 上,实际落地的 `(x_p − x_m)` 相对
|
| 739 |
+
理想值 `2·v·ε/1000` 的**逐元素平均相对误差 29.4%**,其中 **17.4% 的元素扰动被完全舍掉**
|
| 740 |
+
(范数只差 1.3%,所以是零均值量化噪声不是偏置)。这是从上游继承的性质(它的 latent 也是
|
| 741 |
+
bf16)。模型输出对 780×48 个元素求和会把它平均掉不少,但上线前值得用真权重量一下
|
| 742 |
+
`u_p − u_m` 的信噪比(见 anystep_probe.py 的建议)。
|
| 743 |
+
"""
|
| 744 |
+
assert x_t.dim() == 5 and x0.shape == x_t.shape and noise.shape == x_t.shape
|
| 745 |
+
b, dev = x_t.shape[0], x_t.device
|
| 746 |
+
tdt = _hi_dtype(x_t)
|
| 747 |
+
tv = _bvec(t, b, dev, tdt)
|
| 748 |
+
rv = _bvec(r, b, dev, tdt)
|
| 749 |
+
_assert_t_range("t", tv)
|
| 750 |
+
_assert_t_range("r", rv)
|
| 751 |
+
eh = float(epsilon)
|
| 752 |
+
assert eh > 0.0, f"flowmap: epsilon 必须 >0,得到 {eh}"
|
| 753 |
+
assert float(den) > 0.0, f"flowmap: den 必须 >0,得到 {den}"
|
| 754 |
+
|
| 755 |
+
if not probes_needed(tv, rv):
|
| 756 |
+
# 全是 diffusion 槽:一次前向都不跑
|
| 757 |
+
return torch.zeros((), device=dev, dtype=torch.float32)
|
| 758 |
+
|
| 759 |
+
if eps_clamp:
|
| 760 |
+
t_p = torch.minimum(tv + eh, torch.full_like(tv, float(t_max)))
|
| 761 |
+
t_m = torch.maximum(tv - eh, torch.full_like(tv, float(t_min)))
|
| 762 |
+
clamped = (t_p != tv + eh) | (t_m != tv - eh)
|
| 763 |
+
width = torch.where(clamped, t_p - t_m, torch.full_like(tv, 2.0 * eh))
|
| 764 |
+
assert bool((width > 0).all()), \
|
| 765 |
+
f"flowmap: 中心差分步长退化成 0(t={tv.tolist()}, ε={eh}, 区间 [{t_min},{t_max}])"
|
| 766 |
+
else: # 上游:不夹、width ≡ 2ε
|
| 767 |
+
t_p, t_m = tv + eh, tv - eh
|
| 768 |
+
width = torch.full_like(tv, 2.0 * eh)
|
| 769 |
+
|
| 770 |
+
v = (_hi(noise) - _hi(x0)) # ε − x0,至少 fp32 里算系数
|
| 771 |
+
d_p = ((t_p - tv) / float(NUM_TRAIN_TIMESTEPS)).view(b, 1, 1, 1, 1)
|
| 772 |
+
d_m = ((tv - t_m) / float(NUM_TRAIN_TIMESTEPS)).view(b, 1, 1, 1, 1)
|
| 773 |
+
x_p = (_hi(x_t) + v * d_p).to(x_t.dtype)
|
| 774 |
+
x_m = (_hi(x_t) - v * d_m).to(x_t.dtype)
|
| 775 |
+
if pin_first_cell:
|
| 776 |
+
x_p, x_m = keep_frame0(x_p, x0), keep_frame0(x_m, x0)
|
| 777 |
+
|
| 778 |
+
u_p = u_fn(x_p, t_p, rv)
|
| 779 |
+
u_m = u_fn(x_m, t_m, rv)
|
| 780 |
+
assert u_p.shape == x_t.shape and u_m.shape == x_t.shape, \
|
| 781 |
+
f"flowmap: 探针输出形状 {tuple(u_p.shape)} != {tuple(x_t.shape)}"
|
| 782 |
+
|
| 783 |
+
dF = (_hi(u_p) - _hi(u_m)) / (width * float(den)).view(b, 1, 1, 1, 1)
|
| 784 |
+
# t == r 的样本把 dF 硬置 0:(t−r)=0 已经让它进不了 target,但 0×NaN=NaN,必须显式清掉
|
| 785 |
+
same = (tv == rv).view(b, 1, 1, 1, 1)
|
| 786 |
+
return torch.where(same, torch.zeros_like(dF), dF)
|
| 787 |
+
|
| 788 |
+
|
| 789 |
+
# ----------------------------------------------------------------------------
|
| 790 |
+
# 目标与 loss
|
| 791 |
+
# ----------------------------------------------------------------------------
|
| 792 |
+
|
| 793 |
+
def flowmap_target(x0: torch.Tensor, noise: torch.Tensor, t, r,
|
| 794 |
+
dF_dt: torch.Tensor | float = 0.0) -> torch.Tensor:
|
| 795 |
+
"""flow-map 目标(上游 trainer:381):
|
| 796 |
+
|
| 797 |
+
target = (ε − x0) − (t − r)·dF/dt t, r 都是 0..1000 单位
|
| 798 |
+
|
| 799 |
+
`r == t` ⇒ 修正项恒为 0 ⇒ 退化成 rectified flow 的 `ε − x0`(= `flow_pf.training_target`)。
|
| 800 |
+
fp32 里算:dF/dt 在大 (t−r) 上是重尾的,bf16 的 8 位尾数会先吃掉残差再进 MSE。
|
| 801 |
+
"""
|
| 802 |
+
assert x0.dim() == 5 and noise.shape == x0.shape
|
| 803 |
+
b, dev = x0.shape[0], x0.device
|
| 804 |
+
tv = _bvec(t, b, dev, _hi_dtype(x0))
|
| 805 |
+
rv = _bvec(r, b, dev, _hi_dtype(x0))
|
| 806 |
+
_assert_t_range("t", tv)
|
| 807 |
+
_assert_t_range("r", rv)
|
| 808 |
+
v = _hi(noise) - _hi(x0)
|
| 809 |
+
if isinstance(dF_dt, (int, float)) and float(dF_dt) == 0.0:
|
| 810 |
+
return v
|
| 811 |
+
dt = (tv - rv).view(b, 1, 1, 1, 1)
|
| 812 |
+
d = dF_dt if torch.is_tensor(dF_dt) else torch.as_tensor(float(dF_dt), device=dev, dtype=v.dtype)
|
| 813 |
+
return v - dt * _hi(d)
|
| 814 |
+
|
| 815 |
+
|
| 816 |
+
def flowmap_loss(pred: torch.Tensor, target: torch.Tensor, *,
|
| 817 |
+
idx_t: torch.Tensor | None = None, t=None,
|
| 818 |
+
weight_type: str = "beta08", weight_floor: float = 0.0,
|
| 819 |
+
skip_first_cell: bool = True) -> tuple[torch.Tensor, dict]:
|
| 820 |
+
"""逐样本的加权 MSE(上游 trainer:383-385)。返回 (loss_b [b], stats)。
|
| 821 |
+
|
| 822 |
+
se = (pred − target)^2 fp32
|
| 823 |
+
per_sample = se[:, :, 1:].mean(dim=(1,2,3,4)) cell 0 是干净锚点,不计
|
| 824 |
+
loss_b = per_sample · w(σ_t) beta08 钟形,逐样本一个标量
|
| 825 |
+
|
| 826 |
+
`skip_first_cell` 是**我们的**约定(stage1/stage2-CD 都这么切);上游把所有 noisy 帧都算进去,
|
| 827 |
+
因为它的干净上下文是另一段 token,本来就不在这个张量里。
|
| 828 |
+
|
| 829 |
+
权重按下标取(`idx_t`)比按值 argmin 反查(`t`)便宜且等价,两者给一个即可。
|
| 830 |
+
上游 `loss.unsqueeze(-1) * weight` 再 `.mean(-1)`:weight 是 [b,f] 但逐帧同值,等价于逐样本
|
| 831 |
+
乘一个标量 —— 这里直接写成标量乘法。
|
| 832 |
+
"""
|
| 833 |
+
assert pred.shape == target.shape and pred.dim() == 5, \
|
| 834 |
+
f"flowmap: pred {tuple(pred.shape)} vs target {tuple(target.shape)}"
|
| 835 |
+
se = (_hi(pred) - _hi(target)) ** 2
|
| 836 |
+
se_l = se[:, :, 1:] if skip_first_cell else se
|
| 837 |
+
assert se_l.shape[2] >= 1, "flowmap: skip_first_cell=True 时至少要有 2 个 cell"
|
| 838 |
+
per_sample = se_l.mean(dim=(1, 2, 3, 4)) # [b]
|
| 839 |
+
if idx_t is not None:
|
| 840 |
+
w = weight_of_idx(idx_t.to(pred.device), weight_type, weight_floor).to(se.dtype)
|
| 841 |
+
elif t is not None:
|
| 842 |
+
w = weight_of_t(t, weight_type, weight_floor).to(device=pred.device, dtype=se.dtype)
|
| 843 |
+
else:
|
| 844 |
+
w = torch.ones_like(per_sample)
|
| 845 |
+
w = w.reshape(-1)
|
| 846 |
+
assert w.shape == per_sample.shape, f"flowmap: 权重形状 {tuple(w.shape)} != [b]={tuple(per_sample.shape)}"
|
| 847 |
+
loss_b = per_sample * w
|
| 848 |
+
stats = {
|
| 849 |
+
"mse": se_l.mean().detach(), # 完全不加权,跨配方可比(= ar_tf_loss 的 "mse")
|
| 850 |
+
"loss_uw": per_sample.mean().detach(),
|
| 851 |
+
"tw_mean": w.mean().detach(),
|
| 852 |
+
}
|
| 853 |
+
return loss_b, stats
|
| 854 |
+
|
| 855 |
+
|
| 856 |
+
def adaptive_balance(loss_b: torch.Tensor, is_diffusion: torch.Tensor,
|
| 857 |
+
gather_fn=None, eps: float = 1e-5) -> tuple[torch.Tensor, dict]:
|
| 858 |
+
"""上游 trainer:386-390 的自适应配平:把每个**非** diffusion 样本的 loss 重整到全局 diffusion 均值。
|
| 859 |
+
|
| 860 |
+
gl = gather(loss_b.detach()) [G] ← 集合通信,每个 rank 每个 micro 都要跑
|
| 861 |
+
gm = gather(is_diffusion) [G]
|
| 862 |
+
scale = gl[gm].mean() / (loss_b.detach() + eps)
|
| 863 |
+
loss_b = where(is_diffusion, loss_b, loss_b · scale)
|
| 864 |
+
|
| 865 |
+
代数性质(就是它让 (t−r)·dF/dt 这一项可训):重整之后**每个非 diffusion 样本的 loss 值恰好
|
| 866 |
+
等于全局 diffusion 均值**(差一个 eps/(L+eps) 的相对量),梯度方向不变、量级被拉平。
|
| 867 |
+
没有它,修正项那条比普通 flow matching 重尾得多,直接主导梯度。
|
| 868 |
+
|
| 869 |
+
`torch.where` 代替上游的原地 masked 赋值:值相同,图更干净(原地赋值在 autograd 里要多留一份)。
|
| 870 |
+
`gather_fn=None` ⇒ 单进程(测试 / dry_run);trainer 传 `accelerator.gather`。
|
| 871 |
+
⚠ 全局 diffusion 集合为空时**抛异常**而不是产出 NaN:`split_counts` 已经断言 n_diffusion>=1,
|
| 872 |
+
这里是第二道。
|
| 873 |
+
"""
|
| 874 |
+
assert loss_b.dim() == 1 and is_diffusion.shape == loss_b.shape
|
| 875 |
+
is_d = is_diffusion.bool()
|
| 876 |
+
det = loss_b.detach()
|
| 877 |
+
if gather_fn is None:
|
| 878 |
+
gl, gm = det, is_d
|
| 879 |
+
else:
|
| 880 |
+
gl = gather_fn(det)
|
| 881 |
+
# bool 的集合通信在部分后端上不受支持,统一走 float 再阈值化(值等价)
|
| 882 |
+
gm = gather_fn(is_d.to(det.dtype)) > 0.5
|
| 883 |
+
gl, gm = gl.reshape(-1), gm.reshape(-1)
|
| 884 |
+
assert gl.shape == gm.shape, f"flowmap: gather 后形状不一致 {tuple(gl.shape)} vs {tuple(gm.shape)}"
|
| 885 |
+
if not bool(gm.any()):
|
| 886 |
+
raise RuntimeError(
|
| 887 |
+
"flowmap.adaptive_balance: 全局 diffusion 集合为空 —— 分母会是 nan。"
|
| 888 |
+
"检查 --diffusion_ratio / 全局 batch(split_counts 要求 n_diffusion>=1),"
|
| 889 |
+
"以及各 rank 的 split 生成器是不是同一个种子。")
|
| 890 |
+
gm_mean = gl[gm].mean()
|
| 891 |
+
scale = gm_mean / (det + float(eps))
|
| 892 |
+
out = torch.where(is_d, loss_b, loss_b * scale)
|
| 893 |
+
info = {
|
| 894 |
+
"gm_mean": gm_mean.detach(),
|
| 895 |
+
"n_diffusion_global": int(gm.sum()),
|
| 896 |
+
"n_global": int(gm.numel()),
|
| 897 |
+
"scale_mean": scale[~is_d].mean().detach() if bool((~is_d).any()) else torch.zeros((), device=loss_b.device),
|
| 898 |
+
}
|
| 899 |
+
return out, info
|
| 900 |
+
|
| 901 |
+
|
| 902 |
+
# ----------------------------------------------------------------------------
|
| 903 |
+
# 采样:平均速度场上的确定性 Euler
|
| 904 |
+
# ----------------------------------------------------------------------------
|
| 905 |
+
|
| 906 |
+
def euler_step(x: torch.Tensor, u: torch.Tensor, sigma_t, sigma_r) -> torch.Tensor:
|
| 907 |
+
"""一步 flow-map Euler(上游 scheduler.step,按 num_train_timesteps 归一化后的形式):
|
| 908 |
+
|
| 909 |
+
x_r = x_t − (σ_t − σ_r)·u(x_t, t, r)
|
| 910 |
+
|
| 911 |
+
符号与 DiffSynth 的 `prev_sample = sample + model_output·(σ_next − σ)` 一致(σ_next < σ),
|
| 912 |
+
所以没有方向歧义。**全程无新噪声** —— 这与现有 `deploy_parallel/deploy_rollout` 的
|
| 913 |
+
"估 x0 再用新噪声重新加噪" 是**两个不同的采样器**,把 flow-map 权重喂进后者会出似是而非的垃圾。
|
| 914 |
+
"""
|
| 915 |
+
s_t, s_r = float(sigma_t), float(sigma_r)
|
| 916 |
+
assert s_t > s_r - 1e-9, f"flowmap: Euler 必须从噪往净走,得到 σ_t={s_t} → σ_r={s_r}"
|
| 917 |
+
return x - (s_t - s_r) * u
|
| 918 |
+
|
| 919 |
+
|
| 920 |
+
def euler_chain(u_fn, x: torch.Tensor, sigmas, pin_fn=None) -> torch.Tensor:
|
| 921 |
+
"""沿一条 any-step 轴跑完整条 Euler 链(参考实现 / 解析门禁用;真正的部署采样器带 KV cache
|
| 922 |
+
与逐 cell 提交,在 `distill/eval/rollout_eval.py`)。
|
| 923 |
+
|
| 924 |
+
u_fn(x, t_val, r_val) → u;t = σ·1000。`pin_fn(x)` 每步之后调一次(TI2V 锚点重新钉 cell 0)。
|
| 925 |
+
"""
|
| 926 |
+
sig = sigmas if torch.is_tensor(sigmas) else torch.as_tensor(sigmas, dtype=torch.float32)
|
| 927 |
+
assert_anystep_axis(sig, terminal=bool(float(sig[-1]) == 0.0))
|
| 928 |
+
for i in range(int(sig.numel()) - 1):
|
| 929 |
+
s_t, s_r = float(sig[i]), float(sig[i + 1])
|
| 930 |
+
u = u_fn(x, s_t * NUM_TRAIN_TIMESTEPS, s_r * NUM_TRAIN_TIMESTEPS)
|
| 931 |
+
x = euler_step(x, u, s_t, s_r)
|
| 932 |
+
if pin_fn is not None:
|
| 933 |
+
x = pin_fn(x)
|
| 934 |
+
return x
|
backup_2026-09-14/code/distill/longtraj.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""把 21-cell 的训练片拼成连续长轨迹 —— 长 horizon 训练/评测的数据层。
|
| 2 |
+
|
| 3 |
+
**为什么需要它**:stage1/2/3 全部只在 21 cell(5 秒)上训,而评测跑 41 cell、demo 想跑几分钟。
|
| 4 |
+
loss 从来没有覆盖过 5 秒以上的时间跨度。数据里其实有长轨迹,只是从没被用过。
|
| 5 |
+
|
| 6 |
+
**数据事实**(都是实测,不是假设):
|
| 7 |
+
· `data/latent/train_eybx/*.pt` 每个是 21 cell = 81 帧 @16fps
|
| 8 |
+
· 同一 (session, seg_id) 下 `start_cell` 以 **21 为步长**递增 ⇒ 相邻片是同一条视频的连续窗口
|
| 9 |
+
· 实测最长连续段 **45 个 clip = 945 cell = 236 秒**;扫 4000 个样本里 239/678 组有 ≥5 个连续片
|
| 10 |
+
· ★ 但相邻片的 `video_t0` 相差 **5.25 s = 84 帧**,而每片只覆盖 81 帧
|
| 11 |
+
⇒ **每个接缝有 3 帧(0.1875 s)的空隙**,按 69 px/s 约 13 px。源 mp4 也是 81 帧,
|
| 12 |
+
这 3 帧在切数据时就丢了,重编也拿不回来。这是这个数据集的硬限制,不要假装它不存在。
|
| 13 |
+
|
| 14 |
+
**世界坐标怎么接**(分解成两段,各用最可靠的来源):
|
| 15 |
+
片内 80 帧 用该片自己的 `frame_offset_px`(sidecar 真值,精确)
|
| 16 |
+
接缝 3 帧 用片末的逐帧速度外推(误差 ~2 px)
|
| 17 |
+
交叉校验 `data/meta/*.jsonl` 里每片有起点世界坐标 (x, z);实测换算是
|
| 18 |
+
px_x/Δx = +29.84、px_y/Δz = −19.04,比值 1.567 与 walk_speed_px_s 的
|
| 19 |
+
69.24/46.01 = 1.505 相差 4%(深度轴透视压缩)——两个独立来源互证。
|
| 20 |
+
直接拿 x/z 算整段位移不行:逐片估计的离散有 9~19%,整段就是几十 px 的误差。
|
| 21 |
+
|
| 22 |
+
python distill/longtraj.py --build # 建索引(读 jsonl,不碰 .pt,秒级)
|
| 23 |
+
python distill/longtraj.py --verify # 拼接自检:接缝位移 vs x/z 交叉校验
|
| 24 |
+
"""
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import argparse
|
| 28 |
+
import collections
|
| 29 |
+
import glob
|
| 30 |
+
import json
|
| 31 |
+
import os
|
| 32 |
+
|
| 33 |
+
import numpy as np
|
| 34 |
+
import torch
|
| 35 |
+
|
| 36 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 37 |
+
LATENT_DIR = os.path.join(ROOT, "data", "latent", "train_eybx")
|
| 38 |
+
META_GLOB = os.path.join(ROOT, "data", "meta", "*.jsonl")
|
| 39 |
+
INDEX = os.path.join(ROOT, "data", "latent", "longtraj_index.pt")
|
| 40 |
+
|
| 41 |
+
CELLS_PER_CLIP = 21
|
| 42 |
+
FRAMES_PER_CLIP = 81 # 1 + 4×20
|
| 43 |
+
STRIDE_CELLS = 21
|
| 44 |
+
STRIDE_FRAMES = 84 # 相邻片的 video_t0 间隔
|
| 45 |
+
GAP_FRAMES = STRIDE_FRAMES - FRAMES_PER_CLIP # == 3
|
| 46 |
+
# 实测标定(见文件头);只用于交叉校验,不用于生成偏移
|
| 47 |
+
PX_PER_GAME_X, PX_PER_GAME_Z = 29.840, -19.040
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def build_index(latent_dir: str = LATENT_DIR, meta_glob: str = META_GLOB,
|
| 51 |
+
out: str = INDEX, log=print) -> dict:
|
| 52 |
+
"""从 jsonl 建 (session, seg) → 连续 clip 列表 的索引。不 torch.load 任何 .pt。"""
|
| 53 |
+
have = {os.path.basename(p)[:-3] for p in glob.glob(os.path.join(latent_dir, "*.pt"))}
|
| 54 |
+
log(f"[index] latent 目录里 {len(have)} 个片")
|
| 55 |
+
by = collections.defaultdict(list)
|
| 56 |
+
n_meta = n_skip = 0
|
| 57 |
+
for mp in sorted(glob.glob(meta_glob)):
|
| 58 |
+
with open(mp, encoding="utf-8") as f:
|
| 59 |
+
for line in f:
|
| 60 |
+
try:
|
| 61 |
+
r = json.loads(line)
|
| 62 |
+
except json.JSONDecodeError:
|
| 63 |
+
continue # jsonl 里有个别截断行,跳过
|
| 64 |
+
n_meta += 1
|
| 65 |
+
if r["clip_id"] not in have:
|
| 66 |
+
continue
|
| 67 |
+
# ★ transition.jsonl 的 1900 条是**合成转场片**(带 pair/src_a/src_b/n_burn,
|
| 68 |
+
# 由两段不同素材缝出来的),没有 seg_id/x/z,也根本不是连续视频 —— 必须排除,
|
| 69 |
+
# 否则会把人工缝合处当成真实的世界运动去训。
|
| 70 |
+
if "seg_id" not in r:
|
| 71 |
+
n_skip += 1
|
| 72 |
+
continue
|
| 73 |
+
by[(r["session"], r["seg_id"])].append(
|
| 74 |
+
(r["start_cell"], r["clip_id"], r.get("x"), r.get("z")))
|
| 75 |
+
runs = []
|
| 76 |
+
for (sess, seg), items in by.items():
|
| 77 |
+
items.sort()
|
| 78 |
+
cur = [items[0]]
|
| 79 |
+
for prev, nxt in zip(items, items[1:]):
|
| 80 |
+
if nxt[0] - prev[0] == STRIDE_CELLS:
|
| 81 |
+
cur.append(nxt)
|
| 82 |
+
else:
|
| 83 |
+
if len(cur) > 1:
|
| 84 |
+
runs.append(dict(session=sess, seg_id=seg, items=cur))
|
| 85 |
+
cur = [nxt]
|
| 86 |
+
if len(cur) > 1:
|
| 87 |
+
runs.append(dict(session=sess, seg_id=seg, items=cur))
|
| 88 |
+
runs.sort(key=lambda r: -len(r["items"]))
|
| 89 |
+
idx = dict(runs=runs, cells_per_clip=CELLS_PER_CLIP, gap_frames=GAP_FRAMES,
|
| 90 |
+
stride_cells=STRIDE_CELLS)
|
| 91 |
+
torch.save(idx, out)
|
| 92 |
+
lens = np.array([len(r["items"]) for r in runs])
|
| 93 |
+
log(f"[index] 读 {n_meta} 条 meta(跳过 {n_skip} 条合成转场片)⇒ {len(runs)} 个连续段(≥2 片)")
|
| 94 |
+
log(f"[index] 段长(片):中位 {int(np.median(lens))} · p90 {int(np.percentile(lens,90))} · 最长 {lens.max()}"
|
| 95 |
+
f" ⇒ 最长 {lens.max()*CELLS_PER_CLIP} cell = {lens.max()*CELLS_PER_CLIP*4/16:.0f} 秒")
|
| 96 |
+
for k in (2, 3, 5, 10, 20):
|
| 97 |
+
log(f"[index] ≥{k:2d} 片({k*CELLS_PER_CLIP*4/16:5.1f} 秒)的段:{int((lens>=k).sum())}")
|
| 98 |
+
log(f"[index] 写入 {out}")
|
| 99 |
+
return idx
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def chain_offsets(frame_offsets: list[np.ndarray]) -> np.ndarray:
|
| 103 |
+
"""把每片各自从 0 起算的逐帧偏移接成一条连续的世界轨迹 [L*81, 2]。
|
| 104 |
+
|
| 105 |
+
接缝的 3 帧空隙按**该片末尾的逐帧速度**外推(见文件头:片内用真值,只有空隙外推)。
|
| 106 |
+
"""
|
| 107 |
+
out = [frame_offsets[0].copy()]
|
| 108 |
+
origin = frame_offsets[0][-1].copy()
|
| 109 |
+
for fo in frame_offsets[1:]:
|
| 110 |
+
prev = out[-1]
|
| 111 |
+
v_end = prev[-1] - prev[-2] # 片末逐帧速度
|
| 112 |
+
origin = origin + v_end * GAP_FRAMES # 跨过 3 帧空隙
|
| 113 |
+
out.append(fo + origin)
|
| 114 |
+
origin = out[-1][-1].copy()
|
| 115 |
+
return np.concatenate(out, 0)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def load_run(run: dict, n_clips: int, start: int = 0, latent_dir: str = LATENT_DIR,
|
| 119 |
+
sidecar=None):
|
| 120 |
+
"""取 run 里连续的 n_clips 片,返回拼好的长轨迹。
|
| 121 |
+
|
| 122 |
+
返回 dict:latent [48, 21*n, 30, 52] · frame_off [81*n, 2] · cell_off [21*n, 2]
|
| 123 |
+
· prompts / actions / scenes(逐 cell 拼接)· seams(接缝处的绝对 cell 下标)
|
| 124 |
+
★ latent 是**直接拼接**:第 j 片的 cell 0 在视频里只对应 1 帧(其余 cell 各 4 帧),
|
| 125 |
+
所以每个接缝那一格的"曝光"比别处短。这是数据的性质,不是 bug,但训练时要知道。
|
| 126 |
+
"""
|
| 127 |
+
from actionrope import infer
|
| 128 |
+
if sidecar is None:
|
| 129 |
+
sidecar = torch.load(infer.SIDECAR, map_location="cpu", weights_only=False)
|
| 130 |
+
items = run["items"][start:start + n_clips]
|
| 131 |
+
assert len(items) == n_clips, f"该段只有 {len(run['items'])} 片,取不到 {start}+{n_clips}"
|
| 132 |
+
lats, fos, prompts, actions, scenes = [], [], [], [], []
|
| 133 |
+
for _, cid, _, _ in items:
|
| 134 |
+
d = torch.load(os.path.join(latent_dir, cid + ".pt"), map_location="cpu",
|
| 135 |
+
weights_only=False)
|
| 136 |
+
lats.append(d["latent"])
|
| 137 |
+
fos.append(sidecar["clips"][cid]["frame_offset_px"].numpy().astype(np.float64))
|
| 138 |
+
prompts += list(d["prompts"])
|
| 139 |
+
actions += list(d["actions"])
|
| 140 |
+
scenes += list(d["scenes"])
|
| 141 |
+
frame_off = chain_offsets(fos)
|
| 142 |
+
from distill.eval.rollout_eval import frames_to_cells_n
|
| 143 |
+
n_cells = CELLS_PER_CLIP * n_clips
|
| 144 |
+
# 逐 cell 归约按每片各自的 21 格做,再拼 —— 不能整条丢给 frames_to_cells_n,
|
| 145 |
+
# 因为它假设「cell 0 = 1 帧、其余 4 帧」,而拼接后每 21 格就有一个这样的格子。
|
| 146 |
+
cell_off = np.concatenate(
|
| 147 |
+
[frames_to_cells_n(frame_off[i*FRAMES_PER_CLIP:(i+1)*FRAMES_PER_CLIP], CELLS_PER_CLIP)
|
| 148 |
+
for i in range(n_clips)], 0)
|
| 149 |
+
return dict(latent=torch.cat(lats, dim=1), frame_off=frame_off, cell_off=cell_off,
|
| 150 |
+
prompts=prompts, actions=actions, scenes=scenes,
|
| 151 |
+
seams=[i * CELLS_PER_CLIP for i in range(1, n_clips)],
|
| 152 |
+
clip_ids=[c for _, c, _, _ in items], n_cells=n_cells)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def verify(n_runs: int = 20, n_clips: int = 5, log=print) -> bool:
|
| 156 |
+
"""拼接自检:把外推出来的接缝位移和 meta 里的 (x,z) 真值对照。"""
|
| 157 |
+
from actionrope import infer
|
| 158 |
+
idx = torch.load(INDEX, map_location="cpu", weights_only=False)
|
| 159 |
+
sc = torch.load(infer.SIDECAR, map_location="cpu", weights_only=False)
|
| 160 |
+
errs, mags = [], []
|
| 161 |
+
for run in [r for r in idx["runs"] if len(r["items"]) >= n_clips][:n_runs]:
|
| 162 |
+
items = run["items"][:n_clips]
|
| 163 |
+
if any(it[2] is None for it in items):
|
| 164 |
+
continue
|
| 165 |
+
fos = [sc["clips"][c]["frame_offset_px"].numpy().astype(np.float64)
|
| 166 |
+
for _, c, _, _ in items]
|
| 167 |
+
chained = chain_offsets(fos)
|
| 168 |
+
for j in range(1, len(items)):
|
| 169 |
+
got = chained[j * FRAMES_PER_CLIP] - chained[(j - 1) * FRAMES_PER_CLIP]
|
| 170 |
+
dx = items[j][2] - items[j - 1][2]
|
| 171 |
+
dz = items[j][3] - items[j - 1][3]
|
| 172 |
+
ref = np.array([PX_PER_GAME_X * dx, PX_PER_GAME_Z * dz])
|
| 173 |
+
errs.append(np.linalg.norm(got - ref))
|
| 174 |
+
mags.append(np.linalg.norm(ref))
|
| 175 |
+
errs, mags = np.array(errs), np.array(mags)
|
| 176 |
+
rel = errs / np.maximum(mags, 1e-6)
|
| 177 |
+
log(f"[verify] {len(errs)} 个接缝:外推位移 vs (x,z) 真值")
|
| 178 |
+
log(f"[verify] 绝对误差 中位 {np.median(errs):.1f} px p90 {np.percentile(errs,90):.1f} px")
|
| 179 |
+
log(f"[verify] 相对误差 中位 {np.median(rel):.3f} (标定本身离散 9~19%,所以 ≲0.2 即为一致)")
|
| 180 |
+
log(f"[verify] 位移量级 中位 {np.median(mags):.1f} px / 片")
|
| 181 |
+
ok = float(np.median(rel)) < 0.25
|
| 182 |
+
log("[verify] " + ("通过:拼接的世界坐标与独立来源的 (x,z) 一致。"
|
| 183 |
+
if ok else "★ 未通过:拼接偏移与 (x,z) 对不上,别拿它训长 horizon。"))
|
| 184 |
+
return ok
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def main(argv=None):
|
| 188 |
+
ap = argparse.ArgumentParser(description="长轨迹数据层")
|
| 189 |
+
ap.add_argument("--build", action="store_true")
|
| 190 |
+
ap.add_argument("--verify", action="store_true")
|
| 191 |
+
ap.add_argument("--n_clips", type=int, default=5)
|
| 192 |
+
a = ap.parse_args(argv)
|
| 193 |
+
if a.build:
|
| 194 |
+
build_index()
|
| 195 |
+
if a.verify:
|
| 196 |
+
return 0 if verify(n_clips=a.n_clips) else 1
|
| 197 |
+
return 0
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
if __name__ == "__main__":
|
| 201 |
+
raise SystemExit(main())
|
backup_2026-09-14/code/distill/lora.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""低秩补丁层(LoRA)—— 忠实移植 AnyFlow on-policy 阶段的训练面。
|
| 2 |
+
|
| 3 |
+
**为什么需要它。** 我们在 flow-map 学生上做 on-policy DMD 失败了五次,形状完全一样:权重相对
|
| 4 |
+
初值只挪到 1e-4 量级,41-cell rollout 就开始退化,再往前走就烧白/烧黑。追过的原因(学习率、
|
| 5 |
+
CFG 强度、critic 更新比、上下文梯度、监督覆盖面)逐一被对照实验否掉。
|
| 6 |
+
|
| 7 |
+
最后核实出来的差异只有一处:**上游两个阶段都只训 LoRA,我们做的是全量微调。**
|
| 8 |
+
|
| 9 |
+
AnyFlow onpolicy yml:
|
| 10 |
+
lora_config.target_module_name = [attn1.to_q, attn1.to_k, attn1.to_v, attn1.to_out.0,
|
| 11 |
+
ffn.net.0.proj, ffn.net.2, delta_embedder, time_embedder]
|
| 12 |
+
param_names_to_optimize = [lora_A, lora_B, far_patch_embedding]
|
| 13 |
+
lora_rank = lora_alpha = 256 (scale = alpha/rank = 1.0)
|
| 14 |
+
critic(discriminator)用同一份 target 但**不含** delta/time 两个嵌入。
|
| 15 |
+
|
| 16 |
+
破坏的载体已经用拼接实验锁定:是 self-attn / ffn 的**权重矩阵**,bias / norm / embedding
|
| 17 |
+
全部无辜 —— 正是 LoRA 会挂的那一批。
|
| 18 |
+
|
| 19 |
+
★ 但「低秩之所以安全,是因为破坏藏在低秩表达不出来的方向里」这个说法**已被实测否掉**,别再用它当理由:
|
| 20 |
+
把塌掉的那份改动量做 rank-256 截断(只保留 58~73% 的能量)再加回初值,模型**照样烧白**。
|
| 21 |
+
破坏就在前 256 个主方向里,rank-256 的补丁完全有能力表达出来。
|
| 22 |
+
(脚本见 scratchpad/ranktrunc.py 的做法:对每个 target 矩阵的 Δ 做 SVD 截断,其余键保留成坏的,
|
| 23 |
+
这样变量就只剩「秩」这一条。)
|
| 24 |
+
|
| 25 |
+
所以留给补丁的理由只剩两条,都比原来弱:
|
| 26 |
+
· B 零初始化 ⇒ 第 0 步与初值**逐位相同**,更新从零速率起步。补丁训练走的是另一条路径,
|
| 27 |
+
不是「全量微调那个坏终点的低秩投影」—— 上面那个实验没有触及这一条。
|
| 28 |
+
· 上游在同类学生(flow map)上就是这么跑通的,而这是我们与它之间最后一处已知差异。
|
| 29 |
+
|
| 30 |
+
**为什么自己写而不用 peft。** peft 的 get_peft_model 会重写模块树并加自己的一层包装,与我们
|
| 31 |
+
三种自定义前向模式(并行 / TF 双流 / 单 cell 流式 KV)和 FSDP 的交互没人验过;而我们要的东西
|
| 32 |
+
很简单——包住若干个 nn.Linear。自己写还有一个实际好处:合并回去这条路是显然正确的,
|
| 33 |
+
存盘拿到的仍然是 832 键的普通权重,**部署与评测路径一行都不用改**。
|
| 34 |
+
|
| 35 |
+
用法:
|
| 36 |
+
install_lora(unwrap(student).dit, rank=256, alpha=256, is_student=True)
|
| 37 |
+
install_lora(unwrap(critic).dit, rank=256, alpha=256, is_student=False)
|
| 38 |
+
opt = AdamW(lora_parameters(dit), lr=2e-6, betas=(0.0, 0.999))
|
| 39 |
+
...
|
| 40 |
+
sd = merged_state_dict(dit) # 832 键,B@A 已折进 base,可直接存
|
| 41 |
+
"""
|
| 42 |
+
from __future__ import annotations
|
| 43 |
+
|
| 44 |
+
import contextlib
|
| 45 |
+
import math
|
| 46 |
+
import re
|
| 47 |
+
|
| 48 |
+
import torch
|
| 49 |
+
import torch.nn as nn
|
| 50 |
+
|
| 51 |
+
# 上游 target 在我们的模块树里的对应名(见文件头的 yml 引用)
|
| 52 |
+
TARGET_BLOCK = re.compile(r"^blocks\.\d+\.(self_attn\.[qkvo]|ffn\.[02])$")
|
| 53 |
+
TARGET_STUDENT_EXTRA = re.compile(r"^(delta_embedding|time_embedding)\.[02]$")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def is_target(name: str, *, is_student: bool) -> bool:
|
| 57 |
+
"""cross_attn 不在上游 target 里,跟着排除。"""
|
| 58 |
+
if TARGET_BLOCK.match(name):
|
| 59 |
+
return True
|
| 60 |
+
return is_student and bool(TARGET_STUDENT_EXTRA.match(name))
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class LoRALinear(nn.Module):
|
| 64 |
+
"""base(x) + (x @ A^T) @ B^T · (alpha/rank)。base 冻结,bias 从不参与。
|
| 65 |
+
|
| 66 |
+
★ B 必须零初始化:这是「第 0 步与初值逐位相同」的唯一保证,也是低秩之外的另一层保护。
|
| 67 |
+
改成随机初始化不会报错,只会让训练从一个偏离的点开始 —— 那正是我们要避免的东西。
|
| 68 |
+
"""
|
| 69 |
+
|
| 70 |
+
def __init__(self, base: nn.Linear, rank: int, alpha: int):
|
| 71 |
+
super().__init__()
|
| 72 |
+
assert isinstance(base, nn.Linear), type(base)
|
| 73 |
+
self.base = base
|
| 74 |
+
for p in self.base.parameters():
|
| 75 |
+
p.requires_grad_(False)
|
| 76 |
+
d_out, d_in = base.weight.shape
|
| 77 |
+
self.rank, self.scale = rank, alpha / rank
|
| 78 |
+
f = base.weight.dtype
|
| 79 |
+
self.lora_A = nn.Parameter(torch.empty(rank, d_in, dtype=f, device=base.weight.device))
|
| 80 |
+
self.lora_B = nn.Parameter(torch.zeros(d_out, rank, dtype=f, device=base.weight.device))
|
| 81 |
+
nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))
|
| 82 |
+
self.enabled = True # False ⇒ 只走 base(OPSD-V 的"教师 = 同一模型关掉 adapter",见 lora_disabled)
|
| 83 |
+
|
| 84 |
+
def forward(self, x):
|
| 85 |
+
if not self.enabled:
|
| 86 |
+
return self.base(x)
|
| 87 |
+
return self.base(x) + torch.nn.functional.linear(
|
| 88 |
+
torch.nn.functional.linear(x, self.lora_A), self.lora_B) * self.scale
|
| 89 |
+
|
| 90 |
+
@torch.no_grad()
|
| 91 |
+
def delta_w(self) -> torch.Tensor:
|
| 92 |
+
return (self.lora_B.float() @ self.lora_A.float() * self.scale)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def install_lora(root: nn.Module, *, rank: int = 256, alpha: int = 256,
|
| 96 |
+
is_student: bool, log=print) -> int:
|
| 97 |
+
"""把匹配到的 nn.Linear 就地换成 LoRALinear,并冻结其余所有参数。返回替换个数。"""
|
| 98 |
+
hits = [(n, m) for n, m in root.named_modules()
|
| 99 |
+
if isinstance(m, nn.Linear) and is_target(n, is_student=is_student)]
|
| 100 |
+
if not hits:
|
| 101 |
+
raise SystemExit("install_lora 没匹配到任何 Linear —— 模块名口径变了")
|
| 102 |
+
for name, mod in hits:
|
| 103 |
+
parent = root.get_submodule(name.rsplit(".", 1)[0]) if "." in name else root
|
| 104 |
+
setattr(parent, name.rsplit(".", 1)[-1], LoRALinear(mod, rank, alpha))
|
| 105 |
+
# 冻结一切非 LoRA 参数(含所有 bias / norm / embedding / patch_embedding / head)
|
| 106 |
+
n_tr = n_all = 0
|
| 107 |
+
for n, p in root.named_parameters():
|
| 108 |
+
keep = n.endswith(("lora_A", "lora_B"))
|
| 109 |
+
p.requires_grad_(keep)
|
| 110 |
+
n_all += p.numel()
|
| 111 |
+
n_tr += p.numel() if keep else 0
|
| 112 |
+
log(f"[lora] {'学生' if is_student else 'critic'}:包了 {len(hits)} 个 Linear "
|
| 113 |
+
f"(rank={rank} alpha={alpha} scale={alpha/rank:g}),"
|
| 114 |
+
f"可训 {n_tr/1e6:.1f}M / {n_all/1e6:.0f}M = {100*n_tr/n_all:.2f}%")
|
| 115 |
+
return len(hits)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@contextlib.contextmanager
|
| 119 |
+
def lora_disabled(root: nn.Module):
|
| 120 |
+
"""临时关掉 root 下所有 LoRALinear 的补丁项:前向逐位等于冻结的 base(官方 OPSD-V 的教师就是这么切的,
|
| 121 |
+
_swap_generator_to_ema / teacher adapter;这里 base 冻结 ⇒ 教师 = init_ckpt)。嵌套安全,异常也会恢复。"""
|
| 122 |
+
mods = [m for m in root.modules() if isinstance(m, LoRALinear)]
|
| 123 |
+
prev = [m.enabled for m in mods]
|
| 124 |
+
for m in mods:
|
| 125 |
+
m.enabled = False
|
| 126 |
+
try:
|
| 127 |
+
yield
|
| 128 |
+
finally:
|
| 129 |
+
for m, e in zip(mods, prev):
|
| 130 |
+
m.enabled = e
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def lora_parameters(root: nn.Module):
|
| 134 |
+
ps = [p for n, p in root.named_parameters() if n.endswith(("lora_A", "lora_B"))]
|
| 135 |
+
if not ps:
|
| 136 |
+
raise SystemExit("lora_parameters 空 —— install_lora 没跑或名字对不上")
|
| 137 |
+
return ps
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
@torch.no_grad()
|
| 141 |
+
def merged_state_dict(root: nn.Module, src: dict | None = None) -> dict:
|
| 142 |
+
"""把 B@A·scale 折进 base.weight,返回**没有 LoRA 包装**的普通 state_dict。
|
| 143 |
+
|
| 144 |
+
src 非 None 时从它取值(FSDP 下先 accelerator.get_state_dict 拿到全量,再在这里折);
|
| 145 |
+
否则直接读 root 自己的(单进程 / CPU 路径)。
|
| 146 |
+
"""
|
| 147 |
+
sd = dict(src if src is not None else root.state_dict())
|
| 148 |
+
out, n = {}, 0
|
| 149 |
+
# ★ FSDP 会把每个 DiTBlock 就地包一层,named_modules() 的名字里多出 `_fsdp_wrapped_module`(激活检查点则是
|
| 150 |
+
# `_checkpoint_wrapped_module`),而 FULL_STATE_DICT 的键是干净的 —— 不剥掉这些壳,下面按 owner 查模块会全部落空
|
| 151 |
+
# (2026-09-10 stage4 LoRA 冒烟第一次存盘就是这么炸的)。
|
| 152 |
+
_WRAP = {"_fsdp_wrapped_module", "_checkpoint_wrapped_module"}
|
| 153 |
+
lora = {".".join(p for p in n_.split(".") if p not in _WRAP): m
|
| 154 |
+
for n_, m in root.named_modules() if isinstance(m, LoRALinear)}
|
| 155 |
+
for k, v in sd.items():
|
| 156 |
+
if k.endswith((".lora_A", ".lora_B")):
|
| 157 |
+
continue
|
| 158 |
+
if ".base." in k:
|
| 159 |
+
owner, tail = k.split(".base.", 1)
|
| 160 |
+
# ★ 前缀无关地找模块:save_dit 的 post 钩子在 strip_dit_prefix **之前**跑,
|
| 161 |
+
# 所以这里的键还带着 "dit." 之类的外壳前缀,直接拿 owner 去查会全部落空
|
| 162 |
+
# (表现为「一个矩阵都没合并」——下面那条断言就是为这个留的)。
|
| 163 |
+
mod = lora.get(owner)
|
| 164 |
+
if mod is None:
|
| 165 |
+
for cut in range(1, owner.count(".") + 1):
|
| 166 |
+
cand = owner.split(".", cut)[-1]
|
| 167 |
+
if cand in lora:
|
| 168 |
+
mod = lora[cand]
|
| 169 |
+
break
|
| 170 |
+
if mod is not None and tail == "weight":
|
| 171 |
+
A, B = sd.get(owner + ".lora_A"), sd.get(owner + ".lora_B")
|
| 172 |
+
if A is not None and B is not None:
|
| 173 |
+
v = (v.float() + (B.float() @ A.float()) * mod.scale).to(v.dtype)
|
| 174 |
+
n += 1
|
| 175 |
+
out[owner + "." + tail] = v
|
| 176 |
+
else:
|
| 177 |
+
out[k] = v
|
| 178 |
+
if n == 0 and lora:
|
| 179 |
+
raise SystemExit("merged_state_dict 一个矩阵都没合并 —— 键名口径对不上,别存这份权重")
|
| 180 |
+
return out
|
backup_2026-09-14/code/distill/onpolicy.py
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""stage3-onpolicy 的算法层:AnyFlow 的 on-policy DMD,移植到 ActionRoPE 的因果 AR + 滑窗 KV。
|
| 2 |
+
|
| 3 |
+
上游 = `vendor/AnyFlow/far/trainers/trainer_far_wan_anyflow_onpolicy.py`
|
| 4 |
+
+ `vendor/AnyFlow/far/pipelines/pipeline_far_wan_anyflow.py::inference / training_rollout`
|
| 5 |
+
|
| 6 |
+
它与我们原来那份 `stage3_dmd/dmd_arope.py`(WorldMind 口径)有三处**本质**差别,只有第一处是
|
| 7 |
+
新机制,另外两处是命名/形式上的澄清:
|
| 8 |
+
|
| 9 |
+
1. ★ **rollout 恒定 3 次前向,与步数 N 无关**(`pipeline_far_wan_anyflow.py:443-449`)。
|
| 10 |
+
因为学生是 flow map,「从任意 t 跳到任意 r」本来就是它的定义,所以中间步不用跑:
|
| 11 |
+
|
| 12 |
+
prev = [σ_0, σ_g ] 一步跳到 g
|
| 13 |
+
current = [σ_g, σ_{g+1}]
|
| 14 |
+
post = [σ_{g+1}, 0 ] 一步跳到终点
|
| 15 |
+
|
| 16 |
+
三段**都带梯度**(上游 inference() 里没有任何 no_grad)。压掉的是**前向次数**(N → 3),
|
| 17 |
+
不是带梯度的图 —— 每个 in-window cell 仍有 3 次图,是旧 exit_step 版(1 次/cell)的 3 倍。
|
| 18 |
+
所以 grad_horizon 要相应调小:horizon 20 在 8×140 GB 上直接 OOM,实测 6 可跑。
|
| 19 |
+
|
| 20 |
+
`g = randrange(0, N)` 每次 rollout 抽一次(各 rank 同步)。WorldMind 的 exit_step 是
|
| 21 |
+
「跑到第 g 步就停」,要跑 g 次前向;这里是「跳过去」,永远 3 次。
|
| 22 |
+
|
| 23 |
+
2. ★ **每次 rollout 的步数 N 从 `[2,4,8,16,50]` 里随机抽**(`trainer:213`)。这是 on-policy
|
| 24 |
+
阶段**保住 any-step 性质**的唯一机制:只在一张固定表上做 DMD,会把别的步数训丢。
|
| 25 |
+
|
| 26 |
+
3. 上游叫 `discriminator` 的那个模型**不是 GAN 判别器** —— `discriminator_loss` 是在学生
|
| 27 |
+
自己生成的样本上做普通 flow-matching 回归(`trainer:246-251`),也就是 DMD 的 **fake score**。
|
| 28 |
+
与我们 `dmd_arope.py` 里的 `critic` 是同一个东西,这里沿用 `critic` 这个名字。
|
| 29 |
+
|
| 30 |
+
三条与 ActionRoPE 契约相关、上游没有的约束(写错都不报错):
|
| 31 |
+
* cell 0 是干净 TI2V 锚点,任何时候都不进 loss、不被加噪。
|
| 32 |
+
* 提交进 KV cache 恒用 `t=r=0` 的 detach 结果 —— 这是 TF clean 流的同分布要求,与 flow map 无关。
|
| 33 |
+
* real / fake score 都在 `r == t` 上调用(上游 `trainer:120,125` 明写 `r_timestep=timesteps`),
|
| 34 |
+
也就是当**普通扩散**用。它们不必是 flow map,我们就用 827 键的 stage1b 教师。
|
| 35 |
+
"""
|
| 36 |
+
from __future__ import annotations
|
| 37 |
+
|
| 38 |
+
import torch
|
| 39 |
+
|
| 40 |
+
from distill import flowmap as fm
|
| 41 |
+
|
| 42 |
+
__all__ = [
|
| 43 |
+
"STEP_LIST", "draw_rollout_plan", "RolloutPlan",
|
| 44 |
+
"three_range_sigmas", "x0_from_u", "dmd_grad", "dmd_generator_loss",
|
| 45 |
+
"critic_flow_loss", "logit_normal_timesteps", "anystep_rollout",
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
+
# 上游 `rollout_cfg.num_inference_steps_list`(onpolicy yml:105)
|
| 49 |
+
STEP_LIST = (2, 4, 8, 16, 50)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class RolloutPlan:
|
| 53 |
+
"""一次 rollout 的抽签结果。所有 rank 必须拿到**同一份**,否则三段的 σ 对不上、
|
| 54 |
+
DMD 的梯度会在不同 rank 上落在不同噪声档,all-reduce 之后是一锅粥(且不报错)。"""
|
| 55 |
+
|
| 56 |
+
__slots__ = ("n_steps", "grad_idx", "sigmas")
|
| 57 |
+
|
| 58 |
+
def __init__(self, n_steps: int, grad_idx: int, sigmas: torch.Tensor):
|
| 59 |
+
self.n_steps, self.grad_idx, self.sigmas = int(n_steps), int(grad_idx), sigmas
|
| 60 |
+
|
| 61 |
+
def __repr__(self):
|
| 62 |
+
return f"RolloutPlan(N={self.n_steps}, g={self.grad_idx}, σ={[round(float(x),4) for x in self.sigmas]})"
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def draw_rollout_plan(gen: torch.Generator | None = None, *, step_list=STEP_LIST,
|
| 66 |
+
device=None, accelerator=None) -> RolloutPlan:
|
| 67 |
+
"""抽 (N, g) 并算出 any-step 轴。`accelerator` 非 None 时从 rank0 广播(上游 trainer:214,219)。
|
| 68 |
+
|
| 69 |
+
`gen` 只在单进程/测试里用;多卡一律走广播 —— 各 rank 自己抽同一个种子也「通常」一致,
|
| 70 |
+
但一旦哪个 rank 多消耗一次随机数就会静默错开,广播才是硬保证。
|
| 71 |
+
"""
|
| 72 |
+
if accelerator is not None and getattr(accelerator, "num_processes", 1) > 1:
|
| 73 |
+
buf = torch.zeros(2, dtype=torch.long, device=accelerator.device)
|
| 74 |
+
if accelerator.is_main_process:
|
| 75 |
+
i = int(torch.randint(len(step_list), (1,), generator=gen).item())
|
| 76 |
+
n = int(step_list[i])
|
| 77 |
+
buf[0], buf[1] = n, int(torch.randint(n, (1,), generator=gen).item())
|
| 78 |
+
torch.distributed.broadcast(buf, src=0)
|
| 79 |
+
n_steps, grad_idx = int(buf[0]), int(buf[1])
|
| 80 |
+
else:
|
| 81 |
+
n_steps = int(step_list[int(torch.randint(len(step_list), (1,), generator=gen).item())])
|
| 82 |
+
grad_idx = int(torch.randint(n_steps, (1,), generator=gen).item())
|
| 83 |
+
return RolloutPlan(n_steps, grad_idx, fm.anystep_sigmas(n_steps, device=device))
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def three_range_sigmas(plan: RolloutPlan) -> tuple[tuple[float, float] | None,
|
| 87 |
+
tuple[float, float],
|
| 88 |
+
tuple[float, float] | None]:
|
| 89 |
+
"""把 any-step 轴切成上游那三段(`pipeline_far_wan_anyflow.py:443-445`)。
|
| 90 |
+
|
| 91 |
+
轴长 N+1(末位 0.0)。g ∈ [0, N):
|
| 92 |
+
prev (σ[0], σ[g]) —— g == 0 时是空段(起点就是 g),返回 None
|
| 93 |
+
current (σ[g], σ[g+1]) —— 恒非空,带梯度
|
| 94 |
+
post (σ[g+1], σ[-1]) —— g == N-1 时 σ[g+1] 就是末位 0.0,空段,返回 None
|
| 95 |
+
|
| 96 |
+
`t == r` 的段上游直接 `continue`(:394),所以这里用 None 表示,别让调用方去跑一次
|
| 97 |
+
Δσ=0 的 Euler —— 那一步数值上是恒等,但会白跑一次 5B 前向。
|
| 98 |
+
"""
|
| 99 |
+
s = plan.sigmas
|
| 100 |
+
g, N = plan.grad_idx, plan.n_steps
|
| 101 |
+
assert 0 <= g < N, f"grad_idx {g} 必须落在 [0, {N})"
|
| 102 |
+
assert int(s.numel()) == N + 1, f"σ 轴长应为 {N + 1},得到 {int(s.numel())}"
|
| 103 |
+
a, b, c = float(s[0]), float(s[g]), float(s[g + 1])
|
| 104 |
+
d = float(s[-1])
|
| 105 |
+
prev = None if g == 0 else (a, b)
|
| 106 |
+
cur = (b, c)
|
| 107 |
+
post = None if (g + 1) >= N else (c, d)
|
| 108 |
+
return prev, cur, post
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def x0_from_u(x: torch.Tensor, u: torch.Tensor, sigma_t) -> torch.Tensor:
|
| 112 |
+
"""从 flow-map 输出取 x0 估计:一步跳到 σ_r = 0。
|
| 113 |
+
|
| 114 |
+
= 上游 `dmd_scheduler.step(noise_pred, t, noisy, torch.zeros_like(t))`
|
| 115 |
+
(`trainer:121,126,132`)—— r 恒为 0,所以就是 `x − σ_t·u`。
|
| 116 |
+
"""
|
| 117 |
+
return fm.euler_step(x, u, sigma_t, 0.0)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def dmd_grad(pred_fake_x0: torch.Tensor, pred_real_x0: torch.Tensor,
|
| 121 |
+
generated: torch.Tensor, *, normalization: bool = True,
|
| 122 |
+
eval_mask: torch.Tensor | None = None) -> torch.Tensor:
|
| 123 |
+
"""DMD 梯度(上游 `_compute_kl_grad` step 3-4,trainer:141-150)。
|
| 124 |
+
|
| 125 |
+
grad = (x0_fake − x0_real) / mean|generated − x0_real|
|
| 126 |
+
|
| 127 |
+
归一化的分母是**逐样本**的(上游 `mean(dim=[1,2,3,4], keepdim=True)`)。
|
| 128 |
+
`eval_mask`([F] bool)非 None 时只在被打分的 cell 上归约 —— ActionRoPE 的 cell 0 是干净
|
| 129 |
+
锚点,把它算进分母会把归一化系数拉偏;上游没有这个概念,所以它没有这一项。
|
| 130 |
+
|
| 131 |
+
上游**没有** clamp(min=1e-2)(那是 WorldMind 的 dmd_gm.py 有的)。这里保持上游原样,
|
| 132 |
+
只做 nan_to_num —— 分母是 |真样本 − 真分数估计| 的均值,退化到 0 意味着 real score 已经
|
| 133 |
+
完美,那时 grad 本来就该是 0/0,nan_to_num 会把它变成 0,是对的。
|
| 134 |
+
"""
|
| 135 |
+
grad = pred_fake_x0 - pred_real_x0
|
| 136 |
+
if normalization:
|
| 137 |
+
p_real = generated - pred_real_x0
|
| 138 |
+
if eval_mask is not None:
|
| 139 |
+
idx = torch.nonzero(eval_mask, as_tuple=True)[0]
|
| 140 |
+
norm = p_real.index_select(2, idx).abs().mean(dim=[1, 2, 3, 4], keepdim=True)
|
| 141 |
+
else:
|
| 142 |
+
norm = p_real.abs().mean(dim=[1, 2, 3, 4], keepdim=True)
|
| 143 |
+
grad = grad / norm
|
| 144 |
+
return torch.nan_to_num(grad)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def dmd_generator_loss(generated: torch.Tensor, grad: torch.Tensor, weight: float = 1.0) -> torch.Tensor:
|
| 148 |
+
"""把 grad 变成一个可反传的标量(上游 trainer:195):
|
| 149 |
+
|
| 150 |
+
loss = w · MSE(generated, (generated − grad).detach())
|
| 151 |
+
|
| 152 |
+
这不是一个"距离",是把预先算好的梯度塞进 autograd 的标准手法:
|
| 153 |
+
d(loss)/d(generated) ∝ grad。所以 **loss 的数值本身没有意义**,别拿它当收敛判据。
|
| 154 |
+
上游用 double() 算这一步,照抄 —— generated 在 bf16 里,(g − grad) 的残差会被尾数吃掉。
|
| 155 |
+
"""
|
| 156 |
+
return weight * torch.nn.functional.mse_loss(
|
| 157 |
+
generated.double(), (generated.double() - grad.double()).detach(), reduction="mean")
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def logit_normal_timesteps(batch: int, *, mean: float = 0.0, std: float = 1.0,
|
| 161 |
+
gen: torch.Generator | None = None) -> torch.Tensor:
|
| 162 |
+
"""critic 训练用的噪声档抽样(上游 `discriminator_loss` trainer:236,
|
| 163 |
+
`compute_density_for_timestep_sampling(weighting_scheme='logit_normal', logit_mean=0, logit_std=1)`)。
|
| 164 |
+
|
| 165 |
+
返回 [b] 的 **shift 前** u ∈ (0,1):u = sigmoid(N(mean, std))。调用方还要 apply_shift 再 ×1000。
|
| 166 |
+
注意 generator 侧的 DMD 噪声档用的是**均匀** rand(trainer:183),两处不是同一个分布,
|
| 167 |
+
别顺手统一 —— fake score 要在整条轨迹上准,而 DMD 的梯度只在被抽到的那一档上估。
|
| 168 |
+
"""
|
| 169 |
+
return torch.sigmoid(torch.randn(batch, generator=gen) * std + mean)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def critic_flow_loss(u_pred: torch.Tensor, x0: torch.Tensor, noise: torch.Tensor,
|
| 173 |
+
*, eval_mask: torch.Tensor | None = None) -> torch.Tensor:
|
| 174 |
+
"""fake score(上游的 `discriminator`)的训练目标(trainer:247-251):
|
| 175 |
+
|
| 176 |
+
target = ε − x0 普通 rectified flow,**不带** flow map 的 (t−r) 修正项
|
| 177 |
+
—— 它是在 r == t 上调用的,修正项本来就是 0。
|
| 178 |
+
|
| 179 |
+
`eval_mask` 非 None 时只在被打分的 cell 上取均值(ActionRoPE 的 cell 0 不进 loss)。
|
| 180 |
+
"""
|
| 181 |
+
target = noise - x0
|
| 182 |
+
se = (u_pred.float() - target.float()) ** 2
|
| 183 |
+
if eval_mask is not None:
|
| 184 |
+
idx = torch.nonzero(eval_mask, as_tuple=True)[0]
|
| 185 |
+
se = se.index_select(2, idx)
|
| 186 |
+
return se.mean()
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
# ----------------------------------------------------------------------------
|
| 190 |
+
# rollout:因果 AR + 滑窗 KV,每个 cell 走三段跳
|
| 191 |
+
# ----------------------------------------------------------------------------
|
| 192 |
+
|
| 193 |
+
def anystep_rollout(student, initial_latent, ctx_ext, ids_ext, off_ext, num_gen_cells: int,
|
| 194 |
+
plan: "RolloutPlan", *, frame_cond, grad_horizon: int,
|
| 195 |
+
kv_window: int = 20, rope_cap: int = 20, sink_size: int = 0,
|
| 196 |
+
grad_start: int | None = None, gen: torch.Generator | None = None):
|
| 197 |
+
"""学生用自己提交的历史自回归生成 num_gen_cells 个 cell —— on-policy 版。
|
| 198 |
+
|
| 199 |
+
与 `stage3_dmd.self_forcing_rollout` 逐行对应,**只有一处不同**:每个 cell 内部不再沿
|
| 200 |
+
部署表逐步去噪 + 重新加噪,而是走上游的三段跳(`pipeline_far_wan_anyflow.py:443-449`):
|
| 201 |
+
|
| 202 |
+
x ←(no_grad) jump(σ₀ → σ_g) prev
|
| 203 |
+
x ←( grad ) jump(σ_g → σ_{g+1}) current ← 全 rollout 唯一带梯度的前向
|
| 204 |
+
x ←(no_grad) jump(σ_{g+1} → 0) post
|
| 205 |
+
|
| 206 |
+
于是**每个 cell 恒定 ≤3 次前向、与 N 无关**,而且带梯度的图只有一次单帧前向 ——
|
| 207 |
+
`dmd_arope` 里那个 77.8 GB 激活的问题(以及为它写的 stream_grad_replay)在这里不存在。
|
| 208 |
+
|
| 209 |
+
返回 (generated [b,48,1+N,h,w], grad_mask [1+N] bool),与 self_forcing_rollout 同签名,
|
| 210 |
+
所以下游的 score_tail / dmd_targets 那套可以原样接。
|
| 211 |
+
"""
|
| 212 |
+
b, c, _, lat_h, lat_w = initial_latent.shape
|
| 213 |
+
dev = initial_latent.device
|
| 214 |
+
total = 1 + num_gen_cells
|
| 215 |
+
bare = unwrap_fn(student)
|
| 216 |
+
cache = bare.init_kv_cache(kv_window=kv_window, sink_size=sink_size, rope_cap=rope_cap)
|
| 217 |
+
prev_seg, cur_seg, post_seg = three_range_sigmas(plan)
|
| 218 |
+
|
| 219 |
+
def _call(x, s_t, s_r, k, commit, ow, mk):
|
| 220 |
+
"""一次 flow-map 前向。**必须**走 forward(..., kv_cache=):FSDP 的 all-gather 挂在
|
| 221 |
+
包装模块的 forward 上,直接调 forward_frame 拿到的是分片且不报错(dmd_arope risk 2)。"""
|
| 222 |
+
tt = torch.full((b,), float(s_t) * fm.NUM_TRAIN_TIMESTEPS, device=dev)
|
| 223 |
+
rr = torch.full((b,), float(s_r) * fm.NUM_TRAIN_TIMESTEPS, device=dev)
|
| 224 |
+
return student(x, tt, ctx_ext[:, k:k + 1], context_ids=ids_ext[:, k:k + 1],
|
| 225 |
+
r_timestep=rr, first_frame_cond=False, frame_index=k,
|
| 226 |
+
kv_cache=cache, commit=commit, offset_win=ow, mask_in_k=mk)
|
| 227 |
+
|
| 228 |
+
ow0, mk0 = frame_cond(off_ext, 0, sink_size, kv_window, lat_h, lat_w)
|
| 229 |
+
with torch.no_grad():
|
| 230 |
+
_call(initial_latent, 0.0, 0.0, 0, True, ow0, mk0) # 真实首帧,t=r=0 进缓存
|
| 231 |
+
frame_list = [initial_latent[:, :, 0:1].detach()]
|
| 232 |
+
|
| 233 |
+
if grad_start is None:
|
| 234 |
+
grad_start = int(torch.randint(1, max(2, total - grad_horizon + 1), (1,), generator=gen).item())
|
| 235 |
+
grad_end = min(grad_start + grad_horizon, total)
|
| 236 |
+
|
| 237 |
+
for k in range(1, total):
|
| 238 |
+
ow, mk = frame_cond(off_ext, k, sink_size, kv_window, lat_h, lat_w)
|
| 239 |
+
# CPU 上抽再搬:`torch.randn(..., device=cuda, generator=cpu_gen)` 在 CUDA 上直接报
|
| 240 |
+
# "Expected a 'cuda' device type for generator"。与本文件其余噪声(generator_step /
|
| 241 |
+
# critic_step 里的 `randn(...).to(tail)`)口径一致,顺带让不同设备上可复现。
|
| 242 |
+
# 单 cell 的张量很小,多一次 H2D 拷贝可以忽略。
|
| 243 |
+
x = torch.randn(b, c, 1, lat_h, lat_w, generator=gen).to(device=dev, dtype=initial_latent.dtype)
|
| 244 |
+
in_window = grad_start <= k < grad_end
|
| 245 |
+
# ★ 三段**都**带梯度(上游 `inference()` 里没有任何 no_grad,pipeline:447-449)。
|
| 246 |
+
# `grad_timestep` 的作用是把 N 次前向压成 **3** 次,不是「只让其中一次带梯度」——
|
| 247 |
+
# 我第一版写成了后者,那样会丢掉 prev/post 两条通路的梯度,且不会报错。
|
| 248 |
+
# 不在 grad 窗口里的 cell 仍然全程 no_grad:它们不进 loss,建图纯属浪费。
|
| 249 |
+
with (torch.enable_grad() if in_window else torch.no_grad()):
|
| 250 |
+
if prev_seg is not None:
|
| 251 |
+
x = fm.euler_step(x, _call(x, *prev_seg, k, False, ow, mk), *prev_seg)
|
| 252 |
+
x = fm.euler_step(x, _call(x, *cur_seg, k, False, ow, mk), *cur_seg)
|
| 253 |
+
if post_seg is not None:
|
| 254 |
+
x = fm.euler_step(x, _call(x, *post_seg, k, False, ow, mk), *post_seg)
|
| 255 |
+
frame_list.append(x)
|
| 256 |
+
with torch.no_grad(): # 提交趟:detach 的 x0 + t=r=0
|
| 257 |
+
_call(x.detach(), 0.0, 0.0, k, True, ow, mk)
|
| 258 |
+
|
| 259 |
+
generated = torch.cat(frame_list, dim=2)
|
| 260 |
+
grad_mask = torch.tensor([grad_start <= k < grad_end for k in range(total)], device=dev)
|
| 261 |
+
# ★ 必须显式释放:cache 与它内部的 `_ref` 互相引用,引用计数放不掉环,只能等惰性的循环 GC。
|
| 262 |
+
# 一条 rollout 的 K/V 是十几 GB,不放的话几步之内显存就爬满(实测 98→120→126 GB 后 OOM)。
|
| 263 |
+
bare.release_kv_cache(cache)
|
| 264 |
+
return generated, grad_mask
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def unwrap_fn(m):
|
| 268 |
+
"""延迟 import,避免 distill.onpolicy ←→ distill.arope_dit 的循环。"""
|
| 269 |
+
from distill.arope_dit import unwrap
|
| 270 |
+
return unwrap(m)
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
# ----------------------------------------------------------------------------
|
| 274 |
+
# SGF:bounded two-pass replay(vendor/SGF,pipeline/self_gradient_forcing_training.py:117-230)
|
| 275 |
+
# ----------------------------------------------------------------------------
|
| 276 |
+
|
| 277 |
+
def sgf_rollout(student, initial_latent, ctx_ext, ids_ext, off_ext, num_gen_cells: int,
|
| 278 |
+
sigmas, exit_idx: int, *, context_noise: float = 0.0,
|
| 279 |
+
kv_window: int = 20, rope_cap: int = 20, sink_size: int = 0,
|
| 280 |
+
gen: torch.Generator | None = None, unwrap_fn=None, frame_cond_fn=None):
|
| 281 |
+
"""SGF 的 pass 1:**全程 no_grad** 的 AR rollout,只负责「录像」。
|
| 282 |
+
|
| 283 |
+
录两样东西(上游 `_self_gradient_forcing_inference` 的 noisy_at_t / x_ctx_hat):
|
| 284 |
+
noisy_at_t 每个 cell 在抽中的退出档 `exit_idx` 上的**带噪输入**
|
| 285 |
+
x0_cache 每个 cell 提交进 KV 缓存的那份 x0(cache_mode="exit":退出档的 x0,不是走完的)
|
| 286 |
+
|
| 287 |
+
为什么 pass 1 可以全 no_grad —— 这正是 SGF 的论点:现在这条路上的梯度**本来就是断的**
|
| 288 |
+
(每个 cell 提交 KV 时是 `x.detach()`),所以在 pass 1 里建图只是浪费。真正的梯度由 pass 2
|
| 289 |
+
的一次并行 TF 前向补回来,那一次跨帧注意力才第一次带着梯度跑过自生成历史。
|
| 290 |
+
|
| 291 |
+
★ 只跑到 exit_idx 就 break(上游 cache_mode="exit",trainer:179)。每个 cell 的前向数是
|
| 292 |
+
exit_idx+1(去噪)+ 1(提交),期望约 N/2+1,与 anystep_rollout 的三段跳同量级。
|
| 293 |
+
|
| 294 |
+
返回 (noisy_at_t, x0_cache),都是 [b,48,1+N,h,w] 且已 detach。
|
| 295 |
+
"""
|
| 296 |
+
b, c, _, lat_h, lat_w = initial_latent.shape
|
| 297 |
+
dev = initial_latent.device
|
| 298 |
+
total = 1 + num_gen_cells
|
| 299 |
+
bare = unwrap_fn(student)
|
| 300 |
+
cache = bare.init_kv_cache(kv_window=kv_window, sink_size=sink_size, rope_cap=rope_cap)
|
| 301 |
+
|
| 302 |
+
def _call(x, s_t, s_r, k, commit, ow, mk):
|
| 303 |
+
tt = torch.full((b,), float(s_t) * fm.NUM_TRAIN_TIMESTEPS, device=dev)
|
| 304 |
+
rr = torch.full((b,), float(s_r) * fm.NUM_TRAIN_TIMESTEPS, device=dev)
|
| 305 |
+
return student(x, tt, ctx_ext[:, k:k + 1], context_ids=ids_ext[:, k:k + 1],
|
| 306 |
+
r_timestep=rr, first_frame_cond=False, frame_index=k,
|
| 307 |
+
kv_cache=cache, commit=commit, offset_win=ow, mask_in_k=mk)
|
| 308 |
+
|
| 309 |
+
ow0, mk0 = frame_cond_fn(off_ext, 0, sink_size, kv_window, lat_h, lat_w)
|
| 310 |
+
z0 = initial_latent[:, :, 0:1].detach()
|
| 311 |
+
with torch.no_grad():
|
| 312 |
+
_call(z0, 0.0, 0.0, 0, True, ow0, mk0) # 真实首帧,t=r=0 进缓存
|
| 313 |
+
# cell 0 是干净锚点:它的「带噪输入」就是它自己(pass 2 的 first_frame_cond 会把 t/r 抹成 0)
|
| 314 |
+
noisy_list, x0_list = [z0], [z0]
|
| 315 |
+
|
| 316 |
+
with torch.no_grad():
|
| 317 |
+
for k in range(1, total):
|
| 318 |
+
ow, mk = frame_cond_fn(off_ext, k, sink_size, kv_window, lat_h, lat_w)
|
| 319 |
+
x = torch.randn(b, c, 1, lat_h, lat_w, generator=gen).to(device=dev,
|
| 320 |
+
dtype=initial_latent.dtype)
|
| 321 |
+
noisy_at_exit, x0_exit = None, None
|
| 322 |
+
for i in range(len(sigmas) - 1):
|
| 323 |
+
s_t, s_r = float(sigmas[i]), float(sigmas[i + 1])
|
| 324 |
+
if i == exit_idx:
|
| 325 |
+
noisy_at_exit = x # ★ 录的是**进入**这一档的输入
|
| 326 |
+
u = _call(x, s_t, s_r, k, False, ow, mk)
|
| 327 |
+
if i == exit_idx:
|
| 328 |
+
x0_exit = x0_from_u(x, u, s_t) # 退出档的 x0 估计
|
| 329 |
+
break # cache_mode="exit"
|
| 330 |
+
x = fm.euler_step(x, u, s_t, s_r)
|
| 331 |
+
assert noisy_at_exit is not None and x0_exit is not None, "没抓到退出档的状态"
|
| 332 |
+
noisy_list.append(noisy_at_exit)
|
| 333 |
+
x0_list.append(x0_exit)
|
| 334 |
+
_call(x0_exit, 0.0, 0.0, k, True, ow, mk) # 提交:与 x0_cache 逐位相同
|
| 335 |
+
bare.release_kv_cache(cache)
|
| 336 |
+
return (torch.cat(noisy_list, dim=2).detach(),
|
| 337 |
+
torch.cat(x0_list, dim=2).detach())
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
def sgf_replay(student, noisy_at_t, x0_cache, cond, sigmas, exit_idx: int, *,
|
| 341 |
+
context_noise: float = 0.0, gen=None, gradient_checkpointing: bool = True):
|
| 342 |
+
"""SGF 的 pass 2:**一次带梯度的并行 TF 前向**,clean 半流喂 pass 1 自己生成并提交的历史。
|
| 343 |
+
|
| 344 |
+
这就是被补回来的那条通路 —— 跨帧注意力这次带着梯度跑过自生成上下文
|
| 345 |
+
(上游 trainer:216-230,`self_gradient_forcing_match_context=True` 分支)。
|
| 346 |
+
|
| 347 |
+
clean 半流必须与 pass 1 **真正提交进缓存的东西**一致(上游的 match_context)。我们提交的是
|
| 348 |
+
`x0_exit`,所以这里喂的就是 `x0_cache`;`context_noise>0` 时两边都要加同一档噪声。
|
| 349 |
+
|
| 350 |
+
几何:pass 2 是整片并行、first_frame_cond=True、锚在片自己的 cell 0;pass 1 的每个 cell
|
| 351 |
+
锚在自己窗口里最老那一格。二者只在**整窗平移**上不同,而 RoPE 是相对编码、平移不改分数——
|
| 352 |
+
前提是 rollout 长度 == 打分片长(都是 1+K),此时 pass 1 的缓存跨度与 pass 2 的片完全重合。
|
| 353 |
+
rollout 比片长时尾巴前段在 pass 1 里看过片外历史,那才是真的错配,所以别把 rollout 拉长。
|
| 354 |
+
|
| 355 |
+
返回 pass 2 预测的 x0 [b,48,1+N,h,w],带梯度。
|
| 356 |
+
"""
|
| 357 |
+
s_t, s_r = float(sigmas[exit_idx]), float(sigmas[exit_idx + 1])
|
| 358 |
+
b = noisy_at_t.shape[0]
|
| 359 |
+
dev = noisy_at_t.device
|
| 360 |
+
tt = torch.full((b,), s_t * fm.NUM_TRAIN_TIMESTEPS, device=dev)
|
| 361 |
+
rr = torch.full((b,), s_r * fm.NUM_TRAIN_TIMESTEPS, device=dev)
|
| 362 |
+
if context_noise > 0:
|
| 363 |
+
noise = torch.randn(x0_cache.shape, generator=gen).to(x0_cache)
|
| 364 |
+
clean_x = fm.add_noise_scalar(x0_cache, noise,
|
| 365 |
+
torch.full((b,), context_noise, device=dev))
|
| 366 |
+
clean_x = fm.keep_frame0(clean_x, x0_cache) # 锚点始终精确干净
|
| 367 |
+
aug_t = torch.full((b,), context_noise * fm.NUM_TRAIN_TIMESTEPS, device=dev)
|
| 368 |
+
else:
|
| 369 |
+
clean_x, aug_t = x0_cache, None
|
| 370 |
+
# ★ 梯度检查点在这里不是可选项:pass 2 是双流 2×21×390 = 16380 token 过 30 层,
|
| 371 |
+
# 而 stage3 同时驻着四份模型(学生/critic 各带 fp32 主权重+优化器态、teacher、EMA)。
|
| 372 |
+
# 不开实测 8×140 GB 全部 OOM(137.7 GiB 已分配)。stage1 的 TF 也是开着的。
|
| 373 |
+
u = student(noisy_at_t, tt, cond["context"], context_ids=cond["context_ids"],
|
| 374 |
+
offset_px=cond["offset_px"], mask_input=cond["mask_input"],
|
| 375 |
+
first_frame_cond=True, r_timestep=rr,
|
| 376 |
+
clean_x=clean_x.detach(), aug_t=aug_t,
|
| 377 |
+
use_gradient_checkpointing=gradient_checkpointing)
|
| 378 |
+
return x0_from_u(noisy_at_t, u, s_t)
|
backup_2026-09-14/code/distill/runlib.py
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""三阶段蒸馏共用的 run 目录 / 日志 / checkpoint 约定。
|
| 3 |
+
|
| 4 |
+
from distill import runlib
|
| 5 |
+
run_dir = runlib.resolve_run_dir(args.output)
|
| 6 |
+
log = runlib.TextTee(os.path.join(run_dir, "train.log"), enabled=accel.is_main_process)
|
| 7 |
+
runlib.save_dit(accel, student, runlib.dit_path(run_dir, step), {"step": step})
|
| 8 |
+
runlib.rotate(runlib.dit_glob(run_dir), args.keep_last_dits)
|
| 9 |
+
|
| 10 |
+
目录布局(三个 stage 共用,run_dir 默认落在 <repo>/outputs/distill/<name>):
|
| 11 |
+
|
| 12 |
+
<run_dir>/
|
| 13 |
+
config.json 本次 run 的完整参数快照
|
| 14 |
+
progress.json {"step": N, ...},续训只认它
|
| 15 |
+
train.log TextTee 的人读日志(与 stdout 同步)
|
| 16 |
+
train_log.csv CsvLog,一步一行,固定表头
|
| 17 |
+
train_log.jsonl JsonlLogger,一步一行 JSON(可被别的机器 tail)
|
| 18 |
+
dits/dit_step{N}.safetensors 学生原始权重,只为续训 —— 不要拿去部署
|
| 19 |
+
ema/ema_step{N}.safetensors EMA 影子权重 = stage2 / stage3 的部署权重
|
| 20 |
+
critic/critic_step{N}.safetensors stage3 的 fake-score critic,只为续训
|
| 21 |
+
state_A/ state_B/ + state -> 软链 accelerate 完整分片存档(只有 stage1 用)
|
| 22 |
+
|
| 23 |
+
为什么 dits / ema / critic 都要 rotate:单个文件 10.0 GB。WorldMind 只 rotate 了
|
| 24 |
+
state_step*(infra/run.py:rotate_states 的注释明说不动 dits/),它的 v4 stage-1 run
|
| 25 |
+
光 dits/ 就吃掉 746 GB。这里 rotate() 对三种前缀一视同仁。
|
| 26 |
+
|
| 27 |
+
checkpoint 格式沿用 ActionRoPE 的(不是 WorldMind 的):单个 safetensors,827 个 bf16
|
| 28 |
+
张量,DiT 键去掉 `dit.` 前缀(臂键保留 `arm.`),metadata 全是字符串,chmod 0644。
|
| 29 |
+
这样 tests/check_ckpt.py、actionrope/infer.py、eval_bg50.py 不用改一行就能吃蒸馏产物。
|
| 30 |
+
|
| 31 |
+
stage2-flowmap 的产物是这个格式的**唯一一处分叉**:`AropeDiT.install_flowmap()` 多挂 5 个
|
| 32 |
+
张量(`delta_embedding.{0,2}.{weight,bias}` + `delta_gate`),827 → **832**(AROPE_DIT_KEYS_FLOWMAP,
|
| 33 |
+
+20.5 MB)。格式本身一个字没变(单文件 / 全 bf16 / 字符串 metadata / 0644 / .tmp+rename),
|
| 34 |
+
只是键数变了 —— 于是上面那三个下游会**拒收**它。那是对的:flow map 要第二个时间步 r,拿旧的
|
| 35 |
+
renoise 采样器去采它不会报错,只会安静地产出另一个模型的结果。用 is_flowmap_ckpt() 分流。
|
| 36 |
+
|
| 37 |
+
★ save_dit 显式 cast 到 bf16 并断言导出 dtype。stage3 在 prepare 前把每个模型 .float()
|
| 38 |
+
(FSDP + MixedPrecision(bf16) 下不这么做就没有 fp32 主权重),而 accelerator.get_state_dict
|
| 39 |
+
在 FSDP 分支返回的是 FULL_STATE_DICT(offload_to_cpu=True, rank0_only=True) —— rank0 CPU 上的
|
| 40 |
+
**fp32**。不 cast 的话每个文件 20 GB(10 次保存 × 3 个文件 = 600 GB 而不是 300 GB),
|
| 41 |
+
而且 827-key / 10.0 GB 的格式承诺当场作废。
|
| 42 |
+
"""
|
| 43 |
+
from __future__ import annotations
|
| 44 |
+
|
| 45 |
+
import csv
|
| 46 |
+
import glob
|
| 47 |
+
import json
|
| 48 |
+
import os
|
| 49 |
+
import re
|
| 50 |
+
import shutil
|
| 51 |
+
import struct
|
| 52 |
+
import time
|
| 53 |
+
|
| 54 |
+
import torch
|
| 55 |
+
import torch.nn as nn
|
| 56 |
+
from safetensors.torch import load_file, save_file
|
| 57 |
+
|
| 58 |
+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 59 |
+
OUTPUT_ROOT = os.path.join(REPO_ROOT, "outputs", "distill")
|
| 60 |
+
|
| 61 |
+
DIT_PREFIX = "dit." # AropeDiT 里 WanModel 的属性名;导出时剥掉
|
| 62 |
+
ARM_PREFIX = "arm." # baseline 动作臂;蒸馏不用臂,保留只为格式兼容
|
| 63 |
+
MASK_EMBEDDING_NAME = "arope_mask_embedding"
|
| 64 |
+
MASK_KEYS = (f"{MASK_EMBEDDING_NAME}.weight", f"{MASK_EMBEDDING_NAME}.bias")
|
| 65 |
+
AROPE_DIT_KEYS = 827 # 与 outputs/arope_10k_ga2/step-10000.safetensors 逐键一致
|
| 66 |
+
REF_CKPT = os.path.join(REPO_ROOT, "outputs", "arope_10k_ga2", "step-10000.safetensors")
|
| 67 |
+
|
| 68 |
+
# ---- flow map(stage2-flowmap):第二时间步 r 的 delta 分支 ----------------------------------
|
| 69 |
+
# AropeDiT.install_flowmap() 把 delta_embedding(= time_embedding 的 deepcopy,读出层零初始化)和
|
| 70 |
+
# delta_gate(persistent buffer)挂在 **dit 上**,于是 strip_dit_prefix 剥掉 `dit.` 之后就是下面这
|
| 71 |
+
# 5 个平铺键 —— 格式一个字没变(单 safetensors / 全 bf16 / 字符串 metadata / 0644 / .tmp+rename),
|
| 72 |
+
# 只是键数 827 → 832(+20.5 MB,delta_embedding 是 10,229,760 个参数 = 5B 的 0.20 %)。
|
| 73 |
+
#
|
| 74 |
+
# ★ 没有"精确 827 键"的导出:residual 形式下 u(x,t,t) = head(E(t) + g·D(P(t))),两条
|
| 75 |
+
# Sequential(Linear, SiLU, Linear) 的和折不回一条。所以 actionrope/infer.py、tests/check_ckpt.py、
|
| 76 |
+
# eval_bg50.py 会拒收 flow-map 权重 —— 这是 **对的**:拿旧的 renoise 采样器、不给 r 去采一个 flow map,
|
| 77 |
+
# 出来的东西看着像样但是另一个模型。导出时另外写 metadata flowmap=1 / flowmap_form / delta_gate,
|
| 78 |
+
# 下游按 metadata 选采样器,而不是按命令行开关。
|
| 79 |
+
DELTA_EMBEDDING_NAME = "delta_embedding"
|
| 80 |
+
DELTA_GATE_NAME = "delta_gate"
|
| 81 |
+
FLOWMAP_KEYS = (
|
| 82 |
+
f"{DELTA_EMBEDDING_NAME}.0.weight", f"{DELTA_EMBEDDING_NAME}.0.bias",
|
| 83 |
+
f"{DELTA_EMBEDDING_NAME}.2.weight", f"{DELTA_EMBEDDING_NAME}.2.bias",
|
| 84 |
+
DELTA_GATE_NAME,
|
| 85 |
+
)
|
| 86 |
+
AROPE_DIT_KEYS_FLOWMAP = AROPE_DIT_KEYS + len(FLOWMAP_KEYS) # 832
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# ----------------------------------------------------------------------------
|
| 90 |
+
# 路径
|
| 91 |
+
# ----------------------------------------------------------------------------
|
| 92 |
+
|
| 93 |
+
def resolve_run_dir(out: str) -> str:
|
| 94 |
+
"""`out` 可以是绝对路径、相对仓库根的路径、或一个裸名字(落在 outputs/distill/ 下)。"""
|
| 95 |
+
if not out or not str(out).strip():
|
| 96 |
+
raise ValueError("run dir 不能为空")
|
| 97 |
+
out = os.path.expanduser(str(out))
|
| 98 |
+
if os.path.isabs(out):
|
| 99 |
+
return os.path.normpath(out)
|
| 100 |
+
if os.sep in out:
|
| 101 |
+
return os.path.normpath(os.path.join(REPO_ROOT, out))
|
| 102 |
+
return os.path.join(OUTPUT_ROOT, out)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def dits_dir(run_dir: str) -> str:
|
| 106 |
+
return os.path.join(run_dir, "dits")
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def ema_dir(run_dir: str) -> str:
|
| 110 |
+
return os.path.join(run_dir, "ema")
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def critic_dir(run_dir: str) -> str:
|
| 114 |
+
return os.path.join(run_dir, "critic")
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def dit_path(run_dir: str, step: int) -> str:
|
| 118 |
+
"""学生原始权重:只为续训。部署一律用 ema_path()。"""
|
| 119 |
+
return os.path.join(dits_dir(run_dir), f"dit_step{int(step)}.safetensors")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def ema_path(run_dir: str, step: int) -> str:
|
| 123 |
+
"""EMA 影子权重 = stage2 / stage3 的部署权重。"""
|
| 124 |
+
return os.path.join(ema_dir(run_dir), f"ema_step{int(step)}.safetensors")
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def critic_path(run_dir: str, step: int) -> str:
|
| 128 |
+
return os.path.join(critic_dir(run_dir), f"critic_step{int(step)}.safetensors")
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def dit_glob(run_dir: str) -> str:
|
| 132 |
+
return os.path.join(dits_dir(run_dir), "dit_step*.safetensors")
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def ema_glob(run_dir: str) -> str:
|
| 136 |
+
return os.path.join(ema_dir(run_dir), "ema_step*.safetensors")
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def critic_glob(run_dir: str) -> str:
|
| 140 |
+
return os.path.join(critic_dir(run_dir), "critic_step*.safetensors")
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
_STEP_RE = re.compile(r"step(\d+)")
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _step_of(path: str) -> int | None:
|
| 147 |
+
"""从 dit_step500.safetensors / ema_step500.safetensors / state_step500 里取出 500。"""
|
| 148 |
+
m = _STEP_RE.search(os.path.basename(os.path.normpath(path)))
|
| 149 |
+
return int(m.group(1)) if m else None
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def list_ckpts(pattern: str) -> list[tuple[int, str]]:
|
| 153 |
+
"""[(step, path), ...] 按 step 升序。名字里解析不出 step 的条目直接忽略(也就永远不会被 rotate 删)。"""
|
| 154 |
+
out = [(s, p) for p in glob.glob(pattern) if (s := _step_of(p)) is not None]
|
| 155 |
+
return sorted(out)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def latest_ckpt(pattern: str) -> tuple[int, str] | None:
|
| 159 |
+
c = list_ckpts(pattern)
|
| 160 |
+
return c[-1] if c else None
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def rotate(pattern: str, keep: int) -> list[str]:
|
| 164 |
+
"""按 step 排序,删掉最旧的,保留最新 keep 个。dits / emas / critics / state_step* 通用。
|
| 165 |
+
|
| 166 |
+
keep <= 0 ⇒ 什么都不删(不是"全删",别把唯一的续训点抹了)。解析不出 step 的路径不碰。
|
| 167 |
+
返回被删掉的路径。目录用 rmtree,文件用 remove。"""
|
| 168 |
+
keep = int(keep)
|
| 169 |
+
if keep <= 0:
|
| 170 |
+
return []
|
| 171 |
+
removed = []
|
| 172 |
+
for _, p in list_ckpts(pattern)[:-keep]:
|
| 173 |
+
try:
|
| 174 |
+
if os.path.isdir(p) and not os.path.islink(p):
|
| 175 |
+
shutil.rmtree(p, ignore_errors=True)
|
| 176 |
+
else:
|
| 177 |
+
os.remove(p)
|
| 178 |
+
removed.append(p)
|
| 179 |
+
except OSError:
|
| 180 |
+
pass
|
| 181 |
+
return removed
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def rotate_run(run_dir: str, keep_dits: int = 0, keep_emas: int = 0, keep_critics: int = 0) -> dict:
|
| 185 |
+
"""一次把三种权重都 rotate 掉。默认 0 = 不动(显式传才删)。"""
|
| 186 |
+
return {"dits": rotate(dit_glob(run_dir), keep_dits),
|
| 187 |
+
"emas": rotate(ema_glob(run_dir), keep_emas),
|
| 188 |
+
"critics": rotate(critic_glob(run_dir), keep_critics)}
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def disk_free_gb(path: str = REPO_ROOT) -> float:
|
| 192 |
+
try:
|
| 193 |
+
return shutil.disk_usage(path).free / 1e9
|
| 194 |
+
except OSError:
|
| 195 |
+
return float("nan")
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def warn_if_low_disk(path: str = REPO_ROOT, min_gb: float = 200.0) -> str | None:
|
| 199 |
+
free = disk_free_gb(path)
|
| 200 |
+
if free == free and free < min_gb: # 非 NaN 且不够
|
| 201 |
+
return (f"磁盘告急:{path} 只剩 {free:.0f} GB(< {min_gb:.0f} GB)。一个 dit/ema/critic 是 10.0 GB,"
|
| 202 |
+
f"一个 accelerate state 槽约 60 GB;调小 --save_every 或 --keep_last_*。")
|
| 203 |
+
return None
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
# ----------------------------------------------------------------------------
|
| 207 |
+
# progress.json / config.json
|
| 208 |
+
# ----------------------------------------------------------------------------
|
| 209 |
+
|
| 210 |
+
def _atomic_write_text(path: str, text: str) -> None:
|
| 211 |
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
| 212 |
+
tmp = f"{path}.tmp"
|
| 213 |
+
with open(tmp, "w", encoding="utf-8") as fh:
|
| 214 |
+
fh.write(text)
|
| 215 |
+
fh.flush()
|
| 216 |
+
os.fsync(fh.fileno())
|
| 217 |
+
os.replace(tmp, path)
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def progress_path(run_dir: str) -> str:
|
| 221 |
+
return os.path.join(run_dir, "progress.json")
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def write_progress(run_dir: str, step: int, **extra) -> str:
|
| 225 |
+
"""续训只认这个文件。原子写:保存 10 GB 权重时崩掉也不会留下半个 progress.json。"""
|
| 226 |
+
row = {"step": int(step), "wall": time.time(), "time": time.strftime("%Y-%m-%d %H:%M:%S"), **extra}
|
| 227 |
+
p = progress_path(run_dir)
|
| 228 |
+
_atomic_write_text(p, json.dumps(row, ensure_ascii=False, default=str))
|
| 229 |
+
return p
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def read_progress(run_dir: str) -> dict | None:
|
| 233 |
+
p = progress_path(run_dir)
|
| 234 |
+
if not os.path.isfile(p):
|
| 235 |
+
return None
|
| 236 |
+
try:
|
| 237 |
+
with open(p, encoding="utf-8") as fh:
|
| 238 |
+
return json.load(fh)
|
| 239 |
+
except (OSError, json.JSONDecodeError):
|
| 240 |
+
return None
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def progress_step(run_dir: str, default: int = 0) -> int:
|
| 244 |
+
pr = read_progress(run_dir)
|
| 245 |
+
return int(pr["step"]) if pr and "step" in pr else int(default)
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def save_config(run_dir: str, cfg: dict) -> str:
|
| 249 |
+
os.makedirs(run_dir, exist_ok=True)
|
| 250 |
+
p = os.path.join(run_dir, "config.json")
|
| 251 |
+
_atomic_write_text(p, json.dumps(cfg, indent=2, ensure_ascii=False, default=str))
|
| 252 |
+
return p
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
# ----------------------------------------------------------------------------
|
| 256 |
+
# checkpoint:导出
|
| 257 |
+
# ----------------------------------------------------------------------------
|
| 258 |
+
|
| 259 |
+
def _unwrap(model):
|
| 260 |
+
"""剥掉 DDP / DeepSpeedEngine / EmaShadow 之类的外壳,拿到裸 module。"""
|
| 261 |
+
seen = 0
|
| 262 |
+
while hasattr(model, "module") and isinstance(getattr(model, "module"), nn.Module) and seen < 8:
|
| 263 |
+
model = model.module
|
| 264 |
+
seen += 1
|
| 265 |
+
return model
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def _gather_state_dict(accel, model) -> dict:
|
| 269 |
+
"""accel=None ⇒ 单进程,直接 state_dict()。否则走 accelerator.get_state_dict(集合操作,
|
| 270 |
+
每个 rank 都必须进来;FSDP 分支是 rank0_only,非主进程拿到空 dict)。
|
| 271 |
+
|
| 272 |
+
非 nn.Module(distill.ema.EmaShadow)走本地 state_dict:它没进过 prepare,权重在每个 rank 上
|
| 273 |
+
都是整份的,get_state_dict 的 FSDP 分支反而会拿它当 FSDP 模块去遍历。"""
|
| 274 |
+
if accel is None or not isinstance(model, nn.Module):
|
| 275 |
+
return _unwrap(model).state_dict()
|
| 276 |
+
return accel.get_state_dict(model)
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def strip_dit_prefix(state_dict: dict) -> tuple[dict, list[str]]:
|
| 280 |
+
"""AropeDiT 包装的 state_dict → ActionRoPE 的导出键集。
|
| 281 |
+
|
| 282 |
+
有 `dit.` 键 ⇒ 剥前缀,`arm.` 键原样保留,其余(包装层自己的 buffer 之类)丢掉并回报;
|
| 283 |
+
没有 `dit.` 键 ⇒ 传进来的本来就是裸 WanModel,原样返回。"""
|
| 284 |
+
if not any(k.startswith(DIT_PREFIX) for k in state_dict):
|
| 285 |
+
return dict(state_dict), []
|
| 286 |
+
out, dropped = {}, []
|
| 287 |
+
for k, v in state_dict.items():
|
| 288 |
+
if k.startswith(DIT_PREFIX):
|
| 289 |
+
out[k[len(DIT_PREFIX):]] = v
|
| 290 |
+
elif k.startswith(ARM_PREFIX):
|
| 291 |
+
out[k] = v
|
| 292 |
+
else:
|
| 293 |
+
dropped.append(k)
|
| 294 |
+
return out, dropped
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
def save_dit(accel, model, path: str, metadata: dict | None = None, *,
|
| 298 |
+
expect_keys: int | None = AROPE_DIT_KEYS, require_mask: bool = True,
|
| 299 |
+
dtype: torch.dtype = torch.bfloat16, log=None, post=None) -> int:
|
| 300 |
+
"""把(可能被 ZeRO-2 / FSDP 包着的)模型导出成 ActionRoPE 格式的 safetensors,返回字节数。
|
| 301 |
+
|
| 302 |
+
每个 rank 都要调用(get_state_dict 是集合操作),只有主进程落盘。
|
| 303 |
+
|
| 304 |
+
★ 显式 cast 到 bf16 并断言导出 dtype。FSDP 下 accelerator.get_state_dict 返回的是 rank0 CPU 上的
|
| 305 |
+
fp32 全量权重(stage3 在 prepare 前 .float() 过每个模型),不 cast 文件就是 20 GB,827-key /
|
| 306 |
+
10.0 GB 的格式承诺当场作废,infer.py / check_ckpt.py 的 dtype 断言也会跟着变。
|
| 307 |
+
|
| 308 |
+
expect_keys / require_mask 是格式闸门:默认按 827 键 + arope_mask_embedding.{weight,bias} 校,
|
| 309 |
+
对不上直接炸(少两个 mask 键 = 模型是拿 mask_channel=False 建的,装回 infer.py 会静默走错分支)。
|
| 310 |
+
小模型自测传 expect_keys=<n>, require_mask=False。"""
|
| 311 |
+
if accel is not None:
|
| 312 |
+
accel.wait_for_everyone()
|
| 313 |
+
sd = _gather_state_dict(accel, model)
|
| 314 |
+
# post:gather 之后、格式闸门之前的一次改写。目前只有 LoRA 用它把 B@A 折回 base.weight——
|
| 315 |
+
# 不折的话 state_dict 里是 `...base.weight` + `lora_A` + `lora_B`,expect_keys 当场拦下。
|
| 316 |
+
# 每个 rank 都会走到这里,但只有主进程的 sd 非空(见 _gather_state_dict)。
|
| 317 |
+
if post is not None and sd:
|
| 318 |
+
sd = post(sd)
|
| 319 |
+
is_main = True if accel is None else bool(accel.is_main_process)
|
| 320 |
+
size = 0
|
| 321 |
+
if is_main and sd:
|
| 322 |
+
out, dropped = strip_dit_prefix(sd)
|
| 323 |
+
out = {k: v.detach().to(device="cpu", dtype=dtype).contiguous() for k, v in out.items()}
|
| 324 |
+
bad = sorted({str(v.dtype) for v in out.values()} - {str(dtype)})
|
| 325 |
+
if bad:
|
| 326 |
+
raise RuntimeError(f"导出 dtype 不是 {dtype}:{bad}")
|
| 327 |
+
if expect_keys is not None and len(out) != expect_keys:
|
| 328 |
+
raise RuntimeError(f"{path}: 导出 {len(out)} 个张量,期望 {expect_keys} 个"
|
| 329 |
+
f"(丢弃的非 DiT 键 {dropped[:5]}…{len(dropped)})")
|
| 330 |
+
if require_mask:
|
| 331 |
+
miss = [k for k in MASK_KEYS if k not in out]
|
| 332 |
+
if miss:
|
| 333 |
+
raise RuntimeError(f"{path}: 缺 {miss} —— 模型是拿 mask_channel=False 建的?")
|
| 334 |
+
meta = {"arm": "arope", "mask_channel": "True"}
|
| 335 |
+
meta.update({k: str(v) for k, v in (metadata or {}).items()})
|
| 336 |
+
meta.update({"n_tensors": str(len(out)), "dtype": str(dtype).replace("torch.", ""),
|
| 337 |
+
"saved": time.strftime("%Y-%m-%d %H:%M:%S")})
|
| 338 |
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
| 339 |
+
tmp = f"{path}.tmp" # 先写临时文件再 rename:写到一半崩掉不会留下半个 ckpt
|
| 340 |
+
save_file(out, tmp, metadata=meta)
|
| 341 |
+
os.chmod(tmp, 0o644) # safetensors 按 umask 写成 0600,推理可能由别的账号跑
|
| 342 |
+
os.replace(tmp, path)
|
| 343 |
+
size = os.path.getsize(path)
|
| 344 |
+
msg = f"[save] {path} {len(out)} 个张量 {size / 1e9:.2f} GB"
|
| 345 |
+
if log is not None:
|
| 346 |
+
log(msg)
|
| 347 |
+
else:
|
| 348 |
+
print(msg, flush=True)
|
| 349 |
+
del out
|
| 350 |
+
del sd
|
| 351 |
+
if accel is not None:
|
| 352 |
+
accel.wait_for_everyone()
|
| 353 |
+
return size
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
# ----------------------------------------------------------------------------
|
| 357 |
+
# checkpoint:装载 / 体检
|
| 358 |
+
# ----------------------------------------------------------------------------
|
| 359 |
+
|
| 360 |
+
def load_dit_ckpt(model, path: str, *, assign: bool = False, allow_missing_mask: bool = True,
|
| 361 |
+
allow_missing: tuple | list = ()) -> dict:
|
| 362 |
+
"""把 ActionRoPE 格式的 safetensors 装进 AropeDiT(键带 `dit.`)或裸 WanModel(键不带)。
|
| 363 |
+
|
| 364 |
+
strict —— 默认唯一放行的缺失是 arope_mask_embedding.{weight,bias}(install_arope 已经零初始化,
|
| 365 |
+
从没装过 mask 通道的 ckpt 起步时就缺这两个键)。WorldMind 的 load_dit_ckpt 对任何 missing 都抛,
|
| 366 |
+
照抄会拒收每一个 ActionRoPE checkpoint。
|
| 367 |
+
|
| 368 |
+
allow_missing:额外放行的键(去掉 `dit.` 前缀的名字,也接受带前缀的写法)。**唯一** 的正当用法是
|
| 369 |
+
"先 install_flowmap() 再从 827 键的 stage1b 装载"这一个调用点,传 `allow_missing=FLOWMAP_KEYS`;
|
| 370 |
+
别把它设成默认,否则任何一个真的漏键都会静默通过。推荐的顺序反过来:先 strict 装 827、再 install
|
| 371 |
+
(见 AropeDiT.install_flowmap 的文档),那样一个例外都不需要。
|
| 372 |
+
|
| 373 |
+
assign=False(默认):copy_ 进现有参数,保持模型自己的 dtype —— stage3 在 .float() 之后装 bf16
|
| 374 |
+
ckpt 就靠这个,assign=True 会把参数直接换成 bf16 张量,fp32 主权重当场消失。"""
|
| 375 |
+
sd = load_file(path)
|
| 376 |
+
tgt = _unwrap(model)
|
| 377 |
+
tgt_keys = set(tgt.state_dict().keys())
|
| 378 |
+
prefix = DIT_PREFIX if any(k.startswith(DIT_PREFIX) for k in tgt_keys) else ""
|
| 379 |
+
if prefix:
|
| 380 |
+
sd = {(k if k.startswith(prefix) else prefix + k): v for k, v in sd.items()}
|
| 381 |
+
missing, unexpected = tgt.load_state_dict(sd, strict=False, assign=assign)
|
| 382 |
+
allowed = {prefix + k for k in MASK_KEYS} if allow_missing_mask else set()
|
| 383 |
+
allowed |= {(k if prefix and k.startswith(prefix) else prefix + k) for k in allow_missing}
|
| 384 |
+
bad = [k for k in missing if k not in allowed]
|
| 385 |
+
if bad or unexpected:
|
| 386 |
+
raise RuntimeError(f"{path} 与模型键集不符:缺 {bad[:5]}…({len(bad)}),多 {list(unexpected)[:5]}…({len(unexpected)})")
|
| 387 |
+
return {"path": path, "n_tensors": len(sd), "prefix": prefix,
|
| 388 |
+
"missing": list(missing), "unexpected": list(unexpected)}
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
def read_ckpt_header(path: str) -> tuple[dict, dict]:
|
| 392 |
+
"""只读 safetensors 头(不碰 10 GB 数据)→ ({key: {dtype, shape, data_offsets}}, metadata)。"""
|
| 393 |
+
with open(path, "rb") as fh:
|
| 394 |
+
n = struct.unpack("<Q", fh.read(8))[0]
|
| 395 |
+
hdr = json.loads(fh.read(n))
|
| 396 |
+
meta = hdr.pop("__metadata__", {}) or {}
|
| 397 |
+
return hdr, meta
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def is_flowmap_ckpt(path: str) -> bool:
|
| 401 |
+
"""这份 ckpt 是不是 flow map(832 键)。**看张量键,不看 metadata** —— metadata 是人写的字符串,
|
| 402 |
+
张量在不在是事实。下游(rollout_eval)应当据此选采样器:拿 renoise 采样器去采 flow map 不会报错,
|
| 403 |
+
只会安静地产出另一个模型的结果。"""
|
| 404 |
+
hdr, _ = read_ckpt_header(path)
|
| 405 |
+
return all(k in hdr for k in FLOWMAP_KEYS)
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
def check_ckpt_format(path: str, expect_keys: int | None = AROPE_DIT_KEYS,
|
| 409 |
+
dtype: str = "BF16", require_mask: bool = True) -> dict:
|
| 410 |
+
"""导出后的秒级体检(不加载权重):张量数 / dtype / mask 键 / metadata 全是字符串。
|
| 411 |
+
|
| 412 |
+
正品参照:outputs/arope_10k_ga2/step-10000.safetensors —— 827 个 BF16 张量,9,999,690,792 B。"""
|
| 413 |
+
hdr, meta = read_ckpt_header(path)
|
| 414 |
+
dts = sorted({v["dtype"] for v in hdr.values()})
|
| 415 |
+
res = {"path": path, "n_tensors": len(hdr), "dtypes": dts, "size": os.path.getsize(path),
|
| 416 |
+
"metadata": meta, "mask_keys": sorted(k for k in hdr if k.startswith(MASK_EMBEDDING_NAME))}
|
| 417 |
+
if expect_keys is not None and len(hdr) != expect_keys:
|
| 418 |
+
raise RuntimeError(f"{path}: {len(hdr)} 个张量,期望 {expect_keys}")
|
| 419 |
+
if dtype is not None and dts != [dtype]:
|
| 420 |
+
raise RuntimeError(f"{path}: dtype {dts},期望 ['{dtype}']")
|
| 421 |
+
if require_mask and set(res["mask_keys"]) != set(MASK_KEYS):
|
| 422 |
+
raise RuntimeError(f"{path}: mask 键 {res['mask_keys']},期望 {sorted(MASK_KEYS)}")
|
| 423 |
+
bad = {k: type(v).__name__ for k, v in meta.items() if not isinstance(v, str)}
|
| 424 |
+
if bad:
|
| 425 |
+
raise RuntimeError(f"{path}: metadata 必须全是字符串,{bad}")
|
| 426 |
+
return res
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
# ----------------------------------------------------------------------------
|
| 430 |
+
# accelerate 完整存档(只有 stage1 用)
|
| 431 |
+
# ----------------------------------------------------------------------------
|
| 432 |
+
|
| 433 |
+
STATE_SLOTS = ("state_A", "state_B")
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
def save_state_rotating(accel, run_dir: str, trainer_state: dict) -> str:
|
| 437 |
+
"""accelerator.save_state 到两个槽之一,<run_dir>/state 软链指向最新完整的那个。
|
| 438 |
+
|
| 439 |
+
原地覆盖唯一的 state/ 目录时,保存中途崩溃会把唯一的续训点也毁掉;这里总是写到「不是当前
|
| 440 |
+
state 指向」的那个槽,写完再 rename 换软链,任一时刻 state 都指向一份完整存档。不删任何目录,
|
| 441 |
+
磁盘占两份(4 卡 ZeRO-2 约 2×60 GB)。--resume <run_dir>/state 照常。"""
|
| 442 |
+
link = os.path.join(run_dir, "state")
|
| 443 |
+
cur = os.path.basename(os.path.realpath(link)) if os.path.islink(link) else None
|
| 444 |
+
slot = STATE_SLOTS[1] if cur == STATE_SLOTS[0] else STATE_SLOTS[0]
|
| 445 |
+
state_dir = os.path.join(run_dir, slot)
|
| 446 |
+
accel.save_state(state_dir)
|
| 447 |
+
if accel.is_main_process:
|
| 448 |
+
with open(os.path.join(state_dir, "trainer_state.json"), "w", encoding="utf-8") as fh:
|
| 449 |
+
json.dump(trainer_state, fh, ensure_ascii=False, default=str)
|
| 450 |
+
tmp = link + ".tmp"
|
| 451 |
+
if os.path.lexists(tmp):
|
| 452 |
+
os.remove(tmp)
|
| 453 |
+
os.symlink(slot, tmp)
|
| 454 |
+
os.replace(tmp, link) # 只替换软链本身,不动任何目录
|
| 455 |
+
print(f"[save] accelerator.save_state → {state_dir}({link} → {slot})", flush=True)
|
| 456 |
+
accel.wait_for_everyone()
|
| 457 |
+
return state_dir
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
# ----------------------------------------------------------------------------
|
| 461 |
+
# 日志
|
| 462 |
+
# ----------------------------------------------------------------------------
|
| 463 |
+
|
| 464 |
+
class TextTee:
|
| 465 |
+
"""打到 stdout,同时带时间戳追加进 <run_dir>/train.log。
|
| 466 |
+
|
| 467 |
+
enabled=False(非主进程)⇒ 完全静默,也不开文件句柄。"""
|
| 468 |
+
|
| 469 |
+
def __init__(self, path: str, enabled: bool = True):
|
| 470 |
+
self.path, self.enabled, self._f = path, bool(enabled), None
|
| 471 |
+
if self.enabled:
|
| 472 |
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
| 473 |
+
self._f = open(path, "a", encoding="utf-8", buffering=1)
|
| 474 |
+
|
| 475 |
+
def __call__(self, *args):
|
| 476 |
+
if not self.enabled:
|
| 477 |
+
return
|
| 478 |
+
msg = " ".join(str(a) for a in args)
|
| 479 |
+
print(msg, flush=True)
|
| 480 |
+
self._f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n")
|
| 481 |
+
self._f.flush()
|
| 482 |
+
|
| 483 |
+
def close(self):
|
| 484 |
+
if self._f is not None:
|
| 485 |
+
try:
|
| 486 |
+
self._f.close()
|
| 487 |
+
finally:
|
| 488 |
+
self._f = None
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
class CsvLog:
|
| 492 |
+
"""固定表头的追加式 CSV(actionrope/train.py:328 的写法)。已存在且非空就不再写表头。"""
|
| 493 |
+
|
| 494 |
+
def __init__(self, path: str, fields: list[str], enabled: bool = True):
|
| 495 |
+
self.enabled, self.f, self.w = bool(enabled), None, None
|
| 496 |
+
if not self.enabled:
|
| 497 |
+
return
|
| 498 |
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
| 499 |
+
exists = os.path.isfile(path) and os.path.getsize(path) > 0
|
| 500 |
+
self.f = open(path, "a", encoding="utf-8", newline="")
|
| 501 |
+
self.w = csv.DictWriter(self.f, fieldnames=fields, lineterminator="\n", extrasaction="ignore")
|
| 502 |
+
if not exists:
|
| 503 |
+
self.w.writeheader()
|
| 504 |
+
self.f.flush()
|
| 505 |
+
|
| 506 |
+
def write(self, row: dict):
|
| 507 |
+
if not self.enabled:
|
| 508 |
+
return
|
| 509 |
+
self.w.writerow(row)
|
| 510 |
+
self.f.flush()
|
| 511 |
+
|
| 512 |
+
def close(self):
|
| 513 |
+
if self.f is not None:
|
| 514 |
+
try:
|
| 515 |
+
self.f.close()
|
| 516 |
+
finally:
|
| 517 |
+
self.f = None
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
class JsonlLogger:
|
| 521 |
+
"""一行一个 JSON 的追加式指标日志,每行都 flush,别的机器可以直接 tail。"""
|
| 522 |
+
|
| 523 |
+
def __init__(self, path: str, enabled: bool = True):
|
| 524 |
+
self.path, self.enabled, self._f = path, bool(enabled), None
|
| 525 |
+
if self.enabled:
|
| 526 |
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
| 527 |
+
self._f = open(path, "a", encoding="utf-8", buffering=1)
|
| 528 |
+
|
| 529 |
+
def log(self, row: dict):
|
| 530 |
+
if not self.enabled:
|
| 531 |
+
return
|
| 532 |
+
row = dict(row)
|
| 533 |
+
row.setdefault("wall", time.time())
|
| 534 |
+
self._f.write(json.dumps(row, ensure_ascii=False, default=str) + "\n")
|
| 535 |
+
self._f.flush()
|
| 536 |
+
|
| 537 |
+
def close(self):
|
| 538 |
+
if self._f is not None:
|
| 539 |
+
try:
|
| 540 |
+
self._f.close()
|
| 541 |
+
finally:
|
| 542 |
+
self._f = None
|
backup_2026-09-14/code/distill/stage1_ar/ar_tf.py
ADDED
|
@@ -0,0 +1,695 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""stage1:teacher forcing 因果化 —— 把 ActionRoPE 的双向 SFT 检查点训成因果 AR 扩散模型。
|
| 3 |
+
|
| 4 |
+
# 4 卡正式训练(配方常量钉在 run.sh 里,argparse 默认值与它逐项相同)
|
| 5 |
+
bash distill/stage1_ar/run.sh
|
| 6 |
+
|
| 7 |
+
# 单卡 3 步冒烟(不走 accelerate;AdamW 直接更新 bf16 参数、没有 fp32 主权重,loss 数值不代表训练效果)
|
| 8 |
+
CUDA_VISIBLE_DEVICES=0 PYTHONPATH=/data/zhiyangdeng/ActionRoPE DIFFSYNTH_SKIP_DOWNLOAD=True \\
|
| 9 |
+
.venv/bin/python distill/stage1_ar/ar_tf.py \\
|
| 10 |
+
--init_ckpt outputs/arope_10k_ga2/step-10000.safetensors \\
|
| 11 |
+
--limit 8 --max_steps 3 --grad_accum 1 --val_every 0 --save_every 0 --log_every 1 \\
|
| 12 |
+
--output /data/zhiyangdeng/ActionRoPE/distill/_smoke_stage1
|
| 13 |
+
|
| 14 |
+
# 30 秒 dry run(tiny 模型 + 合成数据,不碰真权重;验循环 / 导出 / 日志 / 续训)
|
| 15 |
+
... --dry_run --output <tmp>
|
| 16 |
+
|
| 17 |
+
这一阶段是什么
|
| 18 |
+
-------------
|
| 19 |
+
outputs/arope_10k_ga2/step-10000.safetensors 是**双向**全片 SFT 模型:每个 cell 都能看见未来。
|
| 20 |
+
AR rollout 要的是因果模型,所以先把它「因果化」:同一份 flow-matching 目标、同一份 new 区 ×2 权重图,
|
| 21 |
+
只把自注意力换成 teacher forcing 双流掩码 —— noisy cell f 只看**干净的** 0..f-1 加自己那一格。
|
| 22 |
+
产物 dits/dit_step{N}.safetensors 同时做 stage2 的 --teacher_ckpt 和 --student_ckpt。
|
| 23 |
+
|
| 24 |
+
目标(= PORT_SPEC stage1,`ar_diffusion_gm.py:83-104` 融合 `actionrope/train.py:224-271`)
|
| 25 |
+
-----------------------------------------------------------------------------------
|
| 26 |
+
每个 cell 独立均匀抽噪声档(diffusion forcing 的加噪方式),干净历史走 clean_x 通道而不是噪声流;
|
| 27 |
+
loss 是 ActionRoPE 的 Σw·se/(Σw·C) **逐 cell** 算完,再乘逐 cell 的 bsmntw 钟形权重取平均。
|
| 28 |
+
|
| 29 |
+
相对 WorldMind 的三处刻意改动(每处都有代价明确的理由)
|
| 30 |
+
-----------------------------------------------------
|
| 31 |
+
1. **cell 0**。WorldMind 的 TF 把 cell 0 也当无条件生成来训(loss 覆盖全部 F 帧)。ActionRoPE 的契约是
|
| 32 |
+
cell 0 = 干净锚点,采样时每一步都写回(infer.py:413)。照抄会改变「这个模型是什么」,所以这里
|
| 33 |
+
cell 0 永远是干净 x0 + t=0 且**不计 loss** —— 与 stage2 的 `lat_t[:,:,0]=x0[:,:,0]`、stage3 的
|
| 34 |
+
`ts_pf[:,0]=0` 一路对齐。
|
| 35 |
+
2. **loss 形状**。`se.mean(dim=(1,3,4))`(`ar_diffusion_gm.py:102`)换成 ActionRoPE 的
|
| 36 |
+
Σw·se/(Σw·C),逐 cell 算 —— 10k 检查点就是在 new 区 ×2 的权重下训出来的,丢掉它等于换了目标。
|
| 37 |
+
3. **条件**。offset_px(世界坐标 RoPE)/ mask_input(known 通道)/ context_ids(逐 cell 文本)三路条件
|
| 38 |
+
穿进同一次前向,并在 AropeDiT 内部同时喂给 TF 的**两个半流**。
|
| 39 |
+
|
| 40 |
+
两个必须守住的坑
|
| 41 |
+
---------------
|
| 42 |
+
* `--tf_noise_aug_max_idx` 上游取的是**错的那一端**(`ar_diffusion_gm.py:93` 的 `randint(0, k)` 是
|
| 43 |
+
σ≈1 的纯噪声区,不是近干净区)。这里实现成正确的 `randint(1000-k, 1000)`,默认仍是 0(精确干净)。
|
| 44 |
+
* `--grad_accum` 必须与 accelerate yaml 的 gradient_accumulation_steps 一致,否则等效 batch 不是 8
|
| 45 |
+
—— 沿用 `actionrope/train.py:399` 的 SystemExit 守卫。
|
| 46 |
+
"""
|
| 47 |
+
from __future__ import annotations
|
| 48 |
+
|
| 49 |
+
import argparse
|
| 50 |
+
import json
|
| 51 |
+
import math
|
| 52 |
+
import os
|
| 53 |
+
import platform
|
| 54 |
+
import socket
|
| 55 |
+
import sys
|
| 56 |
+
import time
|
| 57 |
+
import warnings
|
| 58 |
+
import zlib
|
| 59 |
+
|
| 60 |
+
os.environ.setdefault("DIFFSYNTH_SKIP_DOWNLOAD", "True")
|
| 61 |
+
# 分配器要在 import torch 之前设:launch 时 export 的环境变量到不了 accelerate 拉起的子进程(C 的 train.py:22-27)
|
| 62 |
+
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
| 63 |
+
|
| 64 |
+
import torch
|
| 65 |
+
from accelerate import Accelerator
|
| 66 |
+
from accelerate.utils import DistributedType, set_seed
|
| 67 |
+
|
| 68 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 69 |
+
if ROOT not in sys.path:
|
| 70 |
+
sys.path.insert(0, ROOT)
|
| 71 |
+
|
| 72 |
+
from actionrope.dataset import DEFAULT_SIDECAR, TEXT_TABLES, AropeLatentDataset, arope_collate, load_sidecar # noqa: E402
|
| 73 |
+
from distill import runlib # noqa: E402
|
| 74 |
+
from distill.arope_dit import MODEL_DIR, AropeDiT, unwrap # noqa: E402
|
| 75 |
+
from distill.data import make_mask_input, make_weight_map, to_device # noqa: E402
|
| 76 |
+
from distill.flow_pf import ( # noqa: E402
|
| 77 |
+
NUM_TRAIN_TIMESTEPS,
|
| 78 |
+
add_noise_pf,
|
| 79 |
+
idx_of_t,
|
| 80 |
+
sample_pf_timestep_ids,
|
| 81 |
+
t_of_idx,
|
| 82 |
+
training_target,
|
| 83 |
+
training_weight_pf,
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
CELLS = 21 # 一条 clip 的 latent cell 数(数据集硬契约)
|
| 87 |
+
STAGE = "stage1_ar_tf"
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# ----------------------------------------------------------------------------
|
| 91 |
+
# 目标
|
| 92 |
+
# ----------------------------------------------------------------------------
|
| 93 |
+
|
| 94 |
+
def tf_clean_stream(x0: torch.Tensor, noise: torch.Tensor, max_idx: int):
|
| 95 |
+
"""TF 的 clean 半流。max_idx<=0 ⇒ (x0, None) 精确干净(CF 默认)。
|
| 96 |
+
|
| 97 |
+
max_idx>0 ⇒ CF 的 history-noise 增广:给干��历史加一点点噪声,复用**同一份** noise。
|
| 98 |
+
近干净 = **高**下标(sigmas 递减,idx 999 的 σ≈0.005),所以抽 [1000-max_idx, 1000) ——
|
| 99 |
+
上游 `ar_diffusion_gm.py:93` 写的是 `randint(0, max_idx)`,那是 σ≈1 的纯噪声端,方向反了。
|
| 100 |
+
cell 0 无论如何保持精确干净 + t=0:它是 ActionRoPE 的 TI2V 锚点,两个半流都得一致。
|
| 101 |
+
"""
|
| 102 |
+
if max_idx is None or int(max_idx) <= 0:
|
| 103 |
+
return x0, None
|
| 104 |
+
k = int(max_idx)
|
| 105 |
+
assert 0 < k < NUM_TRAIN_TIMESTEPS, f"--tf_noise_aug_max_idx 应在 (0, {NUM_TRAIN_TIMESTEPS}) 内,得到 {k}"
|
| 106 |
+
b, f = x0.shape[0], x0.shape[2]
|
| 107 |
+
idx_aug = torch.randint(NUM_TRAIN_TIMESTEPS - k, NUM_TRAIN_TIMESTEPS, (b, f), device=x0.device)
|
| 108 |
+
clean_x = add_noise_pf(x0, noise, idx_aug)
|
| 109 |
+
aug_t = t_of_idx(idx_aug, device=x0.device)
|
| 110 |
+
clean_x[:, :, 0] = x0[:, :, 0]
|
| 111 |
+
aug_t[:, 0] = 0.0
|
| 112 |
+
return clean_x, aug_t
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def ar_tf_loss(student, batch: dict, *, new_weight: float = 2.0, new_weight_ramp: float = 0.0,
|
| 116 |
+
teacher_forcing: bool = True, tf_noise_aug_max_idx: int = 0,
|
| 117 |
+
use_gradient_checkpointing: bool = False,
|
| 118 |
+
action_dropout: float = 0.0, hist_dropout: float = 0.0, ctx_dropout: float = 0.0,
|
| 119 |
+
anchor_dropout: float = 0.0,
|
| 120 |
+
noise: torch.Tensor | None = None, idx_pf: torch.Tensor | None = None,
|
| 121 |
+
gen: torch.Generator | None = None):
|
| 122 |
+
"""一次 micro batch 的 loss。返回 (loss, stats)。
|
| 123 |
+
|
| 124 |
+
teacher_forcing=True(默认):干净历史走 clean_x 通道,[2S,2S] 双流掩码,S=21*390=8190。
|
| 125 |
+
teacher_forcing=False:diffusion forcing 回退路径 —— 单条 8190 token 流、普通块因果掩码,
|
| 126 |
+
便宜约 2.5×(代价:stage2 也必须 --no-teacher_forcing,见 PORT_SPEC)。
|
| 127 |
+
|
| 128 |
+
条件 dropout(三个默认都是 0 ⇒ stage1 的行为一个字节都不变)
|
| 129 |
+
-------------------------------------------------------
|
| 130 |
+
只有**训练时 drop 过**的条件,才谈得上在蒸馏时对它做 classifier-free guidance:
|
| 131 |
+
否则 v(∅) 是一个没训过的输入上的外推,`v_c − v_∅` 是噪声不是信号。ActionRoPE 的 SFT
|
| 132 |
+
只有 `--scene_dropout`(文本)一条,所以 text 之外的轴都得先补出来。这三个开关就是补它们:
|
| 133 |
+
|
| 134 |
+
action_dropout offset_px ≡ 0、掩码全 known、权重图退成均匀 ⇒ 自洽的"静止相机"输入
|
| 135 |
+
(语料里 22782 条 clip **没有一条**全程静止,所以这个 null 必须现训)
|
| 136 |
+
anchor_dropout 只把 clean cell 0 从每个 noisy cell 的视野里摘掉,**其余历史保留**
|
| 137 |
+
hist_dropout 注意力上下文砍成 **只剩 cell 0**(local=1 sink=1)
|
| 138 |
+
ctx_dropout 注意力上下文砍成 **什么都看不到**(local=1 sink=0)
|
| 139 |
+
|
| 140 |
+
四条 guidance(★ 是实测最该生效的那条):
|
| 141 |
+
★ Δv_anchor = v(全) − v(drop_anchor) 锚点**相对于历史**的话语权
|
| 142 |
+
Δv_action = v(全) − v(offset≡0) 相机运动的贡献
|
| 143 |
+
Δv_hist = v(全) − v(只剩 cell 0) 历史的贡献
|
| 144 |
+
Δv_ref = v(只剩 cell 0) − v(无上下文) 锚点在真空里的贡献
|
| 145 |
+
|
| 146 |
+
为什么 anchor 那条才是主角:实测漂移是**漂掉的近期历史压过了首帧锚点**(AR rollout 里
|
| 147 |
+
|x0| 从 1.111 涨到 1.404、外观洗白),要压回去就得放大"锚点 vs 历史"这一项。
|
| 148 |
+
Δv_ref 量的是"真空里锚点值多少",与它施加的场景(历史俱全)不是同一个 regime。
|
| 149 |
+
两者都留着:anchor 是主力,ref/hist 这对嵌套档给消融用。
|
| 150 |
+
|
| 151 |
+
注意 cell 0 的 TI2V 锚点(干净 latent + t=0)在**输入侧**始终保留,被 drop 的只是"别的
|
| 152 |
+
cell 能不能看到它"。这样 loss 切片(cell 0 不计)和整个 TI2V 结构都不用动。
|
| 153 |
+
"""
|
| 154 |
+
x0 = batch["input_latents"] # [b, 48, 21, 30, 52] bf16
|
| 155 |
+
b, c, f, lat_h, lat_w = x0.shape # 几何从 latent 里取,dry_run 的小分辨率也能过
|
| 156 |
+
dev = x0.device
|
| 157 |
+
off = batch["offset_px"] # [b, 21, 2] f32
|
| 158 |
+
|
| 159 |
+
if noise is None:
|
| 160 |
+
noise = torch.randn_like(x0)
|
| 161 |
+
if idx_pf is None:
|
| 162 |
+
idx_pf = sample_pf_timestep_ids(b, f, dev, gen) # [b, 21] 严格均匀,逐 cell 独立
|
| 163 |
+
noisy = add_noise_pf(x0, noise, idx_pf) # x_t=(1−σ)x0+σε,σ 按 [b,1,21,1,1] 广播
|
| 164 |
+
ts_pf = t_of_idx(idx_pf, device=dev) # [b, 21] f32,(4.98, 1000]
|
| 165 |
+
# TI2V 锚点:cell 0 用干净 x0 + t=0。模型侧 first_frame_cond=True 也会把 cell 0 的 t 抹成 0,
|
| 166 |
+
# 这里显式写一遍是为了让 stats 里的 t 与真实喂进去的一致,且 --no-teacher_forcing 回退路径同样成立。
|
| 167 |
+
noisy[:, :, 0] = x0[:, :, 0]
|
| 168 |
+
ts_pf[:, 0] = 0.0
|
| 169 |
+
|
| 170 |
+
# ---- 条件 dropout:逐 micro batch 抽签(batch_size 恒为 1,所以逐 batch == 逐样本)----
|
| 171 |
+
def _coin(p):
|
| 172 |
+
if p <= 0.0:
|
| 173 |
+
return False
|
| 174 |
+
return bool(torch.rand((), generator=gen, device="cpu").item() < p)
|
| 175 |
+
|
| 176 |
+
drop_act = _coin(action_dropout)
|
| 177 |
+
# 三个上下文档互斥,按"砍得越狠越优先"排:ctx(全空)> hist(只剩 cell0)> anchor(只摘 cell0)
|
| 178 |
+
drop_ctx = _coin(ctx_dropout)
|
| 179 |
+
drop_hist = (not drop_ctx) and _coin(hist_dropout)
|
| 180 |
+
drop_anchor = (not drop_ctx) and (not drop_hist) and _coin(anchor_dropout)
|
| 181 |
+
if drop_act:
|
| 182 |
+
off = torch.zeros_like(off) # 静止相机:世界 RoPE 退回普通屏幕格 RoPE
|
| 183 |
+
|
| 184 |
+
mask_in = make_mask_input(off, lat_h, lat_w) # [b, 1, 21, 30, 52] known=1/new=0
|
| 185 |
+
w = make_weight_map(off, lat_h, lat_w, new_weight=new_weight, ramp_latent=new_weight_ramp)
|
| 186 |
+
clean_x, aug_t = (tf_clean_stream(x0, noise, tf_noise_aug_max_idx) if teacher_forcing else (None, None))
|
| 187 |
+
|
| 188 |
+
# 上下文档只改**掩码窗口**,不动 cell 0 的锚点。掩码按 (f, hw, tf, 窗口, device) 缓存,
|
| 189 |
+
# 三档各自命中自己的 key,不会串。改的是裸模块:DeepSpeed 包装之后 self.xxx 取不到。
|
| 190 |
+
# 只在真要 drop 时才碰这两个属性 —— 测试里的桩模型没有它们,默认路径不该要求它们存在。
|
| 191 |
+
bare = unwrap(student) if (drop_ctx or drop_hist or drop_anchor) else None
|
| 192 |
+
saved = ((bare.train_local_attn_size, bare.train_sink_size, bare.train_drop_anchor)
|
| 193 |
+
if bare is not None else None)
|
| 194 |
+
if drop_ctx:
|
| 195 |
+
bare.train_local_attn_size, bare.train_sink_size = 1, 0 # 谁都看不到
|
| 196 |
+
elif drop_hist:
|
| 197 |
+
bare.train_local_attn_size, bare.train_sink_size = 1, 1 # 只看得到 cell 0
|
| 198 |
+
elif drop_anchor:
|
| 199 |
+
bare.train_drop_anchor = True # 只摘掉 cell 0,其余历史照旧
|
| 200 |
+
try:
|
| 201 |
+
with torch.autocast(device_type=dev.type, dtype=torch.bfloat16, enabled=(dev.type == "cuda")):
|
| 202 |
+
v = student(noisy, ts_pf, batch["context"], context_ids=batch["context_ids"],
|
| 203 |
+
offset_px=off, mask_input=mask_in, first_frame_cond=True,
|
| 204 |
+
clean_x=clean_x, aug_t=aug_t,
|
| 205 |
+
use_gradient_checkpointing=use_gradient_checkpointing)
|
| 206 |
+
finally:
|
| 207 |
+
if bare is not None:
|
| 208 |
+
bare.train_local_attn_size, bare.train_sink_size, bare.train_drop_anchor = saved
|
| 209 |
+
|
| 210 |
+
target = training_target(x0, noise) # ε − x0(rectified flow)
|
| 211 |
+
se = (v.float() - target.float()) ** 2
|
| 212 |
+
se, ww = se[:, :, 1:], w[:, :, 1:] # cell 0 是条件帧,不计 loss
|
| 213 |
+
# ActionRoPE 的 Σw·se/(Σw·C),但**逐 cell** 算:这样 new 区 ×2 的权重和逐 cell 的钟形权重能同时生效
|
| 214 |
+
per_cell = (se * ww).sum(dim=(1, 3, 4)) / (ww.sum(dim=(1, 3, 4)) * c) # [b, 20]
|
| 215 |
+
tw = training_weight_pf(idx_pf[:, 1:]).float() # [b, 20] bsmntw 钟形
|
| 216 |
+
loss = (tw * per_cell).mean()
|
| 217 |
+
stats = {
|
| 218 |
+
"loss_w": per_cell.mean().detach(), # 不乘钟形权重的加权 MSE,与 train.py 的 loss_w 同口径
|
| 219 |
+
"mse": se.mean().detach(), # 完全不加权的 MSE,跨配方可比
|
| 220 |
+
"new_frac": (ww > 1).float().mean().detach(),
|
| 221 |
+
"t_mean": ts_pf[:, 1:].mean().detach(),
|
| 222 |
+
"tw_mean": tw.mean().detach(),
|
| 223 |
+
# 三档 dropout 的命中率:日志里能看到实际抽签比例是不是等于设定的 p(各 rank 独立抽签,
|
| 224 |
+
# 所以单 rank 的短期比例会抖,看长期均值)
|
| 225 |
+
"d_act": torch.tensor(float(drop_act), device=dev),
|
| 226 |
+
"d_hist": torch.tensor(float(drop_hist), device=dev),
|
| 227 |
+
"d_ctx": torch.tensor(float(drop_ctx), device=dev),
|
| 228 |
+
"d_anc": torch.tensor(float(drop_anchor), device=dev),
|
| 229 |
+
}
|
| 230 |
+
return loss, stats
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
# ----------------------------------------------------------------------------
|
| 234 |
+
# 参数
|
| 235 |
+
# ----------------------------------------------------------------------------
|
| 236 |
+
|
| 237 |
+
def parse_args(argv=None):
|
| 238 |
+
ap = argparse.ArgumentParser(description="stage1:teacher forcing 因果化 ActionRoPE SFT 检查点")
|
| 239 |
+
# 默认值 = distill/stage1_ar/run.sh 的实参(PORT_SPEC risk 11:argparse 与 run.sh 不许有第二套配方)
|
| 240 |
+
ap.add_argument("--init_ckpt", default=runlib.REF_CKPT,
|
| 241 |
+
help="要因果化的双向 SFT 检查点(827 键 bf16)")
|
| 242 |
+
ap.add_argument("--model_dir", default=MODEL_DIR)
|
| 243 |
+
ap.add_argument("--output", required=True, help="run 目录;相对路径按 runlib.resolve_run_dir 解析")
|
| 244 |
+
|
| 245 |
+
ap.add_argument("--train_dir", default=os.path.join(ROOT, "data/latent/train_eybx"))
|
| 246 |
+
ap.add_argument("--val_dir", default=os.path.join(ROOT, "data/latent/val_eybx"))
|
| 247 |
+
ap.add_argument("--sidecar", default=DEFAULT_SIDECAR)
|
| 248 |
+
ap.add_argument("--text_table", default=TEXT_TABLES["scene"],
|
| 249 |
+
help="ARoPE 臂固定 text_mode=scene(剥掉动作从句),361 键那张表")
|
| 250 |
+
ap.add_argument("--limit", type=int, default=None, help="smoke 用:只取这么多训练 clip")
|
| 251 |
+
ap.add_argument("--long_seg_dir", default=None,
|
| 252 |
+
help="长视野续训(longhorizon/README.md):给 make_long_segments.py 的段目录"
|
| 253 |
+
"(如 data/latent_long/K3)就用 AropeLongDatasetTF 替换训练集(F=61 等),"
|
| 254 |
+
"其余配方一字不动;val 仍是 --val_dir 的 21-cell clip。None = 原 stage1 行为")
|
| 255 |
+
ap.add_argument("--scene_dropout", type=float, default=0.1)
|
| 256 |
+
ap.add_argument("--min_rt_ratio", type=float, default=0.8)
|
| 257 |
+
ap.add_argument("--val_min_rt_ratio", type=float, default=0.9)
|
| 258 |
+
ap.add_argument("--val_frac", type=float, default=0.0,
|
| 259 |
+
help="兼容 WorldMind 的 hash 划分,只接受 0:ActionRoPE 的划分是离线的 "
|
| 260 |
+
"train_eybx / val_eybx 清单,而且相邻 clip 只差 3 帧,按 clip 名 hash 会漏样本")
|
| 261 |
+
|
| 262 |
+
ap.add_argument("--teacher_forcing", action=argparse.BooleanOptionalAction, default=True,
|
| 263 |
+
help="默认开:干净历史走 clean_x 通道 + [2S,2S] 双流掩码。"
|
| 264 |
+
"--no-teacher_forcing 回退成 diffusion forcing(单流、便宜 2.5×,"
|
| 265 |
+
"但 stage2 必须跟着一起关)")
|
| 266 |
+
ap.add_argument("--tf_noise_aug_max_idx", type=int, default=0,
|
| 267 |
+
help="clean 半流的 history-noise 增广宽度(近干净档 = 高下标)。0 = 精确干净(CF 默认)")
|
| 268 |
+
# ---- 条件 dropout(stage1b 适配微调用;默认全 0 ⇒ stage1 行为不变)----
|
| 269 |
+
ap.add_argument("--action_dropout", type=float, default=0.0,
|
| 270 |
+
help="以此概率把 offset_px 清零 + 掩码全 known ⇒ 造出「静止相机」这个 null 分支。"
|
| 271 |
+
"语料里没有一条全程静止的 clip,不训就没有,蒸馏时 Δv_action 无从定义")
|
| 272 |
+
ap.add_argument("--anchor_dropout", type=float, default=0.0,
|
| 273 |
+
help="★ 主力档:只把 clean cell 0 从每个 noisy cell 的视野里摘掉、其余历史保留 ⇒ "
|
| 274 |
+
"Δv_anchor 的 null。这是「锚点 vs 已漂历史」那一项,实测的外观漂移要压的就是它")
|
| 275 |
+
ap.add_argument("--hist_dropout", type=float, default=0.0,
|
| 276 |
+
help="以此概率把注意力上下文砍成**只剩 cell 0**(local=1 sink=1)⇒ Δv_hist 的 null")
|
| 277 |
+
ap.add_argument("--ctx_dropout", type=float, default=0.0,
|
| 278 |
+
help="以此概率把注意力上下文砍成**什么都看不到**(local=1 sink=0)⇒ Δv_ref 的 null。"
|
| 279 |
+
"与 --hist_dropout 互斥(本档优先),cell 0 的 TI2V 锚点始终保留")
|
| 280 |
+
ap.add_argument("--windowed_train", type=int, default=0,
|
| 281 |
+
help="训练掩码的滑动窗口(cell 数)。0 = 全因果 —— F=21 下 sink=0 的 W=20 会把 "
|
| 282 |
+
"cell 0(干净锚点)挤出 cell 20 的视野,是致命的")
|
| 283 |
+
ap.add_argument("--windowed_sink", type=int, default=0, help="[windowed_train>0 时] 恒可见的开头 cell 数")
|
| 284 |
+
ap.add_argument("--new_weight", type=float, default=2.0, help="new 区 loss 权重(10k 检查点就是这么训的)")
|
| 285 |
+
ap.add_argument("--new_weight_ramp", type=float, default=0.0,
|
| 286 |
+
help=">0 ⇒ new 区权重按离首帧足迹的距离爬升(dist 形状);0 = flat,与 10k 检查点一致")
|
| 287 |
+
|
| 288 |
+
ap.add_argument("--lr", type=float, default=1e-5)
|
| 289 |
+
ap.add_argument("--weight_decay", type=float, default=0.01)
|
| 290 |
+
ap.add_argument("--adam_betas", default="0.9,0.999",
|
| 291 |
+
help="ActionRoPE SFT 的优化器口径。WorldMind 那边是 (0.0, 0.999)(CF 约定),"
|
| 292 |
+
"但 stage1 是同一份 SFT 目标加了因果掩码,用户拍板沿用 SFT 的 betas")
|
| 293 |
+
ap.add_argument("--warmup", "--warmup_steps", dest="warmup", type=int, default=200,
|
| 294 |
+
help="线性 warmup 步数,之后恒定 lr(每个 save_every 边界都是合法的停止点)")
|
| 295 |
+
ap.add_argument("--grad_clip", type=float, default=1.0,
|
| 296 |
+
help="DeepSpeed 下写进 deepspeed_config.gradient_clipping(覆盖 yaml),日志里的 grad_norm 是裁剪前的")
|
| 297 |
+
ap.add_argument("--batch_size", type=int, default=1, help="每卡 micro batch;等效 batch = world × 它 × grad_accum")
|
| 298 |
+
ap.add_argument("--grad_accum", type=int, default=2, help="必须与 accelerate yaml 的 gradient_accumulation_steps 相同")
|
| 299 |
+
ap.add_argument("--max_steps", type=int, default=12000, help="优化器更新数(不是 micro batch 数)")
|
| 300 |
+
ap.add_argument("--gradient_checkpointing", action=argparse.BooleanOptionalAction, default=True)
|
| 301 |
+
ap.add_argument("--num_workers", type=int, default=2)
|
| 302 |
+
ap.add_argument("--seed", type=int, default=0)
|
| 303 |
+
|
| 304 |
+
ap.add_argument("--save_every", type=int, default=500, help="0 ⇒ 只在训练结束时导出一次")
|
| 305 |
+
ap.add_argument("--keep_last_dits", type=int, default=6, help="dits/ 只留最新几个(0 = 不删;上游 v4 光 dits/ 就 746 GB)")
|
| 306 |
+
ap.add_argument("--save_state", action="store_true", help="同时 accelerator.save_state(state_A/state_B 轮换,可续训)")
|
| 307 |
+
ap.add_argument("--val_every", type=int, default=500, help="0 ⇒ 不验证")
|
| 308 |
+
ap.add_argument("--val_n", type=int, default=16)
|
| 309 |
+
ap.add_argument("--val_timesteps", default="200,500,800")
|
| 310 |
+
ap.add_argument("--log_every", type=int, default=20)
|
| 311 |
+
ap.add_argument("--resume", default=None,
|
| 312 |
+
help="*.safetensors ⇒ 只热启动权重(step 归 0);目录 ⇒ accelerator.load_state 续训;"
|
| 313 |
+
"auto ⇒ 有 <run>/state 就续,没有就从头")
|
| 314 |
+
ap.add_argument("--dry_run", action="store_true", help="tiny 模型 + 合成数据,不碰真权重 / 真数据")
|
| 315 |
+
args = ap.parse_args(argv)
|
| 316 |
+
|
| 317 |
+
if abs(args.val_frac) > 1e-12:
|
| 318 |
+
raise SystemExit("--val_frac 只接受 0:ActionRoPE 用离线的 train_eybx / val_eybx 清单划分,"
|
| 319 |
+
"相邻 clip 只差 3 帧,按 clip 名 hash 划会把同一段画面同时放进两边。改用 --val_dir。")
|
| 320 |
+
if args.windowed_sink > 0 and args.windowed_train <= 0:
|
| 321 |
+
raise SystemExit("--windowed_sink 只在 --windowed_train > 0 时有意义")
|
| 322 |
+
try:
|
| 323 |
+
b1, b2 = (float(x) for x in args.adam_betas.split(","))
|
| 324 |
+
except ValueError:
|
| 325 |
+
raise SystemExit(f"--adam_betas 应形如 '0.9,0.999',得到 {args.adam_betas!r}") from None
|
| 326 |
+
args.betas = (b1, b2)
|
| 327 |
+
args.val_timesteps = [float(t) for t in args.val_timesteps.split(",") if t.strip()]
|
| 328 |
+
if args.dry_run:
|
| 329 |
+
args.init_ckpt = None
|
| 330 |
+
elif not os.path.isfile(args.init_ckpt):
|
| 331 |
+
raise SystemExit(f"--init_ckpt 不存在:{args.init_ckpt}")
|
| 332 |
+
return args
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
# ----------------------------------------------------------------------------
|
| 336 |
+
# dry run:tiny 模型 + 合成数据(PORT_SPEC 的 S6)
|
| 337 |
+
# ----------------------------------------------------------------------------
|
| 338 |
+
|
| 339 |
+
TINY = dict(dim=128, in_dim=48, ffn_dim=256, out_dim=48, text_dim=4096, freq_dim=256, eps=1e-6,
|
| 340 |
+
patch_size=(1, 2, 2), num_heads=4, num_layers=2, has_image_input=False)
|
| 341 |
+
TINY_SHAPE = (48, 5, 8, 10) # [C, cells, lat_h, lat_w]
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
class _SynDataset(torch.utils.data.Dataset):
|
| 345 |
+
"""dry run 用:形状与 AropeLatentDataset 一致的合成样本(cell 数 / 分辨率缩小)。"""
|
| 346 |
+
|
| 347 |
+
def __len__(self):
|
| 348 |
+
return 64
|
| 349 |
+
|
| 350 |
+
def __getitem__(self, i: int) -> dict:
|
| 351 |
+
g = torch.Generator().manual_seed(i)
|
| 352 |
+
c, f, h, w = TINY_SHAPE
|
| 353 |
+
off = torch.zeros(f, 2)
|
| 354 |
+
off[1:] = torch.randn(f - 1, 2, generator=g).cumsum(0) * 20.0
|
| 355 |
+
return {
|
| 356 |
+
"input_latents": torch.randn(c, f, h, w, generator=g, dtype=torch.float32).to(torch.bfloat16),
|
| 357 |
+
"context": torch.randn(f, 8, 4096, generator=g, dtype=torch.float32).to(torch.bfloat16),
|
| 358 |
+
"context_ids": torch.zeros(f, dtype=torch.int64),
|
| 359 |
+
"offset_px": off,
|
| 360 |
+
"clip_id": f"syn_{i:04d}",
|
| 361 |
+
"kind": "syn",
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def _tiny_model() -> AropeDiT:
|
| 366 |
+
from diffsynth.models.wan_video_dit import WanModel
|
| 367 |
+
torch.manual_seed(0)
|
| 368 |
+
dit = WanModel(**TINY).to(torch.bfloat16)
|
| 369 |
+
return AropeDiT(dit=dit, mask_channel=True, causal=True)
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
# ----------------------------------------------------------------------------
|
| 373 |
+
# 验证
|
| 374 |
+
# ----------------------------------------------------------------------------
|
| 375 |
+
|
| 376 |
+
@torch.no_grad()
|
| 377 |
+
def run_validation(accelerator: Accelerator, model, val_ds, tids: list[int], args) -> dict:
|
| 378 |
+
"""固定 clip × 固定噪声档 × 固定噪声,各进程分摊 clip,最后 reduce(集合操作,人人都要到)。
|
| 379 |
+
|
| 380 |
+
与训练不同:一次验证把 **所有** cell 都放在同一个噪声档 tid 上(cell 0 依旧干净 + t=0),
|
| 381 |
+
这样曲线只随模型变、不随抽签抖,口径与 `actionrope/train.py:352-386` 一致。
|
| 382 |
+
"""
|
| 383 |
+
model.eval()
|
| 384 |
+
device = accelerator.device
|
| 385 |
+
rank, world = accelerator.process_index, accelerator.num_processes
|
| 386 |
+
acc = torch.zeros(len(tids), 3, device=device, dtype=torch.float64) # loss_w 和 / mse 和 / 计数
|
| 387 |
+
for i in range(rank, len(val_ds), world):
|
| 388 |
+
sample = val_ds[i]
|
| 389 |
+
batch = to_device(arope_collate([sample]), device)
|
| 390 |
+
x0 = batch["input_latents"]
|
| 391 |
+
for j, tid in enumerate(tids):
|
| 392 |
+
g = torch.Generator(device=device)
|
| 393 |
+
g.manual_seed((zlib.crc32(str(sample["clip_id"]).encode()) * 1009 + tid * 7 + args.seed) % (2 ** 31))
|
| 394 |
+
noise = torch.randn(x0.shape, generator=g, device=device, dtype=x0.dtype)
|
| 395 |
+
idx_pf = torch.full((x0.shape[0], x0.shape[2]), int(tid), device=device, dtype=torch.int64)
|
| 396 |
+
_, st = ar_tf_loss(model, batch, new_weight=args.new_weight, new_weight_ramp=args.new_weight_ramp,
|
| 397 |
+
teacher_forcing=args.teacher_forcing, tf_noise_aug_max_idx=0,
|
| 398 |
+
use_gradient_checkpointing=False, noise=noise, idx_pf=idx_pf)
|
| 399 |
+
acc[j, 0] += st["loss_w"].double()
|
| 400 |
+
acc[j, 1] += st["mse"].double()
|
| 401 |
+
acc[j, 2] += 1
|
| 402 |
+
acc = accelerator.reduce(acc, reduction="sum")
|
| 403 |
+
model.train()
|
| 404 |
+
out = {}
|
| 405 |
+
for j, tid in enumerate(tids):
|
| 406 |
+
n = max(acc[j, 2].item(), 1.0)
|
| 407 |
+
t = float(t_of_idx(int(tid)))
|
| 408 |
+
out[t] = {"loss_w": acc[j, 0].item() / n, "mse": acc[j, 1].item() / n, "n": int(acc[j, 2].item())}
|
| 409 |
+
return out
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
# ----------------------------------------------------------------------------
|
| 413 |
+
# 主流程
|
| 414 |
+
# ----------------------------------------------------------------------------
|
| 415 |
+
|
| 416 |
+
def main(argv=None):
|
| 417 |
+
args = parse_args(argv)
|
| 418 |
+
accelerator = Accelerator(gradient_accumulation_steps=args.grad_accum)
|
| 419 |
+
is_main = accelerator.is_main_process
|
| 420 |
+
use_ds = accelerator.distributed_type == DistributedType.DEEPSPEED
|
| 421 |
+
set_seed(args.seed, device_specific=True) # 每个 rank 的 ε / 噪声档 / 场景 dropout 抽签都不同
|
| 422 |
+
device = accelerator.device
|
| 423 |
+
|
| 424 |
+
if use_ds:
|
| 425 |
+
# 让 --grad_clip 成为唯一入口;yaml 里的 gradient_clipping 只是默认值(train.py:394-401)
|
| 426 |
+
ds_cfg = accelerator.state.deepspeed_plugin.deepspeed_config
|
| 427 |
+
ds_cfg["gradient_clipping"] = float(args.grad_clip)
|
| 428 |
+
ga = ds_cfg.get("gradient_accumulation_steps")
|
| 429 |
+
if ga not in ("auto", args.grad_accum):
|
| 430 |
+
raise SystemExit(f"--grad_accum={args.grad_accum} 与 accelerate yaml 的 "
|
| 431 |
+
f"gradient_accumulation_steps={ga} 不一致:要么传 --grad_accum {ga},"
|
| 432 |
+
f"要么换 distill/configs/accelerate_zero2_4gpu_ga{args.grad_accum}.yaml"
|
| 433 |
+
f"(现有 ga2 / ga4 两份;等效 batch = 4 × batch_size × GA)")
|
| 434 |
+
ds_cfg["gradient_accumulation_steps"] = args.grad_accum
|
| 435 |
+
|
| 436 |
+
run_dir = runlib.resolve_run_dir(args.output)
|
| 437 |
+
if is_main:
|
| 438 |
+
os.makedirs(run_dir, exist_ok=True)
|
| 439 |
+
log = runlib.TextTee(os.path.join(run_dir, "train.log"), enabled=is_main)
|
| 440 |
+
jsonl = runlib.JsonlLogger(os.path.join(run_dir, "train_log.jsonl"), enabled=is_main)
|
| 441 |
+
eff_batch = accelerator.num_processes * args.batch_size * args.grad_accum
|
| 442 |
+
log(f"=== {STAGE} | run={run_dir} world={accelerator.num_processes} "
|
| 443 |
+
f"dist={accelerator.distributed_type} mp={accelerator.mixed_precision} ===")
|
| 444 |
+
if max(args.action_dropout, args.hist_dropout, args.ctx_dropout, args.anchor_dropout) > 0:
|
| 445 |
+
log(f"[recipe] 条件 dropout: anchor={args.anchor_dropout} action={args.action_dropout} "
|
| 446 |
+
f"hist={args.hist_dropout} ctx={args.ctx_dropout}(scene={args.scene_dropout} 由数据集做)")
|
| 447 |
+
if args.long_seg_dir:
|
| 448 |
+
log(f"[recipe] 长视野续训:训练集 = {args.long_seg_dir}(AropeLongDatasetTF),val = {args.val_dir}(21 cell)")
|
| 449 |
+
log(f"[recipe] tf={args.teacher_forcing} tf_aug={args.tf_noise_aug_max_idx} "
|
| 450 |
+
f"windowed={args.windowed_train}/{args.windowed_sink} new_weight={args.new_weight} "
|
| 451 |
+
f"lr={args.lr} betas={args.betas} wd={args.weight_decay} warmup={args.warmup} clip={args.grad_clip} "
|
| 452 |
+
f"bs={args.batch_size}×accum{args.grad_accum}×{accelerator.num_processes}卡 ⇒ 等效 batch {eff_batch} "
|
| 453 |
+
f"max_steps={args.max_steps} gc={args.gradient_checkpointing}")
|
| 454 |
+
low = runlib.warn_if_low_disk(run_dir)
|
| 455 |
+
if low:
|
| 456 |
+
log(f"[warn] {low}")
|
| 457 |
+
|
| 458 |
+
# ---- 数据 ----
|
| 459 |
+
if args.dry_run:
|
| 460 |
+
train_ds, val_ds = _SynDataset(), None
|
| 461 |
+
if args.max_steps > 30:
|
| 462 |
+
args.max_steps, args.save_every, args.warmup, args.log_every, args.val_every = 12, 6, 3, 2, 0
|
| 463 |
+
log("[dry_run] tiny 模型 + 合成数据")
|
| 464 |
+
else:
|
| 465 |
+
sidecar = load_sidecar(args.sidecar)
|
| 466 |
+
if args.long_seg_dir:
|
| 467 |
+
# 长段(F=41/61/81):latent 是整段一次过 VAE 的,不是 21-cell clip 拼接;键集与 arope_collate 契约相同
|
| 468 |
+
from longhorizon.arope_long_dataset import AropeLongDatasetTF
|
| 469 |
+
train_ds = AropeLongDatasetTF(args.long_seg_dir, text_table=args.text_table,
|
| 470 |
+
scene_dropout=args.scene_dropout, limit=args.limit, verbose=is_main)
|
| 471 |
+
else:
|
| 472 |
+
train_ds = AropeLatentDataset(args.train_dir, sidecar=sidecar, text_table=args.text_table,
|
| 473 |
+
text_mode="scene", scene_dropout=args.scene_dropout,
|
| 474 |
+
limit=args.limit, seed=args.seed, verbose=is_main,
|
| 475 |
+
exclude_src_in=args.val_dir, min_rt_ratio=args.min_rt_ratio)
|
| 476 |
+
val_ds = None
|
| 477 |
+
if args.val_every > 0 and args.val_n > 0:
|
| 478 |
+
# 验证不做场景 dropout;文本表已经在内存里,别再读一遍 1.5 GB
|
| 479 |
+
val_ds = AropeLatentDataset(args.val_dir, sidecar=sidecar, text_table=train_ds.text_table,
|
| 480 |
+
text_mode="scene", scene_dropout=0.0, limit=args.val_n,
|
| 481 |
+
seed=args.seed, verbose=is_main, min_rt_ratio=args.val_min_rt_ratio)
|
| 482 |
+
log(f"[data] train {len(train_ds)} clip,val {len(val_ds) if val_ds is not None else 0} clip")
|
| 483 |
+
dataloader = torch.utils.data.DataLoader(
|
| 484 |
+
train_ds, batch_size=args.batch_size, shuffle=True, drop_last=True, collate_fn=arope_collate,
|
| 485 |
+
num_workers=args.num_workers, pin_memory=True, persistent_workers=args.num_workers > 0,
|
| 486 |
+
)
|
| 487 |
+
|
| 488 |
+
# ---- 模型 ----
|
| 489 |
+
t0 = time.time()
|
| 490 |
+
if args.dry_run:
|
| 491 |
+
module = _tiny_model()
|
| 492 |
+
resume_info = None
|
| 493 |
+
else:
|
| 494 |
+
module = AropeDiT(model_dir=args.model_dir, mask_channel=True, causal=True,
|
| 495 |
+
train_local_attn_size=args.windowed_train, train_sink_size=args.windowed_sink)
|
| 496 |
+
# 因果化的起点:双向 SFT 检查点。assign=False ⇒ copy_ 进现有参数,保持模型自己的 dtype
|
| 497 |
+
resume_info = runlib.load_dit_ckpt(module, args.init_ckpt)
|
| 498 |
+
log(f"[model] 双向 SFT ← {args.init_ckpt}({resume_info['n_tensors']} 个张量,"
|
| 499 |
+
f"缺 {resume_info['missing']})")
|
| 500 |
+
# 只热启动权重的续训必须在 prepare **之前**:DeepSpeed 在 initialize 时就按当时的参数建好了
|
| 501 |
+
# fp32 主权重副本,prepare 之后再 copy_ 进 bf16 参数,第一次 step 就会被主权重原样盖回去(静默)。
|
| 502 |
+
warm_start = args.resume if (args.resume and args.resume.endswith(".safetensors")) else None
|
| 503 |
+
if warm_start:
|
| 504 |
+
info = runlib.load_dit_ckpt(module, warm_start)
|
| 505 |
+
log(f"[resume] 只热启动权重 {warm_start}(step 从 0 起):{info['n_tensors']} 个张量")
|
| 506 |
+
n_params = sum(p.numel() for p in module.parameters())
|
| 507 |
+
module.train().requires_grad_(True)
|
| 508 |
+
log(f"[model] AropeDiT causal={module.causal} mask_channel={module.mask_channel} "
|
| 509 |
+
f"{n_params:,} 参数,加载 {time.time() - t0:.0f}s")
|
| 510 |
+
|
| 511 |
+
optimizer = torch.optim.AdamW(module.parameters(), lr=args.lr, weight_decay=args.weight_decay,
|
| 512 |
+
betas=args.betas)
|
| 513 |
+
warm = max(int(args.warmup), 0)
|
| 514 |
+
# lr scheduler 故意不进 prepare:accelerate 包装后每步会 step num_processes 次,warmup 被悄悄压缩
|
| 515 |
+
lr_scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lambda s: min(1.0, (s + 1) / warm) if warm > 0 else 1.0)
|
| 516 |
+
|
| 517 |
+
# 只把 **一个** nn.Module 交给 prepare(accelerator.py:2229-2236 只留最后一个,多传会静默丢)
|
| 518 |
+
model, optimizer, dataloader = accelerator.prepare(module, optimizer, dataloader)
|
| 519 |
+
# dit.freqs 是普通 tuple 属性(不是 buffer),prepare / .to() 都搬不动它 —— 必须在裸模块上搬一次
|
| 520 |
+
unwrap(model).move_rope_table(device)
|
| 521 |
+
if is_main and use_ds:
|
| 522 |
+
clip_eff = getattr(getattr(model, "optimizer", None), "clip_grad", None)
|
| 523 |
+
log(f"[env] grad_clip={args.grad_clip}(DeepSpeed 生效值 {clip_eff})")
|
| 524 |
+
|
| 525 |
+
# ---- 续训(accelerate 存档:权重 + 优化器 + 数据顺序,必须在 prepare 之后)----
|
| 526 |
+
start_step = 0
|
| 527 |
+
if args.resume and not warm_start:
|
| 528 |
+
rp = args.resume
|
| 529 |
+
if rp == "auto":
|
| 530 |
+
link = os.path.join(run_dir, "state")
|
| 531 |
+
rp = link if os.path.exists(link) else None
|
| 532 |
+
log(f"[resume] auto ⇒ {rp or '没有存档,从头开始'}")
|
| 533 |
+
if rp:
|
| 534 |
+
accelerator.load_state(rp)
|
| 535 |
+
with open(os.path.join(rp, "trainer_state.json"), encoding="utf-8") as fh:
|
| 536 |
+
start_step = int(json.load(fh)["step"])
|
| 537 |
+
with warnings.catch_warnings(): # 优化器还没 step 就快进 scheduler,PyTorch 会提醒一句,这里是有意的
|
| 538 |
+
warnings.simplefilter("ignore")
|
| 539 |
+
for _ in range(start_step):
|
| 540 |
+
lr_scheduler.step()
|
| 541 |
+
log(f"[resume] accelerator.load_state({rp}),从 step {start_step} 续训")
|
| 542 |
+
|
| 543 |
+
# ---- config.json:复现所需的一切 ----
|
| 544 |
+
if is_main:
|
| 545 |
+
cfg = {
|
| 546 |
+
"stage": STAGE, "args": vars(args), "eff_batch": eff_batch,
|
| 547 |
+
"dataset": ({"synthetic": True} if args.dry_run else {
|
| 548 |
+
"train_dir": args.long_seg_dir or args.train_dir, "long_seg_dir": args.long_seg_dir,
|
| 549 |
+
"train_used": len(train_ds),
|
| 550 |
+
"train_total": getattr(train_ds, "n_total", len(train_ds)),
|
| 551 |
+
"train_invalid": getattr(train_ds, "n_invalid", None),
|
| 552 |
+
"train_leak_excluded": getattr(train_ds, "n_leak", None),
|
| 553 |
+
"train_low_rt_excluded": getattr(train_ds, "n_low_rt", None),
|
| 554 |
+
"text_table_keys": len(train_ds.text_table),
|
| 555 |
+
"val_used": len(val_ds) if val_ds is not None else 0,
|
| 556 |
+
"val_clip_ids": val_ds.clip_ids() if val_ds is not None else []}),
|
| 557 |
+
"model": {"n_params": n_params, "causal": True, "mask_channel": True,
|
| 558 |
+
"init_ckpt": args.init_ckpt, "resume_info": resume_info, "start_step": start_step},
|
| 559 |
+
"env": {"world_size": accelerator.num_processes, "distributed_type": str(accelerator.distributed_type),
|
| 560 |
+
"mixed_precision": accelerator.mixed_precision, "torch": torch.__version__,
|
| 561 |
+
"cuda": torch.version.cuda,
|
| 562 |
+
"gpu": torch.cuda.get_device_name(0) if torch.cuda.is_available() else None,
|
| 563 |
+
"python": sys.executable, "platform": platform.platform(), "hostname": socket.gethostname(),
|
| 564 |
+
"argv": sys.argv, "started": time.strftime("%Y-%m-%d %H:%M:%S"),
|
| 565 |
+
"cuda_visible_devices": os.environ.get("CUDA_VISIBLE_DEVICES")},
|
| 566 |
+
"deepspeed_config": (accelerator.state.deepspeed_plugin.deepspeed_config if use_ds else None),
|
| 567 |
+
}
|
| 568 |
+
try:
|
| 569 |
+
import accelerate, diffsynth # noqa: F401
|
| 570 |
+
cfg["env"]["accelerate"] = accelerate.__version__
|
| 571 |
+
except Exception:
|
| 572 |
+
pass
|
| 573 |
+
runlib.save_config(run_dir, cfg)
|
| 574 |
+
|
| 575 |
+
# ---- 日志 ----
|
| 576 |
+
csv_fields = ["step", "loss", "loss_w", "mse", "new_frac", "t_mean", "lr", "grad_norm",
|
| 577 |
+
"step_time", "mem_gb", "epoch"]
|
| 578 |
+
train_log = runlib.CsvLog(os.path.join(run_dir, "train_log.csv"), csv_fields, enabled=is_main)
|
| 579 |
+
val_log = runlib.CsvLog(os.path.join(run_dir, "val_log.csv"), ["step", "timestep", "loss_w", "mse", "n"],
|
| 580 |
+
enabled=is_main)
|
| 581 |
+
val_tids = [int(idx_of_t(t)) for t in args.val_timesteps]
|
| 582 |
+
|
| 583 |
+
def do_validation(step):
|
| 584 |
+
if val_ds is None:
|
| 585 |
+
return
|
| 586 |
+
tv = time.time()
|
| 587 |
+
res = run_validation(accelerator, model, val_ds, val_tids, args)
|
| 588 |
+
if is_main:
|
| 589 |
+
for t, r in res.items():
|
| 590 |
+
val_log.write({"step": step, "timestep": t, **r})
|
| 591 |
+
mean_w = sum(r["loss_w"] for r in res.values()) / len(res)
|
| 592 |
+
log("[val] step {} ".format(step)
|
| 593 |
+
+ " ".join(f"t={t:.0f}: w={r['loss_w']:.4f} mse={r['mse']:.4f} (n={r['n']})" for t, r in res.items())
|
| 594 |
+
+ f" | mean w={mean_w:.4f} {time.time() - tv:.0f}s")
|
| 595 |
+
|
| 596 |
+
def do_save(step, loss_val=None):
|
| 597 |
+
# save_dit 是集合操作(accelerator.get_state_dict),每个 rank 都必须进来
|
| 598 |
+
runlib.save_dit(accelerator, model, runlib.dit_path(run_dir, step),
|
| 599 |
+
{"stage": STAGE, "step": step, "teacher_forcing": args.teacher_forcing,
|
| 600 |
+
"tf_noise_aug_max_idx": args.tf_noise_aug_max_idx,
|
| 601 |
+
"action_dropout": args.action_dropout,
|
| 602 |
+
"hist_dropout": args.hist_dropout,
|
| 603 |
+
"ctx_dropout": args.ctx_dropout,
|
| 604 |
+
"anchor_dropout": args.anchor_dropout,
|
| 605 |
+
"windowed_train": args.windowed_train, "windowed_sink": args.windowed_sink,
|
| 606 |
+
"new_weight": args.new_weight, "text_mode": "scene", "init_ckpt": args.init_ckpt,
|
| 607 |
+
"long_seg_dir": args.long_seg_dir or ""},
|
| 608 |
+
expect_keys=(None if args.dry_run else runlib.AROPE_DIT_KEYS), log=log)
|
| 609 |
+
if is_main:
|
| 610 |
+
gone = runlib.rotate(runlib.dit_glob(run_dir), args.keep_last_dits)
|
| 611 |
+
if gone:
|
| 612 |
+
log(f"[rotate] 删掉 {len(gone)} 个旧 dit(保留最新 {args.keep_last_dits} 个)")
|
| 613 |
+
runlib.write_progress(run_dir, step, stage=STAGE, loss=loss_val)
|
| 614 |
+
if args.save_state:
|
| 615 |
+
runlib.save_state_rotating(accelerator, run_dir, {"step": step, "stage": STAGE})
|
| 616 |
+
|
| 617 |
+
# ---- 训练循环 ----
|
| 618 |
+
model.train()
|
| 619 |
+
step, epoch, last_saved = start_step, 0, start_step
|
| 620 |
+
if device.type == "cuda":
|
| 621 |
+
torch.cuda.reset_peak_memory_stats(device)
|
| 622 |
+
log(f"[train] 从 step {start_step} 到 {args.max_steps}")
|
| 623 |
+
micro_time, loss_ema = 0.0, None
|
| 624 |
+
while step < args.max_steps:
|
| 625 |
+
for batch in dataloader:
|
| 626 |
+
if step >= args.max_steps:
|
| 627 |
+
break
|
| 628 |
+
t_step = time.time()
|
| 629 |
+
batch = to_device(batch, device)
|
| 630 |
+
lr_now = lr_scheduler.get_last_lr()[0] # 本步用的 lr(step 之后拿到的是下一步的)
|
| 631 |
+
with accelerator.accumulate(model):
|
| 632 |
+
loss, stats = ar_tf_loss(
|
| 633 |
+
model, batch, new_weight=args.new_weight, new_weight_ramp=args.new_weight_ramp,
|
| 634 |
+
teacher_forcing=args.teacher_forcing, tf_noise_aug_max_idx=args.tf_noise_aug_max_idx,
|
| 635 |
+
action_dropout=args.action_dropout, hist_dropout=args.hist_dropout,
|
| 636 |
+
ctx_dropout=args.ctx_dropout, anchor_dropout=args.anchor_dropout,
|
| 637 |
+
use_gradient_checkpointing=args.gradient_checkpointing)
|
| 638 |
+
accelerator.backward(loss) # DeepSpeed 下这一步已含 engine.step()(裁剪 + 更新)
|
| 639 |
+
grad_norm = (accelerator.clip_grad_norm_(model.parameters(), args.grad_clip)
|
| 640 |
+
if accelerator.sync_gradients else None)
|
| 641 |
+
optimizer.step()
|
| 642 |
+
if accelerator.sync_gradients:
|
| 643 |
+
lr_scheduler.step() # DeepSpeed 的 scheduler 包装不认累积边界,只能在这里守门
|
| 644 |
+
optimizer.zero_grad(set_to_none=True)
|
| 645 |
+
if device.type == "cuda":
|
| 646 |
+
torch.cuda.synchronize(device)
|
| 647 |
+
micro_time += time.time() - t_step
|
| 648 |
+
if not accelerator.sync_gradients:
|
| 649 |
+
continue # 还在累积,不算一步
|
| 650 |
+
step += 1
|
| 651 |
+
step_time, micro_time = micro_time, 0.0
|
| 652 |
+
loss_mean = accelerator.reduce(loss.detach().float(), reduction="mean").item()
|
| 653 |
+
loss_ema = loss_mean if loss_ema is None else 0.9 * loss_ema + 0.1 * loss_mean
|
| 654 |
+
if is_main and (step % args.log_every == 0 or step == args.max_steps):
|
| 655 |
+
gn = float(grad_norm) if grad_norm is not None and math.isfinite(float(grad_norm)) else float("nan")
|
| 656 |
+
row = {"step": step, "loss": loss_mean, "loss_w": stats["loss_w"].item(),
|
| 657 |
+
"mse": stats["mse"].item(), "new_frac": stats["new_frac"].item(),
|
| 658 |
+
"t_mean": stats["t_mean"].item(), "lr": lr_now, "grad_norm": gn,
|
| 659 |
+
"step_time": step_time,
|
| 660 |
+
"mem_gb": (torch.cuda.max_memory_allocated(device) / 1024 ** 3 if device.type == "cuda" else 0.0),
|
| 661 |
+
"epoch": epoch}
|
| 662 |
+
train_log.write(row)
|
| 663 |
+
jsonl.log({**row, "loss_ema": loss_ema})
|
| 664 |
+
log(f"step {step}/{args.max_steps} loss {loss_mean:.4f} (ema {loss_ema:.4f}) "
|
| 665 |
+
f"w {row['loss_w']:.4f} mse {row['mse']:.4f} t̄ {row['t_mean']:.0f} new {row['new_frac']:.2f} "
|
| 666 |
+
f"lr {row['lr']:.2e} gnorm {gn:.3f} {step_time:.2f}s mem {row['mem_gb']:.1f}GB")
|
| 667 |
+
if args.val_every > 0 and step % args.val_every == 0:
|
| 668 |
+
do_validation(step)
|
| 669 |
+
if args.save_every > 0 and step % args.save_every == 0:
|
| 670 |
+
do_save(step, loss_mean)
|
| 671 |
+
last_saved = step
|
| 672 |
+
epoch += 1
|
| 673 |
+
|
| 674 |
+
if last_saved != step:
|
| 675 |
+
do_save(step, loss_ema)
|
| 676 |
+
if device.type == "cuda":
|
| 677 |
+
peak = torch.tensor(torch.cuda.max_memory_allocated(device) / 1024 ** 3, device=device)
|
| 678 |
+
peak_max = accelerator.reduce(peak, reduction="max").item()
|
| 679 |
+
else:
|
| 680 |
+
peak_max = 0.0
|
| 681 |
+
log(f"[done] step {step},各进程峰值显存 {peak_max:.1f} GB。"
|
| 682 |
+
f"下一步:把 dits/dit_step{step}.safetensors 同时作为 stage2 的 --teacher_ckpt 与 --student_ckpt")
|
| 683 |
+
if is_main:
|
| 684 |
+
runlib.write_progress(run_dir, step, stage=STAGE, finished=True, peak_mem_gb=peak_max)
|
| 685 |
+
train_log.close()
|
| 686 |
+
val_log.close()
|
| 687 |
+
jsonl.close()
|
| 688 |
+
log.close()
|
| 689 |
+
accelerator.wait_for_everyone()
|
| 690 |
+
accelerator.end_training()
|
| 691 |
+
return step
|
| 692 |
+
|
| 693 |
+
|
| 694 |
+
if __name__ == "__main__":
|
| 695 |
+
main()
|
backup_2026-09-14/code/distill/stage1_ar/config.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_note": "stage1(teacher forcing 因果化)真正在用的配方常量。这三处必须逐项相同:本文件 / run.sh 的默认值 / ar_tf.py 的 argparse 默认值 —— 上游 WorldMind 的 run.sh 与 argparse 有六处不一致,每一处都对应一次已知的崩溃(PORT_SPEC risk 11)。改配方就同时改这三处。",
|
| 3 |
+
"_not_this_file": "跑起来之后 <output>/config.json 是那一次 run 的完整快照(含数据集统计 / 环境 / deepspeed_config),由 runlib.save_config 写;本文件只是签进仓库的配方记录。",
|
| 4 |
+
"_launch": "bash distill/stage1_ar/run.sh",
|
| 5 |
+
"stage": "stage1_ar_tf",
|
| 6 |
+
"script": "distill/stage1_ar/ar_tf.py",
|
| 7 |
+
|
| 8 |
+
"init_ckpt": "outputs/arope_10k_ga2/step-10000.safetensors",
|
| 9 |
+
"init_ckpt_role": "双向全片 SFT(827 键 bf16,9,999,690,792 B);stage1 只把注意力换成因果 + TF 双流,不换目标",
|
| 10 |
+
"output": "outputs/distill/stage1_ar_tf",
|
| 11 |
+
"product": "dits/dit_step{N}.safetensors → stage2 的 --teacher_ckpt 与 --student_ckpt(同一份)",
|
| 12 |
+
|
| 13 |
+
"train_dir": "data/latent/train_eybx",
|
| 14 |
+
"val_dir": "data/latent/val_eybx",
|
| 15 |
+
"sidecar": "data/latent/arope_sidecar.pt",
|
| 16 |
+
"text_table": "data/latent/text_table_actionrope.pt",
|
| 17 |
+
"text_mode": "scene",
|
| 18 |
+
"scene_dropout": 0.1,
|
| 19 |
+
"min_rt_ratio": 0.8,
|
| 20 |
+
"val_min_rt_ratio": 0.9,
|
| 21 |
+
"val_frac": 0,
|
| 22 |
+
"_val_frac": "只接受 0:ActionRoPE 的划分是离线的 train_eybx / val_eybx 清单,相邻 clip 只差 3 帧,按 clip 名 hash 划会漏样本",
|
| 23 |
+
|
| 24 |
+
"teacher_forcing": true,
|
| 25 |
+
"tf_noise_aug_max_idx": 0,
|
| 26 |
+
"_tf_noise_aug_max_idx": "0 = 精确干净的历史(CF 默认)。>0 时本移植抽的是 [1000-k, 1000) 的近干净档;上游 ar_diffusion_gm.py:93 抽的是 [0, k),那是 σ≈1 的纯噪声端,方向反了",
|
| 27 |
+
"windowed_train": 0,
|
| 28 |
+
"windowed_sink": 0,
|
| 29 |
+
"_windowed": "全因果。F=21 下 sink=0 的 W=20 会把 cell 0(ActionRoPE 的干净锚点)挤出 cell 20 的视野",
|
| 30 |
+
"new_weight": 2.0,
|
| 31 |
+
"new_weight_ramp": 0.0,
|
| 32 |
+
"_new_weight": "new 区 ×2 的 loss 权重图照搬 SFT —— stage1 就是那个 SFT 目标加了因果掩码",
|
| 33 |
+
"first_frame_cond": true,
|
| 34 |
+
"loss": "cell 0 干净 + t=0 且不计 loss;cells 1..20 逐 cell 独立均匀抽噪声档,Σw·se/(Σw·48) 逐 cell 算,再乘逐 cell 的 bsmntw 钟形权重取平均",
|
| 35 |
+
|
| 36 |
+
"optimizer": "AdamW",
|
| 37 |
+
"lr": 1e-05,
|
| 38 |
+
"weight_decay": 0.01,
|
| 39 |
+
"adam_betas": [0.9, 0.999],
|
| 40 |
+
"_adam_betas": "ActionRoPE SFT 的口径(用户拍板)。WorldMind 那边是 CF 约定的 (0.0, 0.999),不照抄",
|
| 41 |
+
"warmup": 200,
|
| 42 |
+
"lr_schedule": "线性 warmup 200 步后恒定 —— 每个 save_every 边界都是合法的停止点",
|
| 43 |
+
"grad_clip": 1.0,
|
| 44 |
+
|
| 45 |
+
"batch_size": 1,
|
| 46 |
+
"grad_accum": 2,
|
| 47 |
+
"world_size": 4,
|
| 48 |
+
"eff_batch": 8,
|
| 49 |
+
"_eff_batch": "4 卡 × batch 1 × GA 2。--grad_accum 必须等于 accelerate yaml 的 gradient_accumulation_steps,否则 ar_tf.py 直接 SystemExit",
|
| 50 |
+
"max_steps": 12000,
|
| 51 |
+
"gradient_checkpointing": true,
|
| 52 |
+
"num_workers": 2,
|
| 53 |
+
"seed": 0,
|
| 54 |
+
|
| 55 |
+
"save_every": 500,
|
| 56 |
+
"keep_last_dits": 6,
|
| 57 |
+
"save_state": true,
|
| 58 |
+
"val_every": 500,
|
| 59 |
+
"val_n": 16,
|
| 60 |
+
"val_timesteps": [200, 500, 800],
|
| 61 |
+
"log_every": 20,
|
| 62 |
+
"resume": "auto",
|
| 63 |
+
|
| 64 |
+
"accelerate_config": "distill/configs/accelerate_zero2_4gpu_ga2.yaml",
|
| 65 |
+
"distributed": "DeepSpeed ZeRO-2, bf16, 4 进程(只有一个可训模型,所以不用 FSDP)",
|
| 66 |
+
"gpus": "0,1,2,3",
|
| 67 |
+
"main_process_port": 29531,
|
| 68 |
+
|
| 69 |
+
"_budget": "≈11 s/优化器步(2 × 5.5 s 的 TF micro batch)⇒ 12000 步 ≈ 38 h;显存 ~34-50 GB/卡(143 GB 卡);磁盘 6×10 GB dits + 2 个 state 槽",
|
| 70 |
+
"_fallback": "--no-teacher_forcing 换成 diffusion forcing(单条 8190 token 流,便宜约 2.5×),代价是 stage2 必须跟着一起 --no-teacher_forcing",
|
| 71 |
+
"_gate": "开跑前先过 distill/tests/test_tf_port.py(clean f → noisy f 泄漏必须是 0.00e+00)"
|
| 72 |
+
}
|