老實說,分支預測,是高手過招的殺手鐧,但是對寫業務程式碼沒啥幫助。

why技术發表於2024-03-11

你好呀,我是歪歪。

這篇文章給大家盤一下“分支預測”這個聽起來玄乎,但是對寫業務程式碼沒有任何卵用的小技巧。

上週不是發了這篇文章嘛:《十億行資料,從71s到1.7s的最佳化之路。》

這裡面就提到了一嘴:

雖然對於寫業務程式碼沒啥卵用,但是高手過招的殺手鐧我們還是瞭解一下。

再看程式碼

我就還是順著前面“十億行資料”文章中的場景給大家繼續講,如果你沒看過前一篇也沒有關係,這兩篇是相對獨立的。

只要知道前一篇文章的賽題就行了,我再複述一遍。

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

有一個十億行資料的檔案,檔案的每一行記錄的是一個氣象站的溫度值。氣象站和溫度用分號分隔,溫度值只會保留一位小數。參賽者需要解析這個檔案,然後並計算出每個氣象站的最小、最大和平均溫度,並按照字典序的格式輸出就行了。

雖然有十億行資料,但是一共只有 413 個氣象站。

所以我們需要一個類似於這樣的資料結構:雜湊表<氣象站名稱,氣象站物件>

當遇到 Hash 衝突的時候,對比一下兩個“氣象站名稱”,來判斷是不是同一個物件。

一般來說我們拿著 String 一對比,就算是搞定了,但是這是挑戰賽,如果涉及到字串,那麼可能會在 GC 方面拉跨時間。

所以有個參賽者給出了這樣的對比名稱是否一致的程式碼:

首先能進這個方法說明發生了 hash 衝突。

如果 nameEquals 返回為 true,則說明衝突是因為這個氣象站之前已經出現過,在 hash 表中維護過了。

如果 nameEquals 返回為 false,則說明確實是兩個不同的氣象站,發生了一個單純的 hash 衝突,需要用“開放定址”來解決 hash 衝突。

那 nameEquals 是怎麼來判斷到底是那種情況呢?

思路是在迴圈中,每次按照偏移量(inputNameStart)加上 8 位元組讀取檔案,即一次讀 8 個字元出來進行對比,在對比完整個字串之後,如果都能匹配的上,則說明是同一個氣象站。

比如,一個長度為 18 的氣象站名稱,那就需要對比 3 次,才能確定是否是同一個字串。

這個邏輯,懂得起吧?

上面這個邏輯稍微有點麻煩,我給你 debug 一下,截幾張圖,你大概就能明白了。

首先,進入這個方法的時候 inputNameLen 為 18,表示當前是長度為 18 的氣象站名稱發生了 hash 衝突:

每次迴圈只對比 8 個字元長度,所以理論上這個迴圈要進行 3 次,才能確定對比的名稱是否一致。

程式確實是對比了三次,但是這裡作者還做了一個最佳化,先按下不表。

既然是對比,那麼對比雙方分別是誰呢?

一邊是從檔案中新讀取的資料,一邊是已經在 Hash 表中的資料。

首先,看一下第一次 8 個字元的對比:

透過上圖可以看出,第一次迴圈,i=0,對比雙方均是 “Nakhon R”。

第二次迴圈,i=8,對比雙方均是 “atchasim”。

此時按理來說應該進入第三次迴圈,但是由於此時 i=16,inputNameLen=18,那麼 for 迴圈是這樣的:

不滿足迴圈條件,迴圈結束了。

但是很明顯不對啊,這才對比了 16 個字元呢,還有兩個字元沒對比呢?

別慌,這不是還有一行程式碼嘛:

最後一次迴圈,直接進行“不足額”對比,因為在另外的程式碼中解析資料時,已經解析出了“不足額”部分,也就是這裡的 “a;”。

少了一次 for 迴圈處理,這個就是我前面按下不表的“一個最佳化”。

反正就是令人歎為觀止的最佳化手段。

