ONNX 节点测试模式(Node Test Pattern)完整指南:为算子编写 backend test 用例
2026/9/21 19:05:25 网站建设 项目流程
  • 人工智能
  • 深度学习
  • 机器学习

【免费下载链接】onnx

Open standard for machine learning interoperability

项目地址:https://gitcode.com/gh_mirrors/onn/onnx
点击查看免费下载

本文是 ONNX 开源仓库中 .agents/skills/add-op/references/node-test-pattern.md 的深度展开版,面向需要在 ONNX 仓库中新增算子、或为既有算子补充测试覆盖的开发者。文章以该文档给出的「节点测试模式」为核心骨架,结合仓库中onnx/backend/test/case/目录的真实源码实现,完整讲解测试文件的组织位置、export*静态方法与expect助手的运行原理、测试用例的注册与收集机制、参考实现的配合方式,以及测试数据的生成与执行方法。读完后,你将能按照 ONNX 官方约定,为自己的算子编写一份可被 backend 测试基础设施自动发现、校验并执行的节点测试文件。

一、节点测试在 ONNX 仓库中的定位

ONNX 仓库的算子测试分为多个层次:节点测试(node test)负责验证「单个算子节点」的语义,是算子正确性的第一道防线;与之配套的还有参考实现(reference implementation)、形状推断测试、版本转换测试等。

在新增算子(add-op)的整体流程中,节点测试是明确的一环。根据 .agents/skills/add-op/SKILL.md 中的文件清单,新增一个算子通常需要触及:

组件文件位置
Schema 定义onnx/defs/<domain>/defs.cc
算子集注册onnx/defs/operator_sets.h
类型/形状推断Schema 内联的.TypeAndShapeInferenceFunction(...)
参考实现onnx/reference/ops/op_<lowercase_name>.py
节点测试onnx/backend/test/case/node/<lowercase_name>.py
形状推断测试tests/python/shape_inference_test.py
版本转换适配器onnx/version_converter/adapters/<name>_<from>_<to>.h

完整的算子新增流程详见 docs/AddNewOp.md。本篇文章聚焦于上表中的「节点测试」一栏,即onnx/backend/test/case/node/<name>.py文件的标准写法。

二、节点测试模式:一个文件对应一个算子

原文档给出的模式非常简洁:每个算子的测试文件位于onnx/backend/test/case/node/<name>.py,文件名使用算子名的全小写形式;文件内定义一个继承Base的类,类名与算子名一致(PascalCase),类中的每个export*静态方法都会自动成为一个独立的测试用例。

标准骨架如下(保留原文档完整代码):

from __future__ import annotations import numpy as np import onnx from onnx.backend.test.case.base import Base from onnx.backend.test.case.node import expect class OpName(Base): @staticmethod def export() -> None: node = onnx.helper.make_node( "OpName", inputs=["x"], outputs=["y"], ) x = np.random.randn(3, 4, 5).astype(np.float32) y = np.some_operation(x) expect(node, inputs=[x], outputs=[y], name="test_opname") @staticmethod def export_with_broadcasting() -> None: node = onnx.helper.make_node( "OpName", inputs=["x", "y"], outputs=["z"], ) x = np.random.randn(3, 4, 5).astype(np.float32) y = np.random.randn(5).astype(np.float32) expect(node, inputs=[x, y], outputs=[x + y], name="test_opname_bcast")

要点拆解:

  • make_node只声明图结构,不负责计算onnx.helper.make_node("OpName", inputs=["x"], outputs=["y"])仅生成一个NodeProto,其中的inputs/outputs是符号名称字符串,不是真实数据。真实数据由后面的 numpy 数组xy提供,expect会负责把符号名与数据一一对应起来。
  • 预期输出由 numpy 直接计算y = np.some_operation(x)是在 Python 侧用 NumPy 实现的「参考结果」,它不依赖 ONNX 自身的执行引擎,从而避免「用被测实现验证被测实现」的循环依赖。
  • expect是注册入口expect(node, inputs=[x], outputs=[y], name="test_opname")将节点、输入数据、期望输出与用例名打包成一个TestCase

从源码结构看,export*方法名还承担了「用例名后缀」的职责:在 onnx/backend/test/case/base.py 的process_snippet中,name[len("export_") :]会去掉export_前缀,剩余的字符串(若为空则退化为算子名的小写形式)将作为测试代码片段的命名依据。

三、export*方法如何变成测试用例:Base的元类机制

