十亿行挑战 (1BRC) 是Gunnar Morling于2024年1月1日对JAVA社区发起的编程挑战,探讨现代 Java 在处理10 亿行数据的文本文件,能走多远。C++软件工程师西蒙·托特(ŠIMON TÓTH )发起挑战,给出了自己的方案,从66秒优化到0.77秒,并记录整理了性能优化过程,对于C++应用程序的性能优化的过程,极具参考价值,译者觉得受益匪浅,有兴趣的小伙伴也可以自己挑战一下,以下是中文翻译文章,原文链接:
https://simontoth.substack.com/p/daily-bite-of-c-optimizing-code-to
JAVA最快用时1.535秒,排行榜链接:
https://www.morling.dev/blog/1brc-results-are-in/
“十亿行挑战”最初是针对 Java 开发人员的挑战,目标是处理10 亿条数据记录的文本文件,挑战者开发和优化解析器。
虽然最初的挑战是针对 Java 的,但这次挑战是展示 C++ 代码和相关性能工具优化的绝佳机会。
挑战内容
输入
我们的数据输入是一个名为measurements.txt的文本文件,其中包含来自各个测量站的温度测量值。该文件恰好包含 10 亿行,格式如下:
station name;value
station name;value
测量站名称(station name),是一个 UTF-8 字符串,最大长度为 100 字节,包含任意 1 字节或 2 字节字符(名称不能包含“;”或“\n”)。测量值(value)在-99.9至99.9之间,均保留一位小数。文件的数据中,测量站名称的唯一键限制总数为 10,000 个。
输出
输出(到标准输出 stdout)是按字典顺序排序的站点列表,每个站点都有最低、平均和最高测量温度。
{Abha=-23.0/18.0/59.2, Abidjan=-16.2/26.0/67.3, Abéché=-10.0/29.4/69.0, ...}
第一次基线实现
当然,我们第一次实现必须从基线实现开始。我们的程序将分为两个阶段:处理输入和格式化输出。
对于输入,我们解析站名和测量值并将它们存储在 std::unordered_map 中。
为了生成输出,我们整理唯一键的名称,按字典顺序对它们进行排序,然后打印出最小、平均和最大测量值。
我们留下了一个简单的 main 函数,将这两个部分连接在一起。
这个基线实现非常简单,但遗憾的是,它有两个主要问题:它不正确(我们现在将忽略该细节),并且它非常非常慢。我们将跟踪三台机器的性能:
-
Intel 9700K on Fedora 39
-
Intel 14900K on Windows Subsystem for Linux (WSL)
-
Mac Mini M1(8 核)
这些二进制文件在 9700K 上使用 clang 17、在 14900K 上使用 clang 18 以及在 Mac Mini 上使用 Apple clang 15 进行编译。所有三台机器都使用相同的编译标志:
-O3 -march=native -g0 -DNDEBUG -fomit-frame-pointer
由于我们主要关心性能进展,因此测量结果只是相应机器上最快的运行速度。
-
9700K (Fedora 39): 132s
-
14900K (WSL Ubuntu): 67s
-
Mac Mini M1: 113s
消除内存拷贝
对基线实现的第一次更改,我们不需要特殊的工具来进行。高性能代码最重要的规则是:避免过多的内存拷贝。但是目前为止,不知不觉间,我们进行了不少的内存拷贝。
当我们处理单行时(请记住,有 10 亿行),我们将站名称和测量值读取到 std::string 中。这本质上引入了数据的显式内存拷贝,因为当我们从文件中读取时,文件的内容已经在内存的缓冲区中。
为了消除这个问题,我们有几个选择。我们可以切换到无缓冲读取,第一种是手动处理 istream 的缓冲区,第二种采取更系统级的方法,特别是内存映射文件。在本文中,我们将采用后者,内存映射文件的方法。
当我们对文件进行内存映射时,操作系统会为文件的内容分配地址空间;然而,数据仅根据需要读入内存。优点是我们可以把整个文件当成一个字符数组;缺点是我们无法控制文件的哪些部分在内存中可用(依赖操作系统做出正确的决定)。
由于我们使用的是 C++,因此让我们使用 RAII 对象,来封装更底层的系统逻辑吧。
因为我们将内存映射文件包装在 std::span 中,所以我们可以通过简单地将 std::ifstream 替换为 std::ispanstream 来验证一切是否仍然有效。
虽然这验证了一切仍然有效,但它并没有消除任何过多的内存复制。为此,我们必须将输入处理从在 istream 之上的操作,切换为将输入视为一个大的 C 样式字符串。
我们必须调整哈希映射以支持异构查找,因为我们现在使用 std::string_view 查找测量站。这涉及更换std::unordered_map的比较器(comparator),和添加自定义哈希函数。
这次更改后,使得我们的解决方案运行得更快(我们暂时跳过 M1,因为 clang 15 不支持浮点类型的 std::from_chars)。
-
9700K(Fedora 39):47.6 秒(2.8 倍)
-
14900K(WSL Ubuntu):29.4 秒(2.3 倍)
分析情况
为了进一步优化解决方案,我们必须分析解决方案的哪些部分是主要瓶颈。我们需要一个性能分析器。
当谈到性能分析器时,我们必须在精度和低开销之间做出选择。在本文中,我们将使用 perf,这是一个开销极低的 Linux 分析器,但仍然提供合理的精度。
为了有机会记录配置文件,我们必须在二进制文件中注入一些调试信息:
-fno-omit-frame-pointer # do not omit the frame pointer
-ggdb3 # detailed debugging information
为了记录配置文件,我们在 perf 工具下运行二进制文件:
perf record --call-graph dwarf -F999 ./binary
call-graph 选项,允许 perf 使用存储在二进制文件中的调试信息,将低级函数归属到正确的调用者。第二个选项降低 perf 捕获样本的频率;如果频率太高,可能会丢失一些样本。
然后我们可以查看性能分析报告了:
perf report -g 'graph,caller'
不过,如果我们在当前实现方案中运行 perf,只能得到一个信息量较为有限的性能分析报告。
我们可以推断出运行时的最大部分花费在 std::unordered_map 上。然而,其余的操作都在低级函数中丢失了。例如,您可能会得出结论,解析出测量值只需要 3%(std::from_chars 函数);这是一个错误的结论。
该性能分析报告很差,因为我们将所有逻辑放入一个紧密循环中。虽然这对性能有好处,但我们完全失去了以下正在实现的业务逻辑的意义:
-
解析出站名
-
解析出测量值
-
将数据存储在数据库中(译者注:数据库即是std::unordered_map对象,下文涉及到数据库,都是指这一个map对象)
如果我们将这些业务逻辑包装到单独的函数中,性能分析报告的清晰度将大大提高。
现在,我们可以看到,我们花费了 62% 的时间将数据插入数据库,26% 的时间用于解析测量值,5% 的时间用于解析测量站名称。
我们将讨论hash map,但在此之前,让我们先解析这些值。这也将修复我们代码中的一个持续存在的错误(四舍五入错误)。
伪装的整数
输入数据含有 -99.9 到 99.9 范围内的测量值,始终带有一位小数。这意味着我们一开始就不是在处理浮点数;测量值是定点数(fixed-point numbers)。
表示定点值(fixed-point values)的正确方法是作为整数,我们可以手动解析它(目前以简单的方式)。
此更改也会传播到记录的Record结构体。
数据库插入可以保持不变,但我们可以趁机稍微优化一下代码。
最后,我们必须修改输出格式化代码。由于我们现在使用的是定点数,因此我们必须正确地将存储的整数值,转换成四舍五入的浮点数。
此更改修复了上述舍入错误并改进了运行时间(浮点运算速度很慢)。该实现还与 M1 Mac 兼容。
-
9700K(Fedora 39):35.5 秒(3.7 倍)
-
14900K(WSL Ubuntu):23.7 秒(2.8 倍)
-
Mac Mini M1:55.7 秒(2.0 倍)
自定义Hash Map
标准库中的 std::unordered_map 因速度慢而臭名昭著。这是因为它使用节点结构(实际上是节点链表的数组)。我们可以切换到flat map(来自 Abseil 或 Boost)。然而,这违背了 1brc 挑战的初衷,即禁止外部库。
更重要的是,我们的投入非常有限。 1B 记录最多有 10k 个唯一键,从而获得极高的命中率。
由于我们仅限于 10k 个唯一键,因此我们可以使用基于 16 位哈希的线性Hash Map,直接索引静态大小的数组。当遇到冲突时,我们使用下一个可用的slot(两个不同的测量站名称映射到相同的哈希/索引)。
这意味着在最坏的情况下(当所有站映射到相同的哈希/索引时),我们最终会得到线性复杂度查找。然而,这种情况极不可能发生,对于使用 std::hash 的示例输入,我们最终会出现 5M 冲突,即 0.5%。
这一变化获得了相当大的性能提升。
-
9700K(Fedora 39):25.6 秒(5.1 倍)
-
14900K(WSL Ubuntu):18.4 秒(3.6 倍)
-
Mac Mini M1:49.4 秒(2.3 倍)
微调优化
我们已经理清楚了高层次的优化途径,这意味着是时候深入挖掘,并微观优化代码的关键部分了。
让我们回顾一下我们目前的情况。
我们可以在哈希(17%)和整数解析(21%)方面进行一些低层次微观优化。
微观优化的正确工具是基准测试框架。我们将实现目标函数的多个版本,并将结果相互比较。
在本文中,我们将使用 Google Benchmark。
解析整数
当前版本的整数解析(故意)写得不好并且分支过多。
我们无法正确使用宽指令(AVX),因为这些值可能短至三个字符。随着宽指令的出现,消除分支的唯一方法是查找表。
当我们解析这个数字时,我们只有两种可能的情况(忽略符号):
-
我们遇到一个数字:累加器乘以 10,然后加上数字值
-
我们遇到一个非数字:累加器乘以 1 并加 0
我们可以将其编码为二维数组,其中包含 char 类型的所有 256 个值的信息。
我们可以将这两个版本插入我们的微基准测试中并获得非常决定性的结果。
可悲的是,前一句话是谎言。您不能只将这两个版本插入 Google Benchmark。我们的实现是一个(最多)5 个字符的紧密循环,这使得它对布局非常敏感。我们可以使用 LLVM 标志来对齐函数。
-mllvm -align-all-functions=5
然而,即便如此,结果波动也很大(高达 40%)。
Hashing 哈希
在哈希方面,我们有两个优化机会。
目前,我们首先解析出测量站的名称,然后在lookup_slot内计算哈希值。这意味着我们遍历数据两次。
此外,我们计算 64 位哈希值,但只需要 16 位哈希值。
为了缓解整数解析遇到的问题,我们将解析合并为一个步骤,生成站名称的 string_view、16 位哈希值和定点测量值。
我们使用一个简单的公式来计算自定义 16 位哈希,并依赖于无符号溢出而不是模数。
这步骤下来,获得很好的性能提升(兼具合理的稳定性)。
当我们将这一改进融入到我们的解决方案中时,我们获得了整体性能加速。
-
9700K(Fedora 39):19.2 秒(6.87 倍)
-
14900K(WSL Ubuntu):14.1s(4.75x)(有噪音)
-
Mac Mini M1:46.2 秒(2.44 倍)
释放线程
如果我们现在调查性能分析报告,会发现我们已经达到了可行的极限。
除了解析(我们刚刚优化)、slot查找和数据插入之外,剩下的运行时间非常少。很自然,下一步就是并行化我们的代码。
实现此目的的最简单方法是将输入分块为大致相同的块,在单独的线程中处理每个块,然后合并结果。
我们可以扩展 MappedFile 类型以提供分块访问。
然后,我们可以简单地按块运行现有代码,每个代码都在自己的线程中。
这给出了相当好的缩放比例。
以下是最好的结果。请注意,这些只是相对比较的最佳运行效果,而不是严格的基准。
-
9700K (Fedora 39):2.6s (50x)(8 个线程)
-
14900K (WSL Ubuntu):0.89s (75x)(在 32 个线程上)
-
Mac Mini M1:10.2s (11x)(24 线程)
处理不对称的处理速度
9700K 的扩展非常干净,但这是因为该处理器有 8 个相同的内核,不支持超线程。一旦我们转向 14900K,架构就会因性能和效率核心而变得更加复杂。
如果我们将输入简单地分割成相同的块,效率核心将落后并减慢整体运行时间。因此,我们不要将输入拆分为每个线程一个块,而是让线程根据需要请求块。
以及我们MappedFile中对应的next_chunk方法。
这使我们能够充分发挥 14900K 的最后一点性能。
- 14900K (WSL Ubuntu):0.77s (87x)(32 个线程)
结论
我们将初始实现的性能提高了 87 倍,这绝对不是微不足道的。它值得吗?
嗯,这要看情况。这篇文章花了我很长时间来写,让我失去了很大的理智。值得注意的是,使用微基准时的对齐问题是一个需要克服的巨大痛苦。
如果我正在优化一段生产代码,我可能会停止在基本优化(和线程)上。微观优化可能是值得的,但时间投入很大,而且这些优化在现代架构上的稳定性很差。
完整的源代码可在此 GitHub 存储库中获取。