多模态大模型这两年火得一塌糊涂,但真到动手搭的时候,第一个卡住大多数人的问题往往不是数据、不是训练策略,而是Backbone到底选什么。我前后折腾过好几个图像生成和图文理解的项目,从早期拿Unet硬怼,到后来全面转向Transformer架构,中间踩的坑能写满一个笔记本。这篇就围绕“从0搭建一个SD”这件事,把Backbone选型的来龙去脉、Unet和Transformer各自的脾气、以及一个能跑起来的最小实现路径,掰开揉碎讲清楚。不管你是刚接触扩散模型的新手,还是已经调过几个模型但没深究过结构的老手,应该都能从里面抠出点有用的东西。
1. 先搞清楚Backbone在SD里到底扮演什么角色
很多人一上来就纠结“Unet还是Transformer”,但连Backbone在整条链路里干什么都没想明白,选型就成了拍脑袋。我先把这层窗户纸捅破。
1.1 扩散模型的三段式结构里,Backbone是那个“干重活”的
一个标准的Stable Diffusion类模型,拆开来看其实是三块:文本编码器负责把提示词变成语义向量,**Backbone(去噪网络)**负责在潜空间里一步步把噪声还原成图像特征,**解码器(VAE Decoder)**负责把潜空间特征还原成像素图。文本编码器通常用CLIP或者T5这类现成的,解码器也是固定的VAE,真正决定模型能力上限、参数量大头、训练成本大头的,就是中间那个Backbone。
打个比方,文本编码器像是“翻译官”,把你的话翻译成模型能懂的语言;VAE解码器像是“画师的手”,负责最后落笔;而Backbone是“画师的脑子”,它要在几十步里去判断“当前这团噪声里哪些该去掉、哪些该保留、往哪个方向走”。这个判断的质量,直接决定了出图的构图、细节、语义一致性。所以Backbone选型不是审美问题,是能力问题。
1.2 为什么会有Unet和Transformer两条路线之争
早期扩散模型(DDPM那一批)几乎清一色用Unet,因为Unet天生适合做“图像到图像”的稠密预测,跳跃连接能把浅层细节直接送到深层,去噪这种任务简直是量身定做。SD 1.5、SD 2.1都是Unet backbone,稳定、成熟、生态好。
但后来DiT(Diffusion Transformer)出来了,Sora那一波又把Transformer路线推到了台前。Transformer的优势在于可扩展性——参数量往上堆的时候,性能提升曲线比Unet更平滑,而且天然适合处理多模态输入(文本token和图像token可以拼在一起做注意力)。这就是为什么新一代模型越来越多往Transformer靠。
提示:选型不是“新的就一定好”。Unet在中小规模、单模态图像生成上依然能打,Transformer在超大规模、多模态联合建模上更有优势。你的项目规模决定了你该站哪边。
1.3 从0搭SD,先明确你要的是“能跑”还是“能扩”
我见过太多人一上来就想复现一个Sora级别的架构,结果卡在显存和训练成本上动弹不得。从0搭建,我的建议是先分清目标:如果你是想理解原理、跑通一个能出图的最小闭环,Unet路线成熟度高、调试成本低,两三天能出第一张图;如果你是想做多模态联合训练、为后续扩展留空间,那从一开始就上Transformer架构,哪怕先做小规模,也比后期重构省事。
下面这张表是我自己总结的两条路线对比,选型时可以直接对照:
| 维度 | Unet Backbone | Transformer Backbone(DiT类) |
|---|---|---|
| 参数量规模 | 通常0.8B~3B | 可轻松扩展到数十B |
| 训练稳定性 | 高,收敛行为可预测 | 需要更精细的学习率调度 |
| 显存占用 | 相对友好 | 序列长度一长就吃显存 |
| 多模态扩展 | 需要额外设计融合模块 | 天然支持token拼接 |
| 生态成熟度 | 极高,参考实现多 | 中等,仍在快速演进 |
| 适合场景 | 单模态图像生成、快速验证 | 多模态联合、大规模扩展 |
2. Unet路线:成熟稳重,但别指望它无限扩展
既然要从0搭,我先把Unet这条路走一遍,因为它是理解扩散模型去噪机制最好的入口。你把Unet啃透了,再去看DiT,会发现很多设计思想是一脉相承的。
2.1 Unet的核心结构拆解:编码、瓶颈、解码三段
Unet之所以叫U,是因为它的结构像个字母U:左边一路下采样(编码器),中间一个瓶颈层,右边一路上采样(解码器),左右之间用跳跃连接(skip connection)连起来。在SD里,这个Unet还额外接入了时间步嵌入(timestep embedding)和文本条件(cross-attention),所以它其实是个“条件Unet”。
编码器负责逐层提取特征、降低分辨率,把空间信息压缩成语义信息;瓶颈层在最低分辨率上做全局推理;解码器再逐层上采样恢复分辨率。跳跃连接的作用是把编码器浅层的高频细节直接送到解码器对应层,避免上采样过程中细节丢失。这个设计对去噪任务特别关键——因为去噪既要理解全局语义(这团噪声整体该往哪个方向走),又要保留局部细节(边缘、纹理不能糊)。
我实际搭的时候,编码器和解码器各用4个stage,每个stage包含若干个ResBlock加注意力层,通道数从128起步逐级翻倍到512,瓶颈层再翻一次。这个配置在单卡24G显存上能跑batch size 4左右,属于能验证效果的入门配置。
2.2 时间步和文本条件是怎么注入的
这是Unet做条件生成的关键,也是新手最容易写错的地方。时间步注入用的是正弦位置编码加MLP:把标量时间步t映射成一个高维向量,然后通过全连接层投影到每个ResBlock的通道维度,以加法方式注入。文本条件注入用的是cross-attention:在Unet的中间层和上采样层里插入cross-attention模块,query来自图像特征,key和value来自文本编码器的输出。
这里有个实操细节:cross-attention不要加得太早。我一开始在每个stage都塞了cross-attention,结果训练时文本条件“压过”了图像自身的去噪信号,出图语义对了但构图崩了。后来改成只在瓶颈层和上采样阶段加,效果立刻正常。这个经验在官方文档里不会写,但实测很关键。
# 时间步嵌入的简化实现 import torch import torch.nn as nn import math class TimestepEmbedding(nn.Module): def __init__(self, dim, freq_dim=256): super().__init__() self.freq_dim = freq_dim self.mlp = nn.Sequential( nn.Linear(freq_dim, dim * 4), nn.SiLU(), nn.Linear(dim * 4, dim) ) def forward(self, t): # t: [batch] half = self.freq_dim // 2 freqs = torch.exp( -math.log(10000) * torch.arange(half, device=t.device) / half ) args = t[:, None].float() * freqs[None] emb = torch.cat([torch.cos(args), torch.sin(args)], dim=-1) return self.mlp(emb)2.3 从0搭Unet的实操步骤与参数选择
我把搭建流程拆成五步,按顺序走基本不会乱:
- 确定潜空间分辨率。SD用的是VAE把512x512图像压到64x64x4的潜空间,所以Unet的输入是64x64、4通道。这个分辨率决定了你的下采样次数——64到32到16到8,4个stage刚好,再多就压没了。
- 定通道数基数。入门用128,显存紧张用64,追求效果用192。通道数基数乘以2的stage次方就是各层通道。
- 堆ResBlock和注意力。每个stage至少2个ResBlock,注意力层在低分辨率stage加即可,高分辨率stage加注意力显存吃不消。
- 接条件注入。时间步嵌入全局注入,文本条件用cross-attention选择性注入。
- 写输出层。最后一层用卷积把通道数映射回4(潜空间通道数),输出预测的噪声。
参数选择上有个经验公式:总参数量控制在0.8B到1.2B之间,这个区间在单卡或小集群上训练成本可控,效果也够看。超过这个量级,Unet的扩展收益开始递减,不如换Transformer。
注意:Unet的下采样用卷积stride=2还是用池化,我建议用卷积,因为可学习。池化会丢信息且不可逆,在去噪任务里是减分项。
2.4 Unet路线的坑:我踩过的三个典型问题
第一个坑是跳跃连接的特征对齐。编码器和解码器对应层的通道数、分辨率必须严格一致,否则拼接会报错。我一开始通道数设计成不对称,调试了半天才发现是拼接维度对不上。
第二个坑是注意力层的显存爆炸。在64x64分辨率上做全局注意力,序列长度4096,注意力矩阵是4096x4096,单这一层就能吃掉好几个G。解决办法是在高分辨率用窗口注意力或者干脆不加,只在16x16、8x8这些低分辨率层加全局注意力。
第三个坑是训练初期loss震荡。Unet对学习率比较敏感,我实测用1e-4起步、配合cosine退火比较稳,用1e-3直接发散。另外EMA(指数移动平均)对Unet特别重要,不加EMA出图质量明显差一截。
3. Transformer路线:为扩展而生,但要伺候好它
Unet跑通之后,我把同样的任务用DiT架构重写了一遍,感受完全是另一个世界——灵活是真灵活,难调也是真难调。
3.1 DiT的核心思想:把图像切成patch当token处理
DiT(Diffusion Transformer)的思路很直接:既然Transformer擅长处理序列,那就把潜空间特征图切成一个个patch,每个patch展平成一个token,加上位置编码,然后丢进标准的Transformer block里。文本条件通过cross-attention或者adaLN(自适应层归一化)注入。
和Unet最大的区别在于:DiT没有下采样和上采样,全程保持同一分辨率。这意味着它的计算量随分辨率是平方级增长的,所以patch size的选择很关键。SD潜空间是64x64,patch size取2的话就是32x32=1024个token,这个长度在可接受范围;取1就是4096个token,显存直接翻几倍。
我实测下来,patch size=2是性价比最高的选择,1024个token的序列长度,配合12到24层Transformer block,参数量能控制在0.6B到1.5B之间,单卡能训。
3.2 adaLN条件注入:比cross-attention更优雅的方案
DiT论文里提了一个很聪明的设计:用adaLN-Zero注入条件。具体做法是把时间步嵌入和类别/文本嵌入相加,通过MLP预测出每个Transformer block的scale和shift参数,对LayerNorm的输出做仿射变换。最后一层还额外预测一个gate参数,初始化为0,这样训练初期整个block相当于恒等映射,训练非常稳定。
这个设计比cross-attention优雅在哪?cross-attention要额外引入一组注意力参数,计算量不小;adaLN只是对归一化层做调制,参数少、计算省,而且初始化成0保证了训练稳定性。我在项目里两种都试过,adaLN收敛更快、显存更省,唯一缺点是文本条件的细粒度控制稍弱——如果你的任务需要精确的局部文本对齐,cross-attention还是更合适。
# adaLN-Zero 的简化实现 class DiTBlock(nn.Module): def __init__(self, dim, num_heads): super().__init__() self.norm1 = nn.LayerNorm(dim, elementwise_affine=False) self.attn = nn.MultiheadAttention(dim, num_heads, batch_first=True) self.norm2 = nn.LayerNorm(dim, elementwise_affine=False) self.mlp = nn.Sequential( nn.Linear(dim, dim * 4), nn.GELU(), nn.Linear(dim * 4, dim) ) # 6个调制参数:attn的shift/scale/gate + mlp的shift/scale/gate self.adaLN_modulation = nn.Sequential( nn.SiLU(), nn.Linear(dim, 6 * dim) ) # 零初始化gate,保证训练初期恒等 nn.init.zeros_(self.adaLN_modulation[-1].weight) nn.init.zeros_(self.adaLN_modulation[-1].bias) def forward(self, x, c): shift1, scale1, gate1, shift2, scale2, gate2 = \ self.adaLN_modulation(c).chunk(6, dim=-1) h = self.norm1(x) * (1 + scale1.unsqueeze(1)) + shift1.unsqueeze(1) h, _ = self.attn(h, h, h) x = x + gate1.unsqueeze(1) * h h = self.norm2(x) * (1 + scale2.unsqueeze(1)) + shift2.unsqueeze(1) h = self.mlp(h) x = x + gate2.unsqueeze(1) * h return x3.3 从0搭DiT的关键参数与训练配置
DiT的搭建比Unet简单——没有复杂的上下采样结构,就是patchify、堆block、unpatchify三步。但训练配置比Unet讲究得多:
- 学习率:DiT对学习率更敏感,我用1e-4配合warmup 5000步,比Unet的warmup要长。
- 权重衰减:0.01到0.05之间,太高会欠拟合。
- 梯度裁剪:必须开,阈值1.0,Transformer的梯度比Unet更容易爆。
- 混合精度:bf16比fp16稳,fp16在DiT上容易出现梯度下溢。
patchify的实现要注意:用卷积实现比用reshape+linear更高效,kernel size和stride都等于patch size,输出通道就是hidden dim。unpatchify用转置卷积或者pixel shuffle都行,我用的转置卷积,实测更稳。
3.4 Transformer路线的坑:显存、收敛、位置编码
第一个坑还是显存。1024个token、24层、hidden dim 1152的DiT,单卡24G跑batch size 2都紧张。解决办法是梯度检查点(gradient checkpointing),用时间换显存,能省40%左右,代价是训练慢20%。
第二个坑是收敛慢。DiT前期loss下降比Unet慢,我一度以为是自己写错了,后来对比论文才发现这是正常现象——Transformer需要更多步数才能“找到感觉”。耐心训到2万步左右,loss曲线才开始和Unet持平。
第三个坑是位置编码。DiT用的是固定的2D正弦位置编码,我试过换成可学习的,效果反而差一点,因为可学习位置编码在分辨率变化时泛化性差。如果你的项目需要多分辨率训练,固定编码更稳妥。
4. 两条路线的融合与选型决策:别非黑即白
搭完两套之后我最大的感受是:Unet和Transformer不是对立关系,实际项目里经常是混着用的。
4.1 混合架构:Unet的骨架加Transformer的注意力
现在不少工作走的是混合路线——保留Unet的上下采样结构,但把里面的卷积注意力换成Transformer block。这样既有Unet的多尺度归纳偏置,又有Transformer的长程建模能力。我试过在Unet的瓶颈层和最低分辨率stage用Transformer block替换,出图的全局一致性有提升,显存增加在可接受范围。
这种混合架构的实操要点是:只在低分辨率层换Transformer,高分辨率层保持卷积,否则显存扛不住。另外Transformer block的初始化要小心,建议用零初始化输出投影,保证训练初期不破坏原有Unet的行为。
4.2 选型决策表:按你的实际约束来选
我把选型时该问自己的问题整理成一张表,照着填基本不会选错:
| 你的约束 | 推荐路线 | 理由 |
|---|---|---|
| 单卡24G以内,快速出效果 | Unet | 成熟、省显存、调试快 |
| 需要多模态联合训练 | Transformer | token拼接天然支持 |
| 参数量要上到3B以上 | Transformer | 扩展曲线更优 |
| 团队没有Transformer调参经验 | Unet | 收敛行为更可预测 |
| 要做视频/长序列生成 | Transformer | 序列建模能力强 |
| 推理延迟要求极低 | Unet | 结构轻、推理快 |
4.3 从0搭SD的完整最小闭环
不管选哪条路线,一个能跑的最小闭环都包含这几块:VAE(可以直接用现成的)、文本编码器(CLIP现成的)、Backbone(自己搭)、训练循环(噪声采样、前向、loss、反向)、采样器(DDIM或DPM-Solver)。我的建议是先把VAE和文本编码器用现成的冻结住,只训Backbone,这样能把问题聚焦。
训练循环里有个容易忽略的点:噪声调度。linear schedule和cosine schedule出图风格不一样,cosine在低噪声段更细腻,linear在高噪声段更激进。我默认用cosine,配合v-prediction,实测比epsilon-prediction在细节上更好。
提示:第一次跑通不要追求出图质量,先确认loss能稳定下降、采样能出有结构的图,再逐步调参。我第一版跑出来的图全是噪点,排查后发现是噪声采样和设备没对齐,这种低级错误新手很容易犯。
5. 实操排查:那些文档里不会写的坑
搭模型的过程里,报错和异常是常态。我把高频问题和排查思路整理出来,省得你重复踩。
5.1 训练不收敛的排查顺序
遇到loss不降,按这个顺序查:先看数据管道有没有问题(噪声和图像是否对齐、归一化范围对不对),再看学习率和warmup,然后看条件注入有没有写错(时间步嵌入维度、文本mask),最后看初始化。我遇到过一次loss完全不降,查了两天才发现是时间步嵌入的freq_dim和MLP输入维度对不上,静默广播成了错误形状,这种bug最坑。
5.2 出图质量差的典型原因对照表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 图全糊 | 训练步数不够或学习率太低 | 加步数、调学习率 |
| 语义对但构图乱 | 文本条件注入过强 | 减少cross-attention层数 |
| 颜色异常 | VAE解码问题或归一化错误 | 检查VAE输入范围 |
| 局部重复纹理 | 注意力坍缩 | 加dropout或调温度 |
| 高噪声步出图崩 | 噪声调度不匹配 | 换cosine schedule |
5.3 显存优化的几个实用手段
显存不够是常态,我常用的手段按性价比排序:梯度检查点(省40%,慢20%)、bf16混合精度(省30%左右)、把优化器状态换成分片存储、减小batch size配合梯度累积。最后这个最容易被忽略——batch size 1配合梯度累积8步,效果和batch size 8接近,显存却省了8倍。
6. 我个人在Backbone选型上的几点体会
折腾完这几轮,我最大的体会是:Backbone选型本质是在“归纳偏置”和“可扩展性”之间做权衡。Unet的卷积和跳跃连接是强归纳偏置,它“知道”图像是局部相关的、多尺度的,所以小数据小算力下表现好;Transformer的归纳偏置弱,它靠数据和规模去学这些规律,所以规模上去了才显出优势。
如果你的项目是验证想法、快速迭代,别犹豫,Unet起步,两三天能出结果。如果你明确要做多模态、要往大规模走,那从一开始就上Transformer,哪怕先做小规模,架构上的债早还比晚还省事。混合架构是个不错的折中,但别为了混合而混合,先想清楚你要解决的是全局一致性问题还是局部细节问题。
最后分享一个我调试时的小习惯:每次改Backbone结构,先用一个极小的数据集(比如几十张图)过拟合一遍。如果连几十张图都过拟合不了,说明结构或训练配置有硬伤,别急着上大数据集浪费时间。这个“过拟合小数据集”的土办法,帮我省下的调试时间比任何工具都多。