我给 miniONNXRuntime 做了 mac + CUDA 的 EP,这里是实现思路

这篇文章写给刚接触推理框架的读者。

重点是把实现路径说清楚,不追求把每个细节一次讲完。

项目地址:github.com/WWandP/mini... 本文围绕三个问题推进:

  1. miniONNXRuntimeExecution Provider(简称 EP)是怎么接进去的
  2. mac 上的 Accelerate EPCUDA EP 分别怎么实现
  3. 在 ONNX Runtime 中,同类问题一般怎么处理

1. 背景:为什么要做 EP

先解释两个词:

  • Runtime:运行时。可以理解成"负责执行模型的一套程序"。
  • ONNX:一种模型交换格式。很多训练框架都能导出 ONNX。

如果没有 EP,最常见的做法是:所有算子都走同一条 CPU 路径。 这样可以先跑通,但后续扩展会越来越困难。

EP 的作用是把"算子由谁执行"这件事独立出来。 例如同一个 MatMul(矩阵乘法),可以由 CPU 执行,也可以由 CUDA 执行。

再补一层直觉: 模型推理可以看成"很多节点按顺序执行"。每个节点本质上是一种算子(如 ConvAddMatMul)。 EP 不改模型语义,它只决定"这个节点由哪个后端来算"。

可以先看这张总览图:

flowchart LR M[模型图] --> N[节点列表] N --> Q{节点由谁执行?} Q --> E1[EP A 例如 CUDA] Q --> E2[EP B 例如 Accelerate] Q --> E3[EP C 例如 CPU] E1 --> R[结果写回上下文] E2 --> R E3 --> R

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 主要承担两类事:

  1. 报告"我支持哪些算子"(通过注册 kernel)
  2. 报告"我偏好的内存分配方式"(通过 allocator)

这两件事拆开后,后端扩展就不需要反复改调度主干代码。


3. 调度流程:Session 如何选择后端

这里先解释 Session: 它是一次模型执行会话,负责加载图、组织执行、统计结果。

当前策略是 kFirstMatch(先匹配先使用):

  1. 按 provider 顺序遍历
  2. 某个 provider 声明支持该算子,就把该算子分给它
  3. 后面的 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) -> CPUCPU

这意味着 CPU 是兜底路径,前面的 EP 优先尝试。

这里再补一个容易混淆的点: kFirstMatch 采用"顺序优先"策略,不按速度动态选择。

是否命中 CUDA/Accelerate 由 provider 排序和算子覆盖率共同决定。

可以把这件事理解成下面这张图:

flowchart LR N[模型节点] --> P1{Provider 1 支持?} P1 -- 是 --> A1[分配给 Provider 1] P1 -- 否 --> P2{Provider 2 支持?} P2 -- 是 --> A2[分配给 Provider 2] P2 -- 否 --> CPU[回退到 CPU Provider]

对应到执行期,大致是:

flowchart TD S[Session 开始] --> L[加载输入与初始张量] L --> K[逐节点查 kernel] K --> X{找到实现?} X -- 是 --> R1[执行节点] X -- 否 --> R2[按策略跳过或报错] R1 --> U[写回上下文] U --> K R2 --> K K --> E[Session 结束 输出结果]

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);

对应的计算流程可以看成:

flowchart TD I[输入特征图] --> C1[im2col 展开] C1 --> C2[GEMM 矩阵乘法] C2 --> C3[可选激活函数 如 SiLU] C3 --> O[输出特征图]

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", ...);
}

这样做的优点是稳定、好调试。

代价是数据搬运次数较多,整体性能还有优化空间。

这条路径可以用一张简图表示:

flowchart TD H[CPU 内存输入] --> D1[拷贝到 GPU] D1 --> G[CUDA Kernel 或 cuBLAS 计算] G -->|成功| D2[结果拷回 CPU 内存] G -->|失败| F[CPU fallback 计算] F --> D2

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 类模型里的常见形状。

三种后端思路:

  1. CPU:直接循环实现,优先保证语义正确
  2. mac Accelerate:同 shape 时走向量化快路径,不满足时走通用路径
  3. CUDA:优先走 GPU kernel,异常时回退 CPU

可以先把执行决策写成这张图:

flowchart TD A[Add 输入 lhs rhs] --> B{形状关系} B -->|同 shape| C[走快路径] B -->|可广播| D[走广播路径] B -->|不合法| E[报错] C --> F[输出] D --> F

实操建议:

  • 先把 CPU 广播版本写完整,作为"真值实现"
  • mac / CUDA 的快路径只覆盖高频场景(同 shape、标量广播、末维广播)
  • 快路径失败时,回退到通用实现,先保稳定

6.2 例子二:MatMul(矩阵乘法)

MatMul 更能体现后端差异,因为它直接依赖底层数学库:

  1. CPU:朴素实现或已有 CPU kernel
  2. mac Accelerate:用 cblas_sgemm
  3. CUDA:用 cuBLAS,并处理布局映射

关键在于先把输入整理成库期望的形态,再调用对应库函数。

常见流程是:检查维度 -> 处理 batch 维 -> 调库 -> 写回输出。

flowchart LR I[输入 A B] --> V[维度与类型校验] V --> P[计算输出形状 含 batch 广播] P --> M[映射到后端矩阵库调用] M --> O[输出 Tensor]

这里有一个非常实用的经验:

  • 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 后都保留一个可重复的基线对比。

对比关系可以简化为:

flowchart TD X[同一个模型 同一份输入] --> A[路径 A 混合 Provider] X --> B[路径 B 纯 CPU] A --> R[比较耗时和结果一致性] B --> R

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 推理主线的研究。

相关推荐
东离与糖宝2 小时前
医疗辅助 Agent:病历解读、文献检索、指南对照
人工智能
天一生水water2 小时前
机器学习中的小提琴图有什么作用
人工智能·机器学习
古城小栈2 小时前
Rust在当下AI领域的用武之地:从底层加速到上层应用全解析
开发语言·人工智能·rust
一次旅行2 小时前
2026最新AI编程模式总结
人工智能
东离与糖宝2 小时前
泛化能力基础:AI 适应新数据的关键
人工智能
番石榴AI2 小时前
TalkSheet:AI 驱动的 Excel 分析应用,聊天式操作 + 智能图表
人工智能·qa·chatexcel
bryant_meng2 小时前
【Reading Notes】(8.7)Favorite Articles from 2025 July
人工智能·深度学习·agi·资讯
穿条秋裤到处跑2 小时前
java2AI系列:SpringAI 通过 Function Calling 接入外部系统
人工智能·ai
byte轻骑兵2 小时前
【LE Audio】ASCS精讲[6]: 从配置到流传输 ASE控制全流程拆解
人工智能·音视频·蓝牙·le audio·低功耗音频