teawhite commited on
Commit
8d710c6
·
verified ·
1 Parent(s): 55df65b

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. backup_2026-09-14/code/baseline/SPEC.md +88 -0
  2. backup_2026-09-14/code/baseline/__init__.py +200 -0
  3. backup_2026-09-14/code/baseline/adaln.py +207 -0
  4. backup_2026-09-14/code/baseline/base.py +51 -0
  5. backup_2026-09-14/code/baseline/linear.py +97 -0
  6. backup_2026-09-14/code/baseline/prompt.py +267 -0
  7. backup_2026-09-14/code/baseline/xattn.py +288 -0
  8. backup_2026-09-14/code/configs/accelerate_zero2.yaml +26 -0
  9. backup_2026-09-14/code/configs/accelerate_zero2_4gpu.yaml +26 -0
  10. backup_2026-09-14/code/configs/accelerate_zero2_ga2.yaml +27 -0
  11. backup_2026-09-14/code/demo/engine.py +326 -0
  12. backup_2026-09-14/code/demo/index.html +96 -0
  13. backup_2026-09-14/code/demo/run.sh +24 -0
  14. backup_2026-09-14/code/demo/server.py +195 -0
  15. backup_2026-09-14/code/distill/AUDIT_CFPP_2026-09-10.md +128 -0
  16. backup_2026-09-14/code/distill/MANIFEST.md5 +63 -0
  17. backup_2026-09-14/code/distill/OPSD_OFFICIAL_NOTES.md +50 -0
  18. backup_2026-09-14/code/distill/PORT_SPEC.md +0 -0
  19. backup_2026-09-14/code/distill/RUNBOOK.md +248 -0
  20. backup_2026-09-14/code/distill/__init__.py +0 -0
  21. backup_2026-09-14/code/distill/arope_dit.py +1091 -0
  22. backup_2026-09-14/code/distill/common.sh +89 -0
  23. backup_2026-09-14/code/distill/configs/accelerate_fsdp_dmd_4gpu.yaml +47 -0
  24. backup_2026-09-14/code/distill/configs/accelerate_fsdp_dmd_8gpu.yaml +49 -0
  25. backup_2026-09-14/code/distill/configs/accelerate_zero2_4gpu_ga2.yaml +28 -0
  26. backup_2026-09-14/code/distill/configs/accelerate_zero2_4gpu_ga4.yaml +28 -0
  27. backup_2026-09-14/code/distill/configs/accelerate_zero2_8gpu_ga1.yaml +34 -0
  28. backup_2026-09-14/code/distill/configs/accelerate_zero2_8gpu_ga2.yaml +36 -0
  29. backup_2026-09-14/code/distill/data.py +399 -0
  30. backup_2026-09-14/code/distill/ema.py +160 -0
  31. backup_2026-09-14/code/distill/eval/anystep_probe.py +196 -0
  32. backup_2026-09-14/code/distill/eval/flowmap_liveness.py +183 -0
  33. backup_2026-09-14/code/distill/eval/loop_spec.txt +1 -0
  34. backup_2026-09-14/code/distill/eval/null_branch_probe.py +113 -0
  35. backup_2026-09-14/code/distill/eval/paired50.sh +39 -0
  36. backup_2026-09-14/code/distill/eval/photodrift.sh +39 -0
  37. backup_2026-09-14/code/distill/eval/rollout_eval.py +1105 -0
  38. backup_2026-09-14/code/distill/eval/sgf_parity_probe.py +112 -0
  39. backup_2026-09-14/code/distill/eval/sweep8.sh +73 -0
  40. backup_2026-09-14/code/distill/eval/sweep_photodrift.sh +46 -0
  41. backup_2026-09-14/code/distill/eval/tf_rollout_probe.py +135 -0
  42. backup_2026-09-14/code/distill/flow_map.py +11 -0
  43. backup_2026-09-14/code/distill/flow_pf.py +345 -0
  44. backup_2026-09-14/code/distill/flowmap.py +934 -0
  45. backup_2026-09-14/code/distill/longtraj.py +201 -0
  46. backup_2026-09-14/code/distill/lora.py +180 -0
  47. backup_2026-09-14/code/distill/onpolicy.py +378 -0
  48. backup_2026-09-14/code/distill/runlib.py +542 -0
  49. backup_2026-09-14/code/distill/stage1_ar/ar_tf.py +695 -0
  50. 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
+ }