C++ 中的内存对齐——实践篇
本文为《C++ 中的内存对齐》系列之下篇,上篇介绍内存对齐的理论基础,建议优先食用~
TL;DR
- 编译器可能会在结构体中填充字节,以满足所有成员的对齐要求;
- 可以通过预处理指令
#pragma pack
及alignas
标识符自定义内存对齐; - 对于栈上及静态变量,编译器保证遵循其类型的对齐要求;
- 对于堆上构造的对象,只有在 C++17 后才能保证任何情况下动态申请的内存都满足对齐要求。
通过上一篇文章我们已经了解到,访问未对齐的内存轻则导致性能损失,重则引发 CPU 异常,甚至静默地访问错误的地址,导致数据错误。虽然近几年来有些 CPU 已经支持访问未对齐内存且不会带来性能影响,但考虑到程序的可移植性,在实践中还是应当尽量避免访问未对齐内存的行为。
所幸,C++ 的内存对齐机制已经自动为我们屏蔽了这些底层细节,在绝大部分情况下不必担心访问到未对齐的内存。然而在开发过程中,有时依然需要手动控制内存对齐的细节,或是为了追求更高的性能,抑或是源于某些外部硬件或嵌入式系统的特殊要求。因此作为程序员,有必要了解 C++ 究竟默默在背后为我们做了什么,以及在特定情况下我们能做什么来改变其默认的行为。
栈上及静态变量对齐
对齐要求
每一个完整的对象类型都有一个叫做对齐要求(alignment
requirement)的属性,也称为对齐值。它是一个
size_t
类型的整数值,表示可以分配此类对象的连续地址之间的字节数。换句话说,对象的起始地址必须是其类型的对齐值的整数倍。有效的对齐值是
2 的非负整数幂。我们可以通过 _Alignof
或
alignof
获得某个类型的对齐值。
然而编译器是如何计算对齐值的呢?这里先介绍几个概念:
- 基本类型的自身对齐值:基本类型自身所占的空间大小,即
sizeof()
值 - 结构体或类的自身对齐值:其所有成员中的最大对齐值
- 指定对齐值:使用
#pragma pack(n)
时指定的对齐值n
(1,2,4,8,16...) - 有效对齐值:自身对其值和指定对其值中的较小者,实际对齐时取该值
结构体的对齐规则
为了让结构体(或类,下同)满足所有成员的对齐要求,编译器需要在某些成员之后插入填充(Padding)。
基于前述对齐值的概念,结构体的对齐规则如下:
- 结构体变量的起始地址能够被其有效对齐值整除;
- 每个成员相对于结构体首地址的偏移都能被有效对齐值整除,如不能则在前一个成员后填充字节;
- 结构体的总大小为有效对齐值的整数倍,如不能则在最后面填充字节。
例如,对于如下结构体 struct x_
:
1 | struct x_ |
编译器通过 padding 使得该结构体的实际内存布局如下:
1 | struct x_ |
两种声明得到的 sizeof(x_)
值均为 12 bytes,同时
alignof(x_)
值均为 4 bytes。
自定义内存对齐
前文提到,我们可以使用 #pragma pack (n)
设定一个全局的对齐值上限,该功能经常被用于告诉编译器不要对结构体成员进行对齐。例如当
n = 1 时,编译器不做任何
Padding,结构体中的成员紧密地排列在一起,此时整个结构体的大小等于所有成员大小之和。当你在使用硬件内存映射接口、需要准确控制各个成员所在位置时,很可能会用到它,但这往往以牺牲访存速度作为代价。
然而,一枚硬币有两面。从另一个角度来看,自定义内存对齐有时也能起到提升性能的作用。
alignas
类型说明符是一种可移植的 C++
标准方法,用于指定变量和自定义类型的对齐方式,可以在定义
class、struct、union 或声明变量时使用。如果遇到多个 alignas
说明符,编译器会选择最严格的那个(最大对齐值)。
1 | struct alignas(16) Bar |
内存对齐可以使处理器更好地利用 cache,包括减少 cache line 访问,以及避免多核一致性问题引发的 cache miss。具体来说,在多线程程序中,一种常用的优化手段是将需要高频并发访问的数据按 cache line 大小(通常为 64 字节)对齐。一方面,对于小于 64 字节的数据可以做到只触及一个 cache line,减少访存次数;另一方面,相当于独占了整个 cache line,避免其他数据可能修改同一 cache line 导致其他核 cache miss 的开销。更多原理细节请移步理论篇。
当然,在更多情况下,不需要追求那样极致的性能。你可能只是想让结构的内存布局尽量紧凑一些,但又不至于干涉编译器的默认对齐行为。那么你可以在声明结构时,按照大小的递增/递减顺序排列成员,这样可以最小化需要填充的字节,提高内存利用率。
alignas
的局限
alignas
并不是万能的,我们要清楚它不能做什么。
首先,对数组使用
alignas
,对齐的是数组的首地址,而不是每个数组元素。也就是说,下面这个数组并不是每个
int 都占 64 字节。
1 | alignas(64) int array[128]; |
如果一定要让每个元素都对齐,可以这样实现:
1 | struct alignas(64) S { int a; }; |
其次,编译器不保证拷贝后的数据依然保留原来的对齐属性。例如,memcpy
可以拷贝一个带有 alignas
的结构到任何地址。
同样,你也不能对函数参数指定对齐。当你在堆栈上按值传递具有对齐属性的数据时,其对齐方式由调用过程控制。如果数据对齐在被调用函数中很重要,可以在使用前将参数复制到正确对齐的内存中。
最后,对一个类型指定对齐属性,仅意味着其所有的静态和栈上对象按指定值进行对齐,对于堆上构造的对象则未必。因此,一般的分配器(如
malloc
、operator new
等)返回的内存地址可能不满足 alignas
的要求。
堆内存的对齐
申请对齐的内存
malloc
返回的指针与原始数据类型的最大大小对齐,该值由编译器定义,通常在 32
位机器上为 8 字节,在 64 机器上为 16
字节。如果要求更大的对齐值,则需要用到特殊的内存分配函数。
C++11 提供了一个标准函数,但笔者使用 g++ 和 clang 实测均在 C++17 及以后才支持:
1 | void *aligned_alloc(size_t alignment, size_t size); |
如果你的编译器暂时还不支持,也可以考虑平台特定的方案。
在 Window 上,可以使用 MSVC 提供的:
1 | void *_aligned_malloc(size_t size, size_t alignment); |
在 Linux 上,可以使用 glibc 的:
1 | void *memalign(size_t alignment, size_t size); |
或者
1 | int posix_memalign(void **memptr, size_t alignment, size_t size); |
使用 new
构造对齐的对象
我们来看这样一段代码:
1 | class alignas(32) Vec3d { |
在 C++11/14 下,结果为:
1 | sizeof(Vec3d) is 32 |
在 C++17 下,结果为:
1 | sizeof(Vec3d) is 32 |
在两个结果中,栈上对象的地址均为 32 字节对齐,符合预期。然而,对于堆上对象,C++11/14 不保证其内存地址一定遵循类型的对齐要求。
但在 C++17 中,新标准保证了 new 出来的那个对象也是对齐的。这是如何做到的呢?
原来,C++17 增加了 operator new
的重载函数,带有一个新的
std::align_val_t
类型的入参:
1 | void* operator new(std::size_t count, std::align_val_t al); |
那么假设现在有如下两条语句,编译器如何决定选择哪个重载函数呢?
1 | auto p = new int{}; |
如前所述,编译器默认采用 16 字节对齐(64 位机器)。在 C++17 中,我们可以通过新增的预定义宏来查看默认值:
1 | __STDCPP_DEFAULT_NEW_ALIGNMENT__ |
当你申请内存的对齐要求大于此默认值时,编译器就会使用带有对齐参数的
operator new
函数,其内部实际调用的正是前文介绍的
aligned_alloc
之类的内存分配函数。
上面举的例子都是按类型的 alignas
标识符所指定的值进行对齐。事实上,你甚至还可以在调用 new
的时候动态指定对齐,只不过在释放内存时需要手动析构,然后显式调用对应的
operator delete
函数。
1 | auto pAlignedType = new (std::align_val_t{32}) MyType; |
将一切交给编译器吧
动态内存分配并不仅限于上述手动调用 malloc
或
new
的情况,很多时候还存在于我们看不到的地方,例如
STL、库函数等。能否确保在所有这些情况下,内存分配都能按照我们指定的要求自动对齐呢?
答案是,只要在 C++17 下使用足够高版本的编译器(如 GCC>=7, clang>=5, MSVC>=19.12),那么所有内存对齐相关的细节都可以放心地交给编译器。
举个例子,考虑这段使用了前文定义的 Vec3d
(32
字节对齐)的代码, vector
内部申请的内存也能确保是正确对齐的。
1 | std::vector<Vec3d> vec; |
此外,现在你也可以直接使用 vector
来存放 SIMD
类型而不必手动分配对齐的内存:
1 | std::vector<__m256> vec(10); |
是时候拥抱 C++17 了!
References
- https://linux.die.net/man/3/memalign
- https://en.cppreference.com/w/c/language/object
- https://en.cppreference.com/w/cpp/memory/new/align_val_t
- https://docs.microsoft.com/en-us/cpp/cpp/align-cpp?view=msvc-170
- https://docs.microsoft.com/en-us/cpp/cpp/alignment-cpp-declarations?view=msvc-170
- https://docs.microsoft.com/en-us/cpp/c-runtime-library/reference/aligned-malloc?view=msvc-170
- https://zhuanlan.zhihu.com/p/30007037
- https://www.cnblogs.com/Arthurian/p/8855117.html
- https://www.cppstories.com/2019/08/newnew-align/
- https://stackoverflow.com/questions/3318410/pragma-pack-effect
- https://www.geeksforgeeks.org/structure-member-alignment-padding-and-data-packing/