十亿行数据挑战:JAVA申请出战(从71秒到1.7秒的逐步优化)

714 阅读21分钟

图片创作不易,方便的话点点关注,谢谢

文章结尾有最新热度的文章,感兴趣的可以去看看。

本文是经过严格查阅相关权威文献和资料,形成的专业的可靠的内容。全文数据都有据可依,可回溯。特别申明:数据和资料已获得授权。本文内容,不涉及任何偏颇观点,用中立态度客观事实描述事情本身

文章有点长(7600字阅读时长:15分),期望您能坚持看完,并有所收获。

图片

导读

2024年元旦那个无聊的下午,贡纳尔·莫林(Gunnar Morling)发布了一条推文,内容是这样子的:“你能用Java多快地读取10亿行数据? 挑战内容如下: 编写一个Java程序,从一个文本文件中获取温度测量值,并计算每个气象站的最低、平均和最高温度。但有一个关键问题:这个文件有10亿行!

第一反应是:“求最小值、平均值和最大值,这很简单嘛!”而且数据集看起来也很简单,长度均匀且较短,数据格式超级简单,还有整整一个月的时间来完成。这有什么难度?然而,就像很多情况一样,细节决定成败。

参赛者们很快意识到,只计算最小值、平均值和最大值反而让竞赛变得更难了。为什么呢?因为并没有一个明显消耗CPU周期的地方,到处都有机会进一步提升速度,甚至是在CPU架构最隐蔽的角落。

值得庆幸的是,这个挑战在GitHub上公开进行。借鉴他人的想法不仅是被允许的,还是被鼓励的。这是一次学习的过程。

脱颖而出的方案

如果你曾经有用C++或Rust编写小程序,并查看编译器生成的优化机器码的经验,在这里你会有类似的感觉。抽象被打破,关注点相互交错。大量看起来极其陌生、摆弄比特位的逻辑。

程序员怎么可能做到这种程度呢?和很多其他情况一样,这是人们合作、逐步改进的结果。数十位Java专家反复尝试了许多技巧和窍门,随着1月时间的推移,处理时间越来越短。

这篇文章主要想向你展示,这种惊人速度的很大一部分来自于容易理解且可复用的技巧,你也可以在自己的代码中应用。最后,我还会展示一些更神奇的优化部分。

图片

“常规”实现

首先,我们使用一些符合规范的Java代码:

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

这段代码:

  • • 使用并行的Java流,让所有CPU核心都参与工作。

  • • 没有陷入像Java正则表达式那样的性能陷阱。

  • • 大量依赖JDK提供的优秀构建模块。

在一台装有OpenJDK 21.0.2的Hetzner CCX33实例上,它需要71秒才能完成。但最佳解决方案只需1.5秒——快了47倍!正如我所说,要达到这个目标需要逐步进行,所以让我们从第一步开始。

优化0:选择一个好的虚拟机

在我们触及代码之前,有一种不费力的方法来加速程序:使用现代化的JVM。许多生产环境仍在运行Java 8或11,但从那时起,Java的发展速度非常快。在1BRC挑战中,我们发现GraalVM是一个非常快的JVM。它还支持编译成原生二进制文件,这消除了JVM的启动成本。

仅仅下载GraalVM并将其设为默认虚拟机,我的解决方案就从71秒提升到了66秒——只付出了很少的努力就获得了7.5%的显著提升。

当我们进一步深入优化,将运行时间降低到2 - 3秒时,消除JVM启动时间又能节省150 - 200毫秒。这就很重要了。

优化1:并行化I/O

进行性能分析后,发现我们最初使用Streams API代码并没有明显的瓶颈。

时间大致平均分配在三项主要任务上:

  • • BufferedReader的工作,它为每行输出一个字符串

  • • 处理这些行

  • • 垃圾回收(GC)

VisualVM显示垃圾回收周期疯狂运行,每秒10次甚至更多。更糟糕的是,有些数据溢出,引发了后台的垃圾回收运行。我们必须决定先解决哪个问题。

一种可靠的方法是将文件拆分成与线程数量相同的块,然后独立处理每个块。不幸运的是,为了采取这一步骤,我们不得不告别简洁的Streams API,手动完成所有操作。

