1. 程式人生 > >深入理解計算機系統----第五章優化程式效能

深入理解計算機系統----第五章優化程式效能

轉載地址https://www.jianshu.com/p/4586dc676807

編寫執行的快的程式有三個因素:①選擇合適的演算法和資料結構;②理解編譯器的能力,使用有效的方式讓編譯器能進行優化;③對於運算量特別大的程式,可能還需要進行任務分解。在這一過程中可能還需要對程式的可讀性和執行速度進行權衡。

在閱讀這一章節的過程中花費了大量的時間對我自己的自動辦公軟體進行了優化,算是學以致用。選擇合適的演算法和資料結構不在本章的講解內容中,我們從編譯器的能力和侷限性講起著重介紹幾種提高程式執行速度的方法

1.1 編譯器的侷限性


編譯器遵循的一個優化程式的原則是:安全優化,也就是說為了保證程式的正常執行(優化後的版本與未優化的版本有一致的行為,這不是廢話嗎)編譯器一般都是很保守的。來看一個例子:

安全優化:儲存器別名

從上面的例子不能看出,一般情況下twiddle2要求三次儲存器引用(讀*xp,讀*yp,寫*xp)而中twiddle1要求六次儲存器引用(2次讀*xp,2次讀*yp,2次寫*xp)。所以twiddle2的效率要高於twiddle1,但是如果考慮到xp等於yp,指向儲存器中的同一個位置的時候,我們用twiddle2來優化twiddle1的版本就會造成程式的執行結果的不同。比如當xp = yp = 2的時候,f(twiddle1) = 8 ;而f(twiddle2) =  6 這就是我們說的編譯器的侷限性。

1.2 表示程式的效能CPE


在繼續介紹優化大法的時候,我們對提高程式的效能做一個量化的參考,在以後的章節中好對比我們的優化後版本的執行效率。

CPE:每元素週期(Cycles Per Element),使用時鐘週期,度量每隔週期執行了多少條指令。通常當一個標有“4GHz”的處理器,這表示的是處理器時鐘執行頻率4X10的9次方Hz每秒,那麼一個時鐘週期就是時鐘頻率的倒數,為0.25納秒。

我們來看一個計算集合值的兩個函式,我們假設有集合a = {1,2,4,5,7,9,10,12,16}集合p為集合a的前置和也就是p={1, 1+2, 1+2+4, 1+2+4+5,1+2+4+5+7,……1+2+4+5+7+9+10+12+16}

我們有兩種計算前置和p的方式,psum1和psum2:

迴圈展開技術

psum1是我們通常用到的版本,看起來也比較順眼,psum2是我們以後要詳細講解的迴圈展開技術,核心的思想就是每次迴圈計算兩個元素p[i]和p[i+1]從而減少了迴圈的次數。這個內容我們以後講解。

來看一下兩個函式的效能對比,資料說話:

x軸表示處理的元素,y軸表示週期

我們可以很明顯的看出來,當處理的資料量小的時候,兩個版本的區別不大,但當週期在1000以上的時候,能處理元素的個數就明顯不同了而且這種趨勢越拉越大。

1.3優化大法好:一個程式的進化過程


智人的進化過程

從大約7萬年前的認知革命開始,智人的進化經歷了漫長的過程,終於實現了從動物到“上帝”的轉變,我們將從一個簡單的程式示例講起帶領大家一步步實現這個過程,當然不會花費上萬年的時間。

① 原始版本:程式示例

計算一個向量的集合

有必要解釋一個combine1函式的作用:計算一個向量的集合

我們的向量有如下資料結構:

向量由頭資訊加上指定長度的陣列表示

我們定義typedef int data_t,方便我們用data_t表示不同的int、float、doubule資料。

我們使用: #define IDENT 0 和 #define OP   +來對不同的運算進行求值,其中OP代表運算子號,而IDENT代表不同的初始值。

好了,作為一個起點,我們來看看我們的黑猩猩版本:combine1的效率:

未優化版本的效率

的確有點兒慘不忍睹,我們能為他做些什麼呢?開始來進行 一些改進吧
② 程式碼移動:消除迴圈的低效率

改進迴圈的效率:將vec_length移除迴圈外

一個看上去無足輕重的程式碼片段可能隱藏有漸近低效率,上面combine2只是將求得向量長度的vec_length移除了迴圈外,因為向量的長度不會隨著迴圈的進行而改變。我們來看看效能的改變:

③ 減少函式的呼叫

分析:combine2的程式碼可以看出,在迴圈的過程中每次都會呼叫get_vec_element來訪問向量的元素,對於陣列的引用,檢查邊界是合理的,但分析我們向量的資料結構不難看出,不進行邊界檢查我們也能夠進行合法的訪問:

消除迴圈中的函式呼叫

就像《葵花寶典》開篇就講到的內容,欲練此功揮刀自宮,當我們在進行迴圈體內的呼叫函式優化的時候,必然會損害一些程式的模組性,慎用!

④ 消除不必要的儲存器引用

分析:combine3中每次合併計算會將值累計在指標dest指定的位置上,我們來看看彙編程式碼:

rbp儲存dest的值

從以上彙編程式碼中我們看出,dest的值存放在rbp中,每次迴圈,要先讀rbp到xmm0,計算後的結果又會重新寫入到rbp中去,這樣寫很浪費。我們能夠消除這樣不合理的引用:

臨時區域性變數acc存放中間結果

我們使用區域性變數acc儲存累計計算的結果,這樣就消除了每次迴圈都要對儲存器進行取值和寫回,使得程式效能有顯著的提高。

1.4 理解現代處理器分析combine4的效率瓶頸


到目前為止的優化都不依賴於機器的特性,我們將學習現代處理器的一些知識,比如:關鍵路徑、延遲界限以達到處理器級別的優化。

上圖是一個簡易的處理器框架圖,在實際的處理中,處理器同時對多條指令求值(指令級並行),同時又呈現出一種簡單的順序執行的現象。 整個框架分為兩個大部分:指令控制單元(ICU)和指令執行單元(EU),前者負責從儲存器中讀出指令序列,生成對資料的基本操作。後者負責執行。我們分別進行講解:

指令控制單元(ICU):ICU從指令快取記憶體(包含最近訪問的指令)中讀取指令,通常在ICU當前執行指令很早之前就開始取指,譯碼併發送到EU單元執行。當遇到了分支指令的時候:處理器採用分支預測,投機執行,在未確定該執行哪些操作的時候就對不同的分支目標地址進行取值和譯碼甚至執行。如果預測錯誤就回到最初的位置。使用投機執行技術求得的值不會存放在暫存器和資料儲存器中,暫存器中的退役單元控制著暫存器的更新,只有當所有的分支都確定是正確的時候,指令才會退役,所有對暫存器的更新才會實際執行,否則清空該指令。

指令譯碼:將實際的指令轉化為一組基本的操作 addl %eax,4(%edx)轉為:①從儲存器中載入一個值到處理器中;②將載入的值加上eax;③重新寫回到儲存器中

指令執行單元(EU):接受指令譯碼傳來的一組操作,然後分配到功能單元中,這些功能單元包括:分支、乘除、加法、載入和寫儲存器。其中對儲存器的訪問,通過載入和儲存功能單元對資料快取記憶體的訪問來實現。

!暫存器重新命名機制的實現方式:

先來看看什麼是暫存器重新命名:

圖1

指令4,5,6在功能上並不依賴於1,2,3的執行,但是必須要等待1,2,3完成之後才能執行4.

通過改變一下暫存器的名字可以解除限制:

圖2:將R1重新命名為R2

實現方式:當一條更新R1為R2指令譯碼時,將[r(R1),t(R2)] 的對應關係加入到一張表中,隨後當圖1指令4需要再次訪問到R1的時候,傳送到執行單元的值會將R2作為運算元源的值,而當M[2048]完成賦值任務以後,會形成(v,t)的結果,指明標記的結果M[2048]。所有等待R2的值都會使用v作為源值轉發。這樣做的好處就是值可以從一個操作直接轉發到另一個操作,而不是寫到暫存器檔案再讀出來。

我們學習了一些基礎的知識,我們重新來分析一下combine4的一些特性:

combine4:瓶頸在迴圈部分

已float的乘法為例

我們所熟悉的指令譯碼器,會將以上這4條指令擴充套件為5個步驟:

combine4的迴圈程式碼圖形化表示

我們簡單的來理解一下mulss指令的兩個操作:load指令載入rax、rdx的值並將計算的結果直接傳入到mul指令中,與xmm0進行乘法運算並將結果寫入到xmm0中。

我們重新畫一下上圖的內容,使得結果看起來更清晰一些:

圖b中我們刪除了白色區域(無相關項)和沒有修改暫存器的部分,只留下了迴圈執行過程中對xmm0和rdx迭代進行的一系列操作。

關鍵路徑

終結一下:我們可以看出,兩大關鍵鏈條分別是:mul對acc的操作,和add對i的操作,而左邊的mul鏈條會成為關鍵路徑。通過對處理器結構的分析,我們接下來不難看出,要再一步進行優化,就只有對關鍵路徑進行優化了。(繼續講解迴圈展開技術)

