我修好了一个 bug,同样 24 个镜头,角色跑偏的从 5 个变成了 8 个。
修之前,我一直装着一个拿错的权重文件。它和正确那个大小都是 20,970,379,616 字节,一个字节都不差。
更难受的是中间那一轮:我换上正确的权重重测了一遍,跑了两个小时,结果是「没差别」。其实那一轮从设计上就测不出任何差别,开跑前做一个乘法就能知道。第 2 节就讲这个乘法,想直接拿走的看那节;第 4 节是怎么拿它去读别人的 A/B。
1. 两个文件,怎么会一样大
背景先交代一句。我在自己的卡上用一个本地视频模型拍一段 24 个镜头的小短片,测的是:同一个角色从第 1 镜拍到第 24 镜,脸和场景会不会慢慢跑偏。每个镜头拍完交给一个看图模型判一次「跑偏了 / 没跑偏」,跑偏的镜头数除以 24,就是漂移率,越低越好。
这个模型有两个检查点(可以理解成同一套架构、训练目标不同的两份成品权重):
- 首尾帧版:给它一张起始画面,它往下接着拍
- 参考图版:给它几张角色参考照,它照着人拍
我在测「参考图」这条路,按理该装参考图版。有人看了我的配置问了一句:你一直把这两个当成同一个在用吗,它们分别是什么?
我一查,心里凉了半截。下载参考图版那天我其实看到过两个文件一样大,当时心里过了一下,觉得「同规格嘛,正常」,就没再深究。现在把两份文件摊开对:
| 首尾帧版 | 参考图版 | |
|---|---|---|
| 文件大小 | 20,970,379,616 字节 | 20,970,379,616 字节 |
| 张量数 | 932 | 932 |
| 形状差异 | 0 | 0 |
| 元数据 | 空 | 空 |
| md5 | 不同 | 不同 |
前四行全一样,只有最后一行能把它俩分开。
然后是更要命的一条:加载权重的那个节点根本不检查你给的是哪个版本。装错了照样加载、照样出片,一条报错都没有。
病灶在我自己的跑批代码里。负责批量提交的那段,在需要参考图的时候把生成节点换成了「参考图模式」,但加载权重的那一行没跟着换,文件名是写死的首尾帧版。所以走那条队列跑的所有「参考图实验」,一次都没真正用过参考图版的权重。
那段代码往上数五行,有一条我自己写的注释,大意是「这是今天第六次抓到同一类病:配置被静默忽略,不报错」。这是第七次,就在那条注释下面三行。
这里能拿走的判据很短:判断两份权重是不是同一份,大小、张量数、形状、元数据都不算数,只有哈希算。 加载成功、出片正常,也不算数。
2. 第一次重测:花了两小时,测了个寂寞
修好代码,重启跑批进程(这一步差点又白跑:进程中午就起了,代码晚上才改,内存里跑的还是旧代码),我拿正确的参考图版重跑了之前那版最好的配置。
那版配置叫「混合锚定」:每 6 个镜头算一幕,幕首那一镜用参考图生成,定住角色长相;幕内后面 5 镜用上一镜的最后一帧当起点接着拍,保证动作连贯。之前装着错权重时,它跑出 7/24 = 29%,是五个版本里最好的。
开跑前我写死了判据:落在 6 到 8 个镜头(24%–34%)之间就判「没有实质差异」。
结果 8/24 = 33%,落在带内。「29% 是被错权重拖住的天花板」这个猜想,证伪了。
然后我去数据库里把 24 条记录的生成模式逐条读出来,才看到问题:
第 1、7、13、19 镜:参考图模式 ← 只有幕首这 4 个镜头
其余 20 镜: 接龙模式 ← 跟换哪个权重没关系
换权重这个改动,只碰到了 4/24 = 17% 的镜头。拆开看:
| 漂移 | |
|---|---|
| 参考图模式的 4 个幕首镜 | 1/4 = 25% |
| 接龙模式的 20 镜 | 7/20 = 35% |
总分 33% 的大头,来自压根没被这次修复影响的那 20 镜。
这就是开头说的那个乘法。一个对照实验能不能测出东西,开跑前就能算:
预期位移(个) ≈ 变量的真实效应 × 被这个变量碰到的样本数
能测出来 ⇔ 预期位移 > 噪声带
没有数据的时候不知道「真实效应」有多大,那就先拿上限算:被碰到的样本全部翻转,最多也就动 4 个镜头。我的噪声带是 ±2 个镜头。上限 4 个,只有在那 4 个幕首镜全部朝同一个方向翻的极端情况下才刚好跨过去,正常情况根本够不着。
事后用第 3 节测出来的真实效应(12.5 个百分点)代进去:12.5% × 4 ≈ 0.5 个镜头。半个镜头,对上 ±2 的噪声带。这一轮从设计那一刻起就注定是「没差别」。
再配一个更短的式子,专门看样本够不够细:
分辨率 = 1 ÷ 样本数
24 个镜头,1 镜 = 4.2 个百分点;只看那 4 个幕首镜,1 镜 = 25 个百分点。「幕首镜 1/4 = 25%」这个数,换一个镜头就跳成 0% 或 50%,拿它对参考图版本身下任何结论都是在掷硬币。
这两个式子都是小学算术,我在开跑前一个都没算。两小时的卡时就是这么没的。
3. 第二次:把作用面拉到 100%,「错」的那个赢了
既然问题出在只碰到 17% 的镜头,那就让每个镜头都走参考图模式。我把每幕的长度设成 1 镜,这样 24 个镜头每一个都是「幕首」,作用面 100%。两组只换权重,其余全一样。
开跑前照样先算一遍:
| 第一次(混合锚定) | 第二次(全参考图) | |
|---|---|---|
| 作用面 | 17%(4/24) | 100%(24/24) |
| 效应上限 | 4 镜 | 24 镜 |
| 噪声带 | ±2 镜 | ±2 镜 |
| 判断 | 基本测不出 | 值得跑 |
判据照样提前写死:差 3 个镜头及以上才算信号,差 2 个以内判「没有实质差异」。
结果:
| 组 | 权重 | 漂移 |
|---|---|---|
| A | 参考图版(「对」的那个) | 8/24 = 33% |
| B | 首尾帧版(「错」的那个) | 5/24 = 21% |
差 3 个镜头,12.5 个百分点,刚好压在我写死的线上。两组跑偏的镜头也基本不重合,交集只有 2 个。
我第一反应是先怀疑自己。毕竟这一天里配置被静默忽略已经抓到七次了,判据里也写着「结果反常先查参数」。所以把两组共 48 条记录逐列对了一遍:LoRA、步数、强度、分辨率、帧数、采样器、调度器、参考图组,全部只有一种取值;起始帧字段,两组都是 0 条非空。真正变的只有那一个权重文件。另外两组的提交我都直接从生成队列里读回来确认过,没信跑批脚本自己打的日志。
光这一步就救了我一次。B 组中途跑批进程崩了,自动重启的时候丢掉了指定权重的那个环境变量,它的默认值恰好是参考图版。要不是去读了进程的真实环境,B 组就会变成「参考图版 vs 参考图版」,我还会认真地分析为什么两组差不多。
所以那个 bug 是真的,修它也是对的,只是在这台机器、这个短片上,修完之后变差了。之前那五版「装错权重」跑出来的数字,没有被高估,反而用的是表现更好的那个。
4. 别人贴 A/B 结果时,先问这三句
我这次自己就生产了一个该被拆穿的数字:「29% 是锚定策略的天花板」。它听上去有理有据,五个版本里三次都卡在 29%。拆它用的就是下面三句,拿去读任何人的「换了 X,提升了 Y%」都适用。
第一句:X 实际碰到了多少比例的样本?
一个改动只作用在 17% 的样本上,它在总分里能留下的痕迹最多也就那 17%。对方只报总分、不报作用面,那个「没差别」或者「涨了」都要打问号。
第二句:同一个配置跑两遍,差多少?
我在混合锚定配置下试过:同配置、同种子复跑,输出的 md5 都不一样。那一次我差点把这种运行间的抖动解释成「新策略在第一段就生效了」,解释得头头是道,但源码白纸黑字写着那个策略只作用于续写段。没量过噪声本底的 A/B,差 1 个百分点和差 10 个百分点你都分不清是不是信号。
第三句:两个数是不是只差了这一个变量?
我还在这批数据上翻过一次车。从两个版本里聚合出一个漂亮的「第 3 层断崖」:离幕首 0–2 镜的漂移 12%,3 镜及以后 62%(48 个镜头的样本)。据此预测把每幕缩到 3 镜能压到 20% 以下,结果实测 29%,预测失败。原因是层数和拍摄场景是共线的:离幕首近的恰好大多是人站在柜台后面的稳定镜头,离得远的恰好大多是门口、屏幕特写这种难拍的。
把场景单独拆出来,三种完全不同的锚定策略下排序都一样:
| 场景 | 漂移(三版合计) |
|---|---|
| 柜台(人在固定位置) | 9/42 = 21% |
| 门口(人 + 门 + 雨 + 街景) | 8/18 = 44% |
| 屏幕特写(画面主体是画中画) | 8/12 = 67% |
差 3 倍。锚定策略怎么改都在 29%–46% 之间晃,换权重也没跳出 21%–33%。真正决定漂移的是拍什么,怎么接反而是小头。救我那次的是预测提前写进了文件,写死了「20% 以下才算成功」。没写死的话,29% 和最好那版持平,我大概率会说成「持平说明也可行」。
5. 这个结论现在还站不住的地方
把话说死之前,这几条得摊开:
- 只有 4 步。 我用的是加速采样,每镜 4 步。官方文档写得很明白,4 步是最低值,推荐 6–8 步。参考图版很可能需要更多步数才发挥得出来。所以正确的说法是:「这个短片、这组参考图、4 步下,首尾帧版的漂移低 3/24。」不能说首尾帧版比参考图版好。
- n = 1,而且刚好压线。 3 个镜头的差距正好等于我的阈值。全参考图这个配置下我没单独量过噪声本底,混合锚定下量过一次,同配置复跑结果就是会变。
- 单一指标。 漂移率只管身份和场景是不是一致,不管画质、不管动作幅度、不管叙事接不接得上。
- 一条跟我之前说法冲突的信号。 我之前写过「参考图模式不吃上一镜,所以不会累积漂移」。可 A 组前 12 镜漂移 2/12 = 17%,后 12 镜 6/12 = 50%;B 组反而是前 3/12 = 25%、后 2/12 = 17%。半段只有 12 个镜头,1 镜就是 8.3 个百分点,这条只记录,不当结论。
- B 组跑了三次才成。 第一次在第 4 镜报显存非法访问,第二次在第 1 镜报一个自相矛盾的显存不足(要 125 MiB,说剩 61 GiB),查下来是驱动死了。重启整机之后第三次 24/24 跑完。前两次的残骸没有混进结果,但这说明长时间高压跑批本身也是个不稳定因素。
还有一条元教训,跟技术无关。发现装错权重的前一天,我还编过一句「参考图版需要另一套 144GB 的权重」,白烧了 4 个小时卡时。纠正的时候我又一下滑到另一头,说成「两个版本共用同一套权重」。正确答案在中间:共用架构和加载节点,但是两份不同的检查点。推翻一个错结论,跟写下它一样需要查证。
接下来按性价比该测的:先在全参考图配置下同种子复跑 8 个镜头,量出噪声本底,这一步最便宜,直接决定这 3 镜之差信不信得过;再跑 6 步和 8 步,看参考图版会不会反超。
6. 下次做对照实验,照这个顺序
- 先确认你装上的就是你以为的那个。 权重、配置、环境变量,一律从下游读回来:哈希对文件,队列对提交,进程环境对变量。日志是程序的自述,不算。
- 改完代码,确认跑的进程是改完之后才起的。 进程启动时间早于文件修改时间,就是在跑旧代码。
- 数一下这个变量碰到了多少样本。 用「预期位移 ≈ 效应 × 被碰到的样本数」算上限,上限都跨不过噪声带,就别跑,先改设计把作用面拉上去。
- 用「分辨率 = 1 ÷ 样本数」看你的样本够不够细。 1 个样本就是 25 个百分点的子组,不要拿来下结论。
- 先量噪声本底。 同配置跑两遍,看差多少。这个数就是你的噪声带。
- 判据和预测写进文件再开跑。 阈值、枚举结果、停止条件,跑完不许改。
- 结果反常,先逐列对参数。 对完确认只差一个变量,再相信它。
- 写结论时把作用域一起写上。 哪个样本、哪组参数、几次运行,缺一个就别裸引用。
修一个 bug 修出负收益这种事,我以为只会发生在别人的代码里。