如何在结构中组织成员以在对齐上浪费最少的空间

How do I organize members in a struct to waste the least space on alignment?

本文关键字:空间 对齐 结构 成员      更新时间:2023-10-16

[不是结构填充和打包的副本。这个问题是关于填充如何以及何时发生。这是关于如何处理它。

我刚刚意识到由于对齐C++而浪费了多少内存。考虑以下简单示例:

struct X
{
int a;
double b;
int c;
};
int main()
{
cout << "sizeof(int) = "                      << sizeof(int)                      << 'n';
cout << "sizeof(double) = "                   << sizeof(double)                   << 'n';
cout << "2 * sizeof(int) + sizeof(double) = " << 2 * sizeof(int) + sizeof(double) << 'n';
cout << "but sizeof(X) = "                    << sizeof(X)                        << 'n';
}

使用 g++ 时,程序给出以下输出:

sizeof(int) = 4
sizeof(double) = 8
2 * sizeof(int) + sizeof(double) = 16
but sizeof(X) = 24

这是 50% 的内存开销!在 134'217'728 的 3 GB 数组中,X1 GB 将是纯填充。

幸运的是,问题的解决方案非常简单 - 我们只需要交换double bint c

struct X
{
int a;
int c;
double b;
};

现在结果更令人满意:

sizeof(int) = 4
sizeof(double) = 8
2 * sizeof(int) + sizeof(double) = 16
but sizeof(X) = 16

但是有一个问题:这不是交叉兼容的。是的,在 g++ 下,int是 4 个字节,double是 8 个字节,但这不一定总是正确的(它们的对齐方式也不必相同),所以在不同的环境中,这个"修复"不仅可能毫无用处,而且还可能通过增加所需的填充量使事情变得更糟。

有没有可靠的跨平台方法来解决这个问题(最大限度地减少所需的填充量,而不会因未对齐而导致性能下降)?为什么编译器不执行此类优化(交换结构/类成员以减少填充)?

澄清

由于误解和困惑,我想强调一下,我不想"打包"我的struct。也就是说,我不希望其成员不对齐,从而降低访问速度。相反,我仍然希望所有成员都自对齐,但以一种在填充上使用最少内存的方式。例如,这可以通过使用手动重新排列来解决,如这里和埃里克·雷蒙德(Eric Raymond)的《失落的包装艺术》中所述。我正在寻找一种自动化且尽可能多的跨平台方法来做到这一点,类似于即将推出的 C++20 标准的提案 P1112 中描述的内容。

(不要不假思索地应用这些规则。 请参阅 ESR 关于一起使用的成员的缓存位置的观点。 在多线程程序中,请注意由不同线程编写的成员的错误共享。 通常,出于这个原因,您根本不希望在单个结构中包含每个线程的数据,除非您这样做是为了使用大alignas(128)来控制分离。 这适用于atomic和非原子变量;重要的是线程写入缓存行,无论它们如何执行此操作。

<小时 />

经验法则:从大到小alignof()。 没有什么可以在任何地方都做到完美,但到目前为止,这些天最常见的情况是正常的 32 位或 64 位 CPU 的理智"正常"C++实现。 所有基元类型的大小均为 2 次幂。

大多数类型在实现的寄存器宽度上具有alignof(T) = sizeof(T)alignof(T)上限。 因此,较大的类型通常比较较小的类型更对齐。

大多数 ABI 中的结构打包规则为结构成员提供相对于结构开头的绝对alignof(T)对齐,并且结构本身继承其任何成员的最大alignof()

  • 将始终 64 位成员放在首位(如doublelong longint64_t)。 ISO C++当然不会将这些类型固定为 64 位/8 字节,但实际上在所有 CPU 上,您都关心它们。 将代码移植到外来 CPU 的人可以调整结构布局以在必要时进行优化。

  • 然后是指针和指针宽度整数:size_tintptr_tptrdiff_t(可以是 32 位或 64 位)。 对于具有平面内存模型的 CPU,这些在普通现代C++实现上都是相同的宽度。

    如果您关心 x86 和英特尔 CPU,请考虑将链接列表和树左/右指针放在第一位。 当结构开始地址与您正在访问的成员位于不同的 4k 页面中时,对树或链表中的节点进行指针追踪会受到惩罚。 把他们放在第一位保证了情况并非如此。

  • 然后long(有时是 32 位,即使指针是 64 位,在 Windows x64 等 LLP64 ABI 中也是如此)。 但它至少保证与int一样宽.

  • 然后是 32 位int32_tintfloatenum。 ((可以选择将floatint32_t分开,如果您关心可能的 8/16 位系统,这些系统仍然将这些类型填充到 32 位,或者将它们自然对齐做得更好,则可以提前int。 大多数此类系统没有更宽的负载(FPU 或 SIMD),因此无论如何,更广泛的类型必须始终作为多个单独的块进行处理)。

    ISO C++允许int窄至 16 位或任意宽,但实际上即使在 32 位 CPU 上,它也是 64 位类型。 ABI设计人员发现,如果int更宽,设计用于32位int的程序只会浪费内存(和缓存占用空间)。 不要做出会导致正确性问题的假设,但对于"便携式性能",您只需要在正常情况下是正确的。

    如有必要,针对异国情调平台调整代码的人可以进行调整。如果某个结构布局对性能至关重要,也许可以在标题中评论您的假设和推理。

  • 然后short/int16_t

  • 然后char/int8_t/bool

  • (对于多个bool标志,尤其是在大部分读取或将它们全部修改在一起的情况下,请考虑使用 1 位位字段打包它们。

(对于无符号整数类型,请在我的列表中找到相应的有符号类型。

如果您愿意,可以更早地使用较窄类型的 8 个字节的倍数数组。 但是,如果您不知道类型的确切大小,则无法保证int i+char buf[4]将填充两个double之间的 8 字节对齐插槽。 但这不是一个糟糕的假设,所以如果有某种原因(例如一起访问的成员的空间位置)将它们放在一起而不是最后,我还是会这样做。

异国情调类型:x86-64 System V 有alignof(long double) = 16,但 i386 System V 只有alignof(long double) = 4个,sizeof(long double) = 12。 它是 x87 80 位类型,实际上是 10 个字节,但填充到 12 或 16,因此它是其对齐方式的倍数,使数组成为可能,而不会违反对齐保证。

一般来说,当您的结构成员本身是具有sizeof(x) != alignof(x)的聚合(结构或联合)时,它会变得更加棘手。

另一个转折是,在某些 ABI(例如,如果我没记错的话,是 32 位 Windows)中,结构成员相对于结构开头的大小(最多 8 个字节)对齐,即使alignof(T)对于double仍然只有 4 个,int64_t.
这是为了针对为单个结构单独分配 8 字节对齐内存的常见情况进行优化, 没有给出对齐保证。 i386 System V 对于大多数基元类型也具有相同的alignof(T) = 4(但malloc仍然为您提供 8 字节对齐的内存,因为alignof(maxalign_t) = 8)。 但无论如何,i386 System V 没有该结构打包规则,因此(如果您不从大到小排列结构)最终可能会得到相对于结构开头对齐不足的 8 字节成员。


大多数 CPU 都具有寻址模式,在寄存器中给定一个指针,允许访问任何字节偏移量。 最大偏移量通常非常大,但在 x86 上,如果字节偏移量适合有符号字节 ([-128 .. +127]),它会节省代码大小。 因此,如果您有任何类型的大型数组,最好将其放在结构中常用成员之后。 即使这需要一点填充。

您的编译器几乎总是会制作在寄存器中具有结构地址的代码,而不是在结构中间具有某个地址的代码,以利用短的负位移。


Eric S. Raymond写了一篇文章《结构包装的失落艺术》。 具体来说,结构重新排序部分基本上是这个问题的答案。

他还提出了另一个重要的观点:

9. 可读性和缓存局部性

虽然按大小重新排序是消除污垢的最简单方法,但它不一定是正确的。还有两个问题:可读性和缓存局部性。

在一个可以轻松地跨缓存行边界拆分的大型结构中,如果它们总是一起使用,那么将它们放在附近是有意义的。 甚至连续以允许加载/存储合并,例如,使用一个(未组合的)整数或 SIMD 加载/存储复制 8 或 16 个字节,而不是单独加载较小的成员。

在现代 CPU 上,缓存行通常为 32 或 64 字节。 (在现代 x86 上,始终为 64 字节。 Sandybridge-family在L2缓存中有一个相邻的行空间预取器,它试图完成128字节的行对,与主要的L2流处理器硬件预取模式检测器和L1d预取分开)。

>有趣的事实:Rust 允许编译器重新排序结构以获得更好的打包或其他原因。 不过,如果有任何编译器真的这样做,IDK。 可能只有当您希望选择基于结构的实际使用方式时,才可能通过链接时全程序优化来实现。 否则,程序的单独编译部分无法就布局达成一致。

(@alexis发布了一个链接到ESR文章的仅链接答案,所以感谢这个起点。

gcc 具有-Wpadded警告,当向结构添加填充时发出警告:

https://godbolt.org/z/iwO5Q3:

<source>:4:12: warning: padding struct to align 'X::b' [-Wpadded]
4 |     double b;
|            ^
<source>:1:8: warning: padding struct size to alignment boundary [-Wpadded]
1 | struct X
|        ^

您可以手动重新排列成员,以便减少/没有填充。但这不是一个跨平台的解决方案,因为不同类型的系统在不同的系统上可以有不同的大小/对齐方式(最值得注意的是,不同架构上的指针是 4 或 8 个字节)。一般的经验法则是在声明成员时从最大对齐到最小对齐,如果您仍然担心,请使用-Wpadded编译一次代码(但我一般不会保持它,因为有时需要填充)。

至于编译器不能自动做的原因是因为标准([class.mem]/19)。它保证,因为这是一个只有公共成员的简单结构,&x.a < &x.c(对于某些X x;),所以它们不能重新排列。

在通用情况下确实没有便携式解决方案。 除了标准施加的最低要求外,类型可以是实现想要的任何大小。

为了配合这一点,编译器不允许对类成员进行重新排序以使其更有效。该标准要求对象必须按其声明的顺序(通过访问修饰符)进行布局,因此也是如此。

您可以使用固定宽度类型,例如

struct foo
{
int64_t a;
int16_t b;
int8_t c;
int8_t d;
};

这在所有平台上都是相同的,前提是它们提供这些类型,但它仅适用于整数类型。 没有固定宽度的浮点类型,许多标准对象/容器在不同的平台上可以有不同的大小。

伙计,如果您有 3GB 的数据,您可能应该通过其他方式解决问题,然后交换数据成员。

可以使用"数组的结构"代替使用">

结构数组",而是使用"数组的结构"。 所以说

struct X
{
int a;
double b;
int c;
};
constexpr size_t ArraySize = 1'000'000;
X my_data[ArraySize];

即将成为

constexpr size_t ArraySize = 1'000'000;
struct X
{
int    a[ArraySize];
double b[ArraySize];
int    c[ArraySize];
};
X my_data;

每个元素仍然很容易访问mydata.a[i] = 5; mydata.b[i] = 1.5f;...
没有填充(数组之间的几个字节除外)。内存布局对缓存友好。预取器处理从几个单独的内存区域读取顺序内存块。

这并不像乍一看那么不正统。这种方法广泛用于 SIMD 和 GPU 编程。


结构数组 (AoS), 数组结构

这是一个教科书式的记忆与速度问题。填充是用内存换取速度。你不能说:

我不想"打包"我的结构。

因为编译指示包正是为了以另一种方式进行这种交易而发明的工具:内存速度。

有没有可靠的跨平台方式

不,不可能有。对齐是严格依赖于平台的问题。不同类型的大小是一个取决于平台的问题。通过重新组织来避免填充是依赖于平台的平方。

速度、内存和跨平台 - 您只能有两个。

为什么编译器不执行此类优化(交换结构/类成员以减少填充)?

因为C++规范特别保证编译器不会弄乱您精心组织的结构。想象一下,您连续有四个浮标。有时按名称使用它们,有时将它们传递给采用 float[3] 参数的方法。

你建议编译器应该打乱它们,这可能会破坏自 1970 年代以来的所有代码。出于什么原因?你能保证每个程序员都想为每个结构节省 8 个字节吗?首先,我确信如果我有 3 GB 的阵列,我遇到的问题或多或少比 GB 大。

尽管该标准赋予实现广泛的自由裁量权,可以在结构成员之间插入任意数量的空间,但这是因为作者不想尝试猜测填充可能有用的所有情况,并且"不要无缘无故浪费空间"的原则被认为是不言而喻的。

在实践中,几乎每个常见硬件的常见实现都将使用基元对象,其大小为 2 的幂,其所需的对齐方式是不大于大小的 2 的幂。 此外,几乎每个这样的实现都将结构的每个成员置于其对齐方式的第一个可用倍数上,该对齐方式完全遵循前一个成员。

一些学究会吱地使用这种行为的代码是"不可移植的"。 我会回复他们

C 代码可以是不可移植的。尽管C89委员会努力让程序员有机会编写真正可移植的程序,但C89委员会并不想强迫程序员使用可移植编写,以排除使用C作为"高级汇编器":编写机器特定代码的能力是C的优势之一。

作为该原则的轻微扩展,只需要在90%的机器上运行的代码就可以利用90%机器的共同功能的能力 - 即使这些代码并不完全是"特定于机器的" - 是C的优势之一。 不应该期望C程序员向后弯曲以适应几十年来仅在博物馆中使用的架构的限制,这种想法应该是不言而喻的,但显然不是。

您可以使用#pragma pack(1)但这样做的原因是编译器进行了优化。通过完整寄存器访问变量比访问最小位更快。

特定打包仅对序列化和编译器间兼容性等有用。

正如NathanOliver正确添加的那样,这甚至可能在某些平台上失败。