← 回到对比页

FlashVSR 是怎么工作的

从「它凭什么能一步出图」讲到「我们改了什么、为什么改完更快还不掉画质」。 每个数字都标了来源,凡是没实测的地方会写明「未验证」。

面向自学 全部结论可复现 测试机 8×RTX 5090 32G

目录
  1. 背景:为什么扩散模型做视频超分这么慢
  2. FlashVSR 的三板斧(原理)
  3. 一条片子走完全程:逐步拆解
  4. 它的七个硬约束,以及怎么绕
  5. 我们做的优化,以及每一条为什么安全
  6. 方法论:怎么判断「快了但没坏」
  7. 我踩过的坑(含我自己写错的结论)
  8. 配置速查表

1. 背景:为什么扩散模型做视频超分这么慢

先说清楚超分(Super-Resolution)要解决什么。把一张 960×540 的图放大到 1920×1080, 你需要凭空造出 4 倍的像素。传统方法(双三次插值)只会在已知像素之间做平滑过渡, 所以放大后是糊的——它没有新增任何信息,只是把旧信息摊开。

要造出看起来合理的细节,就需要一个「见过很多真实图像」的模型来猜: 这块模糊的区域,按照真实世界的统计规律,应该长什么样。扩散模型特别擅长这件事。

扩散模型的代价

标准扩散模型的工作方式是从纯噪声开始,反复去噪几十次,每次去掉一点, 最后收敛成一张图。这对单张图还好,对视频是灾难:

成本来源为什么贵
多步采样50 步意味着同一个大网络要跑 50 遍。一条 5 秒 24fps 的视频有 120 帧, 就是 6000 次网络前向。
注意力的平方复杂度Transformer 的自注意力里,每个位置都要和所有其他位置算相关性。 序列长度翻倍,计算量变 4 倍。视频的序列长度 = 帧数 × 高 × 宽,一下就爆了。
训练/测试分辨率不一致模型在 512×512 上训练,你拿 2688×1536 去推理, 注意力要处理的序列长度差了 15 倍,模型没见过这种尺度,容易崩。
VAE 解码扩散在压缩过的「潜空间」里进行,最后要用解码器还原成像素。 解码器本身也是个大网络。
FlashVSR 的三个创新,正好一一对应上面前三行(第四行是它的 tiny 变体解决的): 一步蒸馏干掉多步、局部约束稀疏注意力干掉平方复杂度和尺度不匹配、 流式处理让长视频不必一次性装进显存、微型条件解码器干掉解码开销。 论文报告在单张 A100 上 768×1408 能跑到 ~17 FPS。

2. FlashVSR 的三板斧(原理)

2.1 一步蒸馏(One-step DMD)—— 从 50 步变 1 步

蒸馏的意思是:先训练一个慢但好的「老师」模型(多步扩散), 然后训练一个「学生」模型去一步直接跳到老师走 50 步才到的终点。 DMD(Distribution Matching Distillation)是其中一种做法, 它匹配的不是单条轨迹而是输出分布,所以学生不会只学会复刻老师的某几个样本。

代码里能直接看到这一点——权重文件叫 diffusion_pytorch_model_streaming_dmd.safetensors,而 pipeline 里:

self.scheduler.set_timesteps(1, denoising_strength=1.0, shift=5.0)
self.timestep = torch.tensor([1000.], ...)   # 固定在最大噪声档位
self.t     = self.dit.time_embedding(sinusoidal_embedding_1d(...))
self.t_mod = self.dit.time_projection(self.t).unflatten(1, (6, self.dit.dim))

注意 self.tself.t_mod在循环外算一次然后一直复用的。 因为只有一步,时间步永远是 1000,时间嵌入是个常量。多步扩散里这是每步都要重算的。

去噪本身就一行:

noise_pred = model_fn_wan_video(self.dit, x=cur_latents, timestep=self.timestep, ...)
cur_latents = cur_latents - noise_pred     # 一步到位,没有循环
这解释了一个反直觉的现象:我们发现 num_inference_steps 这类参数完全没用, 调它不影响任何东西。因为这个模型结构上就只有一步, 「步数」这个旋钮在蒸馏之后已经不存在了。

