如果把 ONNX Runtime 比作一间中央厨房,那么它菜单上的菜品,就是内置的数百种标准算子:卷积、激活、归一化……只要客人点单,厨房立刻能做。可是,当一位来自学术实验室的客人递来一张新菜谱——比如论文里刚提出的 FusedGeLU 激活函数——厨房就只能摊手说“这道菜我不会做”,整个订单(模型推理)直接失败。 Custom Op(自定义算子)机制,正是为了解决这个尴尬而设计的“特聘厨师通道”。它允许开发者把独家菜谱带进厨房:先填写一张菜品档案卡(Schema,写清菜名、食材和分量),再展示自己的独门手艺(Kernel 计算逻辑),厨房把这些信息登记进菜单册(注册),之后任何模型文件里出现这道菜,Runtime 都能认出并烹饪。本篇文章,就带你完整走一遍“新菜进入中央厨房”的全流程:从为什么需要自定义算子,到如何注册、定义 Schema、推导形状、实现计算内核、编译部署,再到最后的试菜验收。读完你会明白:扩展一个推理引擎,其实比想象中简单得多。

岗位关联

对 AI 从业者而言,掌握 Custom Op 意味着不再受制于内置算子清单,能独立将新算法、硬件优化落地到任何需要 ONNX Runtime 的生产环境,是算法工程化能力的分水岭之一。

目录
  1. 可视化演示
  2. 定义与动机
  3. 注册机制
  4. 算子Schema
  5. 形状推导
  6. 计算内核
  7. 构建部署
  8. 调试验证
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

演示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(X1,X2,…,Xn;a1,a2,…,am)Y = F(X_1, X_2, \dots, X_n; a_1, a_2, \dots, a_m)
这个公式是自定义算子的“总纲”:左边是输出,右边是函数本身、输入张量集合和属性集合。理解它,就理解了 Schema 和 Kernel 在分别描述什么。
  1. 算子的输出张量集合等式左侧的大写 Y 代表该算子最终产生的所有输出张量,可以是一个,也可以是多个。
    YY
  2. 自定义算子的映射函数大写 F 是这个算子定义的完整计算规则,对应你写的 Kernel 逻辑。
    FF
  3. 第 i 个输入张量X₁ 到 Xₙ 是算子吃进去的输入,个数由 Schema 中的输入定义固定。
    X1,X2,…,XnX_1, X_2, \dots, X_n
  4. 第 j 个自定义属性a₁ 到 aₘ 是算子的属性,如缩放系数、轴方向等,以键值对形式写在模型节点里。
    a1,a2,…,ama_1, a_2, \dots, a_m

注册机制

新菜要进入菜单,第一步是在菜单册上登记。ONNX Runtime 内部维护着一张全局注册表(Registry),它像一本活页菜单,记录着所有“Runtime 认识的算子”的名单。模型加载时,Runtime 逐个检查图里的算子节点:读到的名字在注册表上,就通过;不在,就报错。Custom Op 的注册,就是在菜单册上添加一页。

C++ 侧,开发者通过 OrtCustomOp 结构体和 RegisterCustomOps 接口完成注册;Python 侧,一个 @PythonOp 装饰器就能把类与算子名绑定。注册时机非常关键:必须在创建 InferenceSession 之前完成,因为 Session 加载模型时就会查表。一旦注册成功,注册表里就多了一条长期有效的记录,直到进程结束。

菜单册的故事在这里有了新篇章:Schema 像是菜名和食材说明,Kernel 像是后厨的实际做法,注册则是把它们一起钉进菜单册的动作。只要钉进去了,以后客人重复点单,Runtime 翻册即做,无需重新“传授手艺”。

Reg:(Schema,Kernel)↦CustomOp∈Registry\mathrm{Reg}: (\mathrm{Schema}, \mathrm{Kernel}) \mapsto \mathrm{CustomOp} \in \mathrm{Registry}
注册的本质,是把“档案卡(Schema)”和“手艺(Kernel)”打包成一个可查的算子条目,放进 Runtime 的全局菜单册。之后任何模型点这道菜,厨房都能找到对应做法。
  1. 注册动作Reg 表示一次注册调用,它把两种信息绑定在一起。
    Reg\mathrm{Reg}
  2. 算子的档案与实现冒号左侧的 Schema 是算子档案,Kernel 是计算实现,二者缺一不可。
    (Schema,Kernel)(\mathrm{Schema}, \mathrm{Kernel})
  3. 生成为注册表条目箭头右侧表示注册完成后,这个算子正式成为全局注册表 Registry 的成员。
    CustomOp∈Registry\mathrm{CustomOp} \in \mathrm{Registry}

