本节摘要:FP16 降低带宽与占用 Tensor Core,比 INT8 保真度高、比 FP32 快。ORT 通过 CUDA/TRT EP 的 fp16 选项或模型内 Cast 实现混合精度:骨干 FP16,BN/Softmax 等保留 FP32。
INT8 掉点无法接受,FP32 显存不够——FP16 是中间路线。A100/H100 上 FP16 Tensor Core 吞吐高,带宽减半,而精度损失远小于 INT8:FP16 的动态范围约 1e-5~6e4,覆盖大部分激活;INT8 的动态范围只有 256 档,必须靠 scale 精打细算。代价是 FP16 的精度风险集中在两个地方:小数分辨率(尾数只有 10 位)与溢出(大数值超过 65504 变 Inf)。
FP16 推理分两层理解:EP 层的 fp16_enable 开关让 EP 尽量用 FP16 kernel;模型层的 Cast 节点显式指定某段计算用 FP16。前者省事,后者精细。
| 精度 | 带宽 | 精度风险 | 典型场景 |
|---|---|---|---|
| FP32 | 100% | 最低 | 基线、验收 |
| FP16 | ~50% | 低-中 | GPU 推理默认尝试 |
| INT8 | ~25% | 中-高 | 边缘、超大 QPS |
带宽视角看收益:推理延迟的相当一部分花在搬运权重与中间张量上,FP16 直接把搬运量减半,配合 Tensor Core 计算加速,通常能拿到 1.5~2 倍收益。INT8 再减半,但精度风险更高。决策顺序:先试 FP16(低风险快速拿收益),不满足再评估 INT8。
骨干网络(Conv/MatMul/Attention)走 FP16 吃 Tensor Core,输出敏感层(Softmax、LayerNorm、最终分类头)保留 FP32。转回去的地方插入 Cast 节点,代价是一次逐元素转换,远小于精度风险。
TensorRT EP:
providers = [("TensorrtExecutionProvider", {"trt_fp16_enable": True})]
CUDA EP 的 FP16 选项:
import onnxruntime as ort cuda_opts = { "device_id": 0, "arena_extend_strategy": "kSameAsRequested", "cudnn_conv_algo_search": "HEURISTIC", } sess = ort.InferenceSession( "model.onnx", providers=[("CUDAExecutionProvider", cuda_opts)], ) # CUDA EP 对 FP16 的支持靠模型内的 Cast 节点驱动: # 导出侧把指定层转为 FP16 后导出,或运行时绑定 FP16 输入
CUDA EP 配合 IO 绑定 FP16 输入;模型导出时使用 model.half() 再 export 需验证算子支持——不是所有算子都有 FP16 kernel,不支持的算子要么自动回退 FP32,要么报错。导出侧逐层检查比运行时排查成本低得多。
敏感层的判断依据是 diff 报告,而不是猜测。通用经验排序:Softmax/LogSoftmax 前、LayerNorm、BN、长链累加(RNN 的隐状态)最容易出问题;Conv/MatMul/Embedding 相对安全。ORT 1.16+ 动态精度调度:按算子语义选 FP32/BF16/INT8——BatchNorm 常 FP32,Conv+Relu 可 INT8。这条自动路径适合作为混合精度的起点。
⚠️ 全 FP16 导致 Softmax 溢出 Inf——敏感层必须 FP32。softmax 里 exp 的输入是 logits,FP16 下超过 65504 直接 Inf,NaN 一路传播。混合精度不是"全换"而是"按层换"。
💡 先 FP16 全模型试跑,再仅对 diff 超阈层升 FP32。从全量 FP16 出发往回修,比从全 FP32 出发往前推更快收敛。
| 目标 | 建议 |
|---|---|
| 最低延迟 NVIDIA | TRT FP16 或 INT8 |
| 精度优先 | FP16 + 敏感层 FP32 |
| CPU ARM | INT8 PTQ 常优于 FP16 |
BF16 在 Ampere+ GPU 上对训练/推理动态范围更友好,ORT 算子支持随 opset 扩展——若目标硬件支持 BF16 且算子齐备,它是 FP16 之外的另一选项。量化与混合精度可组合:不同子图分别用 FP16 与 INT8,比如 backbone 用 INT8、Attention 头保留 FP16。
FP16 推理的验收不能只看整体指标,要按层确认"哪里变了"。三件套工具:max diff(找最大误差层)、ratio 统计(误差占比分布)、业务指标(端到端)。前两者定位,第三者拍板。
import numpy as np import onnxruntime as ort def fp16_check(fp32_model, fp16_model, feed): s32 = ort.InferenceSession(fp32_model, providers=["CPUExecutionProvider"]) s16 = ort.InferenceSession(fp16_model, providers=["CPUExecutionProvider"]) y32 = s32.run(None, feed)[0] y16 = s16.run(None, feed)[0] diff = np.abs(y32.astype(np.float64) - y16.astype(np.float64)) print("max diff:", diff.max()) print(">1e-3 的比例:", (diff > 1e-3).mean()) print(">1e-2 的比例:", (diff > 1e-2).mean()) feed = {"input": np.random.randn(8, 3, 224, 224).astype(np.float32)} fp16_check("fp32.onnx", "fp16.onnx", feed)
>1e-2 的比例 比 max diff 更有业务意义:max diff 可能来自单个极端样本,比例分布能反映误差是否系统性。阈值按业务定:分类模型看 argmax 翻转率,回归模型看相对误差,检测模型看 mAP 差。
FP16 减半的是权重与中间张量的字节数,对显存受限场景(边缘盒子、多模型共存)意义明确。配合第4章的 mem_pattern,FP16 模型在静态 shape 下显存占用可以精确预算:权重 n_params * 2B + 中间峰值。预算表进部署 manifest,CI 里断言实际占用不超预算,超了直接失败。
| 预算项 | FP32 | FP16 |
|---|---|---|
| 权重 | n_params * 4B | n_params * 2B |
| 中间张量 | 峰值 * 4B | 峰值 * 2B |
| 保留缓冲 | 固定 | 固定 |
FP16 与 INT8 不是互斥选项,可以组合:backbone 用 FP16 吃 Tensor Core,Attention 头保留 FP32,边缘段用 INT8。组合的复杂度在边界:精度切换处有 Cast 节点,每个 Cast 都是带宽与数值的双重开销。工程建议:先 FP16 全量,再逐段评估 INT8 收益,最后才做混精度——逐步收紧比一步到位容易排查。
第6章:Profiling、调参与部署 CI。
FP16 相比 FP32 有更小的指数/尾数位宽,大数值与小数值并存时可能溢出或下溢。评估方法分三步:
# fp16_check.py —— 余弦相似度对比示意 import numpy as np def cos_sim(a, b): return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9)) # 假设 fp32_out 与 fp16_out 是某层的输出张量 # 若 cos_sim(fp32_out, fp16_out) < 0.99,考虑该层保留 FP32
实践中绝大多数 Transformer 与 CNN 可直接 FP16 无损部署;对数值敏感的算子(LayerNorm、Softmax、部分归一化)常见做法是保持 FP32 计算、FP16 存储,这也正是混合精度命名的由来。