算子代码实现(AI CPU)
简介
AI CPU算子的实现包括两部分:
- 头文件:进行算子类的声明,自定义算子类需要继承CpuKernel基类。
- 源文件:重写算子类中的Compute函数,进行算子计算逻辑的实现。
头文件定义
用户需要在算子工程的“cpukernel/impl/xx.h”文件中进行算子类的声明,如下所示:
// CpuKernel基类以及注册宏定义 #include "cpu_kernel.h" // 定义命名空间aicpu namespace aicpu { // 算子类继承CpuKernel基类 class SampleCpuKernel : public CpuKernel { public: ~SampleCpuKernel() = default; // 声明函数Compute,且Compute函数需要重写 uint32_t Compute(CpuKernelContext &ctx) override; }; } // namespace aicpu
- 进行算子类的声明,此类为CpuKernel类的派生类,并需要声明重载函数Compute,Compute函数需要在算子实现文件中进行实现,详细请参见Compute函数实现。算子类的声明需要在命名空间“aicpu”中,命名空间的名字“aicpu”为固定值,不允许修改。
Compute函数实现
用户需要在算子工程的“cpukernel/impl/xx.cc”文件中进行算子的计算逻辑实现,如下所示:
// 引入声明算子类的头文件 #include "sample_kernels.h" namespace { //sample为算子原型中注册的算子的类型,可查看算子原型定义 const char *SAMPLE = "sample"; } // 定义命名空间aicpu namespace aicpu { // 实现自定义算子类的Compute函数 uint32_t SampleCpuKernel::Compute(CpuKernelContext &ctx) { Tensor *x0 = ctx.Input(0); // 对输入tensor进行基本校验,比如判断是否为空等操作 // 可根据获取到的输入tensor x0获取输入的shape,数据等信息 ... Tensor *x1 = ctx.Input(1); // 对输入tensor进行基本校验,比如判断是否为空等操作 // 可根据获取到的输入tensor x1获取输入的shape,数据等信息 ... Tensor *y0 = ctx.Output(0); // 可根据获取到的输出tensor y0获取输出的shape,数据等信息 ... AttrValue *attr = ctx.GetAttr(attr); //获取属性信息,并对属性进行基本校验,比如判断是否为空等操作 ... // 根据输入信息组织计算逻辑,得到输出结果,并将结果设置到输出的tensor中 ... // 动态shape类算子需要额外更新输出的tensor的shape等信息 ... return 0; } // 注册该算子实现 REGISTER_CPU_KERNEL(SAMPLE, SampleCpuKernel); } // namespace aicpu
- 引入相关头文件。
头文件sample_kernels.h,头文件定义中声明的头文件。
- 定义命名空间,声明常量字符指针指向算子的OpType。
如下所示:
namespace { //sample为算子的OpType const char *SAMPLE = "sample"; }
其中,sample为算子原型中注册的算子的类型,可查看算子原型定义,SAMPLE为声明的指向算子OpType的常量指针。
- 定义命名空间aicpu,并在命名空间aicpu中定义算子的Compute函数,用户实现算子的计算逻辑。
命名空间的名称aicpu为固定值,基类及相关定义都在aicpu命名空间中,声明如下所示:
namespace aicpu { uint32_t SampleCpuKernel::Compute(CpuKernelContext &ctx) { ... ... }
SampleCpuKernel为头文件中定义的自定义算子类,形参CpuKernelContext为CPU Kernel的上下文,包括算子的输入输出Tensor以及属性等相关信息。
- Compute函数实现。
获取输入/输出Tensor相关信息,并进行合法性校验,然后根据输入信息组织计算逻辑,得出计算结果,并将输出结果设置到输出Tensor中。
例如:
// 从context中获取input tensor Tensor *input = ctx.Input(0); // 对输入tensor进行基本校验 // 例如,对获取到的input进行空指针校验 if (input == nullptr) { return 1; } // 获取input tensor的shape信息 auto inputShape = input->GetTensorShape(); for (int32_t i = 0; i < inputShape->GetDims(); ++i) { std::cout << "dim[" << i << "] size:" << inputShape->GetDimSize(i) << std::endl; } // 获取input tensor的DataType DataType inputType = input->GetDataType(); // 获取input tensor的数据地址 auto inputData = input->GetData(); // 获取输出tensor的数据地址以及shape Tensor *output = ctx.Output(0); auto outputShape = output->GetTensorShape(); auto outputData = output->GetData(); // 保存输出结果 outputData[0] = inputData[0];
以上算子实现时使用到API接口介绍可参见AI CPU API。
- 合法性校验一般包含如下几类的校验:
- 对获取到的Input进行空指针校验,此校验必选。
- 对输入输出个数进行校验。
- 对算子输入的内在逻辑进行校验。
例如,对于多输入算子,多个tensor的dtype需要保持一致,此时需要校验多个输入的dtype是否一致。若多输入的内在逻辑要求已经在算子原型定义的Verify函数中进行实现,则compute函数中的此校验可不再实现。
- 对dtype的校验。
可根据算子实际情况来选择是否进行dtype的校验,若某算子仅支持A、B两种数据类型,其他数据类型都不支持,此时可在实现算子计算逻辑前对dtype进行校验,判断dtype是否在支持的dtype列表中。
- 计算逻辑的实现。
// 获取第i个输入的类型 auto data_type = ctx.Input(i)->GetDataType(); switch (data_type) { case DT_FLOAT16: return OpCompute<Eigen::half>(...); case DT_FLOAT: return OpCompute<float>(...); case DT_DOUBLE: return OpCompute<double>(...); case DT_INT8: return OpCompute<int8_t>(...); case DT_INT16: return OpCompute<int16_t>(...); ... ... default: return PARAM_INVAILD; }
其中OpCompute函数为算子的计算过程实现函数。
PARAM_INVAILD的定义如下所示:
const uint32_t PARAM_INVAILD = 1;
算子计算逻辑实现时,有以下几点注意点:
- 由于C++自身不支持半精度浮点类型,对于半精度数据类型的计算实现,可使用第三方库Eigen来表示(建议使用3.3.9版本),具体可参考资料:Link。
例如,对于Less算子,输入为半精度时,用Eigen进行强转。
auto input = reinterpret_cast<Eigen::half *>(input_0->GetData());
说明:
第三方Eigen库还提供了比较系统的矩阵和向量等线性代数相关的运算操作,若您的算子实现涉及到相关操作,可以借助Eigen库实现。
例如,使用Eigen库进行矩阵的定义、初始化,并求取行列式的代码示例如下所示:
#include "Eigen/Dense" int m,n; Eigen::Matrix<float, Eigen::Dynamic, Eigen::Dynamic> eMatrix(m, n); for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { eMatrix(i, j) = i * m + j * n; } } //Using eigen to calculate the Determinant float result = eMatrix.determinant();
- 对于动态shape算子,无法根据算子原型定义的InferShape推导得到输出tensor的shape, 所以需要在Compute函数中完成输出shape的计算与更新。
std::vector<int64_t> dims = {inputData[0],inputData[1],3,4} outputShape ->SetDimSizes(dims);
- 算子计算过程可分块并行执行,这样可以有效地使用昇腾AI处理器的硬件资源,使性能达到最优。
- 分块并行为性能提升的一个手段,开发者可根据对算子的性能要求选择是否需要进行并行计算。
- 分块并行执行的功能,仅支持输入参数间独立运算的场景,若输入参数间存在数据依赖,则无法进行分块并行计算。
- 使用此功能时,需要在算子信息库定义中设置“opInfo.flagSupportBlockDim”为“True”,并设置“opInfo.functionName”为“RunCpuKernelWithBlock”。
- 首先使用GetAttr接口获取分块数目以及本次计算的分块ID,示例如下:
uint32_t blockdim = ctx.GetAttr("block_num")->GetInt(); uint32_t blockid = ctx.GetAttr("block_id")->GetInt();
说明:分块数目是系统根据用户配置的BlockDim切分原则(算子信息库中配置的opInfo.blockDimByIndex)及CPU核数自动计算的。每一个分块都会分配一个“block_id”,“block_id”的取值范围为blockdim-1。
获取“block_num”及“block_id”,用户可自行进行一些基本校验。
- 计算本次计算(当前分块的计算)的偏移量及数据量。
例如,若“opInfo.blockDimByIndex”配置为-1,即按照第一个输入参数的元素个数进行BlockDim的切分,则计算偏移量及数据量的代码示例如下:
// 获取第一个输入参数的元素个数 int64_t total = input0->NumElements(); int64_t startpos = 0; int64_t len = total; if (blockdim != 1) { // 计算每一块的最大数据量 uint32_t per_unit = std::ceil(total / blockdim); // 得出本次计算的偏移量 startpos = blockid * per_unit; // 得出本次计算的数据量。 // blockid的取值范围为:0~blockdim-1,为避免最后一块数据存在拖尾,所以当blockid为最后一块时,len取值为total - per_unit * (blockdim - 1) len = blockid < blockdim - 1 ? per_unit : (total - per_unit * (blockdim - 1)); }
- 进行算子的计算逻辑的实现。
完整的分块并行计算的算子样例可参见开源Ascend Sample仓,更多样例可参见自定义算子模板。
- 由于C++自身不支持半精度浮点类型,对于半精度数据类型的计算实现,可使用第三方库Eigen来表示(建议使用3.3.9版本),具体可参考资料:Link。
- 合法性校验一般包含如下几类的校验:
- 注册算子的Kernel实现。
REGISTER_CPU_KERNEL(SAMPLE, SampleCpuKernel);
- 第一个参数SAMPLE为2中定义的指向算子OpType的字符串指针。
- 第二个参数SampleCpuKernel为自定义算子类的名称。
样例参考
本节设计实现一个对元素求反余弦值的Acos算子。
首先我们对算子进行分析,明确算子的数学表达式,输入输出信息。
- Acos算子的数学表达式为:
y=acos(x)
计算过程是:对输入参数x求反余弦值,并赋值给输出参数y。
- 明确输入和输出。
- Acos算子有一个输入一个输出。
- 本样例中,算子输入支持的数据类型为double,算子输出的数据类型与输入数据的类型相同。
- 本样例中,对算子输入输出支持的shape不做限制。
- 本样例中,算子输入输出支持的format为:ND。
通过以上分析,得到Acos算子的设计规格如下:
算子类型(OpType) |
Acos |
||
---|---|---|---|
算子输入 |
name:x |
data type:double |
format:ND |
算子输出 |
name:y |
data type:double |
format:ND |
样例代码如下:
// 头文件定义 #ifndef _ACOS_KERNELS_H_ #define _ACOS_KERNELS_H_ #include "cpu_kernel.h" namespace aicpu { class AcosCpuKernel : public CpuKernel { public: ~AcosCpuKernel() = default; virtual uint32_t Compute(CpuKernelContext &ctx) override; }; } // namespace aicpu #endif
// 源文件Compute函数实现 #include "acos_kernels.h" #include <cmath> namespace { const char *ACOS = "Acos"; } namespace aicpu { uint32_t AcosCpuKernel::Compute(CpuKernelContext &ctx) { // 从context中获取输入tensor和输出tensor Tensor *input = ctx.Input(0); Tensor *output = ctx.Output(0); // 对输入tensor和输出tensor进行校验 if (input == nullptr || output == nullptr) { return 1; } // 分别获取输入tensor和输出tensor的数据地址 auto inputData = static_cast<double *>(input->GetData()); auto outputData = static_cast<double *>(output->GetData()); // 对输入tensor和输出tensor的数据地址进行校验 if (inputData == nullptr || outputData == nullptr) { return 1; } // 获取输入tensor的DataType DataType inputType = input->GetDataType(); // 对输入tensor的DataType进行校验 switch (inputType) { case DT_DOUBLE: break; default: return 1; } // 调用acos函数进行计算 auto num = input->NumElements(); for(int64_t i = 0; i < num; i++ ){ outputData[i] = std::acos(inputData[i]); } return 0; } REGISTER_CPU_KERNEL(ACOS, AcosCpuKernel); } // namespace aicpu
源文件代码具体解析如下:
- 引入相关头文件。
#include "acos_kernels.h" #include <cmath>
其中acos_kernels.h是Acos算子类声明头文件,cmath是c++标准库头文件。
- 定义命名空间,声明常量字符指针指向算子的OpType。
namespace { const char *ACOS = "Acos"; }
- 定义命名空间aicpu,并在命名空间aicpu中定义算子的Compute函数。
namespace aicpu { uint32_t AcosCpuKernel::Compute(CpuKernelContext &ctx) { ... }
- Compute函数实现。
uint32_t AcosCpuKernel::Compute(CpuKernelContext &ctx) { // 从context中获取输入tensor和输出tensor Tensor *input = ctx.Input(0); Tensor *output = ctx.Output(0); // 对输入tensor和输出tensor进行校验 if (input == nullptr || output == nullptr) { return 1; } // 分别获取输入tensor和输出tensor的数据地址 auto inputData = static_cast<double *>(input->GetData()); auto outputData = static_cast<double *>(output->GetData()); // 对输入tensor和输出tensor的数据地址进行校验 if (inputData == nullptr || outputData == nullptr) { return 1; } // 获取输入tensor的DataType DataType inputType = input->GetDataType(); // 对输入tensor的DataType进行校验 switch (inputType) { case DT_DOUBLE: break; default: return 1; } // 调用acos函数进行计算 auto num = input->NumElements(); for(int64_t i = 0; i < num; i++ ){ outputData[i] = std::acos(inputData[i]); } return 0; }
Compute实现中首先对数据做合法性校验,包括:
- 输入tensor和输出tensor的非空校验
- 输入tensor和输出tensor数据地址的非空校验
- 因为该样例算子设计为只支持double类型,所以对输入tensor的数据类型进行校验,非double数据类型则返回失败
完成合法性校验后,调用c++标准库的acos接口实现反余弦的计算逻辑。
- 注册算子的Kernel实现。
REGISTER_CPU_KERNEL(ACOS, AcosCpuKernel);