生成模型的三条主流路线里,GAN 靠对抗博弈、VAE 靠变分下界、扩散模型靠逐步去噪。扩散模型的训练目标简单到近乎朴素——预测你刚刚加进去的那点噪声——却因为分布覆盖完整、训练稳定、模式不塌陷,把图像生成的质量推到了新的高度。它也是 多模态部署 与视觉生成系统的默认底座。
扩散模型的工程价值不在于公式漂亮,而在于它把「生成」拆成了可以反复复用同一网络的一长串小步骤:每一步都足够简单,因此训练极其稳定,代价是采样很慢。整个工程史,本质上就是围绕「如何把步数压下来」展开的。
从生成模型到扩散
三类生成范式的取舍
| 范式 | 训练信号 | 采样速度 | 主要痛点 |
|---|---|---|---|
| GAN | 判别器对抗 | 单步 | 训练不稳、模式塌陷 |
| VAE | 变分下界 | 单步 | 生成模糊、后验坍缩 |
| 扩散 | 去噪回归 | 多步(10~1000) | 采样慢、算力开销大 |
扩散模型之所以胜出,是因为它的训练目标是一个逐像素(或逐 latent)的回归任务:给定带噪样本和时刻 t,预测被加入的噪声。回归任务的损失面平滑,不像对抗训练那样容易震荡,也不像 VAE 那样需要精心平衡重建项与 KL 项。
核心直觉:把生成拆成去噪
想象一张照片被逐渐撒上雪花,直到变成纯高斯噪声。如果我们能学会「每一步把雪花擦掉一点」的逆过程,那么从纯噪声出发,反复擦除,就能凭空生成一张照片。关键在于:逆过程每一步的幅度都很小,小到可以用高斯分布近似,因此每一步都能用一个神经网络来学。
前向过程:把数据毁掉
前向过程是一个固定的马尔可夫链,不含任何可学习参数。给定真实样本 x0,按方差调度表 β1..βT 逐步加噪:
q(x_t | x_{t-1}) = N(x_t; sqrt(1-β_t) x_{t-1}, β_t I)
重参数化:一步到位
马尔可夫链的妙处在于可以跳步。定义 α_t = 1 - β_t、ᾱ_t = ∏_{s≤t} α_s,则有闭式解:
q(x_t | x_0) = N(x_t; sqrt(ᾱ_t) x_0, (1-ᾱ_t) I)
x_t = sqrt(ᾱ_t) x_0 + sqrt(1-ᾱ_t) ε, ε ~ N(0, I)
这个式子是整个扩散模型的工程基石:训练时不需要真的迭代 T 步加噪,直接采样一个 t,一次算出 x_t。这让训练成本与 T 无关。
方差调度的选择
| 调度 | 形式 | 特点 |
|---|---|---|
| 线性 | β 线性增长 | 原始 DDPM,末段噪声过密 |
| 余弦 | ᾱ 按余弦衰减 | 低噪声段更细腻,图像质量更好 |
| Sigmoid | β 经 sigmoid 映射 | 折中方案,稳定性好 |
import numpy as np
def linear_beta_schedule(T=1000, beta_start=1e-4, beta_end=0.02):
return np.linspace(beta_start, beta_end, T, dtype=np.float64)
def cosine_alpha_bar(T=1000, s=0.008):
steps = np.arange(T + 1, dtype=np.float64) / T
f = np.cos((steps + s) / (1 + s) * np.pi / 2) ** 2
alpha_bar = f / f[0]
betas = np.clip(1 - alpha_bar[1:] / alpha_bar[:-1], 1e-8, 0.999)
return betas.astype(np.float64)
def q_sample(x0, t, alpha_bar, noise=None):
"""x0: (B, C, H, W), t: (B,) 整数索引"""
if noise is None:
noise = np.random.randn(*x0.shape)
ab = alpha_bar[t].reshape(-1, 1, 1, 1)
return np.sqrt(ab) * x0 + np.sqrt(1 - ab) * noise
调度表决定了「信号在哪个时刻彻底消失」。如果末段 ᾱ 衰减太慢,模型会在高噪声区浪费大量容量去拟合几乎无信息的输入;如果衰减太快,低噪声细节又学不好。余弦调度之所以成为 Stable Diffusion 的默认,就是因为它把有效学习区间拉得更宽。
反向过程:学一个去噪网络
反向过程 p_θ(x_{t-1} | x_t) 用神经网络参数化。经过推导,反向均值可以写成:
μ_θ(x_t, t) = (1/sqrt(α_t)) * (x_t - (β_t / sqrt(1-ᾱ_t)) * ε_θ(x_t, t))
也就是说,网络只需要预测噪声 ε_θ,其余都是闭式系数。
训练目标:简单到令人意外
完整的变分下界经过化简,去掉加权系数后得到最常用的简单损失:
L_simple = E_{t, x0, ε} [ || ε - ε_θ(sqrt(ᾱ_t) x0 + sqrt(1-ᾱ_t) ε, t) ||² ]
import torch
import torch.nn.functional as F
def diffusion_loss(model, x0, alpha_bar, device="cuda"):
B = x0.shape[0]
t = torch.randint(0, len(alpha_bar), (B,), device=device)
noise = torch.randn_like(x0)
ab = torch.as_tensor(alpha_bar, device=device)[t].view(-1, 1, 1, 1)
x_t = ab.sqrt() * x0 + (1 - ab).sqrt() * noise
pred = model(x_t, t)
return F.mse_loss(pred, noise)
这就是全部的训练代码。没有判别器,没有 KL 项,没有模式塌陷,一个 MSE 就结束了。
为什么预测噪声而不是预测 x0
预测 x0、预测噪声 ε、预测速度 v = α ε - σ x0 在数学上等价,只是损失权重不同。预测噪声在低噪声段隐式地给细节更高的权重,实测生成质量最好;v-prediction 在高噪声段更稳,常用于视频与大分辨率模型。选择哪种参数化,往往比调学习率更影响最终效果。
采样加速:从 1000 步到 4 步
DDPM 原始采样需要 1000 步,单张图动辄几十秒,这是扩散模型落地的最大障碍。
DDIM:确定性采样
DDIM 把反向过程改写为非马尔可夫的确定性映射,允许跳步采样。当方差取 0 时,采样完全确定,同样的噪声种子必得同样的图:
@torch.no_grad()
def ddim_sample(model, shape, alpha_bar, steps=50, eta=0.0, device="cuda"):
x = torch.randn(shape, device=device)
ts = torch.linspace(len(alpha_bar) - 1, 0, steps).long().to(device)
for i in range(len(ts) - 1):
t, t_prev = ts[i], ts[i + 1]
ab_t = alpha_bar[t]
ab_prev = alpha_bar[t_prev]
eps = model(x, t.expand(shape[0]))
x0_pred = (x - (1 - ab_t).sqrt() * eps) / ab_t.sqrt()
x0_pred = x0_pred.clamp(-1, 1)
sigma = eta * ((1 - ab_prev) / (1 - ab_t)).sqrt() * (1 - ab_t / ab_prev).sqrt()
dir_xt = (1 - ab_prev - sigma ** 2).sqrt() * eps
x = ab_prev.sqrt() * x0_pred + dir_xt
if sigma > 0:
x = x + sigma * torch.randn_like(x)
return x
50 步 DDIM 通常就能达到 1000 步 DDPM 的质量,采样开销直接降 20 倍。
高阶求解器与蒸馏
| 方法 | 步数 | 原理 | 代价 |
|---|---|---|---|
| DDPM | 1000 | 随机反向链 | 慢 |
| DDIM | 20~100 | 确定性跳步 | 略损细节 |
| DPM-Solver++ | 10~25 | 把采样当 ODE 用高阶求解 | 需调阶数 |
| LCM / 一致性蒸馏 | 2~8 | 蒸馏出自一致性映射 | 需重训学生 |
| 对抗蒸馏(ADD) | 1~4 | 生成器直接对抗判别 | 训练复杂 |
工程决策顺序:先上 DPM-Solver++(零训练成本,立竿见影);步数还要再压才考虑 LCM 蒸馏;只有在极低延迟场景(实时交互)才值得做对抗蒸馏。
条件控制与引导
无条件扩散只能随机生成,真正的应用需要「按提示词生成」。
Classifier Guidance 与 CFG
Classifier Guidance 需要额外训练一个噪声分类器,工程上很麻烦。**Classifier-Free Guidance(CFG)**把条件与无条件预测做外推:
ε_guided = ε_uncond + s * (ε_cond - ε_uncond)
@torch.no_grad()
def cfg_step(model, x, t, cond, guidance_scale=7.5):
# 一次前向同时算条件与无条件,batch 翻倍
eps_uncond, eps_cond = model(torch.cat([x, x]), torch.cat([t, t]),
torch.cat([torch.zeros_like(cond), cond]))
return eps_uncond + guidance_scale * (eps_cond - eps_uncond)
guidance_scale 是最重要的推理超参:太小则提示词不生效,太大则过饱和、色彩失真、多样性塌陷。图像模型常用 59,视频模型常压到 35。
训练时的条件丢弃
CFG 要求模型同时具备条件与无条件能力,训练时以约 10% 概率把条件置空即可:
def drop_condition(cond, p_uncond=0.1):
mask = torch.rand(cond.shape[0], device=cond.device) < p_uncond
cond = cond.clone()
cond[mask] = 0 # 空条件 embedding
return cond
结构化条件:ControlNet 与 IP-Adapter
文本条件之外,还需要姿态、深度、边缘、参考图等控制。ControlNet 的做法是复制一份编码器并加零卷积旁路,这样在零初始化时输出与原始模型完全一致,训练稳定:
ControlNet: x -> [冻结主干] -> + [可训练副本] -> [zero-conv] -> 输出
零卷积是关键:初始权重为 0,保证训练开始时不会污染预训练模型,避免了微调初期的大幅震荡。
潜空间扩散与主干架构
为什么要在潜空间做扩散
像素空间扩散在 512×512 上要处理 786k 维,算力开销巨大。Latent Diffusion 先用 VAE 把图像压到 64×64×4 的潜空间(压缩 48 倍),在潜空间做扩散,最后解码回像素:
| 空间 | 分辨率 | 单步算力 | 生成质量 |
|---|---|---|---|
| 像素空间 | 512×512×3 | 高 | 高但贵 |
| 潜空间 | 64×64×4 | 低约 48 倍 | 接近像素级 |
VAE 的重建质量决定了生成质量的天花板:如果 VAE 解码模糊,扩散学得再好也白搭。这也是为什么社区会专门优化 VAE 的 fp16 数值稳定性。
U-Net 与 DiT
Stable Diffusion 1.x/2.x 用 U-Net:编码器降采样、解码器升采样、跳跃连接保留细节,并在每个分辨率块插入 cross-attention 注入文本条件。
SD3 与后续模型转向 DiT(Diffusion Transformer):把潜空间切成 patch,用标准 Transformer 处理,条件通过 adaLN 调制。DiT 的扩展性更好——加参数、加算力就能稳定涨点,且与 LLM 的基础设施高度复用。
class DiTBlock(torch.nn.Module):
def __init__(self, dim, n_heads):
super().__init__()
self.norm1 = torch.nn.LayerNorm(dim, elementwise_affine=False)
self.attn = torch.nn.MultiheadAttention(dim, n_heads, batch_first=True)
self.norm2 = torch.nn.LayerNorm(dim, elementwise_affine=False)
self.mlp = torch.nn.Sequential(
torch.nn.Linear(dim, 4 * dim), torch.nn.GELU(), torch.nn.Linear(4 * dim, dim))
self.adaLN = torch.nn.Sequential(torch.nn.SiLU(), torch.nn.Linear(dim, 6 * dim))
def forward(self, x, cond):
shift1, scale1, gate1, shift2, scale2, gate2 = self.adaLN(cond).chunk(6, dim=-1)
h = self.norm1(x) * (1 + scale1.unsqueeze(1)) + shift1.unsqueeze(1)
x = x + gate1.unsqueeze(1) * self.attn(h, h, h, need_weights=False)[0]
h = self.norm2(x) * (1 + scale2.unsqueeze(1)) + shift2.unsqueeze(1)
return x + gate2.unsqueeze(1) * self.mlp(h)
adaLN 把时间步与类别条件编码成 scale/shift/gate,替代了 U-Net 的 cross-attention 注入。工程上这更简洁:条件不再是序列,而是逐层的调制向量。
微调:把通用模型变成专用模型
三条微调路线
| 路线 | 数据量 | 显存 | 适用 |
|---|---|---|---|
| 全量微调 | 万级以上 | 极高 | 领域大改 |
| LoRA / DoRA | 10~100 张 | 低 | 风格、主体、概念 |
| DreamBooth | 3~5 张 | 中 | 特定主体 |
| Textual Inversion | 3~5 张 | 极低 | 新概念词 |
LoRA 在注意力层注入低秩旁路,只训练 A、B 两个小矩阵:
class LoRALinear(torch.nn.Module):
def __init__(self, base, rank=16, alpha=16):
super().__init__()
self.base = base
for p in self.base.parameters():
p.requires_grad = False
self.A = torch.nn.Parameter(torch.zeros(rank, base.in_features))
self.B = torch.nn.Parameter(torch.zeros(base.out_features, rank))
self.scale = alpha / rank
def forward(self, x):
return self.base(x) + (x @ self.A.T @ self.B.T) * self.scale
alpha/rank 的缩放让 rank 变化时不必重调学习率,是 LoRA 能开箱即用的关键设计。
ControlNet 与 IP-Adapter 的组合
ControlNet 管结构(姿态/深度/线稿),IP-Adapter 管风格(参考图)。两者可以叠加,但要注意条件冲突:如果文本说「红色」,参考图是蓝色,CFG 会放大矛盾。工程上应降低冲突条件的 scale,或用区域掩码分别控制。
训练稳定性与超参调优
扩散模型的训练虽然稳定,但并非没有坑。以下是长期实践中沉淀下来的关键超参经验。
学习率与 warmup
主干通常用 1e-4 ~ 2e-4 的峰值学习率,配 500~2000 步线性 warmup。步数超过 10 万后按余弦衰减到峰值的 10%。warmup 必不可少:训练初期网络对高噪声输入毫无概念,直接大学习率会让 ε_θ 输出爆炸,损失瞬间飞到 NaN。
import math
import torch
def lr_lambda(step, warmup=1000, total=100_000, min_ratio=0.1):
if step < warmup:
return step / max(1, warmup)
progress = (step - warmup) / max(1, total - warmup)
return min_ratio + (1 - min_ratio) * 0.5 * (1 + math.cos(math.pi * progress))
scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)
EMA 与梯度裁剪
扩散模型普遍使用**指数滑动平均(EMA)**权重做推理,衰减系数 0.9999。EMA 权重比训练权重平滑得多,采样质量显著更好:
class EMA:
def __init__(self, model, decay=0.9999):
self.decay = decay
self.shadow = {k: v.detach().clone().float()
for k, v in model.state_dict().items() if v.dtype.is_floating_point}
@torch.no_grad()
def update(self, model):
for k, v in model.state_dict().items():
if k in self.shadow:
self.shadow[k].mul_(self.decay).add_(v.float(), alpha=1 - self.decay)
def copy_to(self, model):
model.load_state_dict({k: v.to(model.state_dict()[k].dtype)
for k, v in self.shadow.items()}, strict=False)
梯度裁剪通常设 max_norm=1.0。如果裁剪触发频繁(超过 5% 的步数),说明学习率偏高或数据里有异常样本。
噪声预测的参数化陷阱
预测 ε、x0、v 三者的关系必须自洽,混用会导致采样发灰:
ε = (x_t - sqrt(ᾱ_t) x0) / sqrt(1-ᾱ_t)
v = sqrt(ᾱ_t) ε - sqrt(1-ᾱ_t) x0
采样代码里从 ε 反解 x0 时,务必用与训练一致的那套公式。曾见过团队把 v-prediction 训练的模型按 ε 去解,结果图永远是灰蒙蒙的一片,排查了整整两天。
数据质量决定上限
扩散模型对数据噪声的容忍度远低于分类模型:一张错标、带水印、分辨率不齐的图,会在生成时被放大成系统性缺陷。工程上必须做去重、美学过滤、分辨率分桶、caption 清洗四件事。caption 的质量甚至比数据量更重要——大量模型是因为 caption 太短、太模板化,导致文本控制能力弱。
部署与成本优化
显存与延迟的主要开销
| 环节 | 开销来源 | 优化手段 |
|---|---|---|
| 文本编码 | CLIP/T5 前向 | 缓存 prompt embedding |
| 去噪循环 | 每步一次主干前向 | 步数蒸馏、批处理 |
| VAE 解码 | 大分辨率解码 | tiling、fp32 数值修复 |
| 注意力 | 高分辨率 O(N²) | FlashAttention、xFormers |
量化与编译
扩散主干对量化相当敏感,因为每一步的误差都会累积放大。实践中的组合:
import torch
from diffusers import StableDiffusionXLPipeline
pipe = StableDiffusionXLPipeline.from_pretrained(
"stabilityai/stable-diffusion-xl-base-1.0",
torch_dtype=torch.float16,
variant="fp16",
)
pipe.to("cuda")
pipe.unet = torch.compile(pipe.unet, mode="reduce-overhead")
pipe.enable_vae_slicing() # 降低 VAE 峰值显存
pipe.enable_attention_slicing() # 显存换速度
# 生成
img = pipe(prompt="a lighthouse at dusk", num_inference_steps=25,
guidance_scale=7.0).images[0]
要点:
- 主干 fp16/bf16,VAE 用 fp32 或修复版 fp16,否则暗部会出现 NaN 或纯黑块。
torch.compile对去噪循环收益极大,因为同一形状反复调用,图捕获后单步开销可降 20~30%。- 提示词 embedding 可缓存,同一 prompt 批量生成时不必重复编码。
步数与批次的联合调优
单图延迟 ≈ 步数 × 单步耗时。批量生成时,单步耗时随 batch 增长远慢于线性(GPU 并行度),因此批量 8 张用 25 步往往比逐张 8 次更划算。但 batch 过大会触发显存溢出,需要梯度检查点式的分块策略。
排错与踩坑清单
- 生成全黑或全灰:通常是 VAE fp16 溢出或 CFG 过高。先把 VAE 切 fp32 验证。
- 提示词不生效:检查 CFG scale 是否过低(<3),或条件 embedding 是否被错误置空。
- 图与提示词无关但很清晰:条件注入的 cross-attention 层被错误冻结或 LoRA 覆盖。
- 训练 loss 不降:检查
ᾱ_t的索引是否与t对齐;t=0时ᾱ≈1,此时 loss 本就接近 0,若 loss 长期在 1.0 附近说明网络没学到东西。 - 采样出现伪影条纹:DDIM 步数过少(<10)且
eta=0,改用 DPM-Solver++ 或提高步数。 - 微调后通用能力崩溃:LoRA rank 过高或学习率过大,降低 rank 与 lr,或用 DoRA 稳定。
- 多卡推理结果不一致:确定性采样下不同卡精度(fp16 vs bf16)会导致微小差异,需要固定 dtype。
小结
扩散模型的核心是「前向加噪固定、反向去噪可学」这一非对称设计:训练目标退化为一个简单的噪声回归,换来的是极其稳定的训练过程。工程上的所有努力——DDIM、DPM-Solver、LCM 蒸馏、潜空间扩散、ControlNet、LoRA——都在回答同一个问题:如何在保住质量的前提下把生成成本压到可接受。理解这条主线,就能在遇到新采样器、新架构时迅速判断它改的是哪一环,以及值不值得迁移。它与其他视觉模型的关系,可以参考 计算机视觉综述 与 模型压缩 的相关章节;推理侧的通用加速手段则与 大模型推理优化 高度共通。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。