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

你好呀,我是歪歪。

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

上週不是發了這篇文章嘛:《十億行數據,從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 分之間,我一直不停的在打字,但是它只有五個版本:

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

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

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

氣得我當場...

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

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章