2.2 局部约束稀疏注意力(LCSA)—— 干掉平方复杂度

这是 FlashVSR 最核心也最巧妙的部分。作者在 README 里专门强调: 有些第三方实现(早期 ComfyUI 版本)没有实现 LCSA、退化成了稠密注意力, 会导致明显的画质下降,尤其在高分辨率下。所以这不是个可选的加速开关, 它是模型正确性的一部分

第一步:把视频切成 3D 窗口

win = (2, 8, 8)                       # 2 帧 × 8 × 8 的潜空间块
q_w = WindowPartition3D.partition(q, win)
k_w = WindowPartition3D.partition(k, win)
v_w = WindowPartition3D.partition(v, win)

注意力不再以「单个 token」为单位,而是以为单位。这一步就把序列长度降了 128 倍 (2×8×8)。

第二步:造一个「只看邻居」的局部掩码

self.local_attn_mask = build_local_block_mask_shifted_vec_normal_slide(
    h//8, w//8, local_range, local_range, include_self=True, ...)

这个函数生成的是一个「每个块只允许看它周围 local_range 范围内的块」的布尔矩阵。 默认 local_range=11,即以自己为中心的 11×11 块邻域。

为什么局部就够?因为超分是个局部任务。要恢复某块砖的纹理, 你需要的是这块砖周围的上下文,不是画面另一头那片天空。 这同时也解决了训练/测试分辨率不一致的问题——不管你输入 512 还是 2688, 每个块看到的邻域大小是固定的,模型面对的局部统计量始终和训练时一致。 这就是论文说的「bridging the train–test resolution gap」。

第三步:在局部范围里再挑最重要的 top-k

avgpool_q = torch.mean(q_w, dim=1)     # 每个块压成一个代表向量
avgpool_k = torch.mean(k_w, dim=1)
scores = einsum("hld,hmd->hlm", q_heads, k_heads) / sqrt(D)   # 块级别的粗略打分
scores = scores + local_attn_mask      # 局部之外直接 -inf
attn_map = softmax(scores, dim=-1)
thresholds = topk(flat, k=topk+1, dim=1).values[:, -1]        # 只留最强的 topk 个块对

这叫 draft mask(草稿掩码):先用极廉价的「块平均向量」算一遍粗略注意力, 看看哪些块对之间真的有关系,只把这些块对交给真正的注意力算子去精算。 最后调用 block_sparse_attn_func——这是一个专门的 CUDA 算子, 能跳过掩码为 0 的块,不算就是不算,不是算完再乘 0。

topk 由参数 sparse_ratio 控制:

topk_ratio = sparse_ratio * 768 * 1280 / (th * tw)

注意这个式子——它把 topk 按分辨率反比缩放,基准是 768×1280。 分辨率越高,保留的比例越小,保证绝对计算量大致恒定。这也是它能「scale 到超高分辨率」的原因。

2.3 流式处理 + KV 缓存 —— 长视频不用一次装下

视频的时间维度也是序列的一部分。如果一次处理 360 帧,注意力的序列长度是 360×H×W,显存直接爆炸。FlashVSR 的做法是滑动窗口 + KV 缓存

process_total_num = (num_frames - 1) // 8 - 2     # 每轮推进 8 帧
for cur_process_idx in range(process_total_num):
    if cur_process_idx == 0:
        pre_cache_k = [None] * len(self.dit.blocks)   # 第一轮:冷启动,喂 7 段 LQ
        inner_loop_num = 7
        cur_latents = latents[:, :, :6, :, :]
    else:
        inner_loop_num = 2                            # 之后每轮只喂 2 段新 LQ
        cur_latents = latents[:, :, 4+idx*2 : 6+idx*2, :, :]

    noise_pred, pre_cache_k, pre_cache_v = model_fn_wan_video(
        ..., pre_cache_k=pre_cache_k, pre_cache_v=pre_cache_v, kv_ratio=kv_ratio)

每一轮只处理一小段新帧,但把之前算好的 K/V 带着往前走, 所以新帧仍然「看得见」历史。这就是「streaming」的含义—— 理论上可以处理无限长的视频,显存占用恒定

kv_ratio 控制这个缓存留多长:

kv_len = min(max(int(window_size * kv_ratio), 1), int(window_size*seqlen)-1)
if cache_num > kv_len:
    cache_k = k_w[one_len:, :, :]    # 丢掉最老的一段
    cache_v = v_w[one_len:, :, :]
这解释了我们的实测结果kv_ratio 是唯一能压 DiT 显存峰值的旋钮 (3.0 → 1.5 让峰值从 28.7 降到 25.8 GiB),因为它直接决定缓存张量有多大。 而它降到 1.5 以下就不再有效——那时缓存已经不是显存大头了。

更重要的是:我们在 UDM10 + YouHQ40 共 50 条片子上配对测过, kv 3.0 → 1.5 对画质的影响所有指标 CI 全部跨 0(测不出差异)。 也就是说这 2.9 GiB 是白捡的。为什么?因为超分是局部任务, 时间上也是——第 100 帧不需要记得第 1 帧长什么样。

2.4 微型条件解码器(TCDecoder)—— tiny 变体的秘密

标准流程里,DiT 在潜空间输出结果,再由 Wan2.1 VAE 解码器还原成像素。 这个解码器是完整的 VAE,485 MB,很重。

FlashVSR 提供了一个替代品 TCDecoder(181 MB),关键在于它是有条件的

pipe.TCDecoder = build_tcdecoder(new_channels=[512, 256, 128, 128],
                                 new_latent_channels=16 + 768)
...
def decode_video(self, x, ..., cond=None):
    if cond is not None:
        x = torch.cat([self.pixel_shuffle(cond), x], dim=2)

看那个 16 + 768:16 是潜空间通道数,768 = 3 × 16 × 16, 正是把 LQ 原图做 16×16 pixel-shuffle 之后的通道数。也就是说, 解码器除了潜变量,还直接看到了低清原图

这就是它能做小的原因:它不需要从潜变量里重建一切, 低频结构、颜色、大致轮廓都可以直接从 LQ 抄,它只需要补高频细节。 调用时确实把 LQ 传进去了:

frames = self.TCDecoder.decode_video(latents.transpose(1,2), parallel=False,
                                     cond=LQ_video[:,:,:LQ_cur_idx,:,:])
这解释了我们最重要的一个发现。我们做了 2×2 拆解实验 (解码器 full/tiny × kv 3.0/1.5,UDM10 n=10 + YouHQ40 n=40),结论是: 换解码器带来的画质变化,100% 由解码器造成,kv_ratio 一点责任都没有。

方向是分裂的:TCDecoder 让保真度变好(PSNR-Y +0.47~0.53,LPIPS 更低), 但无参考观感分变差(MUSIQ −2.2~−2.6,CLIP-IQA −0.06)。

现在能解释了:TCDecoder 大量依赖 LQ 的实际内容,所以它更忠实、更少编造。 Wan VAE 解码器完全从潜变量重建,会「脑补」出更多纹理—— 这些纹理未必真实存在,但 MUSIQ / CLIP-IQA 这类无参考模型奖励看起来锐利, 不管那锐利是不是原本就有的。

2.5 它是 ×4 模型,但可以做真 ×2

先纠正一个我之前写错的说法。我曾经把「scale 写死 4」列为一个缺陷——这是错的。 FlashVSR 本来就是一个 ×4 模型,不是 ×2:官方 6 个推理脚本全部是 seed, scale, dtype, device = 0, 4.0, ...prepare_input_tensorcompute_scaled_and_target_dims 的默认参数都是 scale=4;连 LQ 投影层的类名都叫 Causal_LQ4x_Proj;论文的场景是 540p→4K。这是设计规格,不是 bug。

那为什么我们能让它做 ×2?因为它是 pre-upsample(先放大后处理)架构。 看 prepare_input_tensor 的逻辑:LQ 图先被普通插值放大到目标尺寸, 然后 DiT 在目标分辨率上工作。DiT 的任务不是「放大」, 而是「把一张已经放大但很糊的图修清楚」。

所以 scale 是前处理的参数,没有烧进网络权重。传 2 进去, LQ 就被放大 2 倍,DiT 在 2 倍尺寸上修复,出来就是真 ×2。

反过来说,如果不这么做,想要 ×2 就只能×4 之后再缩小回去—— 那会白烧 4 倍的算力(DiT 在 4 倍面积上工作),还多一次重采样损失。

3. 一条片子走完全程

把上面的部件串起来,一条 141 帧 1344×768 → 2688×1536 的片子是这样走的:

输入 mp4 (1344×768, 141 帧) │ ├─ 读帧 → RGB │ ├─ [我们加的] 边缘 pad 到 128 的倍数 (2688×1536 已经是,不需要) ├─ [我们加的] 尾部补帧到 8k+1 (141 → 145) │ ├─ [我们加的] 空间分块:长边切 2 块,重叠 256 px │ └─ 每块独立走下面全部流程,最后线性 ramp 权重融合 │ ├─ [我们加的] 时域窗口:切成若干 ≥33 帧的窗口,重叠 8 帧 │ │ ┌─ 对每个窗口 ─────────────────────────────┐ │ │ bicubic 放大 LQ 到 2688×1536 │ ← scale=2 在这里生效 │ │ Causal_LQ4x_Proj 把 LQ 投影成条件特征 │ │ │ 初始化纯高斯噪声作为 latents │ │ │ │ │ │ for 每轮推进 8 帧: │ ← 流式 │ │ LQ_proj_in.stream_forward(新的 LQ 段) │ │ │ noise_pred = DiT(latents, t=1000, ...) │ ← 一步,LCSA 稀疏注意力 │ │ latents -= noise_pred │ │ │ 携带 KV 缓存进入下一轮 (kv_ratio 控长度)│ │ │ │ │ │ TCDecoder.decode_video(latents, cond=LQ) │ ← 条件解码,VAE 分块 │ │ ColorCorrector (小波颜色校正) │ │ └───────────────────────────────────────────┘ │ ├─ [我们加的] 窗口间线性 crossfade 融合 ├─ [我们加的] 裁回原始取景、截回 141 帧 │ └─ 输出 PNG 序列 (2688×1536, 141 帧)

标了 [我们加的] 的部分是官方实现里没有的。 官方脚本假设你喂给它的片子形状本来就合法、长度本来就装得下、 分辨率本来就不超过显存——现实中这三条经常不成立。

4. 它的七个硬约束,以及怎么绕

FlashVSR 对输入的形状要求很硬。麻烦的地方在于: 官方脚本处理不合法输入的方式,是悄悄改掉你的片子,而不是报错。 在评测场景下,每一条都会变成一个静默的错误结果。

约束官方实现我们的驱动
画布必须是 128 的倍数 目标尺寸向下取整到 128,再中心裁。1920×1056 静默变成 1920×1024, 凭空丢掉 32 行真实内容。 改成把输入边缘复制 pad 到合法画布,模型在合法尺寸上跑, 出来再裁回原始取景。一行不丢,构图不动。
帧数必须是 8k+1 不满足就按它自己的方式截断。 尾部重复最后一帧补齐,产出后再截回原长。
只吐 num_frames−4 帧 静默少 4 帧,而且丢的是尾巴不是头。 先多喂 4 帧再截。这条是验出来的不是猜的: 对输出和真值做 ±8 帧偏移的 PSNR 扫描,峰值必须落在 0。 我第一次补在头部,扫描峰值出现在 +4,直接证明丢的是尾部。
最短时域窗口 33 帧 17 帧和 25 帧都会在它自己的流式循环里 torch.cat([]) 空列表崩掉。 短片补到 ≥33;低于这个值直接抛明确错误,不让它崩在库内部。
窗口长度取整方向 (这条是我自己引入的 bug,不是官方的) 必须向上取整到 8k+1 再补帧。向下取整会让最后一窗盖不到片尾, 141 帧只出 135 帧。
scale 固定为 4 设计规格就是 ×4(见 2.5 节)。 利用 pre-upsample 架构,前处理传 scale=2,得到真 ×2
超长 / 超宽 整条整幅塞进去,装不下就 OOM。 时域窗口(重叠 8 帧,流式融合)+ 空间分块 (只切长边、128 对齐、重叠 256 px、线性 ramp 互补权重融合)。

5. 我们做的优化,以及每一条为什么安全

方法论先行:先分块计时,再决定优化谁。 凭直觉挑优化目标,很容易在一个只占 3% 时间的模块上花掉一整天。 我们用 FVPROF=1 给每个块加了同步计时器 (每个计时器都 cuda.synchronize(),量的是真实 GPU 时间,不是异步下发延迟)。

5.1 分块计时的结果

2688×1536 × 141 帧,不含 PNG 落盘:

配置总耗时解码器DiT + 前处理
tiny(TCDecoder)74.8 s12.3 s 16%62.4 s 83%
full(Wan VAE 解码器)283.4 s220.1 s 78%63.3 s 22%

DiT 两边都是 ~63 s。两个配置的全部差距都在解码器上。 所以画质优先配置该优化的地方只有一个:那 220 秒。

5.2 最大的一处:VAE 解码重叠

FlashVSR 的 pipeline 解码时用 tile_size=(60,104), tile_stride=(30,52)——50% 重叠。 重叠区域的像素会被解码两次(横竖都重叠就是约 4 次)然后加权融合。

为什么要重叠?因为分块解码时,每块的边缘缺少块外的上下文, 直接拼会有接缝。重叠 + 融合可以把接缝抹掉。

但需要 50% 这么多吗?实测:

tile / stride重叠总耗时解码器显存峰值
60 / 30 (管线默认)50%287.3 s221.7 s16.52 GiB
60 / 4427%177.1 s113.3 s17.03 GiB
60 / 567%147.2 s81.3 s16.52 GiB
96 / 4850%228.1 s161.9 s19.87 GiB
96 / 888%154.3 s86.0 s19.87 GiB
128 / 1206%139.8 s24.50 GiB
160 / 1525%179.3 s30.41 GiB
256 / 2483%OOM>32 GiB

解码 221.7 s → 81.3 s,快 2.7×,显存一点没涨。 注意分块开大(96/128/160)反而更慢且更吃显存—— 省时间的是减少重叠,不是加大分块。

为什么这样安全?两层验证

① 全量画质对比(YouHQ40 n=40,配对 + bootstrap CI):

指标7% 重叠50% 重叠差值95% CI
PSNR-Y ↑24.647424.6395+0.0079[+0.0065, +0.0094]
SSIM-Y ↑0.67080.6703+0.0005[+0.0004, +0.0006]
LPIPS ↓0.22330.2234−0.0000[−0.0001, +0.0001]
DISTS ↓0.10500.1053−0.0003[−0.0004, −0.0002]
MUSIQ ↑71.412671.4389−0.0263[−0.0397, −0.0141]
CLIP-IQA ↑0.61330.6182−0.0049[−0.0056, −0.0042]

保真度略微变好——这不奇怪,重叠融合本身就是一次轻微的模糊,去掉它反而更锐。 CLIP-IQA 掉 0.005,对比换解码器那次掉 0.061,小一个数量级。

② 接缝专项检测。全局指标有个致命弱点:一条 1 像素宽的缝会被平均值淹没。 所以要专门找这个失效模式。做法是量「低重叠版相对高重叠版多出来的水平梯度」, 只看接缝那几列:

extra = gradient(低重叠版) − gradient(高重叠版)
在接缝位置的 extra 偏离均值多少个 σ?

为什么要相减?因为画面本身就有强边缘(比如一根电线杆), 那里两个版本的梯度都很大,直接看绝对梯度会误报。 只有分块版独有、整幅版没有的梯度才是接缝。

结果:4 条片子 × 横竖两个方向 × 2 个接缝位置, 额外梯度最大 3.7σ,全部低于 4σ 判定线; 而且最大偏差点落在画面内容边缘(如 x=866)而不是接缝上。无可见接缝。

5.3 空间分块:为什么要,以及有没有代价

先区分两种分块,之前混着说是我的表述问题:
空间分块tile-px):我们加的,FlashVSR 原本没有。 整幅 3584×1536 送进 DiT 会死在一个 2.15 GiB 的定长分配上, 这个分配跟任何旋钮都无关。不切块,21:9 根本跑不了。
VAE 解码分块tile_size/stride):FlashVSR 自带的,默认开启, 就是上一节讨论的那个。

