TensorRT课程笔记(3)


写在前面

本篇文章程序链接:https://github.com/Shao-junliang/TensorRT-Course-Notes/tree/master/cuda-tensorrt-basic-api

模型构建

  • TensorRT 的工作流程如下图:

    TensorRT 最终推理时需要一个 TensorRT Engine,这个 Engine 是由 TensorRT Builder 构建,而 Builder 构建 Engine 时需要传入两个参数分别是:network, config;其中 network 是由 TensorRT Parser 解析的 ONNX 模型构建的。

    1. 定义 builder, config 和network。

      • createInferBuilder() 创建 IBuilder 类的实例。
      • createBuilderConfig() 创建构建器配置对象。
      • createNetworkV2 创建网络定义对象。
    2. 直接构建:设置模型结构、输入与输出信息。具体方法参考类:INetworkDefinitionIBuilderConfig

      ONNX导入:

      • 创建ONNX解析器 createParser(),通过 parseFromFile() 导入ONNX模型;
      • 设置最大工作空间:setMaxWorkspaceSize()
      • 设置优化配置文件:createOptimizationProfile() 创建,配置最小、最大、最优的输入范围;
      • 添加到配置对象中 addOptimizationProfile(),当模型有多个输入时,必须有多个 profile;
    3. 生成 Engine 模型文件。

      • buildEngineWithConfig() 为给定的 networkconfig 构建引擎。buildEngineWithConfig() 在 TensorRT 8.0 中已弃用,新的构建方法:buildSerializedNetwork()
    4. 模型序列化:serialize() 将网络序列化为流stream,保存时直接保存stream->data()

    注意:builder、config等指针在结束时记得释放,否则会有内存泄漏,使用 ptr->destroy() 释放;ptr->destroy() 在 TRT 8.0 中已弃用,改用 delete

  • 官方文档参考部分 C++ API

模型推理

执行推理的步骤:

  1. 准备模型并加载。

  2. 创建 runtime:createInferRuntime(logger)

  3. 反序列化创建 engine, 得为 engine 提供数据:runtime->deserializeCudaEngine(modelData, modelSize),其中 modelData 包含的是 input 和 output 的名字,形状,大小和数据类型。

    class ModelData(object):
    INPUT_NAME = "data"
    INPUT_SHAPE = (1, 1, 28, 28) // [B, C, H, W]
    OUTPUT_NAME = "prob"
    OUTPUT_SIZE = 10
    DTYPE = trt.float32
  4. 从 engine 创建执行上下文:engine->createExecutionContext()

  5. 创建 CUDA 流 cudaStreamCreate(&stream)

    1. CUDA 编程流是组织异步工作的一种方式,创建流来确定batch推理的独立;
    2. 为每个独立 batch 使用 IExecutionContext (第4步中已经创建了),并为每个独立批次使用 cudaStreamCreate 创建 CUDA 流。
  6. 数据准备:

    1. 在 host 上初始化 input 数据并声明 output 数组大小,然后将 input 数据搬运到 gpu 上。

    2. 推理时,如果是动态输入,还需要明确输入数据的大小,利用 setBindingDimensions() 函数。

    3. 要执行 inference,必须用一个指针数组(bindings)指定inputoutput在gpu中的指针。

      • bindings 为指针数组,表示 input 和 output 在 gpu 中的指针。比如 input 有 a,output 有 b, c, d,那么 bindings = [a, b, c, d],bindings[0] = a,bindings[2] = c。

      • 可以通过 engine->getBindingDimensions(0) 获取指定 binding 索引(这里是索引 0)的张量维度信息。binding 索引是指模型输入和输出张量在 bindings 数组中的位置。

      • binding 索引:在 TensorRT 中,模型的输入和输出被称为 binding。这些 binding 在引擎创建时被确定,并且每个 binding 都有一个唯一的索引。

        float *bindings[] = {input_data_device, output_data_device};
        // 获取第0个binding的维度
        nvinfer1::Dims inputDims = engine->getBindingDimensions(0);
        std::cout << "Input dimensions: ";
        for (int i = 0; i < inputDims.nbDims; ++i) {
        	std::cout << inputDims.d[i] << " ";
        }
        std::cout << std::endl;
    4. 推理并将 output 搬运回CPU。

  7. 启动所有工作后,与所有流同步以等待结果: cudaStreamSynchronize

  8. 按照与创建相反的顺序释放内存。

