Ascend C算子性能优化实用技巧04——Tiling优化

举报
昇腾CANN 发表于 2024/09/24 10:28:54 2024/09/24
【摘要】 Ascend C是CANN针对算子开发场景推出的编程语言,原生支持C和C++标准规范,兼具开发效率和运行性能。使用Ascend C,开发者可以基于昇腾AI硬件,高效的实现自定义的创新算法。

1 简介

Ascend C是CANN针对算子开发场景推出的编程语言,原生支持C和C++标准规范,兼具开发效率和运行性能。使用Ascend C,开发者可以基于昇腾AI硬件,高效的实现自定义的创新算法。

目前已经有越来越多的开发者使用Ascend C,我们将通过几期“Ascend C算子性能优化”专题分享,围绕开发者最为关心的算子性能优化环节,介绍Ascend C算子常用的优化技巧,帮助开发者自主构建出更优性能的算子。专题内容将围绕流水优化、搬运优化、内存优化、API使用优化以及Tiling优化等优化技巧,从方案讲解、优化案例、性能对比等多角度展开介绍。前期内容回顾:

下面进入第四期内容:Ascend C Tiling优化,您将了解到以下优化技巧:

  • 多核切分
  • L2Cache切分
  • 核间负载均衡


2 什么是Tiling

大多数情况下,AI Core内部的Unified Buffer无法完整容纳算子的输入与输出,需要每次搬运一部分输入进行计算然后搬出,再搬运下一部分输入进行计算,直到得到完整的最终结果,这个数据切分、分块计算的过程称之为Tiling,切分数据的算法称为Tiling算法或者Tiling策略。


3 多核切分

AI处理器上一般包括多个AI Core处理核心,为了实现多核并行,提升计算效率,需要将矩阵数据进行切分,分配到不同的核上进行处理。多核切分是最常见、最基本的Tiling策略。

通过SetBlockDim接口设置整个算子计算所用核数blockDim。

context->SetBlockDim(BLOCK_DIM);

blockDim规定了核函数将会在几个核上执行。例如,需要计算8M的数据,每个核上计算1M的数据,blockDim设置为8,但是为了充分利用硬件资源,一般将blockDim设置为硬件平台的核数,根据核数进行数据切分。

blockDim是逻辑核的概念,取值范围为[1,65535]。为了充分利用硬件资源,一般设置为物理核的核数或其倍数。对于耦合架构和分离架构,blockDim在运行时的意义和设置规则有一些区别:

-- 耦合架构:由于其Vector、Cube单元是集成在一起的,blockDim用于设置启动多个AICore核实例执行,不区分Vector、Cube。AI Core的核数可以通过GetCoreNumAiv或者GetCoreNumAic获取。

-- 分离架构:

   − 针对仅包含Vector计算的算子,blockDim用于设置启动多少个Vector(AIV)实例执行,比如某款AI处理器上有40个Vector核,建议设置为40。

   − 针对仅包含Cube计算的算子,blockDim用于设置启动多少个Cube(AIC)实例执行,比如某款AI处理器上有20个Cube核,建议设置为20。

   − 针对Vector/Cube融合计算的算子,启动时,按照AIV和AIC组合启动,blockDim用于设置启动多少个组合执行,比如某款AI处理器上有40个Vector核和20个Cube核,一个组合是2个Vector核和1个Cube核,建议设置为20,此时会启动20个组合,即40个Vector核和20个Cube核。注意:该场景下,设置的blockDim逻辑核的核数不能超过物理核(2个Vector核和1个Cube核组合为1个物理核)的核数。

   − AIC/AIV的核数分别通过GetCoreNumAic和GetCoreNumAiv接口获取。


4 L2Cache切分

