C ++:优化成员variables的顺序?

当时我正在阅读一篇游戏编码器的博客文章 ,他正在忙着榨取每一个CPU的代码。 他提到的一个诀窍就是

“将一个类的成员variables重新sorting为最常用和最less使用”

我不熟悉C ++,也不熟悉它如何编译,但我想知道是否

  1. 这个说法是准确的?
  2. 如何/为什么?
  3. 它是否适用于其他(编译/脚本)语言?

我知道这个技巧所节省的(CPU)时间是很less的,这不是一个破坏交易的方法。 但另一方面,在大多数函数中,确定哪些variables将是最常用的,并且只是默认开始编码就很容易。

这里有两个问题:

  • 是否和何时保持某些领域是一个优化。
  • 如何做到这一点。

可能有帮助的原因是,内存以称为“caching行”的块加载到CPUcaching中。 这需要时间,一般来说,为你的对象加载的caching行越多,所需的时间越长。 此外,其他更多的东西从caching中抛出,以腾出空间,以不可预知的方式减慢其他代码的速度。

caching行的大小取决于处理器。 如果它与对象的大小相比很大,那么很less有对象跨越caching行边界,所以整个优化是非常不相关的。 否则,有时候只有部分对象放在caching中,其余的放在主内存中(也许是L2caching)。 如果您最常用的操作(访问常用字段的操作)尽可能less地使用对象,那么这是一件好事,因此将这些字段组合在一起会给您带来更好的机会。

一般原则被称为“参考地点”。 不同的内存地址越接近,你的程序访问越好,获得良好caching行为的机会就越好。 事先预测性能常常是困难的:同一架构的不同处理器模型可能会有不同的performance,multithreading意味着您经常不知道caching中将会发生什么,等等。但是可以谈论可能发生的事情, 大多数时候。 如果你想知道什么,你通常需要测量它。

请注意,这里有一些陷阱。 如果您使用的是基于CPU的primefaces操作(C ++ 0x中的primefacestypes通常会这样),则可能会发现CPUlocking整个caching行以locking该字段。 然后,如果有几个primefaces字段紧靠在一起,并且不同的线程在不同的核心上运行,同时在不同的字段上运行,则会发现所有这些primefaces操作都被序列化,因为它们都locking了相同的内存位置,重新操作在不同的领域。 如果他们在不同的caching行上运行,那么他们就可以并行工作,运行得更快。 事实上,正如Glen(通过Herb Sutter)在他的回答中指出的那样,在连贯caching体系结构中,即使没有primefaces操作,也会发生这种情况,并且会彻底毁掉你的一天。 因此,即使共享caching,涉及多个内核的参考地点也不一定是好事。 你可以期待它,因为caching错过通常是失去了速度的来源,但在你的具体情况是可怕的错误。

现在,除了区分常用和较less使用的字段外,对象越小,占用的内存越less(因此caching越less)。 这是一个非常好的消息,至less在你没有争议的地方。 对象的大小取决于其中的字段以及必须在字段之间插入的任何填充,以确保它们正确alignment架构。 C ++(有时)会根据它们声明的顺序来限制哪些字段必须出现在对象中。 这是为了使低级编程更容易。 所以,如果你的对象包含:

  • 一个int(4个字节,4个alignment)
  • 后跟一个字符(1个字节,任何alignment)
  • 后跟一个int(4字节,4alignment)
  • 后跟一个字符(1个字节,任何alignment)

那么很可能会占用内存中的16个字节。 顺便说一句,int在每个平台上的大小和alignment方式都不一样,但是4很常见,这只是一个例子。

在这种情况下,编译器将在第二个int之前插入3个填充字节,以便正确alignment,并在结尾填充3个字节。 对象的大小必须是其alignment的倍数,以便相同types的对象可以放置在内存中。 这是一个数组是在C / C + +,内存中的相邻对象。 如果结构是int,int,char,char,那么同一个对象可能是12个字节,因为char没有alignment要求。

我说int是否是4alignment是依赖于平台的:在ARM上它是绝对必须的,因为不alignment的访问会引发硬件exception。 在x86上,您可以访问未alignment的整数,但通常速度较慢,而且IIRC非primefaces。 所以编译器通常(总是)在x86上alignment整数。

