这个代码挑战赛火爆外网,十亿行文本处理,冠军只用了1.5s

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

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

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

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

 比赛结果

比赛规则

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

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

比赛规则很简单,写一段Java程序读取这个文件,计算每个气象站的最小值平均值最大值,并以下面的格式输出到stdout(即,按气象站名字典序排列,每站的值以<min>/<mean>/<max>格式展示,精确到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, ...}

基准示例

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

  1. 将文件读取到内存中,并按行拆分
  2. 对每行分别解析为站名和温度值
  3. 按气象站聚合温度值(最小、最大、总和、总数):避免浮点计算,将温度转换为int类型
  4. 最后用TreeMap按字典序输出各个气象站的温度聚合数据:最小、平均(总和/总数)、最大
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里这段代码需要运行2min48s,官方的运行环境中需要4min49s。

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

优化一:BufferedReader+多线程处理

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

  1. 使用BufferedReader,提高读文件的效率
  2. 使用多线程的方式并行处理和统计数据
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相比还是差了非常多。

性能分析工具

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

VisualGC

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

VisualGC

  • Compile Time: 编译时间,表示虚拟机的JIT编译器编译热点代码的耗时(Java 运行器编译的最大缺点就是它进行编译时需要消耗程序正常的运行时间)
  • Class Loader Time: class的加载和卸载耗时
  • GC Time: GC次数、耗时;上次发生GC的原因(Last Cause)
  • Eden Space(最大可分配空间,当前分配空间): 当前占用空间。Eden Space新生代发生GC的次数和占用时间
  • S0和S1(最大可分配空间,当前分配空间): 当前占用空间。最大分配空间相同,minorGC时,会发生S0和S1的切换
  • **Old Gen(最大可分配空间,当前分配空间): 当前占用空间。**老年代GC也叫做Full GC(因为老年代GC时总是会伴随着Minor GC)
  • Metaspace(最大可分配空间,当前分配空间): 当前占用空间

Async-Profiler

MacOS安装:brew install jbang

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

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

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

火焰图

Perf

perf命令只支持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+的指令(instructions/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. BufferedReader:将每行文本输出为字符串(27%
  2. 处理这些字符串(50%
  3. 垃圾收集(GC)(19%):使用VisualGC可以看到,差不多每秒要gc10次

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

并行化

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

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();
}

RandomAccessFile

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

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很长一段时间,依赖ByteBuffer API来读取native内存,但它使用int类型来索引,那么可映射的空间被限制到了2GB。而1BRC的文件已经达到了13G,显然不适合使用。

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

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;
}

findByte, stringAt实现

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

  • findByte(),寻找下一个分隔符:避免为整行创建一个字符串对象
  • stringAt(),指定位置创建字符串:但站名和温度仍然需要创建为字符串(用于hashMap通过和解析温度)
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报告也显示,每行指令下降到一半了(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相关的耗时都消失了。同时也为后续优化指明了方向:

  • findByte(): 21.6%,必要的读取,基本优化不了
  • stringAt(): 21.79%
  • parseDouble(): 14.5%
  • computeIfAbsent(): 25.9%

IO并行化结果

优化三:温度解析

分析代码可以发现:我们将每行的温度创建为一个字符串(stringAt),并解析为double类型(parseDouble),再将其转换为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%。stringAt也相当于少了一次调用,但整体耗时占比扩大到最新的27%

温度解析优化结果

优化四:自定义哈希表

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

  1. 计算hash值
  2. 判断两个key是否相等

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

  • 指定大小:不需要考虑容量扩张,因为比赛的气象站大小已经确定
  • 开放地址法:如果hash值对应的槽位(slot)已经存在,那么一直向右移动到第一个空的槽位
  • 计算hash值和判断key是否相等:直接通过读取文件对应字节计算和判断,避免创建String对象
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

优化五:GraalVM

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

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

经过GraalVM 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

进阶优化

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

  • 使用sun.misc.Unsafe取代MemorySegment,避免边界校验
  • 避免重新重新读取同一个字节:复用已经加载的值做哈希计算和分号搜索
  • 一次处理8个字节,使用SWAR技术搜索分号
  • 通过统计气象站名称长度分布,优化名称比较的分支预测命中率
  • 等等

总结

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

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值