您当前位置:资讯中心 >大数据 >浏览文章

这波操作看麻了!一亿行数据,从71s到1.7s的优化之路

来源:CTO 日期:2024/3/4 8:29:33 阅读量:(0)

你好呀,我是歪歪。

春节期间关注到了一个关于 Java 方面的比赛,很有意思。由于是开源的,我把项目拉下来试图学(白)习(嫖)别人的做题思路,在这期间一度让我产生了一个自我怀疑:

他们写的 Java 和我会的 Java 是同一个 Java 吗?

不能让我一个人怀疑,所以这篇文章我打算带你盘一下这个比赛,并且试图让你也产生怀疑。

赛题

在 2024 年 1 月 1 日,一个叫做 Gunnar Morling 的帅哥,发了这样一篇文章:

https://www.morling.dev/blog/one-billion-row-challenge/

文章的标题叫做《The One Billion Row Challenge》,一亿行挑战,简称就是 1BRC,挑战的时间是一月份整个月。

赛题的内容非常简单,你只需要看懂这个文件就行了:

图片图片

文件的每一行记录的是一个气象站的温度值。气象站和温度分号分隔,温度值只会保留一位小数。

参赛者只需要解析这个文件,然后并计算出每个气象站的最小、最大和平均温度。按照字典序的格式输出就行了:

图片图片

出题人还配了一个简图:

图片图片

需求非常明确、简单,对不对?

为了让你彻底明白,我再给你举一个具体的例子。

假设文件中的内容是这样的:

chengdu;12.0
guangzhou;7.2;
chengdu;6.3
beijing;-3.6;
chengdu;23.0
shanghai;9.8;
chengdu;24.3
beijing;17.8;

那么 chengdu (成都)的最低气温是 6.3,最高气温是 24.3,平均气温是(12.0+6.3+23.0+24.3)/4=16.4,就是这么朴实无华的计算方式。

最终结果输出的时候,再注意一下字典序就行。

这有啥好挑战的呢?

难点在于出题人给出的这个文件有 10 亿行数据。

在我的垃圾电脑上,光是跑出题人提供的数据生成的脚本,就跑了 20 分钟:

图片图片

跑出来之后文件大小都有接近 13G,记事本打都打不开:

图片图片

所以挑战点就在于“一亿行”数据。

具体的一些规则描述和细节补充,都在 github 上放好了:

https://github.com/gunnarmorling/1brc

针对这个挑战,出题人还提供了一个基线版本:

https://github.com/gunnarmorling/1brc/blob/main/src/main/java/dev/morling/onebrc/CalculateAverage_baseline.java

首先封装了一个 MeasurementAggregator 对象,里面放的就是要记录的最小温度、最大温度、总温度和总数。

图片图片

整个核心代码就二三十行,使用了流式编程:

图片图片

首先是一行行的读取文本,接着每一行都按照分号进行拆分,取出对应的气象站和温度值。

然后按照气象站维度进行 groupingBy 聚合,并且计算最大值、最小值和平均值。

在计算平均值的时候,为了避免浮点计算,还特意将温度乘 10,转换为 int 类型。

最后用 TreeMap 按字典序输出各个气象站的温度数据。

这个基线版本官方的数据是在跑分环境下,2 分钟内可以运行完毕。

而在我的电脑上跑了接近 14 分钟:

很正常,毕竟人家的测评环境配置都是很高的:

Results are determined by running the program on a Hetzner AX161 dedicated server (32 core AMD EPYC? 7502P (Zen2), 128 GB RAM).

参加挑战的各路大神,最终拿出的 TOP 10 成绩是这样的:

图片图片

当时看到这个成绩的瞬间,我人都是麻的,第一个疑问是:我靠,13G 的文件啊?1.5s 内完成了读取、解析、计算的过程?这不可能啊,光是读取 13G 大小的文件,也需要一点时间吧?

但是需要注意的是,歪师傅有这个想法是走入了一个小误区,就是我以为这 13G 的文件一次性加载不完成,怎么快速的从硬盘把文件读取到内存中也是一个考点。

后来发现是我多虑了,人家直接就说了,不用考虑这一点,跑分成绩运行的时候,文件直接就在内存中:

图片图片