原文档指出「Eachexport*static method becomes a separate test case」,其底层实现是Base类使用的元类_Exporter,见 onnx/backend/test/case/base.py:

class _Exporter(type): exports: ClassVar[dict[str, list[tuple[str, str]]]] = defaultdict(list) def __init__( cls, name: str, bases: tuple[type[Any], ...], dct: dict[str, Any] ) -> None: for k, v in dct.items(): if k.startswith("export"): if not isinstance(v, staticmethod): raise ValueError("Only staticmethods could be named as export.*") export = getattr(cls, k) Snippets[name].append(process_snippet(name, k, export)) # export functions should call expect and so populate # TestCases np.random.seed(seed=0) export() super().__init__(name, bases, dct) class Base(metaclass=_Exporter): pass

该机制揭示了几条重要约定:

  1. 类被定义时即注册,无需显式调用:只要测试模块被 import,元类就会在类定义阶段遍历类字典(dct)中所有以export开头的成员,因此每个export*方法在「import 阶段」就被执行并注册测试用例,而不是等到 pytest 收集时才运行。
  2. 方法必须是staticmethod:如果不是静态方法,元类会直接抛出ValueError("Only staticmethods could be named as export.*"),这正是原文档代码中每个方法都标注@staticmethod的原因。
  3. 随机数种子被固定为 0:每次执行export()前都会调用np.random.seed(seed=0),保证np.random.randn(...)生成的输入数据可复现。这是 ONNX 测试基础设施刻意为之——同一用例在任何机器、任何时间生成的模型与数据完全一致,便于对比与回归。
  4. 每个方法独立成用例:多个export_xxx方法 = 多个测试用例,它们共享同一个类但互不影响。

四、expect助手:从「节点 + 数据」到TestCase

expect是节点测试模式的核心助手,其实现位于 onnx/backend/test/case/node/init.py。它的工作流可以概括为五步:

第一步:校验与查重。若全局设置了_TargetOpType(按算子过滤收集时使用)且当前节点类型不匹配,则直接跳过;若name已存在则抛出ValueError,防止用例名冲突(见_existing_names字典)。

第二步:构造图并推断 ValueInfo。通过_extract_value_info(见 base.py)根据 numpy 数组的dtypeshape自动生成ValueInfoProtonp.float32映射为TensorProto.FLOATshape直接取自数组。因此测试作者不需要手动写make_tensor_value_info——只需提供数据即可。

第三步:确定 opset 版本。默认情况下(未传入opset_imports),expect会查询onnx.defs.get_schema(node.op_type).since_version,以该算子的「引入版本」作为模型 opset 版本(见 node/init.py)。这意味着:ops 每次升级版本号后,测试模型会自动跟随since_version使用最新 opset,避免 opset 变更导致模型版本过时。

第四步:生成模型。调用_make_test_model_gen_version构造ModelProto,并将producer_name设为"backend-test"

第五步:注册TestCase生成的TestCase包含以下字段(见 onnx/backend/test/case/test_case.py):

字段说明
name传入的name用例名,如test_abs
model生成的ModelProto单节点图模型
data_sets[(inputs, outputs)]一组输入/期望输出对
kind"node"用例类型
rtol1e-3相对误差容忍度
atol1e-7绝对误差容忍度

注意rtol=1e-3, atol=1e-7是节点测试的默认数值比较精度,backends 在比对输出时会使用这一标准。

4.1 可选输入/输出的处理

expect对 ONNX 的可选(optional)输入/输出做了专门处理:节点声明中空字符串""表示「该位置省略」。例如一个有三个输入、第二个可选的算子,node.input会形如["Param1", "", "Param3"],而inputs参数只包含实际存在的两个值。expect内部通过present_inputs = [x for x in node.input if x != ""]过滤后,再与inputs一一对应生成 ValueInfo(注释见 node/init.py)。

4.2 函数型算子的自动展开测试

对于以 Function(函数体)定义的算子,expect还会自动生成额外的「展开后」测试:function_testcase_helper会取出 schema 中各 opset 版本的FunctionProto(含 context-dependent function),通过function_expand_helper把函数体展开为原始节点序列,生成名为test_xxx_expanded(必要时附加_ver<版本号>后缀)的附加TestCase(见 node/init.py 与 L346-L402)。也就是说,为一个函数型算子写一份节点测试,expect会自动验证其展开后的等价图

五、仓库中的真实示例

5.1 最简示例:Abs

仓库中最简洁的真实用例是 onnx/backend/test/case/node/abs.py,与原文档的骨架模式完全一致:

import numpy as np import onnx from onnx.backend.test.case.base import Base from onnx.backend.test.case.node import expect class Abs(Base): @staticmethod def export() -> None: node = onnx.helper.make_node( "Abs", inputs=["x"], outputs=["y"], ) x = np.random.randn(3, 4, 5).astype(np.float32) y = np.abs(x) expect(node, inputs=[x], outputs=[y], name="test_abs")

对应的参考实现位于 onnx/reference/ops/op_abs.py:

import numpy as np from onnx.reference.ops._op import OpRunUnaryNum class Abs(OpRunUnaryNum): def _run(self, x): return (np.absolute(x),)

测试中的y = np.abs(x)与参考实现中的np.absolute(x)语义一致——节点测试的期望值正是「参考实现应当产生的结果」,两者共同锁定算子语义。

5.2 属性参数与多用例示例:Gemm

当算子带有属性(attributes)时,通常的做法是为每个关键属性组合各写一个export*方法。以 onnx/backend/test/case/node/gemm.py 为例,它在模块顶部定义了一个独立的 numpy 参考函数,然后为每种场景编写用例:

def gemm_reference_implementation( A: np.ndarray, B: np.ndarray, C: np.ndarray | None = None, alpha: float = 1.0, beta: float = 1.0, transA: int = 0, transB: int = 0, ) -> np.ndarray: A = A if transA == 0 else A.T B = B if transB == 0 else B.T C = C if C is not None else np.array(0) Y = alpha * np.dot(A, B) + beta * C return Y.astype(A.dtype) class Gemm(Base): @staticmethod def export_default_zero_bias() -> None: node = onnx.helper.make_node("Gemm", inputs=["a", "b", "c"], outputs=["y"]) a = np.random.ranf([3, 5]).astype(np.float32) b = np.random.ranf([5, 4]).astype(np.float32) c = np.zeros([1, 4]).astype(np.float32) y = gemm_reference_implementation(a, b, c) expect(node, inputs=[a, b, c], outputs=[y], name="test_gemm_default_zero_bias") @staticmethod def export_all_attributes() -> None: node = onnx.helper.make_node( "Gemm", inputs=["a", "b", "c"], outputs=["y"], alpha=0.25, beta=0.35, transA=1, transB=1, ) a = np.random.ranf([4, 3]).astype(np.float32) b = np.random.ranf([5, 4]).astype(np.float32) c = np.random.ranf([1, 5]).astype(np.float32) y = gemm_reference_implementation( a, b, c, transA=1, transB=1, alpha=0.25, beta=0.35 ) expect(node, inputs=[a, b, c], outputs=[y], name="test_gemm_all_attributes")

该文件共包含 10 个export*方法(default_zero_biasdefault_no_biasdefault_scalar_biasdefault_single_elem_vector_biasdefault_vector_biasdefault_matrix_biastransposeAtransposeBalphabetaall_attributes),完整覆盖了GemmtransAtransBalphabeta属性组合与各种 bias 形态。这种「一个方法覆盖一个属性/场景」的组织方式值得借鉴:

  • 属性通过make_node的关键字参数传入,如transA=1alpha=0.5
  • 参考函数同步接收这些属性参数,保证期望输出与节点声明一致;
  • 用例名(如test_gemm_transposeA)清晰描述所测场景,便于定位失败用例。

六、测试用例的收集机制

测试文件写好后,如何被测试框架发现?入口是 onnx/backend/test/case/node/init.py 中的collect_testcases

def collect_testcases(op_type: str | None = None) -> list[TestCase]: """Collect node test cases, optionally filtered to a single op_type.""" global _TargetOpType _TargetOpType = op_type import_recursive(sys.modules[__name__]) return _NodeTestCases

其工作方式:

  1. 递归 import 全部节点测试模块import_recursive(见 onnx/backend/test/case/utils.py)利用pkgutil.iter_modules遍历onnx.backend.test.case.node包下的所有子模块并逐一 import。由于上一节所述的元类机制,import 动作本身就是注册动作——所有export*方法在 import 过程中已把TestCase追加进_NodeTestCases列表。
  2. 可选按算子过滤:传入op_type时,expect会跳过所有op_type不匹配的节点(见_TargetOpType判断),从而支持只针对单个算子生成测试。
  3. 导出为独立数据onnx/backend/test/cmd_tools.pygenerate_data子命令会把收集到的TestCase落盘为标准的 backend 测试数据目录,输出结构为node/<case_name>/model.onnxnode/<case_name>/test_data_set_<i>/input_*.pboutput_*.pb(见 onnx/backend/test/cmd_tools.py)。仓库中onnx/backend/test/data/下的.onnx.pb文件即由此机制生成。

