第3章 算子工程结构

好,咱们今天聊聊算子工程的结构。说白了,就是当你准备写一个自定义算子时,文件夹该怎么摆、CMakeLists.txt 怎么写、算子注册文件长什么样。这些东西看着琐碎,但要是没弄对,后面编译、集成全得翻车。我刚开始接触 CANN 时,就因为少了一个注册文件,折腾了整整一个下午……嗯,从那以后我对工程结构就特别较真。

3.1 算子工程目录规范

一个标准的 CANN 自定义算子工程,通常长这样。我个人习惯把算子实现、原型定义、测试用例分开,这样找东西快,也方便后续集成。

my_custom_op/
├── CMakeLists.txt              # 顶层构建文件
├── op_proto/                   # 算子原型定义 (shape推导, 参数校验)
│   ├── CMakeLists.txt
│   └── my_op.cpp
├── op_impl/                    # 算子实现 (TBE / AscendC)
│   ├── CMakeLists.txt
│   ├── my_op.py                # TBE DSL 实现
│   └── my_op_ascendc.py        # AscendC 实现 (可选)
├── op_kernel/                  # 算子注册文件 (算子信息库)
│   ├── CMakeLists.txt
│   └── my_op.ini               # 算子注册配置文件
├── scripts/                    # 编译 & 测试脚本
│   └── build.sh
└── test/                       # 单测 & 集成测试
    └── test_my_op.py

你看,op_proto 放原型,op_impl 放实现,op_kernel 放注册信息。每个子目录都有自己的 CMakeLists.txt,顶层 CMakeLists.txt 把它们串起来。我在项目中遇到过有人把所有文件塞到一个目录里,结果原型和实现混在一起,后来维护时简直想哭。所以,目录清晰是第一要务。

💡 我的建议: 哪怕你只写一个算子,也请保持这个结构。后面加算子时,直接复制目录,改几个名字就行,省心。

3.2 CMakeLists.txt 编写

CMakeLists.txt 是工程的“总指挥”。顶层文件负责定义项目、找依赖、添加子目录。我贴一个我常用的模板,你直接套用就行。

# 顶层 CMakeLists.txt
cmake_minimum_required(VERSION 3.14)
project(my_custom_op LANGUAGES CXX)

# 设置 CANN 路径 (根据实际安装位置修改)
set(CANN_PATH /usr/local/Ascend/ascend-toolkit/latest)
set(ASCEND_CANN_PACKAGE_PATH ${CANN_PATH})

# 找 CANN 头文件和库
find_package(Ascend REQUIRED)

# 添加子目录
add_subdirectory(op_proto)
add_subdirectory(op_impl)
add_subdirectory(op_kernel)

子目录的 CMakeLists.txt 就简单多了。比如 op_proto/CMakeLists.txt:

# op_proto/CMakeLists.txt
add_library(op_proto SHARED my_op.cpp)
target_include_directories(op_proto PRIVATE ${ASCEND_CANN_PACKAGE_PATH}/include)
target_link_libraries(op_proto PRIVATE Ascend::Proto)

这里要注意:find_package(Ascend REQUIRED) 需要你的 CANN 环境变量配好。我记得第一次编译时,老是报找不到 Ascend,后来发现是 ASCEND_CANN_PACKAGE_PATH 没设对。你可以在 CMake 里加个 message() 打印路径,确认一下。

🔧 避坑指南: 我曾经因为忘记在子目录 CMakeLists.txt 里链接 Ascend::Proto,导致原型定义里的 shape 推导函数链接失败。编译过了,但运行时崩得一塌糊涂。所以链接库千万别省。

3.3 算子注册文件

算子注册文件,通常是一个 .ini 文件,放在 op_kernel/ 下。它告诉 CANN 框架:我这个算子叫什么、输入输出有几个、是什么类型、对应的实现文件是哪个。说白了,就是给算子办个“身份证”。

# my_op.ini
[OpInfo]
op_name = MyOp
impl_path = ./op_impl/my_op.py
input_num = 2
output_num = 1
input_dtype = float16, float16
output_dtype = float16
op_type = custom

字段说明:

字段含义示例
op_name算子名称,框架通过这个名字调用MyOp
impl_path算子实现文件的相对路径./op_impl/my_op.py
input_num输入张量个数2
output_num输出张量个数1
input_dtype输入数据类型,用逗号分隔float16, float16
output_dtype输出数据类型float16
op_type算子类型,custom 表示自定义custom

你可能会问:为什么不用 JSON 而用 INI?嗯,我个人觉得 INI 更轻量,而且 CANN 工具链原生支持,解析起来没坑。不过如果你团队习惯 JSON,也可以自己写个转换脚本,但别改框架默认的注册方式。

⚠️ 注意: 注册文件里的 impl_path 是相对于算子包安装目录的,不是相对于当前目录。我曾经把路径写成了绝对路径,结果换台机器就找不到了。建议用相对路径,并确保算子工程安装后目录结构不变。
算子工程结构总览 CMakeLists.txt (顶层) op_proto/ 原型定义 · shape推导 op_impl/ 算子实现 (TBE/AscendC) op_kernel/ 注册文件 .ini 编译 & 注册流程 1. CMake 构建 → 生成算子动态库 2. 解析 .ini 注册文件 → 算子信息入库 3. 框架加载算子 → 推理/训练调用

看到这张图了吧?顶层 CMakeLists.txt 像大脑,指挥三个子目录协同工作。原型定义负责“长什么样”,实现负责“怎么算”,注册文件负责“怎么被找到”。三者缺一不可。

最后再啰嗦一句:注册文件里的 op_name 一定要和原型定义里的算子名一致,大小写敏感。我有个同事曾经把 MyOp 写成了 myop,结果框架一直报“算子未注册”,排查了半天……嗯,这种低级错误咱们尽量避免。