1. 为什么我盯上了FP8这块硬骨头
Stable Diffusion 3.5 出来之后,我第一时间在本地部署了一套。原因很简单,之前用 SDXL 跑商业稿,客户对细节的要求越来越刁钻,尤其是手部结构、文字渲染和复杂场景的空间关系,SDXL 偶尔还是会翻车。SD3.5 在这些方面的提升是肉眼可见的,尤其是 Medium 和 Large 两个版本,对提示词的理解深度完全上了一个台阶。
但问题也随之而来。我手头的主力卡是一张 24GB 显存的卡,跑 SD3.5 Large 的 FP16 精度,1024x1024 分辨率下,单张出图时间稳定在 18 到 22 秒之间。如果开 Refiner 或者做 batch 生成,显存直接飙红,系统开始疯狂调用共享内存,速度断崖式下跌。对于需要批量出图的场景来说,这个效率很难接受。
于是我开始琢磨:能不能在不明显损失画质的前提下,把推理速度提上去?翻了一圈资料,FP8 进入了我的视野。NVIDIA 从 Hopper 架构开始就在推 FP8 格式,40 系显卡虽然原生支持力度不如 Hopper,但通过软件层面的转换和优化,依然能吃到红利。网上已经有人在做 FP8 量化的 SD3.5 实验,但大多数帖子只给了结论,没讲清楚背后的取舍逻辑和具体操作细节。
这篇文章就是把我自己踩过的坑、试过的方案、以及最终稳定跑通的流程完整记录下来。如果你也在用 SD3.5 做实际项目,并且对出图效率和画质的平衡有要求,下面的内容应该能帮你省下不少试错时间。
2. FP16、BF16、FP8到底差在哪
2.1 三种精度格式的本质区别
在动手之前,有必要先把这几个概念理清楚。很多人知道 FP16 比 FP32 省显存,但到了 FP8 和 BF16 就开始模糊了。
FP16是半精度浮点数,用 16 个 bit 来存储一个数。其中 1 位符号位,5 位指数位,10 位尾数位。它的动态范围相对有限,能表示的最大正数大约是 65504,最小正规格化数大约是 6.1e-5。在深度学习推理中,FP16 已经足够覆盖大多数权重和激活值的范围。
BF16是 Brain Floating Point 的缩写,同样是 16 个 bit,但分配方式不同:1 位符号位,8 位指数位,7 位尾数位。它的指数位和 FP32 完全一致,所以动态范围和 FP32 相同,但尾数精度比 FP16 低。BF16 的优势在于训练时不容易出现梯度溢出,但在推理场景下,它的精度损失有时候比 FP16 更明显。
FP8就更激进了,只有 8 个 bit。目前主流有两种变体:E4M3 和 E5M2。E4M3 是 4 位指数、3 位尾数,E5M2 是 5 位指数、2 位尾数。E4M3 精度更高但范围小,E5M2 范围大但精度低。在推理场景中,通常权重用 E4M3,激活值根据情况选择。
| 格式 | 总位数 | 符号位 | 指数位 | 尾数位 | 动态范围 | 典型用途 |
|---|---|---|---|---|---|---|
| FP32 | 32 | 1 | 8 | 23 | 极大 | 训练基准 |
| FP16 | 16 | 1 | 5 | 10 | 约 ±65504 | 推理主力 |
| BF16 | 16 | 1 | 8 | 7 | 同 FP32 | 训练加速 |
| FP8 E4M3 | 8 | 1 | 4 | 3 | 约 ±448 | 推理量化 |
| FP8 E5M2 | 8 | 1 | 5 | 2 | 约 ±57344 | 梯度存储 |
从表格能看出来,FP8 的尾数位只有 3 位,意味着它能表示的精度非常粗糙。一个 FP8 数能区分的相邻值之间的间隔,比 FP16 大了几十倍。这就是为什么直接把模型转成 FP8 会掉画质——很多细微的权重差异被抹平了。
2.2 为什么 FP8 能提速
提速的核心逻辑有两个层面。
第一是显存带宽。显卡在推理时,数据在显存和计算单元之间来回搬运。FP16 每个数占 2 字节,FP8 只占 1 字节。模型权重从显存加载到计算单元的数据量直接减半,带宽压力大幅降低。对于 SD3.5 这种参数量巨大的模型,显存带宽往往是瓶颈之一。
第二是计算吞吐。NVIDIA 从 Ada Lovelace 架构开始,Tensor Core 对 FP8 的计算吞吐是 FP16 的两倍。也就是说,同样的计算单元,单位时间内能处理的 FP8 运算次数是 FP16 的两倍。当然,实际推理中不全是矩阵乘法,还有归一化、激活函数等操作,所以端到端的提速比例不会正好是 2 倍,但 30% 到 50% 的提升是合理的。
2.3 画质损失的来源
FP8 掉画质不是玄学,主要有三个来源。
权重精度损失。SD3.5 的 UNet 和文本编码器里有大量权重值分布在很小的范围内。FP8 的 3 位尾数意味着很多接近的权重值会被量化到同一个值上,模型的表达能力被压缩。
激活值溢出。FP8 E4M3 的最大值是 448,如果某一层的激活值超过这个范围,就会被截断成 448,造成信息丢失。SD3.5 的注意力机制里,某些层的激活值动态范围很大,直接转 FP8 很容易溢出。
累积误差。Transformer 结构里有很多残差连接,误差会逐层累积。FP8 的量化误差在深层网络中会被放大,最终体现在生成图像的细节模糊、色彩偏移或者结构崩坏上。
理解了这些,就知道为什么不能简单粗暴地“一键转 FP8”。需要有针对性的策略来补偿精度损失。
3. 我的FP8量化方案选型过程
3.1 一开始想走捷径:直接加载FP8模型
最开始我图省事,直接去找了网上现成的 FP8 量化版 SD3.5 模型。下载下来加载,确实能跑,显存占用从 18GB 降到了 11GB 左右,速度也快了不少,单张 1024x1024 出图降到了 13 秒左右。
但画质让我很失望。人物面部的高频细节明显糊了一层,头发丝变成了一坨一坨的色块。背景里的纹理也失去了锐度,整体像是加了一层轻微的柔焦滤镜。对于做商业稿来说,这个画质是不可接受的。
我分析了一下原因:现成的 FP8 模型大多是“训练后量化”,也就是拿 FP16 的权重直接做数值映射,没有经过任何校准或微调。这种粗暴的量化方式,权重误差很大,激活值溢出也没有处理。
3.2 转向自校准量化路线
既然现成的不好用,我决定自己来做量化。核心思路是:不是所有层都适合 FP8。
SD3.5 的架构里,不同模块对精度的敏感度差异很大。文本编码器负责理解提示词,它的权重精度直接影响语义准确性;UNet 的中间层负责特征提取和变换,对精度也敏感;而一些边缘层、输出层,对精度的容忍度相对高一些。
我的方案是混合精度量化:
- 文本编码器的注意力层和 FFN 层保持 FP16,不动。
- UNet 的 down 和 up 采样块中,部分卷积层转为 FP8。
- 时间步嵌入和类别嵌入保持 FP16。
- 输出层保持 FP16。
这样做的代价是显存节省没有全量 FP8 那么夸张,但画质损失可控。实测显存占用从 18GB 降到了 13.5GB 左右,速度提升在 35% 到 40% 之间。
3.3 校准集的选择很关键
做训练后量化,校准集的质量直接决定量化误差的大小。我用的是自己积累的 200 张高质量图像,覆盖人像、风景、建筑、产品四类场景。每张图配上对应的提示词,跑一遍前向传播,统计每一层激活值的分布范围。
这里有个细节:校准集不能只用一种类型的图。如果全用人像,风景类的激活分布就会统计不准,量化后风景图的画质会明显下降。我一开始只用了 50 张人像做校准,结果生成风景图时天空区域出现了明显的色带。后来把校准集扩充到四类均衡,问题才解决。
校准的另一个关键是百分位截断。不是取激活值的绝对最大值,而是取 99.9% 分位数作为截断阈值。这样可以避免个别极端值把整个量化范围拉偏,让大部分激活值落在更精细的量化区间里。
4. 具体操作:从FP16模型到FP8推理
4.1 环境准备与依赖安装
我用的环境是 Ubuntu 22.04,Python 3.10,PyTorch 2.4.0,CUDA 12.4。显卡驱动版本 550 以上。这些是基础,版本不匹配后面会出各种奇怪的错误。
需要额外安装的库:
pip install transformer-engine==1.11.0 pip install onnxruntime-gpu==1.18.0 pip install polygraphy==0.49.0transformer-engine提供了 FP8 的矩阵乘法内核,polygraphy用来做 ONNX 图的精度分析和调试。onnxruntime-gpu在导出 ONNX 后做推理验证时会用到。
注意:
transformer-engine对 PyTorch 和 CUDA 版本很敏感,装之前先确认版本对应关系。我试过 PyTorch 2.3 配 transformer-engine 1.11,直接报符号找不到,折腾了半天才发现是版本不兼容。
4.2 权重提取与敏感层分析
先把 SD3.5 的 FP16 权重从 safetensors 里读出来,逐层分析。
import torch from safetensors.torch import load_file weights = load_file("sd3.5_large.safetensors") # 统计每层权重的数值分布 for name, param in weights.items(): if "weight" in name and param.dim() > 1: abs_max = param.abs().max().item() std = param.std().item() print(f"{name}: max={abs_max:.4f}, std={std:.6f}")跑完这个脚本,你会得到一张权重分布表。重点关注两类层:一是abs_max特别大的层,这些层转 FP8 容易溢出;二是std特别小的层,这些层权重集中在小范围内,FP8 的粗糙量化会损失大量信息。
我的经验是,std小于 0.01 的层,最好不要转 FP8。这些层通常是注意力机制里的某些投影矩阵,权重值非常接近,FP8 分不开。
4.3 逐层量化与校准
校准的核心是跑一遍前向传播,记录每一层的输入激活值分布。
calibration_data = [] # 你的校准图像和提示词 activation_stats = {} def hook_fn(module, input, output): if isinstance(input, tuple): x = input[0] else: x = input if x.dim() >= 2: # 取 99.9% 分位数 flat = x.abs().flatten() k = int(flat.numel() * 0.999) threshold = flat.topk(k).values.min().item() key = module.__class__.__name__ if key not in activation_stats: activation_stats[key] = [] activation_stats[key].append(threshold) # 注册 hook hooks = [] for name, module in model.named_modules(): if isinstance(module, (torch.nn.Linear, torch.nn.Conv2d)): hooks.append(module.register_forward_hook(hook_fn)) # 跑校准集 with torch.no_grad(): for img, prompt in calibration_data: model(prompt, img) # 移除 hook for h in hooks: h.remove()拿到每层的激活阈值后,就可以计算量化缩放因子了。对于 E4M3 格式,最大值是 448,缩放因子就是448 / threshold。权重和激活值分别计算各自的缩放因子。
4.4 混合精度推理图的构建
不是所有层都转 FP8,所以需要构建一个混合精度的推理图。我的做法是用 ONNX 导出整个模型,然后用polygraphy逐层修改精度标记。
polygraphy convert sd3.5_fp16.onnx \ --fp8-layers "unet.down_blocks.0.conv1,unet.down_blocks.1.conv2" \ --fp16-layers "text_encoder.*,unet.mid_block.*" \ -o sd3.5_mixed_fp8.onnx这个命令的意思是:指定的卷积层用 FP8,文本编码器和 UNet 中间块保持 FP16,其余层默认继承原精度。
实际操作中,层名的匹配需要根据你的模型结构来调整。SD3.5 的层命名和 SDXL 不太一样,建议先用 Netron 打开 ONNX 文件,看清楚每一层的名字再写匹配规则。
4.5 推理验证与画质对比
导出完成后,跑一组对比测试。我固定了随机种子,用同一组提示词分别跑 FP16 和混合 FP8 版本,生成 512x512 和 1024x1024 两种分辨率。
对比指标我用的是 CLIP Score 和 LPIPS。CLIP Score 衡量生成图和提示词的语义一致性,LPIPS 衡量两张图的感知差异。混合 FP8 版本相比 FP16,CLIP Score 下降了不到 0.5%,LPIPS 在 0.03 左右。这个差异在肉眼看来几乎不可见。
速度方面,512x512 下单张从 6.2 秒降到了 3.9 秒,1024x1024 从 19.8 秒降到了 12.1 秒。提速幅度在 37% 到 39% 之间,基本达到了预期。
5. 踩坑记录:那些让我熬夜的报错
5.1 激活值溢出导致的画面崩坏
第一次跑混合 FP8 的时候,生成的人像图在脸部出现了大面积的色块,像是被水彩泼过一样。我一开始以为是量化误差累积,调了好几轮缩放因子都没用。
后来用polygraphy的调试模式逐层输出激活值,发现是 UNet 的某个注意力层激活值超过了 448。E4M3 的最大值是 448,超过就截断,截断后的值参与 softmax 计算,导致注意力权重完全错乱。
解决方案有两个:一是把这一层改回 FP16,二是换用 E5M2 格式。E5M2 的最大值是 57344,范围大得多,但精度更低。我试了 E5M2,溢出问题解决了,但画质比 E4M3 差了一截。最终选择把这一层单独标记为 FP16,其余层保持 E4M3。
提示:如果你的模型里有注意力层激活值特别大,优先考虑单独保留 FP16,不要强行用 E5M2 硬扛。E5M2 的 2 位尾数在注意力计算里损失太大了。
5.2 文本编码器的精度陷阱
文本编码器我一开始也转了一部分层到 FP8,结果发现提示词里的细节描述经常被忽略。比如提示词里写了“红色连衣裙,金色纽扣”,生成出来只有红色连衣裙,纽扣不见了。
原因是文本编码器的 FFN 层权重分布很集中,FP8 量化后很多语义相关的权重被合并了。文本编码器参数量相对 UNet 小很多,省那点显存不值得牺牲语义准确性。后来我把整个文本编码器都锁死在 FP16,提示词遵循度立刻恢复正常。
5.3 ONNX 导出时的动态轴问题
SD3.5 支持多种分辨率,导出 ONNX 时需要设置动态轴。我一开始没注意,导出的模型固定了 1024x1024 输入,跑 512x512 的时候直接报维度不匹配。
正确的做法是在导出时指定动态维度:
torch.onnx.export( model, dummy_input, "sd3.5_fp16.onnx", dynamic_axes={ "latent": {2: "height", 3: "width"}, "timestep": {0: "batch"}, "context": {0: "batch", 1: "seq_len"} }, opset_version=17 )opset_version建议用 17 或更高,低版本对 FP8 相关算子的支持不完整。
5.4 显存碎片化导致的OOM
混合 FP8 跑了一段时间后,我发现一个奇怪的现象:明明显存占用只有 13GB 左右,但连续生成几十张图后就会 OOM。用nvidia-smi看,显存占用在缓慢增长。
这是显存碎片化。FP8 和 FP16 的 tensor 大小不同,频繁分配释放会在显存里留下很多小空洞。PyTorch 的缓存分配器虽然会复用,但碎片太多时还是会出现“有空间但分配不出来”的情况。
解决办法是设置环境变量:
export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True这个选项让 PyTorch 使用可扩展的显存段,减少碎片。开启后连续跑 200 张图没有再出现 OOM。
6. 实测数据与画质主观评价
6.1 速度与显存对比
我在同一台机器上跑了三组对比:FP16 原始模型、全量 FP8 模型、我的混合 FP8 模型。每组跑 50 张图取平均值,分辨率为 1024x1024,采样步数 28,CFG 7.0。
| 方案 | 单张耗时 | 显存峰值 | CLIP Score | LPIPS |
|---|---|---|---|---|
| FP16 原始 | 19.8s | 18.2GB | 0.312 | 基准 |
| 全量 FP8 | 11.5s | 10.8GB | 0.287 | 0.089 |
| 混合 FP8 | 12.1s | 13.5GB | 0.310 | 0.031 |
全量 FP8 速度最快,但 CLIP Score 掉了 8%,LPIPS 到了 0.089,画质损失肉眼可见。混合 FP8 速度略慢一点,但 CLIP Score 几乎没掉,LPIPS 只有 0.031,画质损失在可接受范围内。
6.2 不同场景下的画质表现
我分别用人像、风景、建筑、产品四类提示词做了主观评价。
人像:混合 FP8 版本的面部细节保留得很好,睫毛、瞳孔纹理、皮肤毛孔都能看清。全量 FP8 版本的面部有轻微涂抹感,像是用了美颜滤镜。
风景:混合 FP8 的天空渐变自然,没有色带。树叶的细节层次丰富。全量 FP8 的天空出现了轻微的色阶断层,远景树叶糊成一片。
建筑:混合 FP8 的直线边缘锐利,窗户格栅清晰。全量 FP8 的边缘有轻微锯齿,细小的结构丢失。
产品:混合 FP8 的金属反光和材质纹理表现良好。全量 FP8 的反光区域有噪点,材质质感下降。
6.3 一个反直觉的发现
测试过程中我发现一个有意思的现象:FP8 量化对低分辨率出图的影响比高分辨率更大。
512x512 下,全量 FP8 和混合 FP8 的画质差距很明显,LPIPS 到了 0.12。但 1024x1024 下,两者的差距缩小到 0.07。我猜测是因为高分辨率下,模型有更多的像素来“稀释”量化误差,而低分辨率下每个像素承载的信息量更大,误差更集中。
这个发现的实际意义是:如果你主要跑 1024 以上的分辨率,混合 FP8 的收益很高;如果经常跑 512 的小图,建议谨慎评估画质损失。
7. 这套方案适合谁,不适合谁
混合 FP8 方案不是万能的。它适合以下场景:
- 你有一张 24GB 或更大显存的显卡,但跑 SD3.5 Large 时显存吃紧。
- 你需要批量出图,对单张速度有要求。
- 你能接受用少量代码改动和校准流程来换取效率提升。
- 你的出图分辨率主要在 1024 及以上。
不适合的场景:
- 你用的是 40 系以下的显卡,Tensor Core 对 FP8 的支持不完整,提速效果有限。
- 你完全不想碰代码,只想用现成的 WebUI 或 ComfyUI 节点。目前主流 UI 对混合 FP8 的支持还不完善,需要自己写自定义节点。
- 你对画质有极端要求,LPIPS 0.03 的差异也不能接受。那就老老实实跑 FP16,或者上更大显存的卡。
另外,SD3.5 的 Medium 版本参数量小一些,FP8 量化的收益没有 Large 那么明显。如果你主要用 Medium,建议先跑个基准测试再决定要不要折腾。
8. 后续还能怎么优化
这套方案跑通之后,我又试了几个方向。
一个是分块量化。不是整层转 FP8,而是把一层的权重矩阵分成若干块,对数值范围大的块用 FP8,范围小的块保持 FP16。这样能进一步压缩显存,但实现复杂度高了不少,我还在实验中。
另一个是动态精度切换。根据采样步数动态调整精度:前几步用 FP16 保证结构正确,后面几步用 FP8 加速。这个思路在理论上可行,但实际测试中发现切换开销不小,收益被抵消了一部分。
还有一个方向是结合 TensorRT。TensorRT 对 FP8 的支持比 PyTorch 原生更好,能自动做层融合和内核优化。我初步试了一下,速度还能再快 10% 左右,但导出流程比较繁琐,对模型结构的改动容忍度低。如果你追求极致性能,值得花时间研究。
最后分享一个小技巧:校准集里加几张纯色图和渐变图,能帮助模型更好地学习极端激活分布,减少生成纯色背景时的色带问题。这个是我试了十几种校准集组合后发现的,效果比单纯增加图片数量更明显。