算子适配

适配代码仓介绍

  • Paddle 针对除 CPU 和 Nvidia GPU 之外的其他硬件的适配代码,均存于PaddleCustomDevice代码仓
  • 以 NPU 适配代码为例,其路径为 PaddleCustomDevice/backends/npu。在此路径下,有两个目录值得重点关注,分别是 kernels 目录(主要用于算子适配)和 tests 目录(主要用于单元测试)。
    在这里插入图片描述

确认算子原型

CANN包中有的算子才可以适配,根据Paddle的API确认与算子的对应关系是适配的第一步,下边是一些必须的参考资料地址。

适配流程

适配主要分为以下几个步骤:

算子注册

PaddleCustomDevice/backends/npu/kernels 路径下,每个 .cc 文件均包含一个或多个算子的适配代码。
首先,需要查找待适配的算子是否已经完成注册。若未注册,则需添加注册步骤;若已注册,则要检查该算子所支持的数据类型,此过程可参考 Paddle-cpu或 Paddle GPU 的实现方式。
下面以简单的 abs_kernel 为例,该代码文件的最下方为算子注册部分。
在这里插入图片描述

上图注册代码的前四个入参位置固定,具体含义如下:

  • “abs”:API 名称
  • “npu”:硬件名称
  • “ALL_LAYOUT”:支持的数据布局(Layout)类型,默认传入 ALL_LAYOUT 即可
  • “AbsKernel”:适配函数名称

注意:

  • 后续入参为该 API 支持的数据类型,顺序不限。数据类型的传递应主要依据 API 的需求来确定。若算子不支持某些数据类型,可选择在内部算子前后添加类型转换(CAST)操作。
  • 注册代码的主体部分主要用于设置输入张量第一个值的数据类型。通常情况下,此部分无需过多关注,可将其置空。

适配函数入参

适配函数部分,其命名通常为 “Kernel 名(首字母大写)+ Kernel”。入参需与 Paddle CPU 保持一致,但 NPU 自定义算子除外,此类算子代码位于 /npu/custom_op 目录下,不在本文介绍范围内。
仍以 abs_kernel 为例,代码中上方为 CPU 实现写法,下方为 NPU 实现写法。需注意,入参的作用域可能存在差异,必要时需添加 phi:: 前缀进行对应修改。
CPU:
在这里插入图片描述

NPU:
在这里插入图片描述

适配函数主体

适配函数的底层逻辑在于使 Paddle 的 API 参数与 CANN 中算子的参数达成一致。
常见的无法对的齐情况主要有以下几种:

  • Paddle 存在参数缺失或参数无法直接对应;
  • CANN 存在参数缺失;
  • 部分输入数据类型不被支持;
  • CANN 中的算子仅支持 NCHW 布局;
  • 需要借助多个 CANN 算子拼接来实现 Paddle 算子的功能等。
    适配主体中针对参数差异的处理方式,将在aclnn 算子适配举例章节展开讨论。

本章节主要聚焦于介绍适配框架,以及部分算子在 Paddle 和 CANN 中的参数、语义、数据类型等情况。
部分算子在 Paddle 和 CANN 中的参数及语义、数据类型完全相同,在进行 aclnn 算子适配时,仅需完成基本的三个步骤。
下面以 abs_kernel 为例,对 aclnn 算子和 aclop 算子进行说明:

aclnn算子适配主体

如果适配的算子为aclnn,则一般包含三个部分:
在这里插入图片描述

算子检查宏

DO_COMPATIBILITY 宏用于进行算子检查。
由于 aclnn 算子是逐步增加的,在不同的 CANN 版本中支持情况存在差异,因此若没有对应的 aclnn 算子,则使用 aclop 算子。添加该宏的目的在于让 Paddle - NPU 能够兼容更多的 CANN 版本。

适配主体

适配主体部分主要负责参数对齐。其中,必须包含的代码是为输出张量申请内存,即 dev_ctx.template Alloc<T>(out);

算子执行宏

EXEC_NPU_CMD 是用于执行 aclnn 算子的宏。
其前两个参数默认为 aclnn 算子名称和设备上下文,后续参数为 aclnn 算子的输入参数,使用时需严格保证参数顺序对齐。

aclop算子适配主体

部分 API 不存在对应的 aclnn 算子,此时可查阅 aclop 文档进行适配。在 Paddle 中,aclop 算子的覆盖范围更广,而 aclnn 算子的覆盖范围相对较窄。因此,当找不到 aclnn 算子时,可调用 aclop 算子进行计算。
需要注意的是,aclnn 算子和 aclop 算子的适配方式存在一定差异。在执行 aclop 算子时,需使用类似如下的语句:
在这里插入图片描述

