1. 从Sora说起:为什么视频生成突然“统一”到了DiT
如果你这两年一直在关注视频生成这个方向,应该会有一种很强烈的感觉:2024年之前,大家各玩各的,有基于U-Net的、有基于自回归的、有基于GAN的、有基于掩码建模的,路线五花八门。但Sora出来之后,情况变了。你去看2024年下半年到2025年新发的视频模型,不管是开源的还是闭源的,技术报告里几乎都会出现同一个词——DiT,也就是Diffusion Transformer。
这个现象很有意思。它不是说大家商量好了,而是因为Sora用实际效果证明了一件事:当扩散模型的主干网络从U-Net换成Transformer,视频生成的画质、时长、一致性会同时上一个台阶。于是所有人都在往这个方向靠。你可以理解为,DiT成了视频生成领域的“通用底座”。
我自己是从2023年开始折腾视频生成模型的,最早跑的是基于U-Net的AnimateDiff那一套,后来陆续试了Latte、Open-Sora、CogVideoX这些DiT路线的模型。说实话,第一次把U-Net换成Transformer主干的时候,最直观的感受不是画质提升,而是扩展性——你可以很自然地加参数、加数据、加时长,模型不会像U-Net那样很快就撞到天花板。
这篇文章我想把DiT这个东西从头到尾讲清楚。不是那种论文式的公式推导,而是从一个实际跑过、调过、踩过坑的从业者角度,告诉你DiT到底是什么、为什么它成了视频模型的标准剧本、以及如果你想自己动手,应该怎么理解和实现它。适合有一定深度学习基础、想搞清楚视频生成底层逻辑的读者,也适合刚入门、想快速建立认知框架的朋友。
2. DiT到底是什么:把扩散模型的骨架换掉
2.1 扩散模型的核心逻辑,用一句话说清楚
在讲DiT之前,得先把扩散模型(Diffusion Model)的逻辑捋一遍。不然后面说Transformer替换U-Net,你会不知道替换的到底是什么。
扩散模型的核心思想特别朴素:先教模型怎么把一张图一步步加噪变成纯噪声,再教它反过来,从纯噪声一步步去噪还原成一张图。训练的时候是加噪,推理的时候是去噪。整个过程就像你把一滴墨水滴进清水里,看着它慢慢扩散到均匀,然后让模型学会倒放这个过程。
这里面最关键的角色是去噪网络。它负责在每一步预测“当前这团噪声里,哪些是噪声、哪些是信号”,然后把噪声减掉一点。这个去噪网络长什么样,直接决定了整个模型的性能上限。
早期大家用的都是U-Net。U-Net的结构像一个“U”形:先下采样把图像尺寸缩小、通道数增加,再上采样把尺寸还原回去,中间用跳连接把浅层和深层特征连起来。这个结构在图像分割领域很成熟,放到扩散模型里也很好用,因为它能同时捕捉全局和局部信息。
但U-Net有个问题:它本质上是为图像设计的,处理视频这种带时间维度的数据时,扩展起来很别扭。你要么把时间维度当成额外的通道塞进去,要么在U-Net里加3D卷积,但不管哪种方式,参数量和计算量的增长都不是线性的,调起来很痛苦。
2.2 Transformer为什么能接这个活
Transformer最早是为自然语言处理设计的,核心是自注意力机制。它的特点是:输入一串序列,每个元素都能和序列里其他所有元素计算注意力权重,从而捕捉长距离依赖。
这个特性放到图像和视频上,其实非常合适。一张图可以切成若干个小块(patch),每个patch当成一个token;一段视频可以切成若干个小块加时间帧,每个小块当成一个token。这样一来,图像和视频就变成了和文本一样的序列数据,Transformer可以直接处理。
这里有个关键认知:Transformer不关心输入是文本、图像还是视频,它只关心“序列里元素之间的关系”。这种通用性,是它能统一各个模态的根本原因。
但直接用标准Transformer处理图像有个致命问题:计算量爆炸。一张512x512的图,如果切成16x16的patch,就有1024个token。自注意力的计算复杂度是O(n²),1024个token就是百万级别的计算量。视频更夸张,加个时间维度,token数量直接翻几十倍。
所以DiT的关键设计不在于“用了Transformer”,而在于怎么让Transformer在可接受的计算量下处理高维视觉数据。这就引出了DiT的几个核心设计选择。
2.3 DiT的核心设计:Patch化、条件注入、自适应归一化
DiT(Diffusion Transformer)最早是Meta在2022年底提出的,全称是“Scalable Diffusion Models with Transformers”。它的核心设计可以拆成三块:
第一块是Patch化。把输入图像或视频切成固定大小的patch,每个patch通过一个线性层映射成token。这一步和Vision Transformer(ViT)的做法一样。比如一张256x256的图,切成16x16的patch,就得到256个token。视频的话,再在时间维度上切,比如16帧视频,每帧256个token,总共4096个token。
第二块是条件注入。扩散模型需要知道当前去噪到哪一步了(时间步),以及要生成什么内容(文本提示、类别标签等)。DiT的做法是把这些条件信息通过**自适应层归一化(adaLN)**注入到每个Transformer块里。具体来说,就是用一个小的MLP网络,根据时间步和条件信息,生成缩放和平移参数,作用在层归一化之后。
第三块是自适应归一化。这是DiT和标准ViT最大的区别。标准ViT用的是固定的层归一化,DiT用的是adaLN-Zero,也就是自适应层归一化,并且把残差连接的缩放参数初始化为零。这个设计让模型在训练初期表现得像一个恒等映射,训练更稳定。
这三块加起来,就是DiT的基本骨架。你可以把它理解成:用ViT的方式处理输入,用扩散模型的方式训练,用adaLN的方式注入条件。
3. 从图像到视频:DiT是怎么“长”出时间维度的
3.1 视频DiT的两种主流路线:单流和双流
图像DiT跑通之后,很自然的问题就是:视频怎么办?视频比图像多了一个时间维度,怎么把这个维度加进去,成了各个模型分道扬镳的地方。
目前主流的有两种路线:单流DiT和双流DiT。
单流DiT的做法很直接:把视频当成一个3D的patch序列,时间维度和空间维度一起切patch,然后所有token扔进同一个Transformer里处理。Open-Sora、Latte早期版本用的就是这种思路。优点是结构简单,所有token共享同一套注意力计算;缺点是计算量随视频长度增长很快,长视频容易爆显存。
双流DiT的做法更精细:把空间注意力和时间注意力分开。先在每一帧内部做空间注意力,再在帧与帧之间做时间注意力。CogVideoX、Sora的技术报告里都提到了类似的设计。优点是计算量更可控,可以分别优化空间和时间维度的建模;缺点是结构复杂,实现起来更麻烦。
我自己的经验是:如果你刚开始做视频生成,建议从单流DiT入手,结构简单、调试方便。等跑通了再考虑双流,因为双流虽然效率高,但调参的坑多得多。
3.2 时间维度的处理:3D Patch化与位置编码
视频DiT最核心的问题就是怎么处理时间维度。这里有几个关键设计:
3D Patch化。图像是2D patch(高x宽),视频是3D patch(时间x高x宽)。比如一个16帧、256x256的视频,用2x16x16的patch大小,就得到8x16x16=2048个token。时间维度上切了8份,空间维度上每帧256个token。
位置编码。Transformer本身不知道token的顺序,需要位置编码来告诉它每个token在序列里的位置。图像DiT用的是2D位置编码(行和列),视频DiT需要3D位置编码(时间、行、列)。常见做法是用可学习的绝对位置编码,或者用正弦余弦位置编码。Sora的技术报告里提到用了可学习的3D位置编码,并且支持在不同分辨率、不同时长之间插值。
时间注意力的计算。如果是双流DiT,时间注意力只在同一空间位置的不同帧之间计算。比如第i帧的第j个patch,只和第i+1帧的第j个patch、第i-1帧的第j个patch计算注意力。这样计算量从O((THW)²)降到O(T²HW),大幅减少。
3.3 视频DiT的训练难点:显存、时长、一致性
视频DiT训练起来比图像DiT难得多,主要难在三个地方:
显存。视频的token数量是图像的几十倍,自注意力的计算量和显存占用都是平方级增长。一张16帧256x256的视频,token数量是2048,自注意力矩阵就是2048x2048,显存占用相当可观。如果换成64帧512x512,token数量直接上万,单卡根本放不下。
时长。视频越长,时间维度的建模越难。短片段(几秒)还好,一旦超过10秒,模型很容易出现“忘记前面内容”的情况,生成的动作不连贯、物体突然消失或变形。
一致性。视频生成最怕的就是闪烁和跳变。同一物体在不同帧里颜色、形状、位置不一致,看起来就很假。DiT通过全局注意力可以在一定程度上缓解这个问题,但训练数据里的时序一致性也很关键。
实操心得:训练视频DiT的时候,梯度检查点(gradient checkpointing)是必开的,不然显存根本不够。另外,混合精度训练(bf16)也能省不少显存,但要注意数值稳定性,有时候需要把LayerNorm换成fp32。
4. 动手实现一个最小DiT:从零到跑通
4.1 环境准备与依赖安装
如果你想自己跑一个DiT,不管是图像还是视频,环境准备是第一步。我一般用conda建一个干净的环境,避免和系统里的包冲突。
conda create -n dit python=3.10 conda activate dit pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121 pip install einops timm diffusers accelerate这里解释一下几个关键依赖:einops用来做张量重排,写起来比原生PyTorch的view/permute清晰得多;timm提供了很多预训练的视觉模型和工具函数;diffusers是HuggingFace的扩散模型库,里面有很多现成的调度器和工具;accelerate用来做混合精度和分布式训练。
注意:PyTorch版本要和CUDA版本匹配。如果你用的是40系显卡,建议CUDA 12.1以上,不然可能遇到算子不兼容的问题。
4.2 DiT Block的核心代码实现
DiT的核心是Transformer Block,但和标准Transformer Block有几个关键区别。下面是一个简化版的DiT Block实现:
import torch import torch.nn as nn from einops import rearrange class DiTBlock(nn.Module): def __init__(self, hidden_size, num_heads, mlp_ratio=4.0): super().__init__() self.norm1 = nn.LayerNorm(hidden_size, elementwise_affine=False) self.attn = nn.MultiheadAttention(hidden_size, num_heads, batch_first=True) self.norm2 = nn.LayerNorm(hidden_size, elementwise_affine=False) self.mlp = nn.Sequential( nn.Linear(hidden_size, int(hidden_size * mlp_ratio)), nn.GELU(), nn.Linear(int(hidden_size * mlp_ratio), hidden_size) ) # adaLN modulation: 根据条件生成6个参数 self.adaLN_modulation = nn.Sequential( nn.SiLU(), nn.Linear(hidden_size, 6 * hidden_size) ) def forward(self, x, c): # c是条件向量(时间步+文本等) shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = \ self.adaLN_modulation(c).chunk(6, dim=-1) # 自适应层归一化 + 注意力 x = x + gate_msa.unsqueeze(1) * self.attn( self._modulate(self.norm1(x), shift_msa, scale_msa) )[0] # 自适应层归一化 + MLP x = x + gate_mlp.unsqueeze(1) * self.mlp( self._modulate(self.norm2(x), shift_mlp, scale_mlp) ) return x def _modulate(self, x, shift, scale): return x * (1 + scale.unsqueeze(1)) + shift.unsqueeze(1)这段代码有几个关键点:
第一,LayerNorm的elementwise_affine设为False。因为缩放和平移参数是由adaLN_modulation动态生成的,不需要LayerNorm自己再学一套。
第二,adaLN_modulation输出6个参数。分别对应注意力前的shift和scale、注意力后的gate、MLP前的shift和scale、MLP后的gate。这就是adaLN-Zero的核心。
第三,gate初始化为零。实际实现中,通常会把adaLN_modulation的最后一层Linear的权重和偏置初始化为零,这样训练初期gate接近零,整个Block近似恒等映射,训练更稳定。
4.3 Patch化与位置编码的实现
Patch化的代码很简洁,用einops几行就能搞定:
class PatchEmbed(nn.Module): def __init__(self, patch_size, in_channels, hidden_size): super().__init__() self.patch_size = patch_size self.proj = nn.Conv2d(in_channels, hidden_size, kernel_size=patch_size, stride=patch_size) def forward(self, x): # x: (B, C, H, W) x = self.proj(x) # (B, hidden, H/p, W/p) x = rearrange(x, 'b c h w -> b (h w) c') return x视频的话,把Conv2d换成Conv3d,时间维度也做patch化:
class VideoPatchEmbed(nn.Module): def __init__(self, patch_size, in_channels, hidden_size): super().__init__() # patch_size: (t, h, w) self.proj = nn.Conv3d(in_channels, hidden_size, kernel_size=patch_size, stride=patch_size) def forward(self, x): # x: (B, C, T, H, W) x = self.proj(x) # (B, hidden, T/t, H/h, W/w) x = rearrange(x, 'b c t h w -> b (t h w) c') return x位置编码用可学习的参数:
self.pos_embed = nn.Parameter(torch.zeros(1, num_patches, hidden_size)) # 初始化用正态分布,标准差0.02 nn.init.normal_(self.pos_embed, std=0.02)实操心得:位置编码的初始化标准差很关键。太大训练不稳定,太小模型学不到位置信息。0.02是ViT论文里的默认值,实测下来比较稳。
4.4 训练循环与损失函数
DiT的训练目标和标准扩散模型一样:给定一张图(或一段视频),随机采样一个时间步t,加噪得到x_t,然后让模型预测噪声。
def train_step(model, x, optimizer, scheduler): b = x.shape[0] t = torch.randint(0, num_timesteps, (b,), device=x.device) noise = torch.randn_like(x) x_t = scheduler.add_noise(x, noise, t) # 条件:时间步embedding + 文本embedding c = time_embed(t) + text_embed pred = model(x_t, t, c) loss = F.mse_loss(pred, noise) optimizer.zero_grad() loss.backward() optimizer.step() return loss.item()这里有几个细节:
时间步embedding。通常用正弦余弦位置编码,再过一个MLP。和Transformer里的位置编码类似,但这里是给时间步用的。
文本embedding。可以用CLIP、T5等预训练文本编码器提取。Sora用的是T5,CogVideoX用的是T5+CLIP。
损失函数。标准做法是预测噪声(epsilon prediction),也有预测速度(v prediction)或直接预测x0的。实测下来,epsilon prediction最稳,v prediction在少步采样时更好。
注意:训练视频DiT的时候,batch size不要设太大,因为视频的显存占用很高。我一般用梯度累积来模拟大batch,比如实际batch size 2,累积8次,等效batch size 16。
5. 常见问题与排查技巧实录
5.1 训练不收敛怎么办
DiT训练不收敛是最常见的问题,表现是loss震荡或者一直不下降。根据我的经验,排查顺序是这样的:
先看学习率。DiT对学习率很敏感,太大容易震荡,太小收敛慢。建议从1e-4开始试,配合warmup(前5000步线性升温)。如果loss震荡,降到5e-5试试。
再看adaLN的初始化。如果adaLN_modulation的最后一层没有初始化为零,训练初期gate值很大,整个Block的输出会爆炸。检查一下初始化代码,确保最后一层Linear的weight和bias都是零。
然后看位置编码。位置编码的初始化标准差如果太大,模型会过度依赖位置信息,忽略内容信息。0.02是安全值,不要随意改。
最后看数据。如果数据本身噪声很大、标注质量差,模型很难学好。建议先用一个小规模、高质量的数据集跑通,再上大规模数据。
5.2 显存不够怎么办
视频DiT的显存占用是最大的痛点。我整理了一个显存优化的优先级列表:
| 优化手段 | 显存节省 | 速度影响 | 实现难度 |
|---|---|---|---|
| 梯度检查点 | 40-60% | 慢20-30% | 低 |
| 混合精度bf16 | 30-40% | 快10-20% | 低 |
| 梯度累积 | 按累积步数线性 | 慢 | 低 |
| Flash Attention | 20-30% | 快 | 中 |
| 模型并行 | 按卡数线性 | 通信开销 | 高 |
| 序列并行 | 30-50% | 通信开销 | 高 |
我的建议是:先开梯度检查点和bf16,这两个加起来能省一半以上显存,而且实现简单。如果还不够,再考虑Flash Attention和序列并行。
踩过的坑:梯度检查点虽然省显存,但会让训练速度慢20-30%。如果显存够用,不建议开。另外,bf16在某些算子上的数值稳定性不如fp32,如果遇到loss NaN,试试把LayerNorm和softmax换成fp32。
5.3 生成视频闪烁、不连贯怎么办
视频生成最头疼的就是时序不一致。常见原因和解决方法:
原因一:时间注意力太弱。如果是双流DiT,时间注意力的权重可能太小。可以试试增大时间注意力的head数,或者在时间注意力后加一个额外的MLP。
原因二:训练数据时序不一致。如果训练视频本身就有很多剪辑、跳变,模型学到的就是这种不一致。建议筛选训练数据,优先用长镜头、少剪辑的视频。
原因三:位置编码外推。如果训练时用16帧,推理时用32帧,位置编码需要外推。可学习的位置编码外推能力差,建议用正弦余弦位置编码,或者训练时就随机采样不同长度的视频。
原因四:采样步数太少。扩散模型采样步数太少会导致细节丢失和时序不一致。试试增加采样步数,或者用更好的调度器(如DPM-Solver++)。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| loss震荡 | 学习率太大 | 打印每步loss | 降低学习率,加warmup |
| loss不下降 | adaLN初始化问题 | 检查gate值 | 最后一层Linear初始化为零 |
| 生成全黑/全白 | 训练不充分 | 看loss曲线 | 增加训练步数 |
| 视频闪烁 | 时间注意力弱 | 可视化注意力图 | 增大时间注意力权重 |
| 显存OOM | batch太大 | 看显存占用 | 开梯度检查点,减小batch |
| 生成内容与文本不符 | 文本编码器弱 | 检查文本embedding | 换更强的文本编码器 |
| 采样速度慢 | 步数太多 | 计时 | 用DPM-Solver++,减少步数 |
6. DiT之后:视频生成的下一个剧本是什么
DiT统一了视频生成的主干网络,但这不代表故事结束了。从2025年的趋势来看,有几个方向正在成为新的“剧本”。
第一个方向是原生3D一致性。现在的DiT大多是在2D patch上加时间维度,本质上还是“2D+时间”。但真正的3D视频生成需要理解场景的几何结构,保证不同视角下物体的一致性。NeRF和3D Gaussian Splatting和DiT的结合是一个热门方向。
第二个方向是长视频生成。现在的模型大多只能生成几秒到十几秒的视频,再长就崩了。怎么让DiT处理分钟级甚至小时级的视频,是一个开放问题。可能的思路是层次化生成:先生成关键帧,再插帧,再细化。
第三个方向是实时生成。现在的扩散模型采样需要几十步,速度很慢。一致性模型(Consistency Model)、对抗扩散蒸馏(ADD)等技术可以把采样步数降到1-4步,实现实时生成。这对交互式应用很关键。
第四个方向是多模态统一。DiT不仅能生成视频,还能生成图像、音频、3D模型。用一个统一的Transformer处理所有模态,是很多大厂的长期目标。Sora的技术报告里也提到了“世界模拟器”的愿景。
我个人的判断是:DiT作为主干网络的地位在短期内不会被动摇,但围绕它的采样效率、时长扩展、多模态融合会有很多创新。如果你现在入局视频生成,把DiT吃透是性价比最高的选择,因为它的设计思想会渗透到后续很多模型里。
最后分享一个我自己的学习路径:先跑通一个最小的图像DiT(比如MNIST或CIFAR-10),理解adaLN和patch化的细节;然后换成视频DiT,用UCF101或WebVid的小子集跑通;最后再上大规模数据和预训练模型。这个过程大概需要两到三周,但走完之后,你看任何视频生成模型的论文都会觉得“似曾相识”。