E4M3?我第一次在别人的配置里看到这个词,直接愣了几秒钟。做了好几年模型训练和部署,整天跟 FP32、FP64 打交道,突然冒出来这一堆字母加数字的组合,第一反应是“这又是什么新包装的旧概念”。后来真正把 E4M3、E5M2、E2M1、INT8 挨个用了一遍才明白,这些看起来像密码的格式,本质上回答的都是同一个问题:在显存、带宽、功耗和精度都有限的情况下,怎么把每一个比特花在刀刃上。
这篇内容适合正在被训练速度卡住、被显存逼到墙角的人,也适合那些把 ONNX 模型部署到 CPU 或者边缘设备、想把 FP32 换成 INT8 但怕精度崩掉的人。就算你只是单纯好奇为什么一个模型中混着好几种数值格式,这篇也能给你一个可以落地的解释框架。我尽量不讲教科书式术语,每个结论你都能拿回自己的模型上验证。
1. 算力密度与内存带宽:为什么换个数值格式就“变快”了
1.1 同一块 GPU,低精度格式的吞吐量通常翻倍
很多人有一个误区,觉得把模型从 FP32 改成 FP16 只是“省显存”,对速度影响不大。实际上,现代 GPU 的张量核心(Tensor Core)对不同数据类型有完全不同的处理速度。拿常见的服务器显卡举例,在同等硬件上,FP64 的吞吐量往往只有 FP32 的几十分之一,FP16 通常能到 FP32 的 2 倍左右,FP8 在部分新架构上还能再翻倍,INT8 在支持整数张量核心的卡上也经常能达到 4 倍甚至 8 倍。
为什么低精度能更快?这里有个最直觉的理解:张量核心本质上做的是批量矩阵乘,一次硬件指令处理的元素数量是固定的。你把每个元素从 4 字节压缩到 2 字节或 1 字节,同一个寄存器、同一条数据通路里就能塞进两倍甚至四倍的元素。打个生活化的比方,你开着一辆固定容积的货车跑同一条运输路线,把每个箱子的体积缩小一半,一趟车能运的数量自然翻倍,车的速度和油钱却没变,单件物流成本就下来了。
所以数据格式不是“锦上添花”的精度控制工具,它直接决定算力利用率。这也是为什么大模型训练阵营宁可牺牲一点数值分辨率,也要往 BF16、FP8 方向走。
1.2 内存带宽和显存占用是另一个隐藏收益
哪怕 GPU 的峰值计算能力没有变化,把数值从 FP32 降到 FP16 或 INT8,依然能获得明显加速。这里面的秘密在内存带宽。一个 10 亿参数左右的模型,FP32 权重占 4GB 显存;如果推理时必须反复读权重,那每次前向都要把 4GB 数据从显存搬进计算单元。换成 FP16 是 2GB,换成 INT8 是 1GB,读取时间直接减半或者减到四分之一。
显存占用降低带来的连锁反应也很实际:同一个模型,FP32 下只能塞下 batch size 4,换成 FP16 后可能塞下 batch size 8。batch 大了,计算资源排队等待的时间就少了,有效吞吐量自然上去。对于部署场景,模型文件从 100MB 压到 25MB,加载速度、内存占用、冷启动时间全都会跟着变。你在网上下载多少带模型的工具包,体积通常就能说明压缩到了什么格式。
我经常给团队说一句话:先把数值格式理顺了,再去折腾算子优化和框架配置。因为前者是结构性的收益,后者是缝缝补补的收益,两者完全不是一个量级。
2. 从浮点数的基本结构说起:指数位和尾数位在博弈什么
2.1 浮点数其实只有三块:符号、指数、尾数
要真正看懂 E4M3、E5M2、E2M1 这些名字,得先回到浮点数的底层公式。一个标准浮点数的值大约是:
value = (-1)^符号位 × (1 + 尾数部分) × 2^(指数部分 - 偏差)
这三块各自作用不同:
- 符号位:决定正负,只占 1 位。
- 指数位:决定数值量级,也就是这根“尺子”最长能量到哪里。
- 尾数位:决定相邻两个数之间的最小间隔,也就是“尺子”刻度的精细程度。
指数位多,能表示的范围就大;尾数位多,表示同一个范围里的步距就越细。但总共就那么多比特,给谁多了,另一方就得少。这就是所有数值格式设计的根本矛盾。
FP32 有 1 位符号、8 位指数、23 位尾数,能表示从约 1e-38 到 3.4e38 的极宽范围,精度大概在 7 位十进制有效数字。FP64 是 1 位符号、11 位指数、52 位尾数,主要用于科学计算,但 AI 领域基本不会碰,因为硬件吞吐量天然低了一大截,属于为了极端精度而牺牲性能的设计。
2.2 FP16 和 BF16:一对特点完全不同的兄弟
FP16 和 BF16 都是 2 字节,但它们的设计思路正好相反。FP16 是 1 位符号、5 位指数、10 位尾数,精度能保留 3 到 4 位十进制有效数字,但最大只能到 65504。BF16 是 1 位符号、8 位指数、7 位尾数,它直接借用了 FP32 的指数范围,所以能表示到 3.4e38,但尾数只剩 7 位,有效精度只有 2 到 3 位十进制数字。
说个最简单的例子,一个训练过程中数值动辄上万的大语言模型,中间激活值很容易超过 65504。你用 FP16 存这些值,结果就是溢出成 Inf,后面的 loss 直接变成 NaN。BF16 因为指数范围和 FP32 一样,基本不会溢出,但代价是每个数的“刻度”很粗。这也是为什么大模型预训练在很多场景里用 BF16 而不是 FP16,稳定压倒一切。
FP16 想摆脱溢出问题,就得配合“损失缩放”(Loss Scaling)这种技巧,先在反向传播前把损失放大若干倍,让梯度的绝对值不要落在 FP16 的最小刻度之下,更新完权重再缩回去。这种技巧让 FP16 在训练中大放异彩,但也增加了调试成本。你用 FP16 训练不稳定时,第一反应就应该是检查溢出和损失缩放,而不是怀疑代码写错了。
3. FP8 世界的两位主角:E4M3 与 E5M2 的分工
3.1 一个 8 位浮点也能拆出不同配比
FP8 不是单一种格式,而是一个总称。E4M3 和 E5M2 是现在最常出现的两种 8 位浮点变体,名字里的字母意思很直白:E 是指数位,M 是尾数位。E4M3 有 4 位指数、3 位尾数,E5M2 有 5 位指数、2 位尾数。它们都占用 1 字节,但指数量程和数值精度完全不同。
E4M3 的最大值大约在 448 左右,正常最小精度到 0.015625 附近。这个数不大,但对于模型训练和推理过程中的权重、激活值来说,通常已经够用,而且 3 位尾数意味着每个刻度比 E5M2 更细,数值保真度更高。E5M2 的最大值能到 57344 左右,动态范围更大,但只有 2 位尾数,精度明显更粗,相邻数值跳变的幅度很大。
你可以把 E4M3 理解成一把量程较短但刻度较密的尺子,E5M2 是一把量程很宽但刻度很稀的尺子。在 8 位浮点这个范围内,它们都不是什么完美方案,只能看你手里的数据分布更怕“溢出”还是更怕“精度毛刺”。
3.2 训练中的典型分工:前向用 E4M3,梯度用 E5M2
FP8 训练目前比较成熟的使用方式,不是把整个模型无脑切换到 FP8,而是做“混合精度”的再升级版。具体操作是:模型主权重保持 FP32 一份副本,前向和反向的矩阵乘用 FP8 去算,算完梯度后回到 FP32 主权重做更新,更新完再把 FP32 主权重压缩回 FP8 进入下一轮。
在这个流程里,E4M3 通常用来表示前向激活和权重,因为它尾数位多,对推理输出质量的影响更小;E5M2 通常用来表示反向传播中的梯度,因为梯度的动态范围波动很大,容易出现极小值和极大值混合的情况,这时更需要 E5M2 的宽量程来兜底。这不是绝对的铁律,我自己测过一些模型,也见过 E4M3 用在梯度上效果更好的情况,但所有框架默认的推荐配置基本都是“激活 E4M3、梯度 E5M2”。
FP8 的另一个关键点是缩放因子。FP16 时代我们靠全局 loss scaling 解决小梯度问题,FP8 就没这么简单了,因为不同层的数值差异可能横跨几个数量级。实践上需要一个动态的逐层缩放机制,在每层计算时统计当前分布,算出合适的缩放因子,再送进 FP8 张量核心。这也是为什么 FP8 训练目前高度依赖特定的训练框架和硬件,裸写 PyTorch 很难手动玩得转。
3.3 硬件支持是 FP8 最大的门槛
再好的格式,没有硬件支持就是一张废纸。当前对 FP8 支持比较完整的硬件集中在近两年的高性能 GPU 上,比如 H100 之后的多数训练卡,以及一些面向推理的 Ada 架构产品。很多老数据中心显卡只支持 FP16 和 INT8,根本不认 FP8。CPU 生态就更不用想了,绝大多数常规 CPU 编译器对 FP8 的支持非常原始。
所以如果你的部署环境是普通服务器加 CPU,或者两三年前的显卡,FP8 基本只能当“了解过”的知识点。真正能让绝大多数人立刻用上的低精度格式,仍然是 INT8。这也是为什么行业里部署环节的优化经验,绝大多数都集中在 INT8 而不是 FP8。
4. E2M1 与 FP4:更极端的压缩,但请保持敬畏
4.1 E2M1 到底有多“小”
E2M1 是一种 4 位浮点格式,1 位符号、2 位指数、1 位尾数。它占用的空间只有 FP32 的八分之一,连一个字节都不到。这种格式能表示的数值密度极其稀疏,分布在几个非常粗略的档位上,相邻值之间的差距非常大。拿它和 E4M3 比,就像拿一把量程只有几厘米的极短尺子和一把米尺比。
这样极端的设计当然不是为了日常训练准备的。它的存在意义是把模型压缩到存储极限,或者说让超大模型的推理在某些新硬件上能跑起来。最近一两年的新数据中心显卡开始原生支持 FP4 张量计算,这才是 E2M1 真正能落地的前提。没有对应硬件和算子库,你就算把权重强转成 FP4,也只能看着它在内存里躺尸,速度并不会变快。
4.2 什么时候才值得考虑 FP4
以我的实际感受,FP4 目前仍然属于“敢想不敢用”的阶段。除非你同时满足三个条件:部署硬件明确支持 FP4 计算、推理引擎有成熟优化算子、模型对量化误差的容忍度足够高。否则,想极致压缩模型文件,不如先用 INT8 或 FP8;想极致推理速度,INT8 也已经覆盖了绝大多数收益场景。
FP4 更适合被看成未来技术方向,而不是当下工程手段。你如果在一段时期后用上了新硬件和新推理框架,那时候 FP4 的生态可能已经成熟,今天了解清楚它的定位,至少不会在遇到 E2M1 这种词时懵掉。
5. INT8 量化实战:ONNX 模型部署最稳的提速方式
5.1 为什么推理首选用 INT8,而不是 FP8 或 FP4
回到实际部署,INT8 的地位依然无法动摇。原因有三点:第一,CPU 和 GPU 的生态对 INT8 支持时间最长,几乎所有推理引擎都有一整套成熟算子;第二,INT8 是整数格式,没有浮点格式的指数偏差和缩放复杂性,映射关系更直白;第三,模型文件能直接压缩到 FP32 的四分之一,对边缘设备特别友好。
INT8 并不是没有代价。它只有 256 个离散取值,你需要在“用哪个实数代表哪个整数”这件事上做文章。一般用量化公式 real = scale × (int_value - zero_point) 来映射,scale 是缩放因子,zero_point 是零点偏移。如果采用对称量化,zero_point 固定为 0,映射关系更简单;非对称量化多了一个偏移量,能对激活值分布不对称的场景保留更多细节。这些参数设计直接决定精度损失多少。
5.2 ONNX Runtime 的两种量化路径
如果你手头有一个 ONNX 模型,最省事的做法是动态量化。动态量化不需要准备校准数据集,跑一段代码就能把 FP32 权重自动转成 INT8,激活值的缩放因子在推理时动态计算。这个方案非常适合快速验证流程,比如我给一个检测模型做基准测试时,通常会先跑一版动态量化看看收益和损失的边界。
动态量化的写法很直接:
from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( "model_fp32.onnx", "model_dynamic_int8.onnx", weight_type=QuantType.QInt8 )实际跑下来,动态量化的激活值每次都要现算 scale,所以速度提升打折,但胜在不需要额外数据,几秒钟就能产出可运行模型。
更推荐也更能榨出性能的是静态量化。静态量化要先准备一批有代表性的校准数据,比如 500 到 1000 条和线上分布一致的样本,用它们在模型中前向跑一遍,统计每个激活张量的数值范围,提前把 scale 和 zero_point 算好写进模型。推理时不再做动态统计,算子更精简,性能提升更明显。
静态量化的关键调用大概是:
from onnxruntime.quantization import quantize_static, QuantType, CalibrationMethod quantize_static( "model_fp32.onnx", "model_static_int8.onnx", calibration_data_reader=reader, quant_format=QuantFormat.QDQ, per_channel=True, activation_type=QuantType.QInt8, weight_type=QuantType.QInt8, calibrate_method=CalibrationMethod.MinMax )这里的 per_channel 参数值得重点说。per-tensor 是对整层所有通道共用一个 scale,实现简单但误差大;per-channel 是对卷积层每个输出通道单独一个 scale,精度损失可以明显下降。遇到量化后精度不合格的情况,先把 per_tensor 换成 per_channel 试试,经常能救回不少指标。
5.3 量化后怎么验收:别只看推理有没有崩溃
很多人一量化完,看到模型能正常出结果,就以为大功告成。其实更严谨的做法是,先用相同输入分别跑一遍 FP32 和 INT8 的模型,对比推理延迟、最大显存占用和模型文件大小,再拿一套标准评测集算出准确率、mAP 或困惑度,量化到底掉了多少点。我做过一个视觉模型的案例,动态量化后模型文件从约 120MB 压到 30MB,CPU 推理延迟下降了大概 45%,但 mAP 掉了接近 1 个点;后来改成静态量化加 per-channel,mAP 损失收窄到 0.3 个点左右。这个数字不同模型差异很大,但流程一定是这样:先看速度收益,再看精度损失,最后再决定要不要上更复杂的量化感知训练。
还有一种情况是量化后某些层特别敏感,用混合精度量化就行。既然是混合,就意味着你可以把模型里的卷积层、矩阵乘层用 INT8,剩下特别注意的激活层或归一化层保留 FP32。这也是 ONNX 量化工具支持的能力,难度不大,但能在敏感模型中救回大部分质量。
6. 选型速查:什么时候用哪种格式
如今大家手头都有多套候选格式,我根据日常经验整理了一个快速的决策参考,不保证在所有框架和硬件下都是最优,但至少能帮你避开最明显的坑。
| 应用场景 | 推荐格式 | 核心理由 |
|---|---|---|
| 新手训练或小模型 | FP32 | 实现简单,排查问题容易 |
| 常规训练提速 | FP16 / BF16 | 稳定提速一倍,BF16 更稳但精度粗 |
| 大模型预训练 | BF16 | 指数范围大,不易溢出 |
| 新硬件上的大模型训练 | FP8 + FP32 主权重 | 吞吐量优势明显,已有主流框架支持 |
| CPU / 边缘部署 | INT8 | 生态成熟,文件小加速明显 |
| 需要保留更多精度的部署 | INT8 静态量化 | 配合校准集和 per-channel 控制损失 |
| 极致存储压缩 | FP4 / E2M1 | 需要专门硬件和框架,谨慎评估 |
如果做具体选择,可以按这个思路走:训练优先考虑稳定性和框架支持,能选 BF16 就不乱换 FP8;推理优先考虑生态成熟度,能选 INT8 就不盲目追新格式。新格式差的那一倍速度,往往会被老硬件的兼容问题全赔进去。
7. 常见问题与踩坑实录
7.1 模型改成 FP8 或 INT8 之后反而变慢
这是我被问得最多的问题。原因一般有两个:要么模型里的关键算子没有走优化内核,只是把数据格式换了但执行路径还是老的;要么是动态量化带来的运行时缩放计算开销太大。解决思路是尽量用官方集成好的量化 API 或推理引擎,不要自己手动在普通模型脚本里到处强转 dtype。框架的优化算子融合和内核选择是一整套逻辑,不是简单把 dtype 改掉就行的。
7.2 INT8 量化后精度掉得离谱
这种情况大概率是校准数据不具备代表性。你用了一大堆训练集图片算 scale,但线上场景是暗光或强噪声图片,分布偏差一大,激活值的统计范围就彻底跑偏。换个和新场景更接近的校准数据集,比调整任何量化参数都有效。还有一种可能是模型本身很小,比如只有一个简单的 MLP,可压缩空间有限,量化损失自然大,这时候就该考虑上量化感知训练或者只量化部分层。
7.3 FP16 训练时 loss 变成 NaN
先检查权重或中间值有没有超过 65504。大模型里很常见的情况是某些激活层的数值特别大,一旦溢出就是 Inf,后面所有梯度全变成 NaN。解决办法是开启 AMP(Automatic Mixed Precision),让框架自动把敏感操作保持到 FP32;或者换用 BF16,让指数范围直接对齐 FP32。我自己遇到 NaN 时,习惯先把检查范围缩小到 loss 缩放和溢出,省去很多乱猜的时间。
7.4 E4M3 和 E5M2 到底该选哪个
如果你在做 FP8 相关实验,我的经验是:默认用 E4M3 当主力,它尾数位多,对模型质量的保留更好;只有当你发现某些层频繁出现溢出,或者数值动态范围明显偏大时,再把对应层切成 E5M2。不要一次性把整个模型切到 E5M2,那样精度损失往往非常明显。这个取舍很像 FP16 与 BF16 的对立,宽量程和细精度永远是矛盾体。
7.5 数值格式选择不能靠“听起来对”
我个人踩过几次坑之后,养成了一个习惯:所有格式切换都要用同一个评测集、同一个随机种子、同样的迭代次数跑完对比,不拍脑袋做决定。因为数值格式的优劣和模型结构、数据分布、优化器选择都有关系,同一个格式换一个模型就可能反转。所谓“精度的好坏”不是抽象属性,而是在你具体任务上做出来的实际分数。测量对了,再怎么折腾都有依据;测量错了,全是在盲人摸象。
最后分享一个小技巧:如果某天你拿到一个陌生模型要选格式,先拿一个小规模的校准集分别跑 FP32、FP16、INT8 三版,对比延迟和指标。整个流程半小时以内就能出结果,比翻看网络上的争论帖有用得多。数值格式这回事,原理可以讲得很复杂,但最终都得落到你自己的模型和硬件上,跑不跑得快、保不保得住精度,一试便知。