动态shape

  • 网络构建阶段:
    1. 必须在模型定义时,输入维度给定为 -1,否则该维度不会动态。
    2. 配置 profile:
      • create:builder->createOptimizationProfile()
      • set:setDimensions()设置kMIN, kOPT, kMAX的输入尺寸范围;
      • add:config->addOptimizationProfile(profile);添加profile到网络配置中。
  • 网络推理阶段:
    1. 在选择 profile 的索引后设置 input 维度:execution_context->setBindingDimensions(0, nvinfer1::Dims4());
    2. 注意: 在运行时,向 engine 请求绑定维度会返回用于构建网络的相同维度。 即engine.getBindingDimensions(0) 得到的还是动态的维度 [-1, in_channel, -1, -1];获取当前的实际维度,需要查询执行上下文: context.getBindingDimensions(0)

ONNX模型

ONNX 模型是由微软和 Facebook 提出的一种表示深度学习的格式,主作用要是用来加速模型预测;也可以是作为深度学习模型互相转换之间的一个中转站,比如 torch–>onnx–>tensorrt;也可以利用 onnx 实现 torch–>onnx–>tensorflow;其中 torch 到 onnx 再到 tensorflow 的转换比较容易,但是 tensorflow–>onnx–>torch 个人感觉比较困难。

onnx 模型加速原理:主要通过动态量化实现,利用动态量化将 float32 得模型向量权重转化为 Int 类型,从而节省保存空间以及加速运算。

动态量化原理:根据四元组(min_val,max_val,qmin, qmax)来计算2个量化的参数,qmax 与 qmin 一般为127与-128,min_val 和 max_val 是从输入数据中的最大值与最小值,量化分数 scale = max_val / (( qmax-qmin)/2), zero_point 这个参数一般为0,量化后的权重 Q=zero_point + W/(scale)。

  • torch 的量化函数:torch.quantization.quantize_dynamic
  • https://netron.app/ 该网站可以可视化 onnx 模型,当然有时候也可以可视化一些其它的深度学习模型。

onnx模型构成

onnx 模型:定义了可扩展的计算图模型、标准数据类型以及内置的运算符。主要是由图和节点组成的一层一层的层级结构。每一个计算流图都定义为由节点组成的列表,每个节点是一个 OP算子,可能有一个或多个输入与输出,并由这些节点构建有向无环图。 有的 onnx 算子未实现,比如pytorch中的 BILSTM 层转到 onnx 模型中的 DynamicQuantizeLSTM 算子就未实现。

下图是一个 onnx 模型的所有参数了,其中最核心的就是 Graph(GraphProto)。

最核心的几个对象为:

  • ModelProto、GraphProto 、NodeProto 、 ValueInfoProto 、 TensorProto 、 AttributeProto

当我们加载了一个 ONNX 之后,我们获得的就是一个 ModelProto,它包含了一些版本信息,生产者信息和一个 GraphProto。在 GraphProto 里面又包含了四个 repeated 数组,它们分别是 node(NodeProto类型),input(ValueInfoProto类型),output(ValueInfoProto类型)和initializer(TensorProto类型),其中 node 中存放了模型中所有的计算节点,input 存放了模型的输入节点,output 存放了模型中所有的输出节点,initializer 存放了模型的所有权重参数。 每个计算节点中还包含了一个 AttributeProto 数组,用来描述该节点的属性,比如 Conv 节点或者说卷积层的属性包含 group,pad,strides 等等;

一个 node 主要由:input、output、name、op_type、domain、attribute 等组成。

制作一个onnx节点

当下我们知道了一个 onnx 模型是由一个个的节点构成的图,在制作一个 onnx 模型时就是先搭建一个个的节点,然后将所有节点连接成一个图,最后将图与一些其他输入输出等信息组合在一起变成了一个Model。

在构建一个 onnx 模型时,可以利用 onnx.helper 方法提供的接口实现。具体可以参考onnx的官网onnx/helper.py文件,具体如下:

