你有没有见过大型餐厅的后厨?主厨手上有一本精确到克的主菜谱(一份精心记录的“黄金配方”),但真正出菜时,切配、焯水、颠勺这些环节却由帮厨用最快的速度完成——速度优先,细节容后再调。大模型训练也是这么一回事:一群“数字”在后厨里忙得不可开交,有些步骤需要极高的精确度(FP32),有些步骤则完全可以“差不多就行”(FP16/BF16)。把这两类数字安排在同一套流水线里协同工作,就是混合精度训练。它让训练速度更快、显存占用更小,而最终做出来的“菜”(模型精度)几乎不变。本文将用“后厨分工”的比喻,带你看懂这套现代大模型训练标配技术的内幕,以及如何安全地把它用起来。
混合精度训练是大模型时代的“提速引擎”:掌握它,意味着你能用更少的显存跑更大的模型、用更短的时间完成训练迭代——这是每一位AI工程师走向大规模训练实战的必修课。
可视化演示
展示系统处理流水线、实时性能指标曲线及多方案对比分析,包含数据流转、吞吐延迟监控与方案评估。
本节配有交互动效(进入视口后自动加载,约 3KB)。
系统可视化 处理流程:输入→处理→模型→输出 性能监控:实时指标 P=L×T
浮点精度基础
走进后厨,你会看到不同的计量工具:主厨手里的电子秤精确到克(FP32),帮厨用的量杯大概到毫升(FP16),还有一种烤箱温度计,范围极大但刻度稀疏(BF16)。浮点精度,本质上就是“用多少位二进制来记录一个数字”。FP32 占用 32 位(1 位符号 + 8 位指数 + 23 位尾数),能表示约 10^38 的数值范围,精度大约 7 位有效十进制数字。FP16 只有 16 位(1 位符号 + 5 位指数 + 10 位尾数),范围窄到约 10^5,精度约 3 位有效数字。BF16 同样 16 位,但它把指数位扩回 8 位、尾数压到 7 位——范围与 FP32 相当,但精度更低,像一个“刻度稀疏的大温度计”。
在深度学习中,FP32 是“主厨配方”:权重更新这样敏感的操作,需要它保证不跑偏。但它的 32 位存储意味着显存开销大、计算速度慢。FP16 和 BF16 的 16 位存储只有一半大小,GPU 上的吞吐量却近乎翻倍——因为同样的计算单元可以塞进更多数据。代价则是数值表示粗糙:FP16 稍不注意就“爆表”(上溢出到无穷大)或“失明”(下溢成 0),BF16 虽不怕溢出,却因为尾数太少,小数值间的差异会被抹平。
精度与范围的取舍,就像厨具的选择:没有一种工具全能。FP32 精确但笨重,FP16 轻快但娇气,BF16 皮实但粗糙——理解它们的性情,才能在后厨里排兵布阵。
1. 符号位:1 位二进制决定正负,0 为正、1 为负。
2. 指数位:决定数值的“尺度范围”。FP32 有 8 位指数,FP16 仅 5 位,BF16 也是 8 位。
3. 尾数位:决定数值的“精细程度”。FP32 有 23 位尾数,FP16 有 10 位,BF16 只有 7 位。
用一句话记住:指数定范围、尾数定精度。FP16 在训练中最致命的问题,就是梯度(通常是 10^-4 级别的小数)在 5 位指数下直接下溢成 0——这为后续的损失缩放埋下了伏笔。
混合精度原理
后厨的分工逻辑是什么?主厨不亲自切配,但每道菜出锅前他都要亲自调味。混合精度训练正是这种“关键环节用 FP32 把关、计算密集环节用 FP16 跑量”的协作模式。具体来说:前向传播和反向传播中的矩阵乘法(torch.matmul、卷积等)交给 FP16 全速计算;权重更新(optimizer.step())则回到 FP32 精细操作。
为什么不全用 FP16?想象整道菜都用同一个粗糙量杯调味:盐多放一撮、糖少放半勺,累积起来味道就偏了。纯 FP16 训练时,每次权重更新都带来微小舍入误差,成百上千次迭代后,模型收敛质量下降甚至不收敛。反过来,全用 FP32 又太慢、太占显存。混合精度取了一个巧妙的中间态:让“跑量”的动作变快,“把关”的动作不失真。
一次标准训练循环就像一条流水线:FP32 主权重 → 转成 FP16 做前向 → FP16 算损失与反向梯度 → 梯度缩放回 FP32 → 更新 FP32 主权重。每个环节各自使用最合适的工具,最终在精度与速度之间找到平衡点。混合精度的核心收益并不来自某个孤立的运算,而是整条流水线叠加后的系统性提速。
1. 前向传播(FP16):用 FP16 权重和输入计算每层输出。
2. 损失计算(FP32):为避免精度损失,损失值保存在 FP32。
3. 反向传播(FP16):梯度在 FP16 下计算,但数值极小,需配合缩放。
4. 权重更新(FP32):在主权重上完成精确保留的更新。
这里出现了一个关键人物——损失缩放(Loss Scaling)。它像后厨的“调味防呆”:在梯度进入 FP16 通道之前,先把损失人为放大若干倍,让梯度数值落到 FP16 能表示的范围内,反向传播结束后再缩小回来。这正是下一节的主角。
主权重副本
后厨里总有一本“祖传配方”被锁在保险柜里,帮厨手里的只是当天用的复印件。混合精度训练同样维护着两份权重:一份是 FP32 的主权重副本(Master Weight Copy),像保险柜里的配方原本;另一份是 FP16 的工作权重副本,像帮厨手边的便签。每次迭代开始时,FP32 主权重会被复制并转换成 FP16 供前向和反向使用;迭代结束时,FP16 算出的梯度经过缩放后,回到 FP32 上完成更新——而 FP32 主权重本身从未被降精度处理。
为什么要养两份副本,多占一份显存不心疼吗?实际上,FP16 权重副本只占 FP32 一半的空间,即使两份相加也才 1.5 倍 FP32 权重空间,远小于激活值、梯度、优化器状态这些“显存大户”。更大的收益在于:权重更新永远在 FP32 上进行,多次迭代的微小修正得以累积,不会因为 FP16 的舍入误差而逐渐漂移。
形象地说,主权重副本就是“主厨最后的味觉把关”。帮厨的手艺再糙,只要最后调味权在 FP32 主厨手里,菜品(模型权重)不会越走越偏。这也是混合精度训练能在大模型上稳定收敛的基石之一。
1. 复制转 FP16:每步开始,将 FP32 主权重转为 FP16 工作副本。
2. 前向反向:FP16 副本参与计算。
3. 梯度缩放:反向得到的梯度先行放大,防下溢。
4. 反缩放回 FP32:更新前将梯度缩小回真实尺度。
5. 更新主权重:在 FP32 主副本上执行优化器步骤。
整个过程环环相扣:没有主权重副本,FP16 的每次舍入误差都会累积;没有损失缩放,梯度在 FP16 里会直接消失。两者配合,混合精度才能“跑得快又稳”。
损失缩放机制
想象在黑暗的后厨里调酱汁——盐罐刻度太粗,一小撮和一大勺看起来差不多。FP16 的“黑暗”来自它的窄范围:当梯度小于约 6×10^-5 时,直接下溢成 0,就像盐全撒在了黑暗中看不见。损失缩放(Loss Scaling)点亮了一盏灯:在反向传播之前,先把损失值乘以一个较大的缩放因子 s(如 1024),再执行反向传播。梯度随之被整体放大,落入 FP16 的安全表示区间;计算完成后,再除以 s 恢复原值。
缩放因子应该设多大?大了会溢出(梯度超过 65504 变成无穷大),小了又防不住下溢。于是有了动态损失缩放(Dynamic Loss Scaling):训练时持续监控梯度是否溢出——若连续 N 步无溢出,则把 s 调大一倍;一旦发生溢出,则跳过该步更新,把 s 减半。这个“自适应”过程,就像自动调节的烤箱温度:太热了降温,冷却了就升温,始终让食材在最佳温度下烹调。
静态缩放(固定 s)在部分场景下也能工作,但需要人工调参;动态缩放则是主流框架的默认选择。它解决的痛点是:模型不同、数据不同,梯度尺度千差万别,手动缩放要么频繁溢出、要么防不住下溢。动态机制让训练过程自我调节,免去手工干预。
1. 前向与损失:常规计算损失值。
2. 缩放损失:损失乘以缩放因子 s。
3. 反向传播:在放大后的损失上计算梯度。
4. 反缩放梯度:更新前把梯度缩小回来。
5. 溢出检测与因子调整:
自动混合精度
读到这里,你可能已经在想:“这些细节难道都要手写吗?”大可放心。现代深度学习框架把混合精度封装成了开箱即用的工具。在 PyTorch 中,torch.cuda.amp 就是这样一个“后厨总管”:它自动判断哪些操作适合 FP16、哪些必须留在 FP32,并管理损失缩放的一切动态调整。
使用者只需把握三个要点:第一,autocast 上下文包裹前向传播和损失计算,让框架在算子级别自动选择精度;第二,GradScaler 对象负责“放大损失 → 反向 → 反缩放 → 更新权重”的完整流程;第三,unscale_ 和 step 的顺序必须遵循:先反缩放梯度,再调用优化器更新。整个接入过程通常只需几行代码,大模型训练从 FP32 切到混合精度的迁移成本极低。
以 PyTorch 为例,经典 AMP 训练循环如下:
这四行模式(scale → backward → step → update)覆盖了损失缩放、梯度反缩放、权重更新和缩放因子自适应。对工程师而言,AMP 最大的价值是“把正确的事变简单”:你不需要手算梯度范围,不需要逐层检查数值溢出,框架在底层替你维护了那条“安全通道”。
1. autocast 自动选精度:上下文内,框架按算子白名单选择 FP16/FP32。
2. 缩放损失并反向:
3. 反缩放梯度:
4. 更新权重:
5. 动态调整缩放因子:
scaler = torch.cuda.amp.GradScaler()for data, target in loader: optimizer.zero_grad() with torch.autocast(device_type='cuda', dtype=torch.float16): output = model(data) loss = criterion(output, target) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()
实战收益与挑战
混合精度的“收益账本”要从两个维度看。一是显存:FP16 权重和激活值占用的显存约为 FP32 的一半,意味着同样的 GPU 可以塞进更大的 batch size 或更大的模型;二是吞吐:GPU 的 Tensor Core 对 FP16 矩阵乘法的计算速度可达 FP32 的 2~8 倍,训练迭代更快,单位时间处理的样本更多。两者叠加,大模型训练从“跑不动”变成“跑得起”。
然而,收益并非白给。挑战同样真实:一是小模型收益甚微——当模型足够小、计算时间远低于通信开销时,混合精度省下的显存与时间几乎可忽略,反而可能因精度损失导致收敛变慢;二是精度下降风险——某些对数值敏感的任务(如超长序列 Transformer)需要验证混合精度下的收敛曲线是否与 FP32 一致;三是调试复杂度上升——当出现 NaN/Inf 时,你需要判断是“溢出”还是“下溢”,是“缩放因子过大”还是“学习率过高”。这些都要求工程师对底层机制有足够的直觉。
我们在前面章节埋下的所有概念——FP16 范围、主权重副本、损失缩放、AMP 封装——在实战中就汇聚成一份行动清单:默认开启动态损失缩放、监控梯度范数、对比 FP32 基线验证收敛、小模型谨慎启用。记住“主厨掌勺、帮厨跑量”的分工智慧,你就能在精度与速度之间,找到属于自己的最优平衡。
1. 显存收益:
2. 吞吐收益:
3. 模型收益临界条件(规模足够大时收益显著):
AI 应用场景
大语言模型预训练加速
阶段:训练与优化
解决的问题:训练千亿参数大模型时,若全用 FP32,显存需求动辄数 TB,远超单卡 A100/H100 的 80GB 容量;且训练周期以月计,算力成本极高。混合精度训练通过“FP16 跑前反向”将单卡显存峰值降低约 40%,并借助 Tensor Core 将矩阵乘法吞吐提升 2~3 倍,让原本“训不起”的模型“训得起”。
具体案例:某团队在 512 张 A100 GPU 上预训练 130 亿参数语言模型。启用 AMP + 动态损失缩放后,单卡 batch size 从 12 提升至 20,总吞吐量提升约 1.8 倍;通过梯度范数监控与 FP32 基线对比验证,最终模型在验证集上的困惑度下降曲线与 FP32 基本重合,收敛质量无损。
视觉 Transformer 微调
阶段:微调与部署
解决的问题:在资源受限的推理服务器上对 ViT-Base 模型进行领域微调,显存瓶颈限制了 batch size,导致收敛速度慢。混合精度让激活值显存减半,batch size 翻倍,微调周期缩短约 1/3;BF16 模式则避免了 FP16 在敏感层上的溢出风险。
具体案例:医疗影像团队使用 PyTorch AMP + GradScaler 微调 Swin-Transformer。固定随机种子后,混合精度模型在病灶分割任务上的 Dice 指标(0.861)与 FP32 基线(0.863)仅差 0.2%,而训练时间从 14 小时缩短至 9.5 小时。
Python 演示
import mathimport random class SimpleMLP: """A tiny 2-layer MLP for mixed-precision demo.""" def __init__(self, in_dim=4, hid_dim=8, out_dim=1): self.w1 = [[random.uniform(-0.5, 0.5) for _ in range(in_dim)] for _ in range(hid_dim)] self.b1 = [0.0] * hid_dim self.w2 = [random.uniform(-0.5, 0.5) for _ in range(hid_dim)] self.b2 = 0.0 def forward(self, x): # Linear + ReLU (hidden) h = [0.0] * len(self.w1) for i in range(len(self.w1)): z = self.b1[i] + sum(self.w1[i][j] * x[j] for j in range(len(x))) h[i] = z if z > 0 else 0.0 # Linear output (scalar) out = self.b2 + sum(self.w2[i] * h[i] for i in range(len(h))) return out, h def train_step(self, x, y_true, lr=0.01): """One SGD step returning loss and gradients norm.""" pred, h = self.forward(x) diff = pred - y_true loss = 0.5 * diff * diff # Gradients (FP32 scale) g_out = diff # dL/dout g_w2 = [g_out * h[i] for i in range(len(h))] g_b2 = g_out g_h = [g_out * self.w2[i] for i in range(len(h))] g_w1 = [] g_b1 = [] for i in range(len(self.w1)): dh = g_h[i] if h[i] > 0 else 0.0 g_w1.append([dh * x[j] for j in range(len(x))]) g_b1.append(dh) # Update FP32 weights for i in range(len(self.w1)): for j in range(len(self.w1[i])): self.w1[i][j] -= lr * g_w1[i][j] self.b1[i] -= lr * g_b1[i] for i in range(len(self.w2)): self.w2[i] -= lr * g_w2[i] self.b2 -= lr * g_b2 grad_norm = math.sqrt(sum(v * v for row in g_w1 for v in row) + sum(v * v for v in g_w2)) return loss, grad_norm def to_fp16_sim(x): """Simulate FP16 rounding by keeping 3 significant decimals.""" if x == 0.0: return 0.0 s = 1 if x < 0 else -1 ax = abs(x) exp = math.floor(math.log10(ax)) mant = round(ax / (10 ** exp), 3) return s * mant * (10 ** exp) def train_mixed_precision(epochs=30): """Train with simulated FP16 forward/backward and FP32 master weights.""" model = SimpleMLP() lr = 0.05 scaler = 64.0 # loss scaling factor, simulating AMP's GradScaler inf_count = 0 safe_steps = 0 for ep in range(epochs): loss = 0.0 gnorm = 0.0 for step in range(20): x = [random.uniform(-1.0, 1.0) for _ in range(4)] y_true = sum(x) * 0.5 + 0.1 # FP16 forward: cast inputs and weights to fp16-sim x16 = [to_fp16_sim(v) for v in x] w1_16 = [[to_fp16_sim(v) for v in row] for row in model.w1] w2_16 = [to_fp16_sim(v) for v in model.w2] # Scaled loss backward (fp16-sim) pred, h = model.forward(x16) diff = pred - y_true loss = 0.5 * diff * diff g_out_scaled = diff * scaler if math.isinf(g_out_scaled) or math.isnan(g_out_scaled): inf_count += 1 scaler *= 0.5 # overflow: reduce scale safe_steps = 0 continue safe_steps += 1 if safe_steps > 10: scaler *= 2.0 # no overflow recently: try larger scale safe_steps = 0 # Unscale gradient, simulate fp32 update local_lr = lr / scaler g_w2_16 = [to_fp16_sim(g_out_scaled * h[i]) for i in range(len(h))] for i in range(len(model.w2)): model.w2[i] -= local_lr * g_w2_16[i] # simplified weight updates for w1 as well for i in range(len(model.w1)): dh = g_out_scaled if h[i] > 0 else 0.0 for j in range(len(model.w1[i])): model.w1[i][j] -= local_lr * to_fp16_sim(dh * x16[j]) gnorm = abs(g_out_scaled / scaler) if ep % 5 == 0: print("epoch %2d | loss %.4f | grad_norm %.4f | scale %.1f" % (ep, loss, gnorm, scaler)) print("done. infs=%d final_scale=%.1f" % (inf_count, scaler)) if __name__ == "__main__": train_mixed_precision()
to_fp16_sim(x)用保留 3 位有效数字模拟 FP16 精度损失。w1_16 = [[to_fp16_sim(v) for v in row] for row in model.w1]前向传播前,将 FP32 主权重转为 FP16 副本。loss = 0.5 * diff * diff计算 MSE 损失(保留在 FP32 精度下)。g_out_scaled = diff * scaler损失缩放:把梯度放大到 FP16 的安全表示区间。math.isinf(g_out_scaled)检测溢出,若发生则跳过本步更新并缩小缩放因子。if safe_steps > 10: scaler *= 2.0若连续 10 步无溢出,则尝试更大缩放因子以提升精度。model.w2[i] -= local_lr * g_w2_16[i]反缩放后更新 FP32 主权重。
常见误区
误区“反正要提速,把所有张量都转成 FP16 不就行了?反正一半精度一半显存。”初学者容易把混合精度理解成“全换 FP16”,结果模型训练发散。
正解混合精度的核心是“混合”——敏感环节用 FP32(主权重更新、损失计算),计算密集环节用 FP16/BF16(矩阵乘法)。全 FP16 训练会累积舍入误差并急剧缩小数值范围,导致收敛失败。正确做法是让框架自动分配精度。
为何常见:因为“FP16 省一半显存、快一倍”这句话太动听,人们容易忽略“快”只来自 Tensor Core 计算加速,而“稳”来自 FP32 的主权重兜底。
误区“既然小梯度会下溢,那就把缩放因子调得非常大,所有梯度都能表示。”于是把 s 设成 10^6,结果频繁溢出、模型完全训不动。
正解缩放因子过大,梯度会突破 FP16 上限 65504 变成无穷大,直接触发更新跳过。动态损失缩放的意义在于自动寻找“既不溢出、也不下溢”的中间点:溢出就减半、安全就翻倍。手动设超大因子是典型的过犹不及。
为何常见:人们天然倾向于“越大越保险”,但忽略了 FP16 是两端受限的数值系统——缩小梯度的同时也在放大溢出的可能。
误区“框架有 autocast 和 GradScaler,我什么都不用管,自动就加速、自动就稳定。”
正解autocast 只负责算子的精度选择,GradScaler 只负责损失缩放,它们不会修正学习率过高导致的发散,也不会覆盖分布式训练的梯度同步顺序错误。使用 AMP 仍需监控梯度范数、对比 FP32 基线、检查 NaN/Inf 出现位置。
为何常见:框架 API 太“傻瓜化”了,让人误以为底层数值稳定也是全自动的——实际上 AMP 是一个需要配合纪律使用的工具。
误区“既然大模型用混合精度提速 2 倍,我的小模型应该也能提速 2 倍。”
正解混合精度的收益与计算密集度强相关,小模型上矩阵乘法耗时短、host 开销占比高,FP16 带来的加速往往被通信/启动开销淹没。某些 MLP 或小 CNN 上混合精度甚至更慢。建议先做 benchmark 验证收益,再决定是否启用。
为何常见:看到成功案例就照搬,忽略了收益与大模型规模的耦合关系。
面试题
Q1. FP16 相比 FP32,最主要的数值缺陷是什么?
- A.计算速度太慢
- B.表示范围窄,易上溢或下溢← 正确答案
- C.无法在 GPU 上运行
- D.无法表示负数
答案:B。FP16 的指数位只有 5 位,可表示范围远小于 FP32,训练中梯度容易下溢成 0 或上溢成无穷大。类比“量杯刻度太粗,小撮盐看不见,一大勺又洒满桌”。A/ C/ D 都与 FP16 特性不符。
Q2. 混合精度训练中,主权重副本通常以什么精度保存?
- A.FP16
- B.BF16
- C.FP32← 正确答案
- D.INT8
答案:C。主权重副本保存为 FP32,保障权重更新的精度不因舍入误差累积而漂移。类似“主厨的配方原本锁在保险柜,绝不给帮厨随意涂改”。
Q3. 损失缩放的直接目的是什么?
- A.提高模型准确率
- B.防止梯度在 FP16 下下溢成 0← 正确答案
- C.加快卷积计算速度
- D.减少显存占用
答案:B。FP16 表示范围窄,训练中梯度常小于 6×10^-5,直接下溢成 0。损失缩放先把损失放大 s 倍,梯度随之落入 FP16 安全区间,反向后再缩小回来。这不改变准确率上限,也不直接加速卷积或减少显存。
Q4. 动态损失缩放中,“连续 N 步无溢出”后缩放因子如何变化?
- A.保持不变
- B.减半
- C.变为 0
- D.翻倍← 正确答案
答案:D。算法逻辑是“保守时缩小、安全时激进”。连续 N 步无溢出说明当前缩放因子偏低,于是翻倍以提升梯度表示能力;若检测到溢出则减半并跳过该步更新。
Q5. 关于 BF16 与 FP16,下列说法正确的是?
- A.BF16 的指数位与 FP32 相同,均为 8 位← 正确答案
- B.BF16 的精度比 FP16 更高
- C.BF16 不容易上溢/下溢,但尾数精度较低← 正确答案
- D.BF16 常用于省去损失缩放的场景← 正确答案
答案:ACD。BF16 的指数位与 FP32 同为 8 位,表示范围大,所以不易溢出,可减少损失缩放需求(选项 A、C、D 对);但 BF16 尾数只有 7 位,精度比 FP16 的 10 位更低(选项 B 错)。
Q6. PyTorch AMP 的正确训练流程是?
- A.loss.backward() → scaler.step() → scaler.update()
- B.scaler.scale(loss).backward() → scaler.step() → scaler.update()← 正确答案
- C.scaler.scale(loss) → optimizer.zero_grad() → loss.backward()
- D.optimizer.step() → scaler.scale(loss).backward()
答案:B。标准流程是:scaler.scale(loss).backward() 放大损失并反向,scaler.step(optimizer) 内部反缩放并执行权重更新,最后 scaler.update() 动态调整缩放因子。A 缺缩放过;C 先放大却没反向;D 顺序颠倒。
Q7. 混合精度训练中,为什么权重更新必须在 FP32 上执行?
- A.FP32 计算速度更快
- B.防止舍入误差累积导致收敛漂移← 正确答案
- C.FP16 不支持减法
- D.为了减少显存
答案:B。每次 FP16 更新都会引入舍入误差,成百上千次迭代后误差累积,模型精度下降。FP32 主权重副本保留细粒度更新。类比“主厨亲自调味,帮厨只负责切配”。
Q8. 以下哪些情况说明混合精度训练出现了数值问题?
- A.损失变为 NaN← 正确答案
- B.权重中出现无穷大值← 正确答案
- C.梯度范数长期为 0← 正确答案
- D.训练 loss 平稳下降
答案:ABC。NaN 损失、权重无穷大、梯度范数为 0 都是典型的 FP16 数值异常信号(分别对应上溢、溢出未回退、下溢)。loss 平稳下降是正常表现,不属于问题。
Q9. AMP 中的 autocast 上下文主要作用是什么?
- A.自动调整学习率
- B.自动为算子选择合适的浮点精度← 正确答案
- C.自动保存模型检查点
- D.自动做数据增强
答案:B。autocast 按预定义算子策略自动选择 FP16/FP32:矩阵乘法、卷积等计算密集算子用 FP16,敏感归一化、损失计算用 FP32。它不负责学习率、检查点或数据增强。
Q10. 关于混合精度训练的收益与挑战,正确的有?
- A.显存占用可显著降低← 正确答案
- B.吞吐量可能提升 2~8 倍← 正确答案
- C.小模型收益不如大模型显著← 正确答案
- D.启用后无需进行精度验证
答案:ABC。混合精度的收益包括显存减半和 Tensor Core 吞吐翻倍,且收益随模型规模增大而更明显(A/B/C 对)。D 错——任何启用混合精度的项目都应对比 FP32 基线验证收敛质量。