今天开始动手写 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 位于 cann-8.5.0 目录

这次环境显示 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 接口说明

学习时写下的第一块 UB 缓冲:GlobalTensor 绑定之后,再创建 TBuf 和 LocalTensor

这是一张中途的代码截图,当时只完成了 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_VV_MTE3 的方向,分别对应上面的两句话。SetFlag 与 WaitFlag 必须成对使用相同事件;这里从 pipe 获取编号,而不是自己随意挑一个整数。官方流水同步说明

我还犯过两个很具体的笔误:setFlag 应是大小写正确的 SetFlag,搬出方向是 V_MTE3。这些名字虽然长,但把生产者和消费者认清后,记忆负担小了很多。

这段是按学习过程整理的核心片段,不是云端最终工程的完整快照。加入循环、多核或缓冲复用后,需要重新分析同步,不能照搬两对事件就认为全部正确。

6. Host 负责让这段代码真正跑起来

核函数写完还不够。Host 程序需要串起以下步骤:

  1. 初始化运行时,查询并选择设备。
  2. 准备 Host 输入数组,申请 x、y、z 的设备内存。
  3. 创建 stream,把 x、y 拷到设备。
  4. 启动核函数,等待 stream 完成。
  5. 把 z 拷回 Host,逐元素校验。
  6. 释放资源,并保留业务与清理阶段的失败状态。

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 版本及芯片型号为准。外部资料在新标签页打开。