作者​:昇腾实战派
知识地图​:https://blog.csdn.net/Lumos_Lovegood/article/details/161601003

一、背景

在信号处理自研算法中,基础滤波操作——双最大值池化(DualMaxPooling)需要在每个卷积域内同时提取最大值和次大值及其对应权重。为满足高性能计算需求,本文基于AscendC开发该算子,实现对512×512图像使用7×7滤波核进行滤波。并通过高维切分、数据搬移优化和double-buffer等优化手段,优化算子性能。

本文核心内容包括:

  1. DualMaxPooling算子功能实现
  2. DualMaxPooling算子精度对齐
  3. DualMaxPooling算子性能优化方案(高维切分、数据搬移优化、double-buffer)

二、DualMaxPooling算子功能实现

2.1 总体流程

在这里插入图片描述

对大小为512*512的原始图像通过7*7大小的滤波核进行滤波,由于卷积的stride是1,所以该shape下需要经过506*506次卷积计算,在计算之前需要完成img2col的操作,将原始图像在数据搬移的时候处理成506*49*506的矩阵,将7*7的滤波核展平成1*49的向量,方便后续的计算。由于需要在点乘之后取双最大值,所以需要将完整的卷积滤波操作拆分成点乘(Mul)和求和(Sum)操作,对完成img2col的图像矩阵和滤波向量相乘,结果的维度依旧是506*49*506,然后通过DualMaxPooling取出最大值和次大值以及其对应权重,最后将矩阵通过Sum输出506*506的矩阵,完成整个的滤波操作。

2.2 分核策略

在Atlas 800I A2上每个Vector有40个物理核,卷积计算共506×506次,从行维度分核(每个核完成一行506次卷积),共需506/40=12个iter,第13个iter需506%40=26个核参与计算。考虑开启double buffer(预取一轮数据到UB),且UB大小为192KB,若将506×49全部搬入需506×49×2×sizeof(float)≈193.7KB,故分两次搬入,每次每个核重复253个loop。

2.3 流程控制

通过重复调用Process()实现分核复用,对应2.2章节中具体的分核策略。

KERNEL_TASK_TYPE_DEFAULT(KERNEL_TYPE_AIV_ONLY);
   KernelFilter op;
   op.Init(x, y, z, tiling);
// 需要重复13个iter,针对最后一个iter26个核参与运算在Process()函数中实现
   for (int iter = 0; iter < 13; iter++){      
       op.Process(iter);
   }

此部分为一个tile的计算过程,由于每个核需要重复处理两次253*49(padding之后为253*56维度)的数据,所以分为前半部分和后半部分进行处理。前后两次计算通过half参数0/1进行控制,iter和half这两个用于数据搬运过程中的原始数据的索引定位。

int32_t loopCount = 253;
// 控制最后一个iter中只有前26个核参与运算
if (iter == 12 && AscendC::GetBlockIdx() >= 26){        
    return;
}
// 前半部分处理
CopyIn(iter, 0);                                       
for (int32_t loop = 0; loop < loopCount; loop++){
        Compute(iter, loop);
    }
CopyOut(iter, 0);
// 后半部分处理
CopyIn(iter, 1);                                        
for (int32_t loop = 0; loop < loopCount; loop++){
        Compute(iter, loop);
    }
CopyOut(iter, 1);

2.4 初始化

  • GlobalTensor内存分配:前置操作已经将算子需要用到的大小为512*512的原图和7*7的滤波核从Host搬移到了Device侧,在初始化函数中将其地址对应传给GlobalTensor。
  • TQue和TBuf内存分配:此算子在Vector核上运算的LocalTensor分为三种,通过逻辑位置TPosition来进行区别,数据搬入计算单元的VECIN,数据搬出计算单元的VECOUT,临时变量计算单元的VECCALC。Device端的资源统一用TPipe进行管理,在初始化函数中用TPipe给LocalTensor进行内存分配,需要注意的是Atlas 800I A2芯片每个核上的UB(Unified Bufer)大小为192KB,内存初始化不能超过该数值。
  • SelMask初始化:在算子的计算流程中,需要通过AscendC::Select函数将为了满足UB对齐32Byte的地址对齐约束而进行的padding位,从0置为-inf,上述过程需要根据SelMask决定修改的数在源操作数的具体位置,SelMask是一成不变的,所以在Init函数中进行初始化。
// GlobalMemory存放输入的x(512*512的原始图像),y(7*7的滤波核),以及卷积后输出的z
xGm.SetGlobalBuffer((__gm__ float *)x, tiling.x_totalLen);                     
yGm.SetGlobalBuffer((__gm__ float *)y, tiling.y_totalLen);
zGm.SetGlobalBuffer((__gm__ float *)z, tiling.z_totalLen);
// UB存放搬入的数据x,在开启xLocal的double-buffer之后,需要把1->2
pipe.InitBuffer(inQueueX, 1, tiling.x_tileLen * sizeof(float));    
// UB存放搬入的数据y             
pipe.InitBuffer(inQueueY, 1, tiling.y_tileLen * sizeof(float));     
// 存放Mul后的计算结果,56*253*sizeof(float)                 
pipe.InitBuffer(temMul, tiling.x_tileLen * sizeof(float)); 
// 存放最大值和最大值的索引                     
pipe.InitBuffer(temMax, 253*2 * sizeof(float));              
// 存放次大值和次大值的索引                   
pipe.InitBuffer(temSecMax, 253*2 * sizeof(float));    
// 存放最大值对应的权重                          
pipe.InitBuffer(temMaxWei, 253 * sizeof(float));           
// 存放次大值对应的权重                     
pipe.InitBuffer(temSecMaxWei, 253 * sizeof(float));        
// 存放Select操作过程中会用到的mask                     
pipe.InitBuffer(selMask, 56*256 / 16 * sizeof(uint16_t));          
// 存放卷积计算后的结果            
pipe.InitBuffer(outQueueZ, 1, tiling.z_tileLen * sizeof(float));               

AscendC::LocalTensor<uint16_t> maskLocal = selMask.Get<uint16_t>();
// 初始化mask,具体为什么是32639,请看2.6章节
AscendC::Duplicate<uint16_t>(maskLocal, 32639, 56*253 / 16 * sizeof(uint16_t));

2.5 数据搬入(img->col)

在这里插入图片描述

由于在UB上,LocalTensor的起始地址需要保证32字节对齐,所以对于不满足对齐要求的数据要进行padding之后,才能够正常搬入。针对本算子,需要将7*7*sizeof(float)补齐到56*sizeof(float),将253*49*sizeof(float)补齐到253*56*sizeof(float),并且使用右padding,padding的元素为0。使用DataCopyPad实现img2col操作,即通过7次搬运将本不连续的一个卷积操作所覆盖的7*7(这里为了不引起歧义,没有用padding后的维度)的原始数据搬移到一起,并且通过253次loop,将一次tile所需要的所有数据全部搬入。

注意:此前已经将512*512的矩阵拉伸成1*262144大小的向量,将7*7的矩阵拉伸成了1*49的向量,所以可以通过GlobalMemory的连续索引来访问。

// LocalTensor需要对齐32byte,所以x申请56*253*sizeof(float)大小的tileLen,y申请56*sizeof(float)大小的tileLen,此部分在初始化函数中已经执行
uint32_t x_tileLen = 56*253;
uint32_t y_tileLen = 7*7+7;
// 暂设为1
BUFFER_NUM = 1;    
// 控制单核单次loop次数以及数据搬运大小
tileNum = 253;      
pipe.InitBuffer(inQueueX, BUFFER_NUM, tiling.x_tileLen * sizeof(float));
pipe.InitBuffer(inQueueY, BUFFER_NUM, tiling.y_tileLen * sizeof(float));

