Ch13: ONNX Runtime——标准集装箱港口
Part 3: 六大框架架构精讲 前置章节:Ch12: MNN——端侧瑞士军刀 后续章节:Ch14: TNN——ncnn 的架构翻新版
一句话定位
ONNX Runtime(简称 ORT)是六大引擎中唯一一个”标准先行”的框架:它不发明自己的模型格式,而是直接消费 ONNX 标准模型;它不绑定特定硬件优化策略,而是通过 Execution Provider(EP)插件让硬件厂商自己写加速后端。它就像一个国际集装箱港口——不管你的货物从哪个国家来(PyTorch、TensorFlow、PaddlePaddle),只要装进标准集装箱(ONNX 格式),这个港口都能接收、分拣、高效转运。
GitHub 21K star,微软开源,约 8000 个源文件——这个规模比前面所有移动端引擎加在一起还大,因为 ORT 的野心不仅仅是移动端,它要覆盖从云端到边缘到端侧的全场景。
类比:国际集装箱港口
在集装箱被发明之前,港口运输是一件极其低效的事情。每个国家的货物有不同的包装方式,每个港口有不同的装卸设备,每种货物需要不同的处理方式。一艘船到港后,工人需要一件一件地把货物搬下来,按照类型分类,再装上不同的卡车运走。整个过程慢、贵、容易出错。
集装箱改变了一切。不管你运的是电子产品、服装还是食品,统统装进标准尺寸的集装箱。港口只需要标准化的龙门吊和集装箱卡车,就能处理任何货物。你甚至可以在不同港口之间转运——因为所有港口用的都是同一种集装箱标准。
ORT 在推理引擎世界中扮演的就是这个”标准化港口”的角色:
- ONNX 模型格式 = 标准集装箱(统一的模型交换格式)
- Execution Provider = 港口的不同码头(每个码头专门处理特定类型的货物)
- InferenceSession = 一次进港出港流程(模型加载、图分区、推理执行)
- OpKernel = 码头上的装卸机器(每个算子在特定硬件上的具体实现)
类比边界:港口的类比强调的是 ORT 的”标准化”和”插件化”。但真正的港口是被动接收货物的,而 ORT 的 EP 竞价机制是主动的——多个 EP 会”竞价”每个计算节点的执行权,这更像一个拍卖行而不是港口。后面会详细讲这个机制。
设计哲学精讲
哲学一:Execution Provider 插件——竞价机制认领节点
ORT 最核心的架构创新是 Execution Provider(EP) 插件体系。这是它和所有其他移动端引擎最根本的区别。
在 ncnn/MNN/TNN 中,后端(CPU/GPU)是引擎的”内置组件”——引擎代码和后端代码紧密耦合,加一个新后端需要修改引擎的核心代码。在 ORT 中,后端是”插件”——每个 EP 是一个独立的动态库(或静态库),引擎通过标准接口和 EP 交互,加一个新后端不需要改引擎的一行代码。
EP 的工作方式像一个”竞价”过程:
模型加载后,ORT 把计算图中的每个节点(Op)拿出来"拍卖":
节点 1: Conv2D
→ TensorRT EP: "我能执行,而且我有 GPU 加速" [竞价]
→ CUDA EP: "我也能执行" [竞价]
→ CPU EP: "我也能执行(但最慢)" [竞价]
→ 结果:TensorRT EP 中标(优先级最高)
节点 2: CustomOp
→ TensorRT EP: "我不认识这个 Op" [放弃]
→ CUDA EP: "我不认识" [放弃]
→ CPU EP: "我能执行" [竞价]
→ 结果:CPU EP 中标(唯一能执行的)
节点 3: MatMul
→ TensorRT EP: "我能执行" [竞价]
→ 结果:TensorRT EP 中标
竞价的结果是模型图被”分区”(partition):同一个 EP 认领的连续节点被合并成一个子图,由该 EP 一起执行。不同 EP 的子图之间需要插入数据搬运节点(比如 CPU 和 GPU 之间的数据传输)。
// Execution Provider 接口(简化)
class IExecutionProvider {
public:
// 返回这个 EP 的名字(如 "CUDAExecutionProvider")
virtual const std::string& Type() const = 0;
// 竞价:EP 查看图中所有节点,返回它能执行的节点列表
virtual std::vector<std::unique_ptr<ComputeCapability>>
GetCapability(const GraphViewer& graph,
const IKernelLookup& kernel_lookup) const;
// 编译:把认领的节点子图编译成可执行的内核
virtual common::Status Compile(
const std::vector<FusedNodeAndGraph>& fused_nodes,
std::vector<NodeComputeInfo>& node_compute_funcs);
// 内存管理
virtual AllocatorPtr GetAllocator(OrtMemType mem_type) const = 0;
};
这种插件架构的威力在于:硬件厂商可以自己开发 EP,把自己的硬件加速能力”插入”ORT,而不需要修改 ORT 的核心代码。英伟达开发了 TensorRT EP 和 CUDA EP,高通开发了 QNN EP(支持 Hexagon DSP 和 Adreno GPU),苹果开发了 CoreML EP,华为可以开发 CANN EP。每个 EP 只需要实现 IExecutionProvider 接口,就能无缝集成到 ORT 中。
CPU EP(也叫 Default EP)是 ORT 的”兜底”选择——如果没有任何加速 EP 能处理某个节点,CPU EP 总能执行它。这保证了 ORT 的鲁棒性:任何合法的 ONNX 模型在 ORT 上都能跑,即使没有任何加速硬件。
哲学二:Tensor——最薄的数据容器
ORT 的 Tensor 设计和 ncnn/MNN 形成了鲜明对比。ncnn 的 Mat 自己管理内存(内置引用计数和分配器),MNN 的 Tensor 更是一个”胖描述符”(内含 Region、设备信息等)。ORT 的 Tensor 则是最薄的——它不拥有内存。
// ORT 的 Tensor(简化)
class Tensor {
void* p_data_; // 数据指针(不拥有)
TensorShape shape_; // 形状
MLDataType dtype_; // 数据类型
OrtMemoryInfo* alloc_info_; // 内存位置描述
int64_t byte_offset_; // 在底层 buffer 中的偏移
// 注意:没有引用计数、没有分配器、没有 Region
// Tensor 只是一个指向别人管理的内存的"视图"
};
Tensor 不负责分配和释放内存——这些工作由 OrtAllocator 和执行框架(ExecutionFrame)负责。Tensor 只知道”我的数据在哪里、什么形状、什么类型”。
这种”最薄”的设计有几个重要好处:
第一,零拷贝的外部数据接入。用户可以直接把自己分配的内存包装成 ORT Tensor,不需要拷贝数据到 ORT 的内部 buffer。在移动端,这意味着可以直接把相机帧缓冲区作为模型输入,省掉一次内存拷贝。
第二,灵活的内存管理策略。因为 Tensor 不绑定特定的内存管理方式,不同的 EP 可以用不同的内存分配器——CPU EP 用 malloc,CUDA EP 用 cudaMalloc,TensorRT EP 用 TensorRT 自己的内存管理。上层代码不需要知道底层用的是什么分配器。
第三,内存复用更灵活。ORT 的执行框架可以在运行时决定两个 Tensor 是否共享同一块内存(如果它们的生命周期不重叠),而 Tensor 本身不需要做任何修改。
哲学三:OpKernel + KernelRegistry——opset 版本化
ORT 的算子体系有一个独特的维度:opset 版本化。ONNX 标准定义了多个版本的算子集(opset),每个版本可能对同一个算子(如 Conv)有不同的语义定义。比如 opset 1 的 Conv 和 opset 11 的 Conv 在 padding 的处理方式上有区别。
ORT 通过 KernelRegistry 管理不同 opset 版本的算子实现:
// OpKernel 基类(简化)
class OpKernel {
public:
virtual Status Compute(OpKernelContext* context) const = 0;
const OpKernelInfo& Info() const { return info_; }
};
// KernelRegistry: 算子注册表
class KernelRegistry {
public:
// 注册一个内核:指定 Op 名字、opset 范围、数据类型、后端
Status Register(KernelCreateInfo&& create_info);
// 查找内核:给定 Op 名字和 opset 版本,找到匹配的实现
Status TryFindKernel(const Node& node,
ProviderType exec_provider,
const KernelCreateInfo** out) const;
};
// 注册示例
ONNX_CPU_OPERATOR_VERSIONED_KERNEL(
Conv, // Op 名字
1, 10, // opset 版本范围:1 到 10
KernelDefBuilder()
.TypeConstraint("T", DataTypeImpl::GetTensorType<float>()),
Conv<float> // 实现类
);
ONNX_CPU_OPERATOR_KERNEL(
Conv, // Op 名字
11, // opset 版本:11+
KernelDefBuilder()
.TypeConstraint("T", DataTypeImpl::GetTensorType<float>()),
Conv11<float> // 不同的实现类(处理新的 padding 语义)
);
这意味着同一个 Op 名字可以有多个实现,ORT 根据模型指定的 opset 版本选择正确的实现。这保证了向后兼容性——一个用 opset 7 导出的旧模型和一个用 opset 17 导出的新模型,在 ORT 上都能正确运行,因为 ORT 为不同的 opset 版本注册了不同的内核实现。
ncnn/MNN/TNN 都不需要处理这个问题,因为它们有自己的模型格式(ncnn 的 .param、MNN 的 .mnn、TNN 的 .tnnproto),模型转换工具在转换时已经统一了算子语义。ORT 必须处理这个问题,因为它直接消费 ONNX 模型,而 ONNX 标准有版本演进。
哲学四:InferenceSession 图分区——模型的”分拣”过程
ORT 的推理从创建 InferenceSession 开始。这个过程比 ncnn 的 Net::load_param 或 MNN 的 Interpreter::createSession 复杂得多,因为它要完成一个关键步骤:图分区(Graph Partitioning)。
InferenceSession 创建流程:
1. 加载 ONNX 模型(反序列化 protobuf)
└─ 得到原始计算图
2. 图优化(Graph Transformer)
└─ 算子融合(Conv+BN+ReLU -> FusedConv)
└─ 常量折叠
└─ 冗余节点消除
3. 图分区(按 EP 优先级)
└─ 遍历注册的 EP(按优先级排序)
└─ 每个 EP 通过 GetCapability 认领节点
└─ 认领的连续节点合并为子图
└─ 子图之间插入数据传输节点
4. 内核绑定
└─ 为每个节点在对应 EP 的 KernelRegistry 中找到 OpKernel
└─ 绑定完成后,每个节点都有了具体的执行函数
5. 内存规划
└─ 分析 tensor 的生命周期
└─ 决定内存复用策略
// InferenceSession 创建(概念简化)
class InferenceSession {
Status Initialize() {
// 1. 加载模型
Model model;
LoadModel(model_path_, model);
Graph& graph = model.MainGraph();
// 2. 图优化
GraphTransformerManager transformer_mgr;
transformer_mgr.Register(
std::make_unique<ConvBNFusion>());
transformer_mgr.Register(
std::make_unique<ConstantFolding>());
transformer_mgr.ApplyTransformers(graph);
// 3. 图分区
for (auto& ep : execution_providers_) {
auto capabilities = ep->GetCapability(
GraphViewer(graph), kernel_lookup);
// 把认领的节点合并为子图
PartitionGraph(graph, capabilities);
}
// 4. 内核绑定
for (auto& node : graph.Nodes()) {
auto kernel = LookupKernel(node, ep);
session_state_.AddKernel(node, kernel);
}
// 5. 内存规划
MemoryPatternPlanner planner;
planner.Plan(graph, session_state_);
return Status::OK();
}
};
图分区后的模型看起来像这样:
原始模型:
Input -> Conv -> BN -> ReLU -> Resize -> Conv -> Softmax -> Output
分区后 (假设有 TensorRT EP 和 CPU EP):
[TensorRT 子图: Conv -> BN -> ReLU]
-> [数据传输: GPU->CPU]
-> [CPU 子图: Resize(TensorRT 不支持这种 Resize)]
-> [数据传输: CPU->GPU]
-> [TensorRT 子图: Conv -> Softmax]
-> Output
这个分区过程对用户完全透明——用户只需要注册 EP,ORT 自动决定每个节点在哪里执行。如果 TensorRT EP 能处理整个模型,整个图就在 GPU 上跑;如果有些节点 TensorRT 不支持,ORT 自动插入 CPU 回退和数据传输。
哲学五:ONNX 标准直用——不发明新格式
这是 ORT 和所有其他引擎最表面的区别:ORT 直接加载和执行 ONNX 模型,不需要额外的模型转换步骤。
ncnn 需要 onnx2ncnn,MNN 需要 MNNConvert,TNN 需要 onnx2tnn,Paddle-Lite 需要 opt 工具。这些转换工具把通用的 ONNX 模型转换为各自引擎的私有格式。转换过程中可能丢失信息、引入兼容性问题,而且增加了部署流程的复杂度。
ORT 的做法是:ONNX 模型直接用。用户用 PyTorch 导出一个 ONNX 模型,直接丢给 ORT 就能跑。
# PyTorch 导出 ONNX
torch.onnx.export(model, dummy_input, "model.onnx", opset_version=17)
# ORT 直接加载(不需要转换!)
import onnxruntime as ort
session = ort.InferenceSession("model.onnx")
output = session.run(None, {"input": data})
这种”零转换”体验的代价是:ORT 需要内置 ONNX protobuf 的解析器(增加了对 protobuf 库的依赖),而且需要支持 ONNX 标准中定义的所有算子和所有 opset 版本(工作量巨大)。但好处也很明显:最小化部署摩擦。在企业环境中,”不需要额外的转换步骤”本身就是一个巨大的优势——少一步转换就少一个出错点、少一个需要维护的工具。
哲学六:EP 动态库插件——运行时加载后端
ORT 的 EP 不仅可以在编译时静态链接,还可以作为动态库在运行时加载。这意味着你可以发布一个不含 GPU 支持的 ORT 基础包,然后根据用户设备的硬件能力,在运行时动态加载对应的 EP 动态库。
// 运行时注册 EP(C API)
OrtSessionOptions* session_options;
OrtSessionOptionsAppendExecutionProvider_CUDA(session_options, 0);
// 或者加载自定义 EP 动态库
OrtSessionOptionsAppendExecutionProvider(
session_options, "MyCustomEP", keys, values, num_keys);
这种动态库插件机制让 ORT 特别适合”平台化”部署:一个 ML 平台只需要集成 ORT 核心,然后根据不同的部署环境(有 GPU 的服务器、只有 CPU 的边缘设备、有 NPU 的手机)加载不同的 EP 插件。
核心数据结构:Tensor
ORT 的 Tensor 是六大框架中最”薄”的:
// ORT 的 Tensor(简化)
class Tensor final {
void* p_data_; // 数据指针(不拥有这块内存)
TensorShape shape_; // 形状 {batch, channel, height, width}
const DataTypeImpl* dtype_; // 数据类型(float, int8, ...)
std::shared_ptr<IAllocator> allocator_; // 分配器(可选)
OrtMemoryInfo location_; // 内存位置描述
int64_t byte_offset_; // 偏移量
gsl::span<int64_t> strides_; // 步长(可选)
};
和其他框架的对比:
| 特性 | ncnn Mat | MNN Tensor | ORT Tensor |
|---|---|---|---|
| 拥有内存 | 是(引用计数) | 可能(取决于 MemoryType) | 否 |
| 数据类型 | 隐式(通过 elemsize) | 显式(halide_type_t) | 显式(DataTypeImpl) |
| 通道打包 | elempack 字段 | NC4HW4 约定 | 无(标准 NCHW) |
| 设备信息 | 无(CPU=Mat, GPU=VkMat) | 内含 | OrtMemoryInfo |
| 视图/Region | 无 | 有(MEMORY_VIRTUAL) | 无(通过 offset/stride 实现) |
ORT Tensor 不做通道打包(没有 elempack 或 NC4HW4)——这是因为 ORT 遵循 ONNX 标准的数据布局(默认 NCHW)。如果某个 EP 需要打包布局(比如 ARM NEON 后端需要 NC4HW4),那是 EP 内部的事情——EP 在内部做布局转换,对 ORT 核心透明。
推理流程
阶段一:创建 Session
Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "test");
Ort::SessionOptions session_options;
// 注册 EP(按优先级顺序)
OrtSessionOptionsAppendExecutionProvider_CUDA(session_options, 0);
// CPU EP 默认存在,不需要手动注册
// 创建 Session(触发图分区和内核绑定)
Ort::Session session(env, "model.onnx", session_options);
阶段二:准备输入
auto memory_info = Ort::MemoryInfo::CreateCpu(
OrtArenaAllocator, OrtMemTypeDefault);
std::vector<float> input_data(3 * 224 * 224);
// ... 填充数据 ...
std::vector<int64_t> input_shape = {1, 3, 224, 224};
Ort::Value input_tensor = Ort::Value::CreateTensor<float>(
memory_info, input_data.data(), input_data.size(),
input_shape.data(), input_shape.size());
注意这里的关键:CreateTensor 传入的是用户自己分配的 input_data 的指针,不做内存拷贝。ORT 的 Tensor 只是包装了这个指针——这就是”Tensor 不拥有内存”设计的直接体现。
阶段三:执行推理
const char* input_names[] = {"input"};
const char* output_names[] = {"output"};
auto output_tensors = session.Run(
Ort::RunOptions{nullptr},
input_names, &input_tensor, 1,
output_names, 1);
Run 的内部流程:按照图分区的结果,依次执行每个 EP 的子图。如果两个子图在不同的 EP 上(比如第一个在 GPU,第二个在 CPU),中间自动插入数据搬运。
阶段四:读取输出
float* output_data = output_tensors[0].GetTensorMutableData<float>();
auto output_shape = output_tensors[0].GetTensorTypeAndShapeInfo().GetShape();
关键差异:ORT 与其他框架
vs ncnn/MNN/TNN:标准 vs 私有
ORT 直接消费 ONNX 模型,其他三个需要格式转换。这不仅是工程便利性的区别,更是生态位的区别:ORT 是 ONNX 标准的”参考实现”,是连接训练框架和各种硬件的”中间件”;ncnn/MNN/TNN 是面向特定场景优化的”终端引擎”。
vs ncnn:抽象层次
ncnn 的代码几乎没有抽象层——Layer 基类加上具体实现,一共两层。ORT 有 IExecutionProvider、OpKernel、KernelRegistry、ExecutionFrame、SequentialExecutionPlan 等多层抽象。ncnn 的优势是简单可控,ORT 的优势是灵活可扩展。
vs MNN:编译器 vs 分区
MNN 用 Geometry Compiler 把非计算 Op 降级为 Region;ORT 没有类似机制——它把所有 Op 都交给 EP 处理,由 EP 决定怎么优化。MNN 的方法在框架层面解决了跨层优化问题,ORT 把这个问题下放给了 EP(每个 EP 可以在自己的子图内做优化)。
适用场景与局限
最适合 ORT 的场景
企业级 ML 平台。如果你需要一个能在服务端(CUDA/TensorRT)、边缘(ARM CPU/Qualcomm DSP)和端侧(Android/iOS)统一使用的推理引擎,ORT 是最好的选择。一套代码、一套 API、一套模型格式,通过 EP 适配不同硬件。
ONNX 模型的直接部署。如果你的团队已经用 ONNX 作为模型交换格式,ORT 提供了最无缝的部署体验——不需要额外的模型转换步骤。
硬件厂商的推理加速集成。硬件厂商可以通过实现 EP 接口,把自己的加速能力集成到 ORT 中,获得 ORT 庞大生态的流量。这比让用户学习一个全新的推理框架要容易得多。
ORT 的局限
移动端优化深度不如 ncnn/MNN。ORT 在纯 ARM CPU 推理的性能基准测试中,通常不如 ncnn(手写汇编的优势)和 MNN(Geometry Compiler 的优势)。ORT 的 CPU EP 使用的是标准的算子实现(部分使用 MLAS 库做矩阵运算),没有 ncnn 那种极致的手工优化。
二进制体积大。完整编译的 ORT 库在 ARM64 上约 8-15 MB,远大于 ncnn 的约 700KB。虽然可以通过裁剪减小体积(ORT Mobile),但整体还是偏大。在嵌入式设备上可能不可接受。
复杂度高。约 8000 个文件、多层抽象、模板元编程——ORT 的代码复杂度在六大框架中遥遥领先。如果你的目标是”学习推理引擎原理”,ORT 绝对不是一个好的起点。
依赖 protobuf。因为 ONNX 模型格式基于 protobuf,ORT 必须链接 protobuf 库。这增加了编译的复杂度和最终的二进制体积,和 ncnn 的”零依赖”形成鲜明对比。
缺少端侧 LLM 和训练支持。虽然 ORT 有 ORT Training(服务端训练加速),但缺少像 MNN 那样的端侧 LLM 推理优化和端侧微调能力。
代码结构
ORT 的源码是六大框架中最庞大的,约 8000 个文件:
onnxruntime/
├── core/
│ ├── framework/ # 核心框架
│ │ ├── tensor.h # Tensor 数据结构
│ │ ├── op_kernel.h # OpKernel 基类
│ │ ├── kernel_registry.h # 算子注册表
│ │ ├── execution_frame.h # 执行帧(管理运行时状态)
│ │ ├── execution_provider.h # EP 接口
│ │ ├── sequential_execution_plan.h
│ │ └── ...
│ │
│ ├── session/ # 推理会话
│ │ ├── inference_session.h # 核心入口
│ │ └── ...
│ │
│ ├── graph/ # 图表示
│ │ ├── graph.h # ONNX 图
│ │ ├── graph_transformer.h # 图优化
│ │ └── ...
│ │
│ ├── optimizer/ # 图优化 pass
│ │ ├── conv_bn_fusion.h
│ │ ├── constant_folding.h
│ │ └── ...
│ │
│ ├── providers/ # 各种 EP
│ │ ├── cpu/ # CPU EP(默认)
│ │ │ ├── cpu_execution_provider.h
│ │ │ ├── math/
│ │ │ │ ├── matmul.cc
│ │ │ │ └── ...
│ │ │ ├── nn/
│ │ │ │ ├── conv.cc
│ │ │ │ └── ...
│ │ │ └── ...
│ │ ├── cuda/ # CUDA EP
│ │ ├── tensorrt/ # TensorRT EP
│ │ ├── coreml/ # CoreML EP (iOS)
│ │ ├── nnapi/ # NNAPI EP (Android NPU)
│ │ ├── qnn/ # 高通 QNN EP
│ │ └── ...
│ │
│ └── mlas/ # MS线性代数子程序库
│ ├── lib/
│ │ ├── sgemm.cpp # 单精度矩阵乘
│ │ ├── convolve.cpp # 卷积实现
│ │ └── ...
│ └── inc/
│
├── contrib_ops/ # 社区贡献的算子
│ ├── cpu/
│ └── cuda/
│
└── test/ # 测试
关键阅读路径:
- 入门:
core/framework/tensor.h->core/framework/op_kernel.h->core/framework/execution_provider.h - 理解 Session:
core/session/inference_session.h(Initialize函数,图分区的入口) - 理解图优化:
core/optimizer/目录下的各个 pass - 理解 EP:
core/providers/cpu/cpu_execution_provider.h(最简单的 EP) - 理解 MLAS:
core/mlas/(ORT 的高性能数学库,类似 ncnn 的手写内核但用 intrinsics 而不是汇编)
小结
ORT 的设计哲学可以用一个词概括:标准化。它不发明新的模型格式(用 ONNX),不绑定特定硬件(用 EP 插件),不假设特定场景(从云到端全覆盖)。每一个设计决策都指向同一个目标:做一个万物互联的中间层,让任何模型能在任何硬件上跑。
它的核心创新 Execution Provider 竞价机制,解决了”一个引擎怎么适配无限多种硬件”的问题:不是引擎去适配硬件,而是让硬件通过 EP 接口来适配引擎。这种”反转控制”的思路,和 ncnn/MNN 的”引擎内置所有后端”形成了完全不同的架构范式。
它的 Tensor 是六大框架中最薄的——不拥有内存、不做通道打包、不含 Region 描述——极简到只剩一个数据指针和形状描述。这种极简让它可以和任何外部内存管理系统对接,但也意味着它不像 ncnn 的 Mat 或 MNN 的 Tensor 那样”开箱即用”。
对于犀牛鸟竞赛的参赛者来说,ORT 不太适合作为学习推理引擎内部原理的入门材料(太复杂了),但它的 EP 架构思想值得学习——当你的引擎需要支持新硬件时,”插件化”的思路比”硬编码”要优雅得多。
下一章,我们将看到 TNN 如何在 ncnn 的基础上做”架构翻新”——用 BaseLayer/LayerAcc 双层设计解决 ncnn 的可维护性问题。
导读目录:README.md 上一章:Ch12: MNN——端侧瑞士军刀 下一章:Ch14: TNN——ncnn 的架构翻新版