超长源码:根据tensorRT源码对比学习int8量化

引入

我们其实可以看到看到trt_samples_onnx_mnist.cpp中也有对int8的处理,那么为什么还需要sampleINT8.cpp专门对int8进行处理呢?

为什么需要专门的INT8处理?

构建神经网络时的不同:动态范围vs校准器

  1. 精度和性能平衡

    • 使用INT8量化可以显著提高推理速度并减小模型体积,但这通常会牺牲一定的精度。因此,为了最大限度地减少精度损失,需要进行精确的校准和优化。
  2. 校准流程

    • 校准是量化过程中非常关键的一步,它的目的是确定最优的量化参数(如量化比例因子)。这些参数将用于将浮点数值转换为INT8(或其他低精度格式)数值,同时尽量保留原始数据的信息。
  3. 不同模型和数据的需求

    • 不同的模型和不同的输入数据对量化的敏感度不同。一些网络如卷积网络对量化比较鲁棒,而像LSTM这样的循环神经网络可能就更敏感一些。因此,针对特定模型优化的INT8处理可能会有所不同。

校准的作用

校准主要用于确定量化中使用的动态范围。这一过程通常涉及以下步骤:

  1. 选择代表性数据集

    • 使用一组代表性的数据来运行模型,这些数据应该尽可能覆盖到模型在实际应用中可能遇到的各种情况。
  2. 收集激活数据

    • 在校准过程中,通过模型的每一层传递代表性数据,并记录下层激活的统计信息(例如,最大值和最小值)。
  3. 确定量化参数

    • 使用收集到的统计信息来确定每层的量化参数,例如量化的比例因子和偏移量,以确保在转换为INT8时信息损失最小。
  4. 应用量化

    • 最后,使用这些校准得到的参数将模型的权重和激活从浮点数转换为INT8格式。

tensorRT的校准方法

TensorRT 提供了多种校准方法,特别是针对 INT8 量化。这些校准方法旨在为量化过程确定最佳的动态范围,以便最小化精度损失。以下是TensorRT常用的几种INT8校准方法:

  1. 熵校准器(Entropy Calibrator)

    • 这是一种常见的校准方法,基于收集的激活统计数据(主要是激活的直方图)来计算熵,从而确定最优的量化阈值。熵校准器试图找到一种映射方法,使得量化后的输出尽可能接近原始输出的信息内容。
  2. 最小最大校准器(MinMax Calibrator)

    • 这种校准方法通过分析激活值的最小值和最大值来确定量化的尺度因子和零点。这是一种相对简单的方法,能够快速地确定数据的动态范围。
  3. 百分比校准器(Percentile Calibrator)

    • 这种方法通过确定激活数据的特定百分比(如99.9%)来设定量化的范围,可以有效地避免极端异常值对量化精度的影响。

这些校准方法只适用于 INT8 量化,因为 INT8 量化涉及将浮点数精确映射到仅有256个可能值的整数中,因此非常依赖于精确的动态范围估计。 对于 FP16(16位浮点)和 BF16(16位Brain Floating Point),通常不需要进行像 INT8 量化那样的校准过程:

  • FP16:由于 FP16 仍然是浮点格式,它能够表示非常广泛的动态范围,尽管精度比 FP32 低。FP16的转换主要关注数值的范围和精度损失,通常不需要进行校准,但可能需要进行一些数值稳定性的调整。
  • BF16:BF16 设计用于深度学习应用,它提供与 FP16 相似的广泛动态范围,但更适合于神经网络的权重和激活。和FP16一样,BF16转换主要关注的是保持数值范围和最小化精度损失,通常不需要校准。

在将模型转换为 FP16 或 BF16 时,主要工作集中在确保运算过程中数值的稳定性,例如通过增加数值保护措施或调整模型架构来避免潜在的数值下溢问题。而不是像 INT8 那样需要精确控制每个数值的表示范围。

主函数

hello_word主函数

代码步骤:

  1. 解析命令行参数并存储在args对象中。
  2. 检查参数是否有效,如果无效则输出错误信息并显示帮助信息。
  3. 如果用户请求帮助信息,则显示帮助信息并返回成功状态。
  4. 定义并开始测试,记录测试开始的日志。
  5. 初始化SampleOnnxMNIST对象并传入参数。
  6. 输出信息表示正在构建和运行一个用于Onnx MNIST的GPU推理引擎。
  7. 构建推理引擎,如果失败则报告失败。
  8. 进行推理操作,如果失败则报告失败。
  9. 报告测试通过。

数据流向:

  • 命令行参数通过argcargv传入,用于初始化args对象。
  • 初始化参数被传递给SampleOnnxMNIST对象,用于配置和初始化模型。
  • 构建和运行推理引擎的过程中,数据流向包括模型构建、输入数据处理、推理操作和输出结果。
  • 最终的测试结果会影响测试状态的报告。

int8主函数

int8主函数的代码步骤和数据流向如下:

代码步骤:

  1. 检查是否有参数传入并且参数为"--help"或"-h",如果是则打印帮助信息并返回成功状态。
  2. 设置默认的批量大小、第一个评分批次和评分批次数量,并解析额外参数。
  3. 检查批量大小是否大于128,如果是则输出错误信息并返回失败状态。
  4. 检查评分批次的总数是否超过60000,如果是则输出错误信息并返回失败状态。
  5. 解析命令行参数并初始化Args对象。
  6. 初始化SampleINT8对象并定义测试对象。
  7. 报告测试开始并输出日志信息。
  8. 遍历不同数据类型(FP32、FP16、INT8):
    • 输出当前数据类型的信息。
    • 构建当前数据类型的网络,如果构建失败则输出警告信息或报告失败状态。
    • 进行推理操作,如果推理失败则报告失败状态。
  9. 检查FP32的准确率是否达到期望值,如果低于期望值则输出错误信息并报告失败状态。
  10. 检查不同数据类型的准确率是否在指定误差范围内相等,如果不相等则输出错误信息并报告失败状态。
  11. 执行网络清理操作,如果清理失败则报告失败状态。
  12. 返回测试通过状态。

数据流向:

  • 命令行参数通过argcargv传入,用于设置批量大小、评分批次等参数。
  • 数据类型名称、顶部名称、数据类型和分数等信息用于记录和输出日志。
  • 不同数据类型的网络构建和推理过程中,数据流向包括输入数据、网络模型、推理结果和准确率。
  • 最终的准确率比较和网络清理操作的结果会影响最终的测试状态。

对比

两者都有的步骤:

  • 解析命令行参数、
  • 检查参数有效性、
  • 显示帮助信息、
  • 定义并开始测试、
  • 初始化样本参数、
  • 构建推理引擎、
  • 进行推理操作,
  • 最后报告测试结果

解析命令行参数、检查参数有效性

不同的是

int8会设置并解析额外的:

  • 默认的批量大小、
  • 第一个评分批次和评分批次数量以及
  • 检查批量大小和评分批次总数
cpp 复制代码
    // 默认情况下,我们从512开始对57600张图像进行评分,因此我们不对用于搜索校准的图像进行评分
    int batchSize = 32; // 批量大小
    int firstScoreBatch = 16; // 第一个评分批次
    int nbScoreBatches = 1800; // 评分批次数量
    // 解析额外参数
    for (int i = 1; i < argc; ++i)
    {
        // 检查参数是否以特定前缀开头并提取对应数值
        if (!strncmp(argv[i], "batch=", 6))
        {
            // 如果参数以"batch="开头,则提取批量大小数值
            batchSize = atoi(argv[i] + 6);
        }
        else if (!strncmp(argv[i], "start=", 6))
        {
            // 如果参数以"start="开头,则提取第一个评分批次数值
            firstScoreBatch = atoi(argv[i] + 6);
        }
        else if (!strncmp(argv[i], "score=", 6))
        {
            // 如果参数以"score="开头,则提取评分批次数量数值
            nbScoreBatches = atoi(argv[i] + 6);
        }
    }

    // 检查批量大小是否大于128,如果是则输出错误信息并返回失败状态
    if (batchSize > 128)
    {
        sample::gLogError << "请提供小于等于128的批量大小" << std::endl;
        return EXIT_FAILURE;
    }

    // 检查评分批次的总数是否超过60000,如果是则输出错误信息并返回失败状态
    if ((firstScoreBatch + nbScoreBatches) * batchSize > 60000)
    {
        sample::gLogError << "仅有60000张图像可用" << std::endl;
        return EXIT_FAILURE;
    }

显示帮助信息

cpp 复制代码
argc >= 2 &&  // argc >= 2 确保至少有两个参数传递给程序(包括程序名称本身)。

(
    !strncmp(argv[1], "--help", 6) || 
    !strncmp(argv[1], "-h", 2)
)
/*
!strncmp(argv[1], "--help", 6) 检查第二个参数是否以 "--help" 开头(比较前6个字符)。
!strncmp(argv[1], "-h", 2) 检查第二个参数是否以 "-h" 开头(比较前2个字符)。

如果以上条件中任意一个成立且至少有两个参数传递给程序,
表示用户请求显示帮助信息,程序将执行相应的逻辑来处理显示帮助信息的操作。
*/

定义并开始测试、初始化样本参数

除了一个batchsize的参数,没有任何区别

构建推理引擎

