C++ 中的内存对齐——实践篇

本文为《C++ 中的内存对齐》系列之下篇,上篇介绍内存对齐的理论基础,建议优先食用~

TL;DR

  • 编译器可能会在结构体中填充字节,以满足所有成员的对齐要求;
  • 可以通过预处理指令 #pragma packalignas 标识符自定义内存对齐;
  • 对于栈上及静态变量,编译器保证遵循其类型的对齐要求;
  • 对于堆上构造的对象,只有在 C++17 后才能保证任何情况下动态申请的内存都满足对齐要求。

通过上一篇文章我们已经了解到,访问未对齐的内存轻则导致性能损失,重则引发 CPU 异常,甚至静默地访问错误的地址,导致数据错误。虽然近几年来有些 CPU 已经支持访问未对齐内存且不会带来性能影响,但考虑到程序的可移植性,在实践中还是应当尽量避免访问未对齐内存的行为。

所幸,C++ 的内存对齐机制已经自动为我们屏蔽了这些底层细节,在绝大部分情况下不必担心访问到未对齐的内存。然而在开发过程中,有时依然需要手动控制内存对齐的细节,或是为了追求更高的性能,抑或是源于某些外部硬件或嵌入式系统的特殊要求。因此作为程序员,有必要了解 C++ 究竟默默在背后为我们做了什么,以及在特定情况下我们能做什么来改变其默认的行为。

栈上及静态变量对齐

对齐要求

每一个完整的对象类型都有一个叫做对齐要求(alignment requirement)的属性,也称为对齐值。它是一个 size_t 类型的整数值,表示可以分配此类对象的连续地址之间的字节数。换句话说,对象的起始地址必须是其类型的对齐值的整数倍。有效的对齐值是 2 的非负整数幂。我们可以通过 _Alignofalignof 获得某个类型的对齐值。

然而编译器是如何计算对齐值的呢?这里先介绍几个概念:

  • 基本类型的自身对齐值:基本类型自身所占的空间大小,即 sizeof()
  • 结构体或类的自身对齐值:其所有成员中的最大对齐值
  • 指定对齐值:使用 #pragma pack(n) 时指定的对齐值 n (1,2,4,8,16...)
  • 有效对齐值自身对其值指定对其值中的较小者,实际对齐时取该值

结构体的对齐规则

为了让结构体(或类,下同)满足所有成员的对齐要求,编译器需要在某些成员之后插入填充(Padding)。

基于前述对齐值的概念,结构体的对齐规则如下:

  1. 结构体变量的起始地址能够被其有效对齐值整除;
  2. 每个成员相对于结构体首地址的偏移都能被有效对齐值整除,如不能则在前一个成员后填充字节;
  3. 结构体的总大小为有效对齐值的整数倍,如不能则在最后面填充字节。

例如,对于如下结构体 struct x_

1
2
3
4
5
6
7
struct x_
{
char a; // 1 byte
int b; // 4 bytes
short c; // 2 bytes
char d; // 1 byte
} bar[3];

编译器通过 padding 使得该结构体的实际内存布局如下:

1
2
3
4
5
6
7
8
9
struct x_
{
char a; // 1 byte
char _pad0[3]; // padding to put 'b' on 4-byte boundary
int b; // 4 bytes
short c; // 2 bytes
char d; // 1 byte
char _pad1[1]; // padding to make sizeof(x_) multiple of 4
} bar[3];

两种声明得到的 sizeof(x_) 值均为 12 bytes,同时 alignof(x_) 值均为 4 bytes。

自定义内存对齐

前文提到,我们可以使用 #pragma pack (n) 设定一个全局的对齐值上限,该功能经常被用于告诉编译器不要对结构体成员进行对齐。例如当 n = 1 时,编译器不做任何 Padding,结构体中的成员紧密地排列在一起,此时整个结构体的大小等于所有成员大小之和。当你在使用硬件内存映射接口、需要准确控制各个成员所在位置时,很可能会用到它,但这往往以牺牲访存速度作为代价。

然而,一枚硬币有两面。从另一个角度来看,自定义内存对齐有时也能起到提升性能的作用。

alignas 类型说明符是一种可移植的 C++ 标准方法,用于指定变量和自定义类型的对齐方式,可以在定义 class、struct、union 或声明变量时使用。如果遇到多个 alignas 说明符,编译器会选择最严格的那个(最大对齐值)。