算子Schema

特聘厨师进场,第一件事是填一张“菜品档案卡”,这就是 Schema。它不描述菜怎么做,而是回答三个问题:这道菜叫什么名字?需要哪些食材(输入)?最终端出几盘菜(输出)?以及有没有诸如“辣度”这样的可选备注(属性)?

在 ONNX Runtime 中,Schema 使用 ONNX 标准的算子定义语言来描述。算子的名字必须是合法标识符且不能与内置算子冲突,相当于不能让“宫保鸡丁”在菜单上出现两次;每个输入和输出都要给名称,并限定数据类型范围,比如只接受 float 张量;属性则有类型(int、float、string 或列表)和默认值。Runtime 加载模型时会用 Schema 逐项比照模型节点,任何不符都会拒绝执行。

档案卡填得越清楚,厨房备菜越省心。客人点菜时,服务员只需照卡确认食材齐不齐、口味备注有没有,就能下单;Runtime 也是如此——有了 Schema,它才能在真正执行计算之前,快速判断这个模型节点“能不能做”。

Schema(op)=(I,O,A),I={x1,…,xn},O={y1,…,ym},A={a1,…,ak}\mathrm{Schema}(op) = (I, O, A), \quad I = \{x_1, \dots, x_n\}, \quad O = \{y_1, \dots, y_m\}, \quad A = \{a_1, \dots, a_k\}
Schema 是算子的“身份证+食材清单”。它告诉 Runtime 这个算子长什么样、需要什么、给出什么,但不告诉它怎么做。
  1. 算子的完整档案Schema 是关于某个算子 op 的完整结构描述,右侧三元组是它的三个组成部分。
    Schema(op)\mathrm{Schema}(op)
  2. 输入张量集合I 是所有输入张量的集合,花括号里列出每个输入张量的名字。
    I={x1,…,xn}I = \{x_1, \dots, x_n\}
  3. 输出张量集合O 是输出张量的集合,定义了输出的数量与每个输出的名称。
    O={y1,…,ym}O = \{y_1, \dots, y_m\}
  4. 属性集合A 是属性的集合,每个属性都有名称、类型和默认值,对应模型节点里写的 alpha=1.0 这类键值对。
    A={a1,…,ak}A = \{a_1, \dots, a_k\}

形状推导

食材备好了,资深备菜师傅会先估个量:这桌菜做出来大概几斤几两、用什么尺寸的盘子装。这个过程对应到 Custom Op,就是形状推导(InferShape)。它是模型加载阶段的一次“预演”:根据输入张量的形状和算子属性,推算出输出张量应该长什么样。Runtime 拿到这个信息后,才能提前为输出分配内存。

形状推导规则可能很简单,比如逐元素操作保持形状不变;也可能依赖输入的实际数值,比如 Reshape 要把目标形状算出来。更复杂的情况是动态形状——当输入尺寸运行时才确定时,InferShape 也要保证输出形状能正确表达。形状推算出错,轻则模型加载失败,重则执行时内存越界,程序直接崩溃。因此,在填写 InferShape 回调时,一定要对输入维度和属性做合法性检查。

备菜师傅估准分量,厨房才不会浪费盘子,也不会端出超量的菜。InferShape 就是这个“估分量”的师傅:它预估得越准,Runtime 的内存规划就越从容,整个推理管线也就越稳。

shape⁡(y)[i]=g(shape⁡(x1),…,shape⁡(xn);a1,…,am)\operatorname{shape}(y)[i] = g\left(\operatorname{shape}(x_1), \dots, \operatorname{shape}(x_n); a_1, \dots, a_m\right)
形状推导公式的含义:输出张量的第 i 维大小,由所有输入形状和属性共同决定,映射规则 g 由你定义。
  1. 输出张量的形状信息左侧表示输出张量 y 在第 i 个维度上的尺寸。
    shape⁡(y)[i]\operatorname{shape}(y)[i]
  2. 形状映射规则g 是形状推导函数,它描述“输入长什么样 → 输出长什么样”的规则。
    gg
  3. 输入形状作为自变量括号里的 shape(x₁) 到 shape(xₙ) 是各输入张量的形状信息。
    shape⁡(x1),…,shape⁡(xn)\operatorname{shape}(x_1), \dots, \operatorname{shape}(x_n)
  4. 属性参与形状决策分号后的 a₁ 到 aₘ 是属性,比如做矩阵拼接时,沿哪个轴拼就由属性决定。
    a1,…,ama_1, \dots, a_m