helper.make_tensor(name, data_type, dims, vals, raw)
"""
name: tensor 名字
data_type: 一个值,例如 onnx.TensorProto.FLOAT
dims(List[int]): 维度
vals: 值
raw(bool): 如果为真,则 vals 包含张量的序列化内容,否则,vals 应该是 *data_type* 定义的类型的值列表
"""

helper.make_tensor_value_info(name: str,
    						  elem_type: int,
    	                      shape: Optional[Sequence[Union[str, int, None]]],
                              doc_string: str = "",
                              shape_denotation: Optional[List[str]] = None,) 
""" 根据数据类型和形状创建一个 ValueInfoProto。 """

helper.make_attribute(key: str, value: Any, doc_string: Optional[str] = None)  
""" 根据值类型创建一个 AttributeProto。 """

helper.make_node(op_type, inputs, outputs, name, doc_string, domain, **kwargs)
"""
inputs: 输入名称列表
outputs: 输出名称列表
doc_string: NodeProto 的可选文档字符串
domain: NodeProto 的可选域。 如果它是 None,我们将只使用默认域(它是空的)
kwargs: dict类型,节点的属性。 可接受的值记录在 make_attribute 方法中。
"""  

helper.make_graph(nodes, name, inputs, outputs, initializer, doc_string, value_info, sparse_initializer)
"""
nodes: NodeProto 的列表
inputs: ValueInfoProto 列表
outputs: ValueInfoProto 列表
initializer: TensorProto 列表
value_info: ValueInfoProto 列表
sparse_initializer: SparseTensorProto 列表
"""

helper.make_model(graph, **kwargs) 
"""
graph(GraphProto): make_graph 方法返回的图
kwargs: 添加到返回实例的任何属性
"""

另外两个比较常用的方法是对构造完的模型的检查和保存,分别定义在onnx/checker.pyonnx/init.py中:

onnx.checker.check_model()
onnx.save()

总结一下构造一个 onnx 模型的具体流程:

1)根据自己的网络结构调用 make_node() 来创建相关节点,节点的 inputs 和 outputs 参数决定了后续 graph 的连接情况,节点的权值和信息通过调用 make_tensor() 和 make_tensor_value_info() 来创建,它们和节点的联系在于节点的 name;

2)上述三个方法构造的结构分别对应 make_graph() 中的三个参数,具体如下所示:

a.nodes: make_node()

b.inputs: make_tensor_value_info()

c.initializer: make_tensor()

3)最后检查和保存模型即可

参考链接:https://blog.csdn.net/u014090429/article/details/120488773

onnx导出注意事项

  • 对于任何用到 shape、size 返回值的参数时,例如:tensor.view(tensor.size(0), -1) 这类操作,避免直接使用 tensor.size 的返回值,而是加上int转换,tensor.view(int(tensor.size(0)), -1),断开跟踪。
  • 对于 nn.Upsample 或 nn.functional.interpolate 函数,使用 scale_factor 指定倍率,而不是使用 size 参数指定大小。
  • 对于 reshape、view 操作时,-1 的指定请放到 batch 维度。其他维度可以计算出来即可。batch 维度禁止指定为大于 -1 的明确数字。
  • torch.onnx.export 指定 dynamic_axes 参数,并且只指定 batch 维度,禁止其他动态。
  • 使用 opset_version=11,不要低于 11。
  • 避免使用 inplace 操作,例如 y[…, 0:2] = y[…, 0:2] * 2 - 0.5。
  • 尽量少的出现 5 个维度,例如 ShuffleNet Module,可以考虑合并 wh 避免出现 5 维。
  • 尽量把预处理、后处理部分在 onnx 模型中实现,可以提高推理速度。

ONNX解析器

onnx 解析器有两种情况,libnvonnxparser.so 或者源代码,so 文件一般用 CUDA\include 路径下的NvOnnxParser.h 文件。使用源代码可以更好的进行自定义封装,简化插件开发或者模型编译的过程,更加具有定制化,遇到问题可以调试;

