• 【模型部署 01】C++实现GoogLeNet在OpenCV DNN、ONNXRuntime、TensorRT、OpenVINO上的推理部署


     


    深度学习领域常用的基于CPU/GPU的推理方式有OpenCV DNN、ONNXRuntime、TensorRT以及OpenVINO。这几种方式的推理过程可以统一用下图来概述。整体可分为模型初始化部分和推理部分,后者包括步骤2-5。

    以GoogLeNet模型为例,测得几种推理方式在推理部分的耗时如下:

    结论:

    1. GPU加速首选TensorRT;
    2. CPU加速,单图推理首选OpenVINO,多图并行推理可选择ONNXRuntime;
    3. 如果需要兼具CPU和GPU推理功能,可选择ONNXRuntime。

    下一篇内容:【模型部署 02】Python实现GoogLeNet在OpenCV DNN、ONNXRuntime、TensorRT、OpenVINO上的推理部署

    1. 环境配置

    1.1 OpenCV DNN

      【模型部署】OpenCV4.6.0+CUDA11.1+VS2019环境配置

    1.2 ONNXRuntime

      【模型部署】在C++和Python中配置ONNXRuntime环境

    1.3 TensorRT

      【模型部署】在C++和Python中搭建TensorRT环境 

    1.4 OpenVINO2022

      【模型部署】在C++和Python中配置OpenVINO2022环境

    2. PyTorch模型文件(pt/pth/pkl)转ONNX

    2.1 pt/pth/pkl互转

    PyTorch中支持导出三种后缀格式的模型文件:pt、pth和pkl,这三种格式在存储方式上并无区别,只是后缀不同。三种格式之间的转换比较简单,只需要创建模型并加载模型参数,然后再保存为其他格式即可。

    以pth转pt为例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    import torch
    import torchvision
     
    # 构建模型
    model = torchvision.models.googlenet(num_classes=2, init_weights=True)
    # 加载模型参数,pt/pth/pkl三种格式均可
    model.load_state_dict(torch.load("googlenet_catdog.pth"))
    model.eval()
    # 重新保存为所需要转换的格式
    torch.save(model.state_dict(), 'googlenet_catdog.pt')

    2.2 pt/pth/pkl转ONNX

    PyTorch中提供了现成的函数torch.onnx.export(),可将模型文件转换成onnx格式。该函数原型如下:

    1
    2
    3
    4
    5
    export(model, args, f, export_params=True, verbose=False, training=TrainingMode.EVAL,
               input_names=None, output_names=None, operator_export_type=None,
               opset_version=None, do_constant_folding=True, dynamic_axes=None,
               keep_initializers_as_inputs=None, custom_opsets=None,
               export_modules_as_functions=False)

    主要参数含义:

    • model (torch.nn.Module, torch.jit.ScriptModule or torch.jit.ScriptFunction:需要转换的模型。
    • args (tuple or torch.Tensor) :args可以被设置为三种形式:
      • 一个tuple,这个tuple应该与模型的输入相对应,任何非Tensor的输入都会被硬编码入onnx模型,所有Tensor类型的参数会被当做onnx模型的输入。
        1
        args = (x, y, z)
      • 一个Tensor,一般这种情况下模型只有一个输入。
        1
        args = torch.Tensor([1, 2, 3])
      • 一个带有字典的tuple,这种情况下,所有字典之前的参数会被当做“非关键字”参数传入网络,字典中的键值对会被当做关键字参数传入网络。如果网络中的关键字参数未出现在此字典中,将会使用默认值,如果没有设定默认值,则会被指定为None。
        1
        2
        3
        args = (x,
                {'y': input_y,
                 'z': input_z})

        NOTE:一个特殊情况,当网络本身最后一个参数为字典时,直接在tuple最后写一个字典则会被误认为关键字传参。所以,可以通过在tuple最后添加一个空字典来解决。

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        # 错误写法:
        torch.onnx.export(
            model,
            (x,
             # WRONG: will be interpreted as named arguments
             {y: z}),
            "test.onnx.pb")
          
        # 纠正
        torch.onnx.export(
            model,
            (x,
             {y: z},
             {}),
            "test.onnx.pb"
    • f:一个文件类对象或一个路径字符串,二进制的protocol buffer将被写入此文件,即onnx文件。
    • export_params (bool, default False) :如果为True则导出模型的参数。如果想导出一个未训练的模型,则设为False。
    • verbose (bool, default False) :如果为True,则打印一些转换日志,并且onnx模型中会包含doc_string信息。
    • training (enum, default TrainingMode.EVAL) :枚举类型包括:
      • TrainingMode.EVAL - 以推理模式导出模型。
      • TrainingMode.PRESERVE - 如果model.training为False,则以推理模式导出;否则以训练模式导出。
      • TrainingMode.TRAINING - 以训练模式导出,此模式将禁止一些影响训练的优化操作。
    • input_names (list of str, default empty list) :按顺序分配给onnx图的输入节点的名称列表。
    • output_names (list of str, default empty list) :按顺序分配给onnx图的输出节点的名称列表。
    • operator_export_type (enum, default None) :默认为OperatorExportTypes.ONNX, 如果Pytorch built with DPYTORCH_ONNX_CAFFE2_BUNDLE,则默认为OperatorExportTypes.ONNX_ATEN_FALLBACK。枚举类型包括:
      • OperatorExportTypes.ONNX - 将所有操作导出为ONNX操作。
      • OperatorExportTypes.ONNX_FALLTHROUGH - 试图将所有操作导出为ONNX操作,但碰到无法转换的操作(如onnx未实现的操作),则将操作导出为“自定义操作”,为了使导出的模型可用,运行时必须支持这些自定义操作。支持自定义操作方法见链接
      • OperatorExportTypes.ONNX_ATEN - 所有ATen操作导出为ATen操作,ATen是Pytorch的内建tensor库,所以这将使得模型直接使用Pytorch实现。(此方法转换的模型只能被Caffe2直接使用)
      • OperatorExportTypes.ONNX_ATEN_FALLBACK - 试图将所有的ATen操作也转换为ONNX操作,如果无法转换则转换为ATen操作(此方法转换的模型只能被Caffe2直接使用)。例如:
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        # 转换前:
        graph(%0 : Float):
          %3 : int = prim::Constant[value=0]()
          # conversion unsupported
          %4 : Float = aten::triu(%0, %3)
          # conversion supported
          %5 : Float = aten::mul(%4, %0)
          return (%5)
         
         
        # 转换后:
        graph(%0 : Float):
          %1 : Long() = onnx::Constant[value={0}]()
          # not converted
          %2 : Float = aten::ATen[operator="triu"](%0, %1)
          # converted
          %3 : Float = onnx::Mul(%2, %0)
          return (%3)
    • opset_version (int, default 9) :取值必须等于_onnx_main_opset或在_onnx_stable_opsets之内。具体可在torch/onnx/symbolic_helper.py中找到。例如:
      1
      2
      3
      4
      _default_onnx_opset_version = 9
      _onnx_main_opset = 13
      _onnx_stable_opsets = [7, 8, 9, 10, 11, 12]
      _export_onnx_opset_version = _default_onnx_opset_version
    • do_constant_folding (bool, default False) :是否使用“常量折叠”优化。常量折叠将使用一些算好的常量来优化一些输入全为常量的节点。
    • example_outputs (T or a tuple of T, where T is Tensor or convertible to Tensor, default None) :当需输入模型为ScriptModule 或 ScriptFunction时必须提供。此参数用于确定输出的类型和形状,而不跟踪(tracing)模型的执行。
    • dynamic_axes (dict> or dict, default empty dict) :通过以下规则设置动态的维度:
      • KEY(str) - 必须是input_names或output_names指定的名称,用来指定哪个变量需要使用到动态尺寸。
      • VALUE(dict or list) - 如果是一个dict,dict中的key是变量的某个维度,dict中的value是我们给这个维度取的名称。如果是一个list,则list中的元素都表示此变量的某个维度。

    代码实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    import torch
    import torchvision
     
    weight_file = 'googlenet_catdog.pt'
    onnx_file = 'googlenet_catdog.onnx'
     
    model = torchvision.models.googlenet(num_classes=2, init_weights=True)
    model.load_state_dict(torch.load(weight_file, map_location=torch.device('cpu')))
     
    model.eval()
     
    # 单输入单输出,固定batch
    input = torch.randn(1, 3, 224, 224)
    input_names = ["input"]
    output_names = ["output"]
    torch.onnx.export(model=model,
                      args=input,
                      f=onnx_file,
                      input_names=input_names,
                      output_names=output_names,
                      opset_version=11,
                      verbose=True)

    通过netron.app可视化onnx的输入输出: 

    如果需要多张图片同时进行推理,可以通过设置export的dynamic_axes参数,将模型输入输出的指定维度设置为变量。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    import torch
    import torchvision
     
    weight_file = 'googlenet_catdog.pt'
    onne_file = 'googlenet_catdog.onnx'
     
    model = torchvision.models.googlenet(num_classes=2, init_weights=True)
    model.load_state_dict(torch.load(weight_file, map_location=torch.device('cpu')))
     
    model.eval()
     
    # 单输入单输出,动态batch
    input = torch.randn(1, 3, 224, 224)
    input_names = ["input"]
    output_names = ["output"]
    torch.onnx.export(model=model,
                      args=input,
                      f=onnx_file,
                      input_names=input_names,
                      output_names=output_names,
                      opset_version=11,
                      verbose=True,
                      dynamic_axes={'input': {0: 'batch'}, 'output': {0: 'batch'}})

    动态batch的onnx文件输入输出在netron.app可视化如下,其中batch维度是变量的形式,可以根据自己需要设置为大于0的任意整数。

    如果模型有多个输入和输出,按照以下形式导出:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    # 模型有两个输入和两个输出,动态batch
    input1 = torch.randn(1, 3, 256, 192).to(opt.device)
    input2 = torch.randn(1, 3, 256, 192).to(opt.device)
    input_names = ["input1", "input2"]
    output_names = ["output1", "output2"]
    torch.onnx.export(model=model,
                      args=(input1, input2),
                      f=opt.onnx_path,
                      input_names=input_names,
                      output_names=output_names,
                      opset_version=16,
                      verbose=True,
                      dynamic_axes={'input1': {0: 'batch'},
                                    'input2': {0: 'batch'},
                                    'output1': {0: 'batch'},
                                    'output2': {0: 'batch'}})

    3. OpenCV DNN部署GoogLeNet

    3.1 推理过程及代码实现

    整个推理过程可分为前处理、推理、后处理三部分。具体细节请阅读代码,包括单图推理、动态batch推理的实现。

    3.2 选择CPU/GPU

    OpenCV DNN切换CPU和GPU推理,只需要通过下边两行代码设置计算后台和计算设备。

    CPU推理

    1
    2
    net.setPreferableBackend(cv::dnn::DNN_BACKEND_OPENCV);
    net.setPreferableTarget(cv::dnn::DNN_TARGET_CPU);

    GPU推理

    1
    2
    net.setPreferableBackend(cv::dnn::DNN_BACKEND_CUDA);
    net.setPreferableTarget(cv::dnn::DNN_TARGET_CUDA); 

    以下两点需要注意:

    • 在不做任何设置的情况下,默认使用CPU进行推理。
    • 在设置为GPU推理时,如果电脑没有搜索到CUDA环境,则会自动转换成CPU进行推理。

    3.3 多输出模型推理

    当模型有多个输出时,使用forward的重载方法,返回Mat类型的数组:

    1
    2
    3
    4
    5
    6
    // 模型多输出
    std::vector preds;
    net.forward(preds);
     
    cv::Mat pred1 = preds[0];
    cv::Mat pred2 = preds[1];

    4. ONNXRuntime部署GoogLeNet

    4.1 推理过程及代码实现

    代码:

    注意:ORT支持多图并行推理,但是要求转出onnx的时候batch就要使用固定数值。动态batch(即batch=-1)的onnx文件是不支持推理的。

    4.2 选择CPU/GPU

    使用GPU推理,只需要添加一行代码:

    1
    2
    3
    4
    if (useCuda) {
        // 开启CUDA加速
        OrtSessionOptionsAppendExecutionProvider_CUDA(*sessionOptions, deviceId);
    } 

    4.3 多输入多输出模型推理

    推理步骤和单图推理基本一致,需要在输入tensor中依次添加所有的输入。假设模型有两个输入和两个输出:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    // 创建session
    session2 = new Ort::Session(env1, onnxPath, sessionOptions1);
     
    // 获取模型输入输出信息
    inputCount2 = session2->GetInputCount();
    outputCount2 = session2->GetOutputCount();
     
    // 输入和输出各有两个
    Ort::AllocatorWithDefaultOptions allocator;
    const char* inputName1 = session2->GetInputName(0, allocator);
    const char* inputName2 = session2->GetInputName(1, allocator);
    const char* outputName1 = session2->GetOutputName(0, allocator);
    const char* outputName2 = session2->GetOutputName(1, allocator);
    intputNames2 = { inputName1, inputName2 };
    outputNames2 = { outputName1, outputName2 };
     
    // 获取输入输出维度信息,返回类型std::vector
    inputShape2_1 = session2->GetInputTypeInfo(0).GetTensorTypeAndShapeInfo().GetShape();
    inputShape2_2 = session2->GetInputTypeInfo(1).GetTensorTypeAndShapeInfo().GetShape();
    outputShape2_1 = session2->GetOutputTypeInfo(0).GetTensorTypeAndShapeInfo().GetShape();
    outputShape2_2 = session2->GetOutputTypeInfo(1).GetTensorTypeAndShapeInfo().GetShape();
     
    ...
     
    // 创建输入tensor
    auto memoryInfo = Ort::MemoryInfo::CreateCpu(OrtAllocatorType::OrtArenaAllocator, OrtMemType::OrtMemTypeDefault);
    std::vector inputTensors;
    inputTensors.emplace_back(Ort::Value::CreateTensor<float>(memoryInfo,
        blob1.ptr<float>(), blob1.total(), inputShape2_1.data(), inputShape2_1.size()));
    inputTensors.emplace_back(Ort::Value::CreateTensor<float>(memoryInfo,
        blob2.ptr<float>(), blob2.total(), inputShape2_2.data(), inputShape2_2.size()));
         
    // 推理
    auto outputTensors = session2->Run(Ort::RunOptions{ nullptr },
        intputNames2.data(), inputTensors.data(), inputCount2, outputNames2.data(), outputCount2);
     
    // 获取输出
    float* preds1 = outputTensors[0].GetTensorMutableData<float>();
    float* preds2 = outputTensors[1].GetTensorMutableData<float>();

    5. TensorRT部署GoogLeNet

    TRT推理有两种常见的方式:

    1. 通过官方安装包里边的提供的trtexec.exe工具,从onnx文件转换得到trt文件,然后执行推理;
    2. 由onnx文件转化得到engine文件,再执行推理。

    两种方式原理一样,这里我们只介绍第二种方式。推理过程可分为两阶段:使用onnx构建推理engine和加载engine执行推理。

    5.1 构建推理引擎(engine文件) 

    engine的构建是TensorRT推理至关重要的一步,它特定于所构建的确切GPU模型,不能跨平台或TensorRT版本移植。举个简单的例子,如果你在RTX3060上使用TensorRT 8.2.5构建了engine,那么推理部署也必须要在RTX3060上进行,且要具备TensorRT 8.2.5环境。engine构建的大致流程如下:

    engine的构建有很多种方式,这里我们介绍常用的三种。我一般会选择直接在Python中构建,这样模型的训练、转onnx、转engine都在Python端完成,方便且省事。

    方法一:在Python中构建

    生成fp16模型:参数precision设置为fp16即可。int8模型生成过程比较复杂,且对模型精度影响较大,用的不多,这里暂不介绍。

    1
    2
    parser.add_argument("-p", "--precision", default="fp16", choices=["fp32", "fp16", "int8"],
                            help="The precision mode to build in, either 'fp32', 'fp16' or 'int8', default: 'fp16'")

    方法二:在C++中构建

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    #include "NvInfer.h"
    #include "NvOnnxParser.h"
    #include "cuda_runtime_api.h"
    #include "logging.h"
    #include
    #include
    #include
    #include
    #include
    #include
     
    using namespace nvinfer1;
    using namespace nvonnxparser;
    using namespace std;
    using namespace cv;
     
    std::string onnxPath = "E:/inference-master/models/engine/googlenet-pretrained_batch.onnx";
    std::string enginePath = "E:/inference-master/models/engine/googlenet-pretrained_batch_from_cpp.engine"// 通过C++构建
     
    static const int INPUT_H = 224;
    static const int INPUT_W = 224;
    static const int OUTPUT_SIZE = 1000;
     
    static const int BATCH_SIZE = 25;
     
    const char* INPUT_BLOB_NAME = "input";
    const char* OUTPUT_BLOB_NAME = "output";
     
    static Logger gLogger;
     
    // onnx转engine
    void onnx_to_engine(std::string onnx_file_path, std::string engine_file_path, int type) {
     
        // 创建builder实例,获取cuda内核目录以获取最快的实现,用于创建config、network、engine的其他对象的核心类
        nvinfer1::IBuilder* builder = nvinfer1::createInferBuilder(gLogger);
        const auto explicitBatch = 1U << static_cast(nvinfer1::NetworkDefinitionCreationFlag::kEXPLICIT_BATCH);
     
        // 创建网络定义
        nvinfer1::INetworkDefinition* network = builder->createNetworkV2(explicitBatch);
     
        // 创建onnx解析器来填充网络
        nvonnxparser::IParser* parser = nvonnxparser::createParser(*network, gLogger);
     
        // 读取onnx模型文件
        parser->parseFromFile(onnx_file_path.c_str(), 2);
        for (int i = 0; i < parser->getNbErrors(); ++i) {
            std::cout << "load error: " << parser->getError(i)->desc() << std::endl;
        }
        printf("tensorRT load mask onnx model successfully!!!...\n");
     
        // 创建生成器配置对象
        nvinfer1::IBuilderConfig* config = builder->createBuilderConfig();
        
        builder->setMaxBatchSize(BATCH_SIZE);           // 设置最大batch
        config->setMaxWorkspaceSize(16 * (1 << 20));    // 设置最大工作空间大小
     
        // 设置模型输出精度,0代表FP32,1代表FP16
        if (type == 1) {
            config->setFlag(nvinfer1::BuilderFlag::kFP16);
        }
        // 创建推理引擎
        nvinfer1::ICudaEngine* engine = builder->buildEngineWithConfig(*network, *config);
     
        // 将推理引擎保存到本地
        std::cout << "try to save engine file now~~~" << std::endl;
        std::ofstream file_ptr(engine_file_path, std::ios::binary);
        if (!file_ptr) {
            std::cerr << "could not open plan output file" << std::endl;
            return;
        }
        // 将模型转化为文件流数据
        nvinfer1::IHostMemory* model_stream = engine->serialize();
        // 将文件保存到本地
        file_ptr.write(reinterpret_cast<const char*>(model_stream->data()), model_stream->size());
        // 销毁创建的对象
        model_stream->destroy();
        engine->destroy();
        network->destroy();
        parser->destroy();
        std::cout << "convert onnx model to TensorRT engine model successfully!" << std::endl;
    }
     
    int main(int argc, char** argv)
    {
        // onnx转engine
        onnx_to_engine(onnxPath, enginePath, 0);
         
        return 0;
    } 

    方法三:使用官方安装包bin目录下的trtexec.exe工具构建

    1
    trtexec.exe --onnx=googlenet-pretrained_batch.onnx --saveEngine=googlenet-pretrained_batch_from_trt_trt853.engine --shapes=input:25x3x224x224

    fp16模型:在后边加--fp16即可

    1
    trtexec.exe --onnx=googlenet-pretrained_batch.onnx --saveEngine=googlenet-pretrained_batch_from_trt_trt853.engine --shapes=input:25x3x224x224 --fp16 

    5.2 读取engine文件并部署模型

    推理代码:

    5.3 fp32、fp16模型对比测试

    fp16模型推理结果几乎和fp32一致,但是却较大的节约了显存和内存占用,同时推理速度也有明显的提升。

    6. OpenVINO部署GoogLeNet

    6.1 推理过程及代码

    代码:

    注意:OV支持多图并行推理,但是要求转出onnx的时候batch就要使用固定数值。动态batch(即batch=-1)的onnx文件会报错。

    6.2 遇到的问题

    理论:OpenVINO是基于CPU推理最佳的方式。

    实测:在测试OpenVINO的过程中,我们发现OpenVINO推理对于CPU的利用率远没有OpenCV DNN和ONNXRuntime高,这也是随着batch数量增加,OV在CPU上的推理速度反而不如DNN和ORT的主要原因。尝试过网上的多种优化方式,比如设置线程数并发数等等,未取得任何改善。如下图,在OpenVINO推理过程中,始终只有一半的CPU处于活跃状态;而OnnxRuntime或者OpenCV DNN推理时,所有的CPU均处于活跃状态。

    7. 四种推理方式对比测试

    深度学习领域常用的基于CPU/GPU的推理方式有OpenCV DNN、ONNXRuntime、TensorRT以及OpenVINO。这几种方式的推理过程可以统一用下图来概述。整体可分为模型初始化部分和推理部分,后者包括步骤2-5。

    以GoogLeNet模型为例,测得几种推理方式在推理部分的耗时如下:

    基于CPU推理:

    基于GPU推理:

    不论采用何种推理方式,同一网络的前处理和后处理过程基本都是一致的。所以,为了更直观的对比几种推理方式的速度,我们抛去前后处理,只统计图中实际推理部分,即3、4、5这三个过程的执行时间。

    同样是GoogLeNet网络,步骤3-5的执行时间对比如下:

    注:OpenVINO-CPU测试中始终只使用了一半数量的内核,各种优化设置都没有改善。

    最终结论:

    1. GPU加速首选TensorRT;
    2. CPU加速,单图推理首选OpenVINO,多图并行推理可选择ONNXRuntime;
    3. 如果需要兼具CPU和GPU推理功能,可选择ONNXRuntime。

    参考资料

    1. openvino2022版安装配置与C++SDK开发详解

    2. https://github.com/NVIDIA/TensorRT

    3. https://github.com/wang-xinyu/tensorrtx

    4. 【TensorRT】TensorRT 部署Yolov5模型(C++)

  • 相关阅读:
    携职教育:“涉税信息查询结果告知书”如何查询?
    10 个用于网络管理员进行高级扫描的端口扫描工具
    【计算机网络】IP协议第二讲(Mac帧、IP地址、碰撞检测、ARP协议介绍)
    Spring Boot中的JDK 线程池以及Tomcat线程池使用与配置
    利用Bat批处理文件将.resources转换为.resx文件
    基于FPGA的多通道ARINC429总线测试系统
    PC商城开发
    狂神说Es
    nest 第三章 认识nest
    算法高级部分--并查集
  • 原文地址:https://www.cnblogs.com/shaoxx333/p/16630781.html