空间分块有没有代价?跑了一组 --tile-px 1e12(永不分块)的对照, 其余配置逐字相同。分块只在每帧 > 3.0 Mpx 时触发,所以 MiniMax-20 天然分成两组:

① 阴性对照(4 条 1:1,2.36 Mpx,两边都没触发分块): 逐字节 md5 比对 4/4 完全相同。 这一步是必须的——如果这里就不一样,说明管线本身不确定,下面所有数字都是噪声。

② 16 条 >3 Mpx 的真实 A/B:

指标分块不分块差值95% CI
MUSIQ ↑60.968060.9355+0.0325[−0.2494, +0.2895] 跨 0
CLIP-IQA ↑0.51100.5110+0.0000[−0.0045, +0.0041] 跨 0
MANIQA ↑0.34260.3431−0.0005[−0.0019, +0.0010] 跨 0
NIQE ↓4.31914.3649−0.0458[−0.0758, −0.0086]
Downsample-LPIPS ↓0.14710.1483−0.0012[−0.0028, +0.0004] 跨 0

准确的说法:五个指标里四个的 CI 跨 0(测不出差异), 唯一测得出的 NIQE 还是分块更好一点。没有任何指标显示分块更差。 但也不能说「零改变」——输出确实不逐位相同,只是差异小到测不出方向。