编写代码时的经验法则,如果你关心打包,看看结构的每个成员的alignment要求。 然后,先排列最大排列的types,然后排列最小的排列,依此类推,直到没有排列要求的成员。 例如,如果我想写可移植的代码,我可能会想出这个:

struct some_stuff { double d; // I expect double is 64bit IEEE, it might not be uint64_t l; // 8 bytes, could be 8-aligned or 4-aligned, I don't know uint32_t i; // 4 bytes, usually 4-aligned int32_t j; // same short s; // usually 2 bytes, could be 2-aligned or unaligned, I don't know char c[4]; // array 4 chars, 4 bytes big but "never" needs 4-alignment char d; // 1 byte, any alignment }; 

如果您不知道字段的alignment方式,或者您正在编写可移植代码,但是希望尽可能做到最好,那么您可以假设alignment要求是结构中任何基本types的最大要求,基本types的alignment要求就是它们的大小。 所以,如果你的结构包含一个uint64_t或者long long,那么最好的猜测是它是8alignment的。 有时候你会错的,但是你很多时候都是对的。

请注意,像博客这样的游戏程序员经常知道他们的处理器和硬件的一切,因此他们不必猜测。 他们知道caching行大小,他们知道每种types的大小和alignment方式,他们知道他们的编译器使用的结构布局规则(对于POD和非PODtypes)。 如果他们支持多个平台,那么他们可以在必要时为每个平台提供特殊情况。 他们也花了很多时间去思考游戏中的哪些对象会从性能改进中受益,并使用分析器来找出真正的瓶颈所在。 但是,即使如此,根据所应用的一些经验法则,无论对象是否需要,都不是一个好主意。 只要不使代码不清楚,“在对象开始时放置常用字段”和“按alignment要求sorting”是两条很好的规则。

根据您正在运行的程序的types,这个build议可能会导致性能提高,或者可能会大大降低速度。

在multithreading程序中这样做意味着你将增加“虚假分享”的机会。

在这里查看有关这个主题的香草香精文章

我已经说过了,我会一直说。 获得真正性能提升的唯一真正方法是测量代码,并使用工具来确定真正的瓶颈,而不是随意更改代码库中的内容。

这是优化工作集大小的方法之一。 John Robbins有一篇关于如何通过优化工作集大小来加速应用程序性能的好文章 。 当然,这需要仔细select最终用户可能使用该应用程序执行的最常见的用例。

我们对这里的成员略有不同的指导原则(ARM体系结构目标,主要是因为各种原因,THUMB 16位代码):

  • 按照alignment要求分组(或者,对于新手来说,“按分组大小”通常是这样的)
  • 最小的第一

“按组排列”有些明显,并且超出了这个问题的范围; 它避免了填充,使用更less的内存等。

第二个项目符号源自THUMB LDRB(载入寄存器字节),LDRH(载入寄存器半字)和LDR(载入寄存器)指令的小5位“立即”字段大小。

5位表示可以编码0-31的偏移量。 实际上,假设“这个”在寄存器(通常是)中是方便的:

  • 如果8位字节存在于+ 0到+ 31之间,则可以在一条指令中加载
  • 如果在这个+ 0到这个+ 62中存在16位半字;
  • 如果在这个+ 0到这个+ 124之间存在32位机器字。

如果它们超出这个范围,则必须生成多条指令:要么是一系列带有立即数的ADD,要在寄存器中累加适当的地址,或者更糟糕的是,在函数结束时从文字池中加载。

如果我们击中了文字池,它会伤害:文字池通过d-cache,而不是i-cache; 这意味着至less为第一个文字池访问从主内存加载的caching线值,然后如果文字池没有在其自己的caching上启动,则在d-cache和i-cache之间存在潜在的驱逐和失效问题行(即如果实际的代码没有在一个caching行结束时结束)。

(如果我对我们正在编译的编译器有几点希望,强制文字池在cacheline边界上启动的方法就是其中之一。

(无关地,我们为避免文字池使用而做的事情之一就是将所有的“全局variables”保留在一个表中,这意味着一个“GlobalTable”的文字池查找,而不是每个全局查询。真的很聪明,你可能能够保持你的GlobalTable的某种内存,可以访问,而无需加载文字池入口 – 是它.sbss?)

尽pipe提高数据访问高速caching行为的参考地址通常是一个相关的考虑因素,但是在需要优化的时候还有其他一些原因需要优化,特别是在embedded式系统中,尽pipe许多embedded式系统所使用的CPU甚至没有一个caching。

– 结构中的字段的内存alignment

alignment方面的考虑很多程序员都很了解,所以我不会在这里详细讨论。

在大多数CPU体系结构中,结构中的字段必须以本地alignment方式访问以提高效率。 这意味着如果混合了各种大小的字段,编译器必须在字段之间添加填充以保持alignment要求正确。 因此,为了优化结构所使用的内存,重要的是要牢记这一点,并且布置这些字段,使得最大的字段后跟较小的字段,以将所需的填充保持为最小。 如果一个结构被“打包”以防止填充,访问未alignment字段的运行成本很高,因为编译器必须使用对字段的较小部分的一系列访问来访问未alignment字段,以及移位和掩码来组装字段在寄存器中的值。

– 结构中常用字段的偏移

在许多embedded式系统中,另一个重要的考虑是在结构的开始处经常访问字段。

一些体系结构在指令中有一定数量的位可用于对指针访问进行偏移编码,所以如果访问的偏移量超过了该位数的字段,编译器将不得不使用多条指令来形成指向该字段的指针。 例如,ARM的Thumb体系结构有5位来编码偏移量,所以只有在字段距离开始的124字节内时,它才能访问单个指令中的字大小的字段。 所以如果你有一个大的结构,一个embedded式工程师可能要记住的优化就是把常用的字段放在结构布局的开始。

那么第一个成员不需要添加到指针的偏移量来访问它。

在C#中,成员的顺序是由编译器决定的,除非你把属性[LayoutKind.Sequential / Explicit]强制编译器以你告诉它的方式布局结构/类。

据我所知,编译器似乎最大限度地减less了包装,同时按照自然顺序(即4个字节的int开始于4个字节的地址)alignment数据types。

理论上,如果你有大的物体,它可以减lesscaching丢失。 但是将相同大小的成员组合在一起通常会更好,因此您的内存包装更紧密。

我专注于性能,执行速度,而不是内存使用。 没有任何优化开关的编译器将使用与代码中相同的声明顺序来映射variables存储区域。 想像

  unsigned char a; unsigned char b; long c; 

大混乱? 无需alignment开关,低内存操作。 等,我们将有一个无符号的字符在你的DDR3 DIMM上使用一个64位的字,另一个64位的字,但不可避免的一个长期。

所以,这是每个variables的提取。

但是,打包或重新sorting会导致一次读取和一次“和”掩码能够使用无符号字符。

因此,在目前的64位字内存机器上,速度方面,alignment,重新sorting等是没有问题的。 我做微控制器的东西,那里打包/非打包的差异是真正引人注目的(谈论<10MIPS处理器,8位字存储器)

另一方面,众所周知,除了一个好的algorithm指示你做什么之外,调整性能代码所需要的工程努力以及编译器能够优化的东西通常会导致没有真正效果的橡胶燃烧。 这是一个只写一块语法上的dubius代码。

在我看到的最后一步优化中,我看到(在uP中,不要认为它适用于PC应用程序)是将程序作为单个模块编译,使编译器优化它(速度/指针分辨率/内存打包等),并具有链接器垃圾桶所谓的库函数,方法等。

嗯,这听起来像一个非常可疑的做法,编译器为什么不照顾这个?

我非常怀疑这会对CPU的改进产生什么影响 – 可能是可读性。 如果在给定帧内执行的通用执行的基本块在同一组页面中,则可以优化可执行代码。 这是相同的想法,但不知道如何在代码中创build基本块。 我的猜测是,编译器将函数按照它看到的顺序放在这里,而没有进行优化,所以你可以试着把通用的function放在一起。

尝试并运行一个分析器/优化器。 首先你编译一些性能分析选项,然后运行你的程序。 一旦configuration文件的exe完成,它将转储一些configuration文件的信息。 把这个转储并通过优化器作为input运行。

我已经离开了这个工作多年,但没有太多改变他们的工作方式。