这篇文章聚焦一个问题:一个 ONNX 文件是如何被 runtime 变成可执行结果的。文章以 yolov8n.onnx 和当前 miniONNXRuntime 为主线,结合完整 ONNX Runtime 的基础概念,说明模型加载、图结构、值流转和算子执行分别在做什么。

项目地址:https://github.com/WWandP/miniONNXRuntime

1. 文章引入

ONNX 文件本身只描述了结构和参数,真正把它变成推理结果的是 runtime。

yolov8n.onnx 是一个适合观察推理流程的模型,参数量较小,同时包含图结构、权重参数以及推理所需的基本算子,适合用于演示 ONNX Runtime 的运行流程。

当模型输入一张图片后,完整的runtime 需要完成模型加载、图结构解析、算子调度、张量流转和结果输出。这个过程涉及的对象包括 GraphSessionExecutionContextKernelRegistry 以及若干具体 kernel。

下图给出一个简化后的主流程。

KernelRegistry ExecutionContext Internal Graph Session User/Test KernelRegistry ExecutionContext Internal Graph Session User/Test Load(model.onnx) 解析并构建内部图 Initialize() kernel 绑定 Run(feeds) 装载 initializer / 输入 按拓扑顺序调度节点 写回中间结果和输出 返回结果

这张图对应一个最小可运行的推理闭环。Session 负责组织一次推理,Graph 负责表达模型结构,ExecutionContext 负责保存运行时张量,KernelRegistry 负责把节点映射到具体实现。

这一闭环已经覆盖了模型从加载到输出的主流程,也足以说明推理引擎里最核心的几件事:模型如何被解析,值如何在图中流转,算子如何被调度,以及结果如何被回写。

本文以这个 mini runtime 为起点,结合完整 ONNX Runtime 的基础概念,说明一个模型从 ONNX 文件变成推理结果时经历了哪些关键阶段。

2. ONNX 与 ONNX Runtime 的分工

ONNX 和 ONNX Runtime 在日常表述里常被放在一起,但它们承担的角色不同。

ONNX 负责描述模型本身。一个 ONNX 文件中保存了图、节点、initializer、输入输出信息以及算子属性。它提供的是模型的结构化表达,不直接负责执行过程。

runtime 的职责是将这份静态描述转化为可执行状态,包括:

  • 构建内部图结构
  • 组织推理过程
  • 调度算子执行
  • 管理中间张量
  • 返回最终结果

从这个角度看,ONNX 和 runtime 分别承担“描述”和“执行”两个层面的工作。

在完整 ONNX Runtime 中,这种分工同样清晰:模型先被解析成内部图对象,再进入执行阶段。mini runtime 当前实现的正是这条链路的前半部分和执行主线。

3. miniONNXRuntime 当前进度

这个 mini 版本的 ONNX Runtime 已经在 CPU 上跑通了最小链路,后续会持续扩展。
已经完成的部分包括:

  • 读取 yolov8n.onnx
  • 构建内部 Graph / Node / Value / TensorInfo
  • 提取模型元信息、输入输出和 initializer
  • 生成拓扑顺序
  • 提供 Session 作为统一入口
  • 提供 ExecutionContext 作为运行时值表
  • 注册并执行一批 builtin kernels
  • 支持 ConstantAddMulConvMaxPoolResizeShapeGather 等核心算子(CPU)
  • 支持输入预处理和执行跟踪

后续将展开的部分包括:

  • 图优化
  • ExecutionProvider 分配
  • 内存规划和 buffer reuse
  • 多模型泛化
  • CUDA 路径

这一阶段的重点是把模型加载、图结构、值流转和算子执行看清楚。这个边界是明确的,也符合当前实现的实际进度。

接下来的章节聚焦已经实现、且最能体现当前设计的部分。后续的介绍以minionnx为例

4. 模型解析

这一节看模型如何从 ONNX 文件进入内部 Graph。解析入口是 LoadOnnxGraph(const std::filesystem::path& model_path),对应文件位于 include/miniort/loader/onnx_loader.hsrc/loader/onnx_loader.cc

Graph LoadOnnxGraph(const std::filesystem::path& model_path);

解析过程主要分为以下几步:

  1. 打开 ONNX 文件并解析为 onnx::ModelProto
  2. 读取模型级元信息,包括 graph.nameir_versionproducer_nameproducer_versionopset_import
  3. 遍历 graph.inputgraph.outputgraph.value_info,为每个命名值建立 TensorInfo
  4. 遍历 graph.initializer,将权重和常量张量写入 graph.initializers
  5. graph.input 中剔除 initializer 名称,留下真正的运行时输入。
  6. 遍历 graph.node,记录节点名、op_type、输入输出边和属性。
  7. 根据节点依赖关系构建 topological_order