// x切片数据搬入
// 一个卷积核所覆盖的原始数据搬运,重复搬运7次连续内存,,单次7 * sizeof(float)的大小,通过(512-7) * sizeof(float)作为stride进行换行
// 此处的(512-7)涉及到后面精度问题的,开发的原始版本是512 * sizeof(float)
AscendC::DataCopyExtParams x_copyParams{7, 7 * sizeof(float), (512-7) * sizeof(float), 0, 0};       
// 搬入数据需要对齐32Byte,所以采取右pad,每个连续数据pad1个float型数,pad值为0.0f; 
AscendC::DataCopyPadExtParams<float> x_padParams{true, 0, 1, 0};  
// 通过循环253次,将一次tile需要处理的数据全部搬入,一次for循环搬入一个滑窗7*7(pading之后是1*56维)的数据到UB                               
for(int i = 0; i < this->tileNum; i++){                            
    AscendC::DataCopyPad(xLocal[this->x_tileLen / this->tileNum * i],
// 这里通过索引来控制xGm起始位置的偏移,stride指的是512个数据跨度,half指的是前253还是后253个数据
    xGm[(iter * 40 + AscendC::GetBlockIdx()) * this->stride + i + half * this->tileNum], x_copyParams, x_padParams);                                                                                                                              
}

// y数据搬入
pipe.InitBuffer(inQueueY, BUFFER_NUM, tiling.y_tileLen * sizeof(float));
// 和x的搬运对齐,分7次搬运,每次搬运后对齐32Byte
AscendC::DataCopyExtParams y_copyParams{1, 7 * sizeof(float), 0, 0, 0};    
// 需要对齐32Byte,所以采取右pad,pad1个float型数,pad值为0.0f; 
AscendC::DataCopyPadExtParams<float> y_padParams{true, 0, 1, 0};            
AscendC::DataCopyPad(yLocal, yGm, y_copyParams, y_padParams);

2.6 算子计算过程

计算过程中先通过Ascend::Mul计算出中间结果,然后通过AscendC::DataCopy复制一份结果用于后续的取最大值和次大值的操作(因为会涉及到置最小值的操作,所以不能在原地址上进行)。然后将之前的padding位置为-inf,大家可能要问了,问什么不在DataCopyPad的时候直接将padding的值设置为-inf,这是因为我们的算子并不是取了两个最大值就结束了,还要考虑到最后的Sum输出最终结果的操作,0.0f方便进行Sum操作。

// 通过56*loop来控制x的切片,xLocal[0:56*253), loop[0:253),计算前y_tileLen(56)个数
AscendC::Mul(mulLocal, xLocal[this->x_tileLen / this->tileNum * loop], yLocal, this->y_tileLen);    
AscendC::DataCopy(valLocal, mulLocal, this->y_tileLen); 
AscendC::Select(valLocal, maskLocal, mulLocal, std::numeric_limits<float>::lowest(), AscendC::SELMODE::VSEL_TENSOR_SCALAR_MODE, 253*56);

然后通过AscendC::ReduceMax求取最大值,根据最大值对应的索引找到对应的权重,并且把最大值对应的位置置为-inf,方便次大值的求取。

AscendC::ReduceMax<float>(maxLocal, valLocal, shareMaxBuff, this->y_tileLen, true);
index = maxLocal.GetValue(1);
realIndex = *reinterpret_cast<uint32_t*>(&index);
max_val = valLocal.GetValue(realIndex);
max_val_weight = yLocal.GetValue(realIndex);
valLocal.SetValue(realIndex, std::numeric_limits<float>::lowest());

同第一次最大值的求取过程一样,将次大值及其对应权重提取出来,至此DualMaxPooling操作完成,双最大值成功求取。

AscendC::ReduceMax<float>(maxLocal, valLocal, shareMaxBuff, this->y_tileLen, true);
index = maxLocal.GetValue(1);
realIndex = *reinterpret_cast<uint32_t*>(&index);
second_val = valLocal.GetValue(realIndex);
second_val_weight = yLocal.GetValue(realIndex);

最后别忘了把滤波之后的结果求出来,这是我们最终要输出的卷积结果。

// 计算前y_tileLen(56)个数的归约和,存放到sumLocal中
AscendC::ReduceSum<float>(sumLocal, mulLocal, shareBuff, this->y_tileLen);    
// 根据loop的值来确定output的索引位置,loop范围[0,253)                                           
zLocal.SetValue(loop, sumLocal.GetValue(0));                                             

2.7 数据搬出

在这里插入图片描述

经过算子计算后,我们需要将最终得到的滤波结果进行输出,经过一个half的计算之后,得到了253个值,并且是连续内存,计算好GlobalMemory对应的索引直接搬出即可。