1
2
3
4
5
6
7
8
9
10
11
12
struct alignas(16) Bar
{
int i; // 4 bytes
int n; // 4 bytes
alignas(4) char arr[3];
short s; // 2 bytes
};

int main()
{
std::cout << alignof(Bar) << std::endl; // output: 16
}

内存对齐可以使处理器更好地利用 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
2
struct alignas(64) S { int a; };
S array[10];

其次,编译器不保证拷贝后的数据依然保留原来的对齐属性。例如,memcpy 可以拷贝一个带有 alignas 的结构到任何地址。

同样,你也不能对函数参数指定对齐。当你在堆栈上按值传递具有对齐属性的数据时,其对齐方式由调用过程控制。如果数据对齐在被调用函数中很重要,可以在使用前将参数复制到正确对齐的内存中。

最后,对一个类型指定对齐属性,仅意味着其所有的静态和栈上对象按指定值进行对齐,对于堆上构造的对象则未必。因此,一般的分配器(如 mallocoperator 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class alignas(32) Vec3d { 
double x, y, z;
};

int main() {
std::cout << "sizeof(Vec3d) is " << sizeof(Vec3d) << '\n';
std::cout << "alignof(Vec3d) is " << alignof(Vec3d) << '\n';

auto Vec = Vec3d{};
auto pVec = new Vec3d[10];

if (reinterpret_cast<uintptr_t>(&Vec) % alignof(Vec3d) == 0)
std::cout << "Vec is aligned to alignof(Vec3d)!\n";
else
std::cout << "Vec is not aligned to alignof(Vec3d)!\n";

if (reinterpret_cast<uintptr_t>(pVec) % alignof(Vec3d) == 0)
std::cout << "pVec is aligned to alignof(Vec3d)!\n";
else
std::cout << "pVec is not aligned to alignof(Vec3d)!\n";

delete[] pVec;
}

在 C++11/14 下,结果为:

1
2
3
4
sizeof(Vec3d) is 32
alignof(Vec3d) is 32
Vec is aligned to alignof(Vec3d)!
pVec is not aligned to alignof(Vec3d)!

在 C++17 下,结果为:

1
2
3
4
sizeof(Vec3d) is 32
alignof(Vec3d) is 32
Vec is aligned to alignof(Vec3d)!
pVec is aligned to alignof(Vec3d)!

在两个结果中,栈上对象的地址均为 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
2
auto p = new int{};
auto pVec = new Vec3{};

如前所述,编译器默认采用 16 字节对齐(64 位机器)。在 C++17 中,我们可以通过新增的预定义宏来查看默认值:

1
__STDCPP_DEFAULT_NEW_ALIGNMENT__

当你申请内存的对齐要求大于此默认值时,编译器就会使用带有对齐参数的 operator new 函数,其内部实际调用的正是前文介绍的 aligned_alloc 之类的内存分配函数。

上面举的例子都是按类型的 alignas 标识符所指定的值进行对齐。事实上,你甚至还可以在调用 new 的时候动态指定对齐,只不过在释放内存时需要手动析构,然后显式调用对应的 operator delete 函数。

1
2
3
auto pAlignedType = new (std::align_val_t{32}) MyType;
pAlignedType->~MyType();
::operator delete(pAlignedType, std::align_val_t{32});

将一切交给编译器吧

动态内存分配并不仅限于上述手动调用 mallocnew 的情况,很多时候还存在于我们看不到的地方,例如 STL、库函数等。能否确保在所有这些情况下,内存分配都能按照我们指定的要求自动对齐呢?

答案是,只要在 C++17 下使用足够高版本的编译器(如 GCC>=7, clang>=5, MSVC>=19.12),那么所有内存对齐相关的细节都可以放心地交给编译器。

举个例子,考虑这段使用了前文定义的 Vec3d(32 字节对齐)的代码, vector 内部申请的内存也能确保是正确对齐的。

1
2
3
4
5
std::vector<Vec3d> vec;
vec.push_back({});
vec.push_back({});
vec.push_back({});
assert(reinterpret_cast<uintptr_t>(vec.data()) % alignof(Vec3d) == 0);

此外,现在你也可以直接使用 vector 来存放 SIMD 类型而不必手动分配对齐的内存:

1
2
3
std::vector<__m256> vec(10);
vec.push_back(_mm256_set_ps(0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f, 0.7f, 0.8f));
assert(reinterpret_cast<uintptr_t>(vec.data()) % alignof(__m256) == 0);

是时候拥抱 C++17 了!

References