为什么可以没有代价?—— 这里我原来的解释是错的

先纠正我自己。我原先在主站上写过「重叠 256 px 大于模型 local_range=11 ≈ 88 px 的感受野,所以每个像素的上下文是完整的」。 这个数字算错了,差了大约 7 倍。正确的推算是:
输出像素 →(VAE ÷8)→ latent →(DiT patch_size=[1,2,2] ÷2)→ 注意力 token
    1 个 token   = 16 个输出像素
    1 个注意力块 = 8×8 token = 128×128 个输出像素
    local_range=11 块,以自己为中心 → ±5 块 = ±640 个输出像素
所以局部注意力的半跨度是 ±640 px,而我们的重叠只有 256 px(离接缝 ±128 px)。 重叠量其实远小于感受野,接缝附近的像素确实看到了被截断的上下文。

那为什么实测没有代价?我认为有三个原因,但要说清楚—— 下面是解释,不是证据;证据是上面那张 A/B 表和接缝检测

  1. LCSA 在局部窗口内还要再做 top-k 筛选。11×11 的局部窗口只是候选, 真正参与计算的是其中分数最高的 topk 个块对。离得远的块大概率本来就被筛掉了, 所以「有效感受野」比「名义感受野」小得多。
  2. 线性 ramp 融合把最差的像素权重压到最低。紧贴接缝的像素, 上下文缺失最严重,而它在融合里的权重恰好接近 0—— 那里的输出几乎完全来自另一块的中心区域,而那块的上下文是完整的。
  3. 超分本来就是个极局部的任务。恢复一块砖的纹理, 真正有用的信息在几十个像素以内,几百像素外的内容贡献很小。

