onnx文件及其结构、正确导出onnx、onnx解析器

onnx基本概念

1、ONNX的本质,是一种Protobuf格式文件,ONNX文件其实就是Protobuf序列化过后储存的东西。其定义是protobuf语法,语法类似json。
2、Protobuf则通过onnx-ml.proto编译得到onnx-ml.pb.h和onnx-ml.pb.cc或onnx_ml_pb2.py
3、然后用onnx-ml.pb.cc和代码来操作onnx模型文件,实现增删改
4、onnx-ml.proto这个文件则是描述onnx文件如何组成的,具有什么结构,他是操作onnx经常参照的东西。通过onnx-ml.proto这个文件交给protoc,protoc是Protobuf提供的编译程序,把它编译成对应的cc和py文件,拿到这两个文件就可以直接操作onnx,实现各种增删改。
onnx文件及其结构、正确导出onnx、onnx解析器_第1张图片

onnx-ml.proto

打开onnx-ml.proto这个文件,里面的内容就如下图所示。那我们怎么去读懂这个文件呢?不要被这个东西吓到了。下图的NodeProto就表示表示onnx中有节点类型叫node。NodeProto里面有input,就表示node有input属性,是repeated,即重复类型,是数组,repeated就表示数组。他有output属性,是repeated,即重复类型,也是数组。他有name属性是string类型

对于repeated是数组,对于optional无视他。对于input = 1,后面的数字是id,其实每个属性都有特定的id,这些id不能冲突,所以我们不用关心id是怎么来的。我们只关心是否数组,类型是什么。

onnx文件及其结构、正确导出onnx、onnx解析器_第2张图片

ONNX结构

ONNX的opset_import表示算子库版本。打开ONNX模型,我们发现里面是这么个结构。ONNX下面是个model,model底下会挂一个graph,graph底下会挂node,initializer,input,output这些节点,但不是只有这些节点,这些只是主要结构。

  • node就表示算子,比如conv,relu,linear这些,都在node里面储存。每一个node里面也有inputoutput,这里的input和output是表示节点的输入和输出,不要把这个input和output和下面的搞混了,下面表示的是整个模型的输入和输出!node里还有nameop_type,name表示节点名字,可以随便取。但op_type对应的是算子类型,不能随便乱改。node的attribute里储存的就是算子的padding,kernel shape,stride等。
  • initializer储存的是weight,比如conv算子的weight和bias就是在initializer里储存的。
  • input是来标记这个onnx哪些节点是input,即输入是什么,会有对应的名称,shape,数据类型,是表示整个网络的输入!
  • output就用来说明这个onnx的输出是哪些,是表示整个网络的输出!

model:表示整个onnx的模型,包含图结构和解析器格式、opset版本、导出程序类型,这里的model是指 model=onnx.load(“demo.onnx”)。
model.graph:表示图结构,通常是我们netron看到的主要结构
model.graph.node:表示图中的所有节点,数组,例如conv、bn等节点就是在这里的,通过input、output表示节点之间的连接关系
model.graph.initializer:权重类的数据大都储存在这里
model.graph.input:整个模型的输入储存在这里,表明哪个节点是输入节点,shape是多少
model.graph.output:整个模型的输出储存在这里,表明哪个节点是输出节点,shape是多少
对于anchorgrid类的常量数据,通常会储存在model.graph.node中,并指定类型为Constant,该类型节点在netron中可视化时不会显示出来

onnx文件及其结构、正确导出onnx、onnx解析器_第3张图片

