2 倍,不是 16 倍:BF16 混合精度的真实账本,和训练到车端那道断层
先拆掉一个流传很广的数字——BF16 在消费/工作站 Ada 卡上是 FP32 的 2 倍,不是 16 倍,16 倍只属于 A100/H100 那种数据中心 die;TF32 在 Ada 上更是完全没有加速。再从 roofline 讲清这 2 倍为什么反而更实,逐条拆五类坑,最后落到真正麻烦的地方:训练用 FP32 也躲不过的那道断层——它由部署端 FP16 只有 5 位指数决定,和训练精度无关。
上上篇讲过怎么把扩散模型搬上车:DDIM 靠跳步把采样从一千步压到几十步,DiffusionDrive 再把 decoder 削薄,总算力 ≈ 单步 decoder 成本 × 步数,两个因子各砍一刀。但那篇算的是推理的账,训练的账一直没算。这篇补上。而且我在算的过程中发现,关于混合精度最流行的那个数字——「16 倍」——在大多数人的机器上根本不成立。你手上那块卡大概率只有 2 倍。
1. 先拆一个数字:BF16 不是 16 倍
「混合精度提速 16 倍」这句话到处都在传,出处是 NVIDIA A100 白皮书 Table 3:BF16 峰值 312 TFLOPS,FP32 峰值 19.5 TFLOPS,比值正好 16。
问题在于,这个 16 是被 die 的架构决定的,不是被 BF16 这个格式决定的。
看一下同样是 Ampere/Ada 家族的两块卡:
| RTX 5000 Ada (AD102) | A100 (GA100) | |
|---|---|---|
| CUDA cores | 12,800 | 6,912 |
| Tensor cores | 400(第四代) | 432(第三代) |
| Tensor/CUDA 配比 | 0.031 | 0.063 |
| FP32 | 65.3 TFLOPS | 19.5 TFLOPS |
| BF16(FP32 累加) | 130.6 TFLOPS | 312 TFLOPS |
| BF16 / FP32 | 2.0× | 16× |
同样是 BF16,在 A100 上是 FP32 的 16 倍,在 RTX 5000 Ada 上只有 2 倍。
原因是 Tensor Core 和 CUDA core 的配比。GA100 是纯数据中心 die,把面积几乎全给了 Tensor Core;AD102 是消费级 die 往上做的工作站卡,SM 里 CUDA core 占大头,Tensor Core 只是「顺带加强」。你在 AD102 上买到的不是「另一个数量级的算力」,只是「另一个略快一点的算力」。
这个 2 倍是怎么算出来的、能不能验,我把推导链留在这儿:
NVIDIA 官方标 RTX 5000 Ada: Tensor 1,044.4 TFLOPS
→ 官方脚注口径是 FP8 + 2:4 稀疏,去稀疏 = 522.2 TFLOPS (FP8 dense)
→ FP8 是 FP16 的 2 倍,所以 FP16 dense = 261.1
→ FP16 (FP16 累加) 是 FP32 的 4 倍,FP16 (FP32 累加) 再折半
→ FP16/BF16 (FP32 累加) = 65.3 × 2 = 130.6 TFLOPS ✓
拿同 die 的 RTX 4090 交叉验证,比例一模一样:FP32 82.6 / BF16(FP32 累加) 165.2 / FP16(FP16 累加) 330.3 / FP8 660.6 —— 正好 1 : 2 : 4 : 8。
顺带一个更坑的:TF32 在 Ada 上是空的
网上还流传另一句话:「一行 set_float32_matmul_precision("high") 白捡 7 倍」。这句话在 A100 上是对的——A100 的 TF32 是 156 TFLOPS,8 倍于 FP32,PyTorch 官方文档给的实测是同一个 10240³ matmul 从 0.11 秒降到 0.016 秒。
在 Ada 上,这条不成立。 TF32 dense 吞吐约等于 FP32,开了不涨。
| TF32 dense | FP32 | 比值 | |
|---|---|---|---|
| A100 | 156 TFLOPS | 19.5 | 8.0× |
| RTX 5000 Ada | ≈ 65.3 TFLOPS | 65.3 | ≈ 1.0× |
所以我把这件事当成一个提醒写在这里:同一个配置项,在不同 die 上的价值差 8 倍。 网上所有「一行代码提速 N 倍」的帖子,你都得先问一句「你测的是哪张卡」。这条也适用于本文——下面所有数字都是 RTX 5000 Ada 口径,换卡要重算。
2. BF16 到底改了什么:借了指数,砍了尾数
BF16 的设计动机一句话:深度学习的数值问题,九成是范围问题,一成是精度问题。
FP16 是给图形学设计的(贴图颜色都在 0~1 附近,需要细粒度),给了 5 位指数、10 位尾数。放到训练里立刻出问题:
- 最大只能表示 65504 → 大激活、平方项,直接 Inf,然后 NaN 污染整条链
- 最小正规数 6.1e-5 → 小于这个的梯度冲成 0,参数再也不更新
第二个问题就是 loss scaling 存在的全部理由:把 loss 乘个 2^16,让梯度整体上移进 FP16 的区间,反传完再除回来。代价是你多了一整个需要调、需要监控、会在校准期莫名其妙 skip step 的子系统。
BF16 干的事是:指数位从 5 位加到 8 位(和 FP32 一致),尾数从 10 位砍到 7 位。于是:
- 范围问题和 FP32 一样,不会溢出,不需要 GradScaler
- 但尾数只剩 7 位,machine epsilon 是 2^-7 ≈ 7.8e-3
| 格式 | 指数位 | 尾数位 | eps | 最大有限值 |
|---|---|---|---|---|
| FP32 | 8 | 23 | 1.2e-7 | 3.4e38 |
| TF32 | 8 | 10 | 9.8e-4 | 3.4e38 |
| FP16 | 5 | 10 | 9.8e-4 | 65504 |
| BF16 | 8 | 7 | 7.8e-3 | 3.4e38 |
这个 7.8e-3 是理解 BF16 所有坑的唯一钥匙。它的分辨率比 FP16 还粗 8 倍,比 FP32 粗 65000 倍。
具体撞墙场景:权重 w ≈ 1.0,学习率 1e-3,梯度 1e-4,单步更新量 η·g ≈ 1e-7。在 BF16 下(1.0 附近相邻可表示数的间隔约 7.8e-3),这个更新量会被直接舍掉——w 原地不动。 微调场景更危险:基座权重已经在 1.0 量级,学习率给到 1e-5,单步更新 1e-8 量级,完全看不见。
所以有句话得说清楚,因为它经常被反过来讲:
「BF16 比 FP16 稳定」只在「不溢出」这个维度成立;在「精度」维度,BF16 是三种格式里最差的。
上一段说的「磨损」也是这个意思:FP16 的故障是溢出(显眼、有 NaN、有报警、GradScaler 帮你跳过那步),BF16 的故障是磨损(不报错、loss 曲线看着还行、最后指标差一截)。前者打断你,后者只让你怀疑自己。
这也解释了为什么 BF16 有一条不可让渡的红线:主权重和优化器状态必须留在 FP32。混合精度的「混合」不是「前半段低精度后半段高精度」,而是临时值低精度、持久值高精度——前向激活和反向梯度是临时的,权重和 Adam 的 m/v 是存活几千步的,后者一降精度就是慢性失血。
3. 为什么 2 倍也值得做
2 倍听起来不性感。但它有一个 16 倍没有的性质:这 2 倍在两种瓶颈下都成立。
用 roofline 看。算术强度门槛 = 峰值算力 ÷ 显存带宽:
| BF16 峰值 | 带宽 | 门槛 | |
|---|---|---|---|
| RTX 5000 Ada | 130.6 TFLOPS | 576 GB/s | 227 FLOP/byte |
| A100 | 312 TFLOPS | 2,039 GB/s | 153 FLOP/byte |
一个算子的算术强度低于门槛,就是 memory-bound(算力用不满);高于门槛才是 compute-bound。
现在看 BF16 干了什么:它把计算量和数据搬运量都砍了一半。
- compute-bound:
time = compute / 2→ 2× - memory-bound:
time = memory / 2→ 2×
两个区间都指向 2 倍。 这才是 BF16 相对 TF32 的本质优势——TF32 只砍计算不砍搬运,所以在 memory-bound 的负载上一点用没有。而现实里绝大多数卷积和小 batch 的 attention 都是 memory-bound。
那为什么实测只有 1.4~1.8 倍?三个漏斗:
一是 autocast 的黑白名单。 不是所有算子都走 BF16。LayerNorm、BatchNorm、GroupNorm、softmax、log_softmax、cross_entropy、mse_loss、pow、exp、log、sqrt、rsqrt、sum、norm 全部强制 FP32——共同点是要累加很多项或要保留数量级差异。扩散模型的 U-Net 里归一化层占比不低,这部分一点不加速,还要付 dtype 切换的开销。
二是 cast 开销。 每次进出 autocast 都要做 dtype 转换,算子越碎,这个开销占比越高。
三是 data loading。 GPU 快了但 dataloader 没快,GPU 就在等。多相机高分辨率输入的预处理(resize、normalize、color jitter)特别容易变成新瓶颈。
判断方法很土:训练时看 nvidia-smi dmon 的 SM 利用率,长期低于 70% 而 loss 正常下降,就是 data bound。这时候调精度毫无意义,先去把 num_workers、prefetch_factor、pin_memory 调对,或者把预处理搬到 GPU 上。
4. 五类坑,按咬人概率排
4.1 最阴的一条:AdamW 的 EMA 会跟着参数一起降精度
这条值得单独列一节,因为它太容易误入,而且不报错。
torch.optim.AdamW 是拿参数的 dtype 去初始化 state 的。所以如果你为了省事写了 model.to(torch.bfloat16)(而不是用 autocast 只降计算精度),exp_avg 和 exp_avg_sq 也会变成 bf16。
NVIDIA NeMo 文档把这个单独标成 “Risky Pattern”,原文:
“bf16 EMA quantization can quietly slow convergence (higher final loss at the same step count) and, in worse cases, produce unstable grad_norm, loss bumps, loss spikes, or divergence.”
关键词是 quietly。它不崩、不 NaN、loss 曲线看着还行,只是最终指标差一截。你会在两周后对比 FP32 基线时才发现,然后怀疑人生。
两个安全姿势:
- 稳的:参数常驻 FP32,用 autocast 只把计算降到 BF16。更新链路全程 FP32。
- 省的:用 Transformer Engine 的 FusedAdam,参数常驻 BF16,但显式指定
exp_avg_dtype: torch.float32。
还有个连带坑:torch.optim.Adam 的 eps 默认 1e-8。这个值在 FP32 下没问题,但如果 EMA 落到低精度,1e-8 已经低于可表示精度,除零风险直线上升。低精度场景下把 eps 抬到 1e-6 量级。
4.2 框架坑:autocast 的黑白名单,以及「手动 cast 更糟」
torch.autocast 不是「把模型转成 BF16」,它是在 dispatch 时逐个算子决定精度。规则分三类:
走 BF16 的:matmul、mm、bmm、addmm、linear、conv1d/2d/3d、conv_transpose、scaled_dot_product_attention——共同点是计算密集且对舍入宽容。
强制 FP32 的:layer_norm、batch_norm、group_norm、softmax、log_softmax、cross_entropy、nll_loss、mse_loss、pow、exp、log、rsqrt、reciprocal、sum、norm——要累加很多项,或要保留数量级差异。
取最宽输入类型的:cat、stack、index_copy——输入里有一个 FP32,结果就是 FP32。
三条实践推论:
- 不要手写
.bfloat16()。手动 cast 会绕过 autocast 的安全策略,把本该在 FP32 里跑的算子也降下去。PyTorch 官方对 type mismatch 的态度很明确:“This error is indicative of something being off with the network, and it’s usually worth investigating first.”——不要先加 cast,先查网络。 - autocast 只包前向 + loss,
backward()放外面。反传会用前向记录的 dtype,多包一层没有额外收益。 - 只有一处需要手动干预时才手动干预,并且注释清楚为什么。
4.3 扩散模型的专属敏感点
PyTorch 官方在 Best Practices 里点名过一句,值得抄在笔记里:
“High Performance Computing (HPC) applications, regression tasks, and generative networks may simply require full float32 IEEE precision to converge as expected.”
轨迹规划器就是标准意义上的 regression task——输出是 8 个 waypoint × 2 个坐标的连续值,loss 是 MSE。
这里有个容易说岔的地方,值得掰开:MSE 这个算子本身是安全的,4.2 那张表里它就在 FP32 名单上。真正被磨损的是它的被减数——前向算出来的 waypoint 坐标。
算一下磨损量:坐标值在 5 米附近,BF16 在 [4, 8) 区间的 ULP 是 2⁻⁵ ≈ 3.1e-2。也就是说输出本身被量化到 3 厘米的格子上。
这不会让 loss 变 NaN,只是给 L2 焊了一个几厘米的地板。而这个「一点点」,在 nuScenes 上可能就是 0.57m 和 0.61m 的差别——能不能写进论文的差别。
还有一处必须 FP32:
eps_hat = eps_cond + w * (eps_cond - eps_uncond)
CFG 这个式子,括号里是两个预测噪声之差(相近数相减,有效位大幅损失),再乘一个大 guidance scale w(常见 3~7.5)把误差放大。必须在 FP32 里算。
4.4 可复现性
低精度下归约顺序(tree reduction vs sequential)会改变结果。换 batch size、换卡、换 PyTorch 版本,都不会 bit-exact 相同。要报指标就固定 seed + 固定数据顺序 + 固定 batch 形状。
4.5 硬件坑:确认你的卡真的有硬件路径
这条对本文读者可能不适用(RTX 5000 Ada 是 sm_89,BF16 原生支持),但值得记一笔:BF16 需要 sm_80 及以上。Turing(sm_75,比如 Quadro RTX 5000)根本不支持 BF16——不是慢,是 torch.cuda.is_bf16_supported() 直接返回 False,PyTorch 会静默退化或报错。
另外,嵌入式 SoC 上情况更微妙。NVIDIA Jetson Orin 是 GA10b / sm_87,属于 Ampere 家族,BF16 指令能编能跑,但官方 datasheet 只给 FP16 和 INT8 的 Tensor Core 数字,没有 BF16 那一行。官方不给数字这件事本身就是信号:它不是你该指望的那条快路径。
所以第一件事永远是这一行:
print(torch.cuda.get_device_capability()) # 需要 >= (8, 0)
print(torch.cuda.is_bf16_supported())
5. 真正麻烦的地方:从「FP32 类精度」落到 FP16
前面四节都是训练端的事,收益 2 倍、坑已知、可验证。但混合精度真正难的地方不在训练,在训练和部署的接缝上。
先破一个很容易冒出来的念头:「那我训练用 FP32 不就没这事了?」不会,一模一样。
因为溢出这件事由目标格式决定,不由训练格式决定。FP32 和 BF16 共用同一个 8 位指数字段,量程是同一堵墙:
| FP32 | BF16 | FP16 | |
|---|---|---|---|
| 指数位 | 8 | 8(抄 FP32) | 5 |
| 尾数位 | 23 | 7 | 10 |
| 最大有限值 | 3.40e38 | 3.39e38 | 65504 |
| 转成 FP16 时 | 24→11 位,掉 13 位 | 7→10 位,反而涨 3 位 | 不转换 |
最有意思的是最后一行的方向性:BF16 落到 FP16 是「精度变好、范围变差」,FP32 落到 FP16 是「两头一起变差」。
所以「训练用 FP32」在范围上和 BF16 站在同一条线上,一点保护都不多;在精度上反而是三种里掉得最狠的——它白搭了 FP32 那 23 位尾数,最后还是压缩到 11 位。
那道断层的罪魁不是 BF16,是 FP16 只有 5 位指数。 你用 FP32、BF16 还是 FP16 训练,只要终点是 FP16,暴露面完全一样。
PyTorch 官方文档在一次具体的事故上报过这个机理(说的是 BF16→FP16 这一个特例,物理是通用的):
“fine-tuning bfloat16-pretrained models in float16 can easily run into range issues in float16 because of the potentially large range from training in bfloat16, so users should stick with bfloat16 fine-tuning if the model was trained in bfloat16.”
直译:BF16 训出来的东西,别随便丢进 FP16。
而如果你的部署端是国产车规 NPU,情况会更干脆——比如黑芝麻华山 A2000 用的九韶 NPU,官方精度清单是:
全链路支持 INT4、INT8、FP8、FP16、FP32 混合精度,FP16 模型无需量化即可直接运行。
这份清单里没有 BF16。
这其实是个合理的设计决策——BF16 的价值在训练的梯度动态范围,推理端用不上它,NPU 不需要为它浪费晶体管。但它给训练侧留下了一个必须自己处理的对接问题:你在 RTX 5000 Ada 上训出来的东西,不管训练用的是什么精度,最终都要落进一个只有 FP16 的世界。
5.1 溢出真正发生在哪:不是主干,是旁边的小算子
这一节是整篇文章里最该被记住的,因为它和训练精度完全无关。
直觉上大家怕的是那个几万乘几万的大 matmul。实际上主干最安全——归一化层和残差连接把量级死死压在 O(1) 到 O(1e2) 之间,多层堆下来也不会失控。
真正穿 65504 的是夹在中间的那些「小算子」:
| 算子 | 什么时候爆 | 具体数字 |
|---|---|---|
| 平方 / 方差 / 二阶矩 | 输入量级才几百就够 | x = 300 → x² = 9e4 > 65504 |
| L2 距离、squared error | 用未归一的坐标算距离时 | BEV 以分米计时 1000² = 1e6 |
exp | 指数 > 11.09 就 Inf | exp(12) = 1.6e5 |
| 除法 / 归一化分母 | 分母接近 0 | 无下界保护时直接 Inf |
这里有一个非常关键的、容易被忽略的不对称:
在 GPU 上,autocast 有一张黑名单——LayerNorm、softmax、各种 loss、pow/exp/log/rsqrt/reciprocal、sum/mean/norm 全部强制留在 FP32。上面那张表里的算子,基本都在黑名单里。 也就是说 GPU 上你免费获得了一层针对性的溢出保护,你甚至不知道它在。
有一个例外值得单独记:普通的 /(true_divide)不在强制 FP32 名单上,只有 reciprocal 在。 所以 a / b 在 autocast 下会用输入的 dtype 算,而 a * b.reciprocal() 反而会被提升。同一个数学式子,两种写法安全等级不同——这种细节没法靠直觉猜,只能查表。
NPU 上没有 autocast。 精度分配是编译期定死的、静态的。九韶的编译器确实会「按算子特性自动选择最佳精度组合,精度敏感型算子保留更高精度」——但那是编译器的判断,不是你的判断。它默认哪些算子算「敏感」,你在部署前必须拿到精度分配报告逐条核对,不能假设它和 cuBLAS/cuDNN 的黑名单长得一样。
这一条才是训练→部署真正的风险,而且它不会因为你训练用 FP32 而消失一毫米。
5.2 怎么发现断层:四个必做动作
动作一:训练时记录激活值 running max
不用每个 tensor 都记,记关键节点就够:每个 block 的输入、attention 的 logits(softmax 之前)、CFG 的中间量、最终输出。
act_max = {}
def make_hook(name):
def hook(mod, inp, out):
if isinstance(out, torch.Tensor) and out.is_floating_point():
m = out.detach().abs().max().item()
act_max[name] = max(act_max.get(name, 0.0), m)
return hook
for name, mod in model.named_modules():
if isinstance(mod, (nn.Conv2d, nn.Linear, nn.LayerNorm)):
mod.register_forward_hook(make_hook(name))
# 跑完一个 epoch,看有多少超过 65504
over = {k: v for k, v in act_max.items() if v > 65504}
跑完一个 epoch,over 里就是断层点。
动作二:按断层点处理,三选一
| 方式 | 适用 | 代价 |
|---|---|---|
| 该层权重缩放 | 激活整体偏大但分布正常 | 部署端要对应位置反向缩放 |
| 该层保留 FP32 | 只有少数算子爆 | NPU 上混合执行,速度有损;且要先确认工具链允许逐层指定 |
| 训练期加 range 约束(clamp / soft-cap) | 从源头压住 | 轻微影响表达能力 |
扩散模型在这里有个天然优势:轨迹输出本来就有物理边界(横向加速度 ≤ 2.5 m/s²、曲率变化率 ≤ 0.1 rad/m² 这类约束本来就要加),把它们实现成 clip,顺手当数值保护用了,一举两得。
动作三:固定 batch 序列逐层对比
同 seed、同数据、同步数,训练前向 vs FP16 推理,逐层比 max/mean,找第一个偏差超阈值的算子。不能只看最终输出——最终输出是平均值,会掩盖单层爆掉。
动作四:确认 NPU 溢出时是「炸」还是「哑」
这一条几乎没人查,但它决定了故障是「可发现」还是「永不可发现」:
- 炸:溢出 → Inf → NaN 传染下游,输出直接烂掉,你立刻知道
- 哑:推理引擎对超范围值做饱和截断(clamp 到 65504),不产生 Inf、不报错,输出形状完全正常,只是数值被压扁了
前者是响故障,后者是哑故障。车端最怕哑故障——指标掉几个点,查三天不知道从哪来,最后发现是某个没人看的中间层早在半年前就饱和了。
验证方法很土但有效:人为把某一层权重放大 100 倍喂进去,看输出是 NaN,还是一组有限的、被压扁的数。这一条必须在拿到板子的第一周就测掉。
5.3 一个更工程化的选择:训练就用 FP16
这里有个很实际的推论,来自第 1 节的结论:BF16 和 FP16 在 RTX 5000 Ada 上速度完全相同(峰值都是 130.6 TFLOPS,FP32 累加口径)。
而且第 4.3 节那条「回归任务可能真需要 FP32」的担忧,方向其实指向 FP16 而不是 BF16——FP16 的尾数是 10 位,BF16 只有 7 位。同样会磨损,FP16 磨得轻。
既然同速、精度还更好,那就有一个选择:
| 方案 | 训练速度(峰值) | 训练↔部署对齐 | 额外成本 |
|---|---|---|---|
| FP32 训练 → FP16 部署 | 1×(无加速) | ❌ 一样要校准 | 白丢 2 倍,显存还多一倍 |
| BF16 训练 → FP16 部署 | 2× | ❌ 范围要校准 | 校准流程 + 逐层验证 |
| FP16 训练 → FP16 部署 | 2×(相同) | ⚠️ 范围天然对得上 | GradScaler + 溢出调试 |
| FP8 训练 → FP8 部署 | 8× | ⚠️ 同尺也只到 448 | recipe 复杂,生态不成熟 |
注意第一行:这张表里没有「FP32 训练 = 安全」这一格。 FP32 付了 2 倍的时间、2 倍的显存,买到的范围安全和 BF16 一模一样——因为溢出由 FP16 那头决定,跟你训练时有多宽裕毫无关系。这是个很贵的安全感,而且是假的。
第三行那个 ⚠️ 也别读成「万事大吉」。FP16 训练解决的是「范围对得上」,不是「精度分配对得上」。 你在 GPU 上用 FP16 训练时,autocast 仍然把 LayerNorm、softmax、loss 悄悄留在 FP32;而 NPU 上是编译器说了算。所以哪怕训练和部署都是 FP16,5.1 那张小算子表里的东西还是得挨个核——这才是「天然一致」这句话的真实边界。
这笔交易的本质是:用一个「已知的、立刻会暴露的」成本(GradScaler 的调试),换掉一个「未知的、延迟爆发的」风险(车端 NaN)。
FP16 训练的代价说得具体点:需要 loss scaling,校准期会 skip step,65504 上界意味着你训练时就得面对范围问题。但这恰恰是好事——在训练时暴露,比在车端暴露好一百倍。 车端暴露的时候,你面对的是路测数据、是复现困难、是「怎么昨天还好好的」。
所以我的判断是:如果这个模型最终要上只有 FP16 的 NPU,训练用 FP16 反而更工程化;如果只是做研究/发论文,BF16 更省心。 两者速度一样,这个决策纯粹是「工程风险」和「调试便利」的取舍,跟性能无关。
5.4 FP8 是唯一能突破 2 倍的路,但别急着上
RTX 5000 Ada 的第四代 Tensor Core 原生支持 FP8(E4M3/E5M2),dense 522.2 TFLOPS = 8× FP32。而九韶 NPU 的清单里也有 FP8。这是唯一一条「两端同精度、而且真的能加速」的路。
但先说清楚为什么现在别上:
- PyTorch 原生对 FP8 训练支持不成熟,主流路径是 Transformer Engine 或 torchao
- FP8 训练需要 per-tensor / per-block scaling 的 recipe,不是加个 autocast 就完事
- 扩散模型(回归任务)对精度比 LLM 更敏感,FP8 的成功案例主要集中在 LLM
- recipe 调不对的时候,你会在「数值不收敛」上花掉比 8 倍加速多得多的钱
结论:FP8 是明年的事,今年先把 BF16/FP16 这条链路做扎实。
不过还得补一句让这条「最优路」没那么香的话:E4M3 的最大值是 448。
比 FP16 的 65504 又小了 146 倍。回到 5.1 那张小算子表——FP16 下要 x 到 300 才爆平方,FP8 下 x 只要到 21(21² = 441)就到底了。
精度每往下走一步,范围风险不是线性变小,是阶跃式恶化。 这也意味着「训练和部署用同一种精度」这个策略在 FP8 上基本不成立:你得在训练期就把激活量级压到几十以内,而那不是调 recipe 能办到的,是要重新设计归一化。
6. 怎么量:别只看 step time
要把这件事写进周报、评审、简历,得有四组数,缺一组都站不住:
| 维度 | 指标 | 怎么测 | 常见错误 |
|---|---|---|---|
| 速度 | 单 step 时间 / samples per second | 预热 50 step 后取 200 step 中位数 | 算上首步的编译和 cudaMalloc |
| 显存 | torch.cuda.max_memory_allocated() | reset_peak 后跑满一个 epoch | 看 nvidia-smi(那是 reserved,含缓存和碎片) |
| 收敛 | 到目标指标所需 step 数 | 固定 eval 间隔,画 loss-vs-step | 只看最终 loss,不看达到同等 loss 用了几步 |
| 质量 | 最终任务指标(L2 / PDMS) | 同 seed、同数据顺序、跑满 | 用不同 seed 的结果直接比 |
第三行那个「达到同等 loss 需要多少 step」是关键。BF16 常出现「最终 loss 一样,但多跑了 5% 的 step」——因为每步更新里有一小部分被尾数磨损吃掉了。这部分成本不出现在 step time 里,只出现在你的实验周期里。
最小验证流程(半小时能跑完,能挡掉 90% 的坑):
# 1. 固定一个 batch,跑 20 步 FP32,记录 loss / grad_norm / 参数范数
# 2. 同一个 batch、同一个 seed,跑 20 步 BF16
# 3. 逐项对比,阈值:loss 相对差 < 1e-3,grad_norm 相对差 < 5%
# 4. 再跑一次全量,确认没有 NaN(不用等收敛,看前 500 步够)
# 5. 最后才切全量训练,跑够 3 个 epoch 再看指标
别跳过第 5 步。单次反传在 BF16 下基本没问题,问题是几千次反传的累积——这是所有低精度训练 bug 的共同性质:单步无害,累积致命。
还有一件和机器状态有关的事:测 step time 一定要在稳定的热状态下测。 工作站卡在持续负载下会降频,同一份脚本早上跑和连续跑 8 小时后跑,结果可能差 10%。别把这个差异记到你的优化成果里。
7. 回到那两个杠杆
把这篇和前面几篇串起来,加速扩散规划器上车的杠杆是四个,正交可叠加:
| 杠杆 | 砍的是什么 | 量级 | 代价 |
|---|---|---|---|
| DDIM 跳步 | 步数 T | 10~20× | 采样分布略偏 |
| 锚点 + 截断(DiffusionDrive) | 步数 T + decoder 规模 | ~10× | 要重新设计初始化 |
| 低精度(本篇) | 单步计算 + 搬运 | 1.4~1.8× | 数值风险 + 验证成本 |
| 量化 / 编译(TRT、INT8) | 单步成本再一层 | 2~3× | 精度损失,要校准 |
前两个砍步数,后两个砍单步成本。「总算力 ≈ 单步成本 × 步数」这个式子两边各砍两刀,乘起来才是板端能不能塞进 100ms 的全部答案。
还有一件从国产芯片那篇就该记住的事:看算力数字,一定要和精度格式一起读。 一个标称 1000 TOPS 的芯片,那个 1000 是 INT8 稀疏口径;FP16 要打折;BF16 可能根本不在规格表上。「我们的芯片能跑 10Hz 扩散规划器」这句话,补上「在哪个精度下」才是完整的。
BF16 也一样。它给的 2 倍是峰值 matmul 吞吐,不是你的训练时间。你的训练时间里有多少是 matmul,多少是 LayerNorm、data loading、Python 开销、以及在等 loss 曲线收敛——这个比例,才决定你拿到 1.4 倍还是 1.8 倍。
而这个比例只有 profiler 能告诉你。任何一篇文章都不行,包括这篇。