如何寫出CPU友好的代碼,百倍提升性能?

不管是什么樣的數(shù)據(jù),投其所好,才能夠優(yōu)化代碼性能。本文將用一個實際用例為大家分享如何通過用心組織的代碼來提升性能。
出現(xiàn)性能差別的代碼
CPU友好的代碼與我們平時的那些CRUD操作可能沒什么關(guān)系。但是用心組織的代碼其實也能讓性能提升百倍。我們不應(yīng)該停留在CRUD的漩渦中。今天我給大家?guī)硪粋€很神奇的現(xiàn)象,文章不長,原理通用,還請大家耐心看完。
我們可以先看下面的矩陣計算。大家也可以自己思考一下,如果是你來實現(xiàn)一個矩陣的乘法,你會怎么來做。
下圖是我給出的A、B、C 三個解題的思路。大家覺得在JVM里面,下面的代碼性能會有區(qū)別么?如果有的話,哪一個會快一點?如果沒有的話,又為什么?

下圖是benchmark運行的結(jié)果(具體的運行代碼和結(jié)果查看文末附件),是否和你想的一樣呢。

x軸是計算數(shù)組的大小,y軸是所消耗的時間。
最上兩條線是B代碼塊的結(jié)果,中間是A代碼塊的結(jié)果,最下面是C代碼塊的結(jié)果。
從運行時間角度看結(jié)果是:TC < TA < TB。從性能角度看結(jié)果是:PC > PA > PB。
大家猜對結(jié)果了么,是不是很你想的一樣呢?如果不是的話,那就慢慢往下面看。
為什么會有性能差別?
要想知道這個問題的答案,我們需要知道兩個知識點。
首先,我們需要知道Java二維數(shù)組的存儲結(jié)構(gòu)是什么樣子的。
其次,我們需要知道CPU在計算的時候它L1、L2、L3的緩存機制。
2.1 知識點
? 2.1.1 知識點一:Java二維數(shù)組的存儲結(jié)構(gòu)
下圖便是Java二維數(shù)組的一個存儲方式示意圖,意思是 int[][] array_A = new int[4][3]。

在一個數(shù)組里面存的都是“指針”,指向真實存放數(shù)據(jù)的地址塊。
每一行的數(shù)據(jù)是連續(xù)的地址,但是行與行之間的地址就不一定連續(xù)了。這一點很重要,后面會用到。
? 2.1.2 知識點二:CPU的緩存機制
CPU架構(gòu)是會演進的,高低端的參數(shù)也不一定相同。但我們畢竟不是CPU的制造者,不必每一個CPU都去細扣,我們只需要理解他的原理,在適當(dāng)?shù)臅r候做一些抽象方便理解就可以。
下圖是我當(dāng)前Mac的CPU參數(shù),大家需要注意2個東西,L2緩存、L3緩存。這2個參數(shù)就是影響我們今天討論的性能的主要因素。

下面是各個緩存的CPU的訪問時間:
L1 、L2、L3、主存的大小是逐漸增大,速度是逐漸減小的。

下面是現(xiàn)代CPU的一個架構(gòu)示意圖:

其中:
Regs,是寄存器。
d-cache,是數(shù)據(jù)緩存。
i-cache,是指令緩存。本次我們并不討論這個緩存快的影響。
CPU的緩存里面還有很多的細節(jié),知道上面的信息就已經(jīng)足夠我們理解今天的問題了。
2.2?性能損失的原因 —?緩存命中率
有了上面的各級別的緩存參考之后,我們可以想象一下,如果把上面的圖像換成是我們的二維數(shù)組呢。是不是就是下面這樣(可能沒有那么嚴(yán)謹(jǐn),但是不妨礙我們理解)。
在RAM(主存)的數(shù)據(jù)是這樣的:

L3緩存就是這樣的(紅色框選中部分):

L2緩存就是這樣的(紅色框選中部分):

有了這個這些層級的緩存之后,CPU在計算的時候就可以不用來回的到速度極慢的RAM(主存)中去找數(shù)組的數(shù)據(jù)了。
? 2.2.1 友好的遍歷方式
假設(shè)上面的數(shù)據(jù)的變量名稱是A,成員使用a來表述。
我們?nèi)?shù)據(jù)按照從左到右,再從上到下的順序來進行遍歷。

