今天开始动手写 Ascend C。之前接触过 GPU 算子的概念,但这次是从创建源文件开始,自己把一段计算放到 NPU 上跑。
任务很小:两个长度为 256 的 float32 数组逐元素相加,得到 z[i] = x[i] + y[i]。先固定长度、只启动一个 block,把内存、搬运、同步、启动和校验的过程走通。
最终 256 个输出全部通过校验,程序退出码为 0。今天没有测性能,所以这篇记录的是正确性链路,还不能称为优化成果。
1. Notebook 只是入口,核函数仍然需要编译
一开始,我把 C++ 代码写进了 Python Notebook 的 cell。后来才找到文本编辑器,真正创建了 add_custom.cpp。
Python 内核不负责解释 Ascend C。Notebook 环境提供编辑文件和打开终端的入口;源文件需要交给相应编译工具链,生成可执行程序后再运行。
终端里检查设备与编译器:
npu-smi info
which bisheng

这次环境显示 910B4,bisheng 位于 /usr/local/Ascend/cann-8.5.0/bin/bisheng。这张图只能说明当时能看到设备和编译器入口,不代表任意工程都已经配好。
另一个容易混淆的数字是设备 ID。npu-smi 里看到的物理设备编号,不能直接当作 aclrtSetDevice 应填的值;容器和可见设备设置会产生映射。本次 Host 查询到一个可见设备,使用用户设备 ID 0 成功运行。CANN 8.5 设备 ID 映射说明
2. GlobalTensor 绑定已有内存
核函数接收 x、y、z 的 GM 地址。以 x 为例:
AscendC::GlobalTensor<float> xGm;
xGm.SetGlobalBuffer((__gm__ float*)x, 256);
这里的 256 表示 256 个 float 元素,不是 256 字节。SetGlobalBuffer 把 GlobalTensor 与给定地址、长度关联起来,并不会替 Host 新申请一块 GM 内存。SetGlobalBuffer 接口说明
我最初把“绑定数据视图”和“申请存储”混在了一起。把它们分开后,这段代码就好读了:Host 负责准备设备内存,核函数在收到地址后,用对应类型解释它。
3. 为这次计算准备 UB 缓冲
接下来是在片上准备 x 的局部缓冲:
AscendC::TPipe pipe;
AscendC::TBuf<AscendC::TPosition::VECCALC> xBuf;
pipe.InitBuffer(xBuf, 256 * sizeof(float));
AscendC::LocalTensor<float> xLocal = xBuf.Get<float>();
在今天这个用法里,xBuf 管理 UB 中的缓冲,InitBuffer 的长度单位是字节;Get<float>() 取得访问该缓冲的 LocalTensor,并没有再分配一个同样大小的数组。InitBuffer 接口说明