这也说明一件事:我最初那条「有足够余量」的推理即使数字算对了也不该作为结论—— 它是个理论论证,而理论论证代替不了 16 条片子的配对测量和接缝检测。 数字错了但结论没错,纯属运气好,不是方法对。

而且对 tiny 来说分块是双赢:2 块比整幅又快又省 34% 显存 (155.5 s / 20.13 GiB vs 163.4 s / 30.49 GiB)。 为什么切开反而更快?因为注意力的计算量随边长平方增长, 切成两半之后单块计算量是原来的 1/4,跑两趟也才 1/2, 省下的算力超过了跑两趟的固定开销。

5.4 torch.compile:试了,没用

配置eagercompile 解码器compile DiT都 compile
full + 7% 重叠145.8 s145.7 s147.0 s
tiny78.6 s75.9 s78.6 s78.2 s

编译确实生效了(日志里有 [compile] decoder compiled / [compile] dit compiled,不是静默失败),但收益 ≤3%,在测量噪声以内。三个原因:

这正是「先分块计时」的价值。同样是想省时间: 改一个 tile_stride 参数拿到 2.7×, 而 torch.compile(业界公认的加速手段)拿到 0%。 如果一开始就凭直觉去上 compile,会白白花掉一天。

6. 方法论:怎么判断「快了但没坏」

