首页 / 资讯中心 / 文章详情

拆解ONNX模型结构,彻底搞懂TVM前端原理

拆解ONNX模型结构,彻底搞懂TVM前端原理 ★ FEATURED ARTICLE
不用急着去看TVM那一大堆源码先把ONNX模型本身拆开看明白TVM前端在做什么、为什么那么写其实立刻就能清楚了。这篇是从零开始学TVM系列的第三篇。上一篇我们用ONNX模型走通了TVM的编译部署流程但中间那个relay.frontend.from_onnx到底干了什么很多人是懵的。前端Frontend是TVM里最容易让人卡住但又最值得先搞懂的部分——因为所有模型进TVM都得先过这一关。我的建议很简单花半小时把ONNX的结构吃透再回头打开onnx.py源码你会发现它就是一个查表翻译器根本没有想象中那么神秘。这篇文章我就带你把它彻底拆开。1. 为什么要从ONNX结构入手了解TVM前端先聊聊大背景。你用某训练框架训好的模型落地部署时通常不会直接拿去上线。训练框架重、依赖多、推理性能也不够极致所以大家会先把模型转成一种中立的中间格式再交给推理引擎做优化和代码生成。ONNX就是目前最通用的那种中间格式。TVM的定位是端到端深度学习编译器它不直接吃训练框架的模型文件而是通过前端把各种框架的模型转换成自己内部的统一计算图表示——Relay IR。这个转换过程就叫前端Frontend。名字听着高大上本质就是一件事翻译。你想想翻译工作的工作方式先看懂原文的语法结构再按目标语言的规则重新表达一遍。TVM前端做的事情一模一样先解析ONNX的图结构原文语法再生成等价的Relay表达式目标语言。所以问题来了如果连ONNX的结构都没搞明白看前端代码就是看天书。我之前带过几个实习生上来直接读onnx.py读了一周还是云里雾里因为不知道代码里那些node.op_type、node.input、node.attribute背后对应的到底是什么实体。反过来先花半小时把ONNX模型用脚本解剖一遍回头再看前端源码每个变量、每个循环都变得顺理成章。这也是我写这篇文章的核心思路用ONNX模型结构作为主线把TVM前端的架构、源码、调用链全部串起来讲。你不需要预先精通前端代码只要跟着我一步步把ONNX拆开、再把前端对应的处理逻辑对上号就能建立起完整的认知。这篇文章适合哪些人已经跑通了TVM的基础部署流程、想深入理解模型转换机制的开发者被前端源码绕晕、想找一条清晰学习路径的初学者以及做模型部署时频繁遇到算子不支持、shape对不上这类问题的人。看完之后你不仅能看懂前端在干什么还能自己动手定位问题、甚至添加自定义算子支持。2. ONNX模型内部长什么样从小白视角拆解结构如果你用文本编辑器直接打开一个.onnx文件看到的全是乱码因为它是二进制文件。但用Python的onnx库读取之后整个结构就非常清晰了。2.1 onnx文件是一个嵌套的Protobuf结构ONNX使用ProtobufProtocol Buffers作为序列化格式。这里不用纠结Protobuf的细节你只需要理解它是一个按定义好的结构体规则打包出来的二进制数据。读出来之后它是一个多层嵌套的对象。加载一个ONNX模型只要两行代码import onnx model onnx.load(my_model.onnx)这个model对象就是ModelProto它里面最重要的字段是graph也就是GraphProto——真正的计算图信息都在这里。我建议你打印一下看print(onnx.helper.printable_graph(model.graph))打印出来的东西其实就已经是一个图的结构了一批输入、一批输出、一组节点Node、一组初始化器Initializer以及节点间的连接关系。不要被这堆文本吓到其实整个GraphProto就是围着这几个核心对象转。用一个生活化的类比把ONNX模型想象成一个集装箱港口。ModelProto是港口总调度单GraphProto是某一个码头的作业计划NodeProto是集装箱货柜TensorProto也就是权重是货柜里的货物。你从港口里面能看到的是货柜的编号、进出港时间输入输出关系而货物是提前安放好的放在一边由铲车按计划取用。对应到ONNX里initializer就是那些提前放好、不需要每次运行时输入的权重input则是真正需要外部提供的数据端口。2.2 看懂NodeProto图的骨架是算子张量连接关系计算出图结构时最重要的就是遍历graph.node。每一个node是一个NodeProto它描述了一个算子的全部信息for node in model.graph.node: print(op_type:, node.op_type) print(inputs:, list(node.input)) print(outputs:, list(node.output)) for attr in node.attribute: print( attr:, attr.name, , onnx.helper.get_attribute_value(attr))一个典型的Conv节点打印出来大概是这样的op_type: Conv inputs: [input_1, conv1.weight, conv1.bias] outputs: [conv1_output] attr: kernel_shape [3, 3] attr: strides [2, 2] attr: pads [1, 1, 1, 1] attr: group 1这里最关键的信息就三样op_type算子类型告诉前端这是个什么操作。input/output字符串形式的张量名称用来表示数据流向。attribute算子本身的参数比如卷积的kernel大小、stride、padding、group。注意input里既有激活值如input_1也有权重如conv1.weight。前端怎么区分这两种答案是去graph.initializer里查名字。如果某个名字出现在initializer列表里它就是常量权重否则就是运行时输入。所以一个ONNX模型的计算图本质上就是一个算子节点有序列表加张量名连接关系。数据从input节点流入经过一串node算子变换最后从output节点流出。2.3 从计算图到执行流为什么initializer单独放一边如果你把ONNX模型想象成一段程序node列表就是程序的指令序列initializer是程序里预定义的常量数据区。在训练框架导出ONNX时所有训练得到的参数权重、偏置都会被放initializer运行时要重新赋值的才放input。前端拿到这个模型后翻译时有一个重要动作initializer里的每个张量都要转成Relay的Constant节点挂到图里。这些常量在后续图优化中可以被折叠、融合比如把BatchNorm的均值方差合并到卷积权重里。弄懂了initializer这个角色你后面看TVM的params参数就不会困惑——from_onnx返回的params其实就是把这些initializer收集起来的一个字典。3. TVM前端的整体架构一条数据流的旅行现在打开tvm/relay/frontend/onnx.py带着ONNX的结构知识来看你会发现它的脉络很清晰。3.1 入口函数from_onnx的调用链前端最常用的入口是relay.frontend.from_onnx它返回两个东西一个IRModule里面存放转换后的Relay函数和一份params字典权重参数。import tvm from tvm import relay mod, params relay.frontend.from_onnx(model, shape{input_1: (1, 3, 224, 224)})shape参数很重要它告诉前端每个输入的shape是多少。如果你的模型输入是动态的这里也可以传部分维度为None比如{input_1: (1, 3, None, None)}。from_onnx内部做的事情可以概括成四步解析graph拿到nodes、inputs、outputs、initializer。为每个ONNX输入建立一个RelayVar如果是权重则建立Constant并放入params。按拓扑顺序遍历每个ONNX节点调用对应的转换函数生成Relay表达式。把所有表达式组装成一个RelayFunction放进IRModule。你可以进入from_onnx看看它的核心其实就是构建一个ONNXConverter对象然后调用convert_onnx_to_relay。这个类负责维护一个算子名到转换函数的映射表。3.2 一个节点是如何一步步变成Relay表达式的假设当前遍历到了一个Conv节点前端会做这样几件事根据node.input[0]从已有的符号表符号表存的是张量名 - Relay表达式里取出输入表达式。读取node.attribute里的各种参数strides、pads、dilations等。根据node.input[1]、node.input[2]从符号表或params里取出权重和偏置。调用relay.nn.conv2d来构造新的表达式。把这个表达式的输出注册到符号表中key是node.output[0]那个字符串。这个过程没有魔法。你可以理解成一个翻译循环挨个处理ONNX节点把ONNX算子翻译成Relay算子翻译结果记录到符号表里。某个节点的输出如果被后面的节点继续使用后来的节点就从符号表里把对应表达式取出来接着用。3.3 前端为什么要走ONNX这层中间表示你可能会问为什么不直接写一个脚本读训练框架的模型文件非要绕一圈ONNX第一个原因是生态。ONNX已经成为各家训练框架和推理引擎之间的通用语言从某训练框架导出的模型可以比较方便地转换为ONNX而TVM只需要实现ONNX这一套解析就能覆盖绝大多数训练框架的模型。你不需要为每个框架单独写一个前端。第二个原因是协议明确。ONNX规定好了算子语义、参数格式、数据流连接方式。相比直接去解析训练框架的序列化文件那些格式更像Python对象的二进制存档ONNX的结构更接近一个计算图标准解析起来边界清晰。第三个原因是复用。TVM里的图优化、算子融合等都是基于Relay IR做的跟模型来源无关。ONNX进来之后全部转换成Relay后续优化逻辑只需要面对一份中间表示不用为不同框架各写一套优化。一句话总结ONNX是边界清晰的中转站TVM前端负责把ONNX描述翻译成Relay描述剩下的优化和代码生成跟源框架无关。4. 手把手实操走一遍ONNX到Relay的完整转换光看不练是没有体感的。这一节我们用一个小模型把转换过程完整跑一遍并且学会用调试手段偷看TVM内部是怎么处理的。4.1 准备一个最小ONNX模型并打印输出的Relay IR我这里构造一个能被ONNX正常导出和加载的小模型——一个简单的卷积biasReLU结构。实际用某训练框架导出的模型也行但为了演示简洁先聚焦两个算子import onnx import numpy as np import tvm from tvm import relay # 直接从ONNX helper构造一个极小的计算图Conv ReLU node_conv onnx.helper.make_node( Conv, inputs[input, weight, bias], outputs[conv_out], kernel_shape[3, 3], strides[1, 1], pads[1, 1, 1, 1], ) node_relu onnx.helper.make_node(Relu, inputs[conv_out], outputs[output]) graph onnx.helper.make_graph( nodes[node_conv, node_relu], nametiny_graph, inputs[ onnx.helper.make_tensor_value_info(input, onnx.TensorProto.FLOAT, [batch, 3, h, w]), ], outputs[ onnx.helper.make_tensor_value_info(output, onnx.TensorProto.FLOAT, None), ], initializer[ onnx.helper.make_tensor(weight, onnx.TensorProto.FLOAT, [3, 3, 3, 3], np.random.randn(3, 3, 3, 3).flatten().tolist()), onnx.helper.make_tensor(bias, onnx.TensorProto.FLOAT, [3], np.random.randn(3).flatten().tolist()), ], ) onnx_model onnx.helper.make_model(graph, opset_imports[onnx.helper.make_opsetid(, 11)]) onnx.checker.check_model(onnx_model) onnx.save(onnx_model, tiny_model.onnx) # 用TVM前端转换 model onnx.load(tiny_model.onnx) mod, params relay.frontend.from_onnx(model, shape{input: (1, 3, 224, 224)})然后直接打印Relay IR这一步极其关键print(mod)你会看到类似这样的输出不同TVM版本格式略有差异def main(%input: Tensor[(1, 3, 224, 224), float32], %weight: Tensor[(3, 3, 3, 3), float32], %bias: Tensor[(3,), float32]) { %0 nn.conv2d(%input, %weight, padding[1, 1, 1, 1], kernel_size[3, 3], channels3); %1 nn.bias_add(%0, %bias); %2 nn.relu(%1); %2 }对照一下ONNX的节点定义你会看到非常清晰的对应关系ONNX的Conv变成了nn.conv2dbias被单独拆出来成了nn.bias_addRelu变成了nn.relu。这里有一个值得注意的细节ONNX的Conv节点把bias作为第3个输入而TVM的nn.conv2d本身不带bias参数。所以前端代码里会判断bias是否存在存在就额外插一个nn.bias_add。这就是翻译过程中的一个典型的语义对齐操作。4.2 在转换器源码中插入日志的调试技巧如果你想看一个复杂的ONNX模型每个节点具体是怎么被转换的直接改TVM源码加日志是最直观的办法没有之一。我常做的一件事是在convert_onnx_to_relay的主循环里加几行print# tvm/relay/frontend/onnx.py 中的主循环简化示意 def _convert_operator(self, op_name, args, attrs, opset): print(f[FRONTEND DEBUG] converting node, op_type{op_name}, inputs{args}) # ... 原有逻辑但我不建议你直接改源码因为重新编译装环境折腾。更好的办法是在你调用的脚本里设置日志级别或者直接用Python的sys.settrace去Hook那个转换函数。不过说实话最省事的还是先在外部脚本里把ONNX节点列表打出来对着源码看for node in model.graph.node: print(node.op_type, list(node.input), -, list(node.output))然后去onnx.py里搜索对应的_impl_vN(OpType)注册函数挨个对照。这样虽然笨一点但对理解源码非常有效尤其是遇到某个算子转换结果跟预期不符时。如果你确实想断点调试用IDE比如VSCode直接在relay.frontend.from_onnx调用处打断点然后StepInto再在onnx.py的_convert_operator里加断点。这样你可以单步观察每个ONNX节点是如何被分发到具体转换函数的。我强烈建议至少做一次这样的单步调试对建立连贯认知帮助极大。4.3 权重和常量去哪了追踪initializer到Relay的对应关系回到刚才那个例子weight和bias两个initializer并没有出现在main函数的计算表达式里吗不对它们出现在了函数参数里。打印params确认一下for k, v in params.items(): print(k, v.shape)输出会是weight [3, 3, 3, 3] bias [3]这就是关键ONNX里广义上的initializer在TVM前端阶段被分成了两拨。只有那些在ONNX图中被当作推理时不需要变化的常量的张量会直接变成Relay里的Constant塞进IRModule里。而有些权重会被提升为Relay函数的参数放到params字典里便于后续统一管理、量化、分离保存。具体分到哪一拨会影响后续图优化和代码生成的策略。但你暂时不用太纠结内部规则只需要记住一个认知initializer基本都会变成常量或参数挂在图里它们在推理时是不变的。搞懂这一点你在部署时为什么要把params单独存文件、为什么mod和params要分开保存就自然而然理解了。5. 核心算子映射源码解析前端是如何翻译算子的前端源码里最值得看的就是一组组算子转换函数。我挑几个典型来分析你就能触类旁通。5.1 Conv算子转换的完整逻辑Conv是CNN模型里最核心的算子它的转换函数也最有代表性。在onnx.py里Conv的转换函数大概会处理这么几件事第一取出输入表达式、权重、bias。权重往往在initializer里取出来时还要确认它的形状。ONNX的Conv权重shape是[M, C/group, kH, kW]其中M是输出通道数C是输入通道数group是分组卷积的组数。第二解析attribute。包括kernel_shape、strides、pads、dilations、group、auto_pad。这里有个容易踩坑的点ONNX的pads默认值是缺省的而且有两种格式。如果模型导出时用了auto_pad那前端要根据输入shape自己推导padding。我建议你重点看一下_parse_attr或者attr的读取代码理解它怎么把ONNX的attribute变成dict的。第三调用Relay API构造表达式# 简化伪代码 out relay.nn.conv2d( datainput_expr, weightweight_expr, stridesstrides, paddingpadding, dilationdilation, groupsgroup, channelsout_channels, kernel_sizekernel_shape, ) if bias exists: out relay.nn.bias_add(out, bias_expr)这里最微妙的地方是channels和kernel_size的获取。在ONNX的Conv节点里这些信息有时候并不在attribute里而是在权重shape里。比如kernel_shape不写但权重shape是[64, 3, 7, 7]你一眼就知道卷积核是3x3还是7x7。所以前端源码会去检查权重shape推导出kernel_size和channels。这个推导过程是很多新手读代码时看不懂的地方切记权重shape就是最可靠的信息源。5.2 Gemm与BatchNorm数学语义对齐中的细节全连接层在ONNX里一般用Gemm算子表示。它的定义是Y alpha * A * B beta * C其中A和B可能被转置由transA和transB两个attribute控制。在实际模型里常见的是transB1因为训练框架里全连接权重通常存成[in_features, out_features]而ONNX的Gemm要求A是[M, K]、B是[K, N]。前端转换时就要根据transB决定是否对权重做转置。我见过不少初学者在这一块被绕晕一会儿看到transpose一会儿又看到dense。核心逻辑就是把ONNX的Gemm语义对齐到TVM的nn.dense语义如果transB1跟nn.dense的默认权重布局正好一致如果transB0则要先对权重做transpose。BatchNorm的转换也有讲究。ONNX的BatchNorm有5个输入输入数据、scale、bias、mean、var还有一个epsilon属性。TVM里有nn.batch_norm算子它的参数顺序和语义跟ONNX基本对应但返回的是一个tuple输出、mean、var等。前端转换时通常会取第一个输出。如果你在源码里看到类似out[0]的操作不要奇怪那是在取batch_norm的主输出。更值得关注的是后续图优化里面BatchNorm经常被融合到卷积或者全连接里。所以from_onnx转换出来的nn.batch_norm往往只是“临时状态”等走完优化pass后就被FoldScaleAxis这类pass吃掉了。5.3 Reshape与动态维度shape推断机制ONNX的Reshape有一个经典“坑”它的第二个输入是目标shape而且这个shape本身有可能是一个张量由前面的算子计算出来的不一定是常量。TVM前端转换Reshape时如果目标shape是常量直接传newshape参数就行如果目标shape是动态计算的前端通常会用relay.reshape配合动态shape值但这个能力是有限的。还有一个更常见的坑ONNX的Reshape允许shape里包含0表示“从输入对应维度复制”。TVM的reshape语义里并没有这个“0特殊含义”。所以前端在解析shape时如果发现某个维度是0要先去查输入shape对应维度的值再把它替换成实际值。这个逻辑你可以自己写个小脚本验证一下把目标shape里塞一个0看ONNX导出的模型里怎么表示再看TVM前端怎么处理。顺便说一下shape推断的问题。Relay本身有一套shape推断机制当所有输入shape已知时它可以通过每个算子的shape规则自动推导出所有中间tensor的shape。这也是编译器能做静态优化的基础。你以后做动态shape模型部署时会经常跟shape推断失败打交道那时候你回头再看这段就会明白TVM为什么要求你尽量把输入shape确定下来或者至少把不确定的维度范围标出来。6. 常见问题与排查技巧实录写这一节的时候我脑子里冒出来的全是我和身边同事实际踩过的坑。顺手整理出来。6.1 算子不支持报错怎么解决from_onnx最常见的报错就是ValueError: Dont know how to convert to TVM operator for op XXX.这说明前端查表时没找到XXX对应的转换函数。我一般按这个顺序排查先确认ONNX里这个算子的op_type到底是啥打印node列表。去onnx.py里搜一下这个op_type看是不是有转换函数但你没匹配上可能是opset版本问题。如果确实没有内置转换函数考虑把这个算子拆成多个基础算子组合或者用relay.script写一个调用自定义函数的桥接实在不行再加一个自定义算子转换实现。把常见不支持的算子做个速查表ONNX算子问题情况常用替代思路NonMaxSuppression属于检测后处理逻辑TVM里没有对等算子拆出来用Python/numpy在外部处理或走基于算子的自定义实现RoiAlign部分版本或opset下参数解析兼容性有差异升级Toml其实是升级opset兼容版本或自定义DynamicQuantizeLinear量化专用前端不一定直接支持使用量化工具的图处理流程输入端提前转成常量Expand动态shape场景下容易解析失败尽量让目标shape变成常量或改写为broadcast_toWhere一般需要额外条件分支支持考虑拆成equal、multiply、select组合GRU/LSTM递归网络时序结构比较复杂确认RNN前端支持开关否则手动改写为循环体实际上很多不支持不是真的不能做而是前端没有现成的翻译实现。遇到这种情况不要慌先查Issue和源码大部分都能找到替代写法。6.2 opset版本差异导致的解析差异ONNX本身有opset版本号同名算子在老版本和新版本里可能存在细节差异。比如Pad算子老版本的attribute用pads直接写padding值新版本变成input传入前端解析时就会走不同的分支。我在实践中遇到过一个典型的例子某个旧模型导出时opset9里面用了Pad的旧写法而TVM某个较新的版本把Pad的转换逻辑按新opset语法处理导致解析出来的padding参数对不上pad出来的shape完全不对。排查到后面才发现是opset语义变化。建议模型转换前先打印model.opset_import确认opset版本。如果遇到怪异行为第一个怀疑对象就该是opset版本。同一份模型用onnx库升opset的做法比较麻烦更稳妥的办法是在训练框架侧尽量用官方推荐的新opset重新导出。6.3 动态输入和shape推断失败的解决思路很多部署场景的输入分辨率不固定于是from_onnx里要传None维度mod, params relay.frontend.from_onnx(model, shape{input: (1, 3, None, None)})但是你会发现即使这样传了后续有些算子仍然可能shape推断失败。原因往往出在Reshape、Flatten、Gather这类算子它们有可能把某个动态维度直接编到了输出shape里但编译器无法确定这个动态维度的实际数值。我的经验是第一尽量使用固定分辨率做整体部署把动态shape当成后期优化选项而不是默认方案第二如果必须动态优先只动态batch维度不要动态height和width因为卷积类算子对W/H的动态性容忍度高但对依赖shape数值的算子如Reshape、Gather很不友好第三准备一份完整输入shape表在报错时对照着改。6.4 多输出算子的处理特例有一类算子天然有多个输出比如TopK、Split、BatchNorm训练模式。ONNX节点会把多个输出名都写进output列表。前端转换时要么只取第一个输出提供给后续节点要么把所有输出都注册到符号表里。TVM前端的做法是像nn.batch_norm这种返回值是tuple的它会取第一个输出像split这种算子会根据node.output的数量决定按几路切分。如果你在自定义模型里发现某个多输出算子的第二个输出没被用上但前端报错少了一个输出先查一下转换函数里是不是只认了第一个输出。7. 踩过几次坑之后的几点体会这个系列写到这里我自己也复盘了一下学TVM前端最忌讳的就是抱着源码一行行啃却不知道每个变量对应的模型结构实体。反过来从ONNX的结构入手带着它是在翻译什么东西这个问题去读源码效率会高很多。我建议你也走一遍这套流程先用脚本把ONNX模型里的node、initializer、input、output、attribute全部打印出来把计算图可视化到一张纸上然后选一个只有三五个算子的极简模型跑通from_onnx打印Relay IR把两张图对照着看最后再选一个带Conv、Gemm、BatchNorm的真实模型在源码里搜对应的转换函数单步调试一遍。这套方法我每次带新人都用效果比直接甩源码链接好得多。等你走完这一步再去学后续的量化、图优化、算子调度就有了扎实的地基。下一篇我准备聊图优化pass是怎么在前端产物上做手脚的到时候你会发现前期这点功夫全都值得。
阅读完成 · 觉得有帮助?
咨询建站