假设AI处理器的L2Cache大小为192MB,L2Cache读写混合带宽约为7TB/s,而AI Core外部存储Global Memory的带宽约为1.6TB/s,两者之间存在较大差距。搬入或搬出相同数据量的情况下,访问L2Cache读写数据比HBM更快。若数据无法命中L2Cache,即需要访问的数据不在L2Cache内,导致需要去HBM上读写,带宽利用效率较低,最终算子搬入或搬出数据变为算子整个运行过程的性能瓶颈。切分策略建议:当输入和输出数据的数据量超过L2Cache大小时,Tiling中使能L2Cache切分策略。下面举个例子来说明L2Cache切分帮助大家理解。

假设输入数据大小为InputTotalSize = 384MB,L2Cache大小为192MB,总核数为20个核,数据未切分的情况下,整体一次完成计算。假设20个核一次可以处理共192MB的数据,则每个核至少两次读取输入数据。

图4-1 未使能L2Cache切分

1.png

 

constexpr int32_t TOTAL_LENGTH = 384 * 1024 * 1024 / sizeof(half); 
constexpr int32_t USE_CORE_NUM = 20; 
constexpr int32_t TILE_NUM = 2; 
constexpr int32_t BLOCK_LENGTH = TOTAL_LENGTH / USE_CORE_NUM; 
constexpr int32_t TILE_LENGTH = BLOCK_LENGTH / TILE_NUM; 
 
class KernelSample { 
public: 
    __aicore__ inline KernelSample() {} 
    __aicore__ inline void Init(GM_ADDR x) 
    { 
        xGm.SetGlobalBuffer((__gm__ half*)x + BLOCK_LENGTH * GetBlockIdx(), BLOCK_LENGTH); 
        pipe.InitBuffer(inQueueX, 1, BLOCK_LENGTH * sizeof(half)); 
        pipe.InitBuffer(inQueueY, 1, BLOCK_LENGTH * sizeof(half)); 
    } 
    __aicore__ inline void Process() 
    { 
        // 示例演示对输入数据加2的运算 
        constexpr int32_t loopCount = 2; 
        for (int32_t i = 0; i < loopCount; i++) { 
            // 外层的每次循环对输入数据进行加1的运算 
            for (int32_t j = 0; j < TILE_NUM; j++) { 
                // 内层循环分别处理每个核第0块和第1块数据 
                CopyIn(j); 
                Compute(); 
                CopyOut(j); 
            } 
        } 
    } 
private: 
    __aicore__ inline void CopyIn(int32_t process) 
    { 
        LocalTensor<half> xLocal = inQueueX.AllocTensor<half>(); 
        // 对于每个核,除了首次读取外,读取第0块数据时,L2Cache内缓存的是第1块数据; 
        // 对于每个核,读取第1块数据时,L2Cache内缓存的是第0块数据; 
        // 每个核需要4次读取GM上的数据 
        DataCopy(xLocal, xGm[process * TILE_LENGTH], TILE_LENGTH ); 
        inQueueX.EnQue(xLocal); 
    } 
    __aicore__ inline void Compute() 
    { 
        LocalTensor<half> yLocal = inQueueY.AllocTensor<half>(); 
        LocalTensor<half> xLocal = inQueueX.DeQue<half>(); 
        Adds(yLocal, xLocal, 1, TILE_LENGTH);    
        inQueueY.EnQue<half>(yLocal); 
        inQueueX.FreeTensor(xLocal); 
    } 
    __aicore__ inline void CopyOut(int32_t process) 
    { 
        LocalTensor<half> yLocal = inQueueY.DeQue<half>(); 
        DataCopy(yGm[process * TILE_LENGTH], yLocal, TILE_LENGTH); 
        inQueueY.FreeTensor(yLocal); 
    } 
} 
...

使能L2Cache切分后,输入数据均等切分成2份数据,则整体分两次进行计算,每次的计算量为192MB,第一次20个核先计算前192MB的数据,第二次20个核计算后192MB的数据。每次计算前读取的数据能够命中L2Cache,提升算子性能。

图4-2 使能L2Cache切分

2.png

constexpr int32_t TOTAL_LENGTH = 384 * 1024 * 1024 / sizeof(half); 
constexpr int32_t TILE_NUM = 2; 
constexpr int32_t USE_CORE_NUM = 20; 
constexpr int32_t TILE_LENGTH = TOTAL_LENGTH / TILE_NUM; 
constexpr int32_t BLOCK_LENGTH = TILE_LENGTH / USE_CORE_NUM; 
 