如果你還是沒看懂,沒有關係,很正常,我也是反覆除錯之後才理解到了他的思路。

你只要抓住一個點:

在 for 裡面每次讀取了 8 個位元組進行判斷。當字串的名稱大於 8 個位元組的時候,就要對比多次。

還是拉胯

但是,注意我要說但是了。

就這麼牛逼的最佳化之下,作者透過火焰圖發現,這個方法還是一定程度上拉胯了的效能:

然後他接著咂摸了一句:

我得老天爺呀,要是大多數名稱都少於 8 個位元組長度就好了呀。這樣的話,進入 if 分支的條件將始終為 false,我就可以 predictable branch instruction 了,又可以最佳化一波了呀。

啥是 predictable branch instruction?

我也不知道,但是為什麼不問問神奇的 GPT 呢:

上面這段話,對應到程式碼的部分就是這樣的:

假設氣象站的名稱長度為 6,那麼是不是直接都不會進入 for 迴圈,因為不滿足上圖中框起來的 for 迴圈條件。

那麼就不會涉及到 if 判斷,直接到標號為 ① 這部分的程式碼。

也就是說,如果所有的名稱都是不大於 8 個長度的,那麼整個方法就可以簡化到這個樣子,只有一行 return 語句,直接進行對比,效能肯定又上去了:

然而很可惜並沒有這個如果,檔案裡面一眼望去有大量的超過八個字元長度的名稱。

但是既然都想到這裡了,我們是不是可以統計一下十億行資料中氣象站名稱長度的分佈到底是怎麼樣的呢,分析一波資料情況,萬一有意外收穫呢?

資料分析

於是,那個哥們就掏出了這樣一份程式碼:

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

這個程式碼中的 distribution 方法就是在統計十億行資料中氣象站名稱長度的分佈情況,對應的程式碼很簡單,可讀性很高,我就不細說了,你感興趣就自己去看一眼。

需要特別說明的是:

這裡統計的氣象站的名稱長度是包含了分號的,所以後面我提到氣象站的名稱長度時,也都是包含了最後的分號。

這是我本地跑出來的結果,十億資料中有 53.51% 的氣象站名稱長度小於等於 8,有 46.49% 的資料長度大於 8:

“X” 代表大概的佔比,需要注意的是,如果某一行沒有 “X” 並不代表沒有這樣的資料,而是佔比過小,透過程式碼縮放比例之後,連一個 “X” 都佔不到。

同時從結果上看,可以分析出長度在大於 16 這個區間的資料非常非常的少。

那麼這個資料可以幫助我們幹啥事呢?

這就得結合程式碼中的 branchPrediction 方法分析了,你看這個方法的名稱就很有意思啊,branch Prediction,分支預測。

邏輯有很簡單:

首先標號為 ① 的地方是在統計名稱長度小於等於 8 和大於 8 的資料情況。

標號為 ② 的地方,程式碼很簡單,維護了一個 hisCount 和 missCount,一開始我也摸不清楚作者具體在幹啥。

但是他提到了一個叫做“2-bit saturating counter(2 bit 飽和計數器)”的東西:

搜了一波,學習了一下,發現標號為 ② 的地方,就是實現了一個 2 bit 飽和計數器。

它的執行機制是可以分析分支預測的成功率,如果有興趣,你可以用相關關鍵詞查一下,這是維基上相關的介紹:

你搜的時候如果看到了上面那個狀態機對應的圖,就說明找對地方了。我這裡就不展開了,提一句是為了表達這個程式最終輸出的資料是有科學依據的,不是胡來的。

從作者的描述看,他分別以 nameLen>8 和 nameLen>16 跑了一把,執行結果很不一樣:

這是我本地 nameLen>8 時的執行結果:

這是 nameLen>16 時的執行結果:

拿出來對比一波:

  • nameLen>8 :Taken: 464,890,509 (46.5%), not taken: 535,109,491 (53.5%), hits: 504,913,641 (50.5%), misses: 495,086,359 (49.5%).
  • nameLen>16:Taken: 24,209,831 (2.4%), not taken: 975,790,169 (97.6%), hits: 975,205,173 (97.5%), misses: 24,794.827 (2.5%)

主要看 “misses” 這一項的輸出,從 49.5% 降低到了 2.5%。

misses 這個指標,代表的是分支預測錯誤情況佔比。

在 pref 這個效能分析工具的輸出中:

  • branches 是指遇到的分支指令數。
  • branch-misses 是預測錯誤的分支指令數。

在作者的描述中,經過這波最佳化之後,他的 branch-misses 下降了八倍,也就是說提高了分支預測成功率:

從而導致成績從 2.4s 提升到了 1.8s。

這波最佳化

在分析“這波最佳化到 1.8s”之前,我們得先看看 2.4s 這個成績的時候,核心的迴圈邏輯在幹啥:

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

如果只關注我框起來的部分,那麼就是每次以 8 個位元組為長度進行讀取。

迴圈結束的條件是第 108 行 matchBits != 0 為 true 的時候。

那麼 matchBits 是個啥玩意呢?

是 semicolonMatchBits 方法的返回值,這個方法是這樣的:

這個方法我只是看了一眼,眼睛就開始疼了,窒息感就上來了。

我直接放棄理解,把它扔給了這個哥們:

它說了這麼大一堆,你就記住它的第一句話就行了:semicolonMatchBits,這個方法用於在一個長整型中查詢分號的位置。

如果返回的 matchBits 不是 0,則說明當前讀取到的 8 個位元組裡面有一個分號,然後就進入到 if 迴圈中,開始解析資料,最後 break 當前迴圈,處理下一波資料。

虛擬碼大概是這樣的:

//讀取位置偏移量
long nameLen = 0;
while(true){
//從給定的記憶體地址中讀取一個長整型數;
long nameWord = UNSAFE.getLong(偏移量 + nameLen);
if(長整型數對應的字串裡面有分號){
解析資料
if(hash衝突){
1.呼叫前面分析過的nameEquals方法判斷名稱是否相等
2.相等則說明是同一個
3.不相等則用開放尋找法解決hash衝突
}
break;
}
//沒有分號,說明名字還沒拿完,需要繼續讀取下 8 個字元
nameLen += 8;
}

按理來說,這個程式碼裡面全是操作的記憶體地址,沒有實際操作字串,也有大量的位運算,處理十億行資料只需要 2.4s 了,效能已經很高了。

那麼 1.8s 對應的“這波最佳化”到底是什麼呢?

對應程式碼在這裡:

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

我們還是關注這個迴圈:

為什麼要重點關注迴圈,我也簡單的提一句。是因為迴圈相關的程式碼,是處理每一行資料都用的到的,相當於是最核心邏輯,所以要關注它。

但是這個程式碼可讀性真的不高,我除錯了大概幾十次,終於懂了他在幹啥事兒了。

我不會一行行去撕程式碼,主要是理一下思路。

我挑和本文相關的重點部分給你撕。

首先是在每一次迴圈的時候,都會走到標號為 ① 的部分。

這個部分是直接讀取了 2 個 8 位元組長度出來,即 nameWord0 和 nameWord1。

然後再分別判斷 nameWord0 和 nameWord1 裡面包不包含分號。

如果有,則說明 nameWord0 和 nameWord1 裡面有一個完整的氣象站名稱,則進入標號為 ② 的程式碼,開始解析資料。

對應的具體的例子是這樣的:

第一個 8 位元組轉化為字串之後讀出來是這樣的:Dar es S

第二個 8 位元組轉化為字串之後讀出來是這樣的:alaam;17

第二個 8 位元組包含分號,則進行資料解析。最終解析出來的氣象站名稱,就是這樣的:Dar es Salaam;

把 nameWord0 和 nameWord1 儲存到 StatsAcc 物件中:

我知道,這個 StatsAcc 物件是突然冒出來的,但是它不重要,你可以把它理解為一個氣象站物件,裡面封裝的是氣象站名稱、最低、最高、平均氣溫相關的欄位。

好,現在我問你一個問題:如果後面又解析出來一個名稱為“Dar es Salaam;”的氣象站,是不是會出現 hash 衝突?

這個時候我們怎麼判斷到底是名稱一樣帶來的衝突還是真的就衝突了?

是不是涉及到名稱對比了?

於是這裡作者專門寫了一個 findAcc2 和 nameEquals2 方法:

你看這個 nameEquals2 方法,和我們前面剛剛分析過的“我得老天爺呀,要是大多數名稱都少於 8 個位元組長度就好了呀”對應的 nameEquals 方法是不是很像,最後只保留了一個 return 語句:

是的,他們就是同一個邏輯。只不過在 nameEquals2 方法這裡,它一次性對比了兩個 8 位元組,或者準確的說:對於長度小於等於 16 個位元組的氣象站名稱,它在這個方法裡面一次性對比完成了,並沒有任何的 if 分支判斷。

注意,我說的是“長度小於等於 16 個位元組”,這個條件又是從哪裡冒出來的?

因為 nameEquals2 方法是 findAcc2 方法在呼叫,而 findAcc2 方法只有在前面標號為 ② 的部分在呼叫:

能進入標號為 ② 的部分,前提條件我剛剛說的是什麼來著?

直接讀取了 2 個 8 位元組長度出來,即 nameWord0 和 nameWord1,然後分別判斷後發現 nameWord0 和 nameWord1 裡面至少有一個包含分號。

如果分號在 nameWord0 的第二個位元組,說明氣象站的名稱長度為 1。

如果分號在 nameWord1 的最後一個位元組,說明氣象站的名稱長度為 16。

所以,能進這個方法裡面的,說明這個氣象站的長度是小於等於 16 個位元組的。

這個 nameEquals2 就是作者為長度小於等於 16 個位元組的氣象站定製的,和 nameEquals 對比起來,就是在出現 hash 衝突的時候,可以少走一個 for 迴圈和 if 分支判斷。

十億行資料,只有 416 個氣象站名稱,你想想“對比名稱是否相等”的頻率有多高,在這麼高的頻率下,節約了 for 迴圈和一個 if 判斷,收益還是很可觀的。

那作者為什麼要為長度小於等於 16 個位元組的氣象站定製一個方法呢?

為什麼不給長度小於等於 8 個位元組的氣象站定製一個方法呢?

是時候讓這個“平平無奇”的資料再次出現了:

因為長度小於等於 16 個位元組的氣象站在整個資料中的佔比是 97.6%。

這個資料決定了應該給誰定製方法。

所以作者說,他要給名稱長度小於等於 16 個位元組的情況專門寫一個 findAcc 方法和 nameEquals 方法:

理解了標號為 ② 的地方,標號為 ③ 的地方就很好理解了:這裡面專門處理長度大於 16 個位元組的少數情況。

標號為 ④ 的地方就是維護雜湊表的動作。

相對於 2.4s 的版本,1.8s 的版本最大的最佳化就是優先處理了長度小於等於 16 個位元組情況。

對應到具體的程式碼,就是這個 if 分支判斷:

結合前面的資料分析,我們知道絕大部分資料都是小於 16 個長度的,所以絕大部分情況下都會滿足這個 if 分支。

優先處理絕大部分情況,這樣就會提高分支預測的成功率。

好了,現在你大概知道 2.4s 到 1.8s 這之間的主要最佳化就是基於分支預測來的。

也再一次印證了,這種到了 CPU 指令級別的最佳化手段,對於寫業務程式碼確實沒啥卵用。

換個視角

前面分析了“十億行資料”比賽中一個參賽大佬,眾多最佳化實思路中的一個。

現在我們換個視角,跳出這個比賽。

提到分支預測,你在網上搜尋相關資料的時候,大機率是繞不開 stackoverflow 上這個問題的:

https://stackoverflow.com/questions/11227809/why-is-processing-a-sorted-array-faster-than-processing-an-unsorted-array
為什麼處理已排序陣列比處理未排序陣列更快?

提問者在問題裡面附了一份 Java 程式碼。我放在這裡,你粘過去就能跑:

import java.util.Arrays;
import java.util.Random;

public class Main
{
public static void main(String[] args)
{
// Generate data
int arraySize = 32768;
int data[] = new int[arraySize];

Random rnd = new Random(0);
for (int c = 0; c < arraySize; ++c)
data[c] = rnd.nextInt() % 256;

// !!! With this, the next loop runs faster
Arrays.sort(data);

// Test
long start = System.nanoTime();
long sum = 0;
for (int i = 0; i < 100000; ++i)
{
for (int c = 0; c < arraySize; ++c)
{ // Primary loop.
if (data[c] >= 128)
sum += data[c];
}
}

System.out.println((System.nanoTime() - start) / 1000000000.0);
System.out.println("sum = " + sum);
}
}

程式碼邏輯很簡單,隨機生成一個 32768 大小的陣列,陣列內的數值的資料範圍為 (-256,256)。

然後對其中大於等於 128 的資料進行求和,求和的動作迴圈了 10w 次。

在我的電腦,上如果沒有 Arrays.sort(data) 這一行程式碼,執行結果要 7.66s。如果加上排序的邏輯,則只需要 2.4s。

那麼問題就來了:為什麼處理已排序陣列比處理未排序陣列更快?

經過前面的鋪墊你肯定知道了,這不就是分支預測在搞鬼嘛。

在這個 if 判斷中:

如果 data 數值是排好序的,那麼在判斷完所有的 127 之後,剩下的數值全是符合條件的資料,分支預測成功率咔咔就上去了。

這部分效能的提升完全抹去了陣列排序的那點消耗。

然後我們看一下這個問題下的高贊回答:

一上來沒廢話,開口就知道是老江湖了:You are a victim of branch prediction fail。

victim,看起來有點陌生哈,是個考研詞彙:

然後高贊回答舉了一個很貼切的例子,就是上面這個火車軌道交叉口的圖,我給你搬運一下。

假設你現在是老老年間的一個交叉口操作員,又一輛列車來了。但是你不知道它要走哪個方向。為什麼要強調老老年間呢?

因為那個時候沒有電話、無線電啥的,反正就是別人不能提前告訴你他要怎麼走。

所以,你就要讓列車停下來,問老司機他要往哪個方向走,然後你去扳對應的方向。

老司機每次停車也覺得煩,你每次去問也覺得煩。

那有沒有更好的方法?

有,你可以去猜測這趟車要去哪個方向,反正不是 A 路線就是 B 路線嘛,你先給他掰到 A 路線上去。

如果你猜對了,老司機直接就開走了。

如果你猜錯了,司機還是會將停車,你重新給他扳一下就行。

所以,如果你每次都猜對,老司機就永遠不必停下來。

如果你經常猜錯,老司機還是要花費大量時間停車、倒車、重新啟動、罵娘。

具體到提問者的這個問題:

N 代表不滿足 if 條件,T 代表滿足 if 條件。

如果排好序之後,CPU 基於“歷史經驗”來分析 N 和 T 的結果是好預測的,未排序,則反之。

接著老司機這個案例,回到我們前面的賽題部分。

對於這個 if 分支:

你可以理解為,有十億輛車,其中 98% 的車都要走 A 路線,只有 2% 的車要裝怪去走 B 路線。

所以我作為一個交叉口操作員,我就一直猜你要走 A 路線,這樣我猜中的機率是 98%。 和一個個的停下來,然後去問的方式比起來,這個效率蹭蹭蹭、蹭蹭蹭、蹭蹭蹭的就上去了。

道理,就是這個道理。

再換個視角

歪師傅在這裡繼續給你換個視角。 在 Dubbo 官網中,有這樣的一個連結:

https://cn.dubbo.apache.org/zh/blog/2019/02/03/%E6%8F%90%E5%89%8Dif%E5%88%A4%E6%96%AD%E5%B8%AE%E5%8A%A9cpu%E5%88%86%E6%94%AF%E9%A2%84%E6%B5%8B/

這個連結裡面也提到了我們剛剛說到的 stackoverflow 上的問題。

類似的分支預測的最佳化,在 Dubbo 的原始碼裡面也有。

就是這個部分:

org.apache.dubbo.remoting.transport.dispatcher.ChannelEventRunnable

來,我問你一個問題。

如果在沒有任何鋪墊的情況下,你看到這樣的程式碼,是不是會覺得很奇怪,感覺是兩個不同的人寫的。一個喜歡用 if,一個喜歡用 switch。

純看程式碼邏輯的話,針對這些狀態的判斷,都用 if 或者都用 switch 是更優雅的。

混用看起來有一種不倫不類,感覺想要裝逼,但是又不知道具體是裝什麼逼的感覺。

但是官網上有這樣的一句話:

一個 channel 建立起來之後,超過 99.9% 情況它的 state 都是 ChannelState.RECEIVED,那麼可以考慮把這個判斷提前。

結合我們前面的分析,再加上這一句話,你是不是開始品出點什麼味道來了?

是的,就是分支預測的味道。

同時連結裡面還提供了一個 benchmark 驗證。

測試了跑 100w 次,其中極大部分狀態都是 RECEVIED 的情況:

驗證了只有 switch 的情況:

也驗證了 if+switch 混用的情況:

歪師傅還額外加了一個只用 if,但是 if 的第一個條件不是 RECEIVED 的情況:

在我本地跑出來的結果是這樣的:

確實是 if+switch 的模式對應的吞吐量更大一點,效能更好一點。

所以,曾經有人看到 Dubbo 這部分程式碼後,提了一個最佳化版本:

https://github.com/apache/dubbo/pull/7486/files

把這段 if+switch 程式碼刪除了:

然後提交了一版基於列舉的程式碼實現:

我看了一下,列舉的實現方式優雅,確實優雅,但是被拒絕了。

在中介軟體的定位下,在效能的優勢面前,優雅,不值一提。

另外,關於 Dubbo 的這個案例對應到我們前面賽題中就更加類似了,我給你放在一起,你自己品一品:

  • Dubbo:一個 channel 建立起來之後,超過 99.9% 情況它的 state 都是 ChannelState.RECEIVED,那麼可以考慮把這個判斷提前。
  • 賽題:有十億行資料,其中氣象站名稱不超過 16 位長度的資料超過 97.5%,那麼可以考慮把這部分資料過濾出來,進行針對性的處理。

好了,本文寫到這裡就打算收尾了。

本來在我最開始的構思的時候,還應該有一部分關於“為什麼分支預測正確了之後效能就提高了”的描述,打算是從 CPU 指令流水線的角度切入的。

但是我沒時間寫了。

而且這樣的文章其實網上也不少了,我就在這裡提一嘴,如果你感興趣的話自己去找找吧,就當是個課後作業吧。

什麼,你問為什麼沒有時間寫了?

這篇文章是我在有道雲筆記裡面寫的,之前一直用的好好的,不知道為啥,寫這篇文章的時候出現了兩次資料丟失的情況,就是寫著寫著整篇文章突然被清空了,也不支援撤回。

這個“顯示歷史版本”的功能真的搞得我很迷,不知道這個產品它啥邏輯:

比如 20:15 分到 23:23 分之間,我一直不停的在打字,但是它只有五個版本:

五個版本就算了,關鍵是它最後兩次版本之間,雖然差了半小時,但是內容只差了一兩行。

關鍵這種“突然被情況的情況”還出現了兩次,所以有一部分段落,我寫了三次,實在是有點搞心態了,打斷了思路。

本來週末就只有一天時間的,結果還浪費了一些。

氣得我當場...

什麼,你問我為什麼週末只有一天時間?

因為我和以前不一樣了,以前兩天都要卷,現在我長大了,我要留一天時間出去玩。

相關文章