解析后得到的内部图对象主要包含以下字段:

  • Graph::metadata
    • 模型路径、生产者信息、IR 版本、opset 版本。
  • Graph::value_infos
    • 每个 value 的 dtype、shape 和是否为 initializer。
  • Graph::initializers
    • 常量参数和权重张量。
  • Graph::inputs / Graph::outputs
    • 运行时输入和模型输出。
  • Graph::nodes
    • 节点名、算子类型、输入、输出和属性。
  • Graph::topological_order
    • 执行顺序。

属性解析目前覆盖的类型包括:

  • float
  • int
  • string
  • floats
  • ints
  • strings
  • tensor

张量数据则会解析 raw_datafloat_datadouble_dataint32_dataint64_datastring_data。对于当前项目,最常用的是 float32int64

下面这段代码展示了解析阶段的几个关键点:把 ValueInfoProto 转成 TensorInfo,把 TensorProto 转成 TensorData,再把 TensorProto_DataType 映射为项目内部使用的 dtype 字符串。

std::string ToTensorElemTypeString(int elem_type) {
  switch (elem_type) {
    case onnx::TensorProto_DataType_FLOAT:
      return "float32";
    case onnx::TensorProto_DataType_INT64:
      return "int64";
    default:
      return "unknown(" + std::to_string(elem_type) + ")";
  }
}

TensorInfo MakeTensorInfo(const onnx::ValueInfoProto& value_info, bool is_initializer = false) {
  TensorInfo info;
  info.is_initializer = is_initializer;
  ...
  return info;
}

这一节的重点不是解析细节本身,而是确认 mini runtime 如何把 ONNX 的静态描述转换成自己可执行的数据结构。

5. 迷你版 runtime 的结构

这一节看 mini runtime 的模块分工。这个项目已经形成了一个清晰的小闭环,主要由五个部分组成:

loader
  -> model
  -> runtime
  -> kernels
  -> tools
  • loader 负责将 ONNX protobuf 转换为内部图结构。
  • model 保存 Graph / Node / Value / TensorInfo 等核心数据。
  • runtime 负责一次推理过程的组织与调度。
  • kernels 提供最小可用的算子实现。
  • tools 提供模型查看、执行跟踪、输入预处理和结果导出能力。

这个结构对应的是一个最小可运行的推理系统,重点在于把模型加载、值流转和算子调度串成闭环。

6. Session 的作用

Session 是 mini runtime 的运行入口,也是整个执行过程的组织者。

Session 是 mini runtime 的运行入口,对应文件位于 include/miniort/runtime/session.hsrc/runtime/session.cc

它的核心职责包括:

  • 持有 Graph
  • 持有 KernelRegistry
  • 在构造时注册 builtin kernels
  • Run() 中组织一次推理

SessionOptions 目前包含四个开关:

  • verbose
    • 控制执行过程的 trace 输出。
  • allow_missing_kernels
    • 控制遇到未注册算子时是否允许继续执行。
  • auto_bind_placeholder_inputs
    • 控制是否自动为缺失输入绑定占位张量。
  • max_nodes
    • 控制最多执行多少个节点,便于局部调试。

Session::Run() 的执行顺序如下:

  1. 调用 ExecutionContext::LoadInitializers(),把 initializer 装入上下文。
  2. 将外部传入的 feeds 绑定到上下文。
  3. 视配置自动补齐缺失输入的占位张量。
  4. topological_order 顺序遍历节点。
  5. 通过 KernelRegistry::Lookup() 查找对应 kernel。
  6. 调用 kernel 计算输出并写回上下文。
  7. 对缺失 kernel 或异常路径,必要时用元数据生成占位输出。

这个设计和完整 ONNX Runtime 的角色划分一致:Session 负责组织推理,ExecutionContext 负责保存运行时值,KernelRegistry 负责把算子名映射到实现。

Session 的核心结构可以直接从头文件看出来:

class Session {
 public:
  Session(Graph graph, SessionOptions options = {});

  RunSummary Run(const std::unordered_map<std::string, Tensor>& feeds,
                 ExecutionContext& context,
                 std::ostream* trace = nullptr) const;

 private:
  Graph graph_;
  KernelRegistry kernel_registry_;
  SessionOptions options_;
};

Run() 的主循环也很直接:

