🏠 总目录📚 本教程 05 · Latent Diffusion ← →
📑 本页目录(点开跳转)

05 · Latent Diffusion:在压缩过的空间里扩散

⏱ 40 分钟 | ⭐ 为什么 Stable Diffusion 能在一张消费级显卡上跑,以及它为什么画不好小字


🎯 一句话

先用一个自编码器把图压小 48 倍,扩散全程只在压缩后的表示上做,最后再解码回像素 —— 省下的算力从几十倍到几千倍不等,代价是压缩那一步丢掉的细节,后面谁也补不回来。

上一章开头说过,想让出图变快只有两条路:少走几步(那是 DDIM),或者让每一步更便宜。这一章走的是第二条。


💸 一、先把账算出来:直接在像素上扩散有多贵

一张 512×512 的 RGB 图是 512 × 512 × 3 = 786432 个数。扩散的每一步都要把这 786432 个数喂进网络、再吐出同样多的数,中间的特征图分辨率也一路都是 512×512。而采样要走几十到上百步,步与步之间还是硬串行的。

拿 U-Net 主干里最普通的一层 3×3 卷积(128 通道进、128 通道出)算:在 512×512 上是 77.31 GFLOPs。走 50 步就是 3.87 TFLOPs —— 而这只是一层,一个 U-Net 有几十层。

真正劝退的是注意力。把每个空间位置当一个 token,512×512 就是 262144 个 token,注意力矩阵有 $262144^2 \approx 6.87\times10^{10}$ 项 —— 光存下这一个矩阵,按 fp16 算就要 137 GB,而这还只是一层里的一个头。

⭐ 所以问题不是「慢一点」,是根本放不下。 早期的像素空间扩散模型只能在 256×256 甚至 64×64 上训,再靠一串超分模型把图撑大。


🗜️ 二、解法:把扩散整个搬进潜空间

Latent Diffusion 的做法是把生成拆成三段:

① 编码:训练一个自编码器,编码器 $E$ 把 512×512×3 的图压成 64×64×4 的张量(空间边长下采样 8 倍,通道数 3→4)。 ② 扩散:前向加噪、训练目标、DDIM 采样,公式一个字都不改,只是作用对象从像素换成了这个 64×64×4。 ③ 解码:解码器 $D$ 把去噪完的潜变量还原回 512×512×3 的图。

压缩倍数就是 786432 ÷ 16384 = ⭐ 48 倍。Latent 这个词指的正是第 ② 步的作用对象。

⭐ 关键在于次数不对等:$E$ 和 $D$ 各只跑一次,而扩散要跑几十上百次。省掉的正是那个要乘以步数的部分 —— 编解码这点固定开销,摊到 50 步里几乎看不见。

扩散不在像素上做,在压缩过的潜空间里做512×512×3786432 个数E64×64×416384 个数D512×512×3还原回像素扩散在这里跑几十到上百步E 和 D 各跑一次,扩散跑几十上百次 —— 省的是次数不对等的那一头元素数省 48 倍 · 主干卷积省 64 倍 · 注意力省 4096 倍⚠️ 代价:E 在扩散之前跑,画质上限就被它钉死了
看中间那个小方块:扩散全程只在它上面跑。⭐ 三个倍数别混 —— 元素数只省 48 倍,但注意力省 4096 倍,越贵的层省得越狠,这才是它 work 的原因。

🔢 三、三个倍数,别混成一个

每一步里的这一项 像素空间 512×512×3 潜空间 64×64×4 倍数
要去噪的数的个数 786432 16384 48
一层 128→128 的 3×3 卷积 77.31 GFLOPs 1.208 GFLOPs 64
一层全局注意力的矩阵项数 $6.87\times10^{10}$ $1.68\times10^{7}$ 4096

⚠️ 三个数不一样,来路也不同:48 是元素个数之比(通道 3→4 抵消掉了一点空间上的压缩);主干里两边的通道数是一样的,差别只剩空间面积,就是 $8^2=64$;而注意力的代价按面积的平方走,于是 $(8^2)^2 = 4096$。

⭐ 越贵的那一层,省得越狠。 这才是它真正 work 的原因 —— 如果所有层都只省 48 倍,故事远没有这么好听。


🧪 四、动手:这笔账自己算一遍