计算内核

档案卡和分量预估都只是准备工作,真正让食材变成菜肴的,是灶台前的“独门手艺”——计算内核(Kernel)。在 Custom Op 机制中,Kernel 是一个实现了实际计算逻辑的类。它通常分两步:构造阶段读取并缓存算子属性,计算阶段遍历输入张量,把结果写入输出张量。

比如当属性里写了 alpha=1.0,Kernel 的构造函数就会把这个值从节点属性中提取出来存好,之后每次计算直接使用,避免反复解析。计算阶段的核心是一个逐元素或分块的循环:从输入张量的数据指针出发,按内存布局依次读取元素,套用公式算出结果,再写进输出缓冲区。另外,Kernel 还要考虑设备分派——CPU 版本写普通 C++ 循环,GPU 版本则要写 CUDA Kernel,并在注册时声明自己的执行设备。

灶台前的故事很简单:同一个火候、同一味调料,厨师的手微微一抖就是千差万别。Kernel 就是那双手,Schema 决定了手上拿什么食材,而手艺是否出众,直接决定菜品好不好吃——也就是算子的执行速度和数值精度。

yi=h(xi;a1,a2,…,am)y_i = h(x_i; a_1, a_2, \dots, a_m)
内核公式描述了计算的最小单元:输出张量的第 i 个元素,由输入张量的第 i 个元素和属性共同决定。这是绝大多数逐元素算子的基本形态。
  1. 输出张量的第 i 个元素yᵢ 是输出张量中的一个元素,Kernel 的最终任务就是填满所有 yᵢ。
    yiy_i
  2. 内核映射函数h 是对每个元素执行的计算逻辑,即你在 compute 函数里写的那段代码。
    hh
  3. 对应位置的输入元素xᵢ 是与 yᵢ 位置对应的输入张量元素。
    xix_i
  4. 构造阶段缓存的属性a₁ 到 aₘ 在构造时被提取并缓存,计算时直接使用,节省重复解析开销。
    a1,a2,…,ama_1, a_2, \dots, a_m

构建部署

新菜的菜谱和手艺都齐了,但要让它真正出现在客人面前,还需要“装订成册,摆上货架”。在 Custom Op 的语境里,这一步是把写好的 C++ 代码编译成动态链接库,并在运行时把它加载进 ONNX Runtime。库文件就像一本合订食谱,里面可以同时存放多个自定义算子。

C++ 实现编译产生的动态库,需要暴露一个统一的注册入口,ONNX Runtime 通过这个入口把库里的所有算子登记到注册表中。Python 侧则更简单:使用 @PythonOp 直接注册,或用 SessionOptions 的接口加载已有动态库,无需重写逻辑。部署时还需注意跨平台兼容——Linux 编译出 .so,Windows 编译出 .dll,macOS 编译出 .dylib,各自提供给对应平台使用。模型文件本身不需要重新导出,推理时只要 Session 能加载到算子库,模型就能正常运行。

把菜谱册放到厨房书架上,并不意味着厨房会自动知道它的存在——你得亲手把它递给主厨。Custom Op 的部署逻辑完全一致:动态库编译好只是第一步,显式调用注册加载才是让 Runtime 真正“认账”的关键一步。

Runtime(M,L)→ExecGraph\mathrm{Runtime}(M, L) \to \mathrm{ExecGraph}
这里的 M 是包含自定义算子节点的模型文件,L 是编译好的算子动态库。两者一起交给 Runtime,才能组合出真正的可执行计算图。
  1. Runtime 的输入模型M 是含未知算子节点的 ONNX 模型,它是推理的“点菜单”。
    MM
  2. Runtime 的输入算子库L 是保存了自定义算子实现的动态库文件,它是“菜谱册”。
    LL
  3. 组合成可执行图箭头右侧的 ExecGraph 表示经过加载、注册和优化后,Runtime 内部真正可执行的计算图。
    ExecGraph\mathrm{ExecGraph}

