推荐一个原创技术号-非科班大厂码农,号主是机械专业转行进入腾讯的后端程序员!

13G的文件包含10亿行气象数据,用Java读取并计算各个气象站的最小、最大、平均值需要多久?1BRC比赛告诉你只需要1.5s。

本文将介绍各种性能分析工具和优化手段,一步一步带你将处理耗时从,降低到3.3s

十亿行文本挑战赛(1BRC)

火爆外网的十亿行文本挑战赛(1BRChash sum mismatch,The One Row )终于在2月初结束。600多人参与代码提交,最终冠军 的代码只用了1.5s(基准代码需要4分钟,191倍速度提升),比赛结果超出主办者的意料。(It’s how far have the here. — )

比赛结果比赛规则

输入的文件(10亿行文本,大约13G)包含了一批气象站的温度数据,每行都以;格式记录一条观测数据,每个观测数据取值都有1位小数,下面是文件的几行实例:

Hamburg;12.0
Bulawayo;8.9
Palembang;38.8
St. John's;15.2
Cracow;12.6

比赛规则很简单,写一段Java程序读取这个文件,计算每个气象站的最小值、平均值、最大值,并以下面的格式输出到(即,按气象站名字典序排列,每站的值以//格式展示,精确到1位小数)

{Abha=-23.0/18.0/59.2, Abidjan=-16.2/26.0/67.3, Abéché=-10.0/29.4/69.0, Accra=-10.1/26.4/66.4, Addis Ababa=-23.7/16.0/67.0, Adelaide=-27.8/17.3/58.5, ...}

基准示例

主办方提供了一个基准实例,是一段毫无优化的正确的代码:

将文件读取到内存中,并按行拆分

对每行分别解析为站名和温度值

按气象站聚合温度值(最小、最大、总和、总数):避免浮点计算,将温度转换为int类型

最后用按字典序输出各个气象站的温度聚合数据:最小、平均(总和/总数)、最大

Collector<Measurement, MeasurementAggregator, ResultRow> collector = Collector.of(
MeasurementAggregator::new,
(a, m) -> {
a.min = Math.min(a.min, m.value);
a.max = Math.max(a.max, m.value);
a.sum += m.value;
a.count++;
},
(agg1, agg2) -> {
var res = new MeasurementAggregator();
res.min = Math.min(agg1.min, agg2.min);
res.max = Math.max(agg1.max, agg2.max);
res.sum = agg1.sum + agg2.sum;
res.count = agg1.count + agg2.count;

return res;
},
agg -> {
return new ResultRow(agg.min, (Math.round(agg.sum * 10.0) / 10.0) / agg.count, agg.max);
});

Map<String, ResultRow> measurements = new TreeMap(Files.lines(Paths.get(FILE))
.map(l -> new Measurement(l.split(";")))
.collect(groupingBy(m -> m.station(), collector)));

System.out.println(measurements);

在我的MacOS里这段代码需要运行,官方的运行环境中需要。

以下本文的大部分优化手段和代码,来自外网的一个博客:The Row (1BRC) - Step-by-step from 71s to 1.7s(代码:)。我也在MacOS上复现了他的大部分优化手段,因此下面的耗时数据和火焰图,是来自本人的复现数据。由于运行环境不同,处理耗时也很不一样,仅供参考。

优化一:+多线程处理

很快,大家都发现了两个最直接的两个优化:

使用,提高读文件的效率

使用多线程的方式并行处理和统计数据

var allStats = new BufferedReader(new FileReader("measurements.txt"))
.lines()
.parallel()
.collect(
groupingBy(line -> line.substring(0, line.indexOf(';')),
summarizingDouble(line ->
parseDouble(line.substring(line.indexOf(';') + 1)))));
var result = allStats.entrySet().stream().collect(Collectors.toMap(
Entry::getKey,
e -> {
var stats = e.getValue();
return String.format("%.1f/%.1f/%.1f",
stats.getMin(), stats.getAverage(), stats.getMax());
},
(l, r) -> r,
TreeMap::new));
System.out.println(result);

在我的MacOS上运行了46s的时间(2.65倍速度提升),虽然已经优化得很不错了,但和1.5s相比还是差了非常多。

性能分析工具

在进行性能优化之前,一定要做性能分析。下面是大赛选手们都在用的一些分析工具:

: 很老但很有用的性能分析工具。通过加载插件,可以看到代码运行中GC收集和耗时数据。

-安装:brew jbang

在1BRC 页面里提到,使用jbang工具,可以通过ap-运行async-,得到一张火焰图,查看哪些代码耗时更多。

jbang --javaagent=ap-loader@jvm-profiling-tools/ap-loader=start,event=cpu,file=profile.html --enable_preview -m dev.morling.onebrc.CalculateAverage_yourname target/average-1.0.0-SNAPSHOT.jar

将生成的.html文件用浏览器打开,即可看到各个方法耗时和执行堆栈。

火焰图命令只支持Linux系统。MacOS似乎没有替代工具。

perf stat并不会具体分析某个方法,而是告诉你一些底层CPU执行指标。比如指定执行数、分支访问和内存访问数、分支/L1缓存命中情况等。

perf stat -e branches,branch-misses,cache-references,cache-misses,cycles,instructions,idle-cycles-backend,idle-cycles-frontend,task-clock -- java --enable-preview -cp src Blog1

返回的数据,可以告诉我们每行文本执行了2k+的指令(/1B),后面可以通过各种优化方式试图降低这个指标。

393,418,510,508      branches
3,112,052,890 branch-misses
26,847,457,554 cache-references
982,409,158 cache-misses
756,818,510,991 cycles
2,031,528,945,161 instructions

优化二:I/O并行化

火焰图

通过分析火焰图,可以发现,代码处理时间花了三个地方:

1、:将每行文本输出为字符串(27%)

2、处理这些字符串(50%)

3、垃圾收集(GC)(19%):使用可以看到,差不多每秒要gc10次

方案一尽管采用了多线程的方式处理数据,但从文件中读取数据到堆内存仍然是单线程的,并且为每行文本都创建了字符串对象。同时大量对象导致频繁GC触发。

并行化

那么最先意识到的肯定是将I/O并行化处理:将文本拆分为多个部分,每个线程负责读取一部分文件,并独立处理。(那就需要放弃使用优雅的)

var threads = new Thread[chunkCount];
for (int i = 0; i < chunkCount; i++) {
final long chunkStart = chunkStartOffsets[i];
final long chunkLimit = (i + 1 < chunkCount) ? chunkStartOffsets[i + 1] : length;
threads[i] = new Thread(new ChunkProcessor(
mappedFile.asSlice(chunkStart, chunkLimit - chunkStart), results, i));
}
for (var thread : threads) {
thread.start();
}
for (var thread : threads) {
thread.join();
}

支持“随机读写”,即直接跳转到文件的任意地方来读写数据。一般使用场景为:网络请求中多线程下载及断点续传。

final File file = new File("measurements.txt");
final long length = file.length();
final int chunkCount = Runtime.getRuntime().availableProcessors();
// 存储每个chunk负责的起始offset
final var chunkStartOffsets = new long[chunkCount];
try (var raf = new RandomAccessFile(file, "r")) {
for (int i = 1; i < chunkStartOffsets.length; i++) {
var start = length * i / chunkStartOffsets.length;
raf.seek(start);
// 保证每个chunk都是完整的数据(按行分割)
while (raf.read() != (byte) '\n') {
}
// 分别记录每个线程负责的文件范围
start = raf.getFilePointer();
chunkStartOffsets[i] = start;
}
...
}

mmap内存映射

使用内存映射mmap技术来减少内存拷贝,像访问内存一样,直接访问文件的内容,是常用的优化手段之一(也是Kafka和Netty高性能的原因之一,所谓0拷贝技术)。

mmap实现原理

Java已经支持mmap很长一段时间,依赖来读取内存,但它使用int类型来索引,那么可映射的空间被限制到了2GB。而1BRC的文件已经达到了13G,显然不适合使用。

JDK团队正在引入基于long类型索引的新API:(JDK21 预览功能,使用--启用)。

for (var cursor = 0L; cursor < chunk.byteSize();) {
var semicolonPos = findByte(cursor, ';');
var newlinePos = findByte(semicolonPos + 1, '\n');
var name = stringAt(cursor, semicolonPos);
var temp = Double.parseDouble(stringAt(semicolonPos + 1, newlinePos));
var stats = statsMap.computeIfAbsent(name, k -> new StationStats(name));
var intTemp = (int) Math.round(10 * temp);
stats.sum += intTemp;
stats.count++;
stats.min = Math.min(stats.min, intTemp);
stats.max = Math.max(stats.max, intTemp);
cursor = newlinePos + 1;
}

, 实现

既然已经用不到,那么需要手写一些功能方法

private long findByte(long cursor, int b) {
for (var i = cursor; i < chunk.byteSize(); i++) {
if (chunk.get(JAVA_BYTE, i) == b) {
return i;
}
}
throw new RuntimeException(((char) b) + " not found");
}

private String stringAt(long start, long limit) {
return new String(
chunk.asSlice(start, limit - start).toArray(JAVA_BYTE),
StandardCharsets.UTF_8
);
}

经过上述IO并行化,处理耗时也从46s下降到14s。perf stat报告也显示hash sum mismatch,每行指令下降到一半了(945)

229,112,005,628      branches
2,159,757,411 branch-misses
11,691,731,241 cache-references
433,992,993 cache-misses
408,367,307,956 cycles
944,615,442,392 instructions

通过火焰图,可以看到前面IO和GC相关的耗时都消失了。同时也为后续优化指明了方向:

IO并行化结果优化三:温度解析

分析代码可以发现:我们将每行的温度创建为一个字符串(),并解析为类型(),再将其转换为int存储,其实是没有必要的。由于温度的一些特殊性质,可以直接从文件字符入手,解析出温度。

// 地面温度(考虑到只保留一位小数)一般只有4种模式:X.X, XX.X, -X.X, -XX.X
// 转为int类型,需要将温度乘以10
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';
// 如果下一位不是小数点,那么说明是XX.X模式,同时下一位一定是小数点,直接skip
off++;
}
// 获取小数点后一位
b = chunk.get(JAVA_BYTE, off);
temp = 10 * temp + b - '0';
return sign * temp;
}

