一文讲透深度学习数据类型:FP32、FP16、BF16、TF32、INT8
深度学习跑久了很多人会碰到一个诡异现象同样的模型在GPU上训练得好好的一部署到边缘设备上或者一开混合精度Loss突然变成NaN或者推理结果跟原来差了十万八千里。我见过不止一个项目组排查半天最后发现是对数据类型FP32、FP16、BF16、TF32、INT8这些的理解出了问题。这玩意儿看着不起眼实际上决定了你的模型能不能训起来、能不能跑得快、能不能在低算力设备上落地。这篇文章我想把深度学习里主流的数据类型一次性讲透它们在底层的二进制表示上有什么差异训练和推理场景分别怎么选混合精度训练到底在做什么还有ONNX量化和RKNN部署时那些“精度下降”“数值不动”的坑是怎么来的。不管你是刚入门的学生还是被部署问题折磨的工程师读完应该能对“精度”这件事建立起一个完整的直觉。1. 浮点数据类型的基础差异1.1 浮点数在计算机里的底层结构先说一个最基础的问题计算机是怎么存一个小数的绝大多数人学过的答案是IEEE 754浮点数标准这套标准把一个数拆成三部分——符号位、指数位、尾数位。如果把一个浮点数比作科学计数法大概长这样[ \pm 1.xxx \times 2^{y} ]符号位决定正负指数位决定取值范围相当于科学计数法里的“10的几次方”尾数位决定精度相当于有效数字能保留几位。FP32单精度浮点1位符号 8位指数 23位尾数总共32位。FP16半精度浮点1位符号 5位指数 10位尾数总共16位。BF16Brain Floating Point1位符号 8位指数 7位尾数总共16位。TF32TensorFloat-321位符号 8位指数 10位尾数总共19位主要用于NVIDIA Ampere架构的Tensor Core矩阵运算。你看FP16和BF16的总位数都是16位但分配策略完全不同。FP16给了尾数10位却只给指数5位BF16反过来指数给了8位尾数只留7位。这个差异决定了它们擅长的场景是反过来的。后面我会细说。先记住一个核心结论指数位决定你的模型会不会“爆”溢出尾数位决定你的模型“准不准”精度。很多人只盯着精度看忽略了范围这是踩坑的重灾区。1.2 四种主流浮点类型的范围与精度对比不同浮点类型的核心差异先看一张表数据类型总位数指数位尾数位最大范围约最小正正规数相对精度约FP3232823(3.4\times10^{38})(1.18\times10^{-38})(6\times10^{-8})FP1616510(6.55\times10^{4})(6.10\times10^{-5})(4.9\times10^{-4})BF161687(3.4\times10^{38})(1.18\times10^{-38})(7.8\times10^{-3})TF3219810(3.4\times10^{38})(1.18\times10^{-38})(4.9\times10^{-4})从这张表能读出几个关键信息第一FP16最容易被“范围”卡死。它的最大表示范围只有65504约(6.55\times10^{4})。什么意思如果你的梯度或Loss在训练中超过了这个数直接变成Inf。更麻烦的是FP16的最小正正规数是(6.10\times10^{-5})比FP32大了好几个数量级意味着特别小的梯度会被“吸收”成0。梯度变0参数就不更新了模型直接“假死”。第二BF16的范围和FP32完全一样。因为它指数位同样是8位所以不存在上溢和下溢问题。代价是尾数从23位缩到7位精度大幅降低。这就是为什么BF16刚出来的时候很多人说它是“粗制滥造”的精度——好处是深度学习确实不太需要极高的尾数精度后面讲训练时我会展开。第三TF32的精度介于FP16和FP32之间但它主要出现在NVIDIA的Tensor Core内部。它只有10位尾数却保留了FP32的指数范围这决定了它是一个“不用改代码就能白嫖加速”的选项。第四别忘了符号位。四个类型都占了1位作符号所以正负数对称没有额外的坑。1.3 尾数位与指数位的工程比喻我习惯把浮点数想成“相机里的变焦镜头”。指数位是广角端决定你拍得了多亮多暗的景范围尾数位是清晰度决定你细节还原有多好精度。FP16这支镜头广角端很弱最大范围小但放大看细节还行BF16刚好反过来广角端和FP32一样广但细节糊得多TF32把广角端拿到了FP32级别细节精度却只有FP16的水平。这个比喻在实操里很有用当你决定用哪种类型时先问自己两个问题——模型里的数值可能遇到极端大的情况吗需要保留多少位有效数字前者指向指数位后者指向尾数位。先回答这两个问题数据类型选型就成功了一半。2. 训练模式下到底选什么精度2.1 混合精度训练AMP的核心逻辑很多人以为混合精度训练就是把所有参数从FP32换成FP16然后把Loss改成FP16训练不是的。混合精度的意思是参数用FP32保存一份“主副本”计算过程中用FP16加速每次更新时把FP16算出来的梯度转回FP32更新。这就像你平时记账用精确到分的大账本但心算的时候用口头估算算完再誊回账本。具体流程大概是这样的把模型参数复制一份FP16副本每次前向传播用FP16跑速度更快、显存占用更低计算FP16梯度把FP16梯度转回FP32再乘上学习率更新FP32主参数循环进入下一步。为什么不让FP32参数直接更新因为Tensor Core在FP16上的计算吞吐量通常是FP32的几倍甚至更高而模型质量主要靠FP32主参数来保证。这种“FP32保存、FP16计算”的分工就是为了在速度和质量之间找平衡。PyTorch里实现混合精度非常方便import torch from torch.cuda.amp import autocast, GradScaler model MyModel().cuda() optimizer torch.optim.Adam(model.parameters(), lr1e-3) scaler GradScaler() for data, target in dataloader: data, target data.cuda(), target.cuda() optimizer.zero_grad() with autocast(): output model(data) loss criterion(output, target) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()2.2 损失缩放Loss Scaling是怎么回事刚才说到FP16的下限很高梯度小了会被冲成0。损失缩放就是为了对付这个问题。思路很直白把Loss乘以一个系数比如1024或2048让它变大反传出来的梯度也相应变大就不会低于FP16的最小表达范围了。梯度更新前再除以这个系数恢复成正常尺度。这就是GradScaler在PyTorch里干的事。但要注意损失缩放并不会解决FP16的“上溢”问题。如果损失缩放太多反而会让某些梯度超过FP16上限变成Inf。所以GradScaler是动态调整的——一旦发现梯度出现Inf或NaN就减小缩放因子连续多次没出问题就尝试增大一点。这也是为什么用了AMP以后偶尔会看到训练日志里打印出“GradScaler is decreasing the loss scale”之类的信息没必要慌它在自我调节。实操中我个人体会FP16混合精度在多数CV模型ResNet、YOLO、Vision Transformer上效果都比较稳定但在一些NLP模型尤其是带时序依赖的模型上容易出现梯度下溢。这时候可以考虑换BF16。2.3 BF16和FP16怎么选这是当前训练场景下最常被问的问题。BF16的优势前面说过指数位和FP32一致范围和动态范围跟FP32完全对齐训练中几乎不会因为溢出翻车也不需要损失缩放那一套机制。这大大简化了训练流程。但BF16的尾数只有7位精度比FP16还低。你可能会问那它凭什么能训模型实际情况是深度学习计算过程中权重更新依赖的是梯度的“期望”而不是“精确值”。7位二进制尾数差不多对应十进制2-3位有效数字。对SGD/Adam这类带噪声的优化器来说这点有效数字其实够用了。相反FP17那5位指数位反而是硬伤容易让梯度直接消失或爆炸。如果是用NVIDIA A100、H100这些新卡训练我现在的默认选择是主力用BF16混合精度除非发现某个模型在BF16下出现了明确的精度问题再退回TF32或者纯FP32。如果是老卡V100以下不支持BF16就老老实实FP16混合精度配合损失缩放。简单总结对比维度FP16BF16指数范围小易上溢/下溢与FP32一致尾数精度较高较低损失缩放需要不需要硬件支持广泛老卡也支持需要较新硬件适合场景老设备、精度优先新设备、稳定优先2.4 TF32A100用户的隐藏加速开关TF32这个类型比较特殊它不是一个“公开的数据存储类型”而是NVIDIA Ampere架构里Tensor Core做矩阵运算时内部使用的一个精度模式。说白了Tensor Core在算矩阵乘法时没有乖乖用FP32算而是把FP32的输入数据截断成19位10位尾数然后算完再输出FP32结果。这样做的收益是矩阵运算速度可以比纯FP32快很多接近FP16的吞吐量而且代码层面你完全不用改还是用FP32的API调用。损失是尾数精度从23位降到10位。对多数深度学习模型来说这个精度的损失几乎不可感知但确实有个别对数值敏感的任务比如某些科学计算、强化学习会受影响。所以NVIDIA提供了手动开关# 关闭 TF32强制使用真正的 FP32 export NVIDIA_TF32_OVERRIDE0 # 打开 TF32默认状态也可能是开 export NVIDIA_TF32_OVERRIDE1在PyTorch里也可以按需控制torch.backends.cuda.matmul.allow_tf32 True # 矩阵乘开 TF32 torch.backends.cudnn.allow_tf32 True # 卷积开 TF32我踩过的坑是有一段时间跑强化学习任务模型一直不收敛折腾了好几天最后发现是Tensor Core默认走了TF32把某些数值敏感的运算精度削掉了。关掉之后问题立刻消失。所以我现在的建议是训练CV、NLP这类主流模型时TF32一般没问题但如果你的模型训练出现不明原因的抖动、不收敛先把TF32关了试试这个排查成本很低。3. 推理阶段的数据类型与量化3.1 FP16推理真的够用吗训练完成之后模型部署到服务端推理通常第一个加速操作就是把权重从FP32转成FP16。这一步的效果立竿见影显存占用直接减半推理速度在支持FP16 Tensor Core的显卡上也能有明显提升。而且对绝大多数已经训练好的模型来说FP16推理几乎是无损的。原因很简单模型参数的分布范围是训练时确定的一般不会出现极端大值而且推理阶段只做前向计算没有反向传播带来的梯度下溢问题。不过有两点要注意第一激活值的分布范围比权重广。BN层的running_mean/running_var如果出现极小值或极接近0的数FP16下可能直接变成0导致输出异常。我遇到过YOLO系列模型FP16推理时某些BBox坐标出现NaN的情况最后定位到是某个归一化层的数值太小FP16存不了。第二某些算子对精度非常敏感。比如Softmax里面有个指数运算输入稍微大一点指数结果就可能溢出。所以很多推理框架在实现FP16推理时Softmax是算了再转回FP32做的。如果你手写推理引擎这个细节千万别漏。3.2 ONNX导出与INT8量化基础FP16虽然快但对边缘设备或手机端来说还是“太重”了。INT8量化才是真正把模型压到极致的方案权重和激活都用8位整数int8表示模型体积降到原来的四分之一推理速度在支持INT8指令的CPU/NPU上往往还能再快一个量级。ONNX是最常用的模型中间格式它的量化流程一般是这样的把训练好的模型导出为ONNX格式准备一批校准数据calibration dataset典型几百到几千张样本即可用校准数据跑模型统计每一层激活数值的动态范围min/max或直方图根据统计结果计算每个张量的scale和zero_point把FP32/Fp16算子替换为INT8算子比如MatMul、Conv浮点输入转换对应QuantizeLinear导出带上量化信息的ONNX模型。这里最关键的是第2、3步校准数据怎么选。很多人直接把训练集拿来做校准这种做法在分类模型上问题不大但在检测、回归模型上可能会翻车。校准数据的分布必须能覆盖模型在真实场景下会遇到的数值范围。我见过一个项目用全是白天的图片做了校准结果模型一到夜间场景输出全部乱掉。这不是量化算法的锅是校准数据没选对。ONNX量化分两种训练后量化PTQ和量化感知训练QAT。PTQ是傻瓜式方案模型训完直接量化速度快但精度下降不可控。打个比方PTQ就像你先把一张照片打印出来然后拿美图软件去压缩画质原图信息已经定了压缩后能保留多少细节全看运气。QAT则是在训练的时候就模拟量化过程前向传播时把权重人为四舍五入成INT8反向更新时还是用高精度的梯度。这样模型在训练中就“适应”了量化误差最终量化效果通常比PTQ高很多。如果PTQ精度掉得太狠QAT是正解。3.3 RKNN部署INT8量化的那些坑这几年国产NPU设备越来越普及RKNN是Rockchip系列芯片上的神经网络推理框架网上搜“onnx转rknn int8”能出来一大堆问题。我自己在RK3588、RV1126这类设备上部署模型也有不少经验这里专门聊聊INT8量化的坑。第一个坑是算子兼容性。ONNX里很多算子特别是自定义算子、某些新版本算子RKNN根本不支持转换的时候要么直接报错要么被映射到CPU算子导致速度没有提升。所以你在转RKNN之前最好先把ONNX模型里的算子梳理一遍遇到不支持的算子尽量改写成RKNN能支持的组合。第二个坑是量化精度掉得夸张甚至数值不动。这是热词里提到的高频问题。你有没有遇到过这种情况FP32模型在PC上验证没问题转成RKNN INT8之后输出结果要么全是0要么不变要么偏差巨大我总结的排查顺序是看校准数据集和校准方式RKNN的量化校准默认会给每个输入做统计但如果你的数据集跟真实数据分布不一致量化尺度会完全偏掉。可以把RKNN导出时生成的量化参数表如果框架支持导出来看检查每层的scale和zero_point是不是过于离谱。看归一化方式训练时如果用了减均值除方差导出ONNX时很多时候会忽略这个预处理步骤。结果就是RKNN端拿到的输入和训练分布的输入差了十万八千里量化误差自然爆炸。这种情况不是量化的错是预处理的错。检查动态范围统计你有某些层激活值本身就集中在很小的范围内稍微量化一下信息就全丢了。可以把每一层的输出分布打印出来看看。如果某一层在FP32下输出分布范围就很窄比如都在0.01到0.03之间那INT8量化必然出现“数值基本不动”的现象——因为在这个范围内量化步长可能比信号本身还大。试试混合量化不是所有层都需要INT8。把一些敏感层通常是最前面几层和最后几层保留为FP16或FP32中间大计算量的层用INT8效果往往立竿见影。第三个坑是RKNN的量化参数版本问题。RKNN Toolkit和RKNN Runtime版本不对齐时量化结果可能诡异不同。建议开发、验证、量产用同一套版本别乱升乱降。3.4 INT8量化对模型结构的影响很多人好奇为什么有的模型量化后精度基本不掉有的模型一量化就崩核心在于模型结构对量化误差的“容忍度”。卷积、全连接这类算子权重分布如果近似正态分布且比较集中量化误差相对可控。但像MobileNet这类使用了大量Depthwise卷积的结构参数分布往往很尖锐集中在0附近直方图统计不好做量化误差就会被放大。另一个常见隐患是大量BatchNorm层被融合进卷积之后的变化。BN层在训练时是跟着batch统计量跑的推理时用的是全局统计量。ONNX导出后可以去融合BN但融合过程会改变数值范围。有些量化工具对融合后的算子统计做得不好导致量化参数不准。如果你发现某个模型量化后精度暴跌试着对比一下ONNX在“融合前 vs 融合后”的输出分布有时候就是这里出了问题。3.5 INT16的意义除了INT8热词里还提到了INT16。你可能觉得INT8都够用为什么还要INT16实际案例是这样的某些边缘芯片对INT8的加速效果很好但INT8的动态范围只有256个等级对于输入范围很大的传感器数据比如IMU、毫米波雷达数据或者一些对精度要求高的回归任务INT8量化后误差可能不可接受。INT16提供了65536个等级精度损失明显更小而存储和计算开销又比FP16低。所以当你在RKNN或其他边缘平台上遇到“INT8精度不够、FP16又太慢”的尴尬时先看看芯片支不支持INT16这往往是一个性价比很高的中间档。4. 前沿数据类型FP8与更多可能4.1 FP8是什么E4M3与E5M2网络热词里有人问“fp8和bf16, fp16分别是什么意思”这里我专门展开讲一下FP8。FP8是近年英文数据格式的前沿焦点NVIDIA H100、Ada Lovelace架构的GPU以及部分最新旗舰SoC都开始支持FP8运算。它只有8位是比FP16更“激进”的压缩方案。FP8内部又分两种子格式E4M31位符号 4位指数 3位尾数。最大表示范围约448精度相对较高。适合用于前向传播和推理。E5M21位符号 5位指数 2位尾数。最大表示范围可达(5.7\times10^{4})范围更大但精度更低。适合用于反向传播和梯度累积场景。FP8和BF16、FP16的关系可以这样理解BF16是把FP32的尾数砍成一截保留范围FP16是保留较多尾数但牺牲范围FP8则是范围和精度都很激进地压缩它更像一种专门给大规模集群训练动辄成千上万卡设计的传输和计算格式。在几千卡并行训练时通信量是瓶颈FP8能显著降低通信开销这也是H100上大模型训练喜欢用FP8的原因。4.2 FP8在实际工程里要不要追FP8虽然很火但目前工程上我建议保持“观望小范围验证”的态度。原因有三点第一硬件支持还不普及。大多数存量设备根本不支持FP8的Tensor Core加速代码写了也没法上线。第二训练稳定性问题。FP8的尾数太少直接用于训练时优化器状态比如Adam的一阶、二阶矩估计几乎必须用FP32或BF16保存。也就是说FP8只能用在矩阵乘法计算上主权重和优化器状态还得占用高精度存储实际显存节省没有想象中那么多。第三框架支持程度参差不齐。PyTorch的FP8还在快速迭代中API不稳定。如果不是做前沿研究或者公司有明确的技术预研要求现阶段花时间在FP8上不如先把FP16/BF16/INT8这几条常用链路打磨好。个人经验新技术可以关注但生产环境先跑在成熟稳定的数据类型上。这不是保守而是让模型质量有底线的工程选择。5. 常见问题与排查技巧实录5.1 “INT8量化后精度下降、数值不动”的排查路径这个场景实在太经典了我把排查路径整理成一张速查表现象可能原因排查方向精度轻微下降1%-3%校准数据分布不匹配换更多样化的校准集检查归一化参数精度明显下降10%以上某一敏感层量化损坏逐层对比FP32和INT8输出定位可疑层输出全部为0校准集数值范围统计错了/数据预处理错打印量化scale检查输入预处理输出数值固定不变激活值分布过窄量化步长太大检查层输出分布考虑混合量化某些输出NaN/Inf算子溢出或量化零点算错了检查zero_point计算谨慎修改框架默认配置模型能跑但速度没提升有算子被映射为CPU算子查看RKNN/ONNX Runtime日志优化算子支持性还有一个非常有用的调试技巧做逐层对比。把FP32模型和INT8模型分别跑一遍把每一层的输出节点都导出来然后逐层算它们的余弦相似度或平均绝对误差。哪一层从这一层开始偏差突然变大问题就出在哪一层。这个定位方法在ONNX和RKNN上都能用只是要花点时间把中间节点都命名好。5.2 校准数据集怎么准备才靠谱我再单独强调一遍校准数据。这是PTQ量化流程中最容易被低估的环节。校准数据的目标是“尽量穷尽模型在真实推理时会遇到的数值范围”。我的经验是数据量不求多200到1000张或样本足够但来源要覆盖真实场景的各种极端情况分类任务每一类尽量都覆盖到不要全是容易样本检测任务包含小目标、大目标、遮挡、模糊等各种难度回归任务输入范围要覆盖训练时的最小值和最大值尤其要把边界值包含进来如果模型部署在某固定场景比如固定的工业产线或固定角度的摄像头就用这个场景的实际数据做校准不要拿通用数据集代替。5.3 数值敏感层怎么识别最后分享一个识别“敏感层”的技巧这是我踩过几次坑后总结出来的。所谓数值敏感层就是量化后误差被放大的层。常见的有神经网络的输入层第一层卷积/全连接输入数据的动态范围往往很大直接量化容易丢信息输出层最后的全连接或回归头输出值范围直接关联任务结果精度损失会被直接放大注意力机制中的Softmax、LayerNorm对数值稳定性极其敏感残差结构中的Add节点加法会累加量化误差两个量化后的数相加误差会叠加。对于一个模型先尝试把这几类层设成高精度FP16/FP32保留其他层走INT8。多数情况下这样已经能恢复绝大部分精度损失而且推理速度损失很小。5.4 混合精度训练中Loss变成NaN怎么办训练侧有一个问题也经常被问到用了AMP之后Loss变成NaN了怎么办排查顺序一般是这样先看是不是学习率太大。AMP虽然有损失缩放但它不会解决本质上的梯度爆炸问题。确认损失缩放有没有在正常运行。可以打印scaler.get_scale()看看数值是不是在正常范围内。检查模型结构里是否有不稳定的算子比如某些激活函数在FP16下表现不好。尝试把个别模块强制设置成FP32计算PyTorch用model.module.float()或者装饰器torch.cuda.amp.custom_fwd(cast_inputstorch.float32)。如果是老设备且模型对精度极敏感干脆退回BF16或纯FP32别死磕FP16。损失缩放本身是动态的但如果一段时间内梯度频繁出现Inf/NaNGradScaler会不断调低缩放因子最终缩到接近1这时候就等于没用了。所以不要只盯着step看训练跑一半发现Loss突然全部为NaN先检查一下缩放因子的值。6. 我自己的一些习惯总结玩了这么久深度学习的数据类型我自己的选择习惯已经稳定成了一套规则分享出来供你参考训练新卡优先BF16混合精度老卡用FP16 GradScaler出问题先关TF32试试推理服务端FP16起步显存不够再考虑INT8尽量先用PTQ测一测精度掉得多再上QAT边缘部署能用FP16就先用FP16实在跑不动再上INT8/INT16优先做混合量化而不是全INT8遇到任何数值问题第一件事是复现FP32结果确认基线没问题再一层一层往上叠精度损失。数据类型这个话题看似基础但对训练稳定性和部署效果的影响比大多数人想象中大得多。把每一步的数值范围、精度损失来源搞清楚再看那些负面经验类的热词问题基本都能顺势推导出解法。踩过几次坑之后我反而觉得这些“精度问题”是深度学习工程中最有规律可循的一类问题。希望这篇文章能给正在排查的你提供几条清晰的路径。