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

时间:2019-06-25 20:29:41

标签: c++ optimization memory-alignment memory-layout struct-member-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 X s的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 。也就是说,我不希望其成员不结盟,因此访问速度较慢。取而代之的是,我仍然希望所有成员都是自我对齐的,但是要在填充时使用最少的内存。这可以通过使用例如此处和Eric Raymond在The Lost Art of Packing中所述的手动重排来解决。我正在寻找一种自动化且尽可能多的跨平台方法来实现此目的,类似于即将发布的C ++ 20标准在proposal P1112中所描述的。

7 个答案:

答案 0 :(得分:33)

(不要不加考虑就应用这些规则。请参阅ESR关于您一起使用的成员的缓存位置的观点。在多线程程序中,请提防由不同线程编写的成员的错误共享。通常,您不希望每个-出于这个原因,线程数据根本不在一个单一的结构中,除非您使用大型alignas(128)来控制分离,这适用于atomic和非原子var;重要的是线程编写缓存行,而不管它们如何执行。)


经验法则:从大到小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,请考虑首先放置链表和树的左/右指针。通过树或链接列表has penalties when the struct start address is in a different 4k page than the member you're accessing中的节点进行指针追逐。首先把它们保证是不可能的。

  • 然后是long(在Windows x64之类的LLP64 ABI中,即使指针是64位,有时也为32位)。但这保证至少与int一样宽。

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

    ISO C ++允许int窄至16位,或任意宽,但实际上,即使在64位CPU上,它也是32位类型。 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) = 4sizeof(long double) = 12。这是x87 80位类型,实际上是10个字节,但填充为12或16,因此是其alignof的倍数,从而使数组成为可能而不会违反对齐保证。

通常,当您的结构成员本身是带有sizeof(x) != alignof(x)的聚合(结构或联合)时,会变得更加棘手。

另一种扭曲是,在某些ABI中(例如,如果我没记错的话,是32位Windows),结构成员相对于结构的起始位置对齐其大小(最大为8个字节),甚至尽管alignof(T)double的{​​{1}}仍然只有4。
这是针对单个结构的8字节对齐内存的单独分配的常见情况的优化,而不提供对齐 guarantee 。对于大多数基本类型,i386 System V也具有相同的int64_t(但是alignof(T) = 4仍会为您提供8字节对齐的内存)。但是无论如何,i386 System V没有该结构打包规则,因此(如果您不按从最大到最小的顺序排列结构)您可能最终会相对于结构的开头未对齐的8字节成员


大多数CPU都有寻址模式,给定寄存器中的指针,就可以访问任何字节偏移量。最大偏移量通常很大,但是在x86上,如果字节偏移量适合带符号的字节(malloc),它可以节省代码大小。因此,如果您有各种类型的大型数组,则希望稍后将其放入结构中,该列表位于经常使用的成员之后。即使这样做会花费一些填充时间。

您的编译器几乎总是将具有结构地址的代码存储在寄存器中,而不是在结构中间的地址使用短负位移。


Eric S. Raymond写了一篇文章The Lost Art of Structure Packing。具体来说,Structure reordering上的部分基本上是对该问题的解答。

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

  

9. Readability and cache locality

     

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

在一个结构中,可以很容易地将其拆分成一条高速缓存行边界,如果它们总是一起使用,则可以将两件事放在附近。甚至是连续的以允许加载/存储合并,例如使用一个(不需要的)整数或SIMD加载/存储来复制8或16个字节,而不是分别加载较小的成员。

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


有趣的事实:Rust允许编译器为了更好的打包或其他原因而对结构进行重新排序。但是,如果有任何编译器实际这样做,则使用IDK。如果希望基于结构的实际使用方式进行选择,则只有在链接时整个程序优化中才可能实现。否则,程序的单独编译部分将无法在布局上达成共识。


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

答案 1 :(得分: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(对于某些X x;)的简单结构,所以它们不能重新排列。

答案 2 :(得分:15)

在一般情况下,确实没有便携式解决方案。除非标准规定了最低要求,否则类型可以是实现要使它们达到的任何大小。

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

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

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

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

答案 3 :(得分: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编程。


Array of Structures (AoS), Structure of Arrays

答案 4 :(得分:4)

这是一本教科书的内存与速度问题。填充是为了速度而牺牲内存。你不能说:

  

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

因为pragma pack是完全通过另一种方式进行交易的工具:内存速度。

  

是否有可靠的跨平台方式

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

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

  

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

因为C ++规范明确保证了编译器不会弄乱您精心组织的结构。假设您连续有四个浮子。有时您会按名称使用它们,有时会将它们传递给带有float [3]参数的方法。

您建议编译器应该对其进行改组,可能会破坏1970年代以来的所有代码。出于什么原因?您能保证每个程序员实际上都希望每个结构节省8个字节吗?我要肯定的是,如果我有3 GB阵列,那么我遇到的问题比GB或多或少要大。

答案 5 :(得分:3)

尽管标准允许实施人员在结构成员之间插入任意量的空间,但是这是因为作者不想尝试猜测填充可能有用的所有情况,并且原则是“不要浪费空间”无缘无故”被认为是不言而喻的。

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

一些学徒会窃那些利用代码的行为“不可携带”的代码。我会回信给他们

  

C代码可以是不可移植的。尽管尽力为程序员提供编写真正可移植程序的机会,但C89委员会不想强迫程序员进行可移植的编写,以免将C用作“高级汇编程序”:编写机器特定代码的能力是C的优势之一

作为对该原则的略微扩展,仅需要在90%的机器上运行的代码才能利用该90%的机器所共有的功能-即使此类代码并非完全是“特定于机器的” -是C的优势之一。不应期望C程序员向后弯腰以适应数十年来仅在博物馆中使用过的体系结构的限制这一观点是不言而喻的,但显然不是。

答案 6 :(得分:0)

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

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

正确地添加了NathanOliver,这甚至可能在some platforms上失败。