我们可以使用RandomAccessFile的API读取块,但由于它本身不支持缓冲读取,实现起来会很奇怪,需要从本地内存复制到Java缓冲区。

因此,大家都选择了mmap方法。这意味着你可以把文件当作一个大型内存数组来处理。Java长期以来一直支持mmap,依靠ByteBuffer API读取本地内存。它使用int进行索引,将映射区域大小限制在2 GB以内。

JDK团队目前正在引入一个基于long索引的新API,即MemorySegment。本着1BRC鼓励使用最新、最强大的Java特性的精神,我们就用这个:

var raf = new RandomAccessFile(file, "r");
MemorySegment mappedFile = raf.getChannel().map(
    MapMode.READ_ONLY, 0, length, Arena.global()
);

在找到精确的文件拆分位置、启动线程、等待线程等方面涉及一些棘手的细节。处理这些细节使我们的代码从最初的17行激增到120行。

首先,现在看起来像这样:

for (varcursor=0L; cursor < chunk.byteSize();){
varsemicolonPos= findByte(cursor,';');
varnewlinePos= findByte(semicolonPos +1,'\n');
varname= stringAt(cursor, semicolonPos);
vartemp=Double.parseDouble(stringAt(semicolonPos +1, newlinePos));
varstats= statsMap.computeIfAbsent(name, k ->newStationStats(name));
varintTemp=(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;
}

随着Streams API和BufferedReader的消失,我们运行一个手工编写的函数findByte()来查找分隔符。这样避免了为整行创建字符串,但仍然使用名为stringAt()的方法为名称和温度创建字符串。这两个方法如下:

private longfindByte(long cursor, int b){
for(vari= cursor; i < chunk.byteSize(); i++){
if(chunk.get(JAVA_BYTE, i)== b){
return i;
}
}
thrownewRuntimeException(((char) b)+" not found");
}

privateStringstringAt(long start, long limit){
returnnewString(
            chunk.asSlice(start, limit - start).toArray(JAVA_BYTE),
StandardCharsets.UTF_8
);
}

我们还添加了代码来收集所有线程的部分结果并合并它们。综合起来,这使处理时间大幅缩短了4倍,从66秒降到17秒!

在其他情况下,你可能会为这一成就祝贺自己,但在1BRC中,我们才刚刚开始。让我们看看这段代码的perf stat报告:

   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

每行的指令数减半,降到945。 垃圾回收时间几乎消失了。但在VisualVM中,我们可以看到仍有很多小的垃圾回收运行。现在CPU大部分时间花在stringAt上,主要是在字符串创建和从本地内存复制数据到堆分配的字节数组上。在Map.computeIfAbsent()、Double.parseDouble()和findByte()上也花费了不少时间。

让我们先从温度解析入手进行优化。

优化2:直接将温度解析为整数

我们可以大幅改进温度解析。目前的情况是,我们先分配一个字符串,然后对其调用parseDouble(),再转换为整数以进行高效存储和计算。相反,我们应该直接创建整数:

private intparseTemperature(long semicolonPos){
longoff= semicolonPos +1;
intsign=1;
byteb= chunk.get(JAVA_BYTE, off++);
if(b =='-'){
        sign =-1;
        b = chunk.get(JAVA_BYTE, off++);
}
inttemp= 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;
}

仅这一改变,我们的时间就减少了1.6倍,降到11秒。我们不仅在解析上花费的CPU周期少得多,还消除了临时字符串的分配。 我们能做些什么呢?

优化3:自定义哈希表

我们希望避免调用stringAt(),因为这涉及到复制数据和初始化一个新的字符串实例。在几乎所有情况下,该实例的唯一目的就是在HashMap中查找现有条目。

然而,如果我们坚持使用HashMap,避免调用stringAt()将非常困难。HashMap期望我们传入一个与现有实例相等的键类实例。我们最好避免这种情况。那么……也许我们应该构建一个自定义哈希表?

乍一听这可能很疯狂。难道Java不是已经有一个超级优化的HashMap实现了吗?他们说“别自作聪明,使用标准库”难道是在骗我们吗?嗯,不是的。一般来说,他们是完全正确的。但在这里,我们面临一个高度特定、高度受限的问题,通过自定义实现我们可以做得更好。除了我们想要摆脱stringAt()这个主要动机外,HashMap还必须优雅地服务于每一个用例。

private staticfinalintHASHTABLE_SIZE=2048;
privatefinalStatsAcc[] hashtable =newStatsAcc[HASHTABLE_SIZE];

privateStatsAccfindAcc(long cursor, long semicolonPos){
inthash= hash(cursor, semicolonPos);
intslotPos= hash &(HASHTABLE_SIZE -1);
while(true){
varacc= hashtable[slotPos];
if(acc ==null){
            acc =newStatsAcc(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);
}
}

privateinthash(long startOffset, long limitOffset){
inth=17;
for(longoff= startOffset; off < limitOffset; off++){
        h =31* h +((int) chunk.get(JAVA_BYTE, off)&0xFF);
}
return h;
}

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

publicbooleannameEquals(MemorySegment chunk, long otherNameOffset, long otherNameLimit){
varotherNameLen= otherNameLimit - otherNameOffset;
returnnameLen== otherNameLen &&
                chunk.asSlice(nameOffset, nameLen).mismatch(chunk.asSlice(otherNameOffset, nameLen))==-1;
}
}

这是使用它的主循环:

for (varcursor=0L; cursor < chunk.byteSize();){
varsemicolonPos= findByte(cursor,';');
varnewlinePos= findByte(semicolonPos +1,'\n');
vartemp= parseTemperature(semicolonPos);
varacc= findAcc(cursor, semicolonPos);
    acc.sum += temp;
    acc.count++;
    acc.min =Math.min(acc.min, temp);
    acc.max =Math.max(acc.max, temp);
    cursor = newlinePos +1;
}

现在我们不再每次都创建字符串,而是只存储文件中名称的位置。这样,我们就完全消除了热循环中的内存分配。垃圾回收线程现在处于空闲状态。

这本身就很棒,但主要的好处还不止于此:零分配停止了CPU缓存内容的频繁更换。现在缓存中填满了哈希表数据,这非常重要,因为在内存状态中,哈希表部分是我们无法避免随机访问的。

现在我们的运行时间降到了6.6秒,速度提升了1.7倍——我们的方法是值得的。

让我们查看一下perf stat:

    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

优化4:sun.misc.Unsafe、SWAR

到目前为止,我们对初始解决方案的改进达到了一个数量级:从66秒降到6.6秒。还不错。我们应用的技术相对容易理解,并且使用的是标准、安全的Java。

对于下一次迭代,我们将应用所有顶级解决方案都采用的一些技术:

  • • 使用sun.misc.Unsafe代替MemorySegment以避免边界检查

  • • 避免重新读取相同的输入字节:对哈希和分号搜索重复使用相同的已加载值

  • • 使用SWAR技术一次处理8字节数据来查找分号

  • • 使用@merykitty的神奇SWAR(寄存器内单指令多数据流)代码来解析温度

long cursor=0;
while(cursor < inputSize){
longnameStartOffset= cursor;
longhash=0;
intnameLen=0;
while(true){
longnameWord= UNSAFE.getLong(inputBase + nameStartOffset + nameLen);
longmatchBits= semicolonMatchBits(nameWord);
if(matchBits !=0){
            nameLen += nameLen(matchBits);
            nameWord = maskWord(nameWord, matchBits);
            hash = hash(hash, nameWord);
            cursor += nameLen;
longtempWord= UNSAFE.getLong(inputBase + cursor);
intdotPos= dotPos(tempWord);
inttemperature= parseTemperature(tempWord, dotPos);
            cursor +=(dotPos >>3)+3;
            findAcc(hash, nameStartOffset, nameLen, nameWord).observe(temperature);
break;
}
        hash = hash(hash, nameWord);
        nameLen +=Long.BYTES;
}
}

等等,看看它调用的方法:

private staticfinallongBROADCAST_SEMICOLON=0x3B3B3B3B3B3B3B3BL;
privatestaticfinallongBROADCAST_0x01=0x0101010101010101L;
privatestaticfinallongBROADCAST_0x80=0x8080808080808080L;

privatestaticlongsemicolonMatchBits(long word){
longdiff= word ^ BROADCAST_SEMICOLON;
return(diff - BROADCAST_0x01)&(~diff & BROADCAST_0x80);
}

privatestaticintnameLen(long separator){
return(Long.numberOfTrailingZeros(separator)>>>3)+1;
}

// credit: artsiomkorzun
privatestaticlongmaskWord(long word, long matchBits){
longmask= matchBits ^(matchBits -1);
return word & mask;
}

privatestaticfinallongDOT_BITS=0x10101000;
privatestaticfinallongMAGIC_MULTIPLIER=(100*0x1000000+10*0x10000+1);

// credit: merykitty
privatestaticintdotPos(long word){
returnLong.numberOfTrailingZeros(~word & DOT_BITS);
}

// credit: merykitty and royvanrijn
privatestaticintparseTemperature(long numberBytes, int dotPos){
// numberBytes contains the number: X.X, -X.X, XX.X or -XX.X
finallonginvNumberBytes=~numberBytes;

// Calculates the sign
finallongsigned=(invNumberBytes <<59)>>63;
finalint_28MinusDotPos=(dotPos ^0b11100);
finallongminusFilter=~(signed&0xFF);
// Use the pre-calculated decimal position to adjust the values
finallongdigits=((numberBytes & minusFilter)<< _28MinusDotPos)&0x0F000F0F00L;

// Multiply by a magic (100 * 0x1000000 + 10 * 0x10000 + 1), to get the result
finallongabsValue=((digits * MAGIC_MULTIPLIER)>>>32)&0x3FF;
// And apply the sign
return(int)((absValue +signed)^signed);
}

但注意一个总体情况:几乎没有if语句,这就是关键。我们用直接的按位计算取代了分支指令。

CPU会根据之前的迭代尝试预测每个if语句是进入“then”分支还是“else”分支。结果是,在准备好评估条件的所有数据之前,它就开始解码相应的指令。

所以,每当预测错误时,它就不得不丢弃所有已做的工作,重新解码其他指令。一般来说,一次分支预测错误的代价相当于10 - 15条指令。

我们还应用了SWAR思想:寄存器内单指令多数据流,这意味着将一个长整型数当作8个字节值的向量,并对每个字节执行相同操作。

在我们的案例中,semicolonMatchBits()定位ASCII分号字节,并返回一个在找到分号位置设置为1的长整型数。然后nameLen()方法将该位模式转换为告诉我们分号位置的数字。这来自一种标准技术,例如在C语言中用于高效确定以零结尾字符串的长度。

maskWord()方法获取一个包含8字节输入数据的长整型数,并将分号之后的所有字节清零。我们需要这样做来快速进行名称相等性检查。

parseTemperature()和dotPos()中的算法是@merykitty(全英麦,Quan Anh Mai)的天才创作,他专门为这个挑战设计了这个算法。它利用了ASCII - 和.的位模式特性以及其他一些技巧,一次性得出两到三位温度数字的整数值,涵盖了所有四种可能的模式(X.X、-X.X、XX.X和 - XX.X)。

// credit: merykitty
// word contains the number: X.X, -X.X, XX.X or -XX.X
privatestaticintparseTemperatureOG(long word, int dotPos){

// signed is -1 if negative, 0 otherwise
finallongsigned=(~word <<59)>>63;
finallongremoveSignMask=~(signed&0xFF);

// Zeroes out the sign character in the word
longwordWithoutSign= word & removeSignMask;

// Shifts so that the digits come to fixed positions:
// 0xUU00TTHH00 (UU: units digit, TT: tens digit, HH: hundreds digit)
longdigitsAligned= wordWithoutSign <<(28- dotPos);

// Turns ASCII chars into corresponding number values. The ASCII code
// of a digit is 0x3N, where N is the digit. Therefore, the mask 0x0F
// passes through just the numeric value of the digit.
finallongdigits= digitsAligned &0x0F000F0F00L;

// Multiplies each digit with the appropriate power of ten.
// Representing 0 as . for readability,
// 0x.......U...T.H.. * (100 * 0x1000000 + 10 * 0x10000 + 1) =
// 0x.U...T.H........ * 100 +
// 0x...U...T.H...... * 10 +
// 0x.......U...T.H..
//          ^--- H, T, and U are lined up here.
// This results in our temperature lying in bits 32 to 41 of this product.
finallongabsValue=((digits * MAGIC_MULTIPLIER)>>>32)&0x3FF;

// Apply the sign. It's either all 1's or all 0's. If it's all 1's,
// absValue ^ signed flips all bits. In essence, this does the two's
// complement operation -a = ~a + 1. (All 1's represents the number -1).
return(int)((absValue ^signed)-signed);
}

综合运用这些技术后,速度提升了2.8倍。从6.6秒降到了2.4秒。整体提升了28倍。

perf stat报告如下:

    13,612,256,700      branches
       656,550,701      branch-misses
     3,762,166,084      cache-references
        92,058,104      cache-misses
    63,244,307,290      cycles
   119,581,792,681      instructions

指令数量大幅下降,降低了3倍。由于现在每行只有120条指令,我们应该考虑如何让相同数量的指令执行得更快。有一个数据很突出:每行有0.66次分支未命中。

我们能对此做些什么呢?

优化5:利用统计数据取胜

各方法的相对影响没有太大变化。CPU在findAcc()中花费了总时间的45%,仅nameEquals()就占了19%:

boolean nameEquals(long inputBase, long inputNameStart, long inputNameLen, long lastInputWord){
inti=0;
for(; i <= inputNameLen -Long.BYTES; i +=Long.BYTES){
if(getLong(inputBase, inputNameStart + i)!= name[i /8]){
returnfalse;
}
}
returni== inputNameLen || lastInputWord == name[i /8];
}

从表面上看,这似乎效率很高。它每次以8字节为单位比较站名,甚至还使用了对最后一个块重复使用掩码操作的技巧,并将其作为lastNameWord传入。但是,它有一个循环,这会导致不可预测的分支,而且它会从内存中重新读取输入名称。

我们怎么知道循环中的分支指令是不可预测的呢?答案在于站名长度的统计数据。

如果大多数站名长度小于8字节,那么决定是否进入下一次迭代的条件几乎总是为假,这将导致可预测的分支指令。

那么,站名长度的实际分布是怎样的呢?在进行挑战时,我在一个名为Statistics.java的文件中编写了一些代码来找出这类信息。

distribution()方法会打印出名长度的统计分布。如果你在使用1BRC仓库中的一个脚手架脚本(create_meauserements.sh)生成的数据集中运行它,你会发现长度小于等于8字节和大于8字节的站名几乎各占一半。

我还编写了一个方法来模拟CPU的分支预测(Statistics.java中的branchPrediction())。CPU有一个分支历史表(BHT),它跟踪热循环中每个分支指令的行为。

表中的一个条目是一个2位饱和计数器,它根据分支条件的结果递增或递减。它不会溢出,而是在最小值/最大值处保持不变(换句话说,它会饱和)。当计数器为0或1时,它预测分支将被执行;如果是2或3,则预测分支不会被执行。

使用条件nameLen > 8运行Statistics.branchPrediction()会导致50%的分支预测错误。但是,如果我们将那行代码中的条件改为nameLen > 16,预测错误率就会降到仅2.5%。

基于这个发现,很明显我们必须编写一些代码来避免在nameLen > 8这个条件上出现任何分支指令,而是直接处理nameLen > 16的情况。

为此,我们必须按照以下思路展开分号搜索循环:

  • • 一次性执行前两步,不检查任何条件

  • • 使用位操作逻辑来合并在两个长整型字中找到分号的结果

  • • 在一个if检查中使用合并后的结果,这个检查现在涵盖了最初的16字节

我们还需要针对站名长度小于等于16字节和大于16字节的情况分别编写findAcc()和nameEquals()的特定版本。

在我的解决方案中,这将时间减少到1.8秒——又提升了33%,总的提升幅度达到约40倍。

perf stat证实了我们的推理:

     8,227,092,155      branches
        84,323,925      branch-misses
     3,219,383,623      cache-references
        69,268,236      cache-misses
    44,893,388,140      cycles
    98,225,209,459      instructions

每行的指令数只有适度的改进,从120降到98。但是看看“分支未命中”!它从每行0.657次降到0.084次,几乎降低了8倍。

这解释了大部分速度提升的原因。缓存未命中次数也有所下降,从每行0.092次降到0.069次。这可能是因为我们的统计累加器的内存布局得到了改进,现在它将前16个字节的站名存储在类实例内部,而不是在单独分配的数组中。

优化6:消除启动/清理成本

如果我们想将当前1.8秒的结果与冠军的1.5秒进行比较,就必须考虑测量方法。

在这篇文章中,我们一直报告的是内部计时,即代码自己报告的时间。而比赛中测量的外部计时包括JVM启动和结束时的清理时间。这会增加200毫秒——所以实际上我们是2.0秒,而冠军是1.5秒。

@thomaswue意识到,在输出已经产生后,大约一半的时间(100毫秒)花在了取消内存映射文件上。他找到了一种通过技巧来避免这一开销的方法,其他所有顶尖选手都立即效仿。他启动了一个子进程来实际完成工作,这样父进程在转发完所有输出后就可以立即结束,然后让子进程在后台进行清理。

为了使这个技巧生效,参赛者还必须消除JVM启动时间,否则就会加倍付出这部分成本。这会抵消所有的改进!结果是,这迫使每个人都使用提前编译成原生二进制文件的方法。

加上这两个技巧后,我们的外部计时几乎与我们一直报告的内部计时相同,这意味着我们真的很接近了!

优化7:使用更小的数据块和工作窃取

在这一点上,我们深入到了低级优化领域。进一步改进计时的技巧与所有细节紧密相关,例如:

  • • CPU的型号和架构

  • • 与RAM子系统连接的架构

  • • 编译器的细微细节

解释所有这些技巧会让我们陷入繁琐细节,而且这些知识也不具有复用性。相反,让我给你展示最后一个比较巧妙的技巧,它在实际场景中可能会很有用。

我们将工作按每个线程一个数据块进行分配的方式,可能会导致一些线程比其他线程“更幸运”,更早完成任务。当这种情况发生时,在剩余的计算过程中CPU就会未得到充分利用。

为了解决这个问题,我们可以进行一个小的更新,将工作改为分成大量固定大小的小块。一开始我们只计算块的数量,然后让线程获取它们并在准备好时计算边界。

关键在于确保每个块只被处理一次。

神奇的是,这几乎就是一行代码:

static int chunkCount;
staticfinalAtomicIntegerchunkSelector=newAtomicInteger();

...
// at program start:
chunkCount =(int)((file.length()/ CHUNK_SIZE -1)+1);

...
// in the worker thread:
varselectedChunk= chunkSelector.getAndIncrement();
if(selectedChunk >= chunkCount){
break;
}

就是这样!所有的神奇之处都发生在我们递增的原子计数器中。这个技巧又挤出了十分之一秒,降到了1.7秒。

图片

点个“在看”不失联

最新热门文章推荐:

开发者的福音:10款超棒工具让你的工作效率翻倍,告别加班熬夜的痛苦!

十亿行数据挑战:python申请出战

十亿行数据挑战:go申请出战(从15分到5秒)

十亿行数据挑战:C++如何快速高效地处理海量数据?

印度裔科学家AshishVaswani的Transformer模型为何让中国AI学者刮目相看?

传统爬虫 vs AI爬虫:为什么AI能轻松应对网站结构变化,自动理解并适应不同网页内容?

训练Transformer模型:预测股票价格(教程与代码样本)

国外C++大佬分享:多年编码后发现的 8 个 C++ 性能技巧

从美国到中国:入选AI2000榜单最顶尖学者的Trevor Darrell

参考文献: 《The Billion Row Challenge (1BRC) - Step-by-step from 71s to 1.7s》

本文使用 文章同步助手 同步