2024年7月18日华为上研院培训笔记记录,感觉老师讲的还是很不错的
昇腾Ascend C算子开发学习笔记
昇腾处理器的常用算子库
-
核心融合算子库:核心融合算子库是华为昇腾AI处理器提供的基本算子库,包含了各种常用的深度学习算子,可以高效地执行各种神经网络计算任务。该库的设计旨在充分发挥昇腾AI芯片的计算能力,优化计算性能和资源利用率。FlashAttention类,MOE FFN类
-
NN算子库:NN(Neural Network)算子库是专门为神经网络设计的算子库,包含了常见的神经网络操作,例如卷积、池化、全连接层等。该库提供了高度优化的实现,可以显著提升神经网络模型的推理和训练速度。
-
DVPP算子库:DVPP(Data Video Pre-Processing)算子库用于视频和图像的预处理。它包含了一系列高效的视频编解码、图像处理等算子,可以在昇腾AI处理器上高效地执行视频和图像的预处理任务,为后续的深度学习模型推理提供高质量的输入数据。
-
AIPP算子库:AIPP(Artificial Intelligence Pre-Processing)算子库专注于人工智能数据预处理。该库提供了一系列图像预处理算子,例如图像归一化、调整图像尺寸等,以确保输入到深度学习模型的数据在格式和质量上符合模型的要求。
-
BLAS算子库:BLAS(Basic Linear Algebra Subprograms)算子库是一个基本线性代数子程序库,包含了各种矩阵和向量操作算子。这些算子是许多高性能计算和机器学习算法的基础,可以在昇腾AI处理器上高效地执行各种线性代数计算任务。
-
HCCL算子库:HCCL(Huawei Collective Communication Library)算子库是华为提供的分布式通信库,用于在多个昇腾AI处理器之间进行高效的数据传输和通信。该库包含了一系列分布式计算中的通信算子,例如广播、聚合等,旨在优化分布式深度学习的性能。
Ascend C编程常用API
计算类 API
计算类 API 主要用于执行各种计算任务,如张量操作、矩阵计算、神经网络推理等。这些 API 能够充分利用 Ascend 芯片的硬件加速特性,提升计算效率。常见的计算类 API 包括:
-
张量操作:用于执行张量的创建、赋值、维度变换等操作。
-
矩阵运算:包括矩阵乘法、矩阵转置、矩阵求逆等。
-
神经网络推理:执行神经网络模型的前向和反向传播计算。
同步类 API
同步类 API 用于在多线程或分布式环境中协调任务的执行,确保计算过程中的数据一致性和正确性。常见的同步类 API 包括:
-
互斥锁(Mutex):用于保护共享资源,防止多个线程同时访问导致的数据竞争。
-
信号量(Semaphore):用于控制多个线程对有限资源的访问。
-
屏障(Barrier):用于使多个线程在某一点上同步,确保所有线程在继续执行之前都达到该同步点。
搬运类 API
搬运类 API 主要用于在内存之间或设备之间移动数据。这些 API 能够高效地进行数据传输和复制,支持大规模数据的快速搬运。常见的搬运类 API 包括:
-
内存复制(Memory Copy):将数据从一个内存位置复制到另一个内存位置。
-
DMA 传输(Direct Memory Access):用于在主存和设备存储之间直接传输数据,减少 CPU 的干预。
-
数据加载与保存(Data Load/Store):将数据从存储设备加载到内存中,或将数据从内存保存到存储设备中。
Ascend C的SPMD并行计算模式
SPMD 是一种并行计算模型,在这种模型中,每个处理单元(例如 CPU 核或 GPU 核)运行相同的程序代码,但处理不同的数据子集。这种模式非常适合在硬件上具有多处理单元的系统中进行大规模数据并行计算。华为 Ascend AI 处理器就是使用该种并行计算模型。
AI Core
华为昇腾AI处理器的计算核心主要由AI Core构成,可以看作一个简化版的现代微处理器架构。其核心包括三种基础计算资源:
-
矩阵计算单元(Cube Unit):
-
负责矩阵计算操作,适用于大规模矩阵运算。
-
支持不同精度和类型的计算模式。
-
-
向量计算单元(Vector Unit):
-
负责向量计算操作,适用于并行处理多个数据元素的运算。
-
提供多种精度和计算模式。
-
-
标量计算单元(Scalar Unit):
-
负责标量计算操作,适用于处理单个数据元素的运算。
-
这三种计算单元各自承担不同的计算任务,形成三条独立的执行流水线。在系统软件的统一调度下,这些单元互相配合,以优化计算效率。矩阵和向量计算单元内部还提供了多种精度和类型的计算模式,以适应不同的计算需求。
AI Core中的计算单元主要包括:Cube Unit(矩阵计算单元)、Vector Unit(向量计算单元)和Scalar Unit(标量计算单元),完成AI Core中不同类型的数据计算。
算子的概念
在计算机科学和数学中,算子(Operator)是指一种特定的操作或函数,它接受一个或多个输入(操作数)并产生输出。在编程和计算中,算子通常用于描述各种计算操作,比如加法、乘法、矩阵变换等。在不同的上下文中,算子可以具有不同的含义和作用。
算子在神经网络中的含义
在神经网络和深度学习中,算子是指用于执行各种神经网络层和数据处理操作的基本计算单元。每个算子通常代表一个特定的操作,例如卷积、池化、激活函数、矩阵乘法等。这些算子在神经网络的训练和推理过程中起着关键作用。以下是一些常见的神经网络算子:
-
卷积算子(Convolution Operator):
-
用于卷积神经网络(CNN)中的卷积层。
-
通过滑动滤波器(卷积核)在输入特征图上进行卷积操作,提取局部特征。
-
-
池化算子(Pooling Operator):
-
用于卷积神经网络中的池化层。
-
通过下采样减少特征图的空间维度,同时保留重要的特征。
-
常见的池化操作包括最大池化(Max Pooling)和平均池化(Average Pooling)。
-
-
激活函数算子(Activation Function Operator):
-
用于神经网络层之间的激活操作。
-
通过非线性变换引入非线性,使得神经网络能够学习复杂的模式。
-
常见的激活函数包括ReLU(Rectified Linear Unit)、Sigmoid和Tanh等。
-
张量和算子的关系
-
数据与操作的关系:
-
张量是数据的载体,存储输入数据、模型参数和中间计算结果。
-
算子是操作单元,定义如何处理张量,实现数据的变换和计算。
-
-
神经网络的构建:
-
神经网络可以视为张量和算子的组合。输入数据被表示为张量,通过一系列算子的操作,逐层传递并变换,最终输出结果。
-
每一层神经网络都涉及张量的操作,例如卷积层对输入图像张量进行卷积操作,池化层对张量进行下采样操作,全连接层对张量进行矩阵乘法操作。
-
-
计算图(Computational Graph):
-
深度学习模型可以表示为计算图,图中的节点表示算子,边表示张量。计算图描述了数据流和计算过程。
-
前向传播过程中,张量在计算图中传递并通过各个算子进行计算,生成输出。
-
反向传播过程中,误差通过计算图向后传播,通过算子的反向计算更新张量(模型参数)。
-
张量的属性:形状、数据排布格式(NHWC)
常见的算子属性:轴
Ascend C 的优势
1. C/C++ 原语编程
开发者可以使用熟悉的 C/C++ 语言编写高性能的 AI 应用程序,而无需掌握底层的硬件细节。
屏蔽硬件差异
开发者无需关心底层硬件的差异,能够编写跨平台的应用程序。
多层级 API 封装
提供多层次的 API,满足不同开发者的需求,从高层次的框架接口到底层的硬件操作接口。
孪生调试(CPU 测模拟 NPU 侧的行为)
开发者可以在 CPU 上调试和模拟 NPU 上的行为,大大提高开发和调试效率。
自定义算子开发
在深度学习和高性能计算中,虽然现有的框架(如TensorFlow、PyTorch等)提供了丰富的内置算子,但某些特定场景下,开发者可能需要自定义算子以满足特殊需求。
核函数(Kernel Function)是指在AI处理器(如华为Ascend AI处理器)上执行的函数,它通常负责在设备侧进行高效的并行计算。核函数是算子实现的核心部分,主要用于执行实际的计算任务。
核函数——Ascend C算子设备侧实现的入口
声明:
extern "C" __global__ __aicore__ void add_custom(__gm__ uint8_t* x, __gm__ uint8_t* y, __gm__ uint8_t* z);
-
__global__
标识核函数,可以被<<<...>>>
调用。 -
__aicore__
标识该核函数在设备端 AI Core 上执行。
格式
__global__ __aicore__ void kernel_name(argument list);
-
核函数必须具有
void
返回类型。 -
仅支持入参为指针或 C/C++ 内置数据类型(如
half* s0
、float* s1
、int32_t c
)。
为了统一表达,可以使用 GM_ADDR
宏来修饰入参
#define GM_ADDR __gm__ uint8_t* __restrict__
调用
kernel_name<<<blockDim, l2ctrl, stream>>>(argument list);
-
blockDim
:规定核函数将在几个核上执行。 -
l2ctrl
:保留参数,暂时设置为固定值nullptr
。 -
stream
:是一个任务队列,应用程序通过stream
管理任务的并行。
流水线编程范式
Ascend C 编程范式:一种流水线式的编程范式,把算子核内的处理程序分成多个流水任务,通过队列(Queue)完成任务间通信和同步,并通过统一的内存管理模块(Pipe)管理任务间通信内存。
流水任务设计
-
流水任务:单核处理程序中主程序调度的并行任务,在核函数内部实现数据的并行处理,提升性能。
-
任务划分:将单核处理程序的功能拆分为多个独立任务(例如:Stage1、Stage2、Stage3),每个任务专注于完成单一功能,并依赖于前一个任务的完成。
-
并行调度:相同时间点,不同的数据切片可以有多个任务并行处理,提高性能。
矢量编程范式
-
CopyIn:负责搬入操作:
-
使用 DataCopy 接口将 GlobalTensor 数据拷贝到 LocalTensor。
-
使用 EnQue 将 LocalTensor 放入 VECIN 的 Queue 中。
-
-
Compute:负责矢量计算操作。
-
使用 DeQue 从 VECIN 中取出 LocalTensor。
-
使用 Ascend C 接口完成矢量计算。
-
使用 EnQue 将计算结果 LocalTensor 放入到 VECOUT 的 Queue 中。
-
-
CopyOut:负责搬出操作。
-
使用 DeQue 接口从 VECOUT 的 Queue 中取出 LocalTensor。
-
使用 DataCopy 接口将 LocalTensor 拷贝到 GlobalTensor 上。
-
任务间通信和同步
-
Queue 队列:用于完成任务之间的数据通信和同步,提供 EnQue、DeQue 等基础 API。
-
逻辑位置(QuePosition)
:抽象各级别存储,代替物理存储,简化开发者对硬件架构的感知。
-
矢量编程 Queue 类型:
-
VECIN:搬入数据的存放位置。
-
VECCALC:计算中间变量的位置。
-
VECOUT:搬出数据的存放位置。
-
-
矩阵编程 Queue 类型:
-
A1/B1:存放整块 A/B 矩阵(类比二级缓存)。
-
A2/B2:存放切分后的小块 A/B 矩阵(类比一级缓存)。
-
CO1:存放小块结果 C 矩阵。
-
CO2:存放整块结果 C 矩阵。
-
-
内存管理
Pipe 模块
:统一管理任务间数据传递使用的内存。
-
InitBuffer:对外提供 Queue 内存初始化功能。
-
AllocTensor:为 LocalTensor 分配内存。
-
FreeTensor:回收 LocalTensor 的内存。
临时变量内存管理:
-
TBuf 数据结构:用于申请指定 QuePosition 上的存储空间,参与计算,但无法执行 Queue 队列的入队出队操作。
核函数定义
核心过程就是调用算子类的Init和Process函数。Process函数是算子的核心代码
extern "C" __global__ __aicore__ void add_custom(GM_ADDR x, GM_ADDR y, GM_ADDR z)
{
KernelAdd op;
op.Init(x, y, z);
op.Process();
}
此外还可以对核函数的调用进行封装,便于主程序调用
#ifndef __CCE_KT_TEST__
// call of kernel function
void add_custom_do(uint32_t blockDim, void* l2ctrl, void* stream, uint8_t* x, uint8_t* y, uint8_t* z)
{
add_custom<<<blockDim, l2ctrl, stream>>>(x, y, z);
}
#endif
算子类实现
class KernelAdd {
public:
__aicore__ inline KernelAdd() {}
// 初始化函数,完成内存初始化相关操作
__aicore__ inline void Init(GM_ADDR x, GM_ADDR y, GM_ADDR z){}
// 核心处理函数,实现算子逻辑,调用私有成员函数CopyIn、Compute、CopyOut完成矢量算子的三级流水操作
__aicore__ inline void Process(){}
private:
// 搬入函数,完成CopyIn阶段的处理,被核心Process函数调用
__aicore__ inline void CopyIn(int32_t progress){}
// 计算函数,完成Compute阶段的处理,被核心Process函数调用
__aicore__ inline void Compute(int32_t progress){}
// 搬出函数,完成CopyOut阶段的处理,被核心Process函数调用
__aicore__ inline void CopyOut(int32_t progress){}
private:
TPipe pipe; //Pipe内存管理对象
TQue<QuePosition::VECIN, BUFFER_NUM> inQueueX, inQueueY; //输入数据Queue队列管理对象,QuePosition为VECIN
TQue<QuePosition::VECOUT, BUFFER_NUM> outQueueZ; //输出数据Queue队列管理对象,QuePosition为VECOUT
GlobalTensor<half> xGm, yGm, zGm; //管理输入输出Global Memory内存地址的对象,其中xGm, yGm为输入,zGm为输出
};
Init函数实现
-
设置输入输出 Global Tensor 的 Global Memory 内存地址。
-
获取该核函数需要处理的输入输出在 Global Memory 上的内存偏移地址。
-
为输入输出 Queue 分配内存。
constexpr int32_t TOTAL_LENGTH = 8 * 2048;
constexpr int32_t USE_CORE_NUM = 8;
constexpr int32_t BLOCK_LENGTH = TOTAL_LENGTH / USE_CORE_NUM;
constexpr int32_t TILE_NUM = 8;
constexpr int32_t BUFFER_NUM = 2;
constexpr int32_t TILE_LENGTH = BLOCK_LENGTH / TILE_NUM / BUFFER_NUM;
__aicore__ inline void Init(GM_ADDR x, GM_ADDR y, GM_ADDR z) {
xGm.SetGlobalBuffer((__gm__ half*)x + BLOCK_LENGTH * GetBlockIdx(), BLOCK_LENGTH);
yGm.SetGlobalBuffer((__gm__ half*)y + BLOCK_LENGTH * GetBlockIdx(), BLOCK_LENGTH);
zGm.SetGlobalBuffer((__gm__ half*)z + BLOCK_LENGTH * GetBlockIdx(), BLOCK_LENGTH);
pipe.InitBuffer(inQueueX, BUFFER_NUM, TILE_LENGTH * sizeof(half));
pipe.InitBuffer(inQueueY, BUFFER_NUM, TILE_LENGTH * sizeof(half));
pipe.InitBuffer(outQueueZ, BUFFER_NUM, TILE_LENGTH * sizeof(half));
}
Process 函数实现
-
调用三个基本任务:
CopyIn
、Compute
、CopyOut
__aicore__ inline void Process() {
constexpr int32_t loopCount = TILE_NUM * BUFFER_NUM;
for (int32_t i = 0; i < loopCount; i++) {
CopyIn(i);
Compute(i);
CopyOut(i);
}
}
Kernel侧算子实现
动态 Shape 与固定 Shape 的区别
- 动态 Shape:输入的 Shape 是未知的,与输入 Shape 相关的变量(如 TilingData)需要通过 Tiling 函数计算。
- 固定 Shape:变量(如 TILE_NUM、BLOCK_LENGTH、TILE_LENGTH)是固定的数值,不需要在运行时计算。
Tiling 函数
- Host 侧实现:Tiling 函数在 host 侧实现,并在 kernel 侧算子中通过
GET_TILING_DATA
获取 TilingData 结构体参数。 - 获取 TilingData:核函数中调用
GET_TILING_DATA
获取 Tiling 参数,然后基于这些参数进行计算。
Host 侧算子实现
- Tiling 实现:计算数据切分相关参数,比如每次计算的数据量大小。
- Shape 推导
- 算子原型注册
Tiling实现
在大多数情况下,由于Local Memory的容量不足以容纳完整的输入和输出数据,需要将数据分块计算,这个过程称为Tiling。Tiling实现会根据算子的shape等信息确定切分算法的相关参数,如每次搬运的块大小和循环次数。
Tiling实现的输入是算子的shape等信息,输出是切分算法的相关参数,这些参数通过TilingContext结构传递。开发者可以通过TilingContext获取算子的输入输出信息,进行计算并设置TilingData、block_dim、TilingKey和workspace size等输出。
TilingData存储切分算法相关参数,如每次搬运的块大小和循环次数。block_dim表示算子数据切分的份数,TilingKey用于选择不同的kernel实现分支,workspace size表示设备侧Global Memory的内存大小。
对于非对齐shape的算子,需要特殊处理,例如对齐到最小数据块单位,并根据对齐后的总数据量分配核的数量。通过模计算确定分配较多和较少数据量的核心数,完成大块和小块的数据切分。
对于包含属性信息的算子,如ReduceMax,属性信息可以通过TilingData传递到kernel侧参与计算。例如,通过TilingContext获取reduceDim属性值,根据该值获取对应轴的长度,并设置到TilingData中传递到kernel函数。
Shape推导
网络模型生成过程中,会先进行Tensor shape以及dtype的推导。这样可以让我们在图执行之前,就知道各Tensor的数据类型和形状,提前校验其正确性;同时提前推理出算子的输出张量描述,包括张量的形状、数据类型及数据排布格式等信息,算子构图准备阶段就可以为所有的张量静态分配内存,避免动态内存分配带来的开销。