扩散模型原理与工程实践

扩散模型用加噪与去噪取代对抗博弈,成为图像生成的事实标准。本文系统讲解前向加噪与反向去噪的数学推导、DDPM 到 DDIM 与 DPM-Solver 的采样加速、无分类器引导与条件控制的工程实现、潜空间扩散与 U-Net/DiT 架构、LoRA/DreamBooth/ControlNet 微调路径,以及步数蒸馏、量化与显存优化的部署成本账。

生成模型的三条主流路线里,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 倍。

高阶求解器与蒸馏

方法步数原理代价
DDPM1000随机反向链慢
DDIM20~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 / DoRA10~100 张低风格、主体、概念
DreamBooth3~5 张中特定主体
Textual Inversion3~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——都在回答同一个问题:如何在保住质量的前提下把生成成本压到可接受。理解这条主线,就能在遇到新采样器、新架构时迅速判断它改的是哪一环,以及值不值得迁移。它与其他视觉模型的关系,可以参考 计算机视觉综述 与 模型压缩 的相关章节;推理侧的通用加速手段则与 大模型推理优化 高度共通。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「ai」更多文章

  1. 排序学习与搜索召回排序系统
  2. 数据版本控制与血缘:DVC 与 LakeFS
  3. 模型可解释性:SHAP、LIME 与注意力归因