AscendC::DataCopyExtParams z_copyParams{1, 253 * sizeof(float), 0, 0, 0};                                              
// 需要根据index定位搬出的数据在GM对应的起始位置,iter * 40 + AscendC::GetBlockIdx()确定分核计算的次序,half用来控制double buffer前半部分计算还是后半部分计算
AscendC::DataCopyPad(zGm[(iter * 40 + AscendC::GetBlockIdx()) * 506 + half * this->tileNum], zLocal, z_copyParams);

三、精度问题

3.1 问题现象:

滤波核为7*7大小矩阵,数值均为1/49f(做了归一化),图像为512*512大小矩阵,数值为每一行所对应的行号。

打印出2*506个结果供调试,其中Output是npu计算结果,Golden是cpu计算结果。由于图像数据每一行的值都一样,所以每个核算出来的506个值是一样的,但是npu上的最后42个值出了问题,并且每个值多0.020408,是1/49f的值,并且呈现等差数列的现象。

在这里插入图片描述

3.2 定位过程:

以第一个核的第一轮计算为锚点进行精度问题定位,通过dump计算过程中的Mul操作之后的数据,得出第一个核的第一个iter计算中,前半个half没有问题,后半个half的第211个loop数据出现异常,由于前面计算都没有出现异常,所以怀疑是数据搬入除了问题,打印搬入数据,发现从211loop开始,末尾搬入的数据开始向下一行偏移,到252loop的时候,后面的42个数全部错位了,说明AscendC::DataCopyPad()参数存在问题,结合构造的图矩阵的特点,每一行的数据相同,所以在211loop前也存在偏移,只不过即使偏移了,计算结果一致,所以并未显现出来。

在这里插入图片描述
在这里插入图片描述

找到DataCopyPad的代码,发现原代码里的srcStride是两次搬移起始地址的差值:

// 这里的512是两次搬移起始地址的差值
AscendC::DataCopyExtParams x_copyParams{7, 7 * sizeof(float), 512 * sizeof(float), 0, 0};           
AscendC::DataCopyPadExtParams<float> x_padParams{true, 0, 1, 0};
// (iter * 40 + AscendC::GetBlockIdx()) * this->stride 控制行数,i + half * this->tileNum 控制列数
for(int i = 0; i < this->tileNum; i++){
    AscendC::DataCopyPad(xLocal[this->x_tileLen / this->tileNum * i],
    xGm[(iter * 40 + AscendC::GetBlockIdx()) * this->stride + i + half * this->tileNum], x_copyParams, x_padParams);
}

查看AscendC::DataCopyPad()接口定义,srcStride指的是前一次的尾和下一次搬运数据的头之间的间隔:

在这里插入图片描述

srcStride参数存在理解错误,更正后的代码后,精度问题解决。

AscendC::DataCopyExtParams x_copyParams{7, 7 * sizeof(float), (512-7) * sizeof(float), 0, 0};

四、性能优化

4.1 初始性能

采集算子的初始性能,进行分析:

当前的算子实现中,aiv_scalar_time有很高的占比,说明在aicpu上的计算太多了,观察实现逻辑发现,计算过程中存在大量的for循环,优化建议:用高维切分的repeat实现重复计算的操作。

  • 原始实现:

使用了较多的for循环,增加了npu上scalar的开销

__aicore__ inline void Process(int32_t iter)
    {
        int32_t loopCount = this->tileNum * BUFFER_NUM;

        if (iter == 12 && AscendC::GetBlockIdx() >= 26){
            return;
        }
        CopyIn(iter, 0);

        for (int32_t loop = 0; loop < loopCount; loop++){
                Compute(iter, loop, 0);
            }
        CopyOut(iter, 0);
        CopyIn(iter, 1);
        for (int32_t loop = 0; loop < loopCount; loop++){
                Compute(iter, loop, 1);
            }
        CopyOut(iter, 1);
    }

主要需要修改的代码段是

