1BRC-十亿行挑战,如何用最少的时间解析十亿行记录?

258 阅读6分钟

什么是1BRC

编写一个 Java 程序,从文本文件中检索温度测量值并计算每个气象站的最小、平均和最大温度。但有一个警告:该文件有 1,000,000,000 行!
在Java21的基础上,各方大佬使用各种手段来压缩这个执行时间,包括但不限于并发,JVM,使用自定义的函数来代替系统函数.

这对我有什么用?

  • 如果你是一个JVM的初学者,苦于没有场景来实践这些,1RBC机是一个对你来说完美的项目,你可以在这里观察GC,利用火焰图分析程序运行情况,逐步的优化代码.
  • 如果你是一个性能调优达人,你可以在这个挑战中竭尽全力,通过自己的聪明才智和学习他人的优点来提升自己.

这些思路是你自己想到的么?

不,我只是一个Java的初学者,这些都是### Marko Topolnik大神参与这个项目之后分享的解决思路,他发表在questdb这篇博客上,我只是在他的代码上添加了一些学习时的注释,并希望可以对正在阅读的人提供一些绵薄的帮助.

想在本地运行这个代码?

可以的,我将它放到了我的gitee仓库上,gitee,包含生成测试文件的代码和优化步骤.

第一步(基线版本)

这个版本的解决思路就是官方提供的最基础版本的解决思路

public void execute() throws Exception {
    // 这一行作用是读取文件,最后得到的是一个HashMap<String,DoubleSummaryStatistics>
    var allStats = new BufferedReader(new FileReader("measurements.txt"))
        // 将每一行都转化为一个字符串,整体就是一个数组
        .lines()
        // 并发处理,,因为后面采用的使用stream自身的转化map,所以没有线程安全问题
        .parallel()
        // 收集结果
        .collect(
            // 根据每一行的格式“城市;温度”,将城市名称作为key
            groupingBy(line -> line.substring(0, line.indexOf(';')),
                // 后续收集,将string转化为double,并且统计出来,并返回一个DoubleSummaryStatistics
                summarizingDouble(line ->
                    parseDouble(line.substring(line.indexOf(';') + 1)))));
    // 这一行代码经过测试使用stream和stream.parallel的执行结果相同,但是在十亿的数据量下并没发现并发有提高,分别为120,650和124415(并发)毫秒
    // 使用并发有的时候还会导致时间变慢
    // 这里的推测是因为allStats的key大概只有500左右,所以并发没有意义
    // ps: 大佬就是大佬.连parallel用的都这么厉害,我什么时候才能达到这种水平呀(╯‵□′)╯︵┻━┻
    var result = allStats.entrySet().stream().parallel().collect(Collectors.toMap(
        Map.Entry::getKey,
        e -> {
            var stats = e.getValue();
            return String.format("%.1f/%.1f/%.1f",
                stats.getMin(), stats.getAverage(), stats.getMax());
        },
        (l, r) -> r,
        // 采用TreeMap保证有序
        TreeMap::new));
    System.out.println(result);
}

火焰图:

image.png 存在的问题:

  • 特别多的GC,本地测试在火焰图中查看有41.88%的CPU时间都在处理GC;
  • 只有一个线程在读取文件;

第二步(优化GC和多线程读取文件)

这个版本采用多线程的方式读取文件,并且没有直接将一行数据转化成String在进行拆分城市和温度,降低了无用String的生成数量

public void execute() throws Exception {
    final File file = new File("./measurements.txt");
    final long length = file.length();
    // 根据电脑的CPU数量来决定将文件划分为多少块
    final int chunkCount = Runtime.getRuntime().availableProcessors();
    final var results = new StationStats[chunkCount][];
    final var chunkStartOffsets = new long[chunkCount];

    // 以只读的方式打开文件,并且利用了try的自动close特性
    try (var raf = new RandomAccessFile(file, "r")) {
        for (int i = 1; i < chunkStartOffsets.length; i++) {
            // 定义每个块读取的位置
            var start = length * i / chunkStartOffsets.length;
            // 移动文件指针到这里
            raf.seek(start);
            // 这一行的作用是指针指向的位置有大概率不是换行符,移动到换行符
            while (raf.read() != (byte)'\n') {
            }
            // 当前指针位置已经指向新的一行开始位置了
            start = raf.getFilePointer();
            // 特意不管数组下标为0的long,因为按照这个逻辑会从第二行开始读取
            chunkStartOffsets[i] = start;
        }
        // 将这个文件整体以只读的方式映射到内存中
        final var mappedFile = raf.getChannel().map(MapMode.READ_ONLY, 0, length, Arena.global());
        // 显示创建线程是因为不需要线程池管理他,他会一直使用并且不会被回收
        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();
        }
    }
    var totalsMap = new TreeMap<String, StationStats>();
    for (var statsArray : results) {
        for (var stats : statsArray) {
            totalsMap.merge(stats.name, stats, (old, curr) -> {
                old.count += curr.count;
                old.sum += curr.sum;
                old.min = Math.min(old.min, curr.min);
                old.max = Math.max(old.max, curr.max);
                return old;
            });
        }
    }
    System.out.println(totalsMap);
}