如何正确导出onnx

  1. 对于任何用到shape、size返回值的参数时,例如:tensor.view(tensor.size(0), -1)这类操作,避免直接使用tensor.size的返回值,而是加上int进行强制转换,tensor.view(int(tensor.size(0)), -1),断开跟踪trace。
  2. 对于nn.Upsample或nn.functional.interpolate函数,使用scale_factor指定倍率,而不是使用size参数指定大小。
  3. 对于reshape、view操作时,-1的指定请放到batch维度。其他维度可以计算出来即可。batch维度禁止指定为大于-1的明确数字。
  4. torch.onnx.export指定dynamic_axes参数,并且只指定batch维度,禁止其他动态,这个禁止是指最好不要。
  5. 使用opset_version=11,当然只要不低于11问题都不大。
  6. 避免使用inplace操作,例如y[…, 0:2] = y[…, 0:2] * 2 - 0.5,inplace运算操作会直接在旧内存上进行更改,而并不会另外开辟一个新内存进行运算结果的存放。因为inplace操作会带来一个scatter的算子,这个算子是不被支持的,并且也还会带来很多节点。
  7. 尽量少的出现5个维度,例如ShuffleNet Module,可以考虑合并wh避免出现5维。
  8. 尽量把让后处理部分在onnx模型中实现,降低后处理复杂度。所以在导出成onnx模型时,我们一般会再自己重写一个精简版的mypredict.py文件,把前处理,模型推理,后处理都拎出来使得推理的代码看着非常简洁,这样首先方便我们将后处理放到模型里面去,再导出onnx。然后就是当onnx导出成功后,通过这个mypredict.py这个精简版代码让我们清楚整个推理流程后,接着下一步利用tensorRT把onnx编译生成engine,然后在C++上进行推理,这里的推理指的是:前处理+模型推理(模型推理是指生成的engine交给tensorRT去做前向传播)+后处理,所以前处理和后处理是和tensorRT无关的。这时C++的前后处理代码就可以直接仿照着mypredict.py这个python版本再写一遍就好了,就会方便了许多。比如说yolov5的后处理中,要借助anchor要做一些乘加的操作,如果我们单独分开在后处理中去做的话,你就会发现你既要准备一个模型,还得专门储存这个模型的anchor的信息,这样代码的复杂度就很高,后处理的逻辑就会非常麻烦。所以把后处理的逻辑尽量得放在模型里面,使得它的tensor很简单通过decode就能实现。然后自己做的后处理性能可能还不够高,如果放到onnx里,tensorRT顺便还能帮你加速一下。很多时候我们onnx已经导出来了,这个时候我还想去实现onnx后处理的增加,这个时候该怎么做呢? 有两种做法,一种是直接用onnx这个包去操作onnx文件,去增加一些节点是没有问题的,但这个难度系数比较高。第二种做法是可以用pytorch去实现后处理逻辑的代码,把这个后处理专门导出一个onnx,然后再把这个onnx合并到原来的onnx上,这也是实际上我们针对复杂任务专门定制的一个做法。
  9. 掌握了这些,就可以保证后面各种情况的顺利了

这些做法的必要性体现在,简化过程的复杂度,去掉一些不必要的节点,比如gather、shape类的节点,这些节点去除了适应性比较好,因为毕竟大家对这种东西的支持都不太好。很多时候,部分不这么改看似也是可以但是需求复杂后,依旧存在各类问题。按照说的这么修改,基本总能成。做了这些,就不需要使用onnx-simplifer了

pytorch.onnx.export使用示例,用了一个dummy_input作为模型的输入,通过变量名可以发现这是表示假输入,所以dummy_input的值可以随便设置,只要shape满足就行
pytorch.onnx.export方法参数详解

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.onnx
import os

class Model(torch.nn.Module):
    def __init__(self):
        super().__init__()

        self.conv = nn.Conv2d(1, 1, 3, padding=1)
        self.relu = nn.ReLU()
        self.conv.weight.data.fill_(1)
        self.conv.bias.data.fill_(0)
    
    def forward(self, x):
        x = self.conv(x)
        x = self.relu(x)
        return x

# 这个包对应opset11的导出代码,如果想修改导出的细节,可以在这里修改代码
# import torch.onnx.symbolic_opset11
print("对应opset文件夹代码在这里:", os.path.dirname(torch.onnx.__file__))

model = Model()
dummy = torch.zeros(1, 1, 3, 3)
torch.onnx.export(
    model, 

    # 这里的args,是指输入给model的参数,需要传递tuple,因此用括号
    (dummy,), 

    # 储存的文件路径
    "demo.onnx", 

    # 打印详细信息
    verbose=True, 

    # 为输入和输出节点指定名称,方便后面查看或者操作
    input_names=["image"], 
    output_names=["output"], 

    # 这里的opset,指,各类算子以何种方式导出,对应于symbolic_opset11
    opset_version=11, 

    # 表示他有batch、height、width3个维度是动态的,在onnx中给其赋值为-1
    # 通常,我们只设置batch为动态,其他的避免动态
    dynamic_axes={
        "image": {0: "batch", 2: "height", 3: "width"},
        "output": {0: "batch", 2: "height", 3: "width"},
    }
)

print("Done.!")

ONNX解析器

onnx解析器用处就是通过解析onnx文件生成对应tensorRT能看懂的模型(即转换为tensorRT的C++接口定义的模型)。

onnx解析器有两个选项,libnvonnxparser.so或者https://github.com/onnx/onnx-tensorrt(源代码)。使用源代码的目的,是为了更好的进行自定义封装,简化插件开发或者模型编译的过程,更加具有定制化,遇到问题可以调试。

你可能感兴趣的:(模型部署,算法,人工智能,onnx)