郭立 (leeguoo)

# 把视频生成的账算清:Wan2.2 源码精读

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

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

本页目录
1.三个模型,一个舞台2.第一章 去噪走的是一条直线3.为什么"加噪再去噪"能生成4.从 DDPM 到 flow matching5.一把尺子的两种刻度6.第二章 VAE:视频先被压小 46 倍7.4n+1 的由来8.因果卷积和流式推理9.latent 空间的"汇率"10.第三章 prompt 去了哪里11.第四章 DiT:75600 个 token 的注意力12.从 latent 到 token13.14B 是怎么数出来的14.一个 block 的完整前向15.3D RoPE:让 token 知道自己在哪16.一次前向的形状账17.第五章 采样循环:40 步,每步两次前向18.UniPC:怎么走得又快又稳19.shift:40 个停靠点落在哪20.CFG:每步为什么要跑两次前向21.第六章 两个 14B 专家接力22.boundary 和 shift 的联动23.显存账和三件套24.第七章 条件注入:图生视频改了哪几行25.其他任务的条件从哪来26.第八章 75600² 是个什么概念27.第九章 模型为什么会犯它常犯的错28.第十章 症状对旋钮:一张调参速查表29.把账算完30.接下来读什么、动手做什么

一条命令:

$ 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 专家切换有什么看不见的联动?
  • 图生视频在文生视频的骨干上到底改了哪几行?

全文按"原理 → 数据 → 网络 → 采样 → 工程"的顺序展开,每一章只用到前面章节讲过的概念,建议顺读。默认你大致知道张量和 attention 是什么;完全零基础的话,先花十分钟把这两个词查清楚再来,这十分钟值得。

三个模型,一个舞台

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

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

"扩散"这个词先用三句话垫上,第一章再展开:模型学的是"从一块噪点往干净数据修"的方向;推理时从纯随机噪声出发,问模型 40 次方向、挪 40 次,噪点就逐渐显影成视频;你的 prompt 参与每一次方向判断。记住这个骨架,后面每个部件都能挂上去。

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

三台机器一条流水线:T5 吃 prompt 吐 context,DiT 循环 40 步,VAE 解码出 81 帧

三个模型怎么串起来,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。

左边清晰的 720p 像素帧,右边同构图的模糊 latent 鬼影,小 46 倍

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

先不看任何代码,把最根本的问题解决掉:为什么"从噪声里一步步修"能凭空生成一段新视频?

暗房显影四个托盘:step 1 纯噪点到 step 40 清晰成片,每泡一次=两次模型前向

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

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

从 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 的随机游走:DDPM 像在没有 GPS 的城市里凭路人指路开车,每个路口都要停;flow matching 修的路本身是直的,隔一段确认一次方向就够。40 步出片,DDPM 时代同样质量常要上百步。

两张地图:DDPM 的百步弯路 vs flow matching 的 40 步直路,起点噪声终点视频

代码里能直接看到这套数学。调度器文件 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₀

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

一把尺子的两种刻度

后文会同时出现 σ、t、1000、40 这几个数,先把它们的关系钉死。推导里的 σ ∈ [0, 1];代码里的时间步 t 是它乘 1000 后的刻度(配置 num_train_timesteps=1000),t=875 就是 σ=0.875,同一把尺子的两种刻度,t 越大越接近噪声、越小越接近成片。训练时模型在全部 1000 个刻度上都见过样本;推理只是从尺子上挑 40 个点停靠,每个点上模型照常工作。所以步数是采样期的自由参数,改 --sample_steps 成 50 步、20 步都合法,只是积分精度不同,不需要动模型。这也是"少步蒸馏"类工作的入口:既然模型在每个刻度都会走,能不能训它一步走得更远,把 40 步压到 4 步。

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

原理清楚了,回头看数据。第一章的 x₀ 不是像素,是 latent——这一章讲像素和 latent 怎么互相翻译,这次翻译决定了整条链路上所有张量的形状。

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 的空间网格。16 个通道没有红绿蓝那样的含义,是网络自己学出来的一套"内部速记",人类不可读,够用就行。

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,约束的源头就在这一行——CLI 参数里那些奇怪的默认帧数(81、121、77)没有一个是拍脑袋的,全是 4n+1。

81 帧装箱:第 1 帧单独一小盒,其余每 4 帧一大盒,共 21 个 latent 帧