源代码编译时需要注意 onnx 与 onnx-tensorrt 版本对应,如 onnx-tensorrt-8.0 版本对应关系:

  • Protobuf >= 3.0.x
  • TensorRT 8.0.1.6
  1. 查看环境的 Protobuf 版本程序:

    import google.protobuf
    print(google.protobuf.__version__)
  2. 查看环境中 TensorRT 版本:

    直接查看 TensorRT 路径下 include/NvInferVersion.h 文件;

    #define NV_TENSORRT_MAJOR 8 //!< TensorRT major version.
    #define NV_TENSORRT_MINOR 4 //!< TensorRT minor version.
    #define NV_TENSORRT_PATCH 2 //!< TensorRT patch version.
    #define NV_TENSORRT_BUILD 4 //!< TensorRT build number.

自定义插件(op)

  1. 导出 onnx 的时候,为 module 增加 symbolic 函数,实现自定义 op 算子。

    class MySigmoid(torch.autograd.Function):
        @staticmethod
        def forward(ctx, x):
            # 这样写会报错:IndexError: Argument passed to at() was not in the map.
            # return x * 1 / (1 + torch.exp(-x))   
            return torch.sigmoid(x)
        
        @staticmethod
        def symbolic(g, x):
            return g.op("MySigmoid", x,
                        g.op("Constant", value_t=torch.tensor([3, 2, 1], dtype=torch.float32)),
                        attr1_s="string_attr",
                        attr2_i=[1, 2, 3],
                        attr3_f=222.0)
    
        
    class MyModel(nn.Module):
        def __init__(self) -> None:
            super().__init__()
            self.conv = nn.Conv2d(1, 1, 3, padding=1)
            self.conv.weight.data.fill_(1)
            self.conv.bias.data.fill_(0)
    
        def forward(self, x):
            x = self.conv(x)
            x = MySigmoid.apply(x)
            return x
        
    
    if __name__ == "__main__":
        model = Model().eval()
        dummy = torch.zeros(1, 1, 3, 3)
        torch.onnx.export(
            model,
            # 输入给model的参数,需要传递tuple,因此用括号
            (dummy,),   
            # 模型保存路径
            "./src/cuda-tensorrt-basic-api/static/plugin_demo.onnx",   
            # 是否打印详细信息
            verbose=False,
            # 输入节点与输出节点名称
            input_names=["image"],
            output_names=["output"],
            # opset版本,
            opset_version=11,
            # 通常,只设置batch为动态,其他的避免动态
            dynamic_axes={
                "image": {0: "batch", 2: "height", 3: "width"},
                "output": {0: "batch", 2: "height", 3: "width"},
            },
            # 对于插件,需要禁用onnx检查,这个参数只在 torch < 1.10.0 版本存在
            # enable_onnx_checker=False
            operator_export_type=OperatorExportTypes.ONNX_FALLTHROUGH
        )
    
  2. 在 onnx-tensorrt 源代码解析的 builtin_op_importers.cpp 文件中,添加对自定义 op 的解析。添加方法可以参考 FallbackPluginImporter 算子。测试发现这一步跳过后也可以实现自定义插件

    DEFINE_BUILTIN_OP_IMPORTER(MySigmoid) {
        printf("\033[31m=======================call MySigmoid=======================033[0m\n");
        /*
         * OnnxAttrs 类用于处理和提取 ONNX 节点的属性。
         * OnnxAttrs 类的构造函数接受一个节点和上下文对象,
         * OnnxAttrs.get() 方法以键值对形式返回节点的属性;
         */
        OnnxAttrs attrs(node, ctx);
        const std::string pluginName{node.op_type()};
        const std::string pluginVersion{attrs.get<std::string>("plugin_version", "1")};
        const std::string pluginNamespace{attrs.get<std::string>("plugin_namespace", "")};
    
        LOG_INFO("Searching for plugin: " << pluginName << ", plugin_version: " << pluginVersion << ", plugin_namespace: " << pluginNamespace);
    
        // 通过插件名称、版本和命名空间获取插件创建器
        nvinfer1::IPluginCreator *creator = importPluginCreator(pluginName, pluginVersion, pluginNamespace);
        ASSERT(creator && "Plugin not found, are the plugin name, version, and namespace correct?", ErrorCode::kUNSUPPORTED_NODE);
    
        // 获取插件的字段名称集合
        const nvinfer1::PluginFieldCollection *feildNames = creator->getFieldNames();
        // 字段数据需要进行类型擦除,用 fieldData 进行临时分配
        string_map<std::vector<uint8_t>> fieldData{};
        // 加载字段,将字段数据和属性进行绑定
        std::vector<nvinfer1::PluginField> fields = loadFields(fieldData, attrs, feildNames, ctx);
    
        // 创建插件
        const auto plugin = createPlugin(getNodeName(node), creator, fields);
        ASSERT(plugin && "Could not create plugin", ErrorCode::kUNSUPPORTED_NODE);
    
        // 创建一个插件输入的向量
        std::vector<nvinfer1::ITensor *> pluginInputs{};
        for (auto &input : inputs) {
            // 将每个输入转换为 TensorRT 的张量并添加到插件输入向量中
            pluginInputs.emplace_back(&convertToTensor(input, ctx));
        }
        LOG_INFO("Successfully created plugin: " << pluginName);
        // 在 TensorRT 网络中添加插件层
        auto *layer = ctx->network()->addPluginV2(pluginInputs.data(), pluginInputs.size(), *plugin);
        // 注册插件层
        ctx->registerLayer(layer, getNodeName(node));
        RETURN_ALL_OUTPUTS(layer);
    }
  3. 插件类继承 nvinfer1::IPluginV2DynamicExt 类,完成插件的具体实现。

    class MySigmoidPlugin : public IPluginV2DynamicExt {
    public:
        MySigmoidPlugin(const std::string name, const std::string attr1, float attr3);
    
        MySigmoidPlugin(const std::string name, const void *data, size_t length);
    
        MySigmoidPlugin() = delete;
    
        // ============================== 1.IPluginV2DynamicExt 类的纯虚函数 ==============================
    
        // 输出数据的尺寸
        virtual DimsExprs getOutputDimensions(int32_t outputIndex, DimsExprs const *inputs, int32_t nbInputs, IExprBuilder &exprBuilder) noexcept override;
    
        // 支持的数据类型,int8,float16,float32等
        virtual bool supportsFormatCombination(int32_t pos, PluginTensorDesc const *inOut,
                                               int32_t nbInputs, int32_t nbOutputs) noexcept override;
    
        //  配置插件格式
        virtual void configurePlugin(DynamicPluginTensorDesc const *in, int32_t nbInputs,
                                     DynamicPluginTensorDesc const *out, int32_t nbOutputs) noexcept;
    
        // 需要的额外空间大小
        virtual size_t getWorkspaceSize(PluginTensorDesc const *inputs, int32_t nbInputs, PluginTensorDesc const *outputs,
                                        int32_t nbOutputs) const noexcept override {
            return 0;
        }
    
        // 推理具体逻辑
        virtual int32_t enqueue(PluginTensorDesc const *inputDesc, PluginTensorDesc const *outputDesc,
                                void const *const *inputs, void *const *outputs,
                                void *workspace, cudaStream_t stream) noexcept override;
    
        // ============================== 2.IPluginV2Ext 类的纯虚函数 ==============================
        virtual nvinfer1::DataType getOutputDataType(int32_t index, nvinfer1::DataType const *inputTypes, int32_t nbInputs) const noexcept override {
            return inputTypes[0];
        }
    
        // ============================== 3.IPluginV2 类的纯虚函数 ==============================
        const char *getPluginType() const noexcept override;
    
        const char *getPluginVersion() const noexcept override;
    
        int initialize() noexcept override;
    
        void terminate() noexcept override;
    
        size_t getSerializationSize() const noexcept override;
    
        int getNbOutputs() const noexcept override;
    
        void serialize(void *buffer) const noexcept override;
    
        void destroy() noexcept override;
    
        nvinfer1::IPluginV2DynamicExt *clone() const noexcept override;
    
        void setPluginNamespace(const char *pluginNamespace) noexcept override;
    
        const char *getPluginNamespace() const noexcept override;
    
    private:
        const std::string m_LayerName;
        std::string m_attr1;
        float m_attr3;
        size_t m_InputVolume;
        std::string m_Namespace;
    };
  4. 插件创建器,继承 nvinfer1::IPluginCreator 类,重写该基类的纯虚函数。creator 类主要记录了该 plugin 的名字、版本与命名空间等信息,并且可以生成该plugin类型,返回继承关系内更上层的 nvinfer1::IPluginV2* 指针。可以认为 creator 类是一个插件工厂类,用于插件的实例创建 。

    class MySigmoidPluginCreator : public IPluginCreator {
    public:
        MySigmoidPluginCreator();
    
        /*
         * const char*:函数的返回类型是一个指向常量字符的指针。
         * const: 类型限定符,表示这个成员函数是 const 的。const 成员函数保证不会修改它所属对象的成员变量。
         * noexcept: 异常说明符,表示这个函数不会抛出异常。
         * override: 表示这个成员函数重载了基类中的虚函数。使用 override 可以让编译器检查该函数是否确实覆盖了基类中的某个虚拟函数,如果没有覆盖成功,编译器会报错。
         */
        const char *getPluginName() const noexcept override;
    
        const char *getPluginVersion() const noexcept override;
    
        const PluginFieldCollection *getFieldNames() noexcept override;
    
        IPluginV2 *createPlugin(const char *name, const PluginFieldCollection *fc) noexcept override;
    
        IPluginV2 *deserializePlugin(const char *name, const void *serialData, size_t serialLength) noexcept override;
    
        void setPluginNamespace(const char *pluginNamespace) noexcept override;
    
        const char *getPluginNamespace() const noexcept override;
    
    private:
        static PluginFieldCollection m_FC;
        static std::vector<PluginField> m_PluginAttributes;
        std::string m_Namespace;
    };
  5. 在实现 plugin 类和 creator 类的 cpp 文件中对该 plugin 的 creator 进行注册, 采用宏REGISTER_TENSORRT_PLUGIN 注册插件:

REGISTER_TENSORRT_PLUGIN(MySigmoidPluginCreator);

参考链接: Developer Guide :: NVIDIA Deep Learning TensorRT Documentation

简易插件封装集成

在实现自定义插件时,主要实现的是 enqueue() 函数,其他函数主要是一些信息读取等操作。所以将一些重复度高或者说是不需要每次都改变的函数封装起来,每次只重写插件类里面的推理具体逻辑函数,即可完成插件的封装集成,提高工作效率。

TensorRT 量化

简介

TensorRT 支持使用 8 位整数来表示量化浮点值,量化方案包括激活和权重的量化。

激活的量化方案取决于所选的校准算法,以找到最佳平衡特定数据的舍入误差和精度误差的比例 msub

权重的量化方案如下:

$$ s = {max(abs(x_{min}),abs(x_{max}))\over 127}$$

其中 \(x_{min}\) 和 \(x_{max}\) 是权重张量的浮点最小值和最大值。

要启用任何量化操作,必须在构建器配置中设置 INT8 标志。

量化工作流程

创建量化网络有两种工作流程:

  1. 训练后量化 (Post-training quantization,PTQ) 在网络训练后得出比例因子。TensorRT 为 PTQ 提供了一个称为校准的工作流程,当网络在代表性输入数据上执行时,它测量每个激活张量内激活的分布,然后使用该分布来估计张量的尺度值。
  2. 量化感知训练 (Quantization-aware training,QAT) 在训练期间计算比例因子。这允许训练过程补偿量化和反量化操作的影响。
