bf16只保留8个有效位。当残差流里的某个数值涨到600附近,相邻两个bf16数之间的间隔就是4,任何小于2的单步更新都会被直接舍掉。矩阵乘法扛得住这种精度,但承载残差流的那个累加和扛不住——它要做的恰恰是把很多小更新加到一个大数上。
![]()
一组模拟里,bf16残差在4000步内只拿到fp32残差漂移量的0.3%到43%,具体多少取决于噪声水平,其中一种设置下它干脆不动了。主流训练代码用的修法很便宜:残差路径留在fp32,矩阵乘法继续用bf16。做不到的话,随机舍入在期望上能把平均值捞回来。不管选哪种,盯残差和门控的最大值随时间的变化,别只盯loss。
小更新是怎么被吃掉的
一个bf16数由1位符号、8位指数、7位存储尾数构成,算上隐含的前导1,一共8个有效位。相对分辨率大约是2^-8到2^-7,也就是0.4%到0.8%。对一个只被消费一次的权重或激活来说,这够用。
Transformer的残差流不一样。每个block算的是 x = x + f(x),同一个张量被读-改-写一次,几十个block下来,你关心的是那个和。浮点加法会把结果舍入到最近的可表示数,如果更新量小于当前量级下间隔的一半,和就舍回旧值,这次更新等于没发生。
用torch.nextafter在bf16张量上量出来的间隔是这样的:
- 数值在600附近时,间隔为4
- 更新2.0不产生任何效果
- 更新2.1则让数值直接跳4
就近舍入遇到正好一半的平局会向偶数尾数靠,所以恰好等于半个间隔的更新同样会丢。
残差值真有那么大吗
有,在特定维度上。Sun等人的论文《Massive Activations in Large Language Models》报告了少量激活值,比典型值大出最多100,000倍,跨输入保持稳定,充当塑造注意力的隐式偏置。这些值就活在层与层之间流动的隐状态里,也就是残差流。任何流范数随深度增长的模型,都可能把某些坐标推到几百的量级。同一个张量里,中位数坐标接近1、少数坐标接近600,完全可以共存,而那些大坐标恰恰是决定行为的那些。
后果是一种在loss曲线上看不见的精度剖面。一个接近600的坐标只能以4为步长移动。如果该坐标上真实的梯度驱动漂移是每步0.3,那得积累很长一段同向更新才会被记录一次,而记录下来的那一次是成块到达的。
模拟里长什么样
下面这组数字是示意性模拟,不是真实模型训练。4096个坐标全部从600起步,每步加上 mu + sigma * noise,mu 是小的相干漂移,sigma 是每步噪声。三条残差路径做对比:完整fp32残差、接收bf16舍入更新的fp32残差(只把矩阵乘法输出转bf16时就是这个样子)、以及全bf16残差。
三点值得注意。第一,bf16残差不只是噪声大,它是有偏的。均值0.2、标准差0.5时,几乎没有单步更新能越过舍入阈值,800的相干漂移塌缩成2.4。信号本来就是小的那部分,而被删掉的正是这部分。
第二,噪声会掩盖问题。标准差1.0时bf16残差还在漂,因为偶尔有大的噪声抽样越过阈值、被舍入成完整的一步4。它在均值0.2时捞回fp32漂移的43%,均值0.5时捞回21%。看起来像在进展,但这种进展取决于更新碰巧有多吵,这不是优化器该有的性质。
第三,全bf16残差在约427处走平。那是运行值越过1024、间隔翻倍到8的位置。此时约1的更新远低于半个间隔,什么都记不上,残差冻结。
中间那列是关键对照。把更新本身舍入到bf16——bf16矩阵乘法输出干的就是这件事——只要累加器是fp32,代价几乎为零。损失来自对和的舍入。
生产训练栈怎么处理
它们把残差路径留宽。Megatron-LM暴露了一个fp32残差连接选项,且只在配合fp16或bf16时有效(参数校验里就是这么断言的)。Mamba参考实现有一个 residual_in_fp32 标志,把残差转成float32。两个选择说的是同一件事:矩阵乘法可以窄,累加和不行。
PyTorch里的写法很短。下面这段在CPU上跑过(nn.RMSNorm 需要PyTorch 2.4或更新),确认返回的是fp32残差:
- norm之后把h转成bf16
- 在autocast下算前馈,得到u
- 返回 x + u.float(),加法在fp32里完成
代价是每个block边界上一个张量的内存和带宽,不落在权重上,也不落在矩阵乘法输入上。对多数训练配置来说,相比权重、优化器状态和注意力激活,这笔开销很小,不过还是该按自己的batch形状实测一下。同样的思路适用于任何低精度后端:FP8矩阵乘法、老GPU上的fp16、以及累加器窄的加速器。
残差留不住fp32怎么办
对残差加法用随机舍入。不是舍到最近的bf16值,而是按小数距离成比例的概率向上舍。结果的期望值等于真实和,所以小更新在平均意义上活下来了,代价是额外的方差。PyTorch参考实现是在bf16截断位以下加随机比特再截断。
同一组模拟里,bf16残差的随机舍入在均值0.2、标准差0.5时捞回797.5的漂移,fp32是800.2;均值0.5、标准差1.0时是1998.8对2000.5。这是4096个坐标上的平均。单个坐标会更吵,得确认方差对你的任务可接受。硬件对随机舍入的支持参差不齐,上面这种软件版本比普通加法慢。
怎么在跑废之前发现
陷阱在于早期什么都不坏。loss正常下降几千步,然后某个门控或范数开始爬,等loss有反应时这次训练已经很难救。三个信号值得记录:
- 逐层残差最大值。每N步记录每层流的最大绝对值,少数坐标持续爬升就是分辨率在缩水的警告。
- 门控和路由最大值。任何乘在残差上的东西(学习到的门控、逐通道缩放、MoE路由logits)都会放大量化偏差,要盯最大值而不只是均值。
- 梯度范数趋势。全局梯度范数缓慢单调上升、loss却没有对应变化,是停滞或有偏状态的典型特征。
还有一条经验之谈:单步检查发现不了这个问题。拿一步的fp32和bf16梯度对比,余弦相似度看起来非常好,因为每步一次的舍入误差很小。这是几千步累积出来的效应,需要长程测试——用fp32跑一段短参考,沿轨迹对比残差和门控最大值。
在VIDRAFT,一次长时间低精度训练出现过正是这种特征的漂移,把残差路径留在fp32、矩阵乘法保持bf16之后它就消失了。
几个常见疑问
推理受影响吗?小得多。推理时每次残差加法在固定长度路径上每层只发生一次,误差不会跨几千个优化器步累积。极深的模型或长生成仍可能显出小幅漂移,所以有些推理栈也把残差留在更高精度。
这里fp16比bf16好吗?fp16有11个有效位,600处的间隔是0.5而不是4,分辨率更好,但范围小得多(最大65504),还需要loss scaling。bf16拿分辨率换范围。对无界的累加和来说两者都不安全,所以和应该待在fp32里。
权重也需要fp32吗?另一个问题。优化器步用fp32主权重,是防同一个效应出现在参数更新上的标准做法(一个学习率大小的更新加到一个大权重上)。残差流是同一套算术在激活侧的表现。
会变成NaN吗?通常不会。典型结果是无声的:坐标卡住、门控爬升、loss曲线走平或缓慢发散。NaN来得更晚,或者根本不来。
fp32残差的内存代价多大?每个block边界上一个形状为batch×sequence×hidden的张量,每元素从2字节变4字节。如果用激活检查点,存下来的边界正好就是这些张量,所以按自己的配置实测,别信通用估算。
能复现这些数字吗?能。两个脚本都只有20行左右,只需要CPU上的PyTorch,用固定种子。上面的数值来自CPU上的torch 2.12。这组模拟是示意性的:它展示的是算术,不是任何特定模型的训练动力学。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.