不需要模型也不需要 GPU,纯计数,跑多少遍结果都一模一样:

# 像素空间 vs 潜空间:同一件事各要花多少算力(纯计数,不测秒)
import torch

def conv_flops(cin, cout, k, hw):
    """一层 k×k 卷积:每个输出元素做 cin*k*k 次乘加,一次乘加算 2 FLOPs"""
    return 2 * cin * cout * k * k * hw * hw

C_PIX, H_PIX = 3, 512      # 一张 512×512 的 RGB 图
C_LAT, H_LAT = 4, 64       # Stable Diffusion 的潜空间

n_pix = torch.zeros(C_PIX, H_PIX, H_PIX).numel()   # ⭐ 每一步要去噪的东西有多少个数
n_lat = torch.zeros(C_LAT, H_LAT, H_LAT).numel()
print(f"每步要去噪的量:  {n_pix} 个数  vs  {n_lat} 个数  → 压缩 {n_pix // n_lat} 倍")

f_in_p = conv_flops(C_PIX, 128, 3, H_PIX)          # ① 首层:把输入通道抬到 128
f_in_l = conv_flops(C_LAT, 128, 3, H_LAT)
print(f"首层 3x3 卷积:   {f_in_p/1e9:7.2f} GFLOPs vs {f_in_l/1e9:6.3f} GFLOPs → {f_in_p/f_in_l:.0f} 倍")

f_bd_p = conv_flops(128, 128, 3, H_PIX)            # ② 主干:通道数一样,差别全在分辨率
f_bd_l = conv_flops(128, 128, 3, H_LAT)
print(f"主干 128→128 卷积:{f_bd_p/1e9:6.2f} GFLOPs vs {f_bd_l/1e9:6.3f} GFLOPs → {f_bd_p/f_bd_l:.0f} 倍")

a_p, a_l = (H_PIX ** 2) ** 2, (H_LAT ** 2) ** 2    # ③ 全局自注意力:每个空间位置一个 token
print(f"注意力矩阵项数:  {a_p:.3e}     vs {a_l:.3e}    → {a_p // a_l} 倍")

for steps in (50, 1000):                           # ④ 再乘上采样步数
    print(f"走 {steps:4d} 步,光②这一层就要: 像素空间 {f_bd_p*steps/1e12:6.2f} TFLOPs"
          f"  vs  潜空间 {f_bd_l*steps/1e12:5.3f} TFLOPs")

真实输出:

每步要去噪的量:  786432 个数  vs  16384 个数  → 压缩 48 倍
首层 3x3 卷积:      1.81 GFLOPs vs  0.038 GFLOPs → 48 倍
主干 128→128 卷积: 77.31 GFLOPs vs  1.208 GFLOPs → 64 倍
注意力矩阵项数:  6.872e+10     vs 1.678e+07    → 4096 倍
走   50 步,光②这一层就要: 像素空间   3.87 TFLOPs  vs  潜空间 0.060 TFLOPs
走 1000 步,光②这一层就要: 像素空间  77.31 TFLOPs  vs  潜空间 1.208 TFLOPs

💡 最后两行值得停一下:在潜空间里走满 1000 步(1.208 TFLOPs),都比在像素空间里走 50 步(3.87 TFLOPs)便宜。上一章省下来的步数,和这一章省下来的单步开销,是可以叠乘的。


🔍 五、代价:丢掉的细节,是在编码那一步丢的

自编码器不是无损压缩。48 倍压下去,一定有东西回不来。

⭐ 要命的是顺序:$E$ 在扩散之前跑。这意味着整条链路的画质上限是 $D(E(x))$ —— 把原图编码再解码得到的那张图。⚠️ 扩散模型无论学得多好、步数走得多足,都只能逼近这个上限,不可能超过它。

具体丢的是什么?做一次除法就清楚了:512×512 的图压到 64×64,一个潜空间格子对应原图 8×8 = 64 个像素,也就是 192 个数被压成 4 个数。而一个小字的笔画只有一两个像素宽、整个字可能只占十几个像素 —— 它整个人就住在一两个格子里。

💀 这就是这类模型画不好小字的第一层原因,同一条解释也覆盖了远景人脸、手指关节、密集纹理和网格:⭐ 凡是「在 8×8 的块尺度上无法从周围推断出来」的高频细节,编码那一步就已经交代了。