为什么第一帧要特殊?因为压缩用的是因果卷积,第一帧前面没有历史可看,只能独立成帧。这个设计还有一个副产品:一张静态图片可以看成 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 全部挪到前面,每帧输出只依赖当前和过去。读这段代码有个 PyTorch 冷知识要垫上:F.pad 的参数从最后一维往前数,前四个数管宽和高,末尾那对 (2 * self.padding[0], 0) 才是时间维——前补两倍、后补零。

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

传送带上的分块视频过因果卷积,每块留 2 帧缓存拼给下一块,禁止偷看未来

网络本体是标准的分级卷积自编码器,和 Stable Diffusion 的 VAE 同宗:三次空间下采样对应 8 倍空间压缩,后两级各压一半时间轴合成 4 倍,decoder 按同样的楼梯反着爬回去。和扩散主干比它小得几乎不占账(百 M 级参数对 14B),但它决定了生成质量的上限:VAE 重建不出来的细节,扩散模型雕得再好也到不了像素。重建质量、压缩率、解码速度三角互斥,是各家视频模型暗中比拼的地方。

latent 空间的"汇率"

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

$ python
mean = [-0.7571, -0.7089, -0.9113, ..., -0.2921]   # 16 个通道各自的均值
std  = [ 2.8184,  1.4541,  2.3275, ...,  1.9160]   # 16 个通道各自的标准差
self.scale = [self.mean, 1.0 / self.std]

这 32 个数是在训练集上统计出来的。VAE encode 的原始输出各通道量级差异很大(std 最小 1.13、最大 3.27,差三倍),而扩散模型假设自己面对的是接近标准正态的数据,所以 latent 进扩散模型前要逐通道标准化,解码前再乘回去。这组数字是这对 VAE/扩散模型的"汇率",换一版 VAE 就全部作废。仓库里恰好还有一版仅 ti2v-5B 用的 VAE:压缩比 4×16×16、通道 48——模型小一半不够,数据也得跟着小,这是 5B 能在 24GB 消费卡上跑 121 帧的关键;两版 VAE 的 checkpoint 互不通用,根子就在汇率上。VAE 单独训练后冻结,Wan2.1 的这版 VAE 被 Wan2.2 的四个任务直接沿用。

第三章 prompt 去了哪里

去噪的每一步都要听 prompt 的话,prompt 得先变成模型能算的东西。

Wan2.2 用 umT5-XXL(Google 的多语言 T5 变体)的 encoder 编码文本,约 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)
    ids = ids.to(device)
    mask = mask.to(device)
    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。文本不直接画任何像素,它在每一步去噪时持续给模型指语义方向——prompt 不是开工前下达的命令,是全程挂在墙上的施工图。

工地围墙上挂着 512 token 的施工图,每个施工的视频 token 抬头看图,已到第 17/80 轮

为什么用 T5 而不是早期文生图惯用的 CLIP 文本塔?CLIP 的向量擅长"这段话大概在说什么",但 77 token 的上限塞不下细致的场景描述,对数量词和空间关系也很弱;T5 按文本自身结构逐 token 编码、上限 512,配合逐 token 的 cross-attention,模型能对"左边的猫戴红色拳套"这类细节做出区分。选多语言版 umT5 还有一层考虑:中文 prompt 是一等公民,官方负向 prompt 干脆就是中文写的。

说的就是这段负向 prompt,配置里的默认值原文如下:

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

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

最后一个前置环节:用户随手写的 prompt 往往只有一句话,而模型是在长而细的描述上训练的,喂太短的 prompt 出片质量会打折。--use_prompt_extend 会先拿一个 LLM(DashScope 在线 API 或本地 Qwen2.5)把你的 prompt 扩写成带镜头、光线、动作细节的长描述,再送 T5。多卡时扩写只在 rank 0 上跑一次再广播给其他卡——LLM 生成带随机性,8 张卡各扩写一遍会得到 8 个不同的 prompt,视频就没法拼了。

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

零件齐了:数据在 latent 空间里(第二章),文字变成了 context(第三章),要学的目标是速度场(第一章)。现在看那台预测速度的机器,14B 参数都在这里。

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

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

从 latent 到 token

latent 进 DiT 前先切 patch(这一步叫 patchify)。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 是整个模型的最小重复单元,值得逐行读。读之前提醒一句:参数 e 来自时间步 t——第一章那把尺子上的刻度,999 几乎纯噪声、0 是成片——它怎么变成 e,代码后面马上讲。

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

    # 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 function
    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(比如 927)先过标准正弦编码展开成 256 维——网络对单个标量的分辨率很差,展开后高频通道管细粒度、低频通道管粗粒度——再过 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 的默认写法。