對于L2緩存來說:
第一次獲取數(shù)據(jù)a11(“1”)的時候其實是沒有數(shù)據(jù)的,所以會耗時去把a11,a12,a13(“1,2,3”)都取回來緩存起來。
當(dāng)?shù)诙稳12、a13的時候候就直接從L2緩存取了。這樣cache命中率就是 66.7%。
對于L3的情況類似。這樣的遍歷方式對于CPU來說是一個很友好且高效的。?
C代碼塊就是這種橫向優(yōu)先的訪問方式。?
A代碼塊里面對arrays_A的方式是橫向優(yōu)先遍歷的,但是在處理arrays_B的時候就是縱向遍歷的(也就是下面即將提到的方式)。?
B代碼塊所有的訪問都是縱向的(不友好的遍歷方式)。因為發(fā)揮不出CPU緩存的效果,所以性能最差。
? 2.2.2 不友好的遍歷方式
從上到下,再從左到右。

為什么這是一個不好的遍歷方式呢?
這個得結(jié)合上一節(jié)Java的二維數(shù)組的存儲結(jié)構(gòu)一起看。再來回顧一下:

從上面的存儲的結(jié)構(gòu)圖來看,其實a11,a12,a13 與 a21,a22,a23行與行之間并不是連續(xù)的。所以對于L1、L2、L3緩存來說很有可能是不能一起被緩存的(這里用了可能,具體得看L1、L2、L3的容量和數(shù)組的大小)。雖然是可能,但是通常都不會一起出現(xiàn)。
有了這個知識之后,我們再來看,先從上到下,再從左到右的順序的緩存命中率。
第一次,獲取a11,但是緩存里面沒有,找到a11之后就把a11,a12,a13緩存下來了。
第二次,獲取a21,但是緩存里面沒有,找到a21之后就把a21,a22,a23緩存下來了,假設(shè)有CPU有兩行的緩存空間。
第三次,獲取a31,但是緩存里面沒有,找到a31之后把a31,a32,a33緩存下來,并且把a11,a12,a13替換掉(緩存的空間有限,雖然具體的替換策略有很多種,并且還和數(shù)據(jù)本身的Hash有關(guān)系,這里就假設(shè)把第一次的結(jié)果覆蓋了)。
后面的邏輯重復(fù)之前的步驟。最后得到的緩存命中率就是0%。
結(jié)合文章開頭的緩存速率表格,我們就不難發(fā)現(xiàn),如果我們每次都不命中緩存的話,那么延遲帶來的耗時將會相差一個數(shù)量級。
總結(jié)
再來回顧一下我們之前的問題。

C代碼塊是橫向優(yōu)先的訪問方式。?
A代碼塊里面對arrays_A的方式是橫向順序訪問的,但是在處理arrays_B的時候就是縱向遍歷的。?
B代碼塊所有的訪問都是縱向的(不友好的遍歷方式)。因為發(fā)揮不出CPU緩存的效果,所以性能最差。
Java的二維數(shù)組在內(nèi)存里面是行連續(xù)的,但是行與行之間不一定連續(xù)。CPU在緩存大小有限的情況下,不可能把所有的數(shù)據(jù)都緩存下來。再加上每一層級訪問速度的硬件限制,就導(dǎo)致了上面的性能結(jié)果。
相信大家也和我一樣,知道原理之后,也不是那么迷惑了。
在實際的業(yè)務(wù)環(huán)境中,我們不一定能遇到這種純計算的場景。但是我們還是應(yīng)該盡量順序訪問數(shù)據(jù),不管是什么樣的數(shù)據(jù)。投其所好,才能夠優(yōu)化代碼性能。
其次,我們在訪問數(shù)據(jù)的時候,還是需要了解各種語言背后實際的存儲結(jié)構(gòu)和CPU的緩存原理,本次是講述的是Java,但是這個思想其他語言其實也是受用的。
附件
4.1 運行的環(huán)境
系統(tǒng)參數(shù):
4.2 整個benchmark的java代碼
ArrayTestBenchmark
4.3 多次運行benchmark的結(jié)果

引用
[01] 《深入理解計算機操作系統(tǒng)》
[02]?《深入理解Java虛擬機》
