大量数组和内存不足错误
- 整个程序由数十亿个 Foos 组成;
首先,对于 #2,您可能会发现您自己或您的用户(如果其他人运行该软件)通常无法成功分配该数组(如果它跨越千兆字节)。这里的一个常见错误是认为内存不足错误意味着“没有更多可用内存”,而实际上它们通常意味着操作系统找不到连续集未使用与请求的内存大小匹配的页面。正是由于这个原因,当人们请求分配一个 1 GB 的块时,即使他们有 30 GB 的可用物理内存(例如一旦您开始分配的内存大小超过典型可用内存量的 1%,通常是时候考虑避免使用一个巨大的数组来代表整个事物。
所以也许您需要做的第一件事就是重新考虑数据结构。与分配数十亿个元素的单个数组不同,您通常会通过分配较小的块(聚合在一起的较小数组)来显着降低遇到问题的几率。例如,如果您的访问模式本质上是完全顺序的,您可以使用展开列表(链接在一起的数组)。如果需要随机访问,您可以使用诸如指向数组的指针数组,每个数组跨越 4 KB。这需要更多的工作来索引一个元素,但是对于这种数十亿元素的规模,它通常是必要的。
访问模式
问题中未指定的一件事是内存访问模式。这部分对于指导您的决策至关重要。
例如,数据结构是仅按顺序遍历,还是需要随机访问?所有这些字段:a、b、c、d 是否一直都需要一起使用,或者一次可以访问一两个或三个?
让我们尝试涵盖所有可能性。在我们谈论的规模上,这是:
struct Foo {
int a1;
int b1;
int c1;
int d1
};
... 不太可能有帮助。在这种输入规模下,并且在紧密循环中访问,您的时间通常将由内存层次结构的上层(分页和 CPU 缓存)支配。关注层次结构的最低级别(寄存器和相关指令)不再那么重要。换句话说,在处理数十亿个元素时,您最不应该担心的是将此内存从 L1 高速缓存行移动到寄存器的成本以及按位指令的成本,例如(并不是说这根本不是问题,只是说它的优先级要低得多)。
在一个足够小的规模上,所有热数据都适合 CPU 缓存并且需要随机访问,这种直接的表示可以显示由于层次结构的最低级别(寄存器和说明),但它需要的输入比我们正在讨论的要小得多。
所以即使这样也可能是一个相当大的改进:
struct Foo {
char a1;
char b1;
char c1;
char d1;
};
...还有更多:
// Each field packs 4 values with 2-bits each.
struct Foo {
char a4;
char b4;
char c4;
char d4;
};
* 请注意,您可以在上述情况下使用位域,但位域往往会根据所使用的编译器与它们相关联的警告。由于通常描述的可移植性问题,我经常小心避免它们,尽管在您的情况下这可能是不必要的。然而,当我们冒险进入下面的 SoA 和热/冷场分割领域时,我们将到达一个无论如何都不能使用位域的地步。
此代码还侧重于水平逻辑,这可以开始更容易探索一些进一步的优化路径(例如:将代码转换为使用 SIMD),因为它已经是微型 SoA 形式。
数据“消费”
尤其是在这种规模下,尤其是当您的内存访问本质上是顺序的时,从数据“消耗”的角度思考(机器可以多快加载数据、执行必要的算术和存储结果)。我觉得有用的一个简单的心理形象是把电脑想象成有一个“大嘴巴”。如果我们一次向它提供足够大的一勺数据,而不是很小的茶匙,并且将更多相关数据紧密地打包成一勺连续的数据,它会变得更快。
热/冷场分割
到目前为止,上面的代码假设所有这些字段都同样热(经常访问),并且一起访问。您可能有一些冷字段或仅在关键代码路径中成对访问的字段。假设您很少访问c 和d,或者您的代码有一个访问a 和b 的关键循环,以及另一个访问c 和d 的关键循环。在这种情况下,将其拆分为两个结构会很有帮助:
struct Foo1 {
char a4;
char b4;
};
struct Foo2 {
char c4;
char d4;
};
再次,如果我们正在“输入”计算机数据,并且我们的代码目前只对 a 和 b 字段感兴趣,我们可以将更多内容打包到 a 和 b 字段中,如果我们有连续的块只包含 a 和 b 字段,而不是 c 和 d 字段。在这种情况下,c 和d 字段将是计算机目前无法消化的数据,但它会混合到a 和b 字段之间的内存区域中。如果我们希望计算机尽可能快地使用数据,我们现在应该只为其提供感兴趣的相关数据,因此在这些场景中拆分结构是值得的。
用于顺序访问的 SIMD SoA
转向矢量化并假设顺序访问,计算机可以使用数据的最快速率通常是使用 SIMD 并行处理。在这种情况下,我们最终可能会得到这样的表示:
struct Foo1 {
char* a4n;
char* b4n;
};
... 仔细注意对齐和填充(对于 AVX,大小/对齐应该是 16 或 32 字节的倍数,对于未来的 AVX-512 甚至是 64),这是使用更快对齐移动到 XMM/YMM 寄存器所必需的(并可能在未来使用 AVX 指令)。
用于随机/多字段访问的 AoSoA
不幸的是,如果a 和b 经常一起访问,尤其是在随机访问模式下,上述表示可能会开始失去很多潜在的好处。在这种情况下,更优化的表示可以开始如下所示:
struct Foo1 {
char a4x32[32];
char b4x32[32];
};
...我们现在正在聚合这个结构。这使得 a 和 b 字段不再分散,允许 32 个 a 和 b 字段组适合单个 64 字节高速缓存行并一起快速访问。我们现在还可以将 128 或 256 个 a 或 b 元素放入 XMM/YMM 寄存器中。
分析
通常我会尽量避免在性能问题中提出一般性的建议,但我注意到这个似乎避免了手头有分析器的人通常会提到的细节。因此,如果这有点傲慢,或者如果分析器已经被积极使用,我深表歉意,但我认为这个问题值得这一节。
作为轶事,我经常在优化生产代码方面做得更好(我不应该!)由比我更了解计算机架构的人编写的生产代码(我与很多来自打孔卡时代,可以一眼看懂汇编代码),并且经常被要求优化他们的代码(这感觉很奇怪)。原因很简单:我“作弊”并使用了分析器(VTune)。我的同龄人通常不这样做(他们对此过敏,并认为他们对热点的了解与分析器一样,并认为分析是浪费时间)。
当然,理想的做法是找一个既具备计算机架构专业知识又拥有分析器的人,但缺少其中之一,分析器可以提供更大的优势。优化仍然奖励一种生产力思维方式,这种思维方式取决于最有效的优先级,而最有效的优先级是优化真正最重要的部分。分析器为我们提供了准确的时间和地点的详细细分,以及有用的指标,如缓存未命中和分支错误预测,即使是最先进的人类通常也无法预测接近分析器所揭示的准确度。此外,分析通常是通过追踪热点并研究它们存在的原因来发现计算机体系结构如何以更快的速度工作的关键。对我来说,分析是更好地理解计算机体系结构实际工作方式的最终切入点,而不是我想象中的工作方式。直到那时,像Mysticial 这样在这方面经验丰富的人的著作才开始变得越来越有意义。
界面设计
这里可能开始变得明显的一件事是有许多优化的可能性。这类问题的答案将是关于策略,而不是绝对的方法。在您尝试某些东西之后,事后仍然需要发现很多东西,并且仍然会根据您的需要不断迭代以获得越来越多的最佳解决方案。
在复杂的代码库中,其中一个困难是在界面中留出足够的喘息空间来试验和尝试不同的优化技术,以迭代和迭代以获得更快的解决方案。如果界面为寻求这些优化留出了空间,那么我们可以整天优化,并且即使我们以试错的心态正确地衡量事物,也经常会得到一些惊人的结果。
要经常在实现中留出足够的喘息空间来进行实验和探索更快的技术,通常需要接口设计以批量的形式接受数据。如果接口涉及间接函数调用(例如:通过 dylib 或函数指针)而内联不再是一种有效的可能性,则尤其如此。在这种情况下,在不破坏级联接口的情况下留出优化空间通常意味着设计时要摒弃接收简单标量参数的思维方式,转而将指针传递给整个数据块(如果存在各种交错可能性,可能会大步前进)。因此,尽管这涉及到一个相当广泛的领域,但这里优化的许多首要任务将归结为留出足够的喘息空间来优化实现,而无需在整个代码库中进行级联更改,并且手头有一个分析器来指导您正确的方式。
TL;DR
无论如何,其中一些策略应该可以帮助您正确引导。这里没有绝对的东西,只有指南和要尝试的东西,并且总是最好用手中的分析器完成。然而,在处理如此庞大的数据时,总是值得记住饥饿怪物的形象,以及如何最有效地为它提供这些大小合适且包装好的相关数据。