CANN ops-nn Heaviside 算子实战指南:aclnnHeaviside 阶跃激活的接口、约束与源码实现
CANN ops-nn Heaviside 算子实战指南:aclnnHeaviside 阶跃激活的接口、约束与源码实现
本文以 CANN ops-nn 算子库中 Heaviside 算子的官方接口文档 aclnnHeaviside.md 为主体,完整覆盖两段式接口原型、参数校验规则与调用示例,并结合仓库中的 API 实现、tiling 配置与 kernel 源码,解析 broadcast 处理、多核切分与 NaN 行为的底层机制,帮助读者在 NPU 上正确接入并验证 Heaviside 激活算子。
算子定位与产品支持情况
Heaviside 算子位于仓库的 activation/heaviside 目录下,功能是“计算输入 input 中每个元素的 Heaviside 阶跃函数,作为模型的激活函数”。按 算子 README 与接口文档给出的产品支持情况:
| 产品 | 是否支持 |
|---|---|
| Ascend 950PR / Ascend 950DT | 支持 |
| Atlas A3 训练系列 / Atlas A3 推理系列 | 支持 |
| Atlas A2 训练系列 / Atlas A2 推理系列 | 支持 |
| Atlas Kirin X90 / Kirin 9030 处理器系列 | 支持(不支持 BF16,见约束说明) |
| Atlas 200I / 500 A2 推理产品 | 不支持 |
| Atlas 推理系列 / Atlas 训练系列 | 不支持 |
需要注意适用前提:本文所述 BF16/FP16/FP32 三种数据类型支持是针对 Ascend 950、A3、A2 系列;Atlas Kirin 系列产品不支持 BF16。
功能说明与计算公式
Heaviside 是对输入张量逐元素做阶跃映射的激活算子,同时接受一个 values 张量用于指定“输入恰好为 0”时的输出值。计算公式为:
即:负数映射为 0,正数映射为 1,零位置输出 values 中对应位置的值。从 kernel 源码 heaviside.h 的 Compute 函数注释("NAN eq NAN is false, 去除NAN")可以看出,该实现对 NaN 输入做了显式处理:NaN 元素最终输出 0,这与主流框架中阶跃函数对 NaN 的行为保持一致。
两段式接口与函数原型
aclnn 接口采用两段式调用:必须先调用 aclnnHeavisideGetWorkspaceSize 获取计算所需 workspace 大小以及包含算子计算流程的执行器(executor),再调用 aclnnHeaviside 执行计算。函数原型如下(与 aclnn_heaviside.h 中的声明一致):
aclnnStatus aclnnHeavisideGetWorkspaceSize(
const aclTensor *input,
const aclTensor *values,
aclTensor *out,
uint64_t *workspaceSize,
aclOpExecutor **executor)
aclnnStatus aclnnHeaviside(
void *workspace,
uint64_t workspaceSize,
aclOpExecutor *executor,
aclrtStream stream)
aclnnHeavisideGetWorkspaceSize:参数说明与校验行为
参数说明:
| 参数名 | 输入/输出 | 描述 | 使用说明 | 数据类型 | 数据格式 | 维度(shape) | 非连续Tensor |
|---|---|---|---|---|---|---|---|
| input(aclTensor*) | 输入 | 输入的张量,公式中的 input。 | 支持空Tensor;需要与 values 满足 broadcast 关系 | BF16、FP16、FP32 | ND | 0-8 | 支持 |
| values(aclTensor*) | 输入 | input 值为零时使用的值的张量,公式中的 values。 | 支持空Tensor;需要与 input 满足 broadcast 关系 | BF16、FP16、FP32 | ND | 0-8 | 支持 |
| out(aclTensor*) | 输出 | 输出的张量。 | 数据类型与输入保持一致;shape 与 input、values broadcast 后的 shape 一致 | BF16、FP16、FP32 | ND | 0-8 | 支持 |
| workspaceSize(uint64_t*) | 输出 | 返回需要在 Device 侧申请的 workspace 大小 | - | - | - | - | - |
| executor(aclOpExecutor**) | 输出 | 返回 op 执行器,包含算子计算流程 | - | - | - | - | - |
返回值与入参校验: 返回值为 aclnnStatus 状态码,含义参见 aclnn返回码。第一段接口会完成入参校验,出现以下场景时报错:
| 返回码 | 错误码 | 描述 |
|---|---|---|
| ACLNN_ERR_PARAM_NULLPTR | 161001 | 传入的输入参数(input、values)或输出参数 out 是空指针 |
| ACLNN_ERR_PARAM_INVALID | 161002 | 输入/输出参数的数据类型不在支持的范围之内 |
| ACLNN_ERR_PARAM_INVALID | 161002 | 输入参数 input 和 values 的 shape 无法做 broadcast |
| ACLNN_ERR_PARAM_INVALID | 161002 | 输出参数 out 的 shape 与输入参数经过 broadcast 后的 shape 不一致 |
这些校验在源码 aclnn_heaviside.cpp 中有逐条对应实现:CheckNotNull 检查空指针并返回 ACLNN_ERR_PARAM_NULLPTR;CheckDtypeValid 依据支持列表 {DT_FLOAT, DT_FLOAT16, DT_BF16} 校验数据类型;CheckShape 通过 OP_CHECK_BROADCAST_AND_INFER_SHAPE 推导 broadcast 后的 shape,并比较 out 的元素个数是否一致。
此外,源码在 L101-L106 对空 Tensor 做了快速返回处理:当 input、values 或 out 任一为空张量时,直接将 workspaceSize 置 0 并返回成功,即文档中“支持空 Tensor”的具体落地方式。
aclnnHeaviside:参数说明与执行
参数说明:
| 参数名 | 输入/输出 | 描述 |
|---|---|---|
| workspace | 输入 | 在 Device 侧申请的 workspace 内存地址 |
| workspaceSize | 输入 | 在 Device 侧申请的 workspace 大小,由第一段接口 aclnnHeavisideGetWorkspaceSize 获取 |
| executor | 输入 | op 执行器,包含了算子计算流程 |
| stream | 输入 | 指定执行任务的 Stream |
返回值为 aclnnStatus 状态码。从源码 L152-L157 可见,第二段接口内部调用 CommonOpExecutorRun 将第一段接口组装好的算子执行流程在指定 stream 上完成调度。
约束说明
- 确定性计算:aclnnHeaviside 默认确定性实现。
- 数据类型一致:input、values、out 的数据类型必须一致(见 README 约束说明)。
- 维度范围:input 支持 1~8 维(aclnn 接口参数表标注 0-8 维)。
- values 的形状限制:values 为标量(单元素)或与 input 的 shape 相同;kernel 内部不做广播,按相同线性偏移读取 values。如果 input 与 values 满足 broadcast 但 shape 不相同,广播会由 aclnn 第一段接口在处理流程中完成——aclnn_heaviside.cpp 中,当 values 不是单元素且两者 shape 不等时,先调用
l0op::BroadcastTo将两者统一到 broadcast 后的 shape,再执行l0op::Heaviside;否则直接执行l0op::Heaviside。这一分层设计与 kernel 端 tiling 校验逻辑(见下文GetValuesType)相互呼应。 - Kirin 产品限制:Atlas Kirin X90 / Kirin 9030 系列不支持 BF16。
源码纵深:执行流程、Tiling 切分与 Kernel 计算
第一段接口的完整处理链
从 aclnnHeavisideGetWorkspaceSize 的实现可以看到标准的 aclnn 组装流程:
CREATE_EXECUTOR()创建算子执行器;CheckParams完成空指针、数据类型、shape 三类校验;- 空张量快速路径(workspaceSize 置 0 直接返回);
l0op::Contiguous将非连续的 input、values 转为连续张量(因此接口支持非连续 Tensor);- 必要时插入
l0op::BroadcastTo完成广播; - 调用 Level0 基础算子 l0op::Heaviside 执行核心计算;
l0op::ViewCopy将结果写回 out(兼容 out 为非连续 Tensor 的情况);GetWorkspaceSize()汇总中间张量占用的 workspace,并转移 executor 所有权给调用方。
Tiling:多核切分与 values 类型判别
Host 侧 tiling 逻辑位于 heaviside_tiling.cpp,核心设计有三点:
- 单核元素上限:
MAX_ELEMENT_NUM_EACH_CORE = 8 * 1024(8192 个元素)。GetNeedCoreNum用ceil(inputShapeSize / 8192)估算所需核数,并与平台 AIV 核数取小;小于 1 时保底为 1。 - values 类型判别:
GetValuesType只接受两种形态——valuesShapeSize == 1(标量)或valuesShape == inputShape(同 shape 张量),其余直接返回GRAPH_FAILED。这从 host 侧强制了 README 中“kernel 内部不广播”的约束。 - workspace 预留:固定申请
SIZE_16 * 1024 * 1024(16MB)workspace,写入GetWorkspaceSizes(1),即第一段接口返回给用户的 workspace 大小来源。
tiling 数据包含 elementNum(总元素数)、valuesType(values 是标量还是张量)、needCoreNum 三个关键字段,供 kernel 端消费。
图模式下的形状推导同样简单直接:heaviside_infershape.cpp 中,输出 shape 直接取 input 的 shape,输出数据类型与 input 保持一致。
Kernel:向量指令组合实现阶跃函数
Device 侧入口 heaviside.cpp 是一个标准的 __global__ __aicore__ 函数,仅当 tiling key 为 1 时实例化 HeavisideND<DTYPE_INPUT> 并执行 Init + Process。真正的工作在 heaviside.h 的 HeavisideND 模板类中,分为三个阶段:
1. 多核切分(Process):总元素数按 PP_ELEMENT_NUM(A2/A3 平台 8192,Kirin 平台 6144)分块后均分给各核,前几个核多承担一块以消化余数,最后一块的处理还专门照顾了 elementNum 除不尽的尾数(L96-L142)。
2. 数据搬运(CopyInAndCast):使用 ping-pong 双缓冲(EVENT_ID0/EVENT_ID1 事件与 PipeBarrier)重叠 DataCopyPad 搬运与向量计算;BF16 输入先在 UB 内 Cast 为 FP32 再参与比较,避免半精度比较问题。values 的处理依据 tiling 判别结果分两条路:张量形态按同偏移 DataCopyPad 搬运,标量形态用 valuesGm.GetValue(0) 直接取值作为标量。
3. 计算(Compute):Compute(L192-L256)通过 VE 指令链实现公式中的分段逻辑,指令序列为:
// NAN eq NAN is false, 去除NAN
Compare(selMaskOne, x1Tensor, x1Tensor, CMPMODE::EQ, tmpDataCount); // maskOne: x == x
Select(x1Tensor, selMaskOne, x1Tensor, (T)-1, SELMODE::VSEL_TENSOR_SCALAR_MODE, dataCount); // NaN -> -1
// 复用selMaskOne,标记0元素位置
CompareScalar(selMaskOne, x1Tensor, (T)0, CMPMODE::NE, tmpDataCount); // maskOne: x != 0
CompareScalar(selMaskTwo, x1Tensor, (T)0, CMPMODE::LE, tmpDataCount); // maskTwo: x <= 0
Maxs(x1Tensor, x1Tensor, (T)0, tmpDataCount); // x = max(x, 0)
Select(x1Tensor, selMaskTwo, x1Tensor, (T)1, SELMODE::VSEL_TENSOR_SCALAR_MODE, dataCount);
// 零位置回填 values
Select(x1Tensor, selMaskOne, x1Tensor, x2Tensor, SELMODE::VSEL_TENSOR_TENSOR_MODE, dataCount);
其语义可逐步解读:先用 x == x 比较滤掉 NaN(NaN 比较结果为 false),将 NaN 置为 -1;Maxs(0) 将所有非正数截为 0(实现了负数分支),maskTwo 为 false 的正数位置由 Select 置 1(实现了正数分支);最后用 x != 0 掩码(对置为 -1 的 NaN 仍为真,保证其不走 values 分支)把原始为零的位置回填为 values 值(张量形态)或标量 values(标量形态)。最终 NaN 输出 0。CastAndCopyOut 阶段再把 BF16 结果 Cast 回原类型并写回 GM。
调用示例
以下示例继承自接口文档,完整样例可参考 examples/test_aclnn_heaviside.cpp,编译与执行过程请参考 编译与运行样例:
#include <iostream>
#include <vector>
#include "acl/acl.h"
#include "aclnnop/aclnn_heaviside.h"
#define CHECK_RET(cond, return_expr) \
do { \
if (!(cond)) { \
return_expr; \
} \
} while (0)
#define LOG_PRINT(message, ...) \
do { \
printf(message, ##__VA_ARGS__); \
} while (0)
int64_t GetShapeSize(const std::vector<int64_t>& shape) {
int64_t shapeSize = 1;
for (auto i : shape) {
shapeSize *= i;
}
return shapeSize;
}
int Init(int32_t deviceId, aclrtStream* stream) {
// 固定写法,acl初始化
auto ret = aclInit(nullptr);
CHECK_RET(ret == ACL_SUCCESS, LOG_PRINT("aclInit failed. ERROR: %d\n", ret); return ret);
ret = aclrtSetDevice(deviceId);
CHECK_RET(ret == ACL_SUCCESS, LOG_PRINT("aclrtSetDevice failed. ERROR: %d\n", ret); return ret);
ret = aclrtCreateStream(stream);
CHECK_RET(ret == ACL_SUCCESS, LOG_PRINT("aclrtCreateStream failed. ERROR: %d\n", ret); return ret);
return 0;
}
template <typename T>
int CreateAclTensor(const std::vector<T>& hostData, const std::vector<int64_t>& shape, void** deviceAddr,
aclDataType dataType, aclTensor** tensor) {
auto size = GetShapeSize(shape) * sizeof(T);
// 调用aclrtMalloc申请device侧内存
auto ret = aclrtMalloc(deviceAddr, size, ACL_MEM_MALLOC_HUGE_FIRST);
CHECK_RET(ret == ACL_SUCCESS, LOG_PRINT("aclrtMalloc failed. ERROR: %d\n", ret); return ret);
// 调用aclrtMemcpy将host侧数据拷贝到device侧内存上
ret = aclrtMemcpy(*deviceAddr, size, hostData.data(), size, ACL_MEMCPY_HOST_TO_DEVICE);
CHECK_RET(ret == ACL_SUCCESS, LOG_PRINT("aclrtMemcpy failed. ERROR: %d\n", ret); return ret);
// 计算连续tensor的strides
std::vector<int64_t> strides(shape.size(), 1);
for (int64_t i = shape.size() - 2; i >= 0; i--) {
strides[i] = shape[i + 1] * strides[i + 1];
}
// 调用aclCreateTensor接口创建aclTensor
*tensor = aclCreateTensor(shape.data(), shape.size(), dataType, strides.data(), 0, aclFormat::ACL_FORMAT_ND,
shape.data(), shape.size(), *deviceAddr);
return 0;
}
int main() {
// 1.(固定写法)device/stream初始化, 参考acl对外接口列表
// 根据自己的实际device填写deviceId
int32_t deviceId = 0;
aclrtStream stream;
auto ret = Init(deviceId, &stream);
CHECK_RET(ret == ACL_SUCCESS, LOG_PRINT("Init acl failed. ERROR: %d\n", ret); return ret);
// 2.构造输入与输出,需要根据API的接口自定义构造
std::vector<int64_t> selfShape = {2, 4};
std::vector<int64_t> valuesShape = {2, 4};
std::vector<int64_t> outShape = {2, 4};
void* selfDeviceAddr = nullptr;
void* valuesDeviceAddr = nullptr;
void* outDeviceAddr = nullptr;
aclTensor* self = nullptr;
aclTensor* values = nullptr;
aclTensor* out = nullptr;
std::vector<float> selfHostData = {-1.0, -1.0, 0, 0, 0, 0, 5, 7};
std::vector<float> valuesHostData = {-1.0, -1.0, 0, 1, 2, 3, 4, 5};
std::vector<float> outHostData = {0, 0, 0, 0, 0, 0, 0, 0};
// 创建self aclTensor
ret = CreateAclTensor(selfHostData, selfShape, &selfDeviceAddr, aclDataType::ACL_FLOAT, &self);
CHECK_RET(ret == ACL_SUCCESS, return ret);
// 创建values aclTensor
ret = CreateAclTensor(valuesHostData, valuesShape, &valuesDeviceAddr, aclDataType::ACL_FLOAT, &values);
CHECK_RET(ret == ACL_SUCCESS, return ret);
// 创建out aclTensor
ret = CreateAclTensor(outHostData, outShape, &outDeviceAddr, aclDataType::ACL_FLOAT, &out);
CHECK_RET(ret == ACL_SUCCESS, return ret);
// 3.调用CANN算子库API
uint64_t workspaceSize = 0;
aclOpExecutor* executor;
// 调用aclnnHeaviside第一段接口
ret = aclnnHeavisideGetWorkspaceSize(self, values, out, &workspaceSize, &executor);
CHECK_RET(ret == ACL_SUCCESS, LOG_PRINT("aclnnHeavisideGetWorkspaceSize failed. ERROR: %d\n", ret); return ret);
// 根据第一段接口计算出的workspaceSize申请device内存
void* workspaceAddr = nullptr;
if (workspaceSize > 0) {
ret = aclrtMalloc(&workspaceAddr, workspaceSize, ACL_MEM_MALLOC_HUGE_FIRST);
CHECK_RET(ret == ACL_SUCCESS, LOG_PRINT("allocate workspace failed. ERROR: %d\n", ret); return ret);
}
// 调用aclnnHeaviside第二段接口
ret = aclnnHeaviside(workspaceAddr, workspaceSize, executor, stream);
CHECK_RET(ret == ACL_SUCCESS, LOG_PRINT("aclnnHeaviside failed. ERROR: %d\n", ret); return ret);
// 4.(固定写法)同步等待任务执行结束
ret = aclrtSynchronizeStream(stream);
CHECK_RET(ret == ACL_SUCCESS, LOG_PRINT("aclrtSynchronizeStream failed. ERROR: %d\n", ret); return ret);
// 5.获取输出的值,将device侧内存上的结果拷贝至host侧
auto size = GetShapeSize(outShape);
std::vector<float> resultData(size, 0);
ret = aclrtMemcpy(resultData.data(), resultData.size() * sizeof(resultData[0]), outDeviceAddr,
size * sizeof(float), ACL_MEMCPY_DEVICE_TO_HOST);
CHECK_RET(ret == ACL_SUCCESS, LOG_PRINT("copy result from device to host failed. ERROR: %d\n", ret); return ret);
for (int64_t i = 0; i < size; i++) {
LOG_PRINT("result[%ld] is: %f\n", i, resultData[i]);
}
// 6.释放aclTensor
aclDestroyTensor(self);
aclDestroyTensor(values);
aclDestroyTensor(out);
// 7.释放device资源
aclrtFree(selfDeviceAddr);
aclrtFree(valuesDeviceAddr);
aclrtFree(outDeviceAddr);
if (workspaceSize > 0) {
aclrtFree(workspaceAddr);
}
aclrtDestroyStream(stream);
aclrtResetDevice(deviceId);
aclFinalize();
return 0;
}
示例中输入为 {-1, -1, 0, 0, 0, 0, 5, 7}(shape 2×4),values 为 {-1, -1, 0, 1, 2, 3, 4, 5}。按公式,期望输出为 {0, 0, 0, 1, 2, 3, 1, 1}:负数位置输出 0,零位置回填 values 对应值(0, 1, 2, 3),正数位置输出 1。这正好覆盖了公式的全部三个分支,适合作为行为验证用例。
测试验证与进一步阅读
仓库提供了多层级测试用于验证该算子:
- Kernel 单测:tests/ut/op_kernel/test_heaviside.cpp 针对
float32、float16、bfloat16三种数据类型,以 2×4 的 input 和 values 构造 8 元素用例(valuesType = false即张量形态),运行 kernel 后将结果落盘并与参考数据比对;测试数据由 gen_data.py 生成、compare_data.py 比对。 - Host/API 单测:test_aclnn_heaviside.cpp 针对第一段接口的参数校验逻辑做单元测试;test_heaviside_tiling.cpp 与 test_heaviside_infershape.cpp 分别覆盖 tiling 与形状推导。
- 系统测试用例:atk_aclnnHeaviside.json 定义了端到端的 ST 用例参数集。
如需在图模式(IR 构图)中使用该算子,可参考算子 proto 定义 heaviside_proto.h。结合本文内容,读者可以完整掌握:aclnnHeaviside 两段式接口的调用方式与参数约束、broadcast 与非连续 Tensor 在 aclnn 层的处理机制、多核切分与 ping-pong 流水线的设计思路,以及阶跃函数在向量指令层面的组合实现与 NaN 边界行为,从而在昇腾 NPU 上可靠地接入与调试 Heaviside 激活算子。