我如何组织结构中的成员以在对齐上浪费最少 space?

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

[不是 Structure padding and packing 的副本。这个问题是关于如何以及何时发生填充的。这是关于如何处理它的。]

我刚刚意识到由于 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 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/class 成员以减少填充)?

澄清

由于误解和困惑,我想强调一下我不想“打包”我的struct。也就是说,我不希望其成员未对齐,因此访问速度较慢。相反,我仍然希望所有成员都是自对齐的,但是以一种在填充上使用最少内存的方式。这可以通过使用例如此处和 The Lost Art of Packing by Eric Raymond. I am looking for an automated and as much cross-platform as possible way to do this, similar to what is described in proposal P1112 中针对即将到来的 C++20 标准所述的手动重新排列来解决。

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

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

正如 NathanOliver 正确添加的那样,这甚至可能在 some platforms 上失败。

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)。它保证,因为这是一个只有 public 成员的简单结构,&x.a < &x.c(对于某些 X x;),所以它们不能被重新排列。

在一般情况下确实没有可移植的解决方案。除了标准强加的最低要求外,类型可以是实现想要制作的任何大小。

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

您可以使用像

这样的固定宽度类型
struct foo
{
    int64_t a;
    int16_t b;
    int8_t c;
    int8_t d;
};

这在所有平台上都是一样的,前提是它们提供这些类型,但它只适用于整数类型。没有 fixed-width 浮点类型,许多标准 objects/containers 在不同的平台上可以有不同的大小。

这是教科书memory-vs-speed的问题。填充是用内存换取速度。你不能说:

I don't want to "pack" my struct.

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

Is there a reliable cross-platform way

不,不可能有。对齐是严格的 platform-dependent 问题。不同类型的大小是一个 platform-dependent 问题。通过重组避免填充是 platform-dependent 的平方。

速度、内存和 cross-platform - 你只能有两个。

Why doesn't the compiler perform such optimizations (swap struct/class members around to decrease padding)?

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

您提议编译器应该将它们打乱,这可能会破坏自 1970 年代以来的所有代码。为什么?你能保证每个程序员实际上都想为每个结构保存 8 个字节吗?一方面,我确信如果我有 3 GB 阵列,我遇到的问题或多或少会比 1 GB 更大。

尽管标准授予实现广泛的自由裁量权,可以在结构成员之间插入任意数量的 space,这是因为作者不想尝试猜测填充可能有用的所有情况,以及原理"don't waste space for no reason" 被认为是 self-evident。

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

一些学究会抱怨利用该行为的代码是 "non-portable"。对他们我会回复

C code can be non-portable. Although it strove to give programmers the opportunity to write truly portable programs, the C89 Committee did not want to force programmers into writing portably, to preclude the use of C as a “high-level assembler”: the ability to write machine specific code is one of the strengths of C.

作为对该原则的轻微扩展,只需要 运行 在 90% 的机器上使用这 90% 的机器共有的功能的代码的能力——即使这样的代码不会完全是 "machine-specific"-- 是 C 语言的优势之一。不应指望 C 程序员竭尽全力以适应几十年来仅在博物馆中使用的体系结构的局限性的观点应该是 self-evident,但显然不是。

(不要不假思索地应用这些规则。请参阅 ESR 关于您一起使用的成员的缓存位置的观点。在 multi-threaded 程序中,当心由不同线程编写的成员的虚假共享。通常您不会由于这个原因,根本不需要 per-thread 数据在单个结构中,除非你这样做是为了控制与大 alignas(128) 的分离。这适用于 atomic 和 non-atomic vars;重要的是写入缓存行的线程,不管它们是如何写的。)


经验法则:从大到小 alignof()。没有任何事情可以在任何地方都是完美的,但目前为止最常见的情况是针对普通 32 位或 64 位 CPU 的理智的“普通”C++ 实现。所有基本类型都有 power-of-2 大小。

大多数类型的 alignof(T) = sizeof(T)alignof(T) 上限为实现的寄存器宽度。所以较大的类型通常 more-aligned 比较小的类型。

大多数 ABI 中的

Struct-packing 规则赋予结构成员相对于结构开头的绝对 alignof(T) 对齐,并且结构本身继承其任何成员中最大的 alignof() .

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

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

    如果您关心 x86 和 Intel CPUs,请考虑将 linked-list 和树 left/right 指针放在首位。 Pointer-chasing 通过树或链表中的节点 has penalties when the struct start address is in a different 4k page than the member you're accessing。将它们放在首位保证不会出现这种情况。

  • 然后 long(有时指针是 64 位时也是 32 位的,在像 Windows x64 这样的 LLP64 ABI 中)。但是保证至少和int.

    一样宽
  • 然后是 32 位 int32_tintfloatenum。 (可选地将 int32_tfloat 放在 int 之前,如果你关心可能的 8 / 16 位系统仍然将这些类型填充到 32 位,或者在自然对齐的情况下做得更好。大多数此类系统没有更宽的负载(FPU 或 SIMD),因此无论如何必须始终将更宽的类型作为多个单独的块来处理。

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

    如有必要,人们可以针对异国平台调整您的代码。 如果某个结构布局是 perf-critical,或许可以在 [=] 中评论您的假设和推理185=].

  • 然后short/int16_t

  • 然后 char / int8_t / bool

  • (对于多个bool标志,特别是如果read-mostly或者它们一起修改,考虑用1位位域打包。)

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

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

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

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

的聚合(结构或联合)时,它会变得更加棘手

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


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

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


Eric S. Raymond 写了一篇文章 The Lost Art of Structure Packing. Specifically the section on Structure reordering 基本上是对这个问题的回答。

他还有一个重要的观点:

9. Readability and cache locality

While reordering by size is the simplest way to eliminate slop, it’s not necessarily the right thing. There are two more issues: readability and cache locality.

在一个 large 结构中,可以很容易地跨 cache-line 边界拆分,如果它们总是一起使用,将它们放在附近是有意义的。或者甚至连续允许 load/store 合并,例如使用一个(未对齐的)整数或 SIMD load/store 复制 8 或 16 个字节,而不是单独加载较小的成员。

缓存行在现代 CPU 上通常为 32 或 64 字节。 (在现代 x86 上,总是 64 字节。并且 Sandybridge-family 在 L2 缓存中有一个 adjacent-line 空间预取器,它试图完成 128 字节的行对,与主 L2 流媒体 HW 预取模式检测器和 L1d 分开预取)。


有趣的事实:Rust 允许编译器重新排序结构以更好地打包或其他原因。不过,IDK 如果有编译器真的这样做的话。如果您希望选择基于结构的实际使用方式,则可能只有 link-time whole-program 优化才有可能。否则 separately-compiled 部分程序无法就布局达成一致。


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

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

可以使用 'struct of arrays' 而不是 'array of struct'。 所以说

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 编程。


Array of Structures (AoS), Structure of Arrays