AscendC::Mul(mulLocal, xLocal[this->x_tileLen / this->tileNum * loop], yLocal, this->y_tileLen);
AscendC::DataCopy(valLocal, mulLocal, this->y_tileLen);
AscendC::Select(valLocal, maskLocal, mulLocal, std::numeric_limits<float>::lowest(), AscendC::SELMODE::VSEL_TENSOR_SCALAR_MODE, 253*56);                                             
// 选取最大值及其权重值
AscendC::ReduceMax<float>(maxLocal, valLocal, shareMaxBuff, this->y_tileLen, true);
index = maxLocal.GetValue(1);
realIndex = *reinterpret_cast<uint32_t*>(&index);
max_val = valLocal.GetValue(realIndex);
max_val_weight = yLocal.GetValue(realIndex);
// 选取次大值及其权重值
valLocal.SetValue(realIndex, std::numeric_limits<float>::lowest());
AscendC::ReduceMax<float>(maxLocal, valLocal, shareMaxBuff, this->y_tileLen, true);
index = maxLocal.GetValue(1);
realIndex = *reinterpret_cast<uint32_t*>(&index);
second_val = valLocal.GetValue(realIndex);
second_val_weight = yLocal.GetValue(realIndex);
AscendC::ReduceSum<float>(sumLocal, mulLocal, shareSumBuff, this->y_tileLen);

4.2 采用高维切分替代scalar操作

  1. 将xLocal (253*56) 和yLocal (56)通过repeat实现mul,把for循环剔除

由于在高维切分计算中,8datablock为一个计算单位,默认一个datablock为32个字节,所以通过mask(连续模式下为计算的元素个数)和控制重复计算间隔的repstride控制数据在正确的位置进行计算,单次迭代计算56个数,即7个datablock,重复计算时计算结果mulLocal和输入数据xLocal选择部分重复计算,滤波核yLocal选择全部重复计算,其中dstrepstride和src0repstride均为7,src1repstride为0。

在这里插入图片描述

在这里插入图片描述

// 函数原型 __aicore__ inline void Mul(const LocalTensor<T>& dst, const LocalTensor<T>& src0, const LocalTensor<T>& src1, uint64_t mask, const uint8_t repeatTime, const BinaryRepeatParams& {dstblkstride,src0blkstride,src1blkstride,dstrepstride,src0repstride,src1repstride})
AscendC::Mul(mulLocal, xLocal, yLocal, 56253{1,1,1,7,7,0});
  1. 先进行WholeReduceSum完成高维切分计算,减少一次DataCopy

sumLocal的大小为253*sizeof(float),原理同上,srcRepStride选择部分重复计算,值为7,此处dstRepStride的datablock的单位是sizeof(float),用于存放归约之后的value,所以值为1。

// 函数原型 __aicore__ inline void WholeReduceSum(const LocalTensor<T>& dst, const LocalTensor<T>& src, const int32_t mask, const int32_t repeatTime, const int32_t dstRepStride, const int32_t srcBlkStride, const int32_t srcRepStride)
AscendC::WholeReduceSum<float>(sumLocal, mulLocal, 56, 253, 1, 1, 7);
  1. 通过Select把pad的0.0f数据替换成float类型的最小值

采用AscendC::Select的模式1的前n个数据来根据SelMask来选择数值,SelMask的内存大小应该是253*56/16*sizeof(uint16_t),SelMask内的的数值均为32639,二进制为0111 1111 0111 1111(选取padding位置置为float类型的最小值-inf)

// 函数原型 __aicore__ inline void Select(const LocalTensor<T>& dst, const LocalTensor<U>& selMask, const LocalTensor<T>& src0, T src1, SELMODE selMode, uint32_t count)
AscendC::Select<float>(valLocal, selMask, mulLocal, std::numeric_limits<float>::lowest(), AscendC::SELMODE::VSEL_TENSOR_SCALAR_MODE, 253*56)
  1. 通过WholeReduceMax完成高维切分计算->选取最大值

由于要选取出每一个卷积域内的最大值和次大值,所以要用归约的方式来进行求取,maxLocal的大小为253*2*sizeof(float),原理同上,srcRepStride选择部分重负计算,值为7,此处dstRepStride的datablock的单位是2*sizeof(float),用于存放归约之后的value和index,所以值为1