处理耗时从14s下降到12s,通过直接解析温度,不仅降低CPU处理耗时,还减少了临时字符串创建。perf stat报告,每行指令下降到607

144,186,177,966      branches
1,654,689,761 branch-misses
8,425,586,109 cache-references
322,481,746 cache-misses
271,487,424,445 cycles
607,302,995,626 instructions

火焰图也可以看到温度解析耗时占比从14.5%降低到5%。也相当于少了一次调用,但整体耗时占比扩大到最新的27%

温度解析优化结果优化四:自定义哈希表

那么有没有办法可以移除调用呢?分析代码可以看到将站名生成对象,是为了在中存储不同气象站的统计结果数据(作为key)。而的key的作用是

计算hash值

判断两个key是否相等

如果要避免使用调用,那么就不得不抛弃,来实现自定义的哈希表结构。实际上,构造一个指定大小的采用开放地址法解决哈希冲突的哈希表,并不那么复杂:

private static final int HASHTABLE_SIZE = 2048;
private final StatsAcc[] hashtable = new StatsAcc[HASHTABLE_SIZE];

// 取代computeIfAbsent
private StatsAcc findAcc(long cursor, long semicolonPos) {
int hash = hash(cursor, semicolonPos);
int slotPos = hash & (HASHTABLE_SIZE - 1);
while (true) {
var acc = hashtable[slotPos];
if (acc == null) {
// 如果不存在,那么构建一个统计对象,并存放到hashTable中
acc = new StatsAcc(hash, cursor, semicolonPos - cursor);
hashtable[slotPos] = acc;
return acc;
}
// 如果已存在,且名称相等,说明找到了对应气象站的统计对象
if (acc.hash == hash && acc.nameEquals(chunk, cursor, semicolonPos)) {
return acc;
}
// 如果已存在,且名称不同,说明有哈希冲突。移动到下一个槽位
slotPos = (slotPos + 1) & (HASHTABLE_SIZE - 1);
}
}

