如果把 ONNX Runtime 比作一间中央厨房,那么它菜单上的菜品,就是内置的数百种标准算子:卷积、激活、归一化……只要客人点单,厨房立刻能做。可是,当一位来自学术实验室的客人递来一张新菜谱——比如论文里刚提出的 FusedGeLU 激活函数——厨房就只能摊手说“这道菜我不会做”,整个订单(模型推理)直接失败。
Custom Op(自定义算子)机制,正是为了解决这个尴尬而设计的“特聘厨师通道”。它允许开发者把独家菜谱带进厨房:先填写一张菜品档案卡(Schema,写清菜名、食材和分量),再展示自己的独门手艺(Kernel 计算逻辑),厨房把这些信息登记进菜单册(注册),之后任何模型文件里出现这道菜,Runtime 都能认出并烹饪。本篇文章,就带你完整走一遍“新菜进入中央厨房”的全流程:从为什么需要自定义算子,到如何注册、定义 Schema、推导形状、实现计算内核、编译部署,再到最后的试菜验收。读完你会明白:扩展一个推理引擎,其实比想象中简单得多。
对 AI 从业者而言,掌握 Custom Op 意味着不再受制于内置算子清单,能独立将新算法、硬件优化落地到任何需要 ONNX Runtime 的生产环境,是算法工程化能力的分水岭之一。
可视化演示
演示ONNX Runtime自定义算子机制:注册流程展示Domain/Schema/Kernel逐级注册与绑定;形状推理展示输入维度经自定义规则传播到输出;异构执行对比CPU/CUDA/TensorRT多执行提供者的内核加速差异。
本节配有交互动效(进入视口后自动加载,约 3KB)。
自定义算子注册流程 Domain→Schema→Kernel OrtCustomOpDomain 逐级注册将自定义计算绑定到ONNX图
定义与动机
中央厨房的菜单再厚,也一定会遇到客人点“菜单上没有的菜”。ONNX Runtime 内置了上百种标准算子——卷积、池化、归一化、激活——但研究论文刚发表的新算法、某个芯片厂商特有的融合计算,往往不在其中。当模型文件里出现这种未知算子时,Runtime 当场就会报出"Unsupported operator"错误,整个推理任务直接暂停,就像服务员拿着点菜单愣在原地。
面对这种情况,传统思路是“改厨房”:修改 ONNX Runtime 源码,把新算子写进主线再重新编译整个引擎。这种做法代价极高,相当于为了多一道菜,把灶台、烟道、排队系统全部推翻重来。Custom Op 机制的优雅之处在于——不用动厨房一根砖头,只需要请一位“特聘厨师”,带着自己的菜谱和手艺进场。它的核心工作是四件事:注册算子身份、填写 Schema 档案、实现形状推导、编写计算内核。
从数学角度看,任何一个自定义算子本质上都是一条映射规则:它吃进若干输入张量,吐出一个或多个输出张量,同时受一组属性(超参数)控制。
回到厨房的比喻:Y 就是客人最终端走的那盘菜,X 是备好的食材,a 记录着“少盐多辣”这类口味偏好,而 F 是厨师手中那套秘而不宣的烹饪手法。Custom Op 的全部工作,就是把这四个要素明明白白地告诉 Runtime。
- 算子的输出张量集合等式左侧的大写 Y 代表该算子最终产生的所有输出张量,可以是一个,也可以是多个。
- 自定义算子的映射函数大写 F 是这个算子定义的完整计算规则,对应你写的 Kernel 逻辑。
- 第 i 个输入张量X₁ 到 Xₙ 是算子吃进去的输入,个数由 Schema 中的输入定义固定。
- 第 j 个自定义属性a₁ 到 aₘ 是算子的属性,如缩放系数、轴方向等,以键值对形式写在模型节点里。
注册机制
新菜要进入菜单,第一步是在菜单册上登记。ONNX Runtime 内部维护着一张全局注册表(Registry),它像一本活页菜单,记录着所有“Runtime 认识的算子”的名单。模型加载时,Runtime 逐个检查图里的算子节点:读到的名字在注册表上,就通过;不在,就报错。Custom Op 的注册,就是在菜单册上添加一页。
C++ 侧,开发者通过 OrtCustomOp 结构体和 RegisterCustomOps 接口完成注册;Python 侧,一个 @PythonOp 装饰器就能把类与算子名绑定。注册时机非常关键:必须在创建 InferenceSession 之前完成,因为 Session 加载模型时就会查表。一旦注册成功,注册表里就多了一条长期有效的记录,直到进程结束。
菜单册的故事在这里有了新篇章:Schema 像是菜名和食材说明,Kernel 像是后厨的实际做法,注册则是把它们一起钉进菜单册的动作。只要钉进去了,以后客人重复点单,Runtime 翻册即做,无需重新“传授手艺”。
- 注册动作Reg 表示一次注册调用,它把两种信息绑定在一起。
- 算子的档案与实现冒号左侧的 Schema 是算子档案,Kernel 是计算实现,二者缺一不可。
- 生成为注册表条目箭头右侧表示注册完成后,这个算子正式成为全局注册表 Registry 的成员。
算子Schema
特聘厨师进场,第一件事是填一张“菜品档案卡”,这就是 Schema。它不描述菜怎么做,而是回答三个问题:这道菜叫什么名字?需要哪些食材(输入)?最终端出几盘菜(输出)?以及有没有诸如“辣度”这样的可选备注(属性)?
在 ONNX Runtime 中,Schema 使用 ONNX 标准的算子定义语言来描述。算子的名字必须是合法标识符且不能与内置算子冲突,相当于不能让“宫保鸡丁”在菜单上出现两次;每个输入和输出都要给名称,并限定数据类型范围,比如只接受 float 张量;属性则有类型(int、float、string 或列表)和默认值。Runtime 加载模型时会用 Schema 逐项比照模型节点,任何不符都会拒绝执行。
档案卡填得越清楚,厨房备菜越省心。客人点菜时,服务员只需照卡确认食材齐不齐、口味备注有没有,就能下单;Runtime 也是如此——有了 Schema,它才能在真正执行计算之前,快速判断这个模型节点“能不能做”。
- 算子的完整档案Schema 是关于某个算子 op 的完整结构描述,右侧三元组是它的三个组成部分。
- 输入张量集合I 是所有输入张量的集合,花括号里列出每个输入张量的名字。
- 输出张量集合O 是输出张量的集合,定义了输出的数量与每个输出的名称。
- 属性集合A 是属性的集合,每个属性都有名称、类型和默认值,对应模型节点里写的
alpha=1.0这类键值对。
形状推导
食材备好了,资深备菜师傅会先估个量:这桌菜做出来大概几斤几两、用什么尺寸的盘子装。这个过程对应到 Custom Op,就是形状推导(InferShape)。它是模型加载阶段的一次“预演”:根据输入张量的形状和算子属性,推算出输出张量应该长什么样。Runtime 拿到这个信息后,才能提前为输出分配内存。
形状推导规则可能很简单,比如逐元素操作保持形状不变;也可能依赖输入的实际数值,比如 Reshape 要把目标形状算出来。更复杂的情况是动态形状——当输入尺寸运行时才确定时,InferShape 也要保证输出形状能正确表达。形状推算出错,轻则模型加载失败,重则执行时内存越界,程序直接崩溃。因此,在填写 InferShape 回调时,一定要对输入维度和属性做合法性检查。
备菜师傅估准分量,厨房才不会浪费盘子,也不会端出超量的菜。InferShape 就是这个“估分量”的师傅:它预估得越准,Runtime 的内存规划就越从容,整个推理管线也就越稳。
- 输出张量的形状信息左侧表示输出张量 y 在第 i 个维度上的尺寸。
- 形状映射规则g 是形状推导函数,它描述“输入长什么样 → 输出长什么样”的规则。
- 输入形状作为自变量括号里的 shape(x₁) 到 shape(xₙ) 是各输入张量的形状信息。
- 属性参与形状决策分号后的 a₁ 到 aₘ 是属性,比如做矩阵拼接时,沿哪个轴拼就由属性决定。
计算内核
档案卡和分量预估都只是准备工作,真正让食材变成菜肴的,是灶台前的“独门手艺”——计算内核(Kernel)。在 Custom Op 机制中,Kernel 是一个实现了实际计算逻辑的类。它通常分两步:构造阶段读取并缓存算子属性,计算阶段遍历输入张量,把结果写入输出张量。
比如当属性里写了 alpha=1.0,Kernel 的构造函数就会把这个值从节点属性中提取出来存好,之后每次计算直接使用,避免反复解析。计算阶段的核心是一个逐元素或分块的循环:从输入张量的数据指针出发,按内存布局依次读取元素,套用公式算出结果,再写进输出缓冲区。另外,Kernel 还要考虑设备分派——CPU 版本写普通 C++ 循环,GPU 版本则要写 CUDA Kernel,并在注册时声明自己的执行设备。
灶台前的故事很简单:同一个火候、同一味调料,厨师的手微微一抖就是千差万别。Kernel 就是那双手,Schema 决定了手上拿什么食材,而手艺是否出众,直接决定菜品好不好吃——也就是算子的执行速度和数值精度。
- 输出张量的第 i 个元素yᵢ 是输出张量中的一个元素,Kernel 的最终任务就是填满所有 yᵢ。
- 内核映射函数h 是对每个元素执行的计算逻辑,即你在 compute 函数里写的那段代码。
- 对应位置的输入元素xᵢ 是与 yᵢ 位置对应的输入张量元素。
- 构造阶段缓存的属性a₁ 到 aₘ 在构造时被提取并缓存,计算时直接使用,节省重复解析开销。
构建部署
新菜的菜谱和手艺都齐了,但要让它真正出现在客人面前,还需要“装订成册,摆上货架”。在 Custom Op 的语境里,这一步是把写好的 C++ 代码编译成动态链接库,并在运行时把它加载进 ONNX Runtime。库文件就像一本合订食谱,里面可以同时存放多个自定义算子。
C++ 实现编译产生的动态库,需要暴露一个统一的注册入口,ONNX Runtime 通过这个入口把库里的所有算子登记到注册表中。Python 侧则更简单:使用 @PythonOp 直接注册,或用 SessionOptions 的接口加载已有动态库,无需重写逻辑。部署时还需注意跨平台兼容——Linux 编译出 .so,Windows 编译出 .dll,macOS 编译出 .dylib,各自提供给对应平台使用。模型文件本身不需要重新导出,推理时只要 Session 能加载到算子库,模型就能正常运行。
把菜谱册放到厨房书架上,并不意味着厨房会自动知道它的存在——你得亲手把它递给主厨。Custom Op 的部署逻辑完全一致:动态库编译好只是第一步,显式调用注册加载才是让 Runtime 真正“认账”的关键一步。
- Runtime 的输入模型M 是含未知算子节点的 ONNX 模型,它是推理的“点菜单”。
- Runtime 的输入算子库L 是保存了自定义算子实现的动态库文件,它是“菜谱册”。
- 组合成可执行图箭头右侧的 ExecGraph 表示经过加载、注册和优化后,Runtime 内部真正可执行的计算图。
调试验证
一道新菜正式上菜单前,总得有个试菜环节:厨艺老师和质检员对比这道菜与标准做法的差距,确认味道达标、分量正确、出菜速度够快。自定义算子的调试验证,就是这个流程的工程化版本。
最核心的手段是“对照实验”:用 NumPy 或参考库独立实现一遍同样的数学公式,再喂相同的输入给 Custom Op,比较两者输出的最大误差。形状是否正确也要单独验证——输出张量的每个维度都必须与 InferShape 的声明一致,否则后续算子会读到错位的数据。性能方面,可以用 profiling 工具统计算子耗时占比,判断它是否成为推理管线的瓶颈。此外,还要警惕几个高频陷阱:算子名与内置算子冲突、属性类型读错、动态库符号没有正确导出。
试菜不通过,菜谱绝不能上正式菜单。一个只跑通了正确路径、却没经过误差对比和边界测试的自定义算子,就像一道只试吃过一次的家常菜——在真正的晚高峰(生产环境)里,随时可能翻车。调试验证,是 Custom Op 走向生产的最后一道门槛。
- 自定义算子的输出张量y_custom 是你的 Kernel 实际计算出的结果。
- 参考实现的输出张量y_ref 是用 NumPy 等独立工具算出的“标准答案”。
- 最大绝对误差对两个张量逐元素求差取绝对值,再在所有元素中取最大值,就是整个算子的最大偏离程度。
AI 应用场景
语音识别中的自定义 MEL 滤波器算子
阶段:模型转换与推理部署阶段
解决的问题:语音模型的特征提取(MEL 滤波器组)标准 ONNX 算子库里没有直接实现。传统做法是在前处理服务中用 librosa 单独算好特征再喂给模型,导致推理链路多一跳、延迟高,流式场景下耦合尤其严重。
具体案例:某语音团队将 MEL 特征提取写成 Custom Op 注册进 ONNX Runtime,模型推理时直接从原始波形算特征,省去了独立的前处理服务。端到端时延降低约 30%,部署拓扑也简化成单一推理服务。
推荐系统中的融合 Embedding 聚合算子
阶段:推理性能优化阶段
解决的问题:推荐模型里常见的“多特征 Embedding 加权求和再拼接”结构,在 ONNX 中需要拆成 Gather、ReduceSum、Concat 等多个算子,每次都要单独启动内核,浪费显存带宽和调度开销。
具体案例:机器学习平台团队写了一个 FusedEmbeddingReduce 的 C++ Custom Op,把多次内核调用合并成一次,线上推荐服务的 p99 延迟下降 40%,同时显存占用明显减小。
新激活函数的快速原型验证
阶段:算法研究与工程验证阶段
解决的问题:研究人员提出新的激活函数或注意力变体,如果每次都要重新编译整个推理引擎来验证效果,迭代周期太长。
具体案例:一位研究工程师用 Python Op 机制几分钟完成新激活函数的注册,直接在 ONNX Runtime 上加载真实模型对比精度与耗时。验证有效之后,再用 C++ 重写并编译成动态库,无缝迁移到生产环境。
Python 演示
# 演示 ONNX Runtime 自定义算子:从模型构造到注册推理import numpy as npimport onnxfrom onnx import helper, TensorProtoimport onnxruntime as ortfrom onnxruntime import PythonOp def build_model(): """构造一个包含自定义算子 FusedGeLU 的小模型""" X = helper.make_tensor_value_info("X", TensorProto.FLOAT, [2, 4]) Y = helper.make_tensor_value_info("Y", TensorProto.FLOAT, [2, 4]) node = helper.make_node("FusedGeLU", ["X"], ["Y"], alpha=1.0) graph = helper.make_graph([node], "tiny_graph", [X], [Y]) opset = helper.make_opsetid("", 17) return helper.make_model(graph, opset_imports=[opset]) # 用 Python 注册自定义算子,相当于把新菜登记进菜单册@PythonOp("FusedGeLU", since_version=1)class FusedGeLU: """自定义算子内核:y = alpha * GELU(x)""" def __init__(self, alpha: float = 1.0): self.alpha = alpha def compute(self, x): x = x.to_py() if hasattr(x, "to_py") else x sqrt_2pi = np.sqrt(2.0 / np.pi) y = self.alpha * 0.5 * x * ( 1.0 + np.tanh(sqrt_2pi * (x + 0.044715 * x ** 3)) ) return y if __name__ == "__main__": model = build_model() sess = ort.InferenceSession( model.SerializeToString(), providers=["CPUExecutionProvider"], ) x = np.random.randn(2, 4).astype(np.float32) y = sess.run(None, {"X": x})[0] print("自定义算子输出 shape:", y.shape) print("输出前 4 个元素:", y.reshape(-1)[:4])
FusedGeLU 自定义算子的 ONNX 模型,用 Python Op 机制注册该算子,最后加载 Session 并完成一次真实推理。node = helper.make_node("FusedGeLU", ["X"], ["Y"], alpha=1.0)在模型图中插入自定义算子节点,相当于点菜单上写了一道新菜。@PythonOp("FusedGeLU", since_version=1)注册装饰器,把类的实现与算子名绑定,是“登记进菜单册”的 Python 快捷方式。def __init__(self, alpha: float = 1.0):构造阶段读取属性 alpha 并缓存,对应 Kernel 的属性提取逻辑。x = x.to_py() if hasattr(x, "to_py") else x把框架张量转成 NumPy 数组,便于用向量化方式做计算。y = self.alpha * 0.5 * x * (1.0 + np.tanh(...))GELU 近似公式的融合计算主体,是这道“菜”的核心手艺。sess = ort.InferenceSession(model.SerializeToString(), ...)加载含自定义算子的模型,此时 Runtime 到注册表中查询 FusedGeLU。sess.run(None, {"X": x})[0]触发推理,数据流经自定义算子节点,最终拿到输出张量。
常见误区
误区“我以为要 fork ONNX Runtime 源码,把新算子写进 C++ 主线,重新编译整个引擎才能用新算子。这也太劝退了,我一直不敢碰 Custom Op。”
正解实际上 Custom Op 机制的存在意义就是“不动源码只加插件”。通过注册 API 和动态库机制,运行时就能认识新算子。就像厨房不用拆墙重建,只需把新菜谱登记进菜单册。修改源码是最后的兜底手段,对绝大多数场景都不是必需路径。
为何常见:看到官方教程大量使用 C++ 代码,容易误会“必须进入主线”,而忽略了注册 API 本身就是设计给第三方做扩展的。
误区“算子的核心是计算,形状推导只是形式主义。我在 compute 里已经知道输出长什么样了,InferShape 随便写写甚至不写都行。”
正解InferShape 在模型加载阶段执行,Runtime 靠它来预分配输出内存。如果 InferShape 缺失或写错,模型可能直接加载失败;更隐蔽的是加载成功但输出形状与实际计算不符,推理时产生内存越界,程序崩溃且难以定位。备菜师傅不估量,后厨就会乱套。
为何常见:加载阶段的报错往往不明显,很多开发者要等推理出错时才回头排查,于是误以为 InferShape 不重要。
误区“我把 libMyOps.so 放到了和模型文件同一个文件夹里,ONNX Runtime 应该能自动发现并加载吧?毕竟很多框架的插件都是自动扫描的。”
正解ONNX Runtime 不会扫描磁盘上的动态库。它必须通过显式的注册加载流程来认识算子:常见做法是调用 RegisterCustomOps 注册入口,或在 Python 侧用 SessionOptions 的接口加载算子库。菜谱册必须亲手递到主厨手里,放在厨房角落是不会自动出现在菜单上的。
为何常见:其他生态(如浏览器插件、IDE 插件)普遍采用自动发现机制,容易让人把这种模式套用到推理引擎上。
误区“Python Op 注册又方便又省事,线上服务直接用它就好了,没必要再写 C++ 折腾编译。”
正解Python Op 在解释器层执行,且涉及张量在框架与 NumPy 之间的转换开销,适合原型验证和快速迭代。高吞吐、低延迟的线上服务仍需 C++ 实现并编译为动态库,才能压榨出硬件性能。试菜用家常锅没问题,但正式晚高峰还得上专业灶台。
为何常见:Python Op 的上手体验太顺滑,容易让开发者忽略解释器开销在生产规模下被放大后的严重性。
面试题
Q1. 关于 ONNX Runtime 自定义算子,下列说法正确的是?
- A.只能修改 Runtime 源码扩展算子
- B.通过注册机制添加 Runtime 不认识算子← 正确答案
- C.自定义算子不需要定义输入输出
- D.自定义算子仅能用于推理不能训练
答案:B。Custom Op 机制的核心就是通过注册 API 在不动源码的前提下扩展算子库,B 正确。A 与机制设计初衷相悖;C 错在 Schema 必须声明输入输出;D 是过度限定,自定义算子同样可参与训练场景。
Q2. 自定义算子的“菜品档案卡”在 ONNX Runtime 中对应下列哪个概念?
- A.算子的核心计算逻辑部分
- B.描述算子名称输入输出的档案← 正确答案
- C.编译生成的动态链接库文件
- D.存放模型的序列化文件夹
答案:B。Schema 就是算子的档案卡,记录名称、输入输出和属性;A 描述的是 Kernel,C 是部署产物,D 与算子无关。记住“档案卡对应 Schema”即可。
Q3. 形状推导(InferShape)的主要作用是什么?
- A.为输出张量分配内存提供形状信息← 正确答案
- B.加速 GPU 算子融合的并行计算
- C.优化网络模型的梯度更新策略
- D.压缩自定义算子的动态库体积
答案:A。InferShape 在模型加载阶段推算输出形状,Runtime 据此预分配内存。它不参与并行计算(B)、不优化梯度(C)、不处理库文件体积(D)。就像备菜师傅先估量分量,厨房才好备盘子。
Q4. 在自定义算子的 Schema 定义中,声明每条输入输出需要给出什么?
- A.名称数据类型和相关类型约束← 正确答案
- B.内存地址偏移量和字节长度
- C.线程调度优先级和队列编号
- D.节点在计算图中的执行序号
答案:A。Schema 的输入输出声明包含名称、数据类型与类型约束,Runtime 据此校验模型节点。地址偏移(B)和调度信息(C)属于运行时细节,执行序号(D)由计算图决定,均不属于 Schema 范畴。
Q5. 以下哪种方式可以让 ONNX Runtime“认识”含自定义算子的模型?
- A.修改模型内自定义节点的名称
- B.加载包含算子实现的动态链接库← 正确答案
- C.把自定义算子改成内建算子别名
- D.提高模型文件的 opset 版本号
答案:B。动态库中实现了算子逻辑并通过注册入口登记,Runtime 加载后才能识别。改节点名(A)无法解决问题,做别名(C)会与内建算子冲突,提高 opset(D)与算子识别无关。
Q6. 关于 Custom Op 的计算内核(Kernel),描述正确的有?
- A.实际执行计算逻辑的地方← 正确答案
- B.可以在构造阶段读取算子属性← 正确答案
- C.不参与图执行仅作标记
- D.需要按设备 CPU 或 GPU 分派← 正确答案
答案:ABD。Kernel 是真正做计算的地方(A),构造阶段可提取并缓存属性(B),还要按设备分派不同实现(D)。C 恰好说反——Kernel 是图执行时的核心,不是标记。
Q7. 注册 Custom Op 之前模型加载失败,可能的原因有哪些?
- A.注册表中不存在该自定义算子名← 正确答案
- B.声明输入个数与模型节点不匹配← 正确答案
- C.形状推导返回错误形状致分配失败← 正确答案
- D.模型文件使用 yaml 格式存储
答案:ABC。注册表查不到算子名(A)、Schema 输入个数不匹配(B)、InferShape 返回错误形状(C)都会导致加载失败。D 属于基础常识错误——ONNX 模型用 protobuf 二进制存储,与 yaml 无关。
Q8. 关于自定义算子的动态形状推理,下列说法正确的是?
- A.输出形状必须与输入形状完全一致
- B.输出形状可以依赖输入形状和属性← 正确答案
- C.存在动态形状时无需调用 InferShape
- D.InferShape 只会在 GPU 设备上执行
答案:B。InferShape 的输出形状可由输入形状和属性共同推导,例如拼接类算子依据拼接轴属性决定输出维度。A 过于绝对,C 和 D 都错误——动态形状更需要 InferShape,且它在 CPU 上同样执行。
Q9. 在调试自定义算子时,适合采用的做法有哪些?
- A.用独立参考实现对比输出误差← 正确答案
- B.检查形状推导与实际计算维度一致← 正确答案
- C.关闭全部单元测试加速开发
- D.用性能剖析器定位计算热点← 正确答案
答案:ABD。独立参考实现是数值验证的金标准(A),形状一致性检查能避免内存越界(B),性能剖析可确认算子是否成为瓶颈(D)。关闭单元测试(C)只会让缺陷在线上爆发,明显不可取。
Q10. 与标准 ONNX 内置算子相比,自定义算子的主要部署风险是什么?
- A.自定义算子一定无法跨平台部署
- B.部署方需同时携带算子动态库文件← 正确答案
- C.自定义算子的计算速度必然更快
- D.自定义算子无需数值稳定性测试
答案:B。自定义算子以动态库形式分发,使用者必须同时部署库文件,否则模型推理会失败。A 绝对化,跨平台需要分别为各平台编译;C 未必成立,速度取决于实现;D 相反,数值稳定性测试恰恰是必做项。