— — 768p → 2× RTX 5090 —
鼠标在画面上左右移动即可擦除对比 —— 光标左边是 A,右边是 B。两条视频严格同帧、同步播放。 开 放大镜 看纹理级差异;按 空格 播放/暂停,← → 逐帧。 暂停 / 逐帧 / 拖进度条时,两边会一起锁到同一帧的中点,控制条右侧显示当前帧号。
本轮的重点是 FlashVSR 原版 vs 调优版,页面默认就开在这两个上;其余方法作为基础参照保留。
📖 想弄懂原理和每一步为什么这么做,看 FlashVSR 是怎么工作的 · 学习笔记 —— 一步蒸馏 / 稀疏注意力 / 流式 KV / 条件解码器的原理, 我们改的每一处及其安全性论证,以及「怎么判断快了但没坏」的方法论。
| 配置 | 2688×1536 × 141 帧 | 显存峰值 |
|---|---|---|
| 原版(full,kv 3.0,管线默认 VAE 分块) | 385.2 s | 29.15 GiB |
| 调优版(tiny + 2 空间块) | 157.3 s | 20.13 GiB |
2.5× 速度,−31% 显存。15s 容量:16:9 2688×1536 × 360 帧 409.9 s / 20.13 GiB; 21:9 3584×1536 × 360 帧 558.6 s / 22.19 GiB。21:9 只有靠空间分块才成立—— 整幅送进去会死在一个 2.15 GiB 的定长分配上。
kv_ratio 和分块都是白捡的。所以还有第三个配置:
保留作者的解码器,只吃内存优化。
| 配置 | 2688×1536×141 | 显存峰值 | 画质 |
|---|---|---|---|
| 原版(无分块) | 385.2 s | 29.15 GiB | 基准 |
| 画质优先:full + kv1.5 + 2 块 + 7% 解码重叠 | 147.2 s | 16.52 GiB | 与原版同解码器,保真度还略好 |
| 速度优先:tiny + kv1.5 + 2 块 | 157.3 s | 20.13 GiB | 感知分下降(MUSIQ −2.3,CLIP-IQA −0.06) |
24 GB(4090)实测:把 CUDA 分配器硬卡在 23.0 GiB(先用一个已知需 30.5 GiB 的配置做阳性对照, 确认 cap 真的会 OOM),上面四种场景全部通过;15s 21:9 改 3 块后峰值降到 16.44 GiB 而耗时不变(558.0 s), 是 24 GB 卡上更该用的档位。注意:这只模拟了显存预算,没有模拟 sm_89 kernel 和 Ada 更低的带宽, 真 4090 上耗时会更长。
单击设为 A(左),再次单击同一个设为 B(右)。 也可以直接点另一个方法替换 B。
两个配置跑的是同一批片子,所以看的是逐条配对差值,不是各自的均值—— 片子之间的难度差异会在配对里抵消掉。CI 是 2000 次 bootstrap 重采样,种子固定。 差值 = 调优版 − 原版;CI 不跨 0 才算真差异。
「原版」= 作者发布的模型配置(full 管线 / Wan VAE 解码器,kv_ratio 3.0), 跑在同一套内存脚手架里(输入 pad、尾部补 4 帧、scale=2、空间分块)—— 不共用这套脚手架,两次跑的就不是同一批像素,也就没法比。 所以表里原版在 MiniMax 上的峰值是 19.67 GiB 而不是 29.15 GiB: 29.15 是原版配置不分块时的数字,那才是它自己跑 4 Mpx 的代价。
full + kv1.5 + 空间分块,也就是作者的解码器 + 全部内存优化。
这些行是纯容量探针:关掉 PNG 落盘(--no-write)跑的,
因为 wall_s / 峰值都是围着模型量的,而写 360 张 4 Mpx 的 PNG 比被测的推理本身还久,
对结论没有贡献。所以这里的耗时不含落盘,同一条 5s 16:9 带落盘是 381.8 s、不带是 285.1 s。
| 场景 | 23 GiB cap | 50% 重叠 | 7% 重叠 | 显存峰值 |
|---|---|---|---|---|
| 5s 16:9 2688×1536 | 通过 | 285.1 s | 147.2 s | 16.52 GiB |
| 5s 21:9 3584×1536 | 通过 | 415.9 s | 209.9 s | 19.52 GiB |
| 15s 16:9 2688×1536 | 通过 | 751.6 s | 385.0 s | 16.52 GiB |
| 15s 21:9 3584×1536 | 通过 | 931.8 s | 468.2 s | 19.52 GiB |
| 15s 16:9,改 3 块 | 通过 | 744.3 s | — | 14.15 GiB |
| 15s 21:9,改 3 块 | 通过 | 1012.4 s | — | 14.15 GiB |
| 不设 cap(对照) | — | 763.1 / 965.0 s | 385.0 s(16:9) | 同上 |
带 cap 和不带 cap 的峰值完全一致、耗时相差 1.5–3.5%,说明这个配置没有贴着 24 GB 的上限——
不是勉强挤进去的。15s 21:9 还剩 3.5 GiB 余量,换 3 块能再降到 14.15 GiB,
代价是慢 8%。
也就是说:要作者的画质、又要 15s 21:9、又要 24 GB,这三件事不冲突,
需要放弃的只是那 2.4× 的速度。
这里有两种完全不同的分块,之前混着说是我的表述问题:
| 分块 | 是谁做的 | 为什么要 |
|---|---|---|
| 空间分块 tile-px |
我们加的,FlashVSR 原本没有 | 整幅 3584×1536 送进 DiT 会死在一个 2.15 GiB 的定长分配上, 这个分配跟任何旋钮都无关。不切块,21:9 根本跑不了。 |
| VAE 解码分块 tile_size/stride |
FlashVSR 自带的,默认开启 | 解码器一次吃不下整帧。它默认 50% 重叠是为了盖住接缝, 但重叠量给多了(见下一节)。 |
专门跑了一组 --tile-px 1e12(永不分块)的对照,其余配置逐字相同。
分块只在每帧 > 3.0 Mpx 时触发,所以 20 条片子天然分成两组:
① 阴性对照(4 条 1:1,2.36 Mpx,两边都没分块): 逐字节 md5 比对 4/4 完全相同。这一步是必须的——如果这里就不一样, 说明管线本身不确定,下面所有数字都是噪声。
| 指标(16 条 >3 Mpx 的片子) | 分块 | 不分块 | 差值 | 95% CI |
|---|---|---|---|---|
| MUSIQ ↑ | 60.9680 | 60.9355 | +0.0325 | [−0.2494, +0.2895] 跨 0 |
| CLIP-IQA ↑ | 0.5110 | 0.5110 | +0.0000 | [−0.0045, +0.0041] 跨 0 |
| MANIQA ↑ | 0.3426 | 0.3431 | −0.0005 | [−0.0019, +0.0010] 跨 0 |
| NIQE ↓ | 4.3191 | 4.3649 | −0.0458 | [−0.0758, −0.0086] |
| Downsample-LPIPS ↓ | 0.1471 | 0.1483 | −0.0012 | [−0.0028, +0.0004] 跨 0 |
切块只切长边、按 128 对齐、重叠 256 输出像素,融合用线性 ramp 互补权重。
【更正】我之前写过「重叠远大于模型 88 px 的感受野」——那个数字算错了。
正确推算:输出像素 →(VAE ÷8)→ latent →(DiT patch_size=[1,2,2] ÷2)→ token,
1 token = 16 px,1 注意力块 = 8×8 token = 128 px,local_range=11 块
→ ±640 px。重叠其实小于感受野,接缝附近确实看到截断的上下文——
但上面的全量 A/B 和接缝检测都测不出代价,原因见
学习页 5.3 节。
而且对 tiny 来说分块是双赢:2 块比整幅又快又省 34% 显存
(155.5 s / 20.13 GiB vs 163.4 s / 30.49 GiB)——注意力矩阵随边长平方增长,
切小之后省下的算力超过了跑两趟的开销。
在动任何优化之前先分块计时(FVPROF=1,每个计时器都
cuda.synchronize(),量的是真实 GPU 时间不是异步下发延迟)。
2688×1536 × 141 帧,不含落盘:
| 配置 | 总耗时 | 解码器 | DiT + 前处理 |
|---|---|---|---|
| tiny(TCDecoder) | 74.8 s | 12.3 s 16% | 62.4 s 83% |
| full(Wan VAE 解码器) | 283.4 s | 220.1 s 78% | 63.3 s 22% |
DiT 两边都是 ~63 s,两个配置的全部差距都在解码器上。 所以画质优先配置该优化的地方只有一个:那 220 秒。
FlashVSR 的 pipeline 解码时用 tile_size=(60,104), tile_stride=(30,52),
也就是 50% 重叠——每个 latent 像素大约被解码 4 次。
重叠是用来盖住 VAE 分块接缝的,问题是需要这么多吗。
| tile / stride | 重叠 | 总耗时 | 解码器 | 显存峰值 |
|---|---|---|---|---|
| 60 / 30 (管线默认) | 50% | 287.3 s | 221.7 s | 16.52 GiB |
| 60 / 44 | 27% | 177.1 s | 113.3 s | 17.03 GiB |
| 60 / 56 | 7% | 147.2 s | 81.3 s | 16.52 GiB |
| 96 / 48 | 50% | 228.1 s | 161.9 s | 19.87 GiB |
| 96 / 88 | 8% | 154.3 s | 86.0 s | 19.87 GiB |
| 128 / 120 | 6% | 139.8 s | — | 24.50 GiB |
| 160 / 152 | 5% | 179.3 s | — | 30.41 GiB |
| 256 / 248 | 3% | OOM | — | >32 GiB |
解码 221.7 s → 81.3 s,快 2.7×,显存一点没涨。 分块开大(96/128/160)反而更慢且吃显存——省时间的是减少重叠,不是加大分块。
| 指标 | 7% 重叠 | 50% 重叠 | 差值 | 95% CI |
|---|---|---|---|---|
| PSNR-Y ↑ | 24.6474 | 24.6395 | +0.0079 | [+0.0065, +0.0094] |
| SSIM-Y ↑ | 0.6708 | 0.6703 | +0.0005 | [+0.0004, +0.0006] |
| LPIPS ↓ | 0.2233 | 0.2234 | −0.0000 | [−0.0001, +0.0001] |
| DISTS ↓ | 0.1050 | 0.1053 | −0.0003 | [−0.0004, −0.0002] |
| MUSIQ ↑ | 71.4126 | 71.4389 | −0.0263 | [−0.0397, −0.0141] |
| CLIP-IQA ↑ | 0.6133 | 0.6182 | −0.0049 | [−0.0056, −0.0042] |
| NIQE ↓ | 3.1185 | 3.1157 | +0.0028 | [−0.0123, +0.0245] |
保真度略微变好(重叠融合本身就是一次轻微模糊),CLIP-IQA 掉 0.005—— 对比一下换解码器那次是掉 0.061,这里小一个数量级。 接缝专项检测:4 条片子 × 横竖两个方向 × 2 个接缝位置,相对 50% 重叠版的额外梯度 最大 3.7σ,全部低于 4σ 判定线;最大偏差点落在画面内容边缘而不是接缝上。无可见接缝。
| 配置 | eager | compile 解码器 | compile DiT | 两个都 compile |
|---|---|---|---|---|
| full + 7% 重叠 | 145.8 s | 145.7 s | 147.0 s | — |
| tiny | 78.6 s | 75.9 s | 78.6 s | 78.2 s |
编译确实生效了(日志里有 [compile] decoder compiled /
[compile] dit compiled,不是静默失败),但收益在测量噪声以内(≤3%)。原因:
dynamic=True 之下拿不到静态 shape 特化的好处。
结论:这个模型上 torch.compile 不值得开。同样是想省时间,
改一个 tile_stride 参数拿到了 2.7×,而 compile 拿到 0%——
这正是「先分块计时再决定优化目标」的价值。
整节只写 FlashVSR,不涉及任何其他模型。下文的「官方实现」一律指
FlashVSR/examples/WanVSR/infer_flashvsr_v1.1_*.py 和它自己的 pipeline,
也就是 FlashVSR 作者发布的代码;「我们的驱动」指
sr_eval/flashvsr_runner.py。分两块:怎么把一条形状奇怪的片子喂进去,
和怎么调的、每一步验了什么。
FlashVSR 对输入的形状要求很硬,而它自己的官方脚本处理不合法输入的方式是悄悄改掉你的片子,
不报错。下面每一条都会在评测里变成一个静默的错误结果,所以驱动
flashvsr_runner.py 全部改成显式处理。
| 约束 | FlashVSR 官方实现怎么做 | 我们的驱动改成怎么做 |
|---|---|---|
| 画布必须是 128 的倍数 | 目标尺寸向下取整到 128 再中心裁。1920×1056 静默变成 1920×1024, 凭空丢 32 行真实内容。 | 改成把输入边缘复制 pad 到合法画布,模型在合法尺寸上跑, 出来再裁回原始取景。一行不丢,构图不动。 |
| 帧数必须是 8k+1 | 不满足就按它自己的方式截断。 | 在尾部重复最后一帧补到 8k+1,产出后再截回原长。 |
| 只吐 num_frames−4 帧 | 静默少 4 帧,而且丢的是尾巴不是头。 | 先多喂 4 帧再截。这一条是验出来的不是猜的:把输出和真值做 ±8 帧偏移的 PSNR 扫描,峰值必须落在 0。我第一次补在头部,扫描峰值出现在 +4, 直接证明丢的是尾部。 |
| 最短时域窗口 33 帧 | 17 帧和 25 帧都会在它自己的时序窗口里
torch.cat([]) 空列表崩掉。 |
短片补到 ≥33;低于这个值直接抛明确错误,不让它崩在库里面。 |
| 窗口长度取整方向 | (我自己引入的 bug)向下取整到 8k+1。 | 必须向上取整再补帧。向下取整会让最后一窗盖不到片尾, 141 帧只出 135 帧。靠打印逐帧覆盖表(wins / produced / missing)才抓出来。 |
| scale 固定为 4 | FlashVSR 是一个 ×4 模型,不是 ×2。官方 6 个推理脚本全部是
seed, scale, dtype, device = 0, 4.0, ...;
prepare_input_tensor 和 compute_scaled_and_target_dims
的默认参数都是 scale=4;连 LQ 投影层的类名都叫
Causal_LQ4x_Proj。论文里的场景也是 540p→4K。 |
它是 pre-upsample 架构:LQ 先被 bicubic 放大到目标尺寸, DiT 才在目标分辨率上工作。所以 scale 是前处理的参数,没有烧进网络权重, 传 2 进去就是真 ×2,而不是 ×4 之后再缩回来(那会多一次重采样、白烧 4 倍算力)。 本项目全部按 scale=2 跑。 |
| 超长 / 超宽 | 整条整幅塞进去,装不下就 OOM。 | 时域窗口(重叠 8 帧,流式融合)+ 空间分块 (只切长边、128 对齐、重叠 256 px、线性 ramp 互补权重融合)。 |
空间分块只切长边、按 128 对齐、重叠 256 输出像素,线性 ramp 融合。 (更正:重叠量其实小于模型 ±640 px 的局部注意力半跨度, 我早先算成 88 px 是错的;实测无代价的原因见 学习页。) 接缝做了专项检测——不是看全局指标(1 像素的缝会被均值淹没),而是量 分块版相对整幅版多出来的水平梯度,在两个拼接位置只有 0.9σ 和 1.1σ, 全帧最大偏差落在 x=690(画面自身的边缘)而不是拼接处。没有缝。
| 旋钮 | 作用 | 实测 |
|---|---|---|
variant full→tiny | 把 Wan VAE 解码器换成 TCDecoder | 速度的大杠杆,2.4×;也是全部感知分损失的来源(见下) |
kv_ratio 3.0→1.5 | 压 DiT 的 KV 缓存 | 唯一能降 DiT 峰值的(28.7→25.8 GiB),画质完全免费;低于 1.5 不再有效 |
vae_tile | VAE 解码分块尺寸 | 只影响解码阶段峰值——但管线自带的默认值 60×104 正是 4 Mpx 解码 OOM 的元凶 |
tile_px(空间分块) | 超过多少像素就切块 | 对 tiny 是双赢:2 块比整幅又快又省 34%(注意力矩阵变小的收益超过跑两趟的开销) |
sparse_ratio / local_range | 稀疏注意力形状 | 时间和显存都没有可测量的影响,不用调 |
21:9 只有靠空间分块才成立:3584×1536 整幅送进去会死在一个 2.15 GiB 的定长分配上, 这个分配跟任何旋钮都无关,只能靠把画面切小来绕开。
调优版同时动了两个旋钮,两两对比说不清是谁的责任,所以把四格全跑了。 UDM10 / YouHQ40 每帧都在 3 Mpx 分块阈值以下,四格都没有分块,不是混淆项。
wins / produced / missing 逐帧覆盖表定位。
还有一条结论性错误:我曾经报告「FlashVSR 在 5090 上做不了 4 Mpx 输出」。
挡住它的其实是我自己按 kv 3.0 标定、后来不再适用的 px-budget 门限,不是真 OOM。
改掉门限后连原版配置都能跑 2688×1536×141。
显存这半边是实测的:用 set_per_process_memory_fraction 把分配器硬卡在
23.0 GiB(24 GB 减掉约 1 GB 的 CUDA context 等分配器外开销),torch 会在这个上限
精确抛 OOM,和小卡一样。先跑阳性对照——一个已知需 30.5 GiB 的配置在 cap 下确实 OOM,
确认 cap 真的在起作用,后面的行才有意义。
| 场景 | 结果 | 耗时 | 峰值 |
|---|---|---|---|
| 对照:15s 16:9 不分块(需 30.5 GiB) | OOM ✓ 符合预期 | — | — |
| 5s 16:9 2688×1536 | 通过 | 158.7 s | 20.13 GiB |
| 5s 21:9 3584×1536 | 通过 | 235.8 s | 22.19 GiB |
| 15s 16:9 2688×1536 | 通过 | 409.9 s | 20.13 GiB |
| 15s 21:9 3584×1536 | 通过 | 558.6 s | 22.19 GiB |
| 15s 21:9,改 3 块 | 通过 | 558.0 s | 16.44 GiB |
| 15s 21:9,改 4 块 | 通过 | 602.1 s | 14.11 GiB |
24 GB 卡上该用 3 块:和 2 块一样快(558.0 vs 558.6 s),峰值却低 5.75 GiB。
原因是 2 块贴着上限时分配器一直在刷缓存重试,给它留余量反而不慢。
kernel 侧:Block-Sparse-Attention 的 setup.py 默认 arch 里没有 89,
但源码有一段专门为 sm_86/sm_89 写的分支(is_sm8x,hdim128 非因果走 128×32、
只要 48 KB shared memory),正是为了迁就 Ada 更小的 smem;README 也把 Ada 列进 bf16 支持。
用 BLOCK_SPARSE_ATTN_CUDA_ARCHS=80 编即可(sm_80 cubin 按 CUDA 次版本二进制兼容
规则能在 sm_89 上加载)。
没有实机验证过:这里只模拟了显存预算,没有模拟 sm_89 kernel,也没有模拟 Ada 更低的
带宽——真 4090 上每一行的耗时都会更长。
这张表是背景参照,用来给 FlashVSR 的位置定标;本轮的重点是上面那组对比。
—