優化程序性能


 
 

 

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

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

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

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM