這波操作看麻了!十億行資料,從71s到1.7s的最佳化之路。

why技术發表於2024-03-04

你好呀,我是歪歪。

春節期間關注到了一個關於 Java 方面的比賽,很有意思。由於是開源的,我把專案拉下來試圖學(白)習(嫖)別人的做題思路,在這期間一度讓我產生了一個自我懷疑:

他們寫的 Java 和我會的 Java 是同一個 Java 嗎?

不能讓我一個人懷疑,所以這篇文章我打算帶你盤一下這個比賽,並且試圖讓你也產生懷疑。

賽題

在 2024 年 1 月 1 日,一個叫做 Gunnar Morling 的帥哥,發了這樣一篇文章:

https://www.morling.dev/blog/one-billion-row-challenge/

文章的標題叫做《The One Billion Row Challenge》,十億行挑戰,簡稱就是 1BRC,挑戰的時間是一月份整個月。

賽題的內容非常簡單,你只需要看懂這個檔案就行了:

檔案的每一行記錄的是一個氣象站的溫度值。氣象站和溫度分號分隔,溫度值只會保留一位小數。

參賽者只需要解析這個檔案,然後並計算出每個氣象站的最小、最大和平均溫度。按照字典序的格式輸出就行了:

出題人還配了一個簡圖:

需求非常明確、簡單,對不對?

為了讓你徹底明白,我再給你舉一個具體的例子。

假設檔案中的內容是這樣的:

chengdu;12.0
guangzhou;7.2;
chengdu;6.3
beijing;-3.6;
chengdu;23.0
shanghai;9.8;
chengdu;24.3
beijing;17.8;

那麼 chengdu (成都)的最低氣溫是 6.3,最高氣溫是 24.3,平均氣溫是(12.0+6.3+23.0+24.3)/4=16.4,就是這麼樸實無華的計算方式。

最終結果輸出的時候,再注意一下字典序就行。

這有啥好挑戰的呢?

難點在於出題人給出的這個檔案有 10 億行資料。

在我的垃圾電腦上,光是跑出題人提供的資料生成的指令碼,就跑了 20 分鐘:

跑出來之後檔案大小都有接近 13G,記事本打都打不開:

所以挑戰點就在於“十億行”資料。

具體的一些規則描述和細節補充,都在 github 上放好了:

https://github.com/gunnarmorling/1brc

針對這個挑戰,出題人還提供了一個基線版本:

https://github.com/gunnarmorling/1brc/blob/main/src/main/java/dev/morling/onebrc/CalculateAverage_baseline.java

首先封裝了一個 MeasurementAggregator 物件,裡面放的就是要記錄的最小溫度、最大溫度、總溫度和總數。

整個核心程式碼就二三十行,使用了流式程式設計:

首先是一行行的讀取文字,接著每一行都按照分號進行拆分,取出對應的氣象站和溫度值。

然後按照氣象站維度進行 groupingBy 聚合,並且計算最大值、最小值和平均值。

在計算平均值的時候,為了避免浮點計算,還特意將溫度乘 10,轉換為 int 型別。

最後用 TreeMap 按字典序輸出各個氣象站的溫度資料。

這個基線版本官方的資料是在跑分環境下,2 分鐘內可以執行完畢。

而在我的電腦上跑了接近 14 分鐘:

很正常,畢竟人家的測評環境配置都是很高的:

Results are determined by running the program on a Hetzner AX161 dedicated server (32 core AMD EPYC™ 7502P (Zen2), 128 GB RAM).

參加挑戰的各路大神,最終拿出的 TOP 10 成績是這樣的:

當時看到這個成績的瞬間,我人都是麻的,第一個疑問是:我靠,13G 的檔案啊?1.5s 內完成了讀取、解析、計算的過程?這不可能啊,光是讀取 13G 大小的檔案,也需要一點時間吧?

但是需要注意的是,歪師傅有這個想法是走入了一個小誤區,就是我以為這 13G 的檔案一次性載入不完成,怎麼快速的從硬碟把檔案讀取到記憶體中也是一個考點。

後來發現是我多慮了,人家直接就說了,不用考慮這一點,跑分成績執行的時候,檔案直接就在記憶體中:

所以,最終的成績中不包含讀取檔案的時間。

但是也很牛逼了啊,畢竟有十億條資料。

第一名

我嘗試著看了一下第一名的程式碼:

https://github.com/gunnarmorling/1brc/blob/main/src/main/java/dev/morling/onebrc/CalculateAverage_thomaswue.java

過於硬核,實在是看不懂。我只能透過作者寫的一點註釋、方法名稱、程式碼提交記錄去嘗試理解他的程式碼。

在他的程式碼開頭的部分,有這樣的一段描述:

這是他的破題思路,結合了這些資訊之後再去看程式碼,稍微好一點,但是我發現他裡面還是有非常多的微操、太多針對性的最佳化導致程式碼可讀性較差,雖然他的程式碼加上註釋一共也才 400 多行,然而我看還是看不懂。

我隨便截個程式碼片段吧:

問 GPT 這個哥們,他也是能說個大概出來:

所以我放棄了理解第一名的程式碼,開始去看第二名,發現也是非常的晦澀難懂,再到第三名...

最後,我產生了文章開始時的疑問:他們寫的 Java 和我會的 Java 是同一個 Java 嗎?

但是有一說一,雖然我看不懂他們的某些操作,但是會發現他們整體的思路都幾乎是一致。

雖然我沒有看懂第一名的程式碼,但是我還是專門列出了這一個小節,給你指個路,有興趣你可以去看看。

另外,獲得第一名的老哥,其實是一個巨佬:

是 GraalVM 專案的負責人之一:

巨人肩膀

在官方的 github 專案的最後,有這樣的一個部分:

其中最後一篇文章,是一個叫做 Marko Topolnik 的老哥寫的。

我看了一下,這個哥們的官方成績是 2.332 秒,榜單第九名:

但是按照他自己的描述,在比賽結束後他還繼續最佳化了程式碼,最終可以跑到 1.7s,排名第四。

在他的文章中詳細的描述了他的挑戰過程和思路。

我就站在巨人的肩膀上,帶大家看看這位大佬從 71s 到 1.7s 的破題之道:

https://questdb.io/blog/billion-row-challenge-step-by-step/

最常規的程式碼

首先,他給了一個常規實現的程式碼,和基線版本的程式碼大同小異,只不過是使用了並行流來處理:

https://github.com/mtopolnik/billion-row-challenge/blob/main/src/Blog1.java

平時看到流式程式設計我是有點頭疼的,需要稍微的反應一下,但是在看了前三名的最終程式碼後再看這個程式碼,我覺得很親切。

根據作者的描述,這段程式碼:

  • 使用並行 Java 流,將所有 CPU 核心都用起來了。
  • 也不會陷入任何已知的效能陷阱,比如 Java 正規表示式

在一臺裝有 OpenJDK 21.0.2 的 Hetzner CCX33 機器上,跑完需要的時間為 71 秒。

第 0 版最佳化:換個好的 JVM

叫做第 0 版最佳化的原因是作者對於程式碼其實啥也沒動,只是換了一個 JVM:

預設使用 GraalVM 之後,最常規的程式碼,執行時間從 71s 到了 66s,相當於白撿了 5s,我問就你香不香。

同時作者還提到一句話:

When we get deeper into optimizing and bring down the runtime to 2-3 seconds, eliminating the JVM startup provides another 150-200ms in relief. That becomes a big deal.

當我們把程式最佳化到執行時間只需要 2-3 秒的時候,使用 GraalVM,會消除 JVM 的啟動時間,從而提供額外的 150-200ms 的提升。

到那個時候,這個就變得非常重要了。

資料指標很重要

在正式進入最佳化之前,作者先介紹了他使用到的三個非常重要的工具:

關於工具我就不過多介紹了,這裡單獨提一嘴主要是想表達一個貫穿整個最佳化過程的中心思想:資料指標很重要。

你只有收集到了當前程式足夠多的執行指標,才能對你進行下一步最佳化時提供直觀的、最佳化方向上的指導。

工欲善其事必先利其器,就是這個道理。

第一版最佳化:並行 I/O 搞起來

透過檢視當前程式碼對應的火焰圖:

https://questdb.io/html/blog/profile-blog1

透過火焰圖以及觀察 GC 情況,作者發現當前耗時的地方注意是這三個地方:

  1. BufferedReader 將每行文字輸出為字串
  2. 處理每一行的字串
  3. 垃圾收集 (GC):使用 VisualGC 可以看到,差不多每秒要 GC 10 次甚至更多。

可以發現 BufferedReader 佔用了大量的效能,因為當前讀取檔案還是一行行讀取的嘛,效能很差。

於是大多數人意識到的第一件事就是採用並行化 I/O。

所以,我們需要把待處理的檔案分塊。分多少塊呢?

有多少個執行緒就分成多少個塊,每個執行緒各自處理一個塊,這樣效能就上去了。

檔案分塊讀取,大家自然而然的就想到了 mmap 相關的方法。

mmap 可以用 ByteBuffer API 來搞事情,但是使用的索引是 int 型別,所以可對映的大小有 2GB 的限制。

前面說了,在這個挑戰中,光是檔案大小就有 13G,所以 2GB 是捉襟見肘的。

但是在 JDK 21 中,支援一個叫做 MemorySegment 的東西,也可以幹 mmap 一樣的事情,但是它的索引使用的是 long,相當於沒有記憶體限制了。

除了使用 MemorySegment 外,還有一些細節的處理,比如找到正確的分割檔案的位置、啟動執行緒、等待執行緒處理完成等等。

處理這些細節會導致這一版的程式碼從最初的 17 行增加到了 120 行。

這是最佳化後的程式碼地址:

https://github.com/mtopolnik/billion-row-challenge/blob/main/src/Blog2.java

在這個賽題下,我們肯定是需要再迴圈中進行資料的解析和處理的,所以迴圈就是非常重要的一個點。

我們可以關注一下程式碼中的迴圈部分,這裡面有一個小細節:

這個迴圈是每個執行緒在按塊讀取檔案大小,裡面用到了 findByte 方法和 stringAt 方法。

在第一個版本中,我們是用的 BufferedReader 把一行內容以字串的形式讀進來,然後按照分號分隔,並生成城市和溫度兩個字串。

這個過程就涉及到三個字串了。

但是這個哥們的思路是啥?

自定義一個 findByte 方法,先找到分號的位置,然後把下標返回回去。

再用自定義的 stringAt 方法,結合前面找到的下標,直接解析出“城市和溫度”這兩個字串,減少了整行讀取的記憶體消耗。

相當於少了十億個字串,在字串處理和 GC 方面取得了不錯的表現。

這一波操作下來,處理時間直接從 66s 下降到了 17s:

然後再看火焰圖:

https://questdb.io/html/blog/profile-blog2-variant1

可以發現 GC 的時間幾乎消失了。

CPU 現在大部分時間都花在自定義的 stringAt 上。還有相當多的時間花在 Map.computeIfAbsent 方法 、Double.parseDouble 方法和 findByte 方法

其中 Double.parseDouble 方法是解析溫度用的。

作者打算先把這個地方給攻下來。

第二版最佳化:最佳化溫度解析方法

在這版最佳化中,作者直接將溫度解析為整數。

首先,目前的做法是,首先分配一個字串,然後對其呼叫 parseDouble() 方法,最後轉換為整數以進行高效的儲存和計算。

但是,其實我們應該直接建立整數出來,沒必要走字串繞一圈。

同時我們知道,溫度的取值範圍是 [-99.9,99.9],所以針對這個範圍,我們搞個自定義方法就行了:

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

這波操作下來,處理時間又減少了 6s,來到了 11s:

再看對應火焰圖:

https://questdb.io/html/blog/profile-blog2-variant2

溫度解析部分的耗時佔比從 21.43% 降低到 6%,說明是一次正確的最佳化。

接下來,可以再搞一搞 stringAt 方法了。

第三版最佳化:自定義雜湊表

首先,要最佳化 stringAt 方法,我們得知道它是幹啥的。

我們看一眼程式碼:

在經歷了上一波最佳化之後,stringAt 目前在程式碼中的唯一作用就是為了獲取氣象站的名稱。

而獲取到這個名稱的唯一目的是看看當前的 HashMap 中有沒有這個氣象站的資料,如果沒有就新建一個 StationStats 物件,如果有就把之前的 StationStats 物件拿出來進行資料維護。

此外,在賽題中還有這樣的一個資訊,雖然有十億行資料,但是隻有 413 個氣象站:

既然 key 的大小是可控的,那基於這個條件,作者想了一個什麼樣的騷操作呢?

他直接不用 HashMap 了,自定義了一個雜湊表,長這樣的:

https://github.com/mtopolnik/billion-row-challenge/blob/main/src/Blog3.java

主要看一下程式碼中的 findAcc 方法,你就能明白它是幹啥的了:

透過 hash 方法計算出指定字串,即氣象站名稱的 hash 值之後,從自定義的 hashtable 中取出該位置的資料。

首先標號為 ① 的地方,如果沒有取到資料,則說明沒有這個氣象站的資料,新建一個放好,返回就完事。

如果取到了資料,來到標號為 ② 的地方,看看取到的資料和當前要放的資料對應的氣象站名稱是不是一樣的。

如果是則說明已經有了,取出來,然後返回。

如果不是,說明啥情況?

說明 hash 衝突了,來到標號為 ③ 的地方進行下標加一的動作。

然後再次進行迴圈。

來,你告訴我,這是什麼手法?

這不就是開放定址來解決 hash 衝突嗎?

所以 findAcc 方法,就可以替代 computeIfAbsent 方法。

透過自定義的 StatsAcc 雜湊表來代替原生的 HashMap。

而且前面說了,key 的大小是可控的,如果自定義 hash 表的初始化大小控制的合適,那麼整個 hash 衝突的情況也不會非常嚴重。

這一波組合拳下來,執行時間來到了 6.6s,火焰圖變成了這樣:

https://questdb.io/html/blog/profile-blog3

大量的時間花在了前面分析的 findAcc 方法上。

同時作者提到了這樣一句話:

同樣的程式碼,如果放到 OpenJDK 上跑需要執行 9.6s,比 GraalVM 慢了 3.3s。

我滴個乖乖,這就是一個 45% 的效能提升啊。

第四版最佳化:使用 Unsafe 和 SWAR

在這一版最佳化開始之前,作者先寫了這樣一段話:

大概意思就是說,到目前為止,我們用到的都是常規且有效的解決方案,並且是 Java 標準、安全的用法。

即使止步於此也能學到很多最佳化技巧,可以在實際的專案中進行使用。

如果你繼續往下探索,那麼:

Readability and maintainability also take a big hit, while providing diminishing returns in performance. But, a challenge is a challenge, and the contestants pressed on without looking back!
可讀性和可維護性也會受到重創,同時效能的收益會遞減。但是,挑戰就是挑戰,參賽者們繼續努力,沒有回頭!

簡單來說,作者的意思就是打個預防針:接下來就要開始上強度了。

所以,在這個版本中,作者應用一些排名靠前的選上都在用的方案:

  • 使用 sun.misc.Unsafe 而不是 MemorySegment,來避免邊界檢查
  • 避免重新讀取相同的輸入位元組:重複使用載入的值進行雜湊和分號搜尋
  • 每次處理 8 個位元組的資料,使用 SWAR 技術找到分號分隔符。
  • 使用 merykitty 老哥提供的牛逼的 SWAR(SIMD Within A Register)程式碼解析溫度。

這是這一版的程式碼:

https://github.com/mtopolnik/billion-row-challenge/blob/main/src/Blog4.java

比如其中關於迴圈處理資料的部分,看起來就很之前很不一樣了:

然後你再看裡面 semicolonMatchBits、nameLen、maskWord、dotPos、parseTemperature 這些方法的呼叫,直接就是一個懵逼的狀態,看著頭都大了:

但是你仔細看,你會發現這幾個方法是作者從其他人那邊學來的:

比如這個叫做 merykitty 的老哥,提供瞭解析溫度的程式碼,雖然作者加入了大量的註釋說明,但是我也只是大概就看懂了不到三層吧。

這裡面大量的使用了位運算的技巧,同時你仔細看:幾乎沒有 if 判斷的存在。這是重點,用直接的位運算替換了分支指令,從而減少了分支預測錯誤的成本。

此外,還有很多我第一次見、叫不上名字的奇技淫巧。

透過這一波“我看不懂,但是我大受震撼”的操作搞下來,時間降低到了 2.4s:

第五版最佳化:統計學用起來

現在,我們的火焰圖變成了這樣:

https://questdb.io/html/blog/profile-blog4

耗時主要還是在於 findAcc 方法:

而 findAcc 方法的耗時在於 nameEquals 方法,判斷當前氣象站名稱是否出現過:

但是這個方法裡面有個 if 判斷,以位元組為單位比較兩個字串的內容,每次比較 8 個位元組。

首先,它透過迴圈逐步比較兩個字串中的對應位元組。在每次迭代中,它使用 getLong 方法從輸入字串中獲取一個 64 位的長整型值,並與另一個字串中的相應位置進行比較。如果發現不相等的位元組,則返回 false,表示兩個字串不相等。

如果迴圈結束後沒有發現不相等的位元組,它會繼續檢查是否已經比較了輸入字串的所有位元組,或者最後一個輸入字串的位元組與相應位置的字串位元組相等,那麼表示兩個字串相等,則返回 true。

那麼問題就來了?

如果氣象站名稱長度全都是小於 8 個位元組,會出現啥情況?

假設有這樣的一個前提條件,是不是我們就不用在 for 迴圈中進行 if 判斷了,直接一把就比較完成了?

很可惜,沒有這樣一個提前條件。

但是,如果在資料集中,氣象站名稱長度絕大部分都小於 8 個位元組那是不是就可以單獨處理一下?

那到底資料分佈是怎麼樣的呢?

這個問題問題出去的一瞬間,統計學啪的一下就站出來了:這個老子在行,我算算。

所以,作者寫了一個程式來統計分析資料集中氣象站名稱的長度:

https://github.com/mtopolnik/billion-row-challenge/blob/main/src/Statistics.java

基於程式執行結果,最終的結論如下:

透過分析作者發現,賽題的資料集中氣象站名稱長度幾乎均勻分佈在 8 位元組以上和 8 位元組以下。

執行 Statistics.branchPrediction 方法,當條件是 nameLen > 8 時導致了 50% 的分支預測失敗。

也就是說,十億資料中有一半的資料,都是小於 8 位元組的,都是不用特意進行 if 判斷的。

但如果將條件更改為 nameLen > 16,那麼預測失敗率將降至 2.5%。

根據這一發現,很明顯,如果要進一步最佳化程式碼,就需要編寫一些特定的程式碼來避免在 nameLen > 8 上使用任何 if 判斷,直接使用 nameLen > 16 就行。

這是這一版的最終程式碼,可讀性越來越差了:

https://github.com/mtopolnik/billion-row-challenge/blob/main/src/Blog5.java

但是最終的成績是 1.8s:

哦,對了,如果你對於分支預測技術不太清楚,那你可能看得比較懵。

但是分支預測,在效能挑戰中,特別是最後大家比分都咬的非常緊的情況下,每次都是屢立奇功,戰功赫赫,屬於高手間過招殺手鐧級別的最佳化手段。

繼續最佳化

再後面作者還有這兩個部分。

消除啟動/清理成本:

使用更小的檔案分塊和工作竊取機制:

這後面就完全是基於這個賽題進行定製化的最佳化,可移植性不強了,作者就沒有進行詳細描述,再加上一個我也是沒怎麼看明白,就不展開講了。

反正這兩個組合拳下來,又搞了 0.1s 的時間下來,最終的成績為 1.7s:

我實在是學不動了,有興趣的同學可以自己去看看原文的對應部分。

寫在後面

其實關於這篇文章,我原想法是看懂前三名的程式碼,然後對程式碼進行解析、對比,找到他們思路的共同點和差異點,但是後來他們的程式碼確實我看不懂,所以我放棄了這個想法。

但是我知道,只要我願意花時間、有足夠的時間,我肯定可以慢慢地把他們的這幾百行程式碼啃透,但是我也只是想了想而已,很快就放棄了這個思路。

我想如果是大學的時候,我看到這個比賽,我會覺得,真牛逼,我得好好研究一下。

然而現在不一樣了,參加工作了,看到了這個比賽,我還是會覺得,真牛逼,但是對我寫業務程式碼幫助不大,就不深究了,淺嘗輒止。

大學的時候學習是靠自己無窮的精力和對於掌握新知識的樂趣撐著,現在學習主要靠一時衝動。

但是我還是強烈建議感興趣的朋友,按照我問中提到的地址,自己去研究一波別人提交的程式碼。

也許你也會產生一樣的疑問:他們寫的 Java 和我會的 Java 是同一個 Java 嗎?

我的答案是:不是的,他們寫的 Java 是自己熱愛的 Java,我們寫的 Java 只是掙錢的 Java。

沒有高低貴賤之分,但是能讓你不經意間,從業務程式碼的深海中抬頭看一眼,看到自己熟悉的領域中,更廣闊的世界。

相關文章