1.5 迴圈展開:一場真正意義上的進化(combine5)


迴圈展開能減少迴圈開銷的影響

還是以我們之前講到的向量為例:

我們假設有集合a = {1,2,4,5,7,9,10,12,16}使用combine函式進行求和運算:我們模擬計算機的執行順序,一步步在草圖上分析combine5程式碼實現的功能。

k=2迴圈展開兩次,字太醜見諒

總結:我們之所以將limit定義為length-1,是向量的長度不一定是2的倍數,如上圖中的9,為了不至於在第一次迴圈中越界訪問,我們將limit設定為length-1.雖然我們用到了兩次迴圈,但迴圈展開大大縮短的關鍵路徑,提高了效率。我們將這個思想歸納為迴圈展開k次,k < length + 1 ,我們上面講的內容就是k=2次,一次計算2個元素的和。我們看看效率的提升:

浮點運算無變化

注:為什麼浮點運算的沒有效能的提升?雖然展開了兩次迴圈,但是必須要順序的執行,所以沒有效能的提升。

兩次運算展開後是順序執行的

1.6 進一步優化:提高並行性(combine6、combine7)


分析:我們將累積變數放在一個單獨的acc中,在前面的計算完成前,不能計算新的acc值

兩次迴圈展開,使用兩路並行

效能對比圖:

怎樣理解combine6帶來的效能提升:

我們看到唯一不同的地方在與迴圈①(標號12)處程式碼中,加入兩個累積變數:acc0和acc1,這樣做有什麼好處呢?

acc = (acc OP data[i]) OP data[i+1];   轉變為:

acc0 = acc0 OP data[i];   和  acc1 = acc1 OP data[i+1];

帶入分析圖(combine6)

我們再來看看圖形化的資料分析:

引入新變數acc0和acc1分配到不同暫存器暫存器xmm0和xmm1

這樣一來關鍵路徑就成了兩路並行,效率大大提升了:

cimbine6的關鍵路徑

還有沒有其他方法能打破順序相關而提高效率?來看看combine7變種:

combine7重新結合變換

標號12的語句中與combine5相比,只是結合方式發生了變化,將:

acc = (acc OP data[i]) OP data[i+1]  變成了 acc = acc OP (data[i] OP data[i+1])

我們來對比一下combine5和combine7兩個版本的資料流圖:

資料流圖對比

在combine7的版本中,第一個mul通過兩個load指令將i和i+1的乘機計算出來,然後交給第二個mul將乘積累積到xmm1(acc)中。就不像combine5中的load mul順序執行,必須等到第一個load mul執行完成以後才能進行第二次load和mul操作。我們將combine7的模型複製幾次就能看的關鍵路徑變成了n/2個操作,這就帶來了效能的提升:

combine7:我們看到只有一條關鍵路徑,而且包含n/2個操作

總結:到目前為止,我們已經完成了從combine1到combine7的進化,帶來了至少10倍以上的效率提高,我們發現迴圈展開、並行累積值在多個變數中,是可靠的提高程式效能的方法。那還有那些限制因素呢制約著程式的效能呢?

一些限制因素:

① 暫存器溢位:當我們的並行度超過了可用的暫存器數量,編譯器就會將結果溢位到棧中,效能就會急劇下降,筆記訪問儲存器的時間要長很多。

② 避免分支預測和預測錯誤處罰:1> 不要過分關心可預測的分支,因為帶來的效能差異很小;2> 書寫適合用條件傳送實現的程式碼。

1.7理解儲存器效能


分析:為什麼要理解儲存器的效能?

當我們要處理的資料小於1000個元素的向量,資料量不會超過8000個位元組,這些內容都會存放在多個快取記憶體儲存器中,已方便我們快速訪問。接下來我們會研究在快取記憶體中的載入和儲存操作對效能的影響,充分利用快取記憶體來編寫高效率的程式碼

載入的效能:

我們在對連結串列的訪問中,可以看出載入函式對效能的影響,舉個例子:

載入操作的延遲

我們來看看ls = ls->next這句的彙編程式碼:

movq是這個迴圈的關鍵瓶頸

在標號3中,使用movq指令,載入值到rdi暫存器中,而載入操作又依賴於rdi來計算載入的位置,也就是說,必須要等到前一次載入完成才能進行下一次迴圈。這個函式的CPE等於4也就是說載入的延遲為4.

儲存的效能:

分析:從理論上來講,儲存操作並不影響任何暫存器的值,不會產生任何資料相關。而只有載入操作是受儲存的影響的,因為只有載入操作讀取的是有儲存器寫操作的值。

