AI学习者 · 2 天前

【博客转载】C++/CUDA Data Alignment

博客来源:https://leimao.github.io/blog... ,来自Lei Mao,已获得作者转载授权。后续会转载一些Lei Mao的CUDA相关Blog,也是一个完整的专栏,Blog会从稍早一些的CUDA架构到当前最新的CUDA架构,也会包含实用工程技巧,底层指令分析,Cutlass分析等等多个课题,是一个时间线十分明确的专栏。

C++ 数据对齐

简介

数据对齐是现代计算机硬件计算中的一个关键特性。当数据自然对齐时,CPU读取和写入内存的效率最高,这通常意味着数据的内存地址是数据大小的倍数。例如,在32位架构中,如果数据存储在四个连续字节中,且第一个字节位于4字节边界上,则该数据可能是对齐的。

除了性能之外,数据对齐也是许多编程语言的假设条件。尽管编程语言尽可能地为我们处理数据对齐问题,但一些低级编程语言可能会出现未对齐的数据访问,而这种行为是未定义的。

在这篇博客文章中,我想快速讨论数据对齐,包括对齐的内存地址和对齐的内存访问,以及如何在C++中尽可能确保数据对齐。

数据对齐

当内存地址是的倍数时(其中是2的幂),我们说内存地址是字节对齐的。假设我们有一块字节的数据和一个字节对齐的地址。如果不能被整除,那么字节的数据将被填充到字节的数据。

访问字节的数据都有相同的延迟,因为CPU每次从内存中读取字节的数据,这些数据通常会被缓存在CPU中。也就是说,如果数据存储在字节对齐的地址上,其存储大小不是的倍数,那么一些内存访问带宽会被浪费。

当被访问的数据长度为字节且数据地址是字节对齐时,我们说这种内存访问是对齐的。当内存访问不对齐时,我们说它是未对齐的。请注意,根据定义,单字节内存访问总是对齐的。理论上,可以在不是的倍数的内存地址上访问字节的数据,但这会浪费更多的内存访问带宽。但是,由于C和C++标准假设内存访问是对齐的,访问未对齐的地址可能导致未定义的行为。

数据对齐要求

alignof可以用来检查特定数据类型的对齐要求。

#include <cassert>

struct float4_4_t
{
    float data[4];
};

// float4_32_t类型的每个对象都将对齐到32字节边界。
// 可能对SIMD指令有用。
struct alignas(32) float4_32_t
{
    float data[4];
};

// 比同一声明上的另一个alignas更弱的有效非零对齐会被忽略。
struct alignas(1) float4_1_t
{
    float data[4];
};

// 访问对象会导致未定义行为。
// 1字节结构成员对齐。
// size = 32, alignment = 1字节,这些结构成员没有填充。
// 这是不规范的,因为float需要4字节对齐。
#pragma pack(push, 1)
struct alignas(1) float4_1_ub_t
{
    float data[4];
};
#pragma pack(pop)