这是一张中途的代码截图,当时只完成了 x 的缓冲,核函数还没有进行计算。
后来给 y、z 也准备同样的缓冲。每块 1024 字节,三个缓冲共 3072 字节。这里算的是本次 UB 数据缓冲占用,不能把它与 GM 分配量混算。
如果对“为什么还要搬进 UB”感到困惑,可以先看配套的架构文章。
4. 搬入的是输入,搬出的是结果
输入搬运写成:
AscendC::DataCopy(xLocal, xGm, 256);
AscendC::DataCopy(yLocal, yGm, 256);
DataCopy 在前面写目标,后面写来源。这一重载的第三个参数是元素数。256 个 float 共 1024 字节,满足这个普通连续搬运用法的 32 字节倍数要求;换成其他长度时,需要重新考虑尾部处理。普通数据搬运接口说明
我曾顺手也写了一行 DataCopy(zLocal, zGm, 256),后来意识到 z 是输出,旧值并不是本次加法的输入。应该等计算产生结果,再向相反方向搬运:
AscendC::DataCopy(zGm, zLocal, 256);
这两段之间还缺计算和同步,不能把它们直接拼起来当成完整核函数。
5. 加法前后分别是谁等谁
这一步对我最有帮助的做法,是先不用 API 名字,直接描述依赖:Vector 要等输入搬完,搬出单元要等 Vector 算完。
下面是单次处理一块数据时的核心片段,前提是 GM 绑定与三个 UB 缓冲已经准备好:
AscendC::DataCopy(xLocal, xGm, 256);
AscendC::DataCopy(yLocal, yGm, 256);
auto inputReady = pipe.FetchEventID(AscendC::HardEvent::MTE2_V);
AscendC::SetFlag<AscendC::HardEvent::MTE2_V>(inputReady);
AscendC::WaitFlag<AscendC::HardEvent::MTE2_V>(inputReady);
AscendC::Add(zLocal, xLocal, yLocal, 256);
auto outputReady = pipe.FetchEventID(AscendC::HardEvent::V_MTE3);
AscendC::SetFlag<AscendC::HardEvent::V_MTE3>(outputReady);
AscendC::WaitFlag<AscendC::HardEvent::V_MTE3>(outputReady);
AscendC::DataCopy(zGm, zLocal, 256);
MTE2_V 与 V_MTE3 的方向,分别对应上面的两句话。SetFlag 与 WaitFlag 必须成对使用相同事件;这里从 pipe 获取编号,而不是自己随意挑一个整数。官方流水同步说明
我还犯过两个很具体的笔误:setFlag 应是大小写正确的 SetFlag,搬出方向是 V_MTE3。这些名字虽然长,但把生产者和消费者认清后,记忆负担小了很多。
这段是按学习过程整理的核心片段,不是云端最终工程的完整快照。加入循环、多核或缓冲复用后,需要重新分析同步,不能照搬两对事件就认为全部正确。
6. Host 负责让这段代码真正跑起来
核函数写完还不够。Host 程序需要串起以下步骤:
- 初始化运行时,查询并选择设备。
- 准备 Host 输入数组,申请 x、y、z 的设备内存。
- 创建 stream,把 x、y 拷到设备。
- 启动核函数,等待 stream 完成。
- 把 z 拷回 Host,逐元素校验。
- 释放资源,并保留业务与清理阶段的失败状态。
launch_add_custom(...) 不是编译器自动赠送的函数,它是我们自己写的 Host 包装函数;包装函数内部再用 Ascend C 的核启动语法调用 add_custom。两边名字不同,是因为职责不同。
extern "C" 用于约定 C 语言链接方式,让声明和定义按一致的符号名字链接。它本身不会启动 NPU,也不会让普通 C++ 编译器突然支持核启动语法。
这次核函数没有按核编号计算偏移,因此只启动一个 block。简单地把启动数量改大,会让多个执行实例处理相同地址,不能得到正确的数据分工。
7. 错误处理最后为什么用了 RAII
最初的写法是:申请 y 失败时手动释放 x;再加一个 z,就要在 z 失败的分支释放 x、y。后面还有 stream、device 和 runtime,分支很快重复起来。
后来把资源交给小型 RAII 类管理。成功构造的对象,在离开作用域时逆序析构;中途抛出异常,也能清理前面已成功取得的资源。复制被禁止,避免两个对象拥有同一份资源。
这里最值得记下的是三个细节:
- 析构函数不抛异常,清理失败单独记录。
- stream 的构造顺序安排在 buffer 之后,使它先等待、销毁,再释放 buffer。
- 最终退出码同时检查计算是否成功、清理是否失败,不能只看最后一次
aclFinalize()的返回值。
最后这一点是我自己追问过的问题:如果 ret 一直被覆盖,最后的成功就可能掩盖前面的失败。RAII 解决资源归属,错误状态仍然需要认真设计。
8. 编译报错时,先判断卡在哪一层
今天遇到过两个典型报错。
fatal error: 'acl/acl.h' file not found
它说明当前编译步骤没找到头文件,不能直接推断“机器没有安装 ACL”。我们当时已经能找到 CANN 编译器,需要继续核对实际头文件位置,以及 Ascend C 多阶段编译是否真的收到 include 路径。
另一个是 ld.lld 报 .o: unknown file type。这是构建产物识别失败,和 Add 算出来是不是正确是两类问题。仅凭这一行日志不能确定唯一原因;需要看前面的编译命令和产物。当天换用新的构建目录后最终构建通过,但没有保留足够证据把这个报错归因到某一个确定因素。
这次过程让我记住:configure 成功、build 成功、程序退出成功、结果校验通过,是四个需要分别确认的节点。
9. 真正的运行结果
输入后来没有一直用全 1 和全 2,而是使用随下标变化、包含负数的数据。下面是当天终端的实际输出节选:
Visible devices: 1
i=0 x=0 y=-0.75 NPU结果=-0.75 预期=-0.75
i=1 x=0.5 y=-0.5 NPU结果=0 预期=0
i=2 x=1 y=-0.25 NPU结果=0.75 预期=0.75
i=3 x=1.5 y=0 NPU结果=1.5 预期=1.5
i=4 x=2 y=0.25 NPU结果=2.25 预期=2.25
i=5 x=2.5 y=0.5 NPU结果=3 预期=3
i=6 x=3 y=0.75 NPU结果=3.75 预期=3.75
i=7 x=3.5 y=-0.75 NPU结果=2.75 预期=2.75
All 256 output elements match.
运行退出码:0
打印前八项是为了方便观察,真正的校验循环检查了全部 256 项,还检查了非有限值。一次通过只证明这组输入、这个长度下的结果正确,尚未覆盖其他 shape、尾块或多核场景。
下一次准备让固定大小的 UB 缓冲处理更长的数组:先把 Tiling 和偏移写对,再看缓冲什么时候可以安全复用。性能测量会在这个基础上单独补上。
官方参考资料
以下是本文参考的昇腾与 NVIDIA 官方资料。实验环境为 CANN 8.5.0;部分概念说明引用了较早版本的文档,具体 API 约束应以所用 CANN 版本及芯片型号为准。外部资料在新标签页打开。