郭立 (leeguoo)

# 跟着 Wan2.2 源码学视频生成:从一句 prompt 到 81 帧 720p

用 Wan2.2 的真实代码把视频生成的完整原理走一遍:VAE 时空压缩、DiT 与 3D RoPE、flow matching 数学推导、CFG、MoE 双专家、条件注入、序列并行,每个概念都算出具体数字、贴出原始代码。

2026年7月16日 · 文章 · 公开

本页目录
1.三个模型,一个舞台2.第一章 VAE:视频先被压小 46 倍3.4n+1 的由来4.因果卷积和流式推理5.encoder 里面是什么6.两版 VAE7.latent 空间的"汇率"8.第二章 prompt 去了哪里9.为什么是 T5,不用 CLIP10.prompt 太短怎么办:扩写器11.第三章 DiT:75600 个 token 的注意力12.从 latent 到 token13.14B 是怎么数出来的14.一个 block 的完整前向15.3D RoPE:让 token 知道自己在哪16.QK-Norm17.一次前向的形状账18.第四章 去噪走的是一条直线19.为什么"加噪再去噪"能生成20.从 DDPM 到 flow matching21.UniPC:怎么走得又快又稳22.第五章 为什么每一步要跑两次前向23.第六章 两个 14B 专家接力24.和 LLM 的 MoE 是一回事吗25.第七章 条件注入:图生视频改了哪几行26.其他任务的条件从哪来27.第八章 75600² 是个什么概念28.第九章 模型为什么会犯它常犯的错29.第十章 症状对旋钮:一张调参速查表30.把账算完31.接下来读什么、动手做什么32.附:几个绕不开的追问33.附:术语速查

一条命令:

$ sh
python generate.py --task t2v-A14B --size 1280*720 \
  --ckpt_dir ./Wan2.2-T2V-A14B --prompt "Two anthropomorphic cats boxing"

几分钟后得到一段 81 帧、720p、16fps 的视频。这篇文章把中间发生的每一步讲透。所有代码来自 Wan2.2 仓库原文,所有数字都能从配置文件算出来,我把算式也一并写出。读完你应该能回答这些问题:

  • 为什么帧数必须是 4n+1,80 帧就不行?
  • 14B 这个参数量是怎么从配置里数出来的?80GB 显存门槛的账怎么算?
  • flow matching 和 DDPM 在数学上差在哪,为什么前者 40 步就能出片?
  • --sample_shift 12 拧的是什么旋钮,它和 MoE 专家切换有什么看不见的联动?
  • 图生视频在文生视频的骨干上到底改了哪几行?

内容不短,按章节跳读:

  1. VAE:视频怎么被压小 46 倍,4n+1 从哪来,因果卷积和流式推理
  2. 文本编码:umT5、负向 prompt、扩写器
  3. DiT 主干:75600 个 token、14B 怎么数出来、AdaLN、3D RoPE、QK-Norm、一张形状总账
  4. Flow matching:为什么加噪去噪能生成、直线路径的数学、UniPC、shift 的账
  5. CFG:两次前向的贝叶斯推导,负向 prompt 的角色
  6. MoE 双专家:boundary 与 shift 的联动、完整采样循环、和 LLM MoE 的区别
  7. 条件注入:图生视频改了哪几行,五个任务共用的三种手法
  8. 并行:attention 的 FLOPs 账、Ulysses、FSDP、显存对照表
  9. 失败模式:手指、漂移、呆滞、穿模,各自的机制归因
  10. 调参速查 + 论文与动手路径 + 术语表

三个模型,一个舞台

视频生成不是一个模型干的,是三个:

模型作用在 Wan2.2 t2v-A14B 里
文本编码器把 prompt 变成向量序列umT5-XXL 的 encoder,约 5.7B 参数
扩散主干从噪声一步步"雕"出视频的压缩表示WanModel,DiT 架构,40 层,14B 参数 × 2 个专家
VAE在像素和压缩表示之间来回翻译Wan2_1_VAE,时空压缩比 4×8×8

分工清楚:T5 只在开头跑一次,VAE 的 encoder 在 t2v 里根本用不上(没有输入图像)、decoder 只在结尾跑一次,真正烧算力的是中间的扩散主干,它要跑 40 步、每步两次前向。

三个模型怎么串起来,wan/text2video.pygenerate() 就是剧本:编码文本 → 采样噪声 → 循环去噪 → 解码成像素。这份剧本值得先记一个骨架,后面每一章都在展开它的某一行。

先解决一个前置问题:扩散为什么不直接在像素上做?算一笔账。81 帧 720p RGB 视频有

81 × 720 × 1280 × 3 ≈ 2.24 亿个数值

经过 VAE 压缩后,latent 只有

16 × 21 × 90 × 160 ≈ 484 万个数值

少了 46 倍。扩散主干要在这块数据上反复运算 80 次前向,数据小 46 倍,attention 和卷积的开销都跟着塌下来。在压缩空间里做生成、最后一步才解码回像素,这个思路叫 latent diffusion,从 Stable Diffusion 开始成为标配,视频生成把它从 2D 推广到了 3D。

第一章 VAE:视频先被压小 46 倍

wan/configs/wan_t2v_A14B.py 里两行配置决定了整条链路上所有张量的形状:

$ python
t2v_A14B.vae_stride = (4, 8, 8)    # 时间×4, 高×8, 宽×8
t2v_A14B.patch_size = (1, 2, 2)    # DiT 的 patch 大小

时间维压 4 倍,空间每维压 8 倍。text2video.py 用它算 latent 形状:

$ python
target_shape = (self.vae.model.z_dim, (F - 1) // self.vae_stride[0] + 1,
                size[1] // self.vae_stride[1],
                size[0] // self.vae_stride[2])

代入 F=81、720×1280,得到 (16, 21, 90, 160):16 个 latent 通道、21 个"latent 帧"、90×160 的空间网格。

4n+1 的由来

注意时间维的公式是 (F - 1) // 4 + 1,不是 F // 4。视频的第一帧单独编码成一个 latent 帧,之后每 4 帧压成一个。81 帧 = 1 + 80,80 恰好整除 4,得到 1 + 20 = 21 个 latent 帧。传 80 帧或 82 帧,这个除法除不尽,张量形状对不上,直接崩。frame_num 必须是 4n+1,约束的源头就在这一行。

为什么第一帧要特殊?因为压缩用的是因果卷积,第一帧前面没有历史可看,只能独立成帧。这个设计还有一个副产品:一张静态图片可以看成 F=1 的视频,恰好占一个 latent 帧,图像和视频在同一个 VAE 里统一了表示,后面讲图生视频时会用到这一点。

因果卷积和流式推理

vae2_1.pyCausalConv3d 的实现,总共十几行:

$ python
class CausalConv3d(nn.Conv3d):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._padding = (self.padding[2], self.padding[2], self.padding[1],
                         self.padding[1], 2 * self.padding[0], 0)
        self.padding = (0, 0, 0)

    def forward(self, x, cache_x=None):
        padding = list(self._padding)
        if cache_x is not None and self._padding[4] > 0:
            cache_x = cache_x.to(x.device)
            x = torch.cat([cache_x, x], dim=2)
            padding[4] -= cache_x.shape[2]
        x = F.pad(x, padding)
        return super().forward(x)

普通 3D 卷积在时间维前后对称补零,每帧的输出会"看到"未来帧。这里把时间维的 padding 全部挪到前面(2 * self.padding[0], 0,前补两倍、后补零),每帧输出只依赖当前和过去。

因果性换来的是流式处理:视频可以切成小块依次过网络,每块处理完,缓存最后 2 帧(源文件顶部的 CACHE_T = 2)作为下一块的 cache_x 拼在前面,数学上等价于整段视频一次卷完。于是 VAE 的显存占用和视频总长解耦,5 秒和 50 秒的视频,VAE 这一段的峰值显存一样。forward 里那个 padding[4] -= cache_x.shape[2] 就是在做账:缓存顶掉了多少补零,padding 就少补多少。

encoder 里面是什么

_video_vae 的配置勾出了整个网络的轮廓:

$ python
cfg = dict(
    dim=96,
    z_dim=z_dim,
    dim_mult=[1, 2, 4, 4],
    num_res_blocks=2,
    attn_scales=[],
    temperal_downsample=[False, True, True],
    dropout=0.0)

标准的分级卷积自编码器,和 Stable Diffusion 的 VAE 同宗(源码注释也这么写):基础通道 96,经 dim_mult 四级放大到 96→192→384→384;每级两个残差块;级与级之间空间下采样一次,共三次,对应 8 倍空间压缩。temperal_downsample=[False, True, True] 是时间维的安排:第一级不动时间轴,后两级各压一半,合起来 4 倍,这就是 4×8×8 压缩比在结构上的分布。瓶颈处有一个空间自注意力块,给最压缩的表示补一点全局视野,除此之外全是卷积。decoder 按同样的楼梯反着爬回去。

和扩散主干比,这个网络小得几乎不占账(百 M 级参数对 14B),但它决定了生成质量的上限:VAE 重建不出来的细节,扩散模型雕得再好也到不了像素。视频 VAE 的重建质量、压缩率、解码速度三角互斥,是各家视频模型暗中比拼的地方。

两版 VAE

Wan2.2 仓库里有两个 VAE,不通用:

Wan2.1 VAE(t2v/i2v/s2v/animate 用)Wan2.2 VAE(仅 ti2v-5B 用)
压缩比4×8×84×16×16
latent 通道1648
结构差异单路径残差块输入先 2×2 patchify 成 12 通道;下采样加了 AvgDown3D 短接路径

ti2v-5B 的 VAE 压得更狠(空间 16 倍),同样 720p 下 latent 网格更小,这是 5B 小模型能在 24GB 消费卡上跑 121 帧、24fps 的关键:模型小一半不够,数据也得小。代价是重建难度更高,所以通道数从 16 提到 48 来补信息容量。

latent 空间的"汇率"

VAE 和扩散模型是分开训练的两个系统,对接靠一组硬编码的统计量。vae2_1.py 里:

$ python
mean = [
    -0.7571, -0.7089, -0.9113, 0.1075, -0.1745, 0.9653, -0.1517, 1.5508,
    0.4134, -0.0715, 0.5517, -0.3632, -0.1922, -0.9497, 0.2503, -0.2921
]
std = [
    2.8184, 1.4541, 2.3275, 2.6558, 1.2196, 1.7708, 2.6052, 2.0743,
    3.2687, 2.1526, 2.8652, 1.5579, 1.6382, 1.1253, 2.8251, 1.9160
]
self.scale = [self.mean, 1.0 / self.std]

16 个通道各自的均值和标准差,是在训练集上统计出来的。VAE encode 的原始输出各通道量级差异很大(看 std,最小 1.13、最大 3.27,差三倍),扩散模型假设自己面对的是接近标准正态的数据,所以 latent 进扩散模型前要逐通道标准化,解码前再乘回去。这组数字是这对 VAE/扩散模型的"汇率",换一版 VAE 这 32 个数全部作废,两版 VAE 的 checkpoint 不能混用,根子在这里。

VAE 本身的训练目标是"压缩再解压后尽量还原":重建损失打底,加 KL 正则让 latent 分布规整,再加对抗损失(GAN 判别器)防止重建结果发糊。训好之后冻结,扩散模型自始至终只在它定义的 latent 空间里工作。这种解耦有个实用的好处:同一个 VAE 可以服务很多个扩散模型,Wan2.1 的 VAE 就被 Wan2.2 的四个任务直接沿用。

第二章 prompt 去了哪里

umT5-XXL 是 Google 的多语言 T5 变体,Wan2.2 只用它的 encoder。从 t5.py 的配置能算出参数量:

$ python
cfg = dict(
    vocab_size=256384, dim=4096, dim_ffn=10240,
    num_heads=64, encoder_layers=24, ...)

每层是 4 个 4096×4096 的注意力矩阵加 3 个 4096×10240 的门控 FFN 矩阵(T5 的 FFN 带 gate,三个矩阵不是两个),24 层加上 25.6 万词表的 embedding,合计约 5.7B 参数,bf16 存放约 11.4GB。

编码的调用路径很短,t5.pyT5EncoderModel.__call__ 一目了然:

$ python
def __call__(self, texts, device):
    ids, mask = self.tokenizer(
        texts, return_mask=True, add_special_tokens=True)
    seq_lens = mask.gt(0).sum(dim=1).long()
    context = self.model(ids, mask)
    return [u[:v] for u, v in zip(context, seq_lens)]

分词、编码、再按 attention mask 的真实长度逐条裁掉 padding。prompt 变成一串最长 512 个的 4096 维向量,这就是 context。它在 DiT 里的用法只有一个:每层的 cross-attention 拿视频 token 当 query、拿 context 当 key/value。文本不直接画任何像素,它在每一步去噪时持续给模型指语义方向。

为什么是 T5,不用 CLIP

早期文生图(SD 1.x/2.x)用 CLIP 的文本塔编码 prompt。CLIP 的训练目标是图文配对,它的文本向量擅长"这段话大概在说什么",但对长句、数量词、空间关系的表达很弱,77 token 的长度上限也塞不下细致的场景描述。T5 是纯语言模型,按文本自身的结构逐 token 编码,512 的长度上限配合逐 token 的 cross-attention,模型可以对"左边的猫戴红色拳套"这类细节做出区分。从 Imagen 开始,大 T5 逐渐取代 CLIP 成为生成模型的文本骨干,Wan2.2 选多语言版 umT5 还有一层考虑:中文 prompt 是一等公民,官方负向 prompt 干脆就是中文写的。

有个值得知道的细节:umT5 的相对位置编码是逐层独立的(配置里 shared_pos=False),24 层各算一份位置 bias,标准 T5 只算一次全层共享。多语言模型的设计取舍,读代码时容易误认成 bug。

同时编码的还有一段负向 prompt。配置里的默认值是一长串中文,原文如下:

$ python
wan_shared_cfg.sample_neg_prompt = '色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走'

一份"常见翻车现场"清单。它的用途到 CFG 那一章再讲,这里先记住:负向 prompt 也要过一遍 T5,得到 context_null

prompt 太短怎么办:扩写器

用户随手写的 prompt 往往只有一句话,而模型是在长而细的描述上训练的,喂太短的 prompt 出片质量会打折。--use_prompt_extend 打开一个前置环节:先拿一个 LLM 把你的 prompt 扩写成带镜头、光线、动作细节的长描述,再送 T5。两个后端可选,DashScope 在线 API(qwen-plus)或本地加载的 Qwen2.5,扩写用的系统提示词按任务和语言从 system_prompt.py 里取。

多卡时有个工程细节:扩写只在 rank 0 上跑一次,结果用 dist.broadcast_object_list 广播给其他卡。LLM 生成带随机性,8 张卡各扩写一遍会得到 8 个不同的 prompt,视频就没法拼了,广播保证全体用同一份。

第三章 DiT:75600 个 token 的注意力

进入主干之前,值得先回答一个更根本的问题:视频模型和图像模型的本质差别在哪?

答案不是"多了一个维度"这么轻描淡写。逐帧调用图像模型生成 81 张图,每帧单独看都精美,连起来播放就是一场灾难:物体形状帧帧漂移、光照跳变、背景闪烁,行话叫 flicker。时间一致性必须由模型显式建模。业界走过两条路:分解式注意力(空间 attention 和时间 attention 分开做,先让每帧内部自洽,再沿时间轴对齐,便宜但一致性有天花板)和全 3D 联合注意力(所有帧的所有 patch 放进同一个注意力里,任何 token 直接看到任何时刻的任何位置,一致性最强,代价是序列长度爆炸)。Wan2.2 走的是第二条,这个选择直接导出了本章的主角:一个 75600 token 的序列。

从 latent 到 token

latent 进 DiT 前先切 patch。model.py 里就是一个步长等于核大小的 Conv3d:

$ python
self.patch_embedding = nn.Conv3d(
    in_dim, dim, kernel_size=patch_size, stride=patch_size)

patch_size = (1, 2, 2):时间维不切,空间上每 2×2 的 latent 块卷成一个 5120 维的 token。数一下总量:

每个 latent 帧的 token 数 = 90 × 160 / (2×2) = 3600
总 token 数              = 3600 × 21 帧     = 75600

75600,这是生成一段 5 秒 720p 视频时 self-attention 的序列长度。SDXL 生成一张 1024 图的序列长度是 4096,视频比图像长 18 倍。attention 的计算量随序列长度平方增长,18 倍长度就是 340 倍的 attention 开销,后面讲的一切显存问题、序列并行,根源都是这个数字。

核心模型模块

14B 是怎么数出来的

配置给出 dim=5120, ffn_dim=13824, num_layers=40。每层的参数大头:

self-attention:  Q/K/V/O 四个矩阵 = 4 × 5120² ≈ 1.05 亿
cross-attention: 同样四个矩阵     = 4 × 5120² ≈ 1.05 亿
FFN:             两个矩阵         = 2 × 5120 × 13824 ≈ 1.42 亿
每层合计 ≈ 3.51 亿,× 40 层 ≈ 14.05B

"A14B"里的 14B 就是这么来的,embedding 和调制参数只占零头。bf16 每参数 2 字节,一个专家 28GB;t2v-A14B 有两个专家(下一章讲为什么),光 DiT 权重就是 56GB,加上 T5 的 11.4GB、VAE 和采样过程的激活,80GB 的单卡门槛就是这么挤满的。

一个 block 的完整前向

WanAttentionBlock.forward 是整个模型的最小重复单元,值得逐行读:

$ python
def forward(self, x, e, seq_lens, grid_sizes, freqs, context, context_lens):
    with torch.amp.autocast('cuda', dtype=torch.float32):
        e = (self.modulation.unsqueeze(0) + e).chunk(6, dim=2)

    # self-attention
    y = self.self_attn(
        self.norm1(x).float() * (1 + e[1].squeeze(2)) + e[0].squeeze(2),
        seq_lens, grid_sizes, freqs)
    with torch.amp.autocast('cuda', dtype=torch.float32):
        x = x + y * e[2].squeeze(2)

    # cross-attention & ffn
    def cross_attn_ffn(x, context, context_lens, e):
        x = x + self.cross_attn(self.norm3(x), context, context_lens)
        y = self.ffn(
            self.norm2(x).float() * (1 + e[4].squeeze(2)) + e[3].squeeze(2))
        with torch.amp.autocast('cuda', dtype=torch.float32):
            x = x + y * e[5].squeeze(2)
        return x

    x = cross_attn_ffn(x, context, context_lens, e)
    return x

三段结构:Self-Attention(75600 个 token 互相看,建立时空一致性)、Cross-Attention(看文本 context,注入语义)、FFN。真正有讲头的是那个 e,它被 chunk 成 6 份,分别是:

用在哪作用
e[0], e[1]self-attn 前的 normshift 和 scale,norm(x) * (1+scale) + shift
e[2]self-attn 输出gate,控制这一支往残差里加多少
e[3], e[4]FFN 前的 norm同上一组 shift/scale
e[5]FFN 输出gate

这 6 组参数从哪来?追踪时间步 t 的完整旅程。t 是一个标量(比如 927),先过正弦编码:

$ python
def sinusoidal_embedding_1d(dim, position):
    half = dim // 2
    position = position.type(torch.float64)
    sinusoid = torch.outer(
        position, torch.pow(10000, -torch.arange(half).to(position).div(half)))
    x = torch.cat([torch.cos(sinusoid), torch.sin(sinusoid)], dim=1)
    return x

和 Transformer 位置编码同款:一组从高到低的频率,标量 t 被展开成 256 维的 cos/sin 波形。为什么不直接把 t 当一个数喂进网络?因为神经网络对单个标量的分辨率很差,正弦展开让"t=927 和 t=925 的区别"以及"t=900 和 t=100 的区别"都落在网络容易感知的尺度上,高频通道管细粒度、低频通道管粗粒度。

然后过 MLP 和投影:

$ python
self.time_embedding = nn.Sequential(
    nn.Linear(freq_dim, dim), nn.SiLU(), nn.Linear(dim, dim))
self.time_projection = nn.Sequential(nn.SiLU(), nn.Linear(dim, dim * 6))

dim * 6 就是那 6 份调制。这套手法叫 AdaLN(adaptive layer norm),DiT 论文的核心贡献之一:不把 t 当 token 拼进序列,而是让 t 去调制每一层归一化的尺度、偏移和残差门控。直觉上,t=999 时输入几乎是纯噪声,网络该大刀阔斧;t=10 时画面基本成形,网络只该微调。同一套权重,靠这 6 组随 t 变化的参数切换行为模式。DiT 论文对比过"t 当 token 拼接"和"AdaLN 调制"两种方案,后者在同算力下 FID 明显更好,此后成了扩散 transformer 的默认写法。

代码里还有个容易忽略的细节:所有调制运算都包在 autocast float32 里,主干可以跑 bf16,但调制这几步强制全精度。去噪是几十步的迭代过程,调制参数的数值误差会逐步累积,这是作者花显存买稳定的地方。

3D RoPE:让 token 知道自己在哪

75600 个 token 排成一维序列,attention 本身不知道谁挨着谁。位置信息由 RoPE(旋转位置编码)注入,视频版的特殊之处是坐标有三个轴。构造时把每个头的 128 维切成三段:

$ python
d = dim // num_heads          # 5120 // 40 = 128
self.freqs = torch.cat([
    rope_params(1024, d - 4 * (d // 6)),   # 时间轴: 44 维
    rope_params(1024, 2 * (d // 6)),       # 高度轴: 42 维
    rope_params(1024, 2 * (d // 6))        # 宽度轴: 42 维
], dim=1)

应用时,rope_apply 把 (f, h, w) 三个坐标各自的旋转量拼成完整的 128 维旋转,用复数乘法一次施加:

$ python
freqs_i = torch.cat([
    freqs[0][:f].view(f, 1, 1, -1).expand(f, h, w, -1),
    freqs[1][:h].view(1, h, 1, -1).expand(f, h, w, -1),
    freqs[2][:w].view(1, 1, w, -1).expand(f, h, w, -1)
], dim=-1).reshape(seq_len, 1, -1)

x_i = torch.view_as_real(x_i * freqs_i).flatten(2)

三个 expand 把一维频率表广播到整个 (f, h, w) 网格:同一时刻不同位置的 token,时间段旋转相同、空间段不同;同一位置不同时刻则反过来。attention 算 q·k 内积时,旋转的相对性让内积只依赖两个 token 的坐标差,平移不变性是白送的。这是图像 2D RoPE 向视频的直接推广,44/42/42 的分配意味着时间轴拿到的频率通道和单个空间轴差不多,时间关系和空间关系被同等对待。

细节:rope_apply 全程 float64 并且显式关闭 autocast(函数头上的 @torch.amp.autocast('cuda', enabled=False)),旋转是纯几何运算,半精度的相位误差在长序列上会放大,又一处花算力买数值稳定的取舍。

QK-Norm

WanSelfAttention 里 q 和 k 过线性层后各接一个 RMSNorm:

$ python
q = self.norm_q(self.q(x)).view(b, s, n, d)
k = self.norm_k(self.k(x)).view(b, s, n, d)

这是近两年大模型训练的标配技巧:q·k 内积的方差不受控时,softmax 会饱和、注意力熵崩塌,训练发散。把 q、k 各自归一化,内积的量级就被锁住。推理代码里看不出它的价值,但没有它,14B 规模的 DiT 很难训稳。

模型出口是一个带 2 组调制参数的 Head(只有 shift/scale,没有 gate),把 5120 维投回 patch_size 体积 × 16 维,再 unpatchify 折回 latent 形状。

一次前向的形状账

把整章串成一张表,数据从进模型到出模型的每次变形(t2v-A14B,81 帧 720p,单卡):

阶段张量形状说明
带噪 latent16 × 21 × 90 × 160第一章 VAE 定义的空间
patch_embedding(Conv3d)5120 × 21 × 45 × 80空间 2×2 卷成一个位置
展平成序列75600 × 512021×45×80 = 75600
40 × WanAttentionBlock75600 × 5120形状不变,内容反复精炼;每层内部 q/k/v 拆成 40 头 × 128 维,RoPE 只旋转 q 和 k
Head75600 × 6464 = patch 体积 (1×2×2) × 16 通道
unpatchify16 × 21 × 90 × 160折回 latent 形状

进什么形状,出什么形状,中间是 40 层等宽的精炼。输出的语义是"速度"(下一章展开),和输入 latent 逐点对应:每个位置的值指示"这个位置的内容该往哪个方向挪"。文本 context(512 × 4096 经 text_embedding 投到 512 × 5120)全程只作为 cross-attention 的 key/value 存在,不占序列位置。

至此,"给一个带噪 latent 和一个 t,吐一个同形状的预测"这台机器就完整了。它预测的到底是什么,下一章讲。

第四章 去噪走的是一条直线

为什么"加噪再去噪"能生成

先补一层直觉。生成模型要解决的问题是:从"自然视频的分布"里采样一个新样本。这个分布没有解析形式,直接采样无从下手。扩散类方法的思路是修一条路:一头是自然数据,一头是标准高斯噪声(后者随便采),再训一个网络学会沿这条路从噪声端走回数据端。加噪过程就是修路,它把复杂分布和简单分布连了起来;去噪网络是在这条路的每个位置学"往数据方向该怎么走"。训练时网络见过海量"位置 → 方向"的样本,推理时从随机噪声出发按学到的方向场走,终点就是一个看起来像训练数据、但不等于任何一条训练数据的新样本。prompt 通过 cross-attention 参与每一步方向判断,把终点约束在"符合这段描述"的区域里。

从 DDPM 到 flow matching

经典 DDPM 这样定义这条路:前向过程按精心设计的方差表逐步加高斯噪声,反向过程训网络预测每步加进去的噪声,采样时按贝叶斯公式一步步剥。它能 work,但路径是随机游走出来的曲线,采样要上百步,方差调度表本身也是个需要调的超参。

flow matching(Wan2.2 用的是其中最简的 rectified flow)把问题改得直白:干净数据 x₀ 和纯噪声 x₁ 之间,直接用直线插值定义中间态,

x_t = (1 - t)·x₀ + t·x₁ ,  t ∈ [0, 1]

沿这条直线,任意时刻的"速度"是常数:

dx_t/dt = x₁ - x₀

训练目标于是简单到能写成四行伪代码:

x0 = 采一段真实视频的 latent
x1 = 采一份标准高斯噪声
t  = 均匀采一个时间 ∈ [0, 1]
loss = || model((1-t)·x0 + t·x1, t, text) - (x1 - x0) ||²

没有方差调度表,没有 ELBO 推导,回归一个向量场。仓库里只有推理代码,但训练循环就长这样,模型的全部本事来自在亿级视频片段上重复这四行。

推理时反着走:从纯噪声 x₁ 出发,沿网络预测的速度做数值积分,积回 x₀。理想情况下轨迹是直线,一步就能到;实际上网络的速度场并不完美,走一步之后所处的位置对应的真实速度已经变了,所以还是要分步走、走一步问一次路。但直线先验让弯路远少于 DDPM 的随机游走,40 步出片,DDPM 时代同样质量常要上百步。

代码里能直接看到这套数学。fm_solvers_unipc.py 的核心一行:

$ python
if self.config.prediction_type == "flow_prediction":
    sigma_t = self.sigmas[self.step_index]
    x0_pred = sample - sigma_t * model_output

推导很短:把插值式里的 t 记作 σ,x_t = (1-σ)x₀ + σx₁,模型输出 v = x₁ - x₀,那么

x_t - σ·v = (1-σ)x₀ + σx₁ - σx₁ + σx₀ = x₀

当前样本减去 σ 倍的速度预测,恰好就是干净数据的估计。一行代码,一行代数。

UniPC:怎么走得又快又稳

有了速度场,还差一个数值积分器。最朴素的是欧拉法:

x ← x - Δσ · v(x, σ)

一阶精度,每步假设速度在整个步长内不变。速度场实际是弯的,步子越大,直线近似偏得越远,误差随步长线性积累。要么多走小步(慢),要么用更聪明的积分器。

数值分析里成熟的思路是利用历史:速度在最近几步的变化趋势,能外推出它接下来怎么弯。Wan2.2 默认的 UniPC 是一个多步预测-校正器,每步两个动作:

  • predictor:用最近 solver_order 步的模型输出拟合速度的变化,外推着走下一步(类似 Adams 多步法,配置 solver_order=2,实际精度到三阶)
  • corrector:新位置的模型输出算出来之后,回头用它把刚才那一步重新修一遍,白捡一阶精度,不多花前向

对比一下:同样 40 次模型调用,欧拉法就是 40 个一阶步,UniPC 每步的局部误差是三阶小量。工程上的意义是把"可接受质量"的步数预算从上百砍到几十,视频生成每步 2 次 14B 前向,省下的每一步都是真金白银。这也是为什么各家推理框架的采样器列表越来越长:模型不动,换积分器就能白拿速度。

调度器还负责决定 40 个采样点落在哪。set_timesteps 先均匀铺点再做 shift 变换:

$ python
sigmas = shift * sigmas / (1 + (shift - 1) * sigmas)

这个变换把点往高噪声端(σ 大的一端)挤。挤到什么程度,直接算。40 步、boundary 取 t≥875 和 t≥500 两条线数一数:

shift中位时间步t ≥ 875 的步数t ≥ 500 的步数
1(不挤)5136 / 4021 / 40
584017 / 4034 / 40
12(t2v 默认)92726 / 4037 / 40

shift=12 时,一半的采样点挤在 t>927 的区间里,37 步都花在前半程。真实的时间步序列开头长这样(shift=12、40 步,自己跑一遍 set_timesteps 能验证):

1000.0, 997.9, 995.6, 993.3, 990.8, 988.2, ...

前几步的间距只有 2 上下,走得极密;到低噪声段,间距拉大到几十,几步跨完。

顺便厘清 1000 和 40 这两个数的关系,初学最容易混:训练时时间轴被离散成 1000 个刻度(num_train_timesteps=1000),模型在全部刻度上都见过样本;推理时只是从这把尺子上挑 40 个点停靠,每个点上模型照常工作。步数是采样期的自由参数,改 --sample_steps 不需要动模型,50 步、20 步都合法,只是积分精度不同。这也是"蒸馏加速"类工作的入口:既然模型在每个刻度都会走,能不能训它一步走得更远,把 40 步压到 4 步,业界的少步蒸馏就在做这件事。

为什么这样分配?高噪声阶段决定构图、物体和运动走向,是"从无到有"的阶段;低噪声阶段只是磨细节。把预算倾斜给难的阶段,是视频生成比图像更需要的取舍:图像的 shift 一般取 3 左右,视频的运动一致性难得多,Wan2.2 直接拉到 12。同仓库里 i2v 用 5.0(首帧已经给定,结构压力小),ti2v-5B 用 5.0,s2v 用 3,每个任务的 shift 都在讲自己的难度分布。

--sample_shift 不改变步数,只改变步数怎么分配。调它等于在"结构更稳"和"细节更精"之间挪预算。

第五章 为什么每一步要跑两次前向

采样循环的核心(text2video.py):

$ python
noise_pred_cond = model(latent_model_input, t=timestep, **arg_c)[0]
noise_pred_uncond = model(latent_model_input, t=timestep, **arg_null)[0]

noise_pred = noise_pred_uncond + sample_guide_scale * (
    noise_pred_cond - noise_pred_uncond)

同一个 latent、同一个 t,跑两次:一次带你的 prompt(arg_c 里是 context),一次带负向 prompt(arg_null 里是 context_null)。这就是 classifier-free guidance(CFG)

原理值得展开一层数学。生成"符合条件 c 的样本",按贝叶斯公式拆:

p(x|c) ∝ p(x) · p(c|x)

取对数求梯度(score):

∇log p(x|c) = ∇log p(x) + ∇log p(c|x)

第一项是无条件方向(数据本身长什么样),第二项是"让 x 更像属于条件 c"的分类器梯度。早期做法是专门训一个噪声鲁棒的分类器来提供第二项(classifier guidance),又贵又难训。CFG 的洞察是这一项可以白嫖:模型同时学过条件和无条件预测(训练时随机丢掉文本即可),两者相减,

∇log p(c|x) = ∇log p(x|c) - ∇log p(x) ≈ cond 预测 - uncond 预测

分类器梯度就藏在两次前向的差值里。采样时把这一项放大 w 倍:

最终方向 = uncond + w · (cond - uncond)

对照前面贴的三行代码,一模一样,sample_guide_scale 就是 w。

w 怎么选是纯粹的权衡。w=1 退化成条件生成,画面自然但常常"没听懂"prompt;w 拉高,模型被强推向"分类器更确信"的区域,构图服从性上升,代价是色彩过饱和、细节焦糊、动作僵硬,因为 latent 被推离了训练分布的高密度区。图像模型常用 7 上下,视频对时序自然度更敏感,Wan2.2 只用 3.0~4.0。

负向 prompt 在这套推导里的位置也清楚了:标准 CFG 的 uncond 用空文本,Wan2.2 换成那串"过曝、变形、三条腿"的描述,差值方向从"贴近你的 prompt"变成"贴近你的 prompt 且远离这些毛病",一次前向同时干两件事。

负向 prompt 在这套机制里的角色也清楚了:uncond 那一次前向用的不是空文本,是"低质量、过曝、变形"那串描述。差值方向变成"远离这些毛病、靠近你的描述",一箭双雕。

成本同样清楚:40 步 × 2 次前向 = 80 次 14B 模型的完整推理。视频生成慢,一半的账记在 CFG 头上。有些后来的模型用 guidance 蒸馏把两次前向合成一次,Wan2.2 没做,老老实实跑两遍。

调度器与 Prompt 扩展

第六章 两个 14B 专家接力

Wan2.2 A14B 系列的招牌设计:两个 14B 专家分工跑不同的噪声区间。配置里:

$ python
t2v_A14B.boundary = 0.875
t2v_A14B.sample_guide_scale = (3.0, 4.0)  # low noise, high noise

采样循环每步判断 t >= 0.875 × 1000:成立用 high_noise_model,否则用 low_noise_model。设计动机接着上一章的观察:高噪声阶段和低噪声阶段的任务差异极大,前者从混沌里定结构,后者在半成品上磨纹理,一个网络要同时精通两件事,不如两个网络各精一件。训练时两个专家也分别在各自的噪声区间上训,推理时按 t 接力。

这就是官方"27B 总参数、14B 激活"的含义:容量是两倍,但任何一步只有一个专家在跑,算力成本等于 14B 稠密模型。MoE 这个词通常指 token 级路由(每个 token 选不同的 FFN 专家),Wan2.2 的用法是时间步级路由,粒度粗得多,但省掉了路由器,实现只有一个 if。

把完整的采样循环贴出来,前面各章讲的所有零件在这 20 行里合体:

$ python
for _, t in enumerate(tqdm(timesteps)):
    latent_model_input = latents
    timestep = torch.stack([t])

    model = self._prepare_model_for_timestep(
        t, boundary, offload_model)
    sample_guide_scale = guide_scale[1] if t.item(
    ) >= boundary else guide_scale[0]

    noise_pred_cond = model(
        latent_model_input, t=timestep, **arg_c)[0]
    noise_pred_uncond = model(
        latent_model_input, t=timestep, **arg_null)[0]

    noise_pred = noise_pred_uncond + sample_guide_scale * (
        noise_pred_cond - noise_pred_uncond)

    temp_x0 = sample_scheduler.step(
        noise_pred.unsqueeze(0), t,
        latents[0].unsqueeze(0),
        return_dict=False, generator=seed_g)[0]
    latents = [temp_x0.squeeze(0)]

每轮五件事:选专家、选 guide scale、两次前向、CFG 合成、调度器走一步。_prepare_model_for_timestep 里还有单卡救命的 offload 逻辑:

$ python
if offload_model or self.init_on_cpu:
    if next(getattr(self, offload_model_name).parameters()).device.type == 'cuda':
        getattr(self, offload_model_name).to('cpu')
    if next(getattr(self, required_model_name).parameters()).device.type == 'cpu':
        getattr(self, required_model_name).to(self.device)

开了 --offload_model True,每次专家切换时把下岗的搬回内存、上岗的搬上显卡。28GB 权重过一次 PCIe 要好几秒,好在整个采样过程只切换一次(t 单调下降,跨过 boundary 就不回头),这个代价一次性付清。

boundary 和 shift 咬合得很紧,这是调参时最容易看漏的联动。0.875 字面上像"只有 12.5% 的时间步归高噪声专家",但上一章的表格算过:shift=12 把采样点狠狠挤向高噪声端,t ≥ 875 的实际有 26 步,占 65%。预算的大头给了定结构的高噪声专家,低噪声专家用剩下 14 步收尾。单独调 --sample_shift 而不想 boundary,两个专家的工作量分配会静默变化,生成质量的波动会让你摸不着头脑。

两档 guide_scale 也是为分工服务的:高噪声阶段用 4.0(结构阶段贴紧 prompt),低噪声阶段降到 3.0(细节阶段少些强推,画面更自然)。

显存账重算一遍:两个专家 bf16 权重 56GB,T5 encoder 11.4GB,VAE 和激活、KV 若干,80GB 卡刚好挤下。单卡跑不动时的三件套,每一件都对着账上的一项:--offload_model True 把当前不用的专家搬回内存(砍掉 28GB 常驻)、--t5_cpu 让 T5 待在内存里只在编码时上卡(砍 11.4GB)、--convert_model_dtype 压低权重精度。

显存三件套怎么组合,README 的示例命令给了官方答案:80GB 卡跑 A14B 什么都不加;单张消费卡跑 A14B 加 --offload_model True --convert_model_dtype;4090 跑 ti2v-5B 三件全加(--t5_cpu 也上,5B 场景 T5 的 11.4GB 占比反而最大)。README 还有一句反向提示:80GB 以上显存时把这些 flag 去掉,能明显提速,offload 不是免费的。

最后厘清一个高频误会:每个 timestep 只有一个专家登场,那两次前向是 CFG 的 cond/uncond,专家从不在同一步同时工作。"每步双前向"和"双专家"是两件正交的事。

和 LLM 的 MoE 是一回事吗

不是,值得对比着记:

LLM 的 MoE(如 Mixtral)Wan2.2 的双专家
路由粒度每个 token、每层每个 timestep,整个模型
路由依据学出来的 router 网络一行 if,阈值写死在配置
专家数量每层 8 个或更多全局 2 个
专家差异训练中自发分化,人不可解释人为按噪声区间分工,语义明确
切换开销无(同在显存里)offload 模式下要过一次 PCIe

共同点只有那句宣传语:总参数大、激活参数小。Wan2.2 这种粗粒度版本牺牲了路由的灵活性,换来两个好处:不用训 router(扩散模型的时间步天然就是难度标签),以及两个专家可以独立训练、独立换代。读代码时别被"MoE"这个词带偏,它的全部实现就是 _prepare_model_for_timestep 里那个 if。

第七章 条件注入:图生视频改了哪几行

学会了 t2v,其他任务就是"往同一个骨干上加条件"的练习题。加条件的方式无非三种:

  1. cross-attention:文本走的路,条件作为 key/value 被注意
  2. 通道拼接:条件张量直接和噪声 latent 在通道维拼起来,一起进 patch embedding
  3. AdaLN:条件混进时间步 embedding,调制每层的 norm

图生视频(i2v-A14B)用的是第二种。看 image2video.py 里怎么构造条件:

$ python
msk = torch.ones(1, F, lat_h, lat_w, device=self.device)
msk[:, 1:] = 0
msk = torch.concat([
    torch.repeat_interleave(msk[:, 0:1], repeats=4, dim=1), msk[:, 1:]
], dim=1)
msk = msk.view(1, msk.shape[1] // 4, 4, lat_h, lat_w)
msk = msk.transpose(1, 2)[0]
...
y = torch.concat([msk, y])

逐行拆。msk 是"哪些帧已知"的标记:第一帧(输入图像)标 1,其余 80 帧标 0。接下来那串 repeat_interleave 加 reshape 在干一件事:把 81 帧的像素级 mask 折算到 21 帧的 latent 时间轴上。还记得 VAE 的压缩规则吗,第一帧独占一个 latent 帧、之后每 4 帧并一个。mask 也要按同样的规则折叠:把第一帧复制 4 份凑齐分组,再按每 4 帧一组折起来,得到和 latent 对齐的 4×21×h×w。

y 则是输入图像本身过 VAE encoder 的结果(不足的帧用零补齐)。mask 和 y 拼起来共 20 个通道,和 16 通道的噪声 latent 在通道维再拼一次,进模型。model.py 的 forward 开头:

$ python
if y is not None:
    x = [torch.cat([u, v], dim=0) for u, v in zip(x, y)]

i2v 版的 WanModel 的输入通道数是 36(16 噪声 + 16 图像 latent + 4 mask),patch embedding 的第一层卷积比 t2v 版宽,其余 40 层结构完全一样。图生视频对骨干的全部改动,就是输入通道从 16 变 36。

模型从这 36 个通道里学到的行为是:mask=1 的位置照抄给定内容,mask=0 的位置生成,并保证两者时空连贯。第一帧钉死了画面的主体和风格,后 80 帧围绕它展开运动,这就是"让图片动起来"的机制。

其他任务的条件从哪来

同样的思路读其余三个任务,重点看"条件是什么、怎么编码、从哪个口子进模型":

ti2v-5B(文图统一)。同一个 5B 骨干,配 4×16×16 的高压缩 VAE。它的巧妙之处是不改架构就同时支持 t2v 和 i2v:给了图,就把图编码后钉进 latent 的第一帧位置,并用 mask 标记"这一帧是已知的";没给图,纯噪声起步。已知帧和待生成帧在同一批次里用不同的有效 timestep(已知帧永远当作"已去噪完成"),一个模型两副面孔。

s2v-14B(语音驱动)。条件是音频。一个 wav2vec2 编码器把波形变成逐帧特征,再按"音频帧率 → 视频帧率"重新分桶对齐,每个视频帧拿到自己那一小段音频特征,注入模型驱动口型和肢体节奏。视频长度不再由 frame_num 决定,而是跟着音频长度走,超长音频切成多段自回归生成:每段拿上一段结尾几帧的 latent 当"动量条件",保证段与段之间动作连贯。这是仓库里唯一的长视频方案,值得单独研究。

animate-14B(角色动画/替换)。条件最重的任务:骨骼姿态序列(驱动动作)、人脸特征序列(驱动表情)、参考图(锁定角色长相,走 CLIP 视觉编码进 cross-attention)、替换模式下还有背景视频和 mask。预处理脚本先从源视频里提取这些素材,推理时多路条件各走各的口子进模型。

条件来源五花八门,注入手法翻来覆去就是那三种:通道拼接(空间对齐的条件,如 mask、姿态、背景)、cross-attention(全局语义条件,如文本、参考图 CLIP 特征)、逐帧特征注入(时序对齐的条件,如音频)。看懂一个任务,其余的都是排列组合。

第八章 75600² 是个什么概念

多卡并行为什么对视频生成格外重要,把 attention 的账算细一点。单层 self-attention 的核心计算是 QK^T 和 AV 两次矩阵乘,FLOPs 约为

2 × 2 × L² × dim = 4 × 75600² × 5120 ≈ 1.17 × 10¹⁴

一层 117 TFLOPs,40 层、80 次前向,仅 attention 一项就是 3.7 × 10¹⁷ FLOPs 量级。一张 A100 的 bf16 峰值算力约 312 TFLOPS,理想利用率下这部分也要跑二十分钟,还没算 FFN。这就是"5 秒视频要生成几分钟到几十分钟"的物理底账。

单卡扛不住,就切。Wan2.2 的主力方案是 Ulysses 序列并行,核心通信原语在 distributed/util.py,一个维度交换:

$ python
def all_to_all(x, scatter_dim, gather_dim, group=None, **kwargs):
    world_size = get_world_size()
    if world_size > 1:
        inputs = [u.contiguous() for u in x.chunk(world_size, dim=scatter_dim)]
        outputs = [torch.empty_like(u) for u in inputs]
        dist.all_to_all(outputs, inputs, group=group, **kwargs)
        x = torch.cat(outputs, dim=gather_dim).contiguous()
    return x

8 卡跑一遍流程,跟踪张量形状(B=1,L=75600,40 头):

阶段每卡持有
进入 block 前,序列被切L=9450,40 头
all_to_all(scatter 头维, gather 序列维)L=75600,5 头
本地 flash attention完整序列,1/8 的头
all_to_all 换回L=9450,40 头

attention 要求每个 token 看到完整序列,所以不能简单切序列了事;Ulysses 的答案是"切头不切序列":通信两次,换来每卡只算 1/8 的注意力头,attention 的计算和激活显存都降到 1/8。头数必须整除卡数(40 头配 8 卡正好),generate.py 里有对应的 assert。

序列长度这边也有一个为多卡准备的细节,第一章算 latent 形状时跳过了它。text2video.py 里 seq_len 的完整算式是:

$ python
seq_len = math.ceil((target_shape[2] * target_shape[3]) /
                    (self.patch_size[1] * self.patch_size[2]) *
                    target_shape[1] / self.sp_size) * self.sp_size

尾巴上那对 ceil(... / sp_size) * sp_size 是把 token 数向上取整到卡数的整数倍:75600 恰好整除 8,这里不变;换个分辨率算出 75601 个 token,就补到 75608,让每张卡分到等长的切片。单卡时 sp_size=1,这段是恒等变换,读单卡代码时看不出它的用意,多卡语境下才显形。

通信的代价也算得出来。一个完整的 q 张量是 75600 × 5120 个 bf16,约 0.77GB;每层 q/k/v/输出四次 all_to_all,约 3.1GB 数据在卡间搬运,40 层 × 80 次前向累计约 10TB。听着吓人,NVLink 的卡间带宽是每秒几百 GB 量级,摊到整个生成过程里,通信时间远小于计算时间,这买卖划算。换到没有 NVLink、走 PCIe 的机器,同样的账就不一定划算了,多卡加速比会明显缩水。

顺带一提这条路线的竞品:Ring Attention 走"切序列、K/V 绕圈传"的路子,序列可以切到任意长(不受头数整除限制),但通信次数多、实现复杂。Ulysses 实现简单、通信少,上限是卡数不能超过头数。Wan2.2 选了后者,40 个头够 8 卡机器用,再往上扩(比如 64 卡集群)这套方案就得换。

多卡分布式推理策略

参数放不下是另一条轴:FSDP(fully sharded data parallel)把模型权重切成 N 份分驻各卡,前向算到某一层时,全体卡先 all-gather 把这层参数凑齐,算完立即释放,只保留自己那 1/N 份。8 卡下每卡的 DiT 权重常驻从 56GB 降到 7GB,代价是每层一次 all-gather 通信,NVLink 机器上这个开销可以和计算重叠掉大半。

把单卡和 8 卡的显存账并排放一起看:

项目单卡(不 offload)8 卡(fsdp + ulysses)
两个 DiT 专家权重(bf16)56 GB7 GB / 卡
T5 encoder 权重11.4 GB分片或 --t5_cpu 后趋近 0
attention 激活全序列1/8 序列
结论80GB 卡贴边中端卡即可

序列并行管算力,FSDP 管显存,两者正交,--dit_fsdp --ulysses_size 8 可以同时开。README 里 8 卡命令把它们全带上,不是随手写的,每个 flag 都对着账上的一行。

第九章 模型为什么会犯它常犯的错

视频生成的典型翻车,大都能用前面的原理归因。这一章把"现象"和"机制"连上,比背 checklist 有用。

手指、文字、精细结构崩坏。两级压缩合谋的结果。VAE 把 8×8 像素压成一个 latent 位置,patchify 再把 2×2 latent 合成一个 token,一个 token 管着 16×16 像素。手指宽度经常就十几个像素,整根手指落在一两个 token 里,attention 再强也无从在 token 内部分辨五根手指的边界。文字更惨,笔画是像素级的。解药只有降低压缩比或提高分辨率,两者都直接撞算力墙,这是当前这代架构的结构性短板,不是训练数据的锅。

长视频后段漂移。t2v 一次性生成 81 帧,帧间一致性由全 3D attention 硬保证,5 秒内没问题。更长的视频(s2v 的分段自回归)靠"上一段结尾几帧"传递状态,每段传递都是有损的:误差逐段累积,角色的脸慢慢变样、衣服颜色慢慢漂。和 LLM 长文本的漂移同构,自回归的原罪。

运动幅度小、镜头呆。多重因素往同一个方向压:CFG 把 latent 推向"分类器最确信"的区域,而静态构图永远比大动作"安全";训练数据里平缓镜头占多数;负向 prompt 里"静态、静止"这些词就是在对抗这个倾向(注意默认负向 prompt 里"静止不动的画面"出现了,作者显然被这个问题烦过)。降 guide_scale、prompt 里写明镜头运动,都是在给运动松绑。

物理不一致(穿模、液体诡异、影子乱飘)。模型学的是"像素统计上什么样的下一帧常见",没有任何显式物理引擎。物理正确只在训练数据覆盖密集的场景里涌现,数据稀疏处(复杂遮挡、流体、镜面反射)就露馅。这是纯数据驱动路线的已知边界,业界在探索的世界模型方向,针对的就是这一层。

同 prompt 每次出片差异大。生成的起点是随机噪声,prompt 只约束"终点落在哪个语义区域",区域内部的具体样貌由 seed 决定。这是特性不是 bug:固定 seed 才能做受控对比,上一章的动手实验全都要求固定 seed,原因在此。

第十章 症状对旋钮:一张调参速查表

原理都通了,把它翻译成"出片不满意时该拧哪里"。每一行的依据都在前面的章节里:

症状该动的旋钮原理出处
画面结构混乱、物体崩坏--sample_shift 调大,或 --sample_steps 加步第四章:结构在高噪声段决定,shift 往那边倾斜预算
画面呆板、运动幅度小--sample_guide_scale 调低第五章:w 过大把 latent 推离自然分布,动作先僵
完全不贴 prompt--sample_guide_scale 调高;prompt 写细,或开 --use_prompt_extend第五章 CFG + 第二章扩写器
色彩过饱和、细节焦糊--sample_guide_scale 调低第五章:w 过大的典型症状
细节糊但结构对--sample_shift 调小,给低噪声段多留几步第四章:细节在低噪声段打磨
出片太慢--sample_steps 减步(质量换速度);多卡加 --ulysses_size第五章 80 次前向的账 + 第八章
OOM--offload_model True --convert_model_dtype --t5_cpu 三件套,或换 ti2v-5B第六章显存账 + 第一章两版 VAE
复现同一段视频固定 --base_seed噪声由 seed 决定,同 seed 同输入同输出
帧数报形状错误--frame_num 改成 4n+1第一章 VAE 压缩规则

还有一条超出旋钮范围的提醒:调 --sample_shift 时记得第六章的联动,它会静默改变两个专家的工作量分配。shift 从 12 降到 5,高噪声专家的步数从 26 掉到 17,风格会有肉眼可见的漂移,不完全是"结构 vs 细节"一个维度的事。

把账算完

回到开头那条命令,现在每个环节都有数了:

  • prompt 经 umT5(5.7B)变成 ≤512 个 4096 维向量,负向 prompt 同样处理
  • 随机噪声 latent:16 × 21 × 90 × 160,由 --base_seed 决定,同 seed 可复现
  • patchify 成 75600 个 5120 维 token
  • 40 步 UniPC 采样,shift=12 把 26 步挤进 t≥875 的高噪声区,由高噪声专家(14B)执笔,后 14 步换低噪声专家
  • 每步 2 次前向(CFG,guide_scale 高噪声档 4.0、低噪声档 3.0),全程 80 次 14B 推理,attention 计算量 10¹⁷ FLOPs 量级
  • latent 经因果卷积 VAE 流式解码回 81 帧 720p,libx264 编码落盘

时间都花在哪,也能定性排个序。T5 编码只跑一次,秒级;VAE 解码只跑一次,虽然是 81 帧的卷积也就几十秒;真正的大头是中间 80 次 14B 前向,占掉全程九成以上。开了 offload 再加一层权重搬运的时间。所以一切提速手段都冲着采样循环去:减步数、少 CFG(蒸馏)、多卡切 attention,没有人费劲优化 T5 和 VAE,账不在那里。

每个 CLI 旋钮对应链路上一个明确的环节:--sample_steps 是积分步数,换质量和速度;--sample_guide_scale 是 CFG 强度,换贴合度和自然度;--sample_shift 是步数分配,换结构和细节的预算,并连带改变两个专家的工作量;--frame_num 受 VAE 压缩规则约束,必须 4n+1;--ulysses_size/--dit_fsdp 分别对付算力墙和显存墙。

学到这里可以回头看一眼全局:这套系统里没有魔法,只有四样东西的组合:一个把数据变小的压缩器(VAE)、一个在小空间里学方向场的大网络(DiT)、一套走得又快又稳的数值积分(flow matching + UniPC + shift)、一组把语义捏进去的条件机制(cross-attention + CFG + 各任务的注入)。四样各自独立演化,论文各写各的,仓库把它们拧在一起。看懂了拧法,换一个视频模型(可灵、Sora、Veo)去读,会发现零件不同、拧法雷同。

接下来读什么、动手做什么

论文按这条线读,每篇对应本文的一到两章:

论文对应本文一句话
Latent Diffusion(Rombach et al., 2021)第一章为什么在压缩空间做生成
DDPM(Ho et al., 2020)第四章前半扩散生成的奠基框架
Flow Matching(Lipman et al., 2022)/ Rectified Flow(Liu et al., 2022)第四章把随机曲线拉成直线
Classifier-Free Guidance(Ho & Salimans, 2022)第五章两次前向的差值就是分类器梯度
DiT(Peebles & Xie, 2022)第三章transformer 扩散主干和 AdaLN
Wan 技术报告(arXiv:2503.20314)全部这套东西如何组装成视频模型

动手路径比论文更快建立手感,推荐这个顺序:

  1. 用 ti2v-5B 在消费卡上跑通 demo,固定 --base_seed
  2. 只改 --sample_shift(比如 3 / 5 / 12 各跑一遍),同 seed 对比,肉眼看"结构 vs 细节"的预算挪动
  3. 只改 --sample_guide_scale(1 / 3 / 7),看贴合度和过饱和的此消彼长
  4. text2video.py 的采样循环里下断点,打印每步的 t 和 latent 统计量,把第四章的表格亲手复现一遍
  5. image2video.py 的 mask 构造,对照第七章,然后自问:如果要做"给定首尾两帧生成中间"的任务,mask 该怎么改?

第 4 步给个起手式,断点打在 text2video.pyfor _, t in enumerate(tqdm(timesteps)) 那一行,每步打印三样东西:t.item()(对照第四章的时间步序列)、latents[0].std().item()(看噪声强度随步数衰减的曲线)、当前用的是哪个专家(对照第六章的 26/14 分配)。三条曲线画出来,这篇文章前六章就全部落在你自己的数据上了。

想通第 5 步那个问题,你就不是在"用"这个模型了,是在设计它的下一个任务。提示:mask 是逐帧的,首帧标 1 的那行代码改成首尾两帧标 1,再想想 y 里尾帧的 latent 该填什么。业界管这个任务叫 FLF2V(first-last-frame-to-video),Wan2.1 就发布过这个变体,你可以拿它的实现对答案。

工程实现上这套代码还有不少和文档脱节的坑,踩坑清单在另一篇:读完 Wan2.2 全部源码之后。原理和坑,配着读正好。

附:几个绕不开的追问

写作过程中被问过、以及读代码时自己冒出来的问题,集中答掉。

latent 长什么样,能看吗? 能。把 16 通道的 latent 任取三个通道当 RGB 画出来,会看到一团模糊但轮廓可辨的"视频的鬼影",构图和运动趋势都在,细节全无。这个练习值得做一次:它让"扩散在 latent 空间里雕结构、VAE 负责补细节"从口号变成直觉。第四章说的"高噪声段定构图",在 latent 可视化下逐步显形的过程肉眼可见。

为什么恰好 16 个通道? 权衡出来的数,不是推导出来的。通道少,VAE 压缩损失大,重建糊;通道多,latent 分布更复杂,扩散模型更难学,同算力下生成质量反而降。8/16/32 都有模型用过,16 是这一代的常见平衡点。ti2v-5B 的 VAE 空间压缩翻倍后把通道提到 48,就是在同一根跷跷板上重新配平。

两个专家为什么不共享底层、只分化顶层? 共享会省显存,但两个噪声区间的任务从第一层就不同:高噪声输入几乎是纯高斯,网络前几层在做"从统计里猜结构";低噪声输入已是半成品,前几层在做"读懂现有画面"。输入分布差这么远,共享底层反而互相拖累。代价就是第六章算的 56GB 显存,阿里选了质量,显存问题交给 offload 和多卡去消化。

CFG 蒸馏是什么,为什么 Wan2.2 不用? 把"两次前向 + 加权"的效果蒸馏进单次前向:训一个学生模型,直接输出教师模型 CFG 合成后的结果,推理成本立减一半。代价是 guide_scale 被固化进权重,用户失去调节自由,蒸馏本身也要额外训练。开源模型倾向把自由度留给用户,商业闭源服务(固定出片风格、按次计费)更有动力做蒸馏。

这套架构和其他视频模型差别大吗? 骨架高度趋同:latent diffusion + DiT + flow matching 是 2024 年之后的行业公约数。差异在配料:VAE 压缩比和通道数、attention 是全 3D 还是分解式、文本编码器选型、加速手段(蒸馏/缓存/量化)、以及数据配方。最后一项才是各家真正的护城河,也恰好是开源仓库里看不到的部分。读懂 Wan2.2,其他模型的技术报告基本能无障碍平移着读。

为什么整篇没讲训练? 仓库只放了推理代码,训练细节(数据规模、清洗、分辨率课程、两专家的训练调度)在技术报告里也只有粗线条。本文只写代码能验证的东西,训练部分与其转述报告不如直接读报告,链接在下面的论文表里。

附:术语速查

按出场顺序,每条一句人话加一个锚点:

术语一句话在 Wan2.2 里
latent数据的压缩表示,生成在这里发生16 × 21 × 90 × 160 的张量
VAE像素 ↔ latent 的翻译官,单独训练后冻结Wan2_1_VAE,压缩比 4×8×8
因果卷积只看过去不看未来的卷积,换来流式处理CausalConv3d + CACHE_T=2
DiT用 transformer 当扩散主干WanModel,40 层 5120 维
patchify把 latent 网格切成 token 序列(1,2,2),得 75600 token
AdaLN时间步通过调制 norm 参数控制网络行为每 block 6 组 shift/scale/gate
RoPE旋转式位置编码,内积只依赖坐标差3D 版,128 维切 44/42/42
QK-Normq、k 各自归一化,稳住注意力每个 attention 里两个 RMSNorm
flow matching数据和噪声直线插值,模型学速度场prediction_type="flow_prediction"
σ / timestep当前位置在噪声路径上的进度1000 步训练刻度,推理取 40 个点
shift把采样点往高噪声端挤的变形t2v 用 12,i2v 用 5
UniPC多步预测-校正的数值积分器solver_order=2,默认采样器
CFG条件/无条件两次前向,差值放大guide_scale 3.0~4.0
负向 promptuncond 前向用的"要避开的描述"配置里那串中文
MoE 双专家高/低噪声区间各一个完整模型boundary=0.875 一个 if 路由
offload不用的模型搬回内存省显存--offload_model True
Ulysses切注意力头不切序列的并行--ulysses_size,两次 all_to_all
FSDP参数分片,用时聚合--dit_fsdp / --t5_fsdp

表里任何一条展开都是一章,忘了哪个回来查。

下一篇 →
读完 Wan2.2 全部源码之后:这套视频生成代码在哪些地方会咬你

评论

评论发布后会立即公开,如触发规则可能被审核下架。

最多 1000 字。