这一节是最可迁移的部分——换个模型、换个项目,这套判断方法一样用。

6.1 配对比较,不是均值比较

两个配置跑的是同一批片子,所以要看逐条相减的结果。 片子之间的难度差异(有的片子所有方法都得分低)会在配对里抵消掉。 直接比两个均值,真实差异会被片间方差淹没。

差值 = Σ(配置A在片i的分 − 配置B在片i的分) / N     # 配对
而不是 mean(配置A所有分) − mean(配置B所有分)        # 会被噪声淹没

6.2 Bootstrap 置信区间,判断「测得出还是测不出」

有了差值均值还不够——它是 +0.03 还是 −0.03,可能纯属抽样波动。 Bootstrap 的做法是:从这 N 个差值里有放回地重抽 N 个,算一次均值; 重复 2000 次,得到 2000 个均值,取 2.5% 和 97.5% 分位数。

如果这个区间跨过 0,就是「测不出差异」,不能说谁更好。 随机种子固定(20260821),所以重跑不会让区间飘。

6.3 阴性对照:先证明你的测量本身是可信的

分块 A/B 里那 4 条低于阈值的片子,是专门用来验证测量工具的: 两边都没触发分块,所以必须逐字节相同。如果这里就不同, 说明管线有随机性,后面 16 条的差异全是噪声,整个实验作废。