七、如何运行与验证节点测试

7.1 运行参考实现测试

节点测试的期望输出由参考实现保证,最直接的验证方式是运行 reference evaluator 测试。仓库的 tests/python/backend_reference_test.py 会加载各节点测试用例,用onnx.reference.ReferenceEvaluator执行模型并与期望输出比较(使用TestCase中记录的rtol/atol)。新增算子后运行:

pytest tests/python/backend_reference_test.py

7.2 通过 DummyBackend 做结构校验

tests/python/backend_test.py 中的DummyBackend提供了一种不依赖参考实现的快速校验:它的prepare会执行onnx.checker.check_model(model)与严格模式形状推断infer_shapes(model, check_type=True, strict_mode=True),从而验证「测试用例生成的模型本身是合法、形状信息完整的 ONNX 模型」。由于没有真实计算,这类测试最终会以「跳过」收尾,但结构校验部分已经执行。

7.3 生成测试数据

需要把测试用例导出为标准测试数据目录时:

python onnx/backend/test/cmd_tools.py generate-data -o /path/to/output

该命令会为每个TestCase生成model.onnxtest_data_set_N/数据目录,供外部 backends(如 ONNX Runtime)按 ONNX 标准格式消费。

7.4 添加算子后的完整检查清单

根据 .agents/skills/add-op/SKILL.md 的「After Making Changes」一节,写完节点测试后还应执行:

python onnx/defs/gen_doc.py python onnx/backend/test/stat_coverage.py python onnx/gen_proto.py # 仅当 proto 变更时 lintrunner -a --output oneline

其中stat_coverage.py用于统计算子测试覆盖率,确保新算子已被测试覆盖。

八、与参考实现模式的配合

节点测试与参考实现(reference implementation)是新增算子时的「双保险」:

  • 节点测试(本文主题)给出「期望输入→期望输出」的数据对,是声明式的验收标准;
  • 参考实现位于onnx/reference/ops/op_<name>.py,给出可执行的算子语义,是 onnx.reference 参考求值器(ReferenceEvaluator)实际运行的代码。

两者的命名与类名约定一致(如op_abs.py中的Abs),且参考实现的可选基类决定了其接口形态:单输入单输出数值算子继承OpRunUnaryNum,二元算子继承OpRunBinaryNumpy,通用算子继承OpRun(详见 .agents/skills/add-op/references/reference-impl-pattern.md)。编写节点测试时,期望输出应始终与参考实现的语义保持一致。

九、编写节点测试的实践要点总结

结合原文档与仓库实现,归纳以下要点:

  1. 文件位置与命名onnx/backend/test/case/node/<lowercase_name>.py,类名用 PascalCase 且与算子名一致。
  2. 一个方法一个场景:每个export*静态方法对应一个独立测试用例;属性组合、广播、边界形状等场景应拆分为多个方法,并给方法名与用例名起有语义的名字(如export_with_broadcastingtest_opname_bcast)。
  3. 数据必须可复现:随机数据使用np.random.randn/np.random.ranf,因为元类会固定种子为 0;也可使用确定性数据(如np.zerosnp.arange)。
  4. dtype 明确指定np.float32是节点测试的主流精度,务必通过.astype(np.float32)显式转换,避免平台默认浮点精度不一致。
  5. 期望输出自算:用 numpy 独立计算期望输出,不要调用 ONNX 求值器生成期望值。
  6. 用例名全局唯一expect会对重复name抛出ValueError
  7. 利用自动展开:函数型算子无需手写展开图,expect会自动生成_expanded测试。
  8. 运行验证:新增文件后运行pytest tests/python/backend_reference_test.pytests/python/backend_test.py,并用stat_coverage.py确认覆盖率。

按照上述模式,任何新增算子都能获得一份与仓库既有测试风格一致、可被 ONNX backend 测试基础设施自动发现与校验的节点测试文件。

  • 人工智能
  • 深度学习
  • 机器学习

【免费下载链接】onnx

Open standard for machine learning interoperability

项目地址:https://gitcode.com/gh_mirrors/onn/onnx
点击查看免费下载

相关推荐

上一篇:【免费下载】 Fate-Grand-Automata 使用与安装教程
下一篇:【亲测免费】 RTSPtoWeb 项目教程

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询