算子上层适配这件事,我今年在 CANN 生态里完整走了一遍。刚开始接触的时候,最头疼的不是算子本身有多难,而是"不知道去哪找一套能直接跑通的骨架"。说句实在话,网上资料不少,但要么只是讲某个 API 的用法,要么直接扔给你一个巨大的工程让你自己去抠。后来我把custom-op-tutorial这个教程仓库从头到尾梳理了一遍,按真实项目流程把自定义算子开发跑通了,这里把完整链路和踩过的坑一起整理出来,希望能帮你少走一点弯路。
这篇内容适合三类人看:一是正在做 AI 推理框架适配、需要把非标准算子下沉到 NPU 的开发者,二是刚接触 CANN 想搞清楚算子开发流程的学生,三是想评估自定义算子成本、准备做算子性能优化的架构师。你不需要先具备很深的内核功底,但最好懂一些 C++,知道基本的张量概念。文中的代码示例是按教学场景做了精简的,核心逻辑和真实工程一致,可以直接照着改。
1. 为什么自定义算子,以及为什么从custom-op-tutorial开始
1.1 标准算子覆盖之外的真实阻力
CANN 生态里已经内置了大量高频算子,比如卷积、矩阵乘、各种激活函数、归一化操作,覆盖了绝大多数主流模型。但实际做模型迁移或者算法创新时,总会遇到标准算子覆盖不到的场景。我遇到过的情况大概有三类:第一类是新算法里有特殊计算规则,框架层找遍了也没对应算子;第二类是标准算子的实现策略跟我的数据排布不匹配,直接调用会有明显的性能浪费;第三类是多个标准算子组合起来实现的计算,中间临时张量太多,访存开销大得离谱。
这种情况下就只能自己写算子。有人会问,能不能直接用框架层算子组合?能,但往往性能不理想。举个很直观的例子,你要实现一个带掩码的加权求和,用标准算子组合需要先做乘法、再做求和、再做掩码处理,每一轮都要把中间结果写回全局内存,数据搬运的代价比重计算本身还高。自定义算子可以把这几步融合在一个内核里完成,数据只搬一次,时间能省下一大半。
但自定义算子的学习曲线确实陡。它跟你平时写的 C++ 函数完全不是一回事,涉及算子原型的注册、宿主侧的 tiling 策略、内核侧的搬数和计算流水、算子包编译安装,每一步都有一堆概念和接口要熟悉。如果没有一个结构清晰的模板,光是把工程目录搭出来就够折腾一天的。
1.2 教程仓库解决的核心痛点
custom-op-tutorial这个项目的定位很明确:它不是一份文档,而是一整套可以运行的算子工程模板加配套说明。它帮你把算子开发的工程结构、构建脚本、样例代码、测试方法都搭好了,你只需要在这个骨架上填自己的逻辑。
我一开始也怀疑过这种脚手架式教程会不会让人变成"只会套模板"。实际用下来发现恰恰相反,因为算子工程的结构其实是非常固定化的,真正要动脑子的地方是 tiling 策略和内核实现。脚手架省掉的是那些重复且容易出错的工程配置,比如编译参数、算子注册宏、环境变量路径,这些配置如果从零手写,抄错一个路径就要排查半天。它把你学习的注意力集中在最核心的抽象逻辑上,这才是合理的上手方式。
从整个 CANN 生态的角度看,这个仓库相当于是一扇门。先跑通它,你对算子工程的三个层次、编译流程和运行机制就有了整体认知,后面哪怕换一个完全不同的算子,也只是替换部分代码,骨架是完全通用的。
2. 环境准备:拿到工程并确保能编译
2.1 硬件与软件栈检查清单
动手之前先把环境确认好,这一步省不得。自定义算子开发依赖 CANN 工具链,本质上是一个交叉编译过程:你在 x86 服务器上写代码,编译出来的产物部署到带 NPU 的硬件上执行。我建议你先在开发机上检查四件事。
第一是确认 NPU 驱动正常。最简单的办法是执行npu-smi info,能看到卡的基本状态就没有问题。第二是确认 CANN 工具包版本。执行cat /usr/local/Ascend/ascend-toolkit/latest/version.cfg,记录下版本号,后面编译脚本里要严格匹配。第三是确认环境变量已加载。正常安装后/usr/local/Ascend/ascend-toolkit/set_env.sh会被自动写进系统配置,但你最好还是手动source一次再执行echo $ASCEND_TOOLKIT_HOME验证。第四是编译工具链,Make、CMake(3.16 以上)、Python(3.7 或 3.9 都可以)要有。
提示:我吃过一次亏,服务器的 CANN 版本是 7.0 的,但代码是从另一个 5.1 版本环境下拷贝过来的,编译的时候报了一堆找不到头文件的错。后来统一升级重编才解决。如果你要在多个环境间切换,强烈建议按版本建独立目录,不要复用同一个 build 目录。
2.2 拉取教程仓库并整体扫一遍
环境就绪后,把custom-op-tutorial拉到本地。它的目录结构是这样分布的:根目录下有scripts、op_proto、op_host、op_kernel和test。这里我先不展开每个目录的细节,你只需要先对整个结构有个印象,知道它分为"算子定义"和"算子实现"两大块,编译脚本则是把它们串起来的那根线。
拿到仓库后建议别急着编译,先把根目录的 README 通读一遍。这个仓库的 README 写得比较贴近实际操作,里面注明了依赖的 CANN 版本范围、推荐的获取路径和一次快速构建的命令。我见过有人跳过 README 直接编译,结果因为缺少一个三方库报错,来回折腾了一个小时。花十分钟看文档,能省后面大把的时间。
拉取以后,直接执行一次脚本来验证环境是否可以编译。在scripts目录下找build.sh,执行前先打开看一眼。它内部做的事情很简单:创建build目录、调用 CMake 生成工程、执行 make。如果这一把能顺利跑完,说明你的基础环境没问题,可以进入下一步的代码解读了。如果编译失败,先看错误信息的前面部分,大多数情况是路径或者版本问题,后面我会专门列一张排查表。
3. 算子的三层结构:原型、宿主侧与内核侧
3.1 算子原型定义(op_proto 层)
任何一个自定义算子要能被框架识别和调度,第一步是要把"这个算子的抽象描述"注册进算子库。这个工作由op_proto完成。它不关心算子内部怎么算,只定义三件事:输入参数、输出参数、算子属性。
以教程里常见的 Add 算子为例,原型部分用宏定义完成:
#include "graph/operator_reg.h" namespace ge { REG_OP(Add) .INPUT(x, TensorType({DT_FLOAT})) .INPUT(y, TensorType({DT_FLOAT})) .OUTPUT(z, TensorType({DT_FLOAT})) .OP_END_FACTORY_REG(Add); }这里的DT_FLOAT就是只允许 float 类型输入。如果你要支持多种类型,就在TensorType里加枚举,比如{DT_FLOAT, DT_INT32}。你会发现这里的定义方式跟我们在框架层看到的不太一样,它不需要写任何计算逻辑,纯粹是给后续所有环节提供一份"接口契约"。
REG_OP这个宏还有一个作用,它会根据算子名生成对应的算子注册信息,后续框架在构图时会用这个注册名去找算子的实现。如果原型定义和后续内核函数的命名对不上,编译能过但运行时会报找不到算子的错误,这是新手比较容易踩的坑。
3.2 算子宿主侧逻辑(op_host 层)
如果说原型是"接口",那op_host就是"调度大脑"。它主要负责生成 tiling 数据。这个概念对没接触过异构计算的人需要解释一下:NPU 上执行算子时,并不是拿到整个张量直接计算,而是要把计算任务切分成多个小块,分发给多个计算核心并行执行。"怎么切、每块多大、每个核分多少数据"这个切分策略就是 tiling。
宿主侧代码会读取输入张量的 shape,算出总的数据量,然后决定用多少个核来跑、每个核处理多少数据,把这份策略信息打包传给内核函数。这个过程我建议用"一个班要擦黑板"来类比:50 块黑板(数据块),8 个同学(计算核心),tiling 逻辑就是决定"每个人擦几块、从第几块开始擦"的值日表。
3.3 算子内核实现(op_kernel 层)
op_kernel是真正干活的地方,用 Ascend C 编写。它会按照宿主侧传进来的 tiling 参数,把数据从全局内存搬到片上内存,执行计算,再把结果写回全局内存。这里面有两个容易混淆的概念:全局内存和片上内存。
全局内存可以理解为 NPU 上的大仓库,容量大但访问慢;片上内存是计算核心旁边的小工作台,容量小但访问极快。内核开发的核心目标就是让数据在"工作台"上高效流转,尽量避免频繁回仓库取货。Ascend C 提供了一套队列机制来管理搬运和计算,让数据可以像流水线一样一边搬一边算,这些机制后面写内核的时候再细讲。
3.4 构建脚本与 CMake 规则
最后把三层代码串起来的是构建脚本。custom-op-tutorial用 CMake 统一管理三个子目录的编译,核心思想是分别编译出三块产物:原型库(用于框架查询算子信息)、算子宿主库(包含 tiling 逻辑)和内核库(真正的计算代码),最终打包成标准的算子包。
我最初看 CMakeLists 的时候有点被绕晕,后来发现不用理解全部 CMake 语法,重点关注几个变量就行:ASCEND_CUSTOM_OPP_PATH指向输出目录,ASCEND_TOOLKIT_HOME指向 CANN 安装路径,op_proto/op_host/op_kernel分别由三个子目录的 CMakeLists 负责。脚本里现有的构建规则已经覆盖了绝大多数场景,你需要改的通常只有算子源文件列表。
4. 手把手写一个 Add 算子:从 tiling 到内核
4.1 新建算子文件的清单
在教程模板的基础上增加一个算子,需要动五个文件:原型定义(op_proto/add.h和op_proto/add.cpp)、宿主侧 tiling(op_host/add_tiling.cpp)、内核实现(op_kernel/add_kernel.cpp)、宿主侧对应头文件(add_tiling.h)。同时把新文件名写进对应子目录的 CMakeLists。
这里有一个容易忽略的点:编译脚本默认只扫描配置过的源文件。我最初把add_kernel.cpp放进op_kernel目录后没有改 CMakeLists,编译出来算子包是空的,加载算子时系统一直提示算子不存在。所以新建文件后第一件事,是去对应 CMakeLists 里把新增的.cpp加进去。
4.2 编写宿主侧 tiling 逻辑
tiling 代码最核心的目标就一个:根据输入 shape 算出每个核处理多少数据,并生成内核侧需要的数据结构。以下代码是模板里常见的样子,我加了注释解释每个字段的意义:
#include "add_tiling.h" namespace optiling { static ge::graphStatus TilingFunc_Add(gert::TilingContext* context) { auto inputShape = context->GetInputShape(0); uint32_t totalLength = 1; for (size_t i = 0; i < inputShape->GetDimNum(); i++) { totalLength *= inputShape->GetDim(i); } uint32_t blockDim = 8; uint32_t perCoreLen = (totalLength + blockDim - 1) / blockDim; AddTilingData tiling; tiling.totalLength = totalLength; tiling.blockDim = blockDim; tiling.perCoreLen = perCoreLen; context->SetBlockDim(blockDim); context->SetTilingData(&tiling, sizeof(tiling)); return ge::GRAPH_SUCCESS; } }这里我先简单采用均分策略,totalLength是浮点数个数,除以blockDim向上取整得到每个核的目标数量。生产环境当然不会这么草率,后续要考虑数据对齐、buffer 大小、访存连续性,但第一步先跑通最重要。SetTilingData会把自定义的结构体原样传给内核侧,内核侧用对应的GET_TILING_DATA宏取出来。两个结构体的类型必须保持一致,这是约定,也是很容易踩的坑。
4.3 Ascend C 内核代码的结构化实现
看内核代码之前先说个概念:Ascend C 的典型结构是"初始化 + 循环处理 + 队列管理"。Init 阶段从全局内存创建全局张量视图,根据核编号计算本核负责的数据范围;Process 阶段用流水线方式循环搬数、计算、写回。
我写一个简化版的 Add 内核,突出关键结构:
#include "kernel_operator.h" using namespace AscendC; constexpr uint32_t BUFFER_NUM = 2; class KernelAdd { public: __aicore__ inline KernelAdd() {} __aicore__ inline void Init(GM_ADDR x, GM_ADDR y, GM_ADDR z, uint32_t totalLength, uint32_t blockDim) { uint32_t coreIdx = GetBlockIdx(); uint32_t perCoreLen = totalLength / blockDim; uint32_t remainder = totalLength % blockDim; uint32_t myStart = coreIdx * perCoreLen + (coreIdx < remainder ? coreIdx : remainder); uint32_t myLen = perCoreLen + (coreIdx < remainder ? 1 : 0); gmX.SetGlobalBuffer((__gm__ float*)x + myStart, myLen); gmY.SetGlobalBuffer((__gm__ float*)y + myStart, myLen); gmZ.SetGlobalBuffer((__gm__ float*)z + myStart, myLen); pipe.InitBuffer(inQueueX, BUFFER_NUM, myLen * sizeof(float)); pipe.InitBuffer(inQueueY, BUFFER_NUM, myLen * sizeof(float)); pipe.InitBuffer(outQueueZ, BUFFER_NUM, myLen * sizeof(float)); } __aicore__ inline void Process() { uint32_t remaining = /* 本核数据总量 */; while (remaining > 0) { uint32_t curLength = (remaining > step ? step : remaining); CopyIn(curLength); Compute(curLength); CopyOut(curLength); remaining -= curLength; } } private: __aicore__ inline void CopyIn(uint32_t length) { LocalTensor<float> xLocal = inQueueX.AllocTensor<float>(); LocalTensor<float> yLocal = inQueueY.AllocTensor<float>(); DataCopy(xLocal, gmX[当前偏移], length); DataCopy(yLocal, gmY[当前偏移], length); inQueueX.EnQue(xLocal); inQueueY.EnQue(yLocal); } __aicore__ inline void Compute(uint32_t length) { LocalTensor<float> xLocal = inQueueX.DeQue<float>(); LocalTensor<float> yLocal = inQueueY.DeQue<float>(); LocalTensor<float> zLocal = outQueueZ.AllocTensor<float>(); Add(zLocal, xLocal, yLocal, length); outQueueZ.EnQue(zLocal); inQueueX.FreeTensor(xLocal); inQueueY.FreeTensor(yLocal); } __aicore__ inline void CopyOut(uint32_t length) { LocalTensor<float> zLocal = outQueueZ.DeQue<float>(); DataCopy(gmZ[当前偏移], zLocal, length); outQueueZ.FreeTensor(zLocal); } GlobalTensor<float> gmX, gmY, gmZ; TPipe pipe; TQue<QuePosition::VECIN, BUFFER_NUM> inQueueX, inQueueY; TQue<QuePosition::VECOUT, BUFFER_NUM> outQueueZ; }; extern "C" __global__ __aicore__ void add_kernel(GM_ADDR x, GM_ADDR y, GM_ADDR z) { GET_TILING_DATA(tiling, context); KernelAdd op; op.Init(x, y, z, tiling.totalLength, tiling.blockDim); op.Process(); }这种用双 buffer 设计的流水线结构是性能的关键:当计算单元在算当前这一块数据时,搬数单元可以同时搬运下一块数据到另一个 buffer,两者互不阻塞。EnQue和DeQue就是用来管理这种双 buffer 交替的。理解了这个机制,你再看很多高性能算子的实现就不会觉得玄了。
4.4 定义框架需要的注册信息
原型、tiling、内核都写完后,还有一个衔接环节:告诉框架这三个部分怎么关联起来。这一步通常在op_host里的算子注册文件中通过宏完成,模板已经写好了 Add 的注册代码,你只需要把算子名和函数指针配对正确。
这一步骤出错时编译也能过,因为它是运行时配置,但算子首次被调度时会报"算子 xxx 未注册"或者"tiling 函数未找到"。排查这类问题有一个技巧:在模块加载时开启环境变量ASCEND_GLOBAL_LOG_LEVEL=1,看初始化日志里是否打印了算子注册成功的记录,比蒙着眼睛看框架报错信息高效得多。
5. 构建、加载与运行验证
5.1 一键构建需要注意的是细节
执行scripts/build.sh后,脚本会依次编译三层代码,最终产物会在输出目录out下。你可以打开out目录看结构:算子库文件会被组织成op_proto、op_host、op_kernel三个子目录,这些将来要复制到部署环境对应的算子库搜索路径中。
构建过程中我建议盯着几步:CMake 生成阶段是否成功找到 CANN 的ascend-toolkit路径,编译阶段三个目录是否都顺利产出.so文件,最后安装阶段输出日志里有没有出现绿色的[Success]。不要只盯最后一个界面,因为脚本内部如果某一步失败,整个任务会直接终止。
如果跑完后在out目录下看到了算子库,可以执行一个简单的检查命令,用系统自带的算子信息查询工具确认 Add 算子已入库。这一步能帮你把"编译成功"和"算子可用"这两个状态分开,不然手写调用代码验证时会浪费很多时间。
5.2 编写简化的调用代码
算子装好之后,怎么验证它真的能跑?最直接的方式是写一小段调用程序,创建输入张量,调算子,比对输出。我在教程基础上整理了一个最简调用路径:
#include "acl/acl.h" #include <vector> #include <iostream> int main() { // 初始化资源 aclInit(nullptr); aclrtSetDevice(0); // 创建输入输出张量 uint32_t size = 1024 * sizeof(float); float* hostX = new float[1024]; float* hostY = new float[1024]; float* hostZ = new float[1024]; for (uint32_t i = 0; i < 1024; i++) { hostX[i] = i * 1.0f; hostY[i] = (i + 1) * 1.0f; } // 异步 memcpy 到设备端(略写),创建 op 描述符, // 输入输出 tensor 描述,执行算子后再拷贝回 host。 // 预先分配设备内存,并做一次校验 bool correct = true; for (uint32_t i = 0; i < 1024; i++) { if (fabs(hostZ[i] - (hostX[i] + hostY[i])) > 1e-5) { correct = false; break; } } std::cout << (correct ? "PASS" : "FAIL") << std::endl; aclrtResetDevice(0); aclFinalize(); return 0; }这里我只写了框架,细节包括aclrtMalloc、aclrtMemcpyAsync和算子执行的若干调用都需要按实际接口补齐。重点是想告诉你验证算子的最小闭环长什么样:分配内存、搬运数据、执行算子、搬回数据、对比结果。很多人写算子跑通了就算了,我建议一定加这个结果对比,因为有的错误不会崩溃,只是静默地算错,不比对很难发现。
5.3 结果核对与性能剖析
结果正确之后,下一步就是看性能。初学者最容易忽略这一点:能跑对和能高效跑是两回事。性能分析分两层看,第一层是全局耗时,直接看算子调用前后的时间差;第二层是内核内部的时间分布,看看时间主要耗在搬数还是计算上。
CANN 提供了性能剖析工具,打开 profiling 后再跑一次算子,会得到完整的时间轴。我一次印象很深的排查经历是:写了一个看起来没问题的算子,整体跑出来比预期慢两倍,一剖析发现搬数和计算完全没有重叠,相当于流水线没有真正跑起来,数据从一个 buffer 搬到片上等计算完了才搬下一块。后来增加 buffer 数量并重新调整队列深度,性能立刻上来了。这些瓶颈通过工具看数据一眼就明白,靠猜浪费一下午都不一定能定位。
6. 常见报错与排查经验
6.1 高频错误速查表
我在跑通和后续改算子的过程中,把遇到过的错误整理成了一张表。你照着顺序排查,大部分问题能少走弯路:
| 现象 | 常见原因 | 处理方法 |
|---|---|---|
编译时报找不到kernel_operator.h | 环境变量未生效或 CANN 路径配置错误 | 确认ASCEND_TOOLKIT_HOME正确指向已安装版本 |
| 编译成功,但算子加载时说算子不存在 | CMakeLists 没把新增源码加进构建 | 检查三个子目录的 CMakeLists,源文件是否列入 |
| 运行时不报错,但输出全零 | 设备内存没有正确初始化或搬运失败 | 加日志打印每一步内存地址和长度 |
| 运行时提示 tiling 数据错误 | tiling 数据结构和内核侧定义不一致 | 对比add_tiling.h与GET_TILING_DATA用到的结构 |
| 多核运行结果正确但数值错位 | 计算任务切分的边界重叠或遗漏 | 手算几个边界下标,专门构造非整除的数据量测试 |
| 性能远低于预期 | buffer 数量不足,搬数计算未重叠 | 提高BUFFER_NUM,检查 queue 的分配和回收 |
6.2 我踩过的三个实际坑
第一个坑是版本混淆。我第一次在服务器上跑通后,把编译产物放到一个 CANN 版本更高的机器上,结果运行时报"算子与当前运行环境不兼容"。排查到最后发现,算子包在编译时绑定了头文件里的版本宏,换了环境后宏判断不通过。后来我在构建脚本里加了版本检测,不匹配就提前报错,问题就好定位了。
第二个坑是浮点对齐。你以为写完 Add 就完事了?真到生产环境,输入数据的 shape 千奇百怪,不一定能被核数量和 buffer 整除。我最初写的代码只适配了"刚好整除"的情况,线上跑到某个模型时突然性能暴跌,查了很久才发现是有一个核分到的数据量为 0,后续逻辑死循环空转。边界处理一定在写算子时就想到,不要等线上出问题才回头补。
第三个坑是队列资源泄漏。Ascend C 的队列机制要求每次AllocTensor之后必须FreeTensor,我教程里写得很对称,但是后来自己扩展算子时忘了释放,运行几次后内存占用不断上涨,最后直接被系统杀掉。养成每次写完拷贝进拷贝出逻辑后马上自查队列分配和释放对称的习惯,能省很多事。
6.3 排查方法的顺序与工具链
遇到问题不要一上来就蒙头看代码。我的排查顺序是固定的:先确认算子有没有注册,再看 tiling 参数是否正确,接着看内核侧数据搬运的起始地址和长度,最后才怀疑计算逻辑本身。前三个环节错了,表现方式很容易混淆成计算错误,按顺序排查能快速排除。
日志和调试工具是你最好的朋友。开启ASCEND_GLOBAL_LOG_LEVEL=1能看到算子生命周期相关信息,ASCEND_SLOG_PRINT_TO_STDOUT=1把日志打到标准输出。很多人刚上手不认识这些环境变量,遇到问题就靠猜。其实 CANN 的日志体系设计得很有条理,函数入口、tiling 赋值、内存搬运都会留痕,看懂日志输出顺序基本就能定位故障层次。
7. 从 Add 走向更多自定义算子的扩展思路
7.1 增加算子类型与数位宽的常规路线
跑通 Add 之后,你的脑子里应该已经有了一个通用框架图。后面增加新算子,大部分工作不需要改框架,只需要替换三层文件中的核心逻辑。比如做减法,内核算子从Add换成Sub;做逐元素相乘,换成Mul。如果你想让算子支持 int32,需要在原型定义里扩展TensorType,同时在 tiling 逻辑里考虑数据类型的大小对 buffer 占用空间的影响。
更进阶一点的改动是支持广播语义,比如让一个二维张量和一个标量相加。这要求 tiling 阶段处理更复杂的形状关系,内核侧在搬运每个数据块时,也要正确处理标量在哪个位置。这类改造是理解数据布局的关键训练,强烈建议在走通 Add 之后就练一次。
7.2 从教学算子到生产级算子的差距
模板跑通的 Add 和可以直接上生产的算子之间还有几条鸿沟。第一是性能,教学代码对大部分算子写得都很直白,生产算子需要在数据对齐策略、buffer 深度、内层循环展开上做精细调优。第二是鲁棒性,你需要考虑空输入、超大 shape、异常数值等各种边界情况。第三是算子测试的完备性,生产算子一般要求覆盖多种 shape、多种数据类型和多种分布形态。
我经常会收到一种心态比较急的问题:"我把 Add 写出来了,接下来是不是就直接写大模型的自定义算子?" 我的建议是不要跳。你可以在简单算子上把每个环节做深,比如尝试不同的 tiling 策略看看性能差异,尝试不同的 buffer 深度感受流水线机制的影响。这些实验成本低,收益直接,能帮你建立起对这套体系的直觉。有了直觉,后面做复杂算子时你才知道瓶颈和优化方向在哪里,经验都是一步步攒出来的。
7.3 持续跟进生态演进的方式
最后分享一个保持信息新鲜的技巧。CANN 生态更新很快,新版本会调整接口、增加更高效的计算原语、优化编译链的行为。如果你不想每次都在旧环境里打转,建议养成三个习惯:每季度看一次官方 release note,关注新增了哪些算子模式和接口;遇到不确定的函数签名,直接去安装目录下的头文件里查实际定义,很多问题看头文件比看二手文档快;把环境变量和版本信息整理成一个简单脚本,换机器的时候一键复现环境,把最耗时间的版本排查挡在门外。
我自己从第一次跑通 custom-op-tutorial 到现在,大概把里面的模板改造成了几类不同算子的基础骨架,每次改动里收获最大的不是最终那个算子本身,而是对数据搬运、任务切分、流水线交织这套体系的理解。这套理解是可迁移的,拿到任何一张 NPU 上做算子开发,思路都是相通的。
最后再补一句实操感受:如果你真的想在这条路上走远一点,不要满足于"跑通了"。同一个 Add,试试把数据量扩大十倍看会不会崩,试试把核数改成不整除的数,试试不用双 buffer 再对比一次性能。把这些实验做一遍,你对自定义算子的理解会远超那些只看教程的人,后面写任何算子的底气和速度也会完全不一样。