同理,24 GB 显存模拟里我先跑了一个阳性对照—— 一个已知需要 30.5 GiB 的配置,在 23 GiB cap 下必须 OOM。 它确实 OOM 了,才证明 cap 真的在起作用,后面「通过」的行才有意义。

6.4 针对失效模式设计检测,不要只看全局指标

接缝检测是最好的例子。一条 1 像素宽的缝,在 PSNR 里的影响约等于 0, 但人眼一看就出戏。全局指标测不出局部失效。 所以要问:这个改动可能怎么坏?然后专门去测那个坏法。

6.5 分清「保真度」和「观感分」,别指望它们一致

类型代表衡量什么什么时候用
全参考保真度PSNR、SSIM、LPIPS、DISTS 真值差多远有 GT 时。判断「对不对」
无参考观感分MUSIQ、CLIP-IQA、MANIQA、NIQE 看起来像不像一张高质量图没 GT 时。判断「好不好看」
视频质量DOVER 含时间维度的整体观感判断闪烁、抖动
改动量Downsample-LPIPS 把输出缩回原尺寸,和输入比——改写了多少原内容 没 GT 时的保真度代理
这两类经常给出相反的答案,而且都没错。 无参考模型奖励「看起来锐」,不管那锐度原本存不存在。 一个疯狂编造纹理的模型,无参考分会很高、保真度会很差。 所以永远不要只报一类指标。

6.6 监控后台任务:三种状态,不是两种

只等成功标记的监控会在失败时永久挂住。每个后台任务必须区分:

「进程死了且没成功标记」= 失败,不是「还在跑」。

7. 我踩过的坑

放这里不是为了自罚,是因为这些坑的形状是通用的

7.1 一条结论性错误:「5090 跑不了 4 Mpx」

