【2023 · CANN训练营第一季】课堂笔记2-算子编程范式及算子实现
【2023 · CANN训练营第一季】课堂笔记2-算子编程范式及算子实现
一、算子编程范式
1.简介:
快速开发编程的固定步骤
统一代码框架的开发捷径
使用者总结出的开发经验
面向特定场景的编程思想
定制化的方法论开发体验
TIK C++编程范式把算子内部的处理程序,分成多个流水任务(Stage),以张量(Tensor)为数据载体,以队列(Queue)进行任务之间的通信与同步,以内存管理模块(Pipe)管理任务间的通信内存。
2.流水任务:
流水任务(Stage)指的是单核处理程序中主程序调度的并行任务。在核函数内部,可以通过流水任务实现数据的并行处理来提升性能。
单核处理程序的功能可以被拆分成3个流水任务:Stage1、Stage2、Stage3,每个任务专注于完成单一功能;需要处理的数据被切分成n片,使用Progress1~n表示,每个任务需要依次完成n个数据切片的处理。Stage间的箭头表达数据间的依赖关系,比如Stage1处理完Progress1之后,Stage2才能对Progress1进行处理

若Progress的n=3,待处理的数据被切分成3片,对于同一片数据,Stage1、Stage2、Stage3之间的处理具有依赖关系,需要串行处理;不同的数据切片,同一时间点,可以有多个流水任务Stage在并行处理,由此达到任务并行、提升性能的目的

流水的三大基本任务: CopyIn:负责数据搬入操作---->输入数据从Global内存搬移到Local内存
Compute:负责矢量计算操作---->使用local内存进行计算
CopyOut:负责数据搬出操作---->输入数据从Local内存搬移到Global内存
3.任务间的通信与同步:
不同的流水任务之间存在数据依赖,需要进行数据传递 TIK C++中使用Queue队列完成任务之间的数据通信和同步,Queue提供了EnQue、DeQue等基础API Queue队列管理NPU上不同层级的物理内存时,用一种抽象的逻辑位置(QuePosition)来表达各个级别的存储(Storage Scope),代替了片上物理存储的概念,开发者无需感知硬件架构 矢量编程中Queue类型(逻辑位置)包括:VECIN、VECOUT
上文提到的Local内存和Global内存,是数据的载体,他们分别使用GlobalTensor和LocalTensor作为数据的基本操作单元,它是各种指令API直接调用的对象
矢量编程介绍:
矢量编程中有两个逻辑位置(QuePosition):
搬入数据的存放位置:VECIN
搬出数据的存放位置:VECOUT
其操作也是可以按照流水任务的三个stage对应其三个主要流水任务
Stage1:CopyIn任务
1.使用DataCopy接口将GlobalTensor拷贝到LocalTensor。
2.使用EnQue将LocalTensor放入VECIN的Queue中
Stage2:Compute任务
1.使用DeQue从VECIN中取出LocalTensor
2.使用TIK C++指令API完成矢量计算:Add 3.使用EnQue将结果LocalTensor放入VECOUT的Queue中
Stage3:CopyOut任务
1.使用DeQue接口从VECOUT的Queue中取出LocalTensor
2.使用DataCopy接口将LocalTensor拷贝到GlobalTensor

4.内存管理
任务间数据传递使用到的内存统一由内存管理模块Pipe进行管理 Pipe作为片上内存管理者,通过InitBuffer接口对外提供Queue内存初始化功能,开发者可以通过该接口为指定的Queue分配内存 Queue队列内存初始化完成后,需要使用内存时,通过调用AllocTensor来为LocalTensor分配内存给Tensor,当创建的LocalTensor完成相关计算无需再使用时,再调用FreeTensor来回收LocalTensor的内存