显示量化与隐式量化

量化网络有两种方式表示:显示量化与隐式量化。

在隐式量化网络中,每个量化张量都有一个关联的 scale。当读取和写入张量时,scale 用于隐式量化和反量化值。在处理隐式量化网络时,TensorRT 在应用图优化时将模型视为浮点模型,并适时使用 INT8 来优化层执行时间。如果某个层在 INT8 中运行得更快,那么它就会在 INT8 中执行。否则,将使用 FP32 或 FP16。在此模式下,TensorRT 仅针对性能进行优化,并且几乎无法控制 INT8 的使用位置 - 即使在 API 级别显式设置了层的精度,TensorRT 也可能会在图形优化期间将该层与另一个层融合,并丢失必须在 INT8 中执行的信息。 TensorRT 的 PTQ 功能会生成隐式量化网络。

在显式量化网络中,量化值和未量化值之间转换的缩放操作由图中的 IQuantizeLayerIDequantizeLayer 节点显式表示,这些节点可以称为 Q/DQ 节点。与隐式量化相比,显式形式准确指定执行与 INT8 之间的转换的位置,并且优化器将仅执行由模型语义决定的精度转换,即使:

  • 添加额外的转换可以提高层精度(例如,选择 FP16 内核实现而不是 INT8 实现)。
  • 添加额外的转换会导致引擎执行速度更快(例如,选择 INT8 内核实现来执行指定为具有浮点精度的层,反之亦然)。

