专栏名称: InfoQ 架构头条
InfoQ运维领域垂直号。常规运维、亦或是崛起的DevOps,探讨如何IT交付实现价值。努力为技术人呈现有实践意义的内容~
目录
相关文章推荐
51好读  ›  专栏  ›  InfoQ 架构头条

Java 社区的一次十亿行数据编程挑战

InfoQ 架构头条  · 公众号  · 运维  · 2025-03-19 15:00

正文

请到「今天看啥」查看全文


我们深入了解一下如何加快这个程序的速度。人们花了整个一月份的时间来研究这个问题,他们探索到了一个非常深的层次,基本上是计算 CPU 指令。

首先我们谈谈并行化,因为我们有很多 CPU 核心。在我用来评估它的服务器上有 32 个核心,64 个线程。我们想利用这一点,只使用一个核心会有点浪费。我们该怎么做呢?回到我简单的基线实现,我能做的第一件事就是添加这个并行调用,也就是 Java Streams API 的这一部分。

image

现在它将并行处理这个管道,或者说这个流管道的一部分。只需添加这个单一方法调用,就可以将时间缩短到 71 秒,非常轻松的胜利。

如果你仔细想想,是的,它让我们的速度加快了不少,但并没有达到八倍的水平。可我们有 8 个 CPU 核心,为什么它没有八倍的速度?因为这个并行运算符适用于处理逻辑。所有这些聚合和分组逻辑都是并行发生的,但从内存中读取文件仍然是按顺序发生的。读取部分是按顺序进行,其他 CPU 核心依旧处于空闲状态,所以我们也想将其并行化。

新的 Java 版本都带有新的 API、JEP、Java 增强提案。其中之一是最近添加的外部函数和内存 API。

image

本质上它是一个 Java API,允许你使用原生方法。它比旧的 JNI API 更易用,还允许你使用本机内存。你可以管理自己的内存部分(如堆外内存),而不是由 JVM 管理的堆,并且你将负责维护它、释放它,等等。我们想在这里使用它,因为我们可以内存映射这个文件,然后在那里并行处理它。

image

首先我们确定并行度。我们的例子中是八个核心,这就是我们的并行度。接下来我们要对这个文件进行内存映射。在早期的 Java 版本中,你也可以使用内存映射文件,但你有大小之类的限制,你无法一次对整个 13 GB 的文件进行内存映射。而现在有了新的外部内存 API,我们就可以做到这一点。你映射文件。我们有这个 Arena 对象。这本质上是我们对这个内存的表示。有不同类型的 Arena,这里我只是使用这个全局 Arena,它可以从我的应用程序中的任何位置访问。现在我可以使用多个线程并行访问整个内存部分。

为了做到这一点,我们需要分割文件和内存表示。首先,我们将其大致分成八个相等的块。我们将整个大小除以八。当然,很有可能我们会分割到某一行的中间,而理想情况下,我们希望我们的工作进程能够处理整行。这里发生的事情是,我们转到了文件的大约八分之一,然后继续转到下一个行结束符。那么这里就是这个块的结尾,也是下一个块的起点。然后我们处理这些块,我们启动线程,然后将它们连接起来。现在我们真正在整个周期内都利用了所有 8 个内核,进行 I/O 时也一样。

有一个警告。从本质上讲,其中一个 CPU 核心总是最慢的。在某个时候,其他七个核心都会等待最后一个核心完成,因为数据的分布有点不均匀。人们最终的做法是不再使用 8 个块,而是将这个文件分成更小的块。本质上,他们积压了这些块。每当其中一个工作线程处理完当前块时,它就会去处理下一个块。通过这种方式,你可以确保所有 8 个线程始终得到平等利用。事实证明,理想的块大小是 2 兆字节。为什么是 2 兆字节?我使用的这台机器上的这个 CPU 有 16 兆字节的二级缓存,8 个线程每次处理 2 兆字节。这个数值在预测 I/O 等方面是最好的。这也表明,我们确实深入到了具体的 CPU 和架构的层面来真正优化该问题。

解   析

我们更深入地分析一下。我们已经了解了如何利用多个 CPU 核心,但具体处理每一行时究竟发生了什么?我们仔细看看。

我们想摆脱最初的使用正则表达式等分割文件的做法,那样效率并不高。我能想到的办法是,只需逐个字符地处理这些输入行即可。

image

这里差不多是一个状态机。我们读取字符,继续读取行,直到没有字符。然后我们使用将站点名称与温度值分隔开的分号字符来切换这些状态。根据我们所处的状态,我们是读取组成站点名称的字节,还是读取组成测量值的字节?我们需要将它们添加到某个构建器或缓冲区中,以聚合这些值。

然后,如果我们在一行的末尾,也就是说我们找到了行结束符,那么我们需要使用建立的这两个缓冲区来记录站点和测量值。对于测量值,我们需要了解如何将其转换为整数值,这也是人们想出的办法。这个问题被描述为双精度或浮点运算,因此值是 21.7 度,但同样,我总是只遇到一个小数位。人们意识到,这个数据实际上总是只有一个小数位。我们可以利用这一点,只需将数字乘以 100 即可将其视为整数问题,作为计算方法。然后在最后,将其除以 100 或 10。这是人们经常做的事情,我低估了他们会在多大程度上利用该数据集的特定特性。

image

所以我们处理或使用这些值。如果我们看到减号就取反这个值。如果我们看到两位数字中的第一个,就把它乘以 100 或 10。这样做,我们可以把时间缩短到 20 秒,已经比最初的基线实现快了一个数量级。

到目前为止没有什么真正神奇的事情。你也应该得到一个启示,继续做这样的事情有多大意义?如果这是你在日常工作中面临的问题,也许就此打住吧。它可读性好,可维护性好。它比原生基线实现快了一个数量级,所以这相当不错。

当然,为了应对这一挑战,我们可能需要走得更远。我们还能做什么?我们可以再次回到并行的概念,尝试一次处理多个值,现在我们有了不同的并行方法。我们已经看到了如何充分利用所有 CPU 核心。这是并行度的一方面。我们还可以考虑扩展到多个计算节点,这通常是我们对大规模数据存储所做的事情。对于这个问题它并不那么重要,我们必须拆分该文件并将其分发到网络中。也许不是那么理想,但那将是另一个极端。而我们也可以朝另一个方向发展,在特定的 CPU 指令内作并行化。这就是 SIMD(单指令多数据)所做的事情。

基本上所有这些 CPU 都有扩展指令,允许你一次将相同类型的操作应用于多个值。例如,在这里我们想找到行尾字符。现在我们不再逐字节对比,而是可以使用这样的 SIMD 指令将其应用于 8 个或 16 个甚至更多字节,当然这会大大加快速度。问题是,在 Java 中,你没有很好的方法来利用这些 SIMD 指令,因为它是一种可移植的抽象语言,它不允许你降低到这种级别的 CPU 底层上。

image







请到「今天看啥」查看全文