for (std::size_t topo_index = 0; topo_index < graph_.topological_order.size(); ++topo_index) {
  const auto node_index = graph_.topological_order[topo_index];
  const auto& node = graph_.nodes[node_index];
  const auto* kernel = kernel_registry_.Lookup(node.op_type);
  if (kernel != nullptr) {
    (*kernel)(node, context, trace);
  }
}

这段代码展示了 mini runtime 的核心执行方式:按拓扑顺序取节点,再通过 KernelRegistry 找到对应实现。

7. 执行主线

mini runtime 的执行主线如下:

Load
  -> 构建内部 Graph
  -> Session 持有图和 kernel 注册表
  -> ExecutionContext 保存运行时张量
  -> 按拓扑顺序执行节点
  -> 输出结果

这条链路和完整 ONNX Runtime 的基础执行思路一致:先把模型读进来,变成图对象,再进入一次推理的执行过程。当前 mini runtime 聚焦的是这部分最关键的执行逻辑。

8. 算子实现路径

这一节看算子实现放在哪里,以及它们如何被注册到 runtime 中。builtin kernels 的注册入口位于 src/runtime/builtin_kernels.cc,分组声明位于 src/runtime/builtin_kernel_groups.h

void RegisterBuiltinKernels(KernelRegistry& registry) {
  RegisterBasicKernels(registry);
  RegisterElementwiseKernels(registry);
  RegisterNnKernels(registry);
  RegisterShapeKernels(registry);
}

当前实现的算子按功能分为四组:

分组 文件 主要算子
基础算子 src/runtime/basic_kernels.cc Constant
元素级算子 src/runtime/elementwise_kernels.cc SigmoidAddMulDivSubCast
形状与张量操作 src/runtime/shape_kernels.cc ConstantOfShapeShapeGatherUnsqueezeConcatReshapeRangeSplitExpandTransposeSliceReduceMaxArgMax
NN 算子 src/runtime/nn_kernels.cc ConvMaxPoolResizeSoftmax

这些 kernel 的共同特点是:

  • 直接从 ExecutionContext 读取输入张量
  • 根据节点属性执行最小语义实现
  • 构造输出张量并写回上下文

算子实现中还会用到 src/runtime/kernel_utils.h 里的公共辅助函数,例如张量读取、shape 计算、广播和索引转换。

下面看一个具体算子,Constant 的实现位于 src/runtime/basic_kernels.cc

registry.Register("Constant", [](const Node& node, ExecutionContext& context, std::ostream* trace) {
  const auto attr_it = node.attributes.find("value");
  if (attr_it == node.attributes.end() || !attr_it->second.tensor.has_value()) {
    throw std::runtime_error("Constant node missing tensor value attribute: " + node.name);
  }
  const auto& tensor_attr = *attr_it->second.tensor;
  ...
  context.BindTensor(std::move(tensor));
});

这类实现的共同模式是:从节点属性或输入张量中取数据,计算输出形状和内容,再写回 ExecutionContext

9. 迷你版与完整实现的对照

mini runtime 和完整 ONNX Runtime 可以放在同一张表里理解。

主题 miniONNXRuntime 完整 ONNX Runtime
模型范围 yolov8n.onnx 通用 ONNX / 多模型
执行方式 拓扑顺序执行 受图结构、运行时状态和执行计划共同驱动
中间值管理 name -> Tensor 由运行时状态统一管理
图结构 Graph / Node / Value Model / Graph / NodeArg
内存管理 简化处理 结合生命周期分析和运行时分配
后端 CPU 单路径 多后端支持

这个对照有助于建立层次感:mini runtime 负责讲清模型加载、图结构、值流转和算子执行,完整实现则在此基础上扩展出更强的通用性和工程能力。

10. 当前阶段与后续

mini runtime 已经足以说明推理引擎的主线:模型如何进入 runtime,值如何在图中流转,算子如何执行,结果如何返回。

一个 ONNX 模型要真正转化为推理结果,需要 runtime 将图结构、算子执行、内存管理和调度逻辑组织起来。迷你版 runtime 让这条主线变得清晰,完整 ONNX Runtime 则在此基础上完成工程化扩展。

理解主线之后,下一层要处理的就是工程复杂度。两者之间的关系很清楚:前者回答“怎么跑通”,后者回答“怎么跑稳、跑快、跑得通用”。

通过对照这两层实现,可以更直观地理解推理引擎的核心问题,也能更准确地看到“模型描述”与“模型执行”之间的边界。先理解主线,再看工程复杂度,层次会更清楚。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