ONNX 使用显式量化表示 - 当 PyTorch 或 TensorFlow 中的模型导出到 ONNX 时,框架图中的每个假量化操作都导出为 Q,后跟 DQ。由于 TensorRT 保留了这些层的语义,因此模型准确性非常接近原框架中的精度。虽然优化保留了量化和反量化的位置,但它们可能会更改模型中浮点运算的顺序,因此结果不会按位相同。

张量量化与通道量化
  • 张量量化(Per-tensor quantization):对每个张量使用相同的量化参数,使用单个 scale(标量)来缩放整个张量。
  • 通道量化(Per-channel quantization):对每个通道(例如卷积层的每个滤波器或特征图)使用不同的量化参数。

使用显式量化时,权重可以使用张量量化或通道量化来进行量化。在这两种情况下,量化尺度的精度都是FP32。激活(activation)只能使用张量量化。

设置动态范围

TensorRT 提供了直接设置动态范围(必须由量化张量表示的范围)的 API,以支持隐式量化,其中这些值是在 TensorRT 之外计算的。该 API 允许使用最小值和最大值来设置张量的动态范围。由于 TensorRT 目前仅支持对称范围,因此使用 max(abs(min_float), abs(max_float)) 计算缩放比例。请注意,当 abs(min_float) != abs(max_float) 时,TensorRT 使用比配置更大的动态范围,这可能会增加舍入误差。对于将在 INT8 中执行的操作,所有浮点输入和输出都需要动态范围。可以利用 setDynamicRange 设置张量的动态范围:

tensor->setDynamicRange(min_float, max_float);

使用校准进行训练后量化

在训练后量化中,TensorRT 会为网络中的每个张量计算一个缩放值( scale )。这个过程称为校准,需要提供具有代表性的输入数据,TensorRT 在这些数据上运行网络以收集每个激活张量的统计信息。所需的输入数据量取决于具体应用。

对于激活张量的统计信息,决定最佳缩放值并不是一门精确的科学,这需要在量化表示中的两种误差来源之间进行平衡:离散化误差(当每个量化值所表示的范围变大时增加)和截断误差(值被钳制到可表示范围的极限)。因此,TensorRT 提供了多种不同的校准器,这些校准器以不同方式计算 scale 。校准器包含:IInt8EntropyCalibrator2IInt8EntropyCalibratorIInt8MinMaxCalibratorIInt8LegacyCalibrator

校准批次大小也会影响 IInt8EntropyCalibrator2IInt8EntropyCalibrator 的截断误差。

构建 INT8 引擎时,构建器执行以下步骤:

  1. 构建一个 32 位引擎,在校准集上运行它,并为每个张量记录激活值分布的直方图。
  2. 根据直方图构建一个校准表,为每个张量提供一个缩放值。
  3. 使用校准表和网络定义构建 INT8 引擎。

校准可能很慢,因此第 2 步(校准表)的输出可以缓存和重用。这在给定平台上多次构建相同网络时非常有用,且所有校准器都支持这一功能。

在运行校准之前,TensorRT 会查询校准器实现,查看它是否有访问缓存表的权限。如果有,它会直接进入第 3 步。缓存数据作为指针和长度传递。可以在此处找到一个示例校准表。

只要校准在层融合之前进行,校准缓存数据在不同设备之间是可移植的。

使用c++进行 INT8 校准

要向 TensorRT 提供校准数据,请实现 IInt8Calibrator 接口。

构建器按以下步骤调用校准器:

  1. 首先,它会查询接口以获取批量大小,并调用 getBatchSize() 来确定预期的输入批量大小。
  2. 然后,它会反复调用 getBatch() 以获取输入批次。批次必须完全符合 getBatchSize() 返回的批量大小。当没有更多批次时,getBatch() 必须返回 false

在实现校准器后,可以将其配置到构建器中使用:

config->setInt8Calibrator(calibrator.get());

要缓存校准表,请实现 writeCalibrationCache()readCalibrationCache() 方法。


文章作者: LSJune
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 LSJune !
评论
  目录