NpuOpRunner 的入参通常分为四个部分:

  • “Abs”:算子名称,标识相关操作。
  • “{x}”:输入,按顺序排列的输入数据。
  • “{*out}”:输出,按顺序排列的输出数据。
  • “{}”:属性,按顺序排列的属性,需以键值对形式书写。
    在这里插入图片描述

aclop还有一种入参方式,与上述方式等价,但更为直观:
在这里插入图片描述

单元测试

完成适配后,需执行重新编译操作,指令为:

bash tools/compile.sh

编译完成后,进行单元精度验证。此环节主要使用 /npu/tests/unittests 目录下的单元测试。若为新适配的算子,还需增加相应单元测试。

注意:
由于 bf16 数据类型在 numpy 中不存在,无法作为精度验证的标杆。因此,针对 bf16 数据类型,采用前后修改数据类型的方式,相关代码另行编写在*_eager.py文件中。
单元测试本身相对简单,重点在于尽可能覆盖更多的应用场景。
在这里插入图片描述

运行单元测试时,将以下两个 Python 路径添加至环境变量中,其中{codepath}代表代码路径:

export PYTHONPATH=/{codepath}/PaddleCustomDevice/python:$PYTHONPATH
export PYTHONPATH=/ {codepath} /PaddleCustomDevice/python/tests:$PYTHONPATH

其余注意事项

  • 在实际的算子适配工作中,常常会遭遇 CANN 包中算子不支持的问题。针对这一情况,最常用的解决方法是利用小算子进行拼接。在极个别场景下,对于某些算子,可将部分功能置于 CPU 上实现,尤其是与随机数相关的功能。然而,不可忽视的是,这些应对方法均会不可避免地导致性能损失。
  • 算子适配,本质上是一项对齐工作,涵盖功能对齐、精度对齐以及性能提升。在算子适配过程中,可以借助单元测试(单测)进行跟踪。一旦出现问题,通过打印 CANN 日志的方式,能够有效地定位问题所在 。
export ASCEND_SLOG_PRINT_TO_STDOUT=1
export ASCEND_GLOBAL_LOG_LEVEL=0
python xxx.py > xxx.log

使用上述方式可以把CANN的日志重定向到xxx.log中,通过搜索ERROR查看错误信息是定位问题的一个关键手段。

aclnn算子适配举例

Paddle-API 与 CANN-Kernel 差异剖析及适配策略

对于Paddle-API与CANN-Kernel两者中常见的差别与适配方法如下:

Paddle参数缺失或者参数无法直接对应

  • 如果Paddle算子只需要CANN提供的某个参数为默认值的功能,则可通过默认赋值的方式完成
  • 考虑通过计算取得需要参数

CANN参数缺失

  • CANN算子没有某个Paddle有的参数,一般是此算子CANN支持的模式少于Paddle
  • 可通过多个算子分别完成算子的部分功能(如max_pool + avg_pool)
  • 如果CANN只能支持部分功能,则可以在调用处抛出参数值判断异常

数据类型不支持

  • 输入数据类型不匹配时需要在计算前插入 Cast 操作,并且要更改输出的数据类型,在计算后对输出数据进行 Cast 操作返回原数据类型

layout转换

  • NPU算子基本不支持NHWC,但是部分Paddle算子支持,如果遇到这样的情况需要在计算前后插入 Transpose

小算子拼接

  • 部分Paddle-API的功能在NPU中没法直接完成,但可通过多个小算子拼接完成,一般会少许影响性能

加入缺少的参数

以ReluGrad算子为例,通过计算或者默认赋值方式加入缺少的参数:
在这里插入图片描述

在进行参数对齐时,需要检查是否存在需要默认参数的情况。以 Paddle 的 relugrad 算子为例,其对应的 aclnn 的 ThresholdBackward 算子包含额外参数 threshold。
在实际操作中,可通过默认赋值的方式实现参数对齐,如图所示,代码为phi::Scalar threshold = 0.0。完成参数对齐后,即可直接调用 NPU 的 aclnnThresholdBackward 算子 。

数据类型转换

以 nll_loss 算子为例,Paddle API 与 CANN API 所支持的数据类型存在差异。Paddle API 中,输入 x 的数据类型为 double,而 CANN 的对应算子仅支持 float32 这一特定数据类型,具体情况如下图所示:
Paddle侧:
在这里插入图片描述

CAAN侧:
在这里插入图片描述