所以,最终的成绩中不包含读取文件的时间。

但是也很牛逼了啊,毕竟有一亿条数据。

第一名

我尝试着看了一下第一名的代码:

https://github.com/gunnarmorling/1brc/blob/main/src/main/java/dev/morling/onebrc/CalculateAverage_thomaswue.java

过于硬核,实在是看不懂。我只能通过作者写的一点注释、方法名称、代码提交记录去尝试理解他的代码。

在他的代码开头的部分,有这样的一段描述:

图片图片

这是他的破题思路,结合了这些信息之后再去看代码,稍微好一点,但是我发现他里面还是有非常多的微操、太多针对性的优化导致代码可读性较差,虽然他的代码加上注释一共也才 400 多行,然而我看还是看不懂。

我随便截个代码片段吧:

图片图片

问 GPT 这个哥们,他也是能说个大概出来:

图片图片

所以我放弃了理解第一名的代码,开始去看第二名,发现也是非常的晦涩难懂,再到第三名...

最后,我产生了文章开始时的疑问:他们写的 Java 和我会的 Java 是同一个 Java 吗?

但是有一说一,虽然我看不懂他们的某些操作,但是会发现他们整体的思路都几乎是一致。

虽然我没有看懂第一名的代码,但是我还是专门列出了这一个小节,给你指个路,有兴趣你可以去看看。

另外,获得第一名的老哥,其实是一个巨佬:

图片图片

是 GraalVM 项目的负责人之一:

图片图片

巨人肩膀

在官方的 github 项目的最后,有这样的一个部分:

图片图片

其中最后一篇文章,是一个叫做 Marko Topolnik 的老哥写的。

我看了一下,这个哥们的官方成绩是 2.332 秒,榜单第九名:

但是按照他自己的描述,在比赛结束后他还继续优化了代码,最终可以跑到  1.7s,排名第四。

在他的文章中详细的描述了他的挑战过程和思路。

我就站在巨人的肩膀上,带大家看看这位大佬从 71s 到 1.7s 的破题之道:

https://questdb.io/blog/billion-row-challenge-step-by-step/

最常规的代码

首先,他给了一个常规实现的代码,和基线版本的代码大同小异,只不过是使用了并行流来处理:

https://github.com/mtopolnik/billion-row-challenge/blob/main/src/Blog1.java

图片图片

平时看到流式编程我是有点头疼的,需要稍微的反应一下,但是在看了前三名的最终代码后再看这个代码,我觉得很亲切。

根据作者的描述,这段代码:

  • 使用并行 Java 流,将所有 CPU 核心都用起来了。
  • 也不会陷入任何已知的性能陷阱,比如 Java 正则表达式

在一台装有 OpenJDK 21.0.2 的 Hetzner CCX33 机器上,跑完需要的时间为 71 秒。

第 0 版优化:换个好的 JVM

叫做第 0 版优化的原因是作者对于代码其实啥也没动,只是换了一个 JVM:

图片图片

默认使用 GraalVM 之后,最常规的代码,运行时间从 71s 到了 66s,相当于白捡了 5s,我问就你香不香。

同时作者还提到一句话:

When we get deeper into optimizing and bring down the runtime to 2-3 seconds, eliminating the JVM startup provides another 150-200ms in relief. That becomes a big deal.

当我们把程序优化到运行时间只需要 2-3 秒的时候,使用 GraalVM,会消除 JVM 的启动时间,从而提供额外的 150-200ms 的提升。

到那个时候,这个就变得非常重要了。

数据指标很重要

在正式进入优化之前,作者先介绍了他使用到的三个非常重要的工具:

图片图片

关于工具我就不过多介绍了,这里单独提一嘴主要是想表达一个贯穿整个优化过程的中心思想:数据指标很重要。

你只有收集到了当前程序足够多的运行指标,才能对你进行下一步优化时提供直观的、优化方向上的指导。

工欲善其事必先利其器,就是这个道理。

第一版优化:并行 I/O 搞起来

通过查看当前代码对应的火焰图:https://questdb.io/html/blog/profile-blog1

通过火焰图以及观察 GC 情况,作者发现当前耗时的地方注意是这三个地方:

图片图片

  1. BufferedReader 将每行文本输出为字符串
  2. 处理每一行的字符串
  3. 垃圾收集 (GC):使用 VisualGC 可以看到,差不多每秒要 GC 10 次甚至更多。

可以发现 BufferedReader 占用了大量的性能,因为当前读取文件还是一行行读取的嘛,性能很差。

于是大多数人意识到的第一件事就是采用并行化 I/O。

所以,我们需要把待处理的文件分块。分多少块呢?

有多少个线程就分成多少个块,每个线程各自处理一个块,这样性能就上去了。

文件分块读取,大家自然而然的就想到了 mmap 相关的方法。

mmap 可以用 ByteBuffer API 来搞事情,但是使用的索引是 int 类型,所以可映射的大小有 2GB 的限制。

前面说了,在这个挑战中,光是文件大小就有 13G,所以 2GB 是捉襟见肘的。

但是在 JDK 21 中,支持一个叫做 MemorySegment 的东西,也可以干 mmap  一样的事情,但是它的索引使用的是 long,相当于没有内存限制了。

除了使用 MemorySegment 外,还有一些细节的处理,比如找到正确的分割文件的位置、启动线程、等待线程处理完成等等。

处理这些细节会导致这一版的代码从最初的 17 行增加到了 120 行。

这是优化后的代码地址:

https://github.com/mtopolnik/billion-row-challenge/blob/main/src/Blog2.java

在这个赛题下,我们肯定是需要再循环中进行数据的解析和处理的,所以循环就是非常重要的一个点。

我们可以关注一下代码中的循环部分,这里面有一个小细节:

这个循环是每个线程在按块读取文件大小,里面用到了 findByte 方法和 stringAt 方法。

在第一个版本中,我们是用的 BufferedReader 把一行内容以字符串的形式读进来,然后按照分号分隔,并生成城市和温度两个字符串。

这个过程就涉及到三个字符串了。

但是这个哥们的思路是啥?

自定义一个 findByte 方法,先找到分号的位置,然后把下标返回回去。

再用自定义的 stringAt 方法,结合前面找到的下标,直接解析出“城市和温度”这两个字符串,减少了整行读取的内存消耗。

相当于少了一亿个字符串,在字符串处理和 GC 方面取得了不错的表现。

这一波操作下来,处理时间直接从 66s 下降到了 17s:

图片图片

然后再看火焰图:

https://questdb.io/html/blog/profile-blog2-variant1

图片图片

可以发现 GC 的时间几乎消失了。

CPU 现在大部分时间都花在自定义的 stringAt 上。还有相当多的时间花在 Map.computeIfAbsent 方法 、Double.parseDouble 方法和 findByte 方法

其中 Double.parseDouble 方法是解析温度用的。

作者打算先把这个地方给攻下来。

第二版优化:优化温度解析方法

在这版优化中,作者直接将温度解析为整数。

首先,目前的做法是,首先分配一个字符串,然后对其调用 parseDouble() 方法,最后转换为整数以进行高效的存储和计算。

但是,其实我们应该直接创建整数出来,没必要走字符串绕一圈。

同时我们知道,温度的取值范围是 [-99.9,99.9],所以针对这个范围,我们搞个自定义方法就行了:

private int parseTemperature(long semicolonPos) {
    long off = semicolonPos + 1;
    int sign = 1;
    byte b = chunk.get(JAVA_BYTE, off++);
    if (b == '-') {
        sign = -1;
        b = chunk.get(JAVA_BYTE, off++);
    }
    int temp = b - '0';
    b = chunk.get(JAVA_BYTE, off++);
    if (b != '.') {
        temp = 10 * temp + b - '0';
        // we found two integer digits. The next char is definitely '.', skip it:
        off++;
    }
    b = chunk.get(JAVA_BYTE, off);
    temp = 10 * temp + b - '0';
    return sign * temp;
}
关键字:
声明:我公司网站部分信息和资讯来自于网络,若涉及版权相关问题请致电(63937922)或在线提交留言告知,我们会第一时间屏蔽删除。
有价值
0% (0)
无价值
0% (10)

分享转发:

发表评论请先登录后发表评论。愿您的每句评论,都能给大家的生活添色彩,带来共鸣,带来思索,带来快乐。