⚠️ 但别把锅全甩给自编码器:提示词理解不到位、训练数据里就没有清晰小字,同样会让字画歪。自编码器那一份是下界 —— 你可以拿一张写着清晰小字的照片,只做一次编码再解码、扩散模型完全不参与,字通常就已经糊了。这一步不解决,后面怎么调都白搭。

于是有了三条对着这个上限使劲的路:


⚖️ 六、KL 项在这里到底管什么

自编码器如果只用重建损失训,潜空间可以长得非常离谱:整体尺度可能是几十上百,分布里全是空洞和悬崖。

⚠️ 扩散模型在这样的空间上学不动。 原因很直接:前向加噪那条公式 $x_t=\sqrt{\bar\alpha_t}x_0+\sqrt{1-\bar\alpha_t}\,\epsilon$ 里的噪声方差是 1,它默认了数据本身的尺度也在 1 附近。要是潜变量的幅度是 100,整张 $\bar\alpha$ 调度表就全错位 —— 前面大半段的「加噪」相对信号约等于没加,模型在那些步上没东西可学。

所以 LDM 在重建损失之外加了一项 KL,把每个位置的后验 $q(z\mid x)$ 往标准正态 $\mathcal N(0,I)$ 推。

$$\mathcal L = \underbrace{\|x - D(z)\|}_{\text{重建,要准}} + \underbrace{\lambda \cdot D_{KL}\big(q(z\mid x)\,\|\,\mathcal N(0,I)\big)}_{\text{别太散}},\qquad \lambda \text{ 极小}$$

⭐⭐ 这里的 $\lambda$ 小得几乎可以忽略,而这是故意的,不是调参调出来的。 关键在于目标不同:

普通 VAE LDM 的自编码器
生成时 $z$ 从哪来 ⭐ 直接从先验 $\mathcal N(0,I)$ 里采 由扩散模型生成
所以潜分布必须 真的是标准正态,否则采出来的 $z$ 解码成噪声 只要不太离谱:尺度受控、别有大片空洞
KL 权重 大(它是正经的正则项) ⭐ 极小

💡 一句话:LDM 根本不从先验里采样,那 KL 就没必要收得那么紧。 而 KL 一大就会挤占重建质量 —— 直接把上一节那个 $D(E(x))$ 上限拉低,那是 LDM 最不能让的东西。

⭐ 有个能自己核对的痕迹:Stable Diffusion 的代码里,潜变量送进扩散模型之前要乘一个常数 0.18215,解码前再除回去。⚠️ 如果 KL 真把潜空间压成了标准正态,这个缩放常数根本没有存在的理由。 它的存在恰好说明:KL 只是把潜空间「拴住」了,实际标准差离 1 还差着好几倍,得靠一个事后量出来的常数补上。

📐 另一条路:不用 KL,用 VQ(想看再点)

LDM 原论文给了两种正则方式,KL 只是其中一种。另一种是 VQ:不再往标准正态上推,而是维护一本码本,把每个位置的潜向量量化到码本里最近的那一条。

⭐ 两者的共同点才是重点:都不是为了得到一个漂亮的先验分布,而是为了不让潜空间散成扩散模型学不动的样子。


🔗 这一章连到哪里

去哪 为什么
数学原理 01b · KL 散度:交叉熵的真身 ⭐ 那一页的「前向 KL vs 反向 KL」对照表里,直接点名了 VAE / 变分推断用的是反向 KL,mode-seeking,所以估出来的后验偏窄。本章第六节那个 $D_{KL}(q(z\mid x)\,\|\,\mathcal N(0,I))$ 正是这一类 —— 它罚的是「潜变量跑到先验不敢去的地方」,而不是「盖住先验的每个角落」
ML 基础 06 · 无监督:聚类与降维 压缩表示这件事最经典的那一版:PCA 找方差最大的几个方向投影上去,用途表里就写着「去噪:丢掉的小方差方向往往是噪声」。⭐ 值得对照的是丢的东西不一样 —— PCA 是线性的、丢掉的是小方差方向;自编码器是学出来的非线性压缩,丢掉的是重建损失不在乎的东西,而小字恰好就属于那一类
AI 基础设施 09 · 显存优化全家桶 本章省的是每一步的计算量;那一页是省显存的完整清单,七件武器按性价比排好了序(梯度检查点把激活从 $O(L)$ 降到 $O(\sqrt L)$、换 8-bit 优化器、LoRA/QLoRA…)。⭐ 想自己训一个扩散模型时,这两条路是叠加的,不是二选一
全景导论 10b · 图像生成:扩散模型 14 分钟的索引版。⭐ 那一页的组件表里,VAE 只有一行字:「把图压缩到低维潜空间再扩散,省算力(Latent Diffusion)」—— 而它的深挖清单把 Latent Diffusion 整块标成「📕 要外找」。你正在读的就是那一行的展开