此情形需进行数据类型转换:

  • 首先,对输入数据执行 cast 操作,将其转换为 CANN 算子支持的数据类型;
  • 完成转换后,执行 NPU 的 aclnn 算子;
  • 算子运算结束后,再将计算结果的数据类型由 float32 转换回输入 x 原本的数据类型。
    具体流程如下图所示:
    在这里插入图片描述

数据类型转换需要Cast_kernel算子,下面为Cast_kernel算子的声明:
在这里插入图片描述

以下介绍将变量 x 的数据类型从 double 转换为 float32(转换后的变量记为 x_cast)的流程:

对象声明

phi::DenseTensor x_cast;                // 声明目标张量 x_cast
phi::DenseTensorMeta x_cast_meta;       // 声明张量的元数据对象

phi::DenseTensor:深度学习框架中表示多维数组的核心数据结构,包含数据和元信息(如形状、数据类型)。
phi::DenseTensorMeta:用于存储张量的元信息(metadata)。

元数据初始化

x_cast_meta = {phi::DataType::FLOAT32, x.dims();

phi::DataType::FLOAT32:明确将目标张量的数据类型设为 float32。
x.dims():继承输入张量 x 的维度信息(如 [batch_size, channels, height, width])

绑定元数据

x_cast.set_meta(x_cast_meta);  

作用:将初始化后的元数据绑定到目标张量 x_cast

执行类型转换

custom_kernel::CastKernel<T, Context>(dev_ctx, x, phi::DataType::FLOAT32, &x_cast);

核心参数:
dev_ctx: 设备上下文(如 CPU/GPU 资源管理)
x: 输入张量
phi::DataType::FLOAT32: 目标数据类型
&x_cast: 输出的目标张量指针
功能:将输入张量 x 的数据类型转换为 float32,结果写入 x_cast

执行NPU aclnn算子计算

把所有需要进行数据类型转换的参数转换完成后,使用算子执行宏 EXEC_NPU_CMD执行aclnn算子:
在这里插入图片描述

aclnn算子输出结果原类型恢复

在这里插入图片描述

  • 将 out_cast(NPU计算结果)转换为paddle api的 out 张量类型(如 float32 → double)。
  • 将损失计算中累计的权重值 total_weight_cast 数据类型转换为目标类型后写入 total_weight

转置操作

在 Pool2dGradKernel 算子中,若输入数据格式data_format为NHWC,即高度、宽度、通道数位于最后,鉴于 NPU 的操作要求,需将数据转换为NCHW格式。此转换通过Transpose操作达成。
Transpose操作的核心功能是实现张量维度重排,旨在适配 NPU 计算特性所规定的数据布局需求。在本场景中,其主要作用是完成从NHWC(Channel Last)到NCHW(Channel First)这两种内存布局的转换 。
在这里插入图片描述

流程图如下:
在这里插入图片描述

变量声明

phi::DenseTensor transformed_out_grad;
通过临时变量隔离布局转换过程,保证原始数据的完整性

布局判断逻辑

接下来,程序执行条件判断if (channel_last)。该判断旨在检查输入数据是否采用NHWC格式。
if (channel_last)条件成立,即NHWC(Channel Last)格式时,程序将执行转置操作,把数据格式转换为通道优先Channel First的NCHW格式。

维度置换规则

std::vector<int> perm = {0, 3, 1, 2};
数学原理:对应张量维度[N,H,W,C]->[N,C,H,W]
定义了一个perm向量{0, 3, 1, 2},这应该是用来重新排列维度的顺序。原来的维度假设是NHWC(0,1,2,3),转置后变为NCHW(0,3,1,2)。

新形状构建

接着构造了out_grad_tensor_shape,调整形状以匹配新的维度顺序。调整后的形状是通过重新排列out_grad的维度得到的,例如将原维度[0]、[3]、[1]、[2]组合成新的形状。

std::vector<int> out_grad_tensor_shape = {
    out_grad.dims()[0],
    out_grad.dims()[3], 
    out_grad.dims()[1],
    out_grad.dims()[2],
};

通过维度复制而非引用保证形状独立性。

内存分配

然后将transformed_out_grad调整大小,分配内存,并通过TransposeKernel进行转置操作。

transformed_out_grad.Resize(phi::make_ddim(out_grad_tensor_shape));
dev_ctx.template Alloc<T>(&transformed_out_grad);

转置运算

custom_kernel::TransposeKernel<T, Context>(dev_ctx, out_grad, perm, &transformed_out_grad);
custom_kernel::TransposeKernel是调用NPU的转置内核函数,需在算子开发代码中进行函数声明,如图所示:
在这里插入图片描述

Logo

鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。

更多推荐