【高性能计算】内存大小和地址对齐以及异构平台处理手段

内存大小和地址对齐以及异构平台处理手段

字节对齐的原因

对齐速度更快
  • 8字节对齐”的对象存储在以8为倍数的内存地址中。

  • 许多CPU只会从对齐的位置加载一些数据类型;在诸多计算核心上,这样的访问速度更快。

  • 对齐访问速度更快,因为到内存的外部总线不是一个字节宽——它通常是4或8字节宽(甚至更宽)。这意味着CPU每次不提取一个字节,而是从请求的地址开始提取4或8个字节。

  • 因此,外部内存只能在总线宽度的倍数的地址上读写。如果您在地址“9”处请求一个字节,CPU实际上会向内存请求从地址8开始的字节块,并将第二个字节加载到寄存器中(丢弃其他字节)。

  • 这意味着未对齐的访问可能需要从内存中读取两次:

    • 如果您请求从地址9开始的8个字节,CPU必须获取从地址8开始的8字节以及从地址16开始的8位字节,然后屏蔽掉您想要的字节。
    • 另一方面,如果您请求从地址8开始的8个字节,那么只需要一次提取。有些CPU甚至不会执行这种未对齐的加载——它们只会引发异常(甚至会默默地加载错误的数据!)。
总线内存访问
  • 内存对齐在不同方面对性能很重要。这与硬件有关。
    • 自80年代以来,CPU和内存之间的访问时间有所不同。处理器的速度比内存的速度更快。随着时间的推移,这种差异越来越大(举个例子:在Apple II上,CPU的频率是1.023MHz,内存的频率是这个频率的两倍。现代PC的CPU工作频率约为3GHz,内存仅为400MHz)。
    • 解决内存不断变慢问题的一个方法是在更宽的总线上访问内存,而不是一次访问一个字节,CPU将从内存中读取一个64位宽的字。
    • 这意味着即使您从内存中读取1个字节,总线也将传送一个完整的64位(8字节字)。存储器将在地址0、8、16、24、32、40等处具有这8个字节单位。8的倍数。例如,如果您访问地址4处的8字节字,则硬件必须读取地址0处的字,屏蔽该字的高4字节,然后读取地址8处的字、屏蔽该字低部分,将其与前半部分合并并将其提供给寄存器。正如您所看到的,操作非常复杂(因此非常缓慢)。
异构系统
  • 异构系统通常包括更多的计算核心,每个计算核心由于自身架构的差异,会出现多个计算核心有着不同的对齐要求
    • 如 昇腾AI处理器在进行数据搬运和Vector计算时,对于搬运的数据长度和UB首地址都有必须32B对齐的要求,但是其scale标量计算核心需要64B对齐要求。
    • 内存大小对齐、内存地址对齐,这需要对内存进行额外的处理。
    • 我在这里将其展开为两部分方便叙述:内存大小对齐和内存地址对齐。

内存大小对齐

内存搬运的非对齐处理
  • 通常计算机系统对于搬运的数据长度和UB首地址都有必须对齐的要求。
  • 但是对于非对齐大小的内存该如何搬运处理呢,有以下几种方式:
    • 以对齐要求32B为例,当需要从Global拷贝11个half数值到Local时,使用DataCopy将拷贝16个half(32B)数据到Local上,Local[11]~Local[15]被写成无效数据-1。搬出内存时亦然。
    • 使用mask掩掉脏数据,如调用相关接口将Local[11]~Local[15]被写成0
内存搬运与计算实现
  • 在遇到内存大小不对齐的数据时,如何将其Loading到相关的计算核心进行高性能的计算呢?

  • 以一个场景为例:

    • 可以内存对齐
      • 算子的输入shape为(1,2048),支持的数据类型为half类型(2字节),可以对齐到一个datablock的大小(32字节),也可以平均分配到每个核上(假设使用8个核),每个核上处理256个数,16个datablock,每个datablock存储16个数。
    • 不能内存对齐
      • 算子的输入shape为(1,1999),支持的数据类型为half类型,既无法对齐到一个datablock的大小(32B),也无法平均分配到每个核上,需要一些特殊的Tiling处理方法。
  • 以对齐要求32B为例,如何处理不能内存对齐的情况呢?

  • 首先待处理数据需要先保证对齐到datablock的大小(32B)。可以使用上述的内存搬运的非对齐处理。

constexpr uint32_t SIZE_OF_HALF = 2;
constexpr uint32_t BLOCK_SIZE = 32;
constexpr uint32_t ALIGN_NUM = BLOCK_SIZE / SIZE_OF_HALF;
// shape需要对齐到的datablock,假设原totalLength为1999,向上满足32字节对齐后为2000
uint32_t totalLengthAligned = ((totalLength + ALIGN_NUM - 1) / ALIGN_NUM) * ALIGN_NUM;
  • 满足datablock对齐后的数据,应尽可能的均分到每个核上。如果无法均分,那么先将可以均分的部分平均分配,剩余的部分分配给部分核,会有部分核多算一个datablock。
  • 对齐到datablock后为2000个half类型的数据,共125个datablock。125除以8,商为15,余数为5,说明:可以均分的部分平均分配,每个核分配到15个datablock; 还剩余5个datablock,分配给5个核,所以会有5个核分配到16个datablock,剩余3个核分配到15个datablock。
// 基于上文的描述,可以设计如下的Tiling参数及其计算方法如下:

constexpr uint32_t BLOCK_DIM = 8;
constexpr uint32_t SIZE_OF_HALF = 2;
constexpr uint32_t BLOCK_SIZE = 32;
// shape需要对齐到的最小单位
constexpr uint32_t ALIGN_NUM = BLOCK_SIZE / SIZE_OF_HALF;   // alignNum:一个datablock包含的元素个数
...
uint8_t *GenerateTiling()
{
    // shape需要对齐到的datablock,假设原totalLength为1999,向上满足32字节对齐后为2000
    uint32_t totalLengthAligned = ((totalLength + ALIGN_NUM - 1) / ALIGN_NUM) * ALIGN_NUM;
    // 把所有的数据尽可能均匀地分配到每个核上
    // 如果不能均分,先将可以均分的部分平均分配,剩余的部分分配给部分核,会有部分核多算一个datablock 
    // 通过模的计算,可以得到多算一个datablock的核的数量,也可以得到剩余核的数量
    // eg:1999 对齐后的总数据量为2000个数,核心数为8,一个datablock包含16个数,那么:
    // datablock的总数:2000 / 16 = 125
    // 有5个核会分到16个datablock:125 % 8 =5,可以称之为大块
    // 有3个核会分到15个datablock:8 - 5 = 3,可以称之为小块
    uint32_t formerNum = (totalLengthAligned / ALIGN_NUM) % BLOCK_DIM;
    uint32_t tailNum = BLOCK_DIM - formerNum;  // formerNum:分配到大块的核数 tailNum:分配到小块的核数
    // 大块计算的数据量:totalLengthAligned / BLOCK_DIM为每个核上计算的元素个数,formerLength为上述元素个数向上32字节对齐的结果
    uint32_t formerLength = ((totalLengthAligned / BLOCK_DIM + ALIGN_NUM - 1) / ALIGN_NUM) * ALIGN_NUM;  // formerLength:大块计算的数据量
    // 小块计算的数据量:totalLengthAligned / BLOCK_DIM为每个核上计算的元素个数,tailLength 为上述元素个数向下32字节对齐的结果
    uint32_t tailLength = (totalLengthAligned / BLOCK_DIM / ALIGN_NUM) * ALIGN_NUM;  // tailLength:小块计算的数据量

...
}
// 相对应的,在Kernel侧,使用获取到的信息计算得到每个核上的偏移量、每个分块大小的样例如下。

__aicore__ inline void Init(GM_ADDR x, GM_ADDR y, GM_ADDR z, uint32_t formerNum, uint32_t tailNum, uint32_t formerLength, uint32_t tailLength, uint32_t alignNum)
{
    if (GetBlockIdx() < formerNum) {
        this->tileLength = formerLength;
        xGm.SetGlobalBuffer((__gm__ half *)x + formerLength * GetBlockIdx(), formerLength);
        yGm.SetGlobalBuffer((__gm__ half *)y + formerLength * GetBlockIdx(), formerLength);
        zGm.SetGlobalBuffer((__gm__ half *)z + formerLength * GetBlockIdx(), formerLength);
    } else {
        this->tileLength = tailLength;
        xGm.SetGlobalBuffer((__gm__ half *)x + formerLength * formerNum + tailLength * (GetBlockIdx() - formerNum), tailLength);
        yGm.SetGlobalBuffer((__gm__ half *)y + formerLength * formerNum + tailLength * (GetBlockIdx() - formerNum), tailLength);
        zGm.SetGlobalBuffer((__gm__ half *)z + formerLength * formerNum + tailLength * (GetBlockIdx() - formerNum), tailLength);
    }
    pipe.InitBuffer(inQueueX, BUFFER_NUM, this->tileLength * sizeof(half));
    pipe.InitBuffer(inQueueY, BUFFER_NUM, this->tileLength * sizeof(half));
    pipe.InitBuffer(outQueueZ, BUFFER_NUM, this->tileLength * sizeof(half));
}

内存地址对齐

地址对齐是什么
  • 因为随意分配的地址可能addr % p != 0 (p等于8 / 16 / 等等),所以一般的做法是先分配一个比申请空间稍大的空间,,然后将这个头地址往前移动几个字节,使得新的addr满足 % p = 0
    • 空间申请,代码如下:
     unsigned char* udata = (unsigned char*)malloc(size + sizeof(void*) + MALLOC_ALIGN);
     // sizeof(void*) 等于8字节, 
    
  • 明明只申请了size字节大小的空间,为什么要多分配sizeof(void*)+ MALLOC_ALIGN这么多的空间呢?
    • 理由是为了保存原始地址 + 对齐
    • 关于NCNN中内存申请与对齐,见此文章
Malloc和Free关于内存地址对齐的处理
  • Malloc 通常会在分配的内存块之前或之后存储一段元数据,这些元数据包括内存的大小、对齐信息、对齐内存地址、原始内存地址(这可能是不对齐的地址)
  • Free 通过传入的指针找到对应的内存块,并将其标记为可用,方便后续处理。Free 函数基于传入的指针访问内存块的元数据,以知道要释放的内存大小,以及实际申请的地址。因为通过Malloc 申请返回的地址可能是经过对齐操作的地址,而实际分配的地址可能是不对齐的,这需要单独存储在元数据中,以便后续释放。
  • 换句话说,Malloc 实际分配的内存大小是 传入的size + 元数据size 的大小
void *aligned_ptr = ptr;
aligned_ptr = (ptr + alignment -1) & ~(alignment -1);
// 传入size,实际申请按照 total_size 
total_size = alignment + size + sizeof(ChunkHeader);  // ChunkHeader结构体存储了内存的大小、对齐信息、对齐内存地址、原始内存地址等信息
  • 为了计算对齐后的地址,需要增加一个alignment 的大小内存申请
异构系统内存对齐的思考
  • Malloc Free 对于内存对齐的方式对于异构系统多计算核心和多对齐要求有什么启发呢?
    • 对于异构平台的对齐,也可以参考这种方式,增加元数据存储相关的对齐信息。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值