class KernelSample { 
public: 
    __aicore__ inline KernelSample() {} 
    __aicore__ inline void Init(GM_ADDR x, GM_ADDR y, int32_t index) 
    { 
        xGm.SetGlobalBuffer((__gm__ half*)x + BLOCK_LENGTH * GetBlockIdx() + index * TILE_LENGTH, BLOCK_LENGTH); 
        yGm.SetGlobalBuffer((__gm__ half*)y + BLOCK_LENGTH  * GetBlockIdx() + index * TILE_LENGTH, BLOCK_LENGTH); 
        pipe.InitBuffer(inQueueX, 1, BLOCK_LENGTH * sizeof(half)); 
        pipe.InitBuffer(inQueueY, 1, BLOCK_LENGTH * sizeof(half)); 
    } 
    __aicore__ inline void Process() 
    { 
        // 示例演示对输入数据加2的运算 
        constexpr int32_t loopCount = 2; 
        for (int32_t i = 0; i < loopCount; i++) { 
            // 每次循环对输入数据进行加1的运算 
            CopyIn(); 
            Compute(); 
            CopyOut(); 
        } 
    } 
private: 
    __aicore__ inline void CopyIn() 
    { 
        LocalTensor<half> xLocal = inQueueX.AllocTensor<half>(); 
        // 对于每个核,除了首次读取外,第二次读取可以命中L2Cache; 
        // 每个核2次读取GM上的数据,2次访问L2Cache读数据 
        DataCopy(xLocal, xGm, BLOCK_LENGTH ); 
        inQueueX.EnQue(xLocal); 
    } 
    __aicore__ inline void Compute() 
    { 
        LocalTensor<half> yLocal = inQueueY.AllocTensor<half>(); 
        LocalTensor<half> xLocal = inQueueX.DeQue<half>(); 
        Adds(yLocal, xLocal, 1, BLOCK_LENGTH);    
        inQueueY.EnQue<half>(yLocal); 
        inQueueX.FreeTensor(xLocal); 
    } 
    __aicore__ inline void CopyOut() 
    { 
        LocalTensor<half> yLocal = inQueueY.DeQue<half>(); 
        DataCopy(yGm, yLocal, BLOCK_LENGTH); 
        inQueueY.FreeTensor(yLocal); 
    } 
} 
... 
 
extern "C" __global__ __aicore__ void simple_kernel(__gm__ uint8_t* srcGm, __gm__ uint8_t* dstGm) 
{ 
    AscendC::KernelAdd op; 
    // 输入数据均等切分成2份数据进行计算 
    for (int32_t i = 0; i < TILE_NUM; i++) { 
        op.Init(srcGm, dstGm, i); 
        op.Process(); 
    } 
} 
...


5 核间负载均衡

AI处理器的物理核数是固定的,当L2Cache切分之后,可能发生部分核有计算拖尾的情况,即每次所有核计算量除以每个核处理的数据量不能被核数整除,导致最后需要部分尾核来计算尾块数据。而在尾核计算时,部分核始终处于空闲状态,从而使得算子的整体性能变差。

如下图所示,假设总的数据量为TotalSize,L2Cache切分之后分为两份TotalSize / 2,每个核每次的计算量为TotalSize / 2 / 25,即需要25个核进行处理,由于AI处理器的核数为20,因此每次计算时,1到5核的每个核需要多算一份数据,导致发生拖尾的情况。

图5-1 计算拖尾示意图

3.png

针对上述切分策略,调整拖尾核的位置后可以达到全局负载最优,如下图所示,完成所有计算时,1到10核多一次数据块的计算,可以实现全局负载最优。

图5-2 核间负载均衡示意图

4.png

6 更多学习资源

了解更多Ascend C算子性能优化手段和实践案例,请访问:昇腾社区Ascend C信息专区

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。