同一位厨师:t=999 猛火,t=10 文火,灶台 6 个旋钮由时钟自动设定

代码里还有个容易忽略的细节:所有调制运算都包在 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)

应用时,每个 token 按自己的 (f, h, w) 三个坐标取三段旋转量,拼成完整的 128 维旋转,用复数乘法一次施加到 q 和 k 上。attention 算 q·k 内积时,旋转的相对性让内积只依赖两个 token 的坐标差:整个画面平移两格,所有相对关系不变,模型学到的规律照常适用,平移不变性是白送的。这是图像 2D RoPE 向视频的直接推广,44/42/42 的分配意味着时间轴拿到的频率通道和单个空间轴差不多,时间关系和空间关系被同等对待。

电影院观众各戴 row/seat/show 三枚座位章,比对时直接读出相对距离

细节:旋转的实现(rope_apply)全程 float64 并显式关闭 autocast,旋转是纯几何运算,半精度的相位误差在长序列上会放大,又一处花算力买数值稳定的取舍。另外 WanSelfAttention 里 q 和 k 过线性层后各接一个 RMSNorm(QK-Norm),锁住内积量级防止 softmax 饱和——推理代码里看不出它的价值,但没有它,14B 规模的 DiT 很难训稳。

一次前向的形状账

把整章串成一张表,数据从进模型到出模型的每次变形(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,吐一个速度预测"这台机器就完整了。下一章看这个预测怎么被采样循环用起来。

第五章 采样循环:40 步,每步两次前向

机器每次只报一个方向,真正把视频从噪声里走出来的是围着它转的循环:选 40 个停靠点,每个点问两次路。

UniPC:怎么走得又快又稳

有了速度场,还差一个数值积分器:每步拿模型报的速度,决定往前挪多远——这个组件在代码里叫调度器(scheduler),fm_solvers_unipc.py 就是它。最朴素的积分是欧拉法,每步假设速度不变,x ← x - Δσ·v,一阶精度,速度场实际是弯的,步子越大偏得越远。Wan2.2 默认的 UniPC 是一个多步预测-校正器:predictor 用最近两步(配置 solver_order=2)的模型输出拟合速度的变化趋势,外推着走下一步;corrector 在新位置的模型输出算出来之后,回头把刚才那一步重新修一遍,不多花前向白捡一阶精度。同样 40 次模型调用,局部误差从欧拉的一阶小量降到三阶,"可接受质量"的步数预算就是这么从上百砍到几十的——视频生成每步是两次 14B 前向,省下的每一步都是真金白银。

shift:40 个停靠点落在哪

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

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

这个变换把点往高噪声端(σ 大的一端)挤。挤到什么程度,算中位数:

shift中位时间步
1(不挤)512
5840
12(t2v 默认)926

shift=12 时,一半的采样点落在 t>926 的区间里。真实的时间步序列开头长这样(shift=12、40 步,自己跑一遍 set_timesteps 能验证,输出取整成整数):

999, 997, 995, 993, 990, 988, ...

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

为什么这样分配?高噪声阶段决定构图、物体和运动走向,是"从无到有"的阶段;低噪声阶段只是磨细节。把预算倾斜给难的阶段,是视频生成比图像更需要的取舍:图像的 shift 一般取 3 左右,视频的运动一致性难得多,Wan2.2 直接拉到 12。同仓库里 i2v 用 5.0(首帧已经给定,结构压力小),ti2v-5B 用 5.0,s2v 用 3,每个任务的 shift 都在讲自己的难度分布。--sample_shift 不改变步数,只改变步数怎么分配,调它等于在"结构更稳"和"细节更精"之间挪预算——它还和下一章的专家分工咬合得很紧,数字账到那里再算。

CFG:每步为什么要跑两次前向

两位画家:一位拿正向 prompt,一位拿负向清单,两幅画的差值放大 3.5 倍画到终稿

采样循环的核心(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"的分类器梯度——早期做法真的专门训一个分类器来提供它,又贵又难训。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 的 A14B 双专家系列只用 3.0~4.0(ti2v-5B 用 5.0,s2v 用 4.5)。

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

成本同样清楚:40 步 × 2 次前向 = 80 次 14B 模型的完整推理。视频生成慢,一半的账记在 CFG 头上。

第六章 两个 14B 专家接力

上一章的去噪循环和 CFG,默认背后是一个模型在跑。其实 A14B 系列藏着两个,这一章讲它俩怎么分工,以及这为什么直接决定了你的显卡够不够用。

两位雕塑师傅接力:大锤开料的 high-noise 专家干 1-26 步,t=875 交棒给细凿精修的 low-noise 专家,角落双层床写着 both live in VRAM

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 激活"的含义(27B 是精确口径,第四章的 14.05B 只数了每层大头):容量是两倍,但任何一步只有一个专家在跑,算力成本等于 14B 稠密模型。别被"MoE"这个词带偏:Mixtral 那类 LLM MoE 是每层、每个 token 用学出来的 router 选专家;这里是时间步级路由、阈值写死、连 router 都没有,全部实现就是一个 if。粗粒度换来两个好处:扩散的时间步天然就是难度标签,不用训 router;两个专家可以独立训练、独立换代。

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

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

    timestep = torch.stack(timestep)

    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 的联动

这是调参时最容易看漏的一笔账。boundary=0.875 字面上像"只有 12.5% 的时间步归高噪声专家",但第五章讲过 shift 把采样点狠狠挤向高噪声端。以 t≥875 为界数一数 40 步的归属:

shift高噪声专家的步数(t ≥ 875)
15 / 40
517 / 40
12(t2v 默认)26 / 40,占 65%

两条 0-1000 数轴:shift=1 的 40 个点均匀分布,shift=12 挤向 1000 端,875 右侧圈出 26 步

预算的大头给了定结构的高噪声专家,低噪声专家用剩下 14 步收尾。单独调 --sample_shift 而不动 boundary,两个专家的工作量分配会静默变化,生成质量的波动会让你摸不着头脑。两档 guide_scale 也是为分工服务的:高噪声阶段用 4.0(结构阶段贴紧 prompt),低噪声阶段降到 3.0(细节阶段少些强推,画面更自然)。

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

显存账和三件套

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

怎么组合,README 的示例命令给了官方答案:单卡跑 A14B 的示例本身就带 --offload_model True --convert_model_dtype(并标注至少 80GB 显存);4090 跑 ti2v-5B 三件全加(--t5_cpu 也上,5B 场景 T5 的 11.4GB 占比反而最大)。README 在 ti2v-5B 小节还有一句反向提示:显存超过 80GB 时把这些 flag 去掉,能明显提速,offload 不是免费的。

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

到这里,文生视频的完整链路你已经走通了。剩下四个任务(图生、文图、语音、动画)不是四套新模型,而是往这同一根骨干上换不同的"题目"。

加条件的方式无非三种:

  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 帧围绕它展开运动,这就是"让图片动起来"的机制。

81 格胶片:第 1 格钉着照片 mask=1,其余空格 mask=0,折叠成 21 帧 latent 加 mask 层,拼成 36 通道进 DiT

其他任务的条件从哪来

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

ti2v-5B(文图统一)。同一个 5B 骨干配 4×16×16 的高压缩 VAE,不改架构就同时支持 t2v 和 i2v:给了图,就把图编码后钉进 latent 的第一帧位置并用 mask 标记已知;没给图,纯噪声起步。一个模型两副面孔。

s2v-14B(语音驱动)。条件是音频:wav2vec2 把波形变成逐帧特征,按视频帧率重新分桶对齐后注入模型,驱动口型和肢体节奏。视频长度跟着音频走,超长音频切成多段自回归生成,每段拿上一段结尾几帧的 latent 当"动量条件"衔接。这是仓库里唯一的长视频方案。

animate-14B(角色动画/替换)。条件最重的任务:骨骼姿态序列驱动动作、人脸特征序列驱动表情、参考图走 CLIP 视觉编码进 cross-attention 锁定长相,替换模式下还有背景视频和 mask,多路条件各走各的口子。

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

第八章 75600² 是个什么概念

模型怎么工作、怎么加条件,都讲完了。剩下一个纯工程的问题:这么大的计算量,单张卡扛不住怎么办?这一章回到第四章那个 75600,看它如何逼出多卡方案。

把 attention 的账算细一点。单层 self-attention 的核心计算是 QK^T 和 AV 两次矩阵乘,FLOPs 约为

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

(两个 2:一个是两次矩阵乘,一个是每对元素乘、加各计一次 FLOP。)一层 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

attention 要求每个 token 看到完整序列,所以不能简单切序列了事;Ulysses 的答案是"切头不切序列"。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 头

通信两次,换来每卡只算 1/8 的注意力头,attention 的计算和激活显存都降到 1/8。头数必须能被卡数整除(40 头配 8 卡正好),generate.py 里有对应的 assert。

8 位阅卷人每人一份 75600 token 的全卷,各改自己负责的 heads,两轮传卷 all_to_all

序列长度这边也有一个为多卡准备的细节。text2video.py 里 seq_len 的算式尾巴上挂着一对 ceil(... / sp_size) * sp_size,把 token 数向上取整到卡数的整数倍:75600 恰好整除 8,这里不变;换个分辨率算出 75601 个 token,就补到 75608,让每张卡分到等长的切片。单卡时 sp_size=1,这段是恒等变换,多卡语境下才显形。

通信的代价也算得出来:每层 q/k/v/输出四次 all_to_all 约 3.1GB 数据,40 层 × 80 次前向累计约 10TB。听着吓人,NVLink 的卡间带宽是每秒几百 GB 量级,摊到整个生成过程里,通信时间远小于计算时间,这买卖划算;走 PCIe 的机器就不一定了,多卡加速比会明显缩水。顺带一提竞品 Ring Attention:切序列、K/V 绕圈传,不受头数整除限制但通信更复杂;Ulysses 的上限是卡数不能超过 40 个头,64 卡集群就得换方案。

参数放不下是另一条轴:FSDP(fully sharded data parallel)把模型权重切成 N 份分驻各卡,前向算到某一层时全体卡先 all-gather 把这层参数凑齐,算完立即释放。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 内部分辨五根手指的边界。文字更惨,笔画是像素级的。解药只有降低压缩比或提高分辨率,两者都直接撞算力墙,这是当前这代架构的结构性短板,不是训练数据的锅。

手部叠上粗网格,一格=16×16 像素,整根手指落进一格,放大镜下模型看不清格内细节

长视频后段漂移。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 会静默改变两位专家的工作量分配,见第六章的联动账。

把账算完

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

  • 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 编码落盘

时间的大头是中间 80 次 14B 前向,占掉全程九成以上;T5 和 VAE 各只跑一次。所以一切提速手段都冲着采样循环去:减步数、少 CFG(蒸馏)、多卡切 attention,没有人费劲优化 T5 和 VAE,账不在那里。

这套系统里没有魔法,只有四样东西的组合:压缩器(VAE)、在压缩空间里学速度场的大网络(DiT)、走得又快又稳的数值积分(flow matching + UniPC + shift)、把语义捏进去的条件机制(cross-attention + CFG + 各任务的注入)。骨架是 2024 年之后的行业公约数,换一个视频模型(可灵、Sora、Veo)去读,零件不同、拧法雷同——各家真正的护城河在数据配方,恰好是开源仓库里看不到的部分。

如果全文只允许带走三句话,带这三句:

  1. 生成发生在缩略图上:视频先被 VAE 压小 46 倍,大模型全程只碰压缩表示,最后一步才放大回像素。
  2. 去噪是问路 40 次的直线导航:模型每次报一个方向(还要正反各问一遍取差值,这是 CFG),调度器按方向挪一步,前 26 步定结构、后 14 步磨细节,两位专家在第 875 刻度交接。
  3. 所有工程难题都源于一个数:75600 个 token 的平方级 attention,显存、速度、多卡方案,全是在跟它搏斗。

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

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

论文对应本文一句话
DDPM(Ho et al., 2020)第一章前半扩散生成的奠基框架
Flow Matching(Lipman et al., 2022)/ Rectified Flow(Liu et al., 2022)第一章把随机曲线拉成直线
Latent Diffusion(Rombach et al., 2021)第二章为什么在压缩空间做生成
DiT(Peebles & Xie, 2022)第四章transformer 扩散主干和 AdaLN
Classifier-Free Guidance(Ho & Salimans, 2022)第五章两次前向的差值就是分类器梯度
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.item()latents[0].std().item() 和当前专家,对照第五、六章的表格,把 26/14 的分配亲手复现一遍
  5. 把 16 通道的 latent 任取三个通道当 RGB 画出来,看"视频的鬼影"逐步显形——"扩散在 latent 空间雕结构、VAE 补细节"从口号变成直觉
  6. image2video.py 的 mask 构造,对照第七章,然后自问:如果要做"给定首尾两帧生成中间"的任务,mask 该怎么改?业界管这个任务叫 FLF2V,Wan2.1 发布过这个变体,可以拿它的实现对答案

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

← 上一篇
给 coding agent 一个能互相 @ 的房间:AgentParty 是什么、用在哪、原理是什么
下一篇 →
读完 Wan2.2 全部源码之后:这套视频生成代码在哪些地方会咬你

评论

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

最多 1000 字。