- 人工智能
- 深度学习
- 机器学习
【免费下载链接】onnx
Open standard for machine learning interoperability
本文是 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 数组x、y提供,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该机制揭示了几条重要约定:
- 类被定义时即注册,无需显式调用:只要测试模块被 import,元类就会在类定义阶段遍历类字典(
dct)中所有以export开头的成员,因此每个export*方法在「import 阶段」就被执行并注册测试用例,而不是等到 pytest 收集时才运行。 - 方法必须是
staticmethod:如果不是静态方法,元类会直接抛出ValueError("Only staticmethods could be named as export.*"),这正是原文档代码中每个方法都标注@staticmethod的原因。 - 随机数种子被固定为 0:每次执行
export()前都会调用np.random.seed(seed=0),保证np.random.randn(...)生成的输入数据可复现。这是 ONNX 测试基础设施刻意为之——同一用例在任何机器、任何时间生成的模型与数据完全一致,便于对比与回归。 - 每个方法独立成用例:多个
export_xxx方法 = 多个测试用例,它们共享同一个类但互不影响。
四、expect助手:从「节点 + 数据」到TestCase
expect是节点测试模式的核心助手,其实现位于 onnx/backend/test/case/node/init.py。它的工作流可以概括为五步:
第一步:校验与查重。若全局设置了_TargetOpType(按算子过滤收集时使用)且当前节点类型不匹配,则直接跳过;若name已存在则抛出ValueError,防止用例名冲突(见_existing_names字典)。
第二步:构造图并推断 ValueInfo。通过_extract_value_info(见 base.py)根据 numpy 数组的dtype与shape自动生成ValueInfoProto:np.float32映射为TensorProto.FLOAT,shape直接取自数组。因此测试作者不需要手动写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" | 用例类型 |
rtol | 1e-3 | 相对误差容忍度 |
atol | 1e-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_bias、default_no_bias、default_scalar_bias、default_single_elem_vector_bias、default_vector_bias、default_matrix_bias、transposeA、transposeB、alpha、beta、all_attributes),完整覆盖了Gemm的transA、transB、alpha、beta属性组合与各种 bias 形态。这种「一个方法覆盖一个属性/场景」的组织方式值得借鉴:
- 属性通过
make_node的关键字参数传入,如transA=1、alpha=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其工作方式:
- 递归 import 全部节点测试模块:
import_recursive(见 onnx/backend/test/case/utils.py)利用pkgutil.iter_modules遍历onnx.backend.test.case.node包下的所有子模块并逐一 import。由于上一节所述的元类机制,import 动作本身就是注册动作——所有export*方法在 import 过程中已把TestCase追加进_NodeTestCases列表。 - 可选按算子过滤:传入
op_type时,expect会跳过所有op_type不匹配的节点(见_TargetOpType判断),从而支持只针对单个算子生成测试。 - 导出为独立数据:
onnx/backend/test/cmd_tools.py的generate_data子命令会把收集到的TestCase落盘为标准的 backend 测试数据目录,输出结构为node/<case_name>/model.onnx与node/<case_name>/test_data_set_<i>/input_*.pb、output_*.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.py7.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.onnx与test_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)。编写节点测试时,期望输出应始终与参考实现的语义保持一致。
九、编写节点测试的实践要点总结
结合原文档与仓库实现,归纳以下要点:
- 文件位置与命名:
onnx/backend/test/case/node/<lowercase_name>.py,类名用 PascalCase 且与算子名一致。 - 一个方法一个场景:每个
export*静态方法对应一个独立测试用例;属性组合、广播、边界形状等场景应拆分为多个方法,并给方法名与用例名起有语义的名字(如export_with_broadcasting→test_opname_bcast)。 - 数据必须可复现:随机数据使用
np.random.randn/np.random.ranf,因为元类会固定种子为 0;也可使用确定性数据(如np.zeros、np.arange)。 - dtype 明确指定:
np.float32是节点测试的主流精度,务必通过.astype(np.float32)显式转换,避免平台默认浮点精度不一致。 - 期望输出自算:用 numpy 独立计算期望输出,不要调用 ONNX 求值器生成期望值。
- 用例名全局唯一:
expect会对重复name抛出ValueError。 - 利用自动展开:函数型算子无需手写展开图,
expect会自动生成_expanded测试。 - 运行验证:新增文件后运行
pytest tests/python/backend_reference_test.py与tests/python/backend_test.py,并用stat_coverage.py确认覆盖率。
按照上述模式,任何新增算子都能获得一份与仓库既有测试风格一致、可被 ONNX backend 测试基础设施自动发现与校验的节点测试文件。
- 人工智能
- 深度学习
- 机器学习
【免费下载链接】onnx
Open standard for machine learning interoperability
相关推荐
用 @react-aria/test-utils 编写 React Aria 组件测试:ARIA Pattern Tester 完整指南
用 @react aria/test utils 编写 React Aria 组件测试:ARIA Pattern Tester 完整指南 @react aria
前端UI组件设计系统国际化状态管理Sway 单元测试完全指南:用 forc test 为智能合约编写与运行 [test]
Sway 单元测试完全指南:用 forc test 为智能合约编写与运行 test 本篇技术指南围绕 Sway 语言官方文档的 Unit Testing 章节展
编程语言编译器区块链Unity Test测试用例设计模式:可复用测试代码编写方法
Unity Test测试用例设计模式:可复用测试代码编写方法 Unity Test作为C语言单元测试的轻量级框架,提供了多种测试用例设计模式,帮助开发者编写可复
测试嵌入式
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考