private int hash(long startOffset, long limitOffset) {
// 直接计算hash值
int h = 17;
for (long off = startOffset; off < limitOffset; off++) {
h = 31 * h + ((int) chunk.get(JAVA_BYTE, off) & 0xFF);
}
return h;
}

static class StatsAcc {
long nameOffset; long nameLen; int hash;
long sum; int count; int min; int max;

StatsAcc(int hash, long nameOffset, long nameLen) {
this.hash = hash;
this.nameOffset = nameOffset;
this.nameLen = nameLen;
}

public boolean nameEquals(MemorySegment chunk, long otherNameOffset, long otherNameLimit) {
var otherNameLen = otherNameLimit - otherNameOffset;
return nameLen == otherNameLen &&
// 直接比较文件中两个位置的字节
chunk.asSlice(nameOffset, nameLen).mismatch(chunk.asSlice(otherNameOffset, nameLen)) == -1;
}
}

耗时从12s降低到10s(这基本也是冠军代码在我本机上能跑到的最好成绩),perf stat报告显示,每行指令降低到367

76,788,546,143      branches
1,296,328,979 branch-misses
4,669,021,999 cache-references
147,634,298 cache-misses
158,676,495,632 cycles
367,267,766,472 instructions

优化五:

前面的优化手段,都需要改动代码,节约CPU执行时间。然而实际还有一种不需要改动代码的优化方式,即换一个现代化的JVM。

是一个使用即时 (JIT) 编译器加速 Java 和 JVM 应用性能的高性能 JDK。同时, image支持AOT编译,将Java编译后的字节码,生成可近乎瞬时启动且仅占用极少内存资源的本机可执行文件。

经过 AOT编译后,执行时间从10s下降到3.3s,大约3倍的速度提升。

// MacOS安装graalvm jdk 21.0,别忘了配置$JAVA_HOME和$PATH
brew install --cask graalvm-jdk

// AOT编译,大约需要30s
native-image --enable-preview -cp target/average-1.0.0-SNAPSHOT.jar -o target/CalculateAverage_xxx_image dev.morling.onebrc.CalculateAverage_xxx

// 执行
target/CalculateAverage_xxx_image

进阶优化

当然参赛选手们为了极致的优化性能,各种奇技淫巧都用上了(当然也会导致代码越来越不可读)。限于我的能力有限,部分进阶优化技巧还未能参透,就不班门弄斧,感兴趣的同学自行去了解一下:

总结

惊讶于Java代码能在这么短的时间内处理完10亿级文本,1BRC挑战赛带给我的意义体现在”追求极致“这四个字上。因此本文总结和分享这些性能优化实践,也是希望自己和大家都能在工作中积极践行”追求极致“,问问自己是否还有优化空间?


限时特惠:
本站持续每日更新海量各大内部创业课程,一年会员仅需要98元,全站资源免费下载
点击查看详情

站长微信:Jiucxh

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注