1. 系统目标与实现路径
1.1 目标设定
本文聚焦在用 C++ 搭建一个简单的神经网络推理框架,并演示如何在同一项目中无缝接入 ONNX Runtime,实现高效的 AI 推理。该框架以最小化依赖、清晰的模块划分以及可移植性为目标,适合嵌入式与服务器端的混合场景。核心目标包括数据结构的可扩展性、前向传播的高效实现,以及与 ONNX 模型的简单接入。
同时,我们强调可维护性与 性能可观测性,确保后续能逐步扩展模型、算子以及后处理步骤。文中所述实现都是以可编译性和可复用性为导向,避免对现有框架造成过度依赖,从而便于在自有应用中快速落地。
1.2 技术选型
在技术选型层面,选用 C++17/20 提供的语义和标准容器,借助 RAII 模式实现资源管理。为实现跨平台运行,框架设计采用纯头文件/少依赖策略,核心放在数值张量和前向计算部分。编译器优化(如 -O2/ -O3、SIMD 指令集)将作为提升推理吞吐的第一道门槛。
为了能够直接复用现成的高性能推理能力,第二阶段将引入 ONNX Runtime,通过统一的接口加载 ONNX 模型并执行推理。这样既能获得广泛的算子覆盖,又能保持自家框架的轻量化特性。跨库协同的设计思想,是使自研推理模块与 ONNX Runtime 的边界尽可能清晰而高效。
2. 构建简单的神经网络推理框架核心
2.1 数据结构与张量
要实现一个可扩展的推理框架,第一步是设计一个轻量级张量结构,包含形状信息与数据缓冲区。在本文实例中,张量以二维矩阵为核心,后续可扩展到多维。安全性与简洁性是设计的重点。
核心要素包括:形状描述、数据存储、以及一个简单的接口用于访问元素。通过将数据存放在连续内存中,能方便后续的矩阵乘法实现和向量化优化。
#include <vector>
#include <cassert>struct Tensor {std::vector shape; // 例如 {M, K}std::vector data; // 长度等于乘积(shape)Tensor(std::vector s): shape(s), data(product(s)) {}static int product(const std::vector& s) {int p = 1;for(int d : s) p *= d;return p;}// 简单访问(假设 2D:row-major)float& at(int i, int j) {int M = shape[0], N = shape[1];assert(i < M && j < N);return data[i * N + j];}
};
在后续实现中,可以把该张量扩展为更通用的多维版本,并加入内存池和对齐分配以提升性能。当前版本的设计强调易懂与可修改性,作为教程型实现的一部分。
2.2 前向传播核心
一个简单的前向传播核心通常包含全连接层(Fully Connected/线性层)及常见激活函数。我们先实现一个两层网络的前向路径,用于演示数据在框架中的传递方式。矩阵乘法是核心运算,后续可逐步替换成更高效实现。
下面给出一个最小化实现,演示输入 X、权重 W、偏置 b 如何计算输出 Y = ReLU(XW + b)。其中 ReLU 为常用非线性激活函数,能带来非线性表达能力。
#include <vector>
#include <cmath>
#include <cassert>struct Tensor {// 以 2D 为例:shape[0]=M, shape[1]=Nstd::vector shape;std::vector data;Tensor(std::vector s): shape(s), data(product(s)) {}static int product(const std::vector& s) {int p = 1;for(int d : s) p *= d;return p;}float& at(int i, int j) {int M = shape[0], N = shape[1];assert(i < M && j < N);return data[i * N + j];}
};// 矩阵乘法:A(MxK) * B(KxN) = C(MxN)
Tensor matmul(const Tensor& A, const Tensor& B) {int M = A.shape[0], K = A.shape[1];int N = B.shape[1];Tensor C({M, N});for (int i = 0; i < M; ++i) {for (int j = 0; j < N; ++j) {float sum = 0.0f;for (int k = 0; k < K; ++k) {sum += A.data[i * K + k] * B.data[k * N + j];}C.data[i * N + j] = sum;}}return C;
}// 线性层 + ReLU
Tensor linear_relu(const Tensor& X, const Tensor& W, const Tensor& b) {Tensor Y = matmul(X, W);// 加偏置int M = Y.shape[0], N = Y.shape[1];for (int i = 0; i < M; ++i)for (int j = 0; j < N; ++j)Y.data[i * N + j] += b.data[j];// ReLUfor (auto& v : Y.data) v = std::fmax(0.0f, v);return Y;
}
通过上述实现,可以看到矩阵乘法与非线性激活的组合,是最核心的前向计算路径。实际应用中,可将上述逻辑扩展为多层网络,并逐步引入批量处理、缓存与并行化。
2.3 矩阵运算优化
在性能导向的实现中,尽早考虑将矩阵乘法替换为高效的实现。外部库对比如 Eigen、BLAS/LAPACK 等,能显著提升吞吐量。若需要自研实现,可先对关键路径进行矢量化与缓存对齐,尽量降低内存带宽消耗。
另外一个方向是利用 分块矩阵乘法,将大矩阵分解成小块,便于缓存友好地访问数据,从而提高局部性与并行度。无论采用哪种策略,目标都是把推理中最耗时的部分落在可控的、易优化的区域。
3. ONNX Runtime 的集成与示例
3.1 ONNX 模型加载与会话创建
要使框架具备广泛的模型兼容性,ONNX Runtime 提供了稳定的 C++ API,用于加载 ONNX 模型并创建推理会话。基本步骤包括:创建环境、配置会话选项、加载模型、以及获取输入输出张量信息。通过这样的设计,可以让自研框架与通用模型格式实现无缝对接。
以下示例演示了如何初始化 ONNX Runtime、创建会话,以及准备输入数据。注意,这里仅展示最核心的初始化步骤,实际项目中应增加错误处理与资源管理。
#include <onnxruntime_cxx_api.h>
#include <vector>
#include <string>int main() {// 创建环境Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "example");// 会话选项Ort::SessionOptions session_options;session_options.SetIntraOpNumThreads(1);session_options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_EXTENDED);// 加载 ONNX 模型const char* model_path = "model.onnx";Ort::Session session(env, model_path, session_options);// 读取输入输出名称(仅演示用途,实际应缓存名称以避免重复获取)Ort::AllocatorWithDefaultOptions allocator;auto input_name = session.GetInputName(0, allocator);auto output_name = session.GetOutputName(0, allocator);// 继续:创建输入张量、执行推理、获取输出// ... 此处省略数据准备与推理代码return 0;
}
上述代码展示了与 ONNX Runtime 的对接入口,环境、会话和名称获取是最基本的框架骨架。实际应用中需要将输入数据从自研张量转为 Ort::Value,确保数据类型与维度匹配。
3.2 推理调用与输出处理
完成会话创建后,下一步是将输入数据封装为 ONNX Runtime 的输入张量,并执行推理以获取输出。关键点在于:输入数据对齐、推理调用以及对输出的后处理。通过统一的接口,可以在自研前向计算和 ONNX Runtime 之间实现无缝数据流。
下面的示例演示了如何构造一个输入张量、执行推理并读取输出。此处以浮点数据为例,输出通常需要进一步的后处理或解码。
// 假设 X 的形状为 [1, K], W 的形状为 [K, N], 以 FLOAT 为例
std::vector input_values = {/* 输入数据 */};
std::vector input_dims = {1, static_cast(K)};// 创建输入 Ort::Value
Ort::Value input_tensor = Ort::Value::CreateTensor(allocator, input_values.data(), input_values.size(), input_dims.data(), input_dims.size());// 推理执行
auto output_tensors = session.Run(Ort::RunOptions{nullptr},&input_name, &input_tensor, 1, &output_name, 1);// 读取输出
float* float_array = output_tensors.front().GetTensorMutableData();
size_t output_len = output_tensors.front().GetTensorTypeAndShapeInfo().GetElementCount();
// 对输出进行后处理
通过上述流程,自研框架与 ONNX Runtime 的整合点便是数据的封装、推理调用与输出处理的完整路径。实际使用中,还可以将输入输出封装成更通用的接口,方便其他算子与自定义逻辑的接入。
3.3 与自定义算子/扩展
ONNX Runtime 支持自定义算子扩展,以应对特殊硬件或自研算子的需求。对于需要在推理中加入自定义算子的场景,可以将自定义实现挂载到 ONNX Runtime 的加载路径,保持模型可移植性的同时提升特定算子的执行效率。
在自研推理框架中,可以通过 适配层 将 ONNX Runtime 的输出对接回自研数据结构,确保数据格式一致性与内存管理的统一性。这种设计使得未来从纯自研算子向混合算子集成为一个渐进的过程。
通过以上章节的实现思路,您可以在 C++ 环境中搭建一个简单但可扩展的神经网络推理框架,并以 ONNX Runtime 作为通用模型执行后端。框架的核心在于清晰的模块划分:数据结构层、前向传播核心,以及与 ONNX Runtime 的集成桥接。对于 AI 推理的实战场景,这种组合既保留了自研的灵活性,又获得了 ONNX 的生态覆盖。