// 函数原型 __aicore__ inline void WholeReduceMax(const LocalTensor<T>& dst, const LocalTensor<T>& src, const uint64_t mask[], const int32_t repeatTime, const int32_t dstRepStride, const int32_t srcBlkStride, const int32_t srcRepStride, ReduceOrder order = ReduceOrder::ORDER_VALUE_INDEX)
AscendC::WholeReduceMax<float>(maxLocal, valLocal, 56, 253, 1, 1, 7);
  1. 根据WholeReduceMax取出来的index将对应位置置为float最小值-inf

此处不可避免引入scalar操作,需要将index一个个取出,然后再一个个置为-inf

for(int i = 0; i < 253; i++){
    index = maxLocal.GetValue(2*i+1);
    realIndex = *reinterpret_cast<uint32_t*>(&index);
    mulLocal.SetValue(56 * i + realIndex, std::numeric_limits<float>::lowest());
}
  1. 通过WholeReduceMax完成高维切分计算->选取次大值
   AscendC::WholeReduceMax<float>(secMaxLocal, mulLocal, 56, 253, 1, 1, 7);

4.3 滤波核存在多次搬运

yLocal从GM到UB原本放在CopyIn()函数中实现,每次iter都会进行一次搬运。

将其搬运操作提前到Init()函数中,做到整个运算中只搬运一次,减少MTE2的压力。

4.4 开启Double-Buffer

目前MTE2存在搬运bound,通过开启Double-Buffer实现xLocal的提前一轮预取,使计算和搬运互相掩盖,具体实现如下图所示:

在这里插入图片描述

需要在pipe内存初始化的地方给xLocal申请两倍的内存空间,并且修改Compute()函数,在第0iter时,实现xLocal的一轮数据预取,在最后一轮的时候,由于存在预取数据,所以取消xLocal的数据搬运,修改如下:

// 控制最后一个iter中只有前26个核参与运算
if (iter == 12 && AscendC::GetBlockIdx() >= 26){        
    return;
}
if (iter == 0){                                         
    CopyIn(iter, 0);
}
CopyIn(iter, 1);                                       
Compute(iter, 0);
CopyOut(iter, 0);

if (iter < 11 || (iter == 11 && AscendC::GetBlockIdx() < 26)){
    CopyIn(iter + 1, 0);                                
}                            
Compute(iter, 1);
CopyOut(iter, 1);

五、优化后算子实现整体流程

当算子被调用的时候,首先会调用KernelFilter类里的Init函数,完成GlobalTensor、LocalTensor、TQue、TBuff的内存分配、SelMask的初始化,以及滤波核的单次搬入。完成初始化之后,通过CopyIn函数实现被卷积数据的搬入,并且完成img2col操作,在数据搬运的过程中,需要进行double buffer的优化,实现计算和数据搬运的互相掩盖,具体实现上需要完成数据预取,即首轮搬运连续完成两次数据搬入操作,计算时每次都用预取的数据完成计算,double buffer的流程图如下所示。
完成数据搬入后,进入13次的iter循环,在Process函数中进行最后一个iter的判断,判断该核需不需要在最后一个iter进行运算,通过AscendC::GetBlockIdx()来获取当前的核的ID,通过ID来判断是否需要进行下一步的计算。
在计算阶段,先通过AscendC::Mul()进行高维切片计算,处理56*253的数据,接着用AscendC::WholeReduceSum()完成卷积操作,将得到的数据存放到待搬移的LocalTensor上,然后用AscendC::Select()将数据搬移时为方便进行Sum操作而pad的数值0置为float类型的最小值,接着用AscendC::WholeReduceMax()取得最大值,用得到的Index通过LocalTensor.GetValue()取到最大值对应的权重值,然后通过LocalTensor.SetValue()将最大值都置为float类型的最小值,之后再次重复以上操作获取次大值及其对应权重的获取,完成两次池化操作。
最后将卷积得到的结果搬出,并且销毁LocalTensor所分配的内存,完成整个算子的计算。

在这里插入图片描述

六、总结

本项目实现了一个DualMaxPooling(双最大值池化)算子,并且通过高维切分、数据搬移优化、double-buffer等优化手段进行了性能优化。

Logo

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

更多推荐