我曾经报告 FlashVSR 在 5090 上做不了 2688×1536 输出。 这是错的。挡住它的是我自己设的一个 px-budget 门限—— 那个门限是按 kv_ratio 3.0 标定的,后来配置变了却没更新。 改掉门限后,连原版配置都能跑(385 s / 29.15 GiB)。

教训:把自己的保护性检查误当成硬件限制。 遇到「跑不了」,先确认是真的 OOM还是自己的代码拒绝启动

7.2 float32 全片画布:二次复杂度的隐藏成本

我把整条片子的融合缓冲开成 float32,13.7 GB 的张量被反复拷贝 15 次, 一个 GPU 任务变成了 memcpy 瓶颈,跑 28 分钟还没完。 改成预分配 uint8 后同一条 625 s 跑完,输出逐位相同

教训:GPU 任务跑得慢,先看 GPU 利用率。如果是 0%,问题不在 GPU。

7.3 多块分支的尾部黑帧

缓冲按 m 帧分配,但管线只填 m−4 帧,剩下的尾巴保持零值,写出来是黑帧。

7.4 窗口向下取整,片尾漏 6 帧

141 帧只写出 135 帧。靠打印逐帧覆盖表wins / produced / missing)定位。

教训:涉及分块/窗口的代码,加一个「哪些索引被写过」的诊断输出, 比事后猜测省一个数量级的时间。

7.5 双管道死锁

编排器用 Popen 开了 stdout 和 stderr 两个管道,却只 drain stdout。 full 变体加载 Wan VAE 时在 stderr 上话很多,64 KB 内核缓冲写满, 子进程卡在 pipe_write不报错、不超时、永久挂住。 改成 stderr=STDOUT 合并。

7.6 PNG 落盘伪装成推理瓶颈

容量测试跑得异常慢,一度以为是模型问题。实际是 PNG 落盘—— 4 Mpx 一帧 0.69 s,360 帧就是 4 分钟,还和其他并发任务抢 IO(争用时飙到 5.5 s/帧)。 加了 --no-write 之后,容量探针的时间从两个多小时压到 20 分钟。

教训:测什么就只测什么。容量探针要量的是模型,不该把编码器算进去。

8. 配置速查表

场景配置2688×1536×141显存
画质优先
给人看 / 用 IQA 打分
--variant full --kv-ratio 1.5 --vae-tile 60 --vae-stride 56 147.2 s16.52 GiB
速度优先
对着参考评 / 批量跑
--variant tiny(其余默认) 157.3 s → 74.8 s*20.13 GiB
原版参考 --variant full --kv-ratio 3.0 且不分块 385.2 s29.15 GiB

* 157.3 s 含 PNG 落盘,74.8 s 是纯推理(--no-write)。 两个数字口径不同,别混着比。

15 秒长片容量(360 帧,画质优先配置)

场景23 GiB cap耗时显存峰值
15s 16:9 2688×1536通过385.0 s16.52 GiB
15s 21:9 3584×1536通过468.2 s19.52 GiB

旋钮总结

旋钮作用该不该调
variantfull=Wan VAE 解码器,tiny=TCDecoder 看需求。这是唯一真正影响画质的旋钮
kv_ratio流式 KV 缓存长度 调到 1.5。省 2.9 GiB,画质影响 CI 跨 0
vae_stride解码分块步长(重叠量) 调到 56。解码快 2.7×,保真度还略好
tile_px空间分块阈值 开着。21:9 的必要条件,对 tiny 还是双赢
vae_tile解码分块大小 保持 60。开大更慢更吃显存
sparse_ratio / local_rangeLCSA 稀疏度 / 局部范围 别动。时间和显存都没有可测量影响,但它们是模型正确性的一部分
torch.compile 别开。收益 ≤3%,在噪声内

所有数字均在 viggle_new_5090(8×RTX 5090 32G)上实测。 画质对比一律在无损 PNG 上计算,不经过网页视频那层编码。 配对统计用 2000 次 bootstrap 重采样,随机种子 20260821 固定。 本页只讨论 FlashVSR,不涉及其他模型。