如果你在本地跑过 stable diffusion,多半遇到过这种场景:同一张图,同一个提示词,在不同人的电脑上出图速度能差出三到五倍。有人归因于显卡显存不够大,有人归因于模型版本不够新,真正动手一查才发现,瓶颈往往藏在采样器配置、推理引擎选择、管线拆分方式这些不起眼的地方。这篇 Diffusion 推理总纲,就是想把这条技术线完整捋一遍:从扩散模型推理的底层逻辑,到主流推理引擎的选型坐标系,再到底层加速的核心矛盾,最后落到我实际部署中踩过的坑。不管你是刚接触 diffusion model 的算法同学,还是准备做 stable diffusion 本地部署、服务化推理的工程同学,这份总纲都会先帮你把地图铺开,后续系列再逐个点深入。
很多人一开始把扩散模型当成普通生成网络处理,"不就是个 CNN 套个 Transformer 吗,拖进 TensorRT 就完事了"。结果第一次跑完整管线就傻了眼:怎么一张图要好几秒?模型转换以后各种报错?换了个调度器结果图就完全变了?这些问题背后其实指向同一件事——扩散模型的推理逻辑,和传统单次前向的模型根本不一样。搞清楚这件事,后面所有优化手段才有地方落脚。
1. 为什么 Diffusion 推理值得单独拆出来讲
1.1 一次"前向"和五十次"前向"的差别
先看最根本的差异。传统图像模型,比如 ResNet、YOLO、GAN 的生成器,推理时都是一次前向传播:输入进网络,输出直接出来,整个过程是一个确定性的映射。Diffusion 不是这样,它的推理是一个迭代去噪循环。
以 stable diffusion 1.5 为例,默认用 DDIM 采样器、50 步去噪。每一步都要让 UNet 完成一次完整的前向计算,也就是 50 步就是 50 次 UNet 推理。再加上文本编码器跑一遍、VAE 解码器跑一遍,整个 text-to-image 管线才算走完。
这里有个关键的数据认知:UNet 的参数量大约在 1B 左右,单步推理的 FLOPs 就已经上亿级。假设单步 UNet 前向耗时 80ms,50 步就是 4 秒,这还没算 VAE 解码和文本编码。也就是说,扩散模型推理的总耗时,约等于采样步数与单步前向耗时的乘积。步数这个变量,一下就把工作量放大了一个数量级。
这也是为什么"加速扩散模型推理"这件事,从来不是单纯把模型转成 TensorRT 就能解决的。你优化的不只是"一次前向多快",还要考虑"能不能用更少的步数达到同样的生成质量""每一步能不能扛住更高的并发""显存能不能装下更大的 batch"。
1.2 四层链路:采样算法、执行引擎、精度策略、工程外围
我自己做了几年推理优化,越做越觉得,Diffusion 推理不是一个单点技术,而是一条四层链路。总纲篇最有价值的,就是先把这条链路搭起来。
第一层是采样算法。决定你跑 50 步还是 20 步,用 DDIM 还是 DPM-Solver,以及每一步的数学更新方式是怎样的。采样算法直接决定生成质量和步数之间的关系,是"天花板"层面的东西。
第二层是执行引擎。PyTorch Eager、ONNX Runtime、TensorRT、OpenVINO、MLX,这些引擎负责把你的模型计算真正落到硬件上。引擎的性能差距,可以在同样的模型权重下跑出完全不同的帧率。
第三层是精度策略。FP16、BF16、INT8、INT4,每一步去噪网络对精度误差的敏感度不同,VAE 对溢出又特别敏感。精度设置不当,轻则速度没提升多少,重则直接出黑图噪点。
第四层是工程外围。文本编码缓存、VAE 挪到 CPU、并发调度、连续批处理、显存池化管理——这些不直接改模型计算,但决定了你的服务能不能撑住高并发,能不能在边缘设备上把延迟压进可接受范围。
后面系列要写的所有专题,都会落在这四层里。你遇到的具体问题,只要先定位它在哪一层,解决方向基本就清楚了一半。
2. 采样循环的底层逻辑:为什么是"一步一步去噪"而不是一步到位
2.1 正向加噪与反向去噪的直觉
想理解推理,还是得从扩散模型的训练逻辑往回看。扩散模型有两个过程,正向过程和反向过程。
正向过程是把一张干净图片,用预定义的噪声表(noise schedule),逐步往里面加高斯噪声。加一步、加两步……加到几百步以后,图片彻底变成一张纯噪声图,跟原图一点关系都没有。这个步骤在训练时是固定的、确定性的。
反向过程就是反过来:从一个纯噪声出发,训练一个神经网络去预测"当前这一步加进去的噪声是什么"。预测出来以后,从当前噪声图中"减去"这个噪声,得到稍微干净一点的图,然后再预测下一步、再减。如此循环往复,直到走完所有步数,得到一张接近真实分布的图像。
论文里的更新公式长这样:
x_{t-1} = \frac{1}{\sqrt{\alpha_t}} \left( x_t - \frac{\beta_t}{\sqrt{1-\bar{\alpha}t}} \epsilon\theta(x_t, t) \right) + \sigma_t z
这里面 \epsilon_\theta 就是我们的神经网络(通常是 UNet),x_t 是当前带噪声的 latent,z 是随机高斯噪声。我用一个生活化的类比:这就像照片显影,底片泡在显影液里,影像不是瞬间出现的,而是一点点"浮现"出来。每一步去噪,都是在让轮廓清晰一点点。
值得注意最后那项 \sigma_t z。它不是严格"只去噪",而是会加入一小部分随机噪声,让结果不至于陷入确定性。这也是为什么同样的文字提示词、同样的模型权重,只要换随机种子,生成出来就是不同图像。这种"带随机性的迭代过程",是 Diffusion 推理独有的节奏。
2.2 调度器与采样器的分工:两个容易混淆的概念
很多同学在代码里看到 scheduler、sampler 两个词就头大,这俩确实容易混。我拆开说。
**调度器(scheduler)**管理的是"噪声表"和"时间步曲线"。它决定:总共多少步、每一步的噪声方差是多少、每一步网络应该处于哪个时间步 t。常用的 noise schedule 有 linear、scaled_linear、cosine 等。调度器更像一个"曲谱",规定了每一步该用多大力气去噪。
**采样器(sampler)**则是在调度器给定步数的基础上,用一种数学方法来逼近去噪过程。不同的采样器,对"如何从噪声预测结果更新 latent"这件事有自己的迭代策略。
拿我常用的几个举例:
- DDIM:把反向过程当作一个 ODE(常微分方程)来解,能把原来需要 1000 步的马尔可夫链缩减到 20~50 步,质量损失相对可控。这是最经典、最稳妥的采样器之一。
- DPM-Solver / DPM-Solver++:这是高阶 ODE Solver,利用扩散 ODE 的半线性结构,用更少的步数逼近更好的结果。实际使用中 15~25 步就能达到 DDIM 50 步的视觉质量,速度优势非常明显。
- Euler / Heun:结构更简单,计算开销更小,经常配合蒸馏类模型(比如 LCM、SD-Turbo)用来做 1~4 步的极速推理。
同样一个模型权重,用 DDIM 50 步和用 DPM++ 20 步,生成的图可能在细节上略有差异,但速度差出两倍以上。这就是采样算法的价值,也是"总纲"里第一层要解决的核心问题:在不牺牲质量的前提下,尽可能减少步数。
2.3 一张图,三段式管线
把 stable diffusion 的整条推理管线拆开,其实是三段式的:
- 文本编码阶段:文本提示词通过 CLIP(或 SDXL 里的 CLIP + T5)编码成条件向量 encoder_hidden_states,形状一般是 [batch, 77, 768] 或类似尺寸。
- 采样循环阶段:随机生成一个 [batch, 4, 64, 64] 的 latent(对 512x512 输出而言),反复调用 UNet 预测噪声,更新 latent。
- 解码阶段:把去噪完成的 latent 交给 VAE Decoder,上采样还原成真正的 512x512 像素图像。
写成分步代码,大概长这样:
import torch from diffusers import AutoencoderKL, UNet2DConditionModel # 初始化模型 vae = AutoencoderKL.from_pretrained("stabilityai/sd-vae-ft-mse") unet = UNet2DConditionModel.from_pretrained( "stable-diffusion-v1-5/stable-diffusion-v1-5", subfolder="unet" ) # 采样循环(简化示意) latents = torch.randn((1, 4, 64, 64), generator=generator) for t in scheduler.timesteps: noise_pred = unet(latents, t, encoder_hidden_states=text_embeds).sample latents = scheduler.step(noise_pred, t, latents).prev_sample # 解码 image = vae.decode(latents / vae.config.scaling_factor).sample我自己的经验数据:在一张 NVIDIA 3090 上,SD 1.5 用 DDIM 50 步跑 512x512 图,整条管线大约 5~7 秒,其中80% 以上的时间都花在 UNet 采样循环里,文本编码通常只占 100~200ms,VAE 解码 300~500ms。
这也解释了后面工程优化里的一个重要思路:为什么服务化部署要把文本编码结果缓存起来,为什么可以把 VAE 放到 CPU 上跑,为什么采样循环才是 GPU 优化的主战场。三段式管线的每一段都有各自的优化空间,但它们的重要性完全不一样。
3. 性能瓶颈的三条主线:计算、访存与顺序依赖
3.1 计算主线:UNet 每步都在做大规模卷积与注意力
既然采样循环占了大头,那 UNet 单步前向的"计算密度"就非常关键。UNet 的核心成分是什么?是大量的卷积层加上跨层跳接,以及中间若干层 Transformer 的 Attention 模块。随着 SDXL 这类模型引入更大的 DiT 结构,Attention 的计算量占比越来越高。
这里有一个量级概念:分辨率每翻一倍,UNet 单步的计算量大约翻四倍;采样步数每翻一倍,总计算量翻一倍。所以推理优化的第一反应,永远是降低总计算量,手段包括但不限于:
- 算子融合:把卷积 + 激活 + 归一化熔成一个算子,减少 kernel 启动开销;
- 使用 TensorRT / AITemplate 这类编译型引擎,把图结构优化到极致;
- 降低精度:FP16 相比 FP32 可以带来接近翻倍的算力吞吐。
3.2 访存主线:显存带宽往往被低估
很多人只盯着"算得快不快",忽略了 Diffusion 推理其实也是一个访存密集型过程。UNet 的权重动辄 1B 参数,每一步前向都要把所有权重从显存搬到计算单元,中间特征图也要不断读写。当 batch 增大或分辨率提升时,访存压力比计算压力涨得更猛。
在 NVIDIA 显卡上,这是 HBM 高带宽显存的价值所在;到了核显(iGPU)上,显存和系统内存共享,带宽直接成为硬瓶颈,这就是为什么核显跑 SD 必须优先量化或裁剪,而不是上来就堆算力。
访存优化的落点通常是:
- 用半精度或更低精度把权重尺寸减半,等于把访存量减半;
- 用算子融合减少中间结果的反复搬运;
- 在 Apple Silicon 这类统一内存架构上,充分利用 CPU 与 GPU 共享内存的特性,减少数据拷贝。
3.3 顺序依赖主线:x_t 依赖 x_{t-1},没法并行
这可能是 Diffusion 推理和大语言模型推理最不一样的地方。LLM 推理虽然也是自回归逐 token 生成,但它有 KV Cache 可以缓存历史计算;Diffusion 的每一步去噪,都需要上一步的完整 latent 作为输入,中间没有可以复用的"历史缓存",一条链直接串到底。
这种顺序依赖带来了两个工程结论:
- 单样本的延迟优化,只能靠降低单步耗时或减少总步数,没法通过并行跑多个样本缩短;
- 要想提高吞吐,就得往"多实例并发"和"batch 内并行"方向走。
Batch 内并行指什么?指的是在同一个采样循环里,把多张图的 latent 拼成一个 batch 一起过 UNet。这样可以提高 GPU 利用率,显存足够的情况下是很划算的吞吐优化手段。
3.4 和 LLM 推理引擎的对照:nano-vllm 给我们的启发
这里想多说一句,因为热词里有个"基于 nano-vllm 学习大模型推理关键功能",很多人会把 Diffusion 推理和 LLM 推理直接类比,其实两者是同域不同路。
LLM 推理核心是:自回归 decode + KV Cache 管理 + 连续批处理 + 投机采样。Diffusion 推理核心是:迭代式去噪 + 采样步数控制 + 单步算子优化 + 批量并发。但两者共享很多底层基础设施:显存池化、算子融合、低精度量化、动态形状处理、服务化调度。
所以如果你正在学 nano-vllm 这类大模型推理引擎,里面关于显存管理、调度队列、连续批处理的思路,完全可以平移过来设计 Diffusion 的服务化层。只是落到模型执行层时,Diffusion 不能用 KV Cache 那种思路,必须围绕"每步全量前向"来做文章。这个对照,能帮你省下很多走弯路的时间。
4. 推理引擎选型坐标系:别一上来就干 TensorRT
4.1 主流引擎横向对比:从平台到性能到集成成本
选推理引擎,核心看三件事:你在什么硬件上跑、你对延迟还是吞吐更敏感、你的工程化预算有多少。我先给一张我自己用下来的横向对比表:
| 引擎 | 适用平台 | 优势 | 劣势 | 典型场景 |
|---|---|---|---|---|
| TensorRT | NVIDIA GPU | 性能极致,算子融合成熟 | 闭源,转换易踩坑,插件问题多 | 服务端高吞吐、单机单卡优化 |
| AITemplate | NVIDIA GPU | 模板编译,性能接近 TensorRT | 社区更新节奏不稳,算子覆盖有限 | 可接受编译时间、追求顶级性能 |
| ONNX Runtime | CPU / GPU / 核显 | 跨平台,跨框架,集成速度快 | 极端性能不如编译型引擎 | 快速上线、跨平台适配 |
| OpenVINO | Intel CPU/GPU/NPU | Intel 平台优化充分 | NVIDIA 上优势不明显 | Intel 边缘设备、核显推理 |
| MLX | Apple Silicon | M 系列芯片适配极好 | 生态集中在 Apple 平台 | Mac 本地部署与开发调试 |
我自己在服务端的常规建议是:如果是 NVIDIA 独显且追求吞吐,TensorRT 依然是绕不开的主力;如果只是快速验证、或者要同时跑多个框架的模型,ONNX Runtime 是更稳妥的起点。AITemplate 在 A100 上的单算子编译效果确实很强,但你要做好跟踪它社区更新节奏的心理准备。
4.2 核显/集成显卡的选型:不要盲目上 TensorRT
热词里有一条"780m 核显推理用哪个推理工具最合适",这个我专门展开说,因为核显场景太容易选错工具了。
AMD 780M 这类 RDNA3 核显、Intel Arc 核显,跑 stable diffusion 能不能跑?能。但很多人直接套用 NVIDIA 独显的经验,装了 TensorRT 版,结果要么构建失败,要么推理速度惨不忍睹。原因很简单:TensorRT 的 CUDA 生态并不适配核显架构,它默认是给 NVIDIA 独显设计的。
核显场景我建议两条路:
- ONNX Runtime + DirectML:微软的 DirectML 后端在 Windows 上能统一调度核显,AMD 和 Intel 都支持得不错,集成成本低。
- OpenVINO:Intel 平台(尤其是 Arc 核显)上用 OpenVINO 的 Stable Diffusion 优化非常成熟,官方就出过完整的适配教程,CPU 和核显都能吃到优化红利。
另外,核显天然共享系统内存,显存带宽远不如独立显存,所以量化优先级要排在最前面。先把 UNet 的权重从 FP16 减到 INT8,访存压力降一半,速度提升通常比换引擎还明显。
4.3 从 nano-vllm 借鉴什么:推理引擎的最小骨架
如果你对"推理引擎到底是怎么设计出来的"感兴趣,nano-vllm 是一个非常合适的学习样本。它代码精简,但把大模型推理引擎的关键环节都保留了下来:显存池化、请求调度、连续批处理。我自己当初啃它的时候,最大的收获就是搞明白了一个道理——推理引擎的大部分复杂度,并不在算子本身,而在"怎么把一堆请求高效地喂给 GPU"。
这个道理对 Diffusion 同样适用。当你把 diffusion 模型做成一个 Web 服务,同时来 10 个用户请求,每个请求都要完整的采样循环,你不能简单粗暴地按顺序跑,那样后面的请求会等死。正确的思路是借鉴 LLM 推理引擎的调度方式:把多个请求拼成 batch 一起进采样循环,或者对不同采样步数的请求做动态排布,让 GPU 始终处于满载状态。
Diffusion 的输入不像 LLM 那样是变长 token 序列,latent 尺寸相对规整,做 batch 调度反而更简单。这也是总纲把 nano-vllm 放进坐标系的原因:它解决的问题,和 Diffusion 服务化高度重叠。
5. 我在实战中反复踩过的坑,总纲篇先列一份清单
5.1 动态形状导致的优化失效:转换成功但不提速
我在把 stable diffusion 转 ONNX 的早期,做过一个非常蠢的事:把输入尺寸设成动态。ONNX Runtime 里动态轴用起来很方便,但当我再把模型转到 TensorRT 时,动态形状直接让 TensorRT 的图优化退化成保守模式,算子融合全部失效,推理速度跟 PyTorch Eager 差不多。
解决方案很直接:固定分辨率或分档。要么固定 512x512,要么做 512 和 768 两个档位分别构建引擎,宁可多占一点显存,也要换取 TensorRT 的激进优化。动态形状确实灵活,但灵活性在推理引擎里往往是性能的敌人。
5.2 FP16 下的 VAE NaN:SDXL 用户的经典黑图
SDXL 刚出来那阵,我一直用 FP16 跑完整管线,结果经常在 VAE decode 阶段出问题,要么出图是一整块黑色,要么出现大片噪点和色彩异常。排查了半天才发现,VAE 在 FP16 下特别容易数值溢出,latent 里的某些值稍微大一点,decode 直接爆炸。
解决方法按优先级排:
- BF16 替换 FP16:BF16 的动态范围和 FP32 基本一致,不容易溢出;
- 只有 FP16 的硬件(部分老卡)就单独把 VAE 放在 FP32 上跑;
- 实在不行,对 latent 做 clamp,把数值范围限制在安全区间。
这个问题说白了就是"精度策略"这层没做好。谨记:UNet 用半精度没问题,VAE 不见得撑得住半精度。
5.3 调度器不匹配导致的"假失败":别急着骂引擎
还有一次,我把 PyTorch 模型切到 ONNX Runtime 之后,发现生成结果和原来不一样,第一反应是引擎转换有 bug。后来一步一步往前查,才发现 ONNX 后端采样时用的是另一种调度器实现,连随机噪声生成方式都不一样,图自然就变了。
这也是做推理迁移时最容易误判的点:输出不一致,不一定是引擎的问题,可能是调度器实现的细节差异。所以做 AB 对比时,务必固定随机种子、固定 scheduler、固定采样步数,确保控制变量。如果还怀疑,就导出中间每一步的 latent 做逐层 diff,看到底从哪一步开始偏离。
5.4 尺寸对齐与峰值显存:边缘部署的隐藏门槛
最后两个小坑,一个关于尺寸,一个关于显存。
VAE 的 latent 下采样倍率是 8,所以输入宽高必须是 8 的整数倍。很多人从网上随便拉一张图 resize 到 511x511,VAE decode 直接报错,本地可能没注意,服务化以后就成了崩溃来源。我现在的习惯是:任何进管线的图像,先做 pad 到 8 的倍数,再做后续处理。
峰值显存方面,如果 batch 开大导致 OOM,不要急着换小 batch,可以先试试把 VAE 挪到 CPU 推理,或者把文本编码结果缓存起来用。VAE 解码只在最后跑一次,放到 CPU 上慢那么几百毫秒,但对显存峰值影响很明显,整体吞吐反而可能更高。
6. 总纲之后:系列路线图
总纲篇最后,我按四层链路的框架,把后续系列的路线画出来,方便大家按需取用。
先说采样算法这条线,我会单独写一篇采样器专题,重点拆 DDIM 和 DPM-Solver 的数学直觉与工程实现,讲清楚"为什么 DPM++ 用 20 步能顶 DDIM 50 步",以及怎么在质量与速度之间找平衡点。这个专题适合所有想从"会用"进阶到"懂调参"的读者。
第二条线是引擎部署专题,以 TensorRT 为主,完整走一遍从 PyTorch 模型导出、ONNX 转换、TensorRT builder 构建到插件踩坑的流程,把动态形状、算子不支持、构建时间优化这些老问题一次说透。这条线适合服务端部署和技术负责人参考。
第三条线是量化与服务化专题,从 FP16 到 INT8、INT4 的逐层精度测试,再到基于 nano-vllm 思路的连续批处理和显存池化设计。这条线偏向架构层面,适合要把 Diffusion 服务正式推向生产的同学。
你可以先对照自己当前卡在哪个环节,再决定从哪篇切入。我自己当年就是吃了"东一榔头西一棒子"的亏,一会儿调采样器,一会儿换引擎,最后发现每个方向都懂一点皮毛,但哪条线都没打通。后来按这个四层框架把问题归档,思路一下就清爽了。总纲篇先把这张地图放在这里,后面的每一篇,都是往这几个方向里填实料。