4、基线建立:如何获取融合前的原始精度数据
做算子融合的精度排查,第一步是什么?
不是看融合后的代码,也不是调参数。
是先把融合前的精度数据拿到手。这个数据,就是我们常说的“基线”。
没有基线,你拿什么去对比?拿什么证明融合后出了问题?
核心观点:基线数据就是融合前的“黄金标准”。后续所有精度对比,都要以它为锚点。
4.1 为什么基线这么重要?
我遇到过不少同学,上来就调融合后的算子,调了半天发现精度还是不对。最后回头一看——融合前的精度本身就有问题。
你想想看,如果原始模型精度就不达标,融合后怎么可能好?
所以,建立基线有两个目的:
- 验证原始模型精度——确保你的起点是对的
- 提供对比基准——融合后的每个输出,都要跟基线比
说白了,基线就是你的“底牌”。底牌不对,后面全白费。
4.2 怎么获取基线数据?
获取基线数据,其实就三步。我一个个说。
4.2.1 第一步:关闭所有融合优化
这一步听起来简单,但坑不少。
大多数推理引擎,比如 TensorRT、OpenVINO、ONNX Runtime,默认都会开启融合优化。你需要显式关闭它们。
举个例子,在 TensorRT 中:
// 关闭所有图优化
builder->setMaxBatchSize(1);
builder->setStrictTypeConstraints(true);
// 关闭算子融合
config->setFlag(nvinfer1::BuilderFlag::kSTRICT_TYPES);
// 或者直接使用 FP32 精度,避免精度损失
config->setFlag(nvinfer1::BuilderFlag::kFP32);
在 ONNX Runtime 中:
// 关闭所有优化
session_options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_DISABLE_ALL);
// 或者只关闭算子融合
session_options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_BASIC);
注意:关闭所有优化后,推理速度会变慢。这是正常的。我们只为了拿基线数据,不需要追求速度。
4.2.2 第二步:逐层保存中间结果
光有最终输出还不够。你需要每一层的输出。
为什么?因为融合后的算子,可能对应原始模型中的多个层。如果最终精度不对,你得知道是哪一层开始出问题的。
我个人习惯的做法是:
- 在原始模型中插入“钩子”(hook),保存每一层的输出
- 用同一个输入跑一遍,把中间结果全部 dump 到文件
- 给每个输出命名,格式建议:
layer_序号_算子名
比如在 PyTorch 中:
import torch
import torch.nn as nn
class ModelWithHooks(nn.Module):
def __init__(self, model):
super().__init__()
self.model = model
self.outputs = {}
def _hook_fn(self, name):
def hook(module, input, output):
self.outputs[name] = output.detach().cpu()
return hook
def register_hooks(self):
for name, module in self.model.named_modules():
module.register_forward_hook(self._hook_fn(name))
def forward(self, x):
self.outputs.clear()
return self.model(x)
这样跑一次,所有中间结果就都存下来了。
4.2.3 第三步:保存输入数据
这一步很多人会忘。但很重要。
你保存的基线数据,必须和融合后用的输入完全一致。否则对比就没有意义。
我建议的做法:
- 固定随机种子,保证每次生成的输入相同
- 或者直接保存一份输入数据到文件,后续复用
import numpy as np
# 固定随机种子
np.random.seed(42)
input_data = np.random.randn(1, 3, 224, 224).astype(np.float32)
# 保存输入
np.save("baseline_input.npy", input_data)
4.3 基线数据长什么样?
我一般会保存成这样的结构:
baseline/
├── input.npy # 输入数据
├── layer_0_conv1.npy # 第0层输出
├── layer_1_relu.npy # 第1层输出
├── layer_2_conv2.npy # 第2层输出
├── ...
└── output.npy # 最终输出
每个文件都是一个 numpy 数组。这样后续对比时,直接 np.load() 就能用。
4.4 验证基线数据的正确性
数据保存好了,不代表就对了。你需要验证一下。
怎么验证?
我常用的方法:
- 跑两次,对比两次结果——如果两次结果不一致,说明有随机性,需要排查
- 用已知正确的参考实现对比——比如用官方预训练模型跑一次,看输出是否匹配
- 检查数值范围——比如激活值应该在合理范围内,不能出现 NaN 或 Inf
小技巧:我习惯在保存基线时,同时保存一个“校验和”文件。里面记录每个输出的 shape、dtype、以及数值的 min/max/mean。这样后续快速对比时,不用加载全部数据。
4.5 一个完整的基线建立流程
嗯,我把整个流程总结一下:
- 关闭推理引擎的所有融合优化
- 在原始模型中插入钩子,保存每一层输出
- 固定输入数据,保存到文件
- 跑一次推理,保存所有中间结果
- 验证基线数据的正确性(跑两次对比、检查数值范围)
- 将基线数据归档,后续精度对比时直接使用
我曾经在一个项目中,因为偷懒没保存中间结果,结果融合后精度出了问题,排查了整整两天才发现是某一层的问题。从那以后,我再也不敢跳过这一步。
4.6 常见坑点
最后说几个我踩过的坑:
- 精度不一致——关闭优化后,某些引擎可能仍然有微小差异。建议用 FP32 跑基线,不要用 FP16
- 内存爆炸——如果模型很大,保存所有中间结果可能内存不够。可以分批保存,或者只保存关键层的输出
- 命名混乱——中间结果的文件名一定要清晰。我见过有人用
out1.npy、out2.npy,最后自己都分不清哪个是哪个
一句话总结:基线数据是精度排查的“锚点”。花半小时把基线建好,后面能省你半天甚至一天的时间。