ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从ONNX模型解析到计算图构建:OInfer推理引擎核心实现详解

从ONNX模型解析到计算图构建:OInfer推理引擎核心实现详解 1. 项目概述与核心目标上次我们聊了聊 OInfer 这个轻量级推理引擎的顶层设计把框架的骨架搭了起来。今天咱们得往里面填血肉了核心就是处理 ONNX 模型。你可能会问市面上已经有 ONNX Runtime 这样的成熟方案了为什么还要自己折腾解析原因很简单一是为了学习亲手拆解一个模型文件理解从静态描述到动态计算图的完整转换过程对理解深度学习部署的底层逻辑有巨大帮助二是为了极致轻量当我们只需要支持特定算子或特定硬件时一个从零构建的、高度定制化的解析器和图构建器能带来更小的二进制体积和更直接的性能优化空间。OInfer 这一阶段的目标就是读取一个标准的 ONNX 模型文件.onnx将其内部复杂的节点、张量、属性等信息转换为我们自己定义的一套内存中的计算图数据结构并完成初步的拓扑排序和形状推断为后续的算子实现和运行时调度打下坚实基础。这个过程就像是把一张建筑设计蓝图ONNX文件翻译成施工队能看懂的、步骤清晰的工序流程图OInfer计算图。2. ONNX 模型文件格式深度解析要解析 ONNX首先得知道它里面到底装了些什么。ONNX 本质上是一个基于 Protocol Buffers (protobuf) 序列化格式的文件。它不只是一个简单的层列表而是一个完整的、自描述的图结构。2.1 ONNX 模型的核心构成一个 ONNX 模型ModelProto主要包含以下几个关键部分ir_version: 表示 ONNX 的中间表示版本号这决定了我们解析时需要用到的模式Opset。不同版本可能对算子的定义有细微差别。opset_import: 算子集导入声明。这是一个非常重要的部分它指明了模型中使用的算子来源于哪个领域的哪个版本。最常见的是ai.onnx标准算子集但也可能是ai.onnx.ml传统机器学习算子或其他自定义域。解析时必须根据这里声明的版本来查找对应的算子定义。producer_name与producer_version: 产生这个模型的框架名称和版本例如pytorch 2.1.0。这对调试和兼容性判断有帮助。graph: 这是模型的核心一个GraphProto对象。我们绝大部分的解析工作都围绕它展开。2.2 计算图GraphProto的解剖GraphProto包含了构成计算图的所有元素node(NodeProto 列表): 计算节点即算子。每个节点有op_type: 算子类型如Conv,Relu,Add。input: 字符串列表表示该节点的输入张量名称。output: 字符串列表表示该节点的输出张量名称。attribute(AttributeProto 列表): 算子的属性例如卷积的kernel_shape、strides、pads。属性类型多样有整数、浮点数、字符串、张量、图等。input(ValueInfoProto 列表): 模型的输入张量信息。不仅包含名称更重要的是包含其type信息尤其是tensor_type中的shape。这个形状可能是完全确定的如[1, 3, 224, 224]也可能包含动态维度如[batch_size, 3, 224, 224]其中batch_size为符号。output(ValueInfoProto 列表): 模型的输出张量信息结构与input类似。initializer(TensorProto 列表):常量张量也就是模型的权重和偏置。例如卷积层的滤波器权重、全连接层的矩阵、BatchNorm 的缩放和偏移参数等。这些张量在推理时是固定的会在解析时直接加载到内存中。initializer也有自己的名字并且这些名字通常会出现在某些节点的input列表中。value_info(ValueInfoProto 列表): 中间张量的信息。ONNX 文件可以选择性地保存中间节点的形状和类型信息这对于调试和离线形状推断非常有帮助。注意这里有一个关键的解析逻辑节点的input既可能来自上游节点的输出中间张量也可能直接指向initializer中的常量。在构建计算图时我们需要区分这两种情况将initializer处理为特殊的“常量节点”或直接作为节点的属性值。2.3 张量TensorProto与数据类型TensorProto描述了张量的数据。它包含dims: 形状一个整数列表。data_type: 数据类型如FLOAT1,UINT82,INT326等。我们需要一个映射表将 ONNX 的枚举值转换为我们内部的数据类型枚举。raw_data: 这是最常用的数据存储方式以字节流bytes的形式存储序列化的张量数据。数据布局通常是连续的按照dims指定的形状排列。此外还有float_data,int32_data等字段用于存储特定类型的数据但raw_data因其通用性和紧凑性而更常见。解析raw_data时必须根据data_type和dims来正确反序列化。例如一个dims[2,3]、data_typeFLOAT的张量其raw_data的长度应为2*3*sizeof(float) 24字节。3. OInfer 内部计算图数据结构设计在解析 ONNX 之前我们必须先定义好 OInfer 内部用来表示计算图的数据结构。这套结构的设计直接影响到后续优化、执行的效率。3.1 核心类设计我们设计几个核心类Tensor: 表示一个张量。struct Tensor { std::string name; std::vectorint64_t shape; // 形状-1 表示动态维度 DataType dtype; // 内部数据类型枚举如 kFloat32 std::vectorchar data; // 存储原始数据非常量张量此项可为空 bool is_weight; // 是否为权重来自initializer };Node: 表示一个计算节点算子。struct Node { std::string name; // 可自动生成如 “Conv_0” std::string op_type; // 算子类型如 “Conv” std::vectorTensor* inputs; // 输入张量指针 std::vectorTensor* outputs; // 输出张量指针 std::unordered_mapstd::string, Attribute attrs; // 算子属性 // 拓扑排序相关 std::vectorNode* next_nodes; // 后继节点 int in_degree 0; // 入度用于拓扑排序 };Attribute: 表示节点的属性需要支持多种类型。我们可以使用std::variant。using Attribute std::variant int64_t, float, std::string, std::vectorint64_t, std::vectorfloat, Tensor // 对于像Constant这样的节点属性可能就是张量 ;Graph: 表示整个计算图。class Graph { public: bool loadFromONNX(const std::string path); bool topologicalSort(); bool inferShape(); // ... 其他方法 private: std::unordered_mapstd::string, std::unique_ptrTensor tensors_; std::vectorstd::unique_ptrNode nodes_; std::vectorNode* sorted_nodes_; // 拓扑排序后的节点执行顺序 // 输入输出张量名称列表 std::vectorstd::string input_names_; std::vectorstd::string output_names_; };3.2 内存管理考量这里使用std::unique_ptr来管理Tensor和Node的生命周期避免内存泄漏。Node中存储的是Tensor*原始指针指向Graph中tensors_所拥有的对象。这种设计保证了张量数据的唯一所有权同时节点可以高效地引用它们。在构建图的过程中我们需要非常仔细地建立这些指针关联确保不会出现悬空指针。4. ONNX 解析与计算图构建的完整流程现在我们来详细拆解Graph::loadFromONNX这个核心函数的具体步骤。4.1 步骤一加载与验证 ONNX 文件首先我们需要读取.onnx文件。由于它是 protobuf 格式我们需要使用 ONNX 提供的 C 接口onnx.proto编译生成的头文件或第三方 protobuf 库来解析。#include onnx/onnx_pb.h // 假设使用ONNX的官方头文件 bool Graph::loadFromONNX(const std::string path) { std::ifstream file(path, std::ios::binary); if (!file.is_open()) { LOG(ERROR) Failed to open ONNX file: path; return false; } onnx::ModelProto model; if (!model.ParseFromIstream(file)) { LOG(ERROR) Failed to parse ONNX file.; return false; } // 基础验证 if (!model.has_graph()) { LOG(ERROR) ONNX model does not contain a graph.; return false; } const onnx::GraphProto onnx_graph model.graph(); // 检查 opset 版本确保我们支持 for (const auto opset : model.opset_import()) { if (opset.domain() || opset.domain() ai.onnx) { if (opset.version() SUPPORTED_ONNX_OPSET_VERSION) { LOG(WARNING) ONNX model opset version opset.version() is higher than supported version SUPPORTED_ONNX_OPSET_VERSION , may cause compatibility issues.; } } } // ... 后续步骤 }4.2 步骤二处理初始器Initializer与输入在创建节点之前我们先处理initializer和graph.input。这是因为initializer是常量它们不依赖于任何计算可以视为图的“源”之一。同时我们需要将模型的输入也创建为Tensor。// 步骤2创建所有 Initializer 张量 std::unordered_mapstd::string, Tensor* name_to_tensor_ptr; for (const auto tensor_proto : onnx_graph.initializer()) { auto tensor std::make_uniqueTensor(); tensor-name tensor_proto.name(); // 解析 dims, data_type, raw_data for (auto d : tensor_proto.dims()) { tensor-shape.push_back(d); } tensor-dtype convertONNXDataType(tensor_proto.data_type()); if (tensor_proto.has_raw_data()) { const std::string raw tensor_proto.raw_data(); tensor-data.assign(raw.begin(), raw.end()); } else { // 处理 float_data, int32_data 等非 raw_data 格式较少见 fillTensorDataFromProto(tensor_proto, *tensor); } tensor-is_weight true; auto ret tensors_.emplace(tensor-name, std::move(tensor)); name_to_tensor_ptr[tensor-name] ret.first-second.get(); } // 步骤3创建模型输入张量 for (const auto input_info : onnx_graph.input()) { // 注意initializer 的名字也会出现在 input 列表中 // 如果一个名字已经在 initializer 中说明它是常量输入我们跳过创建。 if (name_to_tensor_ptr.find(input_info.name()) ! name_to_tensor_ptr.end()) { continue; // 这是一个权重已经创建过了 } auto tensor std::make_uniqueTensor(); tensor-name input_info.name(); tensor-is_weight false; // 解析输入张量的类型和形状 const auto type_proto input_info.type(); if (type_proto.has_tensor_type()) { const auto tensor_type type_proto.tensor_type(); tensor-dtype convertONNXDataType(tensor_type.elem_type()); const auto shape_proto tensor_type.shape(); for (const auto dim : shape_proto.dim()) { if (dim.has_dim_value()) { tensor-shape.push_back(dim.dim_value()); } else if (dim.has_dim_param()) { // 动态维度用 -1 或特殊值标记 tensor-shape.push_back(-1); LOG(INFO) Dynamic dimension found in input: dim.dim_param(); } else { tensor-shape.push_back(-1); // 未知维度 } } } auto ret tensors_.emplace(tensor-name, std::move(tensor)); name_to_tensor_ptr[input_info.name()] ret.first-second.get(); input_names_.push_back(input_info.name()); }实操心得处理graph.input时一定要与initializer去重。很多导出工具如 PyTorch 的torch.onnx.export会把模型的权重也列在input中但它们实际上是由initializer提供的常量。如果重复创建会导致同一个张量名有两个实体后续节点连接时会产生混乱。4.3 步骤三创建计算节点Node遍历onnx_graph.node()为每个NodeProto创建我们内部的Node对象。// 步骤4创建节点 for (int i 0; i onnx_graph.node().size(); i) { const auto onnx_node onnx_graph.node(i); auto node std::make_uniqueNode(); node-op_type onnx_node.op_type(); node-name onnx_node.name().empty() ? (node-op_type _ std::to_string(i)) : onnx_node.name(); // 处理输入将输入名称解析为 Tensor 指针 for (const auto input_name : onnx_node.input()) { if (input_name.empty()) continue; // 某些算子允许可选输入 auto it name_to_tensor_ptr.find(input_name); if (it name_to_tensor_ptr.end()) { // 输入张量尚未被创建。这可能是中间张量我们创建一个“占位符”Tensor。 // 它的形状和类型将在形状推断阶段确定。 auto tensor std::make_uniqueTensor(); tensor-name input_name; tensor-is_weight false; auto ret tensors_.emplace(input_name, std::move(tensor)); it name_to_tensor_ptr.emplace(input_name, ret.first-second.get()).first; } node-inputs.push_back(it-second); } // 处理输出创建输出张量并建立节点与张量的关联 for (const auto output_name : onnx_node.output()) { auto tensor std::make_uniqueTensor(); tensor-name output_name; tensor-is_weight false; auto ret tensors_.emplace(output_name, std::move(tensor)); Tensor* output_tensor_ptr ret.first-second.get(); name_to_tensor_ptr[output_name] output_tensor_ptr; node-outputs.push_back(output_tensor_ptr); // 同时这个张量的生产者就是当前节点后续需要反向链接这里先记录 // 我们可以在所有节点创建完后再统一建立节点间的边。 } // 处理属性Attribute for (const auto attr_proto : onnx_node.attribute()) { Attribute attr convertONNXAttribute(attr_proto); // 一个转换函数 node-attrs[attr_proto.name()] std::move(attr); } nodes_.push_back(std::move(node)); }4.4 步骤四构建节点间的边图连接目前我们的Node只有inputs和outputs张量指针列表但节点之间的前后依赖关系边还没有显式建立。我们需要遍历所有张量找到每个张量的生产者节点和消费者节点。// 步骤5构建节点间的依赖关系图连接 // 首先建立张量名到生产者节点的映射 std::unordered_mapstd::string, Node* tensor_to_producer; for (const auto node_uptr : nodes_) { Node* node node_uptr.get(); for (auto* output_tensor : node-outputs) { auto ret tensor_to_producer.emplace(output_tensor-name, node); if (!ret.second) { LOG(ERROR) Tensor output_tensor-name has multiple producers!; return false; } } } // 然后为每个节点设置其后继节点并更新入度 for (const auto node_uptr : nodes_) { Node* node node_uptr.get(); for (auto* input_tensor : node-inputs) { auto prod_it tensor_to_producer.find(input_tensor-name); if (prod_it ! tensor_to_producer.end()) { Node* producer_node prod_it-second; // 建立边producer_node - node producer_node-next_nodes.push_back(node); node-in_degree; } else { // 输入张量没有生产者。它要么是模型输入要么是 initializer。 // 对于 initializer我们可以选择创建一个虚拟的“常量节点”作为其生产者。 // 这里我们简单处理不创建额外节点后续调度时特殊处理。 } } } // 步骤6记录模型输出 for (const auto output_info : onnx_graph.output()) { output_names_.push_back(output_info.name()); }至此一个完整的内存计算图就构建出来了。我们有了节点集合、张量集合以及它们之间的依赖关系。5. 计算图的拓扑排序计算图通常是一个有向无环图DAG。为了正确执行我们需要确定一个节点的执行顺序这个顺序必须保证当一个节点被执行时它的所有输入张量都已经被其前驱节点计算好了。拓扑排序就是用来解决这个问题的经典算法。5.1 算法实现Kahn 算法我们使用基于入度的 Kahn 算法它直观且易于实现。bool Graph::topologicalSort() { sorted_nodes_.clear(); std::queueNode* zero_in_degree_queue; // 初始化将所有入度为0的节点加入队列 for (const auto node_uptr : nodes_) { if (node_uptr-in_degree 0) { zero_in_degree_queue.push(node_uptr.get()); } } // 处理队列 while (!zero_in_degree_queue.empty()) { Node* node zero_in_degree_queue.front(); zero_in_degree_queue.pop(); sorted_nodes_.push_back(node); // 将该节点输出的所有后继节点的入度减1 for (Node* succ_node : node-next_nodes) { succ_node-in_degree--; // 如果后继节点入度变为0加入队列 if (succ_node-in_degree 0) { zero_in_degree_queue.push(succ_node); } } } // 检查是否所有节点都被排序 if (sorted_nodes_.size() ! nodes_.size()) { LOG(ERROR) Graph has cycle(s)! Topological sort failed.; // 可以选择打印出剩余入度不为0的节点帮助调试环的位置 return false; } return true; }5.2 排序后的验证与可视化排序完成后sorted_nodes_就包含了节点的执行顺序。我们可以写一个简单的函数来打印这个顺序用于调试。void Graph::printExecutionOrder() const { LOG(INFO) Execution order ( sorted_nodes_.size() nodes):; for (size_t i 0; i sorted_nodes_.size(); i) { LOG(INFO) [ i ] sorted_nodes_[i]-name ( sorted_nodes_[i]-op_type ); } }注意事项拓扑排序的结果可能不唯一。只要满足依赖关系任何有效的顺序都是可以的。不同的顺序通常不会影响计算结果的正确性但可能会影响内存使用效率例如影响张量的生命周期和内存复用。更高级的调度器会在拓扑排序的基础上进行优化。6. 静态形状推断形状推断是推理引擎中至关重要的一环。它的目标是在模型实际运行之前确定计算图中所有张量尤其是中间张量的形状。这对于内存分配、算子选择例如卷积算法选择、性能优化和模型验证都必不可少。6.1 形状推断的基本原理形状推断基于一个简单的规则已知一个算子的输入形状和属性可以根据该算子的规则推导出其输出形状。我们需要为支持的每一种op_type实现一个形状推断函数。例如对于一个Conv算子输入X(形状[N, C_in, H_in, W_in]),W(权重形状[C_out, C_in, kH, kW])属性strides[sH, sW],pads[padH_begin, padW_begin, padH_end, padW_end],dilations[dH, dW]输出Y的形状计算公式为H_out floor((H_in padH_begin padH_end - dH*(kH-1) -1) / sH) 1 W_out floor((W_in padW_begin padW_end - dW*(kW-1) -1) / sW) 1Y的形状为[N, C_out, H_out, W_out]6.2 形状推断的实现策略我们可以实现一个形状推断器它按照拓扑排序后的节点顺序依次处理。bool Graph::inferShape() { // 首先确保拓扑排序已完成 if (sorted_nodes_.empty() !topologicalSort()) { return false; } // 初始化模型输入的形状已经在加载时确定了 // 遍历所有节点进行形状推断 for (Node* node : sorted_nodes_) { // 1. 检查该节点所有输入张量的形状是否已知 for (Tensor* input_tensor : node-inputs) { if (input_tensor-shape.empty()) { LOG(ERROR) Shape inference failed for node node-name : input tensor input_tensor-name shape is unknown.; return false; } // 对于动态维度-1需要更复杂的符号推理这里简化处理假设运行时会提供具体值。 // 一个健壮的实现需要引入符号维度系统。 } // 2. 根据算子类型调用对应的形状推断函数 std::vectorstd::vectorint64_t input_shapes; for (auto* t : node-inputs) { input_shapes.push_back(t-shape); } std::vectorstd::vectorint64_t output_shapes; if (!shapeInferenceHelper(node-op_type, input_shapes, node-attrs, output_shapes)) { LOG(ERROR) Shape inference failed for op_type: node-op_type; return false; } // 3. 将推断出的形状赋给输出张量 if (output_shapes.size() ! node-outputs.size()) { LOG(ERROR) Shape inference output count mismatch for node node-name; return false; } for (size_t i 0; i node-outputs.size(); i) { node-outputs[i]-shape output_shapes[i]; } } // 最后验证模型输出的形状是否已推断出来 for (const auto out_name : output_names_) { auto it tensors_.find(out_name); if (it tensors_.end() || it-second-shape.empty()) { LOG(ERROR) Output tensor out_name shape is unknown after inference.; return false; } } return true; }其中shapeInferenceHelper是一个大的分发函数内部根据op_type调用具体的形状计算函数。bool shapeInferenceHelper(const std::string op_type, const std::vectorstd::vectorint64_t input_shapes, const std::unordered_mapstd::string, Attribute attrs, std::vectorstd::vectorint64_t output_shapes) { output_shapes.clear(); if (op_type Conv) { return inferShapeForConv(input_shapes, attrs, output_shapes); } else if (op_type Relu) { // Relu 输出形状与输入相同 if (input_shapes.size() ! 1) return false; output_shapes.push_back(input_shapes[0]); return true; } else if (op_type Add) { // Add 要求输入可广播输出为广播后的形状 return inferShapeForAdd(input_shapes, attrs, output_shapes); } else if (op_type Reshape) { return inferShapeForReshape(input_shapes, attrs, output_shapes); } else if (op_type Gemm) { // 全连接层 return inferShapeForGemm(input_shapes, attrs, output_shapes); } // ... 其他算子 LOG(WARNING) Shape inference not implemented for op_type: op_type; // 对于未实现的算子我们可以选择返回失败或者暂时跳过将输出形状标记为未知。 // 为了稳健性可以先跳过但记录警告。 return false; // 或根据策略返回 true 并设置一个未知形状标记 }6.3 处理动态形状与符号推理上面的简化实现假设所有维度都是具体数字。但 ONNX 支持动态形状用dim_param表示。处理动态形状需要引入符号系统。一个简单的开始是将动态维度用一个唯一的字符串符号如”batch_size”表示并在形状计算过程中传播这些符号。例如输入形状为[“batch_size”, 3, 224, 224]经过一个不影响批大小的算子如Relu后输出形状依然是[“batch_size”, 3, 224, 224]。对于会改变维度的算子如Reshape规则会更复杂。实现一个完整的符号推理器是一个不小的工程在 OInfer 的初期我们可以先支持固定形状模型或假设运行时会提供所有动态维度的具体值。7. 常见问题与调试技巧实录在实现 ONNX 解析和计算图构建的过程中我踩过不少坑。这里分享几个典型问题和解决方法。7.1 问题一节点输入/输出名称重复或为空现象构建张量到生产者的映射时发现同一个张量名对应多个生产者节点导致断言失败。排查检查 ONNX 模型。使用netron工具可视化模型查看问题张量连接到了哪里。打印有问题的节点信息。通常这是因为模型中存在“分支”结构但某个分支的节点错误地重用了输出名称。规范的 ONNX 模型中每个节点的输出名称应该是唯一的。也可能是解析代码在创建输出张量时逻辑有误重复创建了同名的张量对象。解决确保在tensors_映射中每个张量名只对应一个唯一的Tensor对象。如果 ONNX 模型本身不规范可以考虑在解析时自动重命名冲突的输出例如添加后缀但这可能会影响后续基于名称的查找。7.2 问题二拓扑排序失败检测到环现象topologicalSort函数返回 falsesorted_nodes_.size() nodes_.size()。排查日志输出在排序失败后遍历所有节点打印出入度仍大于0的节点及其输入/输出这能帮你快速定位环可能涉及的节点。检查边构建逻辑重点检查tensor_to_producer的构建和节点间边的建立逻辑。确保没有将同一个节点错误地添加为自己的后继。模型问题极少数情况下模型本身可能存在计算环例如递归结构但标准的前馈神经网络不应有环。用netron检查模型结构。解决99% 的情况是代码 bug。仔细检查步骤四中构建边的双重循环逻辑确保producer_node和node不是同一个节点并且映射关系正确。7.3 问题三形状推断结果与预期不符现象模型能运行但输出张量形状错误导致后续算子崩溃或结果异常。排查逐层打印在inferShape函数中每处理完一个节点就打印其输入输出形状。与一个已知正确的参考如 ONNX Runtime 的推理结果或netron显示的形状进行对比。聚焦第一个出错的算子找到形状开始与预期不符的第一个节点。集中检查该算子的形状推断函数。核对属性解析形状推断严重依赖算子属性如Conv的pads,strides。检查convertONNXAttribute函数是否正确解析了这些属性。ONNX 中pads的顺序可能是[begin_h, begin_w, end_h, end_w]而你的计算可能假设是[top, bottom, left, right]。注意默认值ONNX 算子属性可能有默认值。例如Conv的dilations默认为[1, 1]。如果你的属性映射没有提供这些默认值形状计算就会出错。解决为形状推断函数编写单元测试。使用一些简单的、形状已知的模型例如只有一个Conv或Gemm的模型进行测试确保基础算子的形状推断绝对正确。7.4 问题四处理“初始器即输入”的混淆现象在创建模型输入张量时错误地为initializer中已有的权重创建了额外的输入张量导致图结构混乱。复盘正如在步骤二中强调的graph.input列表包含了模型所有的“输入”这既包括真正的运行时输入如图像数据也包括模型内部的常量权重。ONNX 通过同时出现在initializer中来标识后者。解决在创建输入张量时必须首先检查其名称是否已存在于从initializer创建的张量映射中。如果是则跳过创建。这是解析 ONNX 模型的一个关键步骤务必小心。7.5 调试技巧可视化与比对善用 Netron这是最强大的 ONNX 模型可视化工具。将你的模型在 Netron 中打开可以清晰地看到整个计算图的结构、每个节点的属性、每个张量的形状。这是验证你解析结果是否正确的最直观方法。与 ONNX Runtime 对比使用 ONNX Runtime 的 Python API 加载同一个模型通过sess.get_inputs(),sess.get_outputs()获取输入输出信息也可以通过onnxruntime的某些内部接口或直接打印onnx.load()后的对象来获取中间节点的信息与你解析的结果进行比对。编写小型测试模型不要一开始就用复杂的 ResNet 或 YOLO 测试。用 PyTorch 或 TensorFlow 导出只有两三个算子的微型 ONNX 模型用于验证你解析器的基本功能。例如一个Conv - Relu - Add的链式结构就足够测试节点连接、拓扑排序和基础形状推断了。构建一个可靠的 ONNX 解析器和计算图是推理引擎的基石。这个过程虽然繁琐但每一步都充满了对深度学习模型底层表示的理解。当你能成功地将一个.onnx文件加载到内存中并构建出正确的计算图时你就已经打通了模型部署道路上最关键的一环。接下来我们就可以在这个计算图的基础上实现具体的算子并让它真正地“跑”起来了。
返回列表