这篇文章写给刚接触推理框架的读者。
重点是把实现路径说清楚,不追求把每个细节一次讲完。
项目地址:github.com/WWandP/mini...
本文围绕三个问题推进:
miniONNXRuntime里Execution Provider(简称EP)是怎么接进去的- mac 上的
Accelerate EP和CUDA EP分别怎么实现 - 在 ONNX Runtime 中,同类问题一般怎么处理
1. 背景:为什么要做 EP
先解释两个词:
Runtime:运行时。可以理解成"负责执行模型的一套程序"。ONNX:一种模型交换格式。很多训练框架都能导出 ONNX。
如果没有 EP,最常见的做法是:所有算子都走同一条 CPU 路径。 这样可以先跑通,但后续扩展会越来越困难。
EP 的作用是把"算子由谁执行"这件事独立出来。 例如同一个 MatMul(矩阵乘法),可以由 CPU 执行,也可以由 CUDA 执行。
再补一层直觉: 模型推理可以看成"很多节点按顺序执行"。每个节点本质上是一种算子(如 Conv、Add、MatMul)。 EP 不改模型语义,它只决定"这个节点由哪个后端来算"。
可以先看这张总览图:
2. 这个项目里的 EP 接口
在 miniONNXRuntime 中,EP 接口很小:
cpp
class ExecutionProvider {
public:
virtual ~ExecutionProvider() = default;
virtual std::string_view Name() const = 0;
virtual void RegisterKernels(KernelRegistry& registry) const = 0;
virtual std::shared_ptr<TensorAllocator> CreateTensorAllocator() const = 0;
};
三个方法分别做什么:
Name():返回后端名字,比如"CPU"、"CUDA"。RegisterKernels(...):注册本后端能处理的算子实现。CreateTensorAllocator():返回内存分配器。 内存分配器可以理解成"申请和复用 tensor 内存的模块"。
为了读起来更顺,这里再解释四个高频词:
Kernel:某个算子的具体实现代码。 例如同样是Add,CPU kernel 和 CUDA kernel 会是两套实现。Kernel Registry:算子名到实现函数的映射表。 运行时会在这里查"这个节点该调用哪个实现"。Session:一次完整推理过程的组织者。 它负责装配 provider、执行节点、收集结果。Execution Context:运行时数据仓库。 中间张量和输出张量会绑定在这里。
从职责上看,这个项目里 EP 主要承担两类事:
- 报告"我支持哪些算子"(通过注册 kernel)
- 报告"我偏好的内存分配方式"(通过 allocator)
这两件事拆开后,后端扩展就不需要反复改调度主干代码。
3. 调度流程:Session 如何选择后端
这里先解释 Session: 它是一次模型执行会话,负责加载图、组织执行、统计结果。
当前策略是 kFirstMatch(先匹配先使用):
- 按 provider 顺序遍历
- 某个 provider 声明支持该算子,就把该算子分给它
- 后面的 provider 不再覆盖
简化代码如下:
cpp
for (const auto& provider : providers_) {
KernelRegistry provider_registry;
provider->RegisterKernels(provider_registry);
for (const auto& [op_type, fn] : provider_registry.Entries()) {
if (!kernel_registry_.Has(op_type)) {
kernel_registry_.Register(op_type, fn);
}
}
}
默认顺序:
- 开启 CUDA 构建时:
CUDA -> Accelerate(mac) -> CPU - 未开启 CUDA 时:
Accelerate(mac) -> CPU或CPU
这意味着 CPU 是兜底路径,前面的 EP 优先尝试。
这里再补一个容易混淆的点: kFirstMatch 采用"顺序优先"策略,不按速度动态选择。
是否命中 CUDA/Accelerate 由 provider 排序和算子覆盖率共同决定。
可以把这件事理解成下面这张图:
对应到执行期,大致是:
4. mac Accelerate EP 的实现思路
先解释 Accelerate: 它是 Apple 提供的数学加速库,包含 BLAS、向量计算等能力。
4.1 接入方式
AccelerateExecutionProvider 先完成最小接入:
- 提供名字:
"Accelerate" - 注册一批算子 kernel
- 分配器先复用
CpuTensorAllocator
先复用 CPU 分配器是有意为之。
这个阶段先验证"算子能分配给新后端并正确执行",设备内存系统放在后续阶段完善。
4.2 计算路径
这条 EP 的主要做法:
- 向量算子:调用
vDSP / vForce - 矩阵算子:调用
cblas_sgemm - 卷积:
im2col + GEMM
这里解释两个词:
im2col:把卷积滑窗展开成矩阵,便于复用矩阵乘法。GEMM:通用矩阵乘法(General Matrix Multiply)。
简化后的卷积主计算:
cpp
FillIm2ColBuffer(batch_input, params, columns);
cblas_sgemm(CblasRowMajor, CblasNoTrans, CblasNoTrans,
c_out, output_hw, kernel_dim,
1.0f, weight, kernel_dim,
columns, output_hw,
0.0f, out, output_hw);
对应的计算流程可以看成:
5. CUDA EP 的实现思路
先解释两个词:
CUDA:NVIDIA 的 GPU 计算平台。cuBLAS:CUDA 生态里的矩阵计算库。
5.1 当前版本的定位
当前实现是"教学型混合执行":
- 一部分算子在 CUDA 上执行
- 失败时可以回退 CPU 路径
- 数据仍以 host(CPU 内存)为主,按算子搬运到 device(GPU 内存)
常见模式:
cpp
try {
output = RunCudaBinaryFloatOp(node, context, "Add", CudaBinaryFloatOp::kAdd);
} catch (const CudaError& ex) {
output = RunBinaryNumericFallback(node, context, "Add", ...);
}
这样做的优点是稳定、好调试。
代价是数据搬运次数较多,整体性能还有优化空间。
这条路径可以用一张简图表示:
5.2 MatMul / Gemm 的关键点
代码里处理了 row-major 与 column-major 的映射问题。 这是 GPU 矩阵计算里很常见的坑,处理正确才能保证结果一致。
6. 跨后端算子实操:同一个算子,三种写法
前面讲的是 EP 框架,真正落地时最难的部分通常是"补算子"。 因为一个算子要在多个后端都正确,往往同时涉及:
- 数据类型(
float32/int64等) - 形状与广播(broadcast)
- 内存布局(row-major / column-major)
- 错误处理与回退策略
下面用两个常见算子做对比:Add(入门)和 MatMul(进阶)。
6.1 例子一:Add(逐元素加法)
Add 看起来简单,但最容易踩的坑是广播。 例如 [B, T, C] + [C] 是 GPT 类模型里的常见形状。
三种后端思路:
- CPU:直接循环实现,优先保证语义正确
- mac Accelerate:同 shape 时走向量化快路径,不满足时走通用路径
- CUDA:优先走 GPU kernel,异常时回退 CPU
可以先把执行决策写成这张图:
实操建议:
- 先把 CPU 广播版本写完整,作为"真值实现"
- mac / CUDA 的快路径只覆盖高频场景(同 shape、标量广播、末维广播)
- 快路径失败时,回退到通用实现,先保稳定
6.2 例子二:MatMul(矩阵乘法)
MatMul 更能体现后端差异,因为它直接依赖底层数学库:
- CPU:朴素实现或已有 CPU kernel
- mac Accelerate:用
cblas_sgemm - CUDA:用
cuBLAS,并处理布局映射
关键在于先把输入整理成库期望的形态,再调用对应库函数。
常见流程是:检查维度 -> 处理 batch 维 -> 调库 -> 写回输出。
这里有一个非常实用的经验:
- CPU 路径先做"可读、可验证"
- Accelerate / CUDA 路径再做"高频场景优化"
- 每补一个快路径,都和 CPU 基线做数值对比
6.3 为什么三端不能写成完全一样
很多读者会问:既然是同一个 Add/MatMul,为什么不能一套代码通吃?
主要原因是底层能力不同:
- CPU 适合通用逻辑和复杂分支,调试成本低
- Accelerate 依赖 Apple 数学库,最适合向量/矩阵批量计算
- CUDA 依赖 GPU 并行和设备内存,适合大规模并行算子
所以实操上通常是"语义统一,路径分化": 对外都叫 Add/MatMul,内部根据 provider 走不同实现。
7. 如何验证这两条 EP 路径
这个工具会构造两条会话:
- 默认 provider 路径(可能包含 CUDA / Accelerate)
- 纯 CPU 路径
然后比较:
mixed_ms:混合路径耗时cpu_only_ms:CPU 路径耗时speedup_pct:加速比例
这是一个很实用的工程习惯: 每次改 EP 后都保留一个可重复的基线对比。
对比关系可以简化为:
8. 对照 ONNX Runtime
8.1 接口层
ONNX Runtime 的 IExecutionProvider 除了 kernel registry,还包含:
GetCapability(...):告诉框架"我能接哪些子图" 这里的"子图"是原图中的一段节点集合。GetDataTransfer():设备间数据拷贝能力OnRunStart/OnRunEnd:一次 Run 的生命周期回调Sync():同步设备执行状态
8.2 调度与分图
ONNX Runtime 会通过 GraphPartitioner 做图分区(partition):
先按能力切分子图,再分配到各个 EP。
8.3 CUDA 路径
ONNX Runtime 一般还会包含:
- 更完整的设备分配器(arena/mempool)
- pinned memory 与数据传输管理
- stream(执行流)与异步调度
- 更大规模算子覆盖
8.4 Apple 路径
mini 当前是 AccelerateExecutionProvider,属于算子级加速。 ORT 中常见的是 CoreMLExecutionProvider,通常会做子图下沉和编译执行。
9. 小结
这版实现优先把架构边界搭好,再持续提升性能:
- EP 抽象成立
- mac 和 CUDA 走同一套调度入口
- CPU 保持可用兜底
- 有稳定的对比工具验证改动效果
这套基础打好后,再继续做覆盖率和性能优化,成本会低很多。
10. 下一步工作
下一篇文章将会进入 GPT-2 推理主线的研究。