✅ 检查点

  1. 512×512×3 的图有多少个数?压到 Stable Diffusion 的潜空间之后剩多少?压缩几倍?
  2. 为什么说像素空间扩散「不是慢一点,是根本放不下」?举出那个最劝退的数字。
  3. 编码器和解码器各跑几次?扩散跑几次?省下来的到底是哪一部分?
  4. 48 倍、64 倍、4096 倍这三个数分别是什么之比?为什么它们不一样?
  5. 整条链路的画质上限是什么?为什么扩散模型再准也超不过它?
  6. 一个潜空间格子对应原图多少个像素、多少个数压成多少个数?这怎么解释小字画不好?
  7. LDM 的 KL 权重为什么设得极小?普通 VAE 为什么不能这么干?0.18215 那个缩放常数又说明了 KL 项的什么事?
👀 答案
  1. 786432 个数(512×512×3),压到 64×64×4 = 16384 个数,⭐ 48 倍。
  2. 因为注意力的代价按面积的平方走:512×512 = 262144 个 token,注意力矩阵 $6.87\times10^{10}$ 项,光存这一个矩阵按 fp16 算就要 137 GB —— 而这还只是一层里的一个头。所以早期像素空间的模型只能在 256×256 甚至 64×64 上训。
  3. 编码器和解码器各只跑 1 次,扩散要跑几十上百次。 省的正是那个要乘以步数的部分;编解码那点固定开销摊到 50 步里几乎看不见。
  4. 48 是元素个数之比(通道 3→4 抵消掉一点空间压缩);64 是主干卷积之比 —— 两边通道数一样,差别只剩空间面积 $8^2$;4096 是注意力矩阵之比 —— 它按面积的平方走,$(8^2)^2$。⭐ 越贵的层省得越狠,这才是它 work 的原因。
  5. 上限是 $D(E(x))$,也就是原图编码再解码得到的那张图。因为 $E$ 在扩散之前跑,细节在那一步就已经没了,后面的扩散只在潜空间里工作,无从补起。
  6. 一个格子对应 8×8 = 64 个像素,即 192 个数压成 4 个数。小字的笔画只有一两个像素宽、整个字只占十几个像素,整个住在一两个格子里 —— 凡是「在 8×8 块尺度上无法从周围推断」的高频细节,编码那步就交代了。⚠️ 但它只是下界,提示词理解和训练数据同样有份。
  7. 因为 LDM 不从先验里采样:$z$ 由扩散模型生成,不是从 $\mathcal N(0,I)$ 里抽的。所以潜空间只要「不太离谱」(尺度受控、别有大片空洞)就够,KL 收紧只会挤占重建质量、拉低 $D(E(x))$ 这个上限。⭐ 普通 VAE 生成时就是从先验里采 $z$,潜分布不真的接近标准正态,采出来的 $z$ 解码就是噪声。而 0.18215 的存在说明 KL 没有真把潜空间压成标准正态 —— 实际标准差离 1 还差好几倍,得靠一个事后量出来的常数补上;真是标准正态的话,这个常数没有存在的理由。

🛑 可以停在这里

读到这里,你已经能解释 Stable Diffusion 为什么塞得进一张消费级显卡,也知道了「换更好的模型就能写对小字」这个期待错在哪一层。

出现这些情况时再回来:出图细节糊、小字或手指崩(第五节,先确认是不是自编码器的下界问题);想训自己的扩散模型却 OOM(第六节末尾那条链接);看到别人说「换了 16 通道 VAE」不知道在换什么(第五节最后)。

⚡ 走神救援

先记住这几件事

下一节 👉 06-条件控制与CFG.md

打卡记录保存在你的浏览器里,首页能看到总进度