调试验证

一道新菜正式上菜单前,总得有个试菜环节:厨艺老师和质检员对比这道菜与标准做法的差距,确认味道达标、分量正确、出菜速度够快。自定义算子的调试验证,就是这个流程的工程化版本。

最核心的手段是“对照实验”:用 NumPy 或参考库独立实现一遍同样的数学公式,再喂相同的输入给 Custom Op,比较两者输出的最大误差。形状是否正确也要单独验证——输出张量的每个维度都必须与 InferShape 的声明一致,否则后续算子会读到错位的数据。性能方面,可以用 profiling 工具统计算子耗时占比,判断它是否成为推理管线的瓶颈。此外,还要警惕几个高频陷阱:算子名与内置算子冲突、属性类型读错、动态库符号没有正确导出。

试菜不通过,菜谱绝不能上正式菜单。一个只跑通了正确路径、却没经过误差对比和边界测试的自定义算子,就像一道只试吃过一次的家常菜——在真正的晚高峰(生产环境)里,随时可能翻车。调试验证,是 Custom Op 走向生产的最后一道门槛。

ε=∥ycustom−yref∥∞\varepsilon = \| y_{\mathrm{custom}} - y_{\mathrm{ref}} \|_\infty
误差度量公式:取自定义算子输出与参考实现的逐元素绝对差,再取最大值。ε 越小,说明算子的数值行为越接近标准实现。
  1. 自定义算子的输出张量y_custom 是你的 Kernel 实际计算出的结果。
    ycustomy_{\mathrm{custom}}
  2. 参考实现的输出张量y_ref 是用 NumPy 等独立工具算出的“标准答案”。
    yrefy_{\mathrm{ref}}
  3. 最大绝对误差对两个张量逐元素求差取绝对值,再在所有元素中取最大值,就是整个算子的最大偏离程度。
    ε=max⁡∣ycustom−yref∣\varepsilon = \max |y_{\mathrm{custom}} - y_{\mathrm{ref}}|

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 演示

python45 行
# 演示 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 并完成一次真实推理。
  1. node = helper.make_node("FusedGeLU", ["X"], ["Y"], alpha=1.0)在模型图中插入自定义算子节点,相当于点菜单上写了一道新菜。
  2. @PythonOp("FusedGeLU", since_version=1)注册装饰器,把类的实现与算子名绑定,是“登记进菜单册”的 Python 快捷方式。
  3. def __init__(self, alpha: float = 1.0):构造阶段读取属性 alpha 并缓存,对应 Kernel 的属性提取逻辑。
  4. x = x.to_py() if hasattr(x, "to_py") else x把框架张量转成 NumPy 数组,便于用向量化方式做计算。
  5. y = self.alpha * 0.5 * x * (1.0 + np.tanh(...))GELU 近似公式的融合计算主体,是这道“菜”的核心手艺。
  6. sess = ort.InferenceSession(model.SerializeToString(), ...)加载含自定义算子的模型,此时 Runtime 到注册表中查询 FusedGeLU。
  7. 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 自定义算子,下列说法正确的是?

单选题 · easy

  • A.只能修改 Runtime 源码扩展算子
  • B.通过注册机制添加 Runtime 不认识算子← 正确答案
  • C.自定义算子不需要定义输入输出
  • D.自定义算子仅能用于推理不能训练

答案:B。Custom Op 机制的核心就是通过注册 API 在不动源码的前提下扩展算子库,B 正确。A 与机制设计初衷相悖;C 错在 Schema 必须声明输入输出;D 是过度限定,自定义算子同样可参与训练场景。

Q2. 自定义算子的“菜品档案卡”在 ONNX Runtime 中对应下列哪个概念?

单选题 · easy

  • A.算子的核心计算逻辑部分
  • B.描述算子名称输入输出的档案← 正确答案
  • C.编译生成的动态链接库文件
  • D.存放模型的序列化文件夹

答案:B。Schema 就是算子的档案卡,记录名称、输入输出和属性;A 描述的是 Kernel,C 是部署产物,D 与算子无关。记住“档案卡对应 Schema”即可。

Q3. 形状推导(InferShape)的主要作用是什么?