private static class ChunkProcessor implements Runnable {
    private final MemorySegment chunk;
    private final StationStats[][] results;
    private final int myIndex;
    private final Map<String, StationStats> statsMap = new HashMap<>();

    ChunkProcessor(MemorySegment chunk, StationStats[][] results, int myIndex) {
        this.chunk = chunk;
        this.results = results;
        this.myIndex = myIndex;
    }

    @Override
    public void run() {
        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 intTemp = (int)Math.round(10 * temp);

            var stats = statsMap.computeIfAbsent(name, k -> new StationStats(name));
            stats.sum += intTemp;
            stats.count++;
            stats.min = Math.min(stats.min, intTemp);
            stats.max = Math.max(stats.max, intTemp);
            // 修改游标地址
            cursor = newlinePos + 1;
        }
        // 这个就是该组的全部数据,之后只需要跟其他组相比较就好
        results[myIndex] = statsMap.values().toArray(StationStats[]::new);
    }

    /**
     * chunk按照偏移量依次读取,找到指定字符
     * @param cursor
     * @param b
     * @return
     */
    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");
    }

    /**
     * 按照其实位置将一定长度的字符串读取出来
     * @param start
     * @param limit
     * @return
     */
    private String stringAt(long start, long limit) {
        return new String(
            chunk.asSlice(start, limit - start).toArray(JAVA_BYTE),
            StandardCharsets.UTF_8
        );
    }
}

/**
 * 类似DoubleSummaryStatistics,对获取到的数据进行存储
 */
static class StationStats implements Comparable<StationStats> {
    String name;
    long sum;
    int count;
    int min;
    int max;

    StationStats(String name) {
        this.name = name;
    }

    @Override
    public String toString() {
        return String.format("%.1f/%.1f/%.1f", min / 10.0, Math.round((double)sum / count) / 10.0, max / 10.0);
    }

    @Override
    public boolean equals(Object that) {
        return that.getClass() == StationStats.class && ((StationStats)that).name.equals(this.name);
    }

    @Override
    public int compareTo(StationStats that) {
        return name.compareTo(that.name);
    }
}

火焰图:

image.png 在火焰图中可以看到:

  • Double.parseDouble(15.89%),
  • HashMap.computIfAbsent(14.72%)
  • ChunkProcessor.findByte(26.07%)
  • ChunkProcessor.stringAt(33.17%)

这四个方法占用了几乎全部CPU的时间,接下来的目标就是优化这四个方法.

第三步(采用自定义函数代替Double.parseDouble)

这个版本采用自定义的函数代替了SDK中的Double.parseDouble

第一个修改点:

public void run() {
    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 intTemp = (int)Math.round(10 * temp);

        // 修改后的方式:
        var intTemp = parseTemperature(semicolonPos);

        var stats = statsMap.computeIfAbsent(name, k -> new StationStats(name));
        stats.sum += intTemp;
        stats.count++;
        stats.min = Math.min(stats.min, intTemp);
        stats.max = Math.max(stats.max, intTemp);
        // 修改游标地址
        cursor = newlinePos + 1;
    }
    // 这个就是该组的全部数据,之后只需要跟其他组相比较就好
    results[myIndex] = statsMap.values().toArray(StationStats[]::new);
}

添加的新方法:

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++);
    }
    // 因为char里面的编码顺序都是递增的,所以用这个去减就能获得对应的数字了
    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;
}

火焰图:

image.png

到这里火焰图变成:

  • ChunkProcessor.parseTemperature(5.32%),
  • HashMap.computIfAbsent(19.66%)
  • ChunkProcessor.findByte(37.27%)
  • ChunkProcessor.stringAt(24.87%)

未完待续

接下来的优化手段是通过重写HashMap来减少HashMap.computIfAbsent时间,我还需要在学一学才能继续完善这篇文章