int main()
{
    assert(alignof(float4_4_t) == 4);
    assert(alignof(float4_32_t) == 32);
    assert(alignof(float4_1_t) == 4);
    assert(alignof(float4_1_ub_t) == 1);

    assert(sizeof(float4_4_t) == 16);
    assert(sizeof(float4_32_t) == 32);
    assert(sizeof(float4_1_t) == 16);
    assert(sizeof(float4_1_ub_t) == 16);
}
内存分配根据GNU文档(https://www.gnu.org/software/libc/manual/html_node/Aligned-Memory-Blocks.html),在GNU系统中,malloc或realloc返回的块地址总是8的倍数(在64位系统上是16的倍数)。数组的默认内存地址对齐由元素的对齐要求决定。可以为分配的静态内存和动态内存使用自定义数据对齐。alignas(T)可以用来指定静态数组的字节对齐,aligned_alloc可以用来指定动态内存上缓冲区的字节对齐。#include <cstdio>
#include <cstdlib>
#include <iostream>

int main()
{
    unsignedchar buf1[sizeof(int) / sizeof(char)];
    std::cout << "默认 "
              << alignof(unsignedchar[sizeof(int) / sizeof(char)]) << "字节"
              << " 对齐地址: " << static_cast<void*>(buf1) << std::endl;
    std::cout << reinterpret_cast<uintptr_t>(buf1) %
                     alignof(unsignedchar[sizeof(int) / sizeof(char)])
              << std::endl;
    std::cout << reinterpret_cast<uintptr_t>(buf1) % alignof(int) << std::endl;

    alignas(int) unsignedchar buf2[sizeof(int) / sizeof(char)];
    std::cout << alignof(int)
              << "字节对齐地址: " << static_cast<void*>(buf2)
              << std::endl;
    std::cout << reinterpret_cast<uintptr_t>(buf2) %
                     alignof(unsignedchar[sizeof(int) / sizeof(char)])
              << std::endl;
    std::cout << reinterpret_cast<uintptr_t>(buf2) % alignof(int) << std::endl;

    void* p1 = malloc(sizeof(int));
    std::cout << "默认 "
              << "16字节"
              << " 对齐地址: " << p1 << std::endl;
    std::cout << reinterpret_cast<uintptr_t>(p1) % 16 << std::endl;
    std::cout << reinterpret_cast<uintptr_t>(p1) % 1024 << std::endl;
    free(p1);

    void* p2 = aligned_alloc(1024, sizeof(int));
    std::cout << "1024字节对齐地址: " << p2 << std::endl;
    std::cout << reinterpret_cast<uintptr_t>(p2) % 16 << std::endl;
    std::cout << reinterpret_cast<uintptr_t>(p2) % 1024 << std::endl;
    free(p2);
}
$ g++ alloc.cpp -o alloc -std=c++11
$ ./alloc
Default 1-byte aligned addr: 0x7ffd46d76304
0
0
4-byte aligned addr: 0x7ffd46d76300
0
0
Default 16-byte aligned addr: 0x559a6e1c42c0
0
704
1024-byte aligned addr: 0x559a6e1c4400
0
0
未定义行为如果数据对齐不正确,向静态数组或动态缓冲区写入数据可能导致未定义行为。例如,如果我们在unsigned char buf[sizeof(T) / sizeof(char)]上创建类型T的对象,可能会发生读写的未定义行为,特别是使用reinterpret_cast和未对齐的内存地址增量时。对于使用malloc分配的动态缓冲区上创建对象T也是如此。但是,由于malloc返回的地址在32位架构上是8字节对齐的,在64位架构上是16字节对齐的,8字节和16字节对齐可以被几乎所有的数据满足,特别是基本类型。因此不太可能发生未定义行为。例如,以下数据结构Bar有sizeof(Bar) == 6和alignof(Bar) == 2。struct Bar
{
    char arr[3];    // 3字节 + 1个填充字节
    short s;        // 2字节
};
对齐要求总是数据结构中每个成员的最大对齐要求。在现代计算机中,它必须是2的幂。在数据结构Bar的情况下,alignof(char) == 1和alignof(short) == 2。因此,sizeof(Bar) == max(alignof(char), alignof(short)) == 2。如果内存中的Bar对象是2字节对齐的,访问其需要1字节要求的char类型属性会自动满足。由于填充字节的存在,访问其需要2字节要求的short类型属性也会自动满足。在我的x86-64架构计算机上,我可以使用malloc(sizeof(Bar)),返回的指针将是16字节对齐的地址,这满足了Bar数据结构的2字节对齐要求。为了完全确保不会因数据对齐而引起未定义行为,我们应该使用T buf[N]、alignas(T) unsigned char buf[N * sizeof(T) / sizeof(char)]和aligned_alloc(alignas(T), N * sizeof(T))来分配内存,然后在其上创建类型T的对象。这也意味着当编译器生成数据结构时,sizeof(T)必须是alignas(T)的倍数。否则,从数组中的第二个元素开始,它们可能开始变得未对齐。结论手动确保数据对齐容易出错。因此,对于大多数使用情况,我们应该尝试使用高级接口函数和STL容器,如new和delete以及std::vector,用于动态内存分配,并通过减少危险的指针类型转换来确保类型安全。参考文献Data Alignment(https://www.songho.ca/misc/alignment/dataalign.html)alignas specifier(en.cppreference.com/w/cpp/language/alignas)std::aligned_alloc(https://en.cppreference.com/w/cpp/memory/c/aligned_alloc)博客来源:https://leimao.github.io/blog/CUDA-Data-Alignment/ ,来自Lei Mao,已获得作者转载授权。CUDA 数据对齐简介为了获得最佳性能,与C++中的数据对齐要求类似(https://leimao.github.io/blog/CPP-Data-Alignment/),CUDA也需要数据对齐。在这篇博客文章中,我将快速讨论CUDA中的数据对齐要求。全局内存的合并访问全局内存驻留在设备内存中,设备内存通过32、64或128字节的内存事务进行访问。这些内存事务必须自然对齐:只有对齐到其大小的32、64或128字节的设备内存段(即,其首地址是其大小的倍数)才能被内存事务读取或写入。当一个线程束执行访问全局内存的指令时,它会根据每个线程访问的字大小和内存地址在线程间的分布,将线程束内线程的内存访问合并为一个或多个内存事务。一般来说,需要的事务越多,除了线程访问的字之外传输的未使用字就越多,相应地降低了指令吞吐量。对于计算能力6.0或更高的设备,要求可以很容易地总结:线程束中线程的并发访问将合并为多个事务,事务数量等于服务线程束中所有线程所需的32字节事务数量。驻留在全局内存中的变量地址或驱动程序或运行时API的内存分配例程(如cudaMalloc或cudaMallocPitch)返回的任何地址始终至少对齐到256字节。示例例如,如果由32个线程组成的线程束中的每个线程都想读取4字节数据,并且如果线程束中所有线程的4字节数据(128字节数据)彼此相邻且32字节对齐,即第一个4字节数据的地址是32的倍数,那么内存访问是合并的,GPU将进行次32字节内存事务。由于GPU进行了尽可能少的事务,实现了最大内存事务吞吐量。如果128字节数据在内存上不是32字节对齐的,比如说是4字节对齐的,那么将必须进行一次额外的32字节内存事务,因此内存访问吞吐量变为最大理论吞吐量的。(跨了5个数据段)此外,如果所有线程的4字节数据彼此不相邻并且在内存上稀疏分散,那么可能需要进行最多32次32字节内存事务,吞吐量仅为最大理论吞吐量的。大小和对齐要求全局内存指令支持读取或写入大小等于1、2、4、8或16字节的字。只有当数据类型的大小为1、2、4、8或16字节且数据自然对齐(即,其地址是该大小的倍数)时,对驻留在全局内存中的数据的任何访问(通过变量或指针)才会编译为单个全局内存指令。如果不满足此大小和对齐要求,访问将编译为多个具有交错访问模式的指令,这些指令阻止这些指令完全合并。因此,建议对驻留在全局内存中的数据使用满足此要求的类型。读取非自然对齐的8字节或16字节字会产生错误结果(偏差几个字),因此必须特别注意维护这些类型的任何值或值数组的起始地址的对齐。因此,使用大小等于1、2、4、8或16字节的字有时很简单,因为如上所述,内存分配CUDA API返回的起始内存地址始终至少对齐到256字节,这已经是1、2、4、8或16字节对齐的。所以我们可以安全地将字序列(如数值数组、矩阵或张量)保存到分配的内存中,而不必担心读取8字节或16字节大小的字会产生错误结果。为了实现最佳的内存访问吞吐量,在kernel实现上需要特别注意,以便合并的内存访问也是自然对齐的。但是,如果字大小不是1、2、4、8或16字节怎么办?内存分配CUDA API返回的起始内存地址将不保证它是自然对齐的,因此内存访问吞吐量将显著受损。通常有两种方法来处理这个问题。使用内置向量类型(https://docs.nvidia.com/cuda/archive/11.7.0/cuda-c-programming-guide/index.html#built-in-vector-types),其对齐要求已经指定并满足。类似于GCC中用于强制结构数据对齐的编译器指定符alignas,在NVCC中使用编译器指定符__align__来强制结构数据对齐。struct __align__(4) int8_3_4_t
{
    int8_t x;
    int8_t y;
    int8_t z;
};

struct __align__(16) float3_16_t
{
    float x;
    float y;
    float z;
};
结论始终使字大小等于1、2、4、8或16字节,并使数据自然对齐。如果分配的内存仅用于相同类型字的序列,读取字并产生错误结果的情况很少发生,因为内存分配CUDA API返回的起始内存地址始终至少对齐到256字节。但是,如果为不同类型的多个字序列分配一大块内存(带有或不带有填充),必须特别注意维护任何字或字序列的起始地址的对齐,因为它可能产生错误结果(对于非自然对齐的8字节或16字节字)。参考文献C++ Data Alignment(https://leimao.github.io/blog/CPP-Data-Alignment/)CUDA Device Memory Access(https://docs.nvidia.com/cuda/archive/11.7.0/cuda-c-programming-guide/index.html#device-memory-accesses)Coalesced Access to Global Memory(https://docs.nvidia.com/cuda/archive/11.7.0/cuda-c-best-practices-guide/index.html#coalesced-access-to-global-memory)

内存分配

根据GNU文档(https://www.gnu.org/software/...\_node/Aligned-Memory-Blocks.html),在GNU系统中,mallocrealloc返回的块地址总是8的倍数(在64位系统上是16的倍数)。数组的默认内存地址对齐由元素的对齐要求决定。

可以为分配的静态内存和动态内存使用自定义数据对齐。alignas(T)可以用来指定静态数组的字节对齐,aligned_alloc可以用来指定动态内存上缓冲区的字节对齐。

#include <cstdio>
#include <cstdlib>
#include <iostream>

int main()
{
    unsignedchar buf1[sizeof(int) / sizeof(char)];
    std::cout << "默认 "
              << alignof(unsignedchar[sizeof(int) / sizeof(char)]) << "字节"
              << " 对齐地址: " << static_cast<void*>(buf1) << std::endl;
    std::cout << reinterpret_cast<uintptr_t>(buf1) %
                     alignof(unsignedchar[sizeof(int) / sizeof(char)])
              << std::endl;
    std::cout << reinterpret_cast<uintptr_t>(buf1) % alignof(int) << std::endl;

    alignas(int) unsignedchar buf2[sizeof(int) / sizeof(char)];
    std::cout << alignof(int)
              << "字节对齐地址: " << static_cast<void*>(buf2)
              << std::endl;
    std::cout << reinterpret_cast<uintptr_t>(buf2) %
                     alignof(unsignedchar[sizeof(int) / sizeof(char)])
              << std::endl;
    std::cout << reinterpret_cast<uintptr_t>(buf2) % alignof(int) << std::endl;

    void* p1 = malloc(sizeof(int));
    std::cout << "默认 "
              << "16字节"
              << " 对齐地址: " << p1 << std::endl;
    std::cout << reinterpret_cast<uintptr_t>(p1) % 16 << std::endl;
    std::cout << reinterpret_cast<uintptr_t>(p1) % 1024 << std::endl;
    free(p1);

    void* p2 = aligned_alloc(1024, sizeof(int));
    std::cout << "1024字节对齐地址: " << p2 << std::endl;
    std::cout << reinterpret_cast<uintptr_t>(p2) % 16 << std::endl;
    std::cout << reinterpret_cast<uintptr_t>(p2) % 1024 << std::endl;
    free(p2);
}
$ g++ alloc.cpp -o alloc -std=c++11
$ ./alloc
Default 1-byte aligned addr: 0x7ffd46d76304
0
0
4-byte aligned addr: 0x7ffd46d76300
0
0
Default 16-byte aligned addr: 0x559a6e1c42c0
0
704
1024-byte aligned addr: 0x559a6e1c4400
0
0

未定义行为

如果数据对齐不正确,向静态数组或动态缓冲区写入数据可能导致未定义行为。例如,如果我们在unsigned char buf[sizeof(T) / sizeof(char)]上创建类型T的对象,可能会发生读写的未定义行为,特别是使用reinterpret_cast和未对齐的内存地址增量时。对于使用malloc分配的动态缓冲区上创建对象T也是如此。但是,由于malloc返回的地址在32位架构上是8字节对齐的,在64位架构上是16字节对齐的,8字节和16字节对齐可以被几乎所有的数据满足,特别是基本类型。因此不太可能发生未定义行为。

例如,以下数据结构Barsizeof(Bar) == 6alignof(Bar) == 2

struct Bar
{
    char arr[3];    // 3字节 + 1个填充字节
    short s;        // 2字节
};

对齐要求总是数据结构中每个成员的最大对齐要求。在现代计算机中,它必须是2的幂。在数据结构Bar的情况下,alignof(char) == 1alignof(short) == 2。因此,sizeof(Bar) == max(alignof(char), alignof(short)) == 2

如果内存中的Bar对象是2字节对齐的,访问其需要1字节要求的char类型属性会自动满足。由于填充字节的存在,访问其需要2字节要求的short类型属性也会自动满足。

在我的x86-64架构计算机上,我可以使用malloc(sizeof(Bar)),返回的指针将是16字节对齐的地址,这满足了Bar数据结构的2字节对齐要求。

为了完全确保不会因数据对齐而引起未定义行为,我们应该使用T buf[N]alignas(T) unsigned char buf[N * sizeof(T) / sizeof(char)]aligned_alloc(alignas(T), N * sizeof(T))来分配内存,然后在其上创建类型T的对象。这也意味着当编译器生成数据结构时,sizeof(T)必须是alignas(T)的倍数。否则,从数组中的第二个元素开始,它们可能开始变得未对齐。

结论

手动确保数据对齐容易出错。因此,对于大多数使用情况,我们应该尝试使用高级接口函数和STL容器,如newdelete以及std::vector,用于动态内存分配,并通过减少危险的指针类型转换来确保类型安全。

参考文献

博客来源:https://leimao.github.io/blog... ,来自Lei Mao,已获得作者转载授权。

CUDA 数据对齐

简介

为了获得最佳性能,与C++中的数据对齐要求类似(https://leimao.github.io/blog...),CUDA也需要数据对齐。

在这篇博客文章中,我将快速讨论CUDA中的数据对齐要求。

全局内存的合并访问

全局内存驻留在设备内存中,设备内存通过32、64或128字节的内存事务进行访问。这些内存事务必须自然对齐:只有对齐到其大小的32、64或128字节的设备内存段(即,其首地址是其大小的倍数)才能被内存事务读取或写入。

当一个线程束执行访问全局内存的指令时,它会根据每个线程访问的字大小和内存地址在线程间的分布,将线程束内线程的内存访问合并为一个或多个内存事务。一般来说,需要的事务越多,除了线程访问的字之外传输的未使用字就越多,相应地降低了指令吞吐量。

对于计算能力6.0或更高的设备,要求可以很容易地总结:线程束中线程的并发访问将合并为多个事务,事务数量等于服务线程束中所有线程所需的32字节事务数量。

驻留在全局内存中的变量地址或驱动程序或运行时API的内存分配例程(如cudaMalloccudaMallocPitch)返回的任何地址始终至少对齐到256字节。

示例

例如,如果由32个线程组成的线程束中的每个线程都想读取4字节数据,并且如果线程束中所有线程的4字节数据(128字节数据)彼此相邻且32字节对齐,即第一个4字节数据的地址是32的倍数,那么内存访问是合并的,GPU将进行次32字节内存事务。由于GPU进行了尽可能少的事务,实现了最大内存事务吞吐量。

如果128字节数据在内存上不是32字节对齐的,比如说是4字节对齐的,那么将必须进行一次额外的32字节内存事务,因此内存访问吞吐量变为最大理论吞吐量的。(跨了5个数据段)

此外,如果所有线程的4字节数据彼此不相邻并且在内存上稀疏分散,那么可能需要进行最多32次32字节内存事务,吞吐量仅为最大理论吞吐量的。

大小和对齐要求

全局内存指令支持读取或写入大小等于1、2、4、8或16字节的字。只有当数据类型的大小为1、2、4、8或16字节且数据自然对齐(即,其地址是该大小的倍数)时,对驻留在全局内存中的数据的任何访问(通过变量或指针)才会编译为单个全局内存指令。

如果不满足此大小和对齐要求,访问将编译为多个具有交错访问模式的指令,这些指令阻止这些指令完全合并。因此,建议对驻留在全局内存中的数据使用满足此要求的类型。

读取非自然对齐的8字节或16字节字会产生错误结果(偏差几个字),因此必须特别注意维护这些类型的任何值或值数组的起始地址的对齐。

因此,使用大小等于1、2、4、8或16字节的字有时很简单,因为如上所述,内存分配CUDA API返回的起始内存地址始终至少对齐到256字节,这已经是1、2、4、8或16字节对齐的。所以我们可以安全地将字序列(如数值数组、矩阵或张量)保存到分配的内存中,而不必担心读取8字节或16字节大小的字会产生错误结果。为了实现最佳的内存访问吞吐量,在kernel实现上需要特别注意,以便合并的内存访问也是自然对齐的。

但是,如果字大小不是1、2、4、8或16字节怎么办?内存分配CUDA API返回的起始内存地址将不保证它是自然对齐的,因此内存访问吞吐量将显著受损。通常有两种方法来处理这个问题。

  • 使用内置向量类型(https://docs.nvidia.com/cuda/...),其对齐要求已经指定并满足。
  • 类似于GCC中用于强制结构数据对齐的编译器指定符alignas,在NVCC中使用编译器指定符__align__来强制结构数据对齐。
struct __align__(4) int8_3_4_t
{
    int8_t x;
    int8_t y;
    int8_t z;
};

struct __align__(16) float3_16_t
{
    float x;
    float y;
    float z;
};

结论

始终使字大小等于1、2、4、8或16字节,并使数据自然对齐。

如果分配的内存仅用于相同类型字的序列,读取字并产生错误结果的情况很少发生,因为内存分配CUDA API返回的起始内存地址始终至少对齐到256字节。但是,如果为不同类型的多个字序列分配一大块内存(带有或不带有填充),必须特别注意维护任何字或字序列的起始地址的对齐,因为它可能产生错误结果(对于非自然对齐的8字节或16字节字)。

参考文献

END

作者:Lei Mao
来源:GiantPandaLLM

推荐阅读

欢迎大家点赞留言,更多 Arm 技术文章动态请关注极术社区嵌入式AI专栏欢迎添加极术小姐姐微信(id:aijishu20)加入技术交流群,请备注研究方向。

推荐阅读
关注数
18974
内容数
1486
嵌入式端AI,包括AI算法在推理框架Tengine,MNN,NCNN,PaddlePaddle及相关芯片上的实现。欢迎加入微信交流群,微信号:aijishu20(备注:嵌入式)
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息