单选题 · mid

  • A.为输出张量分配内存提供形状信息← 正确答案
  • B.加速 GPU 算子融合的并行计算
  • C.优化网络模型的梯度更新策略
  • D.压缩自定义算子的动态库体积

答案:A。InferShape 在模型加载阶段推算输出形状,Runtime 据此预分配内存。它不参与并行计算(B)、不优化梯度(C)、不处理库文件体积(D)。就像备菜师傅先估量分量,厨房才好备盘子。

Q4. 在自定义算子的 Schema 定义中,声明每条输入输出需要给出什么?

单选题 · mid

  • A.名称数据类型和相关类型约束← 正确答案
  • B.内存地址偏移量和字节长度
  • C.线程调度优先级和队列编号
  • D.节点在计算图中的执行序号

答案:A。Schema 的输入输出声明包含名称、数据类型与类型约束,Runtime 据此校验模型节点。地址偏移(B)和调度信息(C)属于运行时细节,执行序号(D)由计算图决定,均不属于 Schema 范畴。

Q5. 以下哪种方式可以让 ONNX Runtime“认识”含自定义算子的模型?

单选题 · mid

  • A.修改模型内自定义节点的名称
  • B.加载包含算子实现的动态链接库← 正确答案
  • C.把自定义算子改成内建算子别名
  • D.提高模型文件的 opset 版本号

答案:B。动态库中实现了算子逻辑并通过注册入口登记,Runtime 加载后才能识别。改节点名(A)无法解决问题,做别名(C)会与内建算子冲突,提高 opset(D)与算子识别无关。

Q6. 关于 Custom Op 的计算内核(Kernel),描述正确的有?

多选题 · hard

  • A.实际执行计算逻辑的地方← 正确答案
  • B.可以在构造阶段读取算子属性← 正确答案
  • C.不参与图执行仅作标记
  • D.需要按设备 CPU 或 GPU 分派← 正确答案

答案:ABD。Kernel 是真正做计算的地方(A),构造阶段可提取并缓存属性(B),还要按设备分派不同实现(D)。C 恰好说反——Kernel 是图执行时的核心,不是标记。

Q7. 注册 Custom Op 之前模型加载失败,可能的原因有哪些?

多选题 · hard

  • A.注册表中不存在该自定义算子名← 正确答案
  • B.声明输入个数与模型节点不匹配← 正确答案
  • C.形状推导返回错误形状致分配失败← 正确答案
  • D.模型文件使用 yaml 格式存储

答案:ABC。注册表查不到算子名(A)、Schema 输入个数不匹配(B)、InferShape 返回错误形状(C)都会导致加载失败。D 属于基础常识错误——ONNX 模型用 protobuf 二进制存储,与 yaml 无关。

Q8. 关于自定义算子的动态形状推理,下列说法正确的是?

单选题 · hard

  • A.输出形状必须与输入形状完全一致
  • B.输出形状可以依赖输入形状和属性← 正确答案
  • C.存在动态形状时无需调用 InferShape
  • D.InferShape 只会在 GPU 设备上执行

答案:B。InferShape 的输出形状可由输入形状和属性共同推导,例如拼接类算子依据拼接轴属性决定输出维度。A 过于绝对,C 和 D 都错误——动态形状更需要 InferShape,且它在 CPU 上同样执行。

Q9. 在调试自定义算子时,适合采用的做法有哪些?

多选题 · extreme

  • A.用独立参考实现对比输出误差← 正确答案
  • B.检查形状推导与实际计算维度一致← 正确答案
  • C.关闭全部单元测试加速开发
  • D.用性能剖析器定位计算热点← 正确答案

答案:ABD。独立参考实现是数值验证的金标准(A),形状一致性检查能避免内存越界(B),性能剖析可确认算子是否成为瓶颈(D)。关闭单元测试(C)只会让缺陷在线上爆发,明显不可取。

Q10. 与标准 ONNX 内置算子相比,自定义算子的主要部署风险是什么?

单选题 · mid

  • A.自定义算子一定无法跨平台部署
  • B.部署方需同时携带算子动态库文件← 正确答案
  • C.自定义算子的计算速度必然更快
  • D.自定义算子无需数值稳定性测试

答案:B。自定义算子以动态库形式分发,使用者必须同时部署库文件,否则模型推理会失败。A 绝对化,跨平台需要分别为各平台编译;C 未必成立,速度取决于实现;D 相反,数值稳定性测试恰恰是必做项。