cpp 复制代码
// 定义数据类型名称和顶部名称
std::vector<std::string> dataTypeNames = {"FP32", "FP16", "INT8"};
std::vector<std::string> topNames = {"Top1", "Top5"};
// 定义数据类型和分数
std::vector<DataType> dataTypes = {DataType::kFLOAT, DataType::kHALF, DataType::kINT8};
std::vector<std::vector<float>> scores(3, std::vector<float>(2, 0.0f));
// 遍历dataTypes数组
for (size_t i = 0; i < dataTypes.size(); i++)
{
    // 输出当前数据类型的信息
    sample::gLogInfo << dataTypeNames[i] << " 运行:" << nbScoreBatches << " 批次,每批大小为 " << batchSize
                     << ",从第 " << firstScoreBatch << " 批次开始" << std::endl;

进行推理操作

int8的推理是遍历推理

最后报告测试结果

int8会输出评分,准确率等,同时因为是caffe模型,所以还会在报告之前执行teardown()函数

这段代码是一个测试用例的一部分,主要用来检测和报告不同数据类型(如 FP32, INT8, FP16)的模型在进行推理时的准确率。其目的是确保模型在不同精度设置下的推理结果与FP32(通常视为最高精度和参考基准)的结果相比,准确率的变化在可接受的误差范围内。

具体做法是比较各数据类型的准确率与FP32数据类型的准确率是否在指定的误差范围内(tolerance)。如果准确率低于期望值(goldenMNIST),则输出错误信息并返回测试失败状态。

遍历不同数据类型和准确率指标(Top1、Top5),检查是否有准确率差异超过误差范围,如果有则输出差异信息并返回测试失败状态。

cpp 复制代码
    // 定义一个lambda函数,用于判断两个浮点数是否在指定的误差范围内近似相等
    auto isApproximatelyEqual = [](float a, float b, double tolerance) { return (std::abs(a - b) <= tolerance); };
    // 定义误差范围和期望值
    const double tolerance{0.01};
    const double goldenMNIST{0.99};

    // 检查FP32的准确率是否达到期望值
    if ((scores[0][0] < goldenMNIST) || (scores[0][1] < goldenMNIST))
    {
        // 如果准确率低于期望值,输出错误信息并返回测试失败状态
        sample::gLogError << "检测到FP32准确率低于99%: Top1 = " << scores[0][0] << ", Top5 = " << scores[0][1]
                          << "." << std::endl;
        return sample::gLogger.reportFail(sampleTest);
    }
    // 遍历topNames和dataTypes数组
    for (unsigned i = 0; i < topNames.size(); i++)
    {
        for (unsigned j = 1; j < dataTypes.size(); j++)
        {
            // 检查是否需要输出准确率差异信息
            if (scores[j][i] != 0.0f && !isApproximatelyEqual(scores[0][i], scores[j][i], tolerance))
            {
                // 输出准确率差异信息
                sample::gLogError << "FP32(" << scores[0][i] << ") 和 " << dataTypeNames[j] << "(" << scores[j][i]
                                  << ") 的 " << topNames[i] << " 准确率相差超过 " << tolerance << "。"
                                  << std::endl;
                // 返回测试失败状态
                return sample::gLogger.reportFail(sampleTest);
            }
        }
    }

初始化示例参数initializeSampleParams()

对比

1. 目的和模型类型

  • initializeSampleParams for ONNX (hello_world_onnx):

    • 此函数专为加载和处理ONNX模型参数设计,它默认加载的是一个名为mnist.onnx的模型。
    • 使用的是ONNX模型,因此不需要提供如Prototxt或Caffe模型的特定文件名。
  • initializeSampleParams for Caffe (int8_caffe):

    • 这个函数设计来加载和处理Caffe模型参数,更适合用于INT8量化的场景,同时配置了更多与数据路径和模型文件相关的参数。
    • 它不仅涉及到普通的数据目录,还特别加入了针对INT8量化的数据路径,如int8/mnist/

2. 参数配置和功能

  • ONNX版本:

    • 支持从命令行参数设置FP16, INT8, BF16运行模式,并直接应用到初始化参数中。
    • 只涉及基本的数据目录配置,当没有提供数据目录时,会添加默认的数据目录。
  • Caffe版本:

    • 更详细地管理了数据目录,包括默认目录和专门为INT8处理设置的目录。
    • 额外配置了批次大小、校准批次数和校准批次大小,这些是进行INT8量化时特别需要的。
    • 包含了具体的模型文件名(如权重和Prototxt文件),这对于Caffe模型是必需的。

3. 数据目录的处理方式

  • ONNX版本 :
    • 直接使用用户提供的目录或默认目录,较为简单。
  • Caffe版本 :
    • 除了使用用户提供的目录,还额外添加了多个与INT8相关的特定目录,显示了更复杂的目录管理。

4. 额外的参数设置

  • Caffe版本 :
    • 除了通用的DLA核心设置外,还详细配置了与Caffe模型和INT8量化相关的多个参数,如输入/输出张量名称和网络名称。
    • 这显示了一个更为定制化的参数设置过程,适合于复杂的模型量化任务。

这两个函数虽然都是用于初始化参数,但明显地服务于不同的框架和目的。ONNX版本偏向于通用模型加载和简单配置,而Caffe版本则专注于为量化处理提供更详细和复杂的参数配置。这种设计上的差异反映了不同模型和应用场景的具体需求。

hello_world_onnx

cpp 复制代码
//!
//! \brief 使用命令行参数初始化params结构的成员
//!
samplesCommon::OnnxSampleParams initializeSampleParams(
    const samplesCommon::Args& args)
{
    // 创建一个OnnxSampleParams对象params
    samplesCommon::OnnxSampleParams params;
    // 如果用户未提供目录路径,则使用默认目录
    if (args.dataDirs.empty())
    {   // 添加默认数据目录路径到params的dataDirs中
        params.dataDirs.push_back("data/mnist/");
        params.dataDirs.push_back("data/samples/mnist/");
    }else // 如果用户提供了数据目录,则使用用户提供的目录
    {   // 将args中的dataDirs赋值给params的dataDirs
        params.dataDirs = args.dataDirs;
    }
    // 设置onnx文件名为"mnist.onnx"
    params.onnxFileName = "mnist.onnx";
    // 将输入张量名称"Input3"添加到params的inputTensorNames中
    params.inputTensorNames.push_back("Input3");
    // 将输出张量名称"Plus214_Output_0"添加到params的outputTensorNames中
    params.outputTensorNames.push_back("Plus214_Output_0");
    // 将args中的useDLACore赋值给params的dlaCore
    params.dlaCore = args.useDLACore;
    // 将args中的runInInt8赋值给params的int8
    params.int8 = args.runInInt8;
    // 将args中的runInFp16赋值给params的fp16
    params.fp16 = args.runInFp16;
    // 将args中的runInBf16赋值给params的bf16
    params.bf16 = args.runInBf16;
    return params;
}

int8_caffe

cpp 复制代码
//!
//! \brief 使用命令行参数初始化params结构的成员
//!
SampleINT8Params initializeSampleParams(
    const samplesCommon::Args& args, int batchSize)
{
    SampleINT8Params params;
    // 使用用户提供的目录以及默认目录
    params.dataDirs = args.dataDirs;
    params.dataDirs.emplace_back("data/mnist/"); // 添加默认数据目录路径
    params.dataDirs.emplace_back("int8/mnist/"); // 添加int8数据目录路径
    params.dataDirs.emplace_back("samples/mnist/"); // 添加samples数据目录路径
    params.dataDirs.emplace_back("data/samples/mnist/"); // 添加data/samples数据目录路径
    params.dataDirs.emplace_back("data/int8/mnist/"); // 添加data/int8数据目录路径
    params.dataDirs.emplace_back("data/int8_samples/mnist/"); // 添加data/int8_samples数据目录路径

    params.batchSize = batchSize; // 设置批量大小
    params.dlaCore = args.useDLACore; // 设置DLA核心
    params.nbCalBatches = 10; // 设置校准批次数量
    params.calBatchSize = 50; // 设置校准批次大小
    params.inputTensorNames.push_back("data"); // 添加输入张量名称
    params.outputTensorNames.push_back("prob"); // 添加输出张量名称
    params.prototxtFileName = "deploy.prototxt"; // 设置prototxt文件名
    params.weightsFileName = "mnist_lenet.caffemodel"; // 设置权重文件名
    params.networkName = "mnist"; // 设置网络名称
    return params; // 返回初始化后的params结构
}

如果是int8_onnx应该怎样初始化参数?

cpp 复制代码
namespace samplesCommon {

struct OnnxSampleParams {
    std::vector<std::string> dataDirs;         // 数据目录列表
    std::string onnxFileName;                  // ONNX模型文件名
    std::vector<std::string> inputTensorNames; // 输入张量名称列表
    std::vector<std::string> outputTensorNames;// 输出张量名称列表
    int dlaCore = -1;                          // DLA核心的使用
    bool int8 = false;                         // 是否使用INT8量化
    bool fp16 = false;                         // 是否使用FP16
};

struct Args {
    std::vector<std::string> dataDirs;
    int useDLACore = -1;
    bool runInInt8 = false;
    bool runInFp16 = false;
};

OnnxSampleParams initializeSampleParams(const Args& args) {
    OnnxSampleParams params;

    // 设置数据目录
    if (args.dataDirs.empty()) {
        // 添加默认数据目录路径
        params.dataDirs.push_back("data/samples/mnist/");
        params.dataDirs.push_back("data/mnist/");
    } else {
        params.dataDirs = args.dataDirs;
    }

    // 设置ONNX文件名
    params.onnxFileName = "mnist.onnx";

    // 设置输入和输出张量名称
    params.inputTensorNames.push_back("Input3");
    params.outputTensorNames.push_back("Plus214_Output_0");

    // 设置DLA核心
    params.dlaCore = args.useDLACore;

    // 设置是否使用INT8量化
    params.int8 = args.runInInt8;

    // 设置是否使用FP16
    params.fp16 = args.runInFp16;

    return params;
}

} // namespace samplesCommon

核心类

public

helloworld

csharp 复制代码
SampleOnnxMNIST(const samplesCommon::OnnxSampleParams& params)
        : mParams(params)
        , mRuntime(nullptr)
        , mEngine(nullptr)
bool build()
bool infer()

int8

arduino 复制代码
SampleINT8(const SampleINT8Params& params)
        : mParams(params)
        , mEngine(nullptr)
        
bool build(DataType dataType)
bool infer(std::vector<float>& score, 
                int firstScoreBatch, 
                int nbScoreBatches);

先看SampleOnnxMNIST()和SampleINT8()

可以看到int8这边定义了一个结构体 SampleINT8ParamssamplesCommon内的CaffeSampleParams封装了起来,并且在定义父类的时候还增加了一个额外的操作

使用initLibNvInferPlugins函数初始化TensorRT插件,传入日志记录器和空字符串作为参数。 initLibNvInferPlugins(&sample::gLogger.getTRTLogger(), "");

而未量化版本使用的还是samplesCommon命名空间

int8使用的是caffe模型,那如果想改为onnx应该怎么改呢?

cpp 复制代码
struct SampleINT8Params
{
    int nbCalBatches;        //!< 用于校准的批次数量
    int calBatchSize;        //!< 校准批次大小
    std::string networkName; //!< 网络的名称
    std::string onnxModelFileName; // ONNX模型文件名
    std::string onnxWeightsFileName; // ONNX权重文件名
    std::vector<std::string> inputTensorNames; // 输入张量名称
    std::vector<std::string> outputTensorNames; // 输出张量名称
};
cpp 复制代码
#include <NvOnnxParser.h>

struct SampleINT8Params : public samplesCommon::GenericSampleParams
{
    int nbCalBatches;        // 用于校准的批次数量
    int calBatchSize;        // 校准批次大小
    std::string modelFileName; // ONNX模型文件名
};

class SampleINT8
{
public:
    SampleINT8(const SampleINT8Params& params)
        : mParams(params)
        , mEngine(nullptr)
    {
        initLibNvInferPlugins(&sample::gLogger.getTRTLogger(), "");
    }

    bool build()
    {
        auto builder = nvinfer1::createInferBuilder(sample::gLogger.getTRTLogger());
        auto network = builder->createNetworkV2(0U);
        auto parser = nvonnxparser::createParser(*network, sample::gLogger.getTRTLogger());

        if (!parser->parseFromFile(mParams.modelFileName.c_str(), static_cast<int>(nvinfer1::ILogger::Severity::kINFO)))
        {
            sample::gLogError << "Failed to parse ONNX model" << std::endl;
            return false;
        }

        // 进一步的构建和优化网络代码...
        return true;
    }

private:
    SampleINT8Params mParams;
    nvinfer1::ICudaEngine* mEngine;
};

build()、infer()等

可以看到int8新定义了一个teardown()并且在build()infer()都增加了一些参数

cpp 复制代码
//!
    //! \brief build函数用于构建网络引擎
    //!
    bool build(DataType dataType);
    /*
        DataType是一个枚举类型,在TensorRT库中定义。
        在这里将build()函数增加DataType参数的意义在于指定要构建的网络的数据类型(如FP32、FP16、INT8),以便根据不同的数据类型构建相应的网络引擎。
        这样可以根据需要构建不同数据类型的网络,使代码更加灵活和通用。
    */

    //!
    //! \brief infer函数用于运行此示例的TensorRT推理引擎
    //!
    bool infer(std::vector<float>& score, 
                int firstScoreBatch, 
                int nbScoreBatches);
    /*
        在这段代码中,infer函数的参数含义如下:
        score: 用于存储推理结果的向量,包括准确率等信息。
        firstScoreBatch: 第一个评分批次的索引,表示从哪个批次开始进行评分。
        nbScoreBatches: 要评分的批次数量,表示总共要评分的批次数。
    */

teardown()是做什么的?

teardown函数用于清理示例类中创建的任何状态

因为int8文件是针对caffe模型,与onnx模型不同,需要清理SampleINT8类中创建的任何状态,这包括释放资源、关闭文件等操作,以确保程序在结束时能够正确清理并释放资源,避免内存泄漏或资源泄漏。

cpp 复制代码
//!
//! \brief 清理示例类中创建的任何状态
//!
bool SampleINT8::teardown()
{
    //! 在解析完成后清理libprotobuf文件
    // libprotobuf文件指的是Protocol Buffers库的相关文件,用于处理协议缓冲区数据
    //! \note 在调用ShutdownProtobufLibrary()后,
    // 使用协议缓冲区库the protocol buffers library的任何其他部分是不安全的,
    // 也就是不能调用libprotobuf等文件
    
    //! Clean up the libprotobuf files as the parsing is complete
    //! \note It is not safe to use any other part of the protocol buffers library after
    //! ShutdownProtobufLibrary() has been called.
    nvcaffeparser1::shutdownProtobufLibrary();
    return true;
}

Protocol Buffers(协议缓冲区)是一种用于序列化结构化数据的方法,通常用于在不同系统之间进行数据交换或持久化存储。它定义了一种轻量级、高效的数据交换格式,可以用于不同编程语言之间的数据通信。通过定义数据结构和消息格式,Protocol Buffers可以将数据序列化为二进制格式,以便在网络上传输或存储到磁盘上。这种格式具有高效的编解码速度和较小的数据体积,使得数据交换更加高效和可靠。

build()内部实现对比

int8多了一段

cpp 复制代码
    // 如果数据类型为INT8且构建器不支持快速INT8,
    // 或者数据类型为HALF且构建器不支持快速FP16,则返回false
    if ((dataType == DataType::kINT8 && !builder->platformHasFastInt8())
        || (dataType == DataType::kHALF && !builder->platformHasFastFp16()))
    {
        return false;
    }

并且没有定义mOutputDims、profileStream、plan、mRuntime、mEngine

其中plan、profileStream和mEngine是在int_caffe的constructNetwork()私有函数中定义的(实现一样,只是位置不同)

也没有验证mOutputDims输出维度

parser因为模型不同而不太一样

cpp 复制代码
//hello world _ onnx
auto parser = SampleUniquePtr<nvonnxparser::IParser>(nvonnxparser::createParser(*network, sample::gLogger.getTRTLogger()));
// int 8_caffe
auto parser = SampleUniquePtr<nvcaffeparser1::ICaffeParser>(nvcaffeparser1::createCaffeParser());
    

且在constructNetwork()中定义为runtime而非mRuntime,他们的指针类型不一样

cpp 复制代码
// hello world _ onnx build()
// 使用std::shared_ptr来管理mRuntime对象,将createInferRuntime返回的指针转换为std::shared_ptr类型。
mRuntime = std::shared_ptr<nvinfer1::IRuntime>(createInferRuntime(
    sample::gLogger.getTRTLogger()));

// int 8_caffe constructNetwork()
// 使用SampleUniquePtr来管理runtime对象,将createInferRuntime返回的指针转换为SampleUniquePtr类型。
SampleUniquePtr<IRuntime> runtime{createInferRuntime(
    sample::gLogger.getTRTLogger())};

两种指针SampleUniquePtr vs std::shared_ptr

SampleUniquePtrstd::shared_ptr是两种不同的智能指针类型,它们有以下区别:

  1. 所有权管理:
  • SampleUniquePtr是一个自定义的智能指针类,用于管理资源的所有权。它采用独占所有权模式,即同一时间只能有一个SampleUniquePtr指向资源,确保资源在超出作用域时被正确释放。

  • std::shared_ptr是C++标准库提供的智能指针类,采用共享所有权模式,允许多个std::shared_ptr共享同一资源,资源会在最后一个std::shared_ptr超出作用域时被释放。

  1. 复制和移动语义:
  • SampleUniquePtr不支持复制构造函数和复制赋值操作符,但支持移动构造函数和移动赋值操作符,因此适合用于独占所有权的场景。

  • std::shared_ptr支持复制构造函数和复制赋值操作符,允许多个std::shared_ptr指向同一资源,适合用于共享所有权的场景。

  1. Overhead:
  • 由于SampleUniquePtr是一个轻量级的自定义智能指针类,可能在实现上比std::shared_ptr更轻量,减少了一些额外的开销。

  • std::shared_ptr是C++标准库提供的通用智能指针,提供了更多功能和灵活性,但可能会带来一些额外的开销。

综上所述,SampleUniquePtr适用于需要独占所有权的场景,而std::shared_ptr适用于需要共享所有权的场景。

infer()内部实现对比

因为int8的infer()函数增加了很多传参,而且onnx和caffe模型也不同,所以这个函数的区别比较大

  1. 定义buffer ->

  2. 执行context ->

  3. 输出张量总大小(不同)->

    绑定维度 vs getEngine()

  4. 确保输入张量名称列表中只有一个张量 ->

  5. 从主机host输入缓冲区复制到设备device输入缓冲区 buffers.copyInputToDevice(); ->

  6. 推理操作(不同) ->

    context->executeV2 vs cuda流

  7. 从设备输出缓冲区复制到主机输出缓冲区 buffers.copyOutputToHost(); ->

  8. 验证与收尾(不同)

private私有类

helloworld

c 复制代码
samplesCommon::OnnxSampleParams mParams; //!< 用于存储示例参数。
nvinfer1::Dims mInputDims;  //!< 网络输入的维度。
nvinfer1::Dims mOutputDims; //!< 网络输出的维度。
int mNumber{0};             //!< 待分类的数字
std::shared_ptr<nvinfer1::IRuntime> mRuntime;   //!< 用于反序列化引擎的TensorRT运行时
std::shared_ptr<nvinfer1::ICudaEngine> mEngine; //!< 用于运行网络的TensorRT引擎
bool constructNetwork(SampleUniquePtr<nvinfer1::IBuilder>& builder,
    SampleUniquePtr<nvinfer1::INetworkDefinition>& network, 
    SampleUniquePtr<nvinfer1::IBuilderConfig>& config,
    SampleUniquePtr<nvonnxparser::IParser>& parser);
bool processInput(const samplesCommon::BufferManager& buffers); // 读取输入数据并将结果存储在BufferManager管理的缓冲区中
bool verifyOutput(const samplesCommon::BufferManager& buffers); // 对数字进行分类并验证结果

TLDR:清爽版

scss 复制代码
// 清爽版
mParams
mInputDims
mOutputDims
mNumber
mRuntime
mEngine
constructNetwork(builder,network,config,parser)
bool processInput(buffers)
bool verifyOutput(buffers)

int8

scss 复制代码
// 清爽版
mParams
mInputDims
mEngine
constructNetwork(builder,network,config,parser,
    dataType)
bool processInput(buffers)
int calculateScore(buffers,labels,batchSize, outputSize,threshold);

不同之处在于 int8私有类没有定义mOutputDims、mNumber、mRuntime

cpp 复制代码
// hello_world_onnx
bool verifyOutput(const samplesCommon::BufferManager& buffers);

// int8_caffe
int calculateScore(
        const samplesCommon::BufferManager& buffers, float* labels, int batchSize, int outputSize, int threshold);

calculateScore()评分模型的实现如下

cpp 复制代码
//!
//! \brief 评分模型的实现
//!
// 计算模型得分
int SampleINT8::calculateScore(
    const samplesCommon::BufferManager& buffers, float* labels, int batchSize, int outputSize, int threshold)
{
    // 获取输出概率
    float* probs = static_cast<float*>(buffers.getHostBuffer(mParams.outputTensorNames[0]));

    int success = 0; // 成功计数
    for (int i = 0; i < batchSize; i++)
    {
        float *prob = probs + outputSize * i, correct = prob[(int) labels[i]]; // 计算正确概率

        int better = 0; // 更好计数
        for (int j = 0; j < outputSize; j++)
        {
            if (prob[j] >= correct)
            {
                better++; // 计算比正确概率更好的数量
            }
        }
        if (better <= threshold)
        {
            success++; // 如果更好数量小于等于阈值,则成功计数加一
        }
    }
    return success; // 返回成功计数
}

这段代码是用于评分模型的函数。它的作用是计算模型的得分,具体步骤如下:

  1. 从buffers中获取输出概率数据。

  2. 遍历每个样本(batchSize个样本):

  • 计算当前样本的正确概率correct,并将其与模型输出的概率进行比较。

  • 统计比正确概率更高的数量,即计算有多少个类别的概率比正确类别的概率高。

  • 如果比正确概率更高的数量不超过阈值threshold,则将成功计数success加一。

  1. 返回成功计数,表示成功的样本数量。

这样做的目的是评估模型在推理过程中的准确性,通过统计模型输出的概率中有多少个类别的概率比正确类别的概率高来衡量模型的性能。这种评分方法可以帮助评估模型在分类任务中的表现,并提供有关模型准确性的指标。

constructNetwork() 【重要】

hello_world_onnx如何构建神经网络constructNetwork()

cpp 复制代码
 // 从文件中解析 ONNX 模型
    auto parsed = parser->parseFromFile(
        locateFile(mParams.onnxFileName, mParams.dataDirs).c_str(), // 解析的ONNX模型文件的路径
        // locateFile返回的是std::string类型的文件路径,因此需要通过.c_str()将其转换为C风格的字符串。
        static_cast<int>(sample::gLogger.getReportableSeverity()));
        /* 
        在上下文中,static_cast<int>将某个值转换为int类型,以便在后续代码中使用整数值表示日志级别。
        getReportableSeverity是一个函数或方法,用于获取日志记录器(logger)的报告级别(severity)。
以便在日志记录中确定要报告的消息的严重程度。
        */

int8时设置动态范围

cpp 复制代码
// 设置所有动态范围为指定值
samplesCommon::setAllDynamicRanges(network.get(), 127.0F, 127.0F);
// 调用samplesCommon::setAllDynamicRanges(network.get(), 127.0F, 127.0F)来设置所有动态范围为指定值127.0,
// 这是因为在INT8精度下需要设置动态范围。
/*
在INT8量化过程中,将浮点数转换为整数时需要考虑数值的范围。
动态范围指的是输入数据的范围,即数据的最大值和最小值。
在INT8精度下,数据被量化为8位整数,因此需要将浮点数映射到整数范囋内。
设置动态范围可以帮助模型在INT8精度下正确地量化和推理,确保模型的准确性和性能。
*/

int8_caffe如何构建神经网络constructNetwork()

解析caffe模型文件,将blob名称映射到张量
cpp 复制代码
    // 解析caffe模型文件,将blob名称映射到张量
    const nvcaffeparser1::IBlobNameToTensor* blobNameToTensor
        = parser->parse(
            locateFile(
                mParams.prototxtFileName, 
                mParams.dataDirs).c_str(),
            locateFile(
                mParams.weightsFileName,
                mParams.dataDirs).c_str(),
            *network,
            dataType == DataType::kINT8 ? DataType::kFLOAT : dataType);
    // 循环遍历输出张量的名称(mParams.outputTensorNames),
    // 将每个输出张量标记为网络的输出。
    // 这样做的目的是将caffe模型文件中定义的输出层映射到
    // TensorRT网络中的输出张量,
    // 以便在推理过程中正确获取模型的输出结果。
    for (auto& s : mParams.outputTensorNames)
    {
        network->markOutput(*blobNameToTensor->find(s.c_str()));
    }

这段代码用于解析caffe模型文件并将blob名称映射到张量。

首先,使用caffe解析器nvcaffeparser1的parse方法解析模型文件,将模型文件的prototxt文件和权重文件转换为张量。

然后,通过循环遍历输出张量的名称(mParams.outputTensorNames),将这些张量标记为网络的输出。这样可以将caffe模型文件中定义的输出层映射到TensorRT网络中的输出张量。

校准器
cpp 复制代码
    // 创建一个独立的Int8校准器对象
    std::unique_ptr<IInt8Calibrator> calibrator;
    
    ...
    
    if (dataType == DataType::kINT8)
    {
        // 创建一个名为calibrationStream的MNISTBatchStream对象,
        // 用于校准INT8模型。
        // 这个对象会读取MNIST数据集的图像和标签数据进行校准。
        MNISTBatchStream calibrationStream(
            mParams.calBatchSize,
            mParams.nbCalBatches, 
            "train-images-idx3-ubyte", // 是指MNIST数据集中包含图像数据的文件。
            "train-labels-idx1-ubyte", // 是指MNIST数据集中包含标签数据的文件。
            mParams.dataDirs);
        // 重置 calibrator 智能指针,
        // 指向一个新的 Int8EntropyCalibrator2 对象 熵校准器
        // 这个对象会根据提供的数据流和其他参数进行INT8模型的校准。
        calibrator.reset(
            new Int8EntropyCalibrator2<MNISTBatchStream>(
                calibrationStream, 
                0, 
                mParams.networkName.c_str(), 
                mParams.inputTensorNames[0].c_str()
                )
            );
        // 将 calibrator 设置为构建器配置的 INT8 校准器
        config->setInt8Calibrator(calibrator.get());
    }
  • 校准器的目的是为了量化模型中的权重和激活值,将它们转换为INT8格式,以便在推理时减少计算量并提高性能。

  • 校准器通常会统计模型在校准数据上的表现,然后根据这些统计信息对模型进行量化,以确保在推理阶段的准确性和性能。

校准器的工作原理基于对模型权重和激活值的量化,以及根据校准数据集的统计信息来调整模型,使其在INT8精度下表现良好。这样可以在保持准确性的同时,提高模型的推理速度和效率。

这里可以看到是使用 熵校准器(Entropy Calibrator)的原理是基于信息熵的概念,用于量化模型中的权重和激活值,以便在推理阶段使用更低的精度(如INT8)来加速推理过程。熵校准器的主要步骤包括: 1. 数据流向:

  • calibrationStream提供了用于校准的数据流,其中包含了用于量化模型的图像数据。

  • 参数0通常用于指定校准器的模式或配置,具体含义取决于校准器的实现。

  • mParams.networkName.c_str()表示网络的名称,用于标识要校准的网络。

  • mParams.inputTensorNames[0].c_str()表示输入张量的名称,用于指定要校准的输入张量。

processinput()

因为hello_world_onnx是做的mnist,而int_8_caffe做的是resnet50

hello_world_onnx有更多需要对输入进行处理的部分,比如生成随机数作为文件名的一部分,读取对应的PGM图像文件,并将图像数据存储在fileData中供后续处理使用以及打印ASCII表示等。

这里int8_caffe多了一个输入数据存储到hostDataBuffer的步骤

cpp 复制代码
// 使用std::memcpy函数将输入数据复制到主机数据缓冲区中
std::memcpy(hostDataBuffer,
             data, 
             mParams.batchSize * samplesCommon::volume(mInputDims) * sizeof(float)
             );
/* 
这段代码使用std::memcpy函数将输入数据从指针data复制到主机数据缓冲区hostDataBuffer中。
具体来说,它将data中的数据复制到hostDataBuffer中,
复制的字节数由mParams.batchSize * samplesCommon::volume(mInputDims) * sizeof(float)确定。
mParams.batchSize: 表示批量大小,即要处理的数据批次的数量。
samplesCommon::volume(mInputDims): 表示输入数据的维度大小,即数据的总元素个数。
sizeof(float): 表示每个元素的大小,这里是float类型。
将这三个值相乘得到的结果就是要复制的数据量,即要从data指针复制到hostDataBuffer中的数据的总字节数。
*/

帮助信息

cpp 复制代码
// 设置批量大小(默认值 = 32)
std::cout << "batch=N         设置批量大小(默认值 = 32)" << std::endl;
// 设置要评分的第一个批次(默认值 = 16) 
std::cout << "start=N         设置要评分的第一个批次(默认值 = 16)。所有在此批次之前的批次将用于校准。" << std::endl;
// 设置要评分的批次数量(默认值 = 1800)
std::cout << "score=N         设置要评分的批次数量(默认值 = 1800)" << std::endl;
相关推荐
林的快手17 分钟前
209.长度最小的子数组
java·数据结构·数据库·python·算法·leetcode
千天夜26 分钟前
多源多点路径规划:基于启发式动态生成树算法的实现
算法·机器学习·动态规划
从以前31 分钟前
准备考试:解决大学入学考试问题
数据结构·python·算法
.Vcoistnt1 小时前
Codeforces Round 994 (Div. 2)(A-D)
数据结构·c++·算法·贪心算法·动态规划
ALISHENGYA1 小时前
全国青少年信息学奥林匹克竞赛(信奥赛)备考实战之分支结构(实战训练三)
数据结构·c++·算法·图论
我码玄黄3 小时前
正则表达式优化之算法和效率优化
前端·javascript·算法·正则表达式
Solitudefire4 小时前
蓝桥杯刷题——day9
算法·蓝桥杯
三万棵雪松5 小时前
1.系统学习-线性回归
算法·机器学习·回归·线性回归·监督学习
Easy数模5 小时前
基于LR/GNB/SVM/KNN/DT算法的鸢尾花分类和K-Means算法的聚类分析
算法·机器学习·支持向量机·分类·聚类
2401_858286115 小时前
117.【C语言】数据结构之排序(选择排序)
c语言·开发语言·数据结构·笔记·算法·排序算法