5.临时变量的内存管理
编程过程中使用到的临时变量内存同样通过Pipe进行管理。临时变量可以使用TBuf数据结构来申请指定QuePosition上的存储空间,并使用Get()来将分配到的存储空间分配给新的LocalTensor
从TBuf上获取全部长度,或者获取指定长度的LocalTensor
ps:使用TBuf申请的内存空间只能参与计算,无法执行Queue队列的入队出队操作
二、算子开发流程--矢量算子的编程
快速TIK C++算子开发流程:
完成算子核函数的开发
基于内核调用符方式进行算子运行验证

标准TIK C++算子开发流程:
完成算子核函数的开发
完成单算子网络应用程序的开发
基于ACL单算子调用方式进行算子运行验证

标准和快速开发两种方式的对比

三大步骤:
算子分析:分析算子的数学表达式、输入、输出以及计算逻辑的实现,明确需要调用的TIK C++接口。 核函数定义:定义TIK C++算子入口函数。 根据矢量编程范式实现算子类:完成核函数的内部实现。
(1)算子分析:
明确算子的数学表达式及计算逻辑
明确输入和输出
确定核函数名称和参数
确定算子实现所需接口
(2)核函数定义:
1.完成内存初始化:
2.完成核心逻辑实现
3.对于核函数的调用,使用内置宏__CCE_KT_TEST__来标识<<<…>>>仅在NPU模式下才会编译到(CPU模式g++没有<<<…>>>的表达),对核函数的调用进行封装,
(3)算子类的实现
流水任务:
CopyIn任务:将Global Memory上的输入Tensor xGm和yGm搬运至Local Memory,分别存储在xLocal, yLocal
Compute任务:对xLocal, yLocal执行加法操作,计算结果存储在zLocal中
CopyOut任务:将输出数据从zLocal搬运至Global Memory上的输出Tensor zGm中
各任务的通信方式:
CopyIn,Compute任务间通过VECIN队列inQueueX,inQueueY进行通信和同步
Compute,CopyOut任务间通过VECOUT队列outQueueZ进行通信和同步
pipe内存管理对象对任务间交互使用到的内存、临时变量使用到的内存统一进行管理

Init函数的实现
这里老师讲解的例子是一个add算子,shape是(8,2048)的,打算将其放入8个核进行计算,这里老师的init代码如下:

使用多核并行计算的方法:
需要将数据切片,获取到每个核实际需要处理的在Global Memory上的内存偏移地址 数据整体长度TOTAL_LENGTH为8* 2048,平均分配到8个核上运行,每个核上处理的数据大小BLOCK_LENGTH为2048。block_idx为核的逻辑ID,(gm half*)x + block_idx * BLOCK_LENGTH即索引为block_idx的核的输入数据在Global Memory上的内存偏移地址
使用单核处理数据的方法:
可以进行数据切块(Tiling),将数据切分成8块。切分后的每个数据块再次切分成BUFFER_NUM=2块,可开启double buffer,实现流水线之间的并行 单核需要处理的2048个数被切分成16块,每块TILE_LENGTH=128个数据。Pipe为inQueueX分配了BUFFER_NUM块大小为TILE_LENGTH * sizeof(half)个字节的内存块,每个内存块能容纳TILE_LENGTH=128个half类型数据
Process()函数实现
处理函数的实现主要就是三大流水任务:
copyIn:

Compute:

copyOut:

double buffer机制
double buffer通过将数据搬运与矢量计算并行执行以隐藏数据搬运时间并降低矢量指令的等待时间,最终提高矢量计算单元的利用效率 1个Tensor同一时间只能进行搬入、计算和搬出三个流水任务中的一个,其他两个流水任务涉及的硬件单元则处于Idle状态 如果将待处理的数据一分为二,比如Tensor1、Tensor2
当矢量计算单元对Tensor1进行Compute时,Tensor2可以执行CopyIn的任务
当矢量计算单元对Tensor2进行Compute时,Tensor1可以执行CopyOut的任务
当矢量计算单元对Tensor2进行CopyOut时,Tensor1可以执行CopyIn的任务
由此,数据的进出搬运和矢量计算之间实现并行,硬件单元闲置问题得以有效缓解

ps:该文仅是为了记录CANN训练营的学习过程所用,不参与任何商业用途