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 第二步:逐层保存中间结果

光有最终输出还不够。你需要每一层的输出。

为什么?因为融合后的算子,可能对应原始模型中的多个层。如果最终精度不对,你得知道是哪一层开始出问题的。

我个人习惯的做法是:

  1. 在原始模型中插入“钩子”(hook),保存每一层的输出
  2. 用同一个输入跑一遍,把中间结果全部 dump 到文件
  3. 给每个输出命名,格式建议: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 验证基线数据的正确性

数据保存好了,不代表就对了。你需要验证一下。

怎么验证?

我常用的方法:

  1. 跑两次,对比两次结果——如果两次结果不一致,说明有随机性,需要排查
  2. 用已知正确的参考实现对比——比如用官方预训练模型跑一次,看输出是否匹配
  3. 检查数值范围——比如激活值应该在合理范围内,不能出现 NaN 或 Inf

小技巧:我习惯在保存基线时,同时保存一个“校验和”文件。里面记录每个输出的 shape、dtype、以及数值的 min/max/mean。这样后续快速对比时,不用加载全部数据。

4.5 一个完整的基线建立流程

嗯,我把整个流程总结一下:

  1. 关闭推理引擎的所有融合优化
  2. 在原始模型中插入钩子,保存每一层输出
  3. 固定输入数据,保存到文件
  4. 跑一次推理,保存所有中间结果
  5. 验证基线数据的正确性(跑两次对比、检查数值范围)
  6. 将基线数据归档,后续精度对比时直接使用

我曾经在一个项目中,因为偷懒没保存中间结果,结果融合后精度出了问题,排查了整整两天才发现是某一层的问题。从那以后,我再也不敢跳过这一步。

4.6 常见坑点

最后说几个我踩过的坑:

  • 精度不一致——关闭优化后,某些引擎可能仍然有微小差异。建议用 FP32 跑基线,不要用 FP16
  • 内存爆炸——如果模型很大,保存所有中间结果可能内存不够。可以分批保存,或者只保存关键层的输出
  • 命名混乱——中间结果的文件名一定要清晰。我见过有人用 out1.npy、out2.npy,最后自己都分不清哪个是哪个

一句话总结:基线数据是精度排查的“锚点”。花半小时把基线建好,后面能省你半天甚至一天的时间。

基线建立流程 关闭融合优化 关闭所有图优化 插入钩子 保存每层输出 固定输入数据 保存到文件 跑推理 保存结果 验证基线正确性 跑两次对比、检查数值 归档基线数据 后续精度对比使用 图:基线建立流程示意图

可可精品资料库