寫讀的相關性:

寫讀相關

為了討論寫和讀的相關性,我們來看看上述程式碼的兩種不同的情況:假設a[0]=-10,a[1]=17:

互不相干的情況CPE=2

舉例A中可以看出,初始化的條件下a{-10,17},val = 0, cnt = 3;當程式開始執行迴圈的時候,寫操作:*dest = val 而讀操作:val = (*src)+1訪問的分別是不同位置,a[0]和a[1]。就是我們前面說過的資料不相關。

讀寫相關CPE=6

而舉例B的情況就完全不一樣了,dest和src操作的是同一塊位置a[0],一個儲存器讀的結果依賴於一個最近的儲存器的寫。為什麼讀寫相關以後程式的效能就降低了?我們來看看載入和儲存單元的內部構造:

載入和儲存單元的細節

在上圖的內部構造中,我們發現儲存單元多了一個儲存緩衝區,這樣做有一個好處就是,當一些列儲存操作開始執行的時候,不需要等待快取記憶體更新完成就能夠開始執行了。而當一條載入操作發生的時候,載入單元必須先檢查儲存緩衝區,看看有沒有相匹配的條目,如果匹配成功就從儲存緩衝區中取出資料作為載入的結果。

內迴圈資料流圖

上圖的內容有點兒亂,我們來說明一下:

①指令movl被譯碼成兩個操作:s_addr和s_data其中前者負責計算儲存器的地址,並在儲存緩衝區中建立一個條目,設定地址欄位;後者負責設定該條目的資料欄位;

②s_addr同s_data右邊的箭頭表示,設定資料欄位必須要等到計算地址階段完成才能進行。此外,第二條movl指令被譯碼成了load指令,這條載入指令必須要檢查所有的地址,包括正在讀寫的地址,所以s_addr與這條load指令也有相關性;還有一條虛線相關性,連線s_data和load,這表示如果兩個地址相同,那麼load必須要等到s_data將資料段設定到儲存緩衝區。我們將上圖修改一下,大家容易理解:

讀存資料相關流圖

標號①表示:儲存地址必須在資料被儲存之前計算出來;

標號②表示:load操作將它的地址與所有未完成操作的地址進行比較;

標號③表示:資料相關,當訪問相同位置時出現

我們剔除不影響資料操作的關聯後形成如下圖:

讀寫相關

我們清楚的看到了兩條的關鍵路徑,其中左邊表示的是:儲存載入相關;右邊表示的是增加資料值相關。將以上圖複製幾次,並同不相關的資料進行比較,我們能看到讀寫相關對CPE的影響了:

左邊地址不同,右邊相同

總結:對儲存器的操作,只有當載入和儲存地址都被計算出來了以後才能確定其對效能的影響。

1.8 優化程式大法總結


到目前為止,我們基本上上講完了所有的優化程式效能的方法,套路如下:

① 高階設計:選擇適當的演算法和資料結構,要提高警惕,避免漸近低效率;

② 基本編碼原則:

*消除連續的函式呼叫,有可能的時候將計算移動到迴圈體外;

*消除不必要的儲存器引用,引入臨時變數儲存中間值。

③ 低階優化:

*展開迴圈,降低開銷;

*提高並行,使用多個累積變數或者重新結合,用良好的風格重新條件操作。

在實際的優化程式的過程中,我們不可能像之前講到的簡單程式那樣,快速的分析出一個程式片段的效能瓶頸,畢竟在真實的專案中原始碼的量相當大,這時候我們有必要運用一些軟體來分析程式的效能瓶頸,Unix系統中有一個GPROF可以實現相關分析,在此不做講解了。

備註:(Amdahl定律)Gene Amdahl曾經發現過一個很有意思的現象,最後以Amdahl命名了這個定律,大意就是:當我們選擇提高某個系統的效率的時候,被改進的這一部分效率,對整體的影響,在於被改進部分到底有多重要(聽起來像廢話)。我們來舉個例子,加入一個軟體的耗時分為Told=(1+6+3)=10我們將6這個部分的效率提高了3倍,變成了Tnew=(1+2+3)=6。整個系統的加速還是不大。這就是Amdahl定律要告訴我們的主要觀點,要想獲得整體效能的提升,我們必須要提高很大一部分系統的速度。單靠一個方面是不行的。



作者:進擊吧巨人
連結:https://www.jianshu.com/p/4586dc676807
來源:簡書
簡書著作權歸作者所有,任何形式的轉載都請聯絡作者獲得授權並註明出處。