60

[不是结构填充和包装的副本。这个问题是关于填充的发生方式和时间。这个是关于如何处理的。]

我刚刚意识到由于 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 Xs 的 3 GB 数组中,1 GB 将是纯填充。

幸运的是,问题的解决方案非常简单——我们只需要double b左右交换int 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++ 下,anint是 4 个字节,adouble是 8 个字节,但这不一定总是正确的(它们的对齐方式也不必相同),所以在不同的环境下,这个“修复”不仅没用,而且通过增加所需的填充量,它还可能使事情变得更糟。

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

澄清

由于误解和困惑,我想强调我不想“打包”我的struct. 也就是说,我不希望它的成员不对齐,从而降低访问速度。相反,我仍然希望所有成员都是自对齐的,但是以一种在填充上使用最少内存的方式。这可以通过使用例如此处和Eric Raymond的 The Lost Art of Packing中描述的手动重新排列来解决。我正在寻找一种自动化且尽可能跨平台的方法来执行此操作,类似于提案 P1112中针对即将推出的 C++20 标准所描述的方法。

4

7 回答 7

37

(不要不加思索地应用这些规则。请参阅 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 和 Intel CPU,请考虑首先放置链表和树左/右指针。当结构起始地址与您正在访问的成员位于不同的 4k 页中时,通过树或链表中的节点进行指针追踪会受到惩罚。将它们放在首位保证不会出现这种情况。

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

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

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

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

  • 那么short/int16_t

  • 那么char// int8_t_bool

  • (对于多个bool标志,特别是如果主要读取或全部修改在一起,请考虑使用 1 位位域打包它们。)

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

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

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

通常,当您的结构成员本身是带有sizeof(x) != alignof(x).

另一个转折是,在某些 ABI(例如,如果我没记错的话,是 32 位 Windows)中,结构成员与其相对于 struct 开头的大小(最多 8 个字节)对齐,即使andalignof(T)仍然只有 4 个。 这是为了优化为单个结构单独分配 8 字节对齐内存的常见情况,而不提供对齐保证。i386 System V对于大多数原始类型也具有相同的功能(但仍然为您提供 8 字节对齐的内存,因为)。但无论如何,i386 System V 没有那个结构打包规则,所以(如果你没有从最大到最小排列你的结构)你可能会得到 8 字节成员相对于结构的开头对齐不足.doubleint64_t
alignof(T) = 4mallocalignof(maxalign_t) = 8


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

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


Eric S. Raymond 写了一篇文章The Lost Art of Structure Packing。具体来说,结构重新排序部分基本上是对这个问题的回答。

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

9. 可读性和缓存局部性

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

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

现代 CPU 上的高速缓存行通常为 32 或 64 字节。(在现代 x86 上,总是 64 字节。Sandybridge 系列在 L2 缓存中有一个相邻行空间预取器,它尝试完成 128 字节的行对,与主要的 L2 流媒体硬件预取模式检测器和 L1d 预取分开)。


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


(@alexis 发布了一个链接到 ESR 文章的仅链接答案,因此感谢您的起点。)

于 2019-06-26T20:11:22.570 回答
32

当填充添加到结构时, 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(对于 some X x;),所以它们不能重新排列。

于 2019-06-25T20:48:08.130 回答
14

在通用案例中确实没有可移植的解决方案。除了标准强加的最低要求,类型可以是实现想要的任何大小。

与此同时,编译器不允许重新排序类成员以提高效率。标准要求对象必须按照它们声明的顺序(通过访问修饰符)进行布局,因此也是如此。

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

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

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

于 2019-06-25T20:50:32.907 回答
5

伙计,如果您有 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)、数组结构

于 2019-06-28T02:06:16.540 回答
4

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

我不想“打包”我的结构。

因为 pragma pack 正是为了以另一种方式进行交易而发明的工具:内存速度。

有没有可靠的跨平台方式

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

速度、内存和跨平台——你只能拥有两个。

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

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

您建议编译器应该对它们进行洗牌,可能会破坏自 1970 年代以来的所有代码。出于什么原因?你能保证每个程序员都会真正想要为每个结构保存 8 个字节吗?一方面,我确信如果我有 3 GB 阵列,我会遇到比 GB 或多或少更大的问题。

于 2019-06-26T09:49:02.573 回答
2

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

实际上,几乎每个普通硬件的普通实现都将使用大小为 2 的幂的原始对象,并且其所需的对齐方式是不大于大小的 2 的幂。此外,几乎每个这样的实现都会将结构的每个成员放置在其对齐的第一个可用倍数处,该对齐完全遵循前一个成员。

一些学究会抱怨利用这种行为的代码是“不可移植的”。我会回复他们

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

作为对该原则的一个小小的扩展,只需要在 90% 的机器上运行的代码能够利用 90% 的机器共有的特性——即使这样的代码不完全是“特定于机器的”——是C 的优势之一。不应该期望 C 程序员为了适应几十年来只在博物馆中使用的架构的限制而向后弯腰的想法应该是不言而喻的,但显然不是。

于 2019-06-26T19:04:23.540 回答
1

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

具体打包只对序列化和互编译器兼容性等有用。

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

于 2019-06-25T20:33:30.977 回答