上海 网站开发 工作室,深圳公司做年报网站,龙岗住房建设局网站,建设银联官方网站ONNX再探
本文转自#xff1a;https://blog.csdn.net/just_sort/article/details/113802330
这篇文章从多个角度探索了ONNX#xff0c;从ONNX的导出到ONNX和Caffe的对比#xff0c;以及使用ONNX遭遇的困难以及一些解决办法#xff0c;另外还介绍了ONNXRuntime以及如何基于…ONNX再探
本文转自https://blog.csdn.net/just_sort/article/details/113802330
这篇文章从多个角度探索了ONNX从ONNX的导出到ONNX和Caffe的对比以及使用ONNX遭遇的困难以及一些解决办法另外还介绍了ONNXRuntime以及如何基于ONNXRuntime来调试ONNX模型等后续也会继续结合ONNX做一些探索性工作。
0x0. 前言
接着上篇文章继续探索ONNX。这一节我将主要从盘点ONNX模型部署有哪些常见问题以及针对这些问题提出一些解决方法另外本文也会简单介绍一个可以快速用于ONNX模型推理验证的框架ONNXRuntime。如果你想用ONNX作为模型转换和部署的工具可以耐心看下去。今天要讲到的ONNX模型部署碰到的问题大多来自于一些关于ONNX模型部署的文章以及自己使用ONNX进行模型部署过程中的一些经历有一定的实践意义。
0x1. 导出ONNX
这里以Pytorch为例来介绍一下要把Pytorch模型导出为ONNX模型需要注意的一些点。首先Pytorch导出ONNX的代码一般是这样
import torch
device torch.device(cuda if torch.cuda.is_available() else cpu)model torch.load(test.pth) # pytorch模型加载
batch_size 1 #批处理大小
input_shape (3, 244, 224) #输入数据,改成自己的输入shape# #set the model to inference mode
model.eval()x torch.randn(batch_size, *input_shape) # 生成张量
x x.to(device)
export_onnx_file test.onnx # 目的ONNX文件名
torch.onnx.export(modelx,export_onnx_file,opset_version10,do_constant_foldingTrue, # 是否执行常量折叠优化input_names[input], # 输入名output_names[output], # 输出名dynamic_axes{input:{0:batch_size}, # 批处理变量output:{0:batch_size}})可以看到Pytorch提供了一个ONNX模型导出的专用接口只需要配置好相关的模型和参数就可以完成自动导出ONNX模型的操作了。代码相关细节请自行查看这里来列举几个导出ONNX模型中应该注意的问题。
自定义OP问题
以2020年的YOLOV5为例在模型的BackBone部分自定义了一个Focus OP这个OP的代码实现为
class Focus(nn.Module):# Focus wh information into c-spacedef __init__(self, c1, c2, k1, s1, pNone, g1, actTrue): # ch_in, ch_out, kernel, stride, padding, groupssuper(Focus, self).__init__()self.conv Conv(c1 * 4, c2, k, s, p, g, act)# self.contract Contract(gain2)def forward(self, x): # x(b,c,w,h) - y(b,4c,w/2,h/2)return self.conv(torch.cat([x[..., ::2, ::2], x[..., 1::2, ::2], x[..., ::2, 1::2], x[..., 1::2, 1::2]], 1))# return self.conv(self.contract(x))这个操作就是一个stride slice然后再concat的操作类似于PixelShuffle的逆向过程。下面是把YOLOv5模型导出ONNX模型之后Focus层的可视化结果。 可以看到这个OP在使用Pytorch导出ONNX的过程中被拆成了很多更小的操作这个时候Focus OP的问题就是推理的效率可能比较低并且拆成的小OP各个推理框架的支持程度不一致。要解决这种问题要么直接在前向推理框架实现一个自定义的Focus OPncnn在实现yolov5的时候也是这样做的https://github.com/Tencent/ncnn/blob/master/examples/yolov5.cpp#L24。要么将这个OP使用其它的操作来近似代替比如这里可以使用一个stride为2的卷积OP来代替Focus结构注意代替之后有可能准确率会下降需要做精度和部署友好性的平衡。
综上自定义的OP在导出ONNX进行部署时除了考虑ONNX模型的执行效率问题还要考虑框架是否支持的问题。如果想快速迭代产品建议尽量以一些经典结构为基础尽量少引入自定义OP。
后处理的问题
如果我们要导出检测网络的ONNX模型进行部署就会碰到这个问题后处理部分是否需要导入到ONNX模型
我们知道在使用Pytorch导出ONNX模型时所有的Aten操作都会被ONNX记录下来具体记录什么内容请参考文章开头链接推文的介绍成为一个DAG。然后ONNX会根据这个DAG的输出节点来反推这个DAG中有哪些节点是有用的这样获得的就是最终的ONNX模型。而后处理比如非极大值抑制也是通过Aten操作拼起来的所谓Aten操作就是Pytorch中的基础算术单元比如加减乘除所有的OP以及和Tensor相关的操作都基于Aten中的操作拼。
检测网络比如YOLOV3的后处理就是NMS代码示例如https://github.com/ultralytics/yolov3/blob/master/utils/general.py#L325。当我们完成检测网络的训练之后直接导出ONNX模型我们就会发现NMS这个实现也全部被导入了ONNX如下图所示 这个结构非常复杂我们要在实际业务中去部署这个模型难度是很大的。另外刚才我们提到ONNX模型只能记录Pytorch中的Aten操作对其它的一些逻辑运算符比如 if 是无能为力的意思是不能记录if的多个子图而后处理过程中根据置信度阈值来筛选目标框是常规操作。如果我们在导出ONNX模型时是随机输入或者没有指定目标的图片就会导致这个ONNX记录下来的DAG可能有缺失。最后每个人实现后处理的方式可能都是不一样的这也增加了ONNX模型部署的难度。为了部署的友好性和降低转换过程中的风险后处理过程最好由读者自己完成我们只需要导出模型的Backbone和Neck部分为ONNX。
具体来说我们只需要在Pytorch的代码实现中屏蔽掉后处理部分然后导出ONNX模型即可。这也是目前使用ONNX部署检测模型的通用方案。
所以针对后处理问题我们的结论就是在使用ONNX进行部署时直接屏蔽后处理将后处理单独拿出来处理。
胶水OP问题
在导出ONNX模型的过程中经常会带来一些胶水OP比如Gather, Shape等等。例如上节推文中介绍到当执行下面的Pytorch导出ONNX程序时就会引入很多胶水OP。
import torchclass JustReshape(torch.nn.Module):def __init__(self):super(JustReshape, self).__init__()def forward(self, x):return x.view((x.shape[0], x.shape[1], x.shape[3], x.shape[2]))net JustReshape()
model_name ../model/just_reshape.onnx
dummy_input torch.randn(2, 3, 4, 5)
torch.onnx.export(net, dummy_input, model_name, input_names[input], output_names[output])导出的ONNX模型可视化如下 这个时候的做法一般就是过一遍onnx-simplifer可以去除这些胶水OP获得一个简化后的模型。 综上我们在导出ONNX模型的一般流程就是去掉后处理尽量不引入自定义OP然后导出ONNX模型并过一遍大老师的https://github.com/daquexian/onnx-simplifier这样就可以获得一个精简的易于部署的ONNX模型。从ONNX官方仓库提供的模型来看似乎微软真的想用ONNX来统一所有框架的所有操作。但理想很丰满现实很骨干各种训练框架的数据排布OP实现不一致人为后处理不一致各种推理框架支持度不一致推理芯片SDK的OP支持度不一致都让这个ONNX万能格式遭遇了困难所以在基于ONNX做一些部署业务的时候也要有清晰的判断并选取风险最小的方法。
0x2. ONNX or Caffe
这个问题其实源于之前做模型转换和基于TensorRT部署一些模型时候的思考。我们还是以Pytorch为例要把Pytorch模型通过TensorRT部署到GPU上一般就是Pytorch-Caffe-TensorRT以及Pytorch-ONNX-TensorRT当然Pytorch也是支持直接转换到TensorRT这里不关心。那么这里就有一个问题我们选择哪一条路比较好
其实我想说的应该是Caffe是过去而ONNX是将来。为什么要这样说
首先很多国产推理芯片比如海思NNIE高通SNPE它们首先支持的都是Caffe这种模型格式这可能是因为年代的原因也有可能是因为这些推理SDK实现的时候OP都非常粗粒度。比如它对卷积做定制的优化有NC4HW4有Im2Colgemm有Winograd等等非常多方法后面还考虑到量化半精度等等然后通过给它喂Caffe模型它就知道要对这个网络里面对应的卷积层进行硬件加速了。所以这些芯片支持的网络是有限的比如我们要在Hisi35xx中部署一个含有upsample层的Pytorch模型是比较麻烦的可能不太聪明的工程说我们要把这个模型回退给训练人员改成支持的上采样方式进行训练而聪明的工程师可能说直接把upsample的参数填到反卷积层的参数就可以了。无论是哪种方式都是比较麻烦的所以Caffe的缺点就是灵活度太差。其实基于Caffe进行部署的方式仍然在工业界发力ONNX是趋势但是ONNX现在还没有完全取代Caffe。
接下来我们要再提一下上面那个 if 的事情了假设现在有一个新的SOTA模型被提出这个模型有一个自定义的OP作者是用Pytorch的Aten操作拼的逻辑大概是这样
result check()
if result 0:result algorithm1(result)
else:result algorithm2(result)
return result然后考虑将这个模型导出ONNX或者转换为Caffe如果是Caffe的话我们需要去实现这个自定义的OP并将其注册到Caffe的OP管理文件中虽然这里比较繁琐但是我们可以将if操作隐藏在这个大的OP内部这个 if 操作可以保留下来。而如果我们通过导出ONNX模型的方式 if 子图只能保留一部分要么保留algorithm1要么保留algorithm2对应的子图这种情况ONNX似乎就没办法处理了。这个时候要么保存两个ONNX模型要么修改算法逻辑绕过这个问题。从这里引申一下如果我们碰到有递归关系的网络基于ONNX应当怎么部署ONNX还有一个缺点就是OP的细粒度太细执行效率低不过ONNX已经推出了多种化方法可以将OP的细粒度变粗提高模型执行效率。目前在众多经典算法上ONNX已经支持得非常好了。
最后越来越多的厂商推出的端侧推理芯片开始支持ONNX比如地平线的BPU华为的Ascend310相关的工具链都开始接入ONNX所以个人认为ONNX是推理框架模型转换的未来不过仍需时间考验毕竟谁也不希望因为框架OP对齐的原因导出一个超级复杂的ONNX模型还是简化不了的那种导致部署难度很大。
0x3. 一些典型的坑点及解决办法
第一节已经提到将我们的ONNX模型过一遍onnx-simplifer之后就可以去掉胶水OP并将一些细粒度的OP进行op fuse成粗粒度的OP并解决一部分由于Pytorch和ONNX OP实现方式不一致而导致模型变复杂的问题。除了这些问题本节再列举一些ONNX模型部署中容易碰到的坑点并尝试给出一些解决办法。
预处理问题
和后处理对应的还有预处理问题如果在Pytorch中使用下面的代码导出ONNX模型。
import torchclass JustReshape(torch.nn.Module):def __init__(self):super(JustReshape, self).__init__()self.mean torch.randn(2, 3, 4, 5)self.std torch.randn(2, 3, 4, 5)def forward(self, x):x (x - self.mean) / self.stdreturn x.view((x.shape[0], x.shape[1], x.shape[3], x.shape[2]))net JustReshape()
model_name ../model/just_reshape.onnx
dummy_input torch.randn(2, 3, 4, 5)
torch.onnx.export(net, dummy_input, model_name, input_names[input], output_names[output])我们先给这个ONNX模型过一遍onnx-simplifer然后使用Netron可视化之后模型大概长这样
如果我们要把这个模型放到NPU上部署如果NPU芯片不支持Sub和Div的量化计算那么这两个操作会被回退到NPU上进行计算这显然是不合理的因为我们总是想网络在NPU上能一镜到底中间断开必定会影响模型效率所以这里的解决办法就是把预处理放在基于nn.Module搭建模型的代码之外然后推理的时候先把预处理做掉即可。
框架OP实现的问题
当从Mxnet转换模型到ONNX时如果模型是带有PReLU OP的在人脸识别网络很常见就是一个大坑了。主要有两个问题当从mxnet转为ONNX时PReLU的slope参数维度可能会导致onnxruntime推理时失败报错大概长这样
2[ONNXRuntimeError] : 6 : RUNTIME_EXCEPTION : Non-zero status code returned while running PRelu node. Name:conv_1_relu...... Attempting to broadcast an axis by a dimension other than 1. 56 by 64 这个错误产生的原因可能是MxNet的版本问题https://github.com/apache/incubator-mxnet/issues/17821这个时候的解决办法就是修改PRelu层的slope参数的shape不仅包括type参数对应的slope值也要修改来和shape对应。
核心代码如下
graph.input.remove(input_map[input_name])
new_nv helper.make_tensor_value_info(input_name, TensorProto.FLOAT, [input_dim_val,1,1])
graph.input.extend([new_nv])想了解更加详细的信息可以参考问后的资料2和资料3。
这个问题其实算是个小问题我们自己在ONNX模型上fix一下即可。下一个问题就是如果我们将处理好之后的ONNX通过TensorRT进行部署的话我们会发现TensorRT不支持PReLU这个OP这个时候解决办法要么是TensorRT自定义PReLU插件但是这种方法会打破TensorRT中convbnrelu的op fusion速度会变慢并且如果要做量化部署似乎是不可行的。所以这个时候一般会采用另外一种解决办法使用relu和scale op来组合成PReLU如下图所示 所以我们在onnx模型中只需要按照这种方法将PReLU节点进行等价替换就可以了。
这个地方以PReLU列举了一个框架OP实现不一致的问题比如大老师最新文章也介绍的就是squeeze OP在Pytorch和ONNX实现时的不一致导致ONNX模型变得很复杂这种问题感觉是基于ONNX支持模型部署时的常见问题虽然onnx-simplifier已经解决了一些问题但也不能够完全解决。
其他问题
当我们使用tf2onnx工具将TensorFlow模型转为ONNX模型时模型的输入batch维度没有被设置我们需要自行添加。解决代码如下
# 为onnx模型增加batch维度def set_model_input_batch(self, index0, nameNone, batch_size4):model_input Noneif name is not None:for ipt in self.model.graph.input:if ipt.name name:model_input iptelse:model_input self.model.graph.input[index]if model_input:tensor_dim model_input.type.tensor_type.shape.dimtensor_dim[0].ClearField(dim_param)tensor_dim[0].dim_value batch_sizeelse:print(get model input failed, check index or name)当我们基于ONNX和TensorRT部署风格迁移模型里面有Instance Norm OP的时候可能会发现结果不准确这个问题在这里被提出https://forums.developer.nvidia.com/t/inference-result-inaccurate-with-conv-and-instancenormalization-under-certain-conditions/111617。经过debug发现这个问题出在这里https://github.com/onnx/onnx-tensorrt/blob/5dca8737851118f6ab8a33ea1f7bcb7c9f06caf5/builtin_op_importers.cpp#L1557。 问题比较明显了instancenorm op里面的eps只支持1e-4的所以要么注释掉这个限制条件要么直接在ONNX模型中修改instancenorm op的eps属性代码实现如下
# 给ONNX模型中的目标节点设置指定属性
# 调用方式为set_node_attribute(in_node, epsilon, 1e-5)
# 其中in_node就是所有的instancenorm op。def set_node_attribute(self, target_node, attr_name, attr_value):flag Falsefor attr in target_node.attribute:if (attr.name attr_name):if attr.type 1:attr.f attr_valueelif attr.type 2:attr.i attr_valueelif attr.type 3:attr.s attr_valueelif attr.type 4:attr.t attr_valueelif attr.type 5:attr.g attr_value# NOTE: For repeated composite types, we should use something like# del attr.xxx[:]# attr.xxx.extend([n1, n2, n3])elif attr.type 6:attr.floats[:] attr_valueelif attr.type 7:attr.ints[:] attr_valueelif attr.type 8:attr.strings[:] attr_valueelse:print(unsupported attribute data type with attribute name)return Falseflag Trueif not flag:# attribute not in original nodeprint(Warning: you are appending a new attribute to the node!)target_node.attribute.append(helper.make_attribute(attr_name, attr_value))flag Truereturn flag当我们使用了Pytorch里面的[]索引操作或者其它需要判断的情况ONNX模型会多出一些if OP这个时候这个if OP的输入已经是一个确定的True因为我们已经介绍过为False那部分的子图会被丢掉。这个时候建议过一遍最新的onnx-simplifier或者手动删除所有的if OP代码实现如下
# 通过op的类型获取onnx模型的计算节点def get_nodes_by_optype(self, typename):nodes []for node in self.model.graph.node:if node.op_type typename:nodes.append(node)return nodes
# 移除ONNX模型中的目标节点def remove_node(self, target_node):删除只有一个输入和输出的节点node_input target_node.input[0]node_output target_node.output[0]# 将后继节点的输入设置为目标节点的前置节点for node in self.model.graph.node:for i, n in enumerate(node.input):if n node_output:node.input[i] node_inputtarget_names set(target_node.input) set([weight.name for weight in self.model.graph.initializer])self.remove_weights(target_names)target_names.add(node_output)self.remove_inputs(target_names)self.remove_value_infos(target_names)self.model.graph.node.remove(target_node)具体顺序就是先获取所有if类型的OP然后删除这些节点。
0x4. ONNXRuntime介绍及用法
ONNXRuntime是微软推出的一个推理框架似乎最新版都支持训练功能了用户可以非常方便的运行ONNX模型。ONNXRuntime支持多种运行后端包括CPUGPUTensorRTDML等。ONNXRuntime是专为ONNX打造的框架虽然我们大多数人把ONNX只是当成工具人但微软可不这样想ONNX统一所有框架的IR表示应该是终极理想吧。从使用者的角度我们简单分析一下ONNXRuntime即可。
import numpy as np
import onnx
import onnxruntime as ortimage cv2.imread(image.jpg)
image np.expand_dims(image, axis0)onnx_model onnx.load_model(resnet18.onnx)
sess ort.InferenceSession(onnx_model.SerializeToString())
sess.set_providers([CPUExecutionProvider])
input_name sess.get_inputs()[0].name
output_name sess.get_outputs()[0].nameoutput sess.run([output_name], {input_name : image_data})
prob np.squeeze(output[0])
print(predicting label:, np.argmax(prob))这里展示了一个使用ONNXRuntime推理ResNet18网络模型的例子可以看到ONNXRuntime在推理一个ONNX模型时大概分为Session构造模型加载与初始化和运行阶段和静态图框架类似。ONNXRuntime框架是使用C开发同时使用Wapper技术封装了Python接口易于用户使用。
从使用者的角度来说知道怎么用就可以了如果要了解框架内部的知识请移步源码https://github.com/microsoft/onnxruntime和参考资料6。
0x5. 调试工具
会逐渐补充一些解决ONNX模型出现的BUG或者修改调试ONNX模型的代码到这里https://github.com/BBuf/onnx_learn 。这一节主要介绍几个工具类函数结合ONNXRuntime来调试ONNX模型。
假设我们通过Pytorch导出了一个ONNX模型在和Pytorch有相同输入的情况下输出结果却不正确。这个时候我们要定位问题肯定需要获取ONNX模型指定OP的特征值进行对比但是ONNX模型的输出在导出模型的时候已经固定了这个时候应该怎么做
首先我们需要通过名字获取ONNX模型中的计算节点实现如下
# 通过名字获取onnx模型中的计算节点def get_node_by_name(self, name):for node in self.model.graph.node:if node.name name:return node然后把这个我们想看的节点扩展到ONNX的输出节点列表里面去实现如下 # 将target_node添加到ONNX模型中作为输出节点def add_extra_output(self, target_node, output_name):target_output target_node.output[0]extra_shape []for vi in self.model.graph.value_info:if vi.name target_output:extra_elem_type vi.type.tensor_type.elem_typefor s in vi.type.tensor_type.shape.dim:extra_shape.append(s.dim_value)extra_output helper.make_tensor_value_info(output_name,extra_elem_type,extra_shape)identity_node helper.make_node(Identity, inputs[target_output], outputs[output_name], nameoutput_name)self.model.graph.node.append(identity_node)self.model.graph.output.append(extra_output)然后修改一下onnxruntime推理程序中的输出节点为我们指定的节点就可以拿到指定节点的推理结果了和Pytorch对比一下我们就可以知道是哪一层出错了。
这里介绍的是如何查看ONNX在确定输入的情况下如何拿到推理结果如果我们想要获取ONNX模型中某个节点的信息又可以怎么做呢这个就结合上一次推文讲的ONNX的结构来看就比较容易了。例如查看某个指定节点的属性代码实现如下
def show_node_attributes(node):print(*10, attributes of node: , node.name, *10)for attr in node.attribute:print(attr.name)print(*60)查看指定节点的输入节点的名字实现如下
def show_node_inputs(node):# Generally, the first input is the truely input# and the rest input is weight initializerprint(*10, inputs of node: , node.name, *10)for input_name in node.input:print(input_name) # type of input_name is strprint(*60)…
0x6. 总结
这篇文章从多个角度探索了ONNX从ONNX的导出到ONNX和Caffe的对比以及使用ONNX遭遇的困难以及一些解决办法另外还介绍了ONNXRuntime以及如何基于ONNXRuntime来调试ONNX模型等后续会继续结合ONNX做一些探索性工作。
0x7. 参考资料
资料1https://zhuanlan.zhihu.com/p/128974102资料2https://zhuanlan.zhihu.com/p/165294876资料3https://zhuanlan.zhihu.com/p/212893519资料4https://blog.csdn.net/zsf10220208/article/details/107457820资料5https://github.com/bindog/onnx-surgery资料6https://zhuanlan.zhihu.com/p/346544539资料7https://github.com/daquexian/onnx-simplifier