優化程序性能的策略匯總


寫程序需要顧及兩個方面:1.程序的簡潔性和可維護性。2.程序的運行速度。很多時候這兩者是互相制約的,編寫可讀性良好的代碼有時會損失一部分性能,而有些底層優化是以降低程序的可讀性和模塊性為代價的。實際開發過程中,我們要在這兩者之間做出權衡。在速度滿足基本要求的情況下,盡量使編寫的代碼簡潔優雅。本文主要總結了優化代碼性能的方式,暫不考慮可讀性問題。

一、高級設計

為遇到的問題選擇適當的算法和數據結構。

這要求我們對各種算法的時間復雜度和空間復雜度有一個基本的了解。並要理解各種數據結構的優缺點,要針對解決的問題選擇適當的數據結構。比如數組和鏈表的選擇:對於想要快速訪問數據,不經常有插入和刪除元素的時候,選擇數組。對於需要經常的插入和刪除元素,而對訪問元素時的效率沒有很高要求的話,選擇鏈表。

程序的算法和數據結構是程序員需要主要關心的方面,由於這方面內容比較寬泛,這里就不過多總結了。

二、基本編碼原則

1. 代碼移動

這類優化包括識別要執行多次(例如在循環里)但是計算結果不會改變的值,然后將計算部分移動到代碼前面不會被多次求值的部分。比如下面例子,每次循環都需要調用函數檢查字符串的長度,如果我們確定字符串長度不會在循環中改變的話,可以把求字符串的過程移在循環前面:

 1 //優化前
 2 void fun(){
 3   for(int i=0;i<strlen(s);i++){
 4     //某些操作...
 5   }
 6 }
 7 
 8 //優化后
 9 void fun(){
10   int len=strlen(s);//把固定的值寫在循環外面
11   for(int i=0;i<len;i++){
12     //某些操作...
13   }
14 }

2. 減少屬性查找

在web開發中,訪問對象的屬性是一個O(n)操作,對象上的任何屬性查找都要比訪問變量或者數組花費更長的時間,因為必須在原型鏈上對擁有該名稱的屬性進行一次搜索。簡而言之,屬性查找越多,執行時間越長。當多次用到對象屬性時,應該將其存儲在局部變量中。舉例如下:

1 //優化前,需要6次查找
2 var query=window.location.href.substring(window.location.href.indexOf("?"));
3 
4 //優化后,只需要4次查找
5 var url=window.location.href;
6 var query=url.substring(url.indexOf("?"));

另外,如果即可以用數字化的數組進行訪問,也可以使用命名屬性,那么使用數字位置。

3. 減少過程調用

過程調用會帶來額外的開銷,比如在內存棧中分配空間,存儲相關變量等。不過此優化原則只有在必要的時候才需要考慮,因為它是以損害代碼的模塊性為代價的。

4. 消除不必要的內存引用

由於訪問寄存器要比訪問內存快的多,所以在寫代碼時,盡量引入中間變量來保存中間結果。只有在最后的值計算出來時,才將結果存放到對象、數組或全局變量中。

三、低級優化——處理器中指令的執行過程

為了進一步提高性能,我們需要應用現代處理器的指令級並行能力,這要求我們理解現代處理器的微體系結構。

1. 現代處理器中指令的執行過程

在代碼級上,指令看上去似乎是一次執行一條,而在實際的處理器中,是同時對多條指令求值的,這個現象稱為指令級並行,在某些設計中,可以有100或更多條指令在處理中。現代處理器的整個設計主要有兩個部分:指令控制單元和執行單元。前者負責從內存中讀出指令序列,並根據這些指令序列通過譯碼生出一組針對程序數據的基本操作。而后者執行這些操作。通常,指令控制單元每個時鍾周期會把多條指令轉換出來的多個基本操作(每條指令會轉換為一個或多個基本操作),發送給執行單元,在執行單元中,這些操作會被分派到一組功能單元中,它們會並行的執行實際的操作。示例圖如下:

2. 分支預測

由於一條指令在實際執行之前,就會被取值、譯碼並將操作發送給執行單元,即在代碼執行之前已經對代碼進行了一系列處理。當遇到分支時,現代處理器會采取分支預測技術,提前加載和譯碼預測的分支會跳到的地方的指令,當分支操作被送到執行單元時,執行單元確定分支預測是否正確,如果錯誤會丟棄分支點之后計算出來的結果,並指出正確的分支目的。預測錯誤會導致很大的性能開銷。

3. 寄存器更新

每條指令的執行都會伴隨着寄存器的更新,當指令被分解為可並行執行的操作,並且指令有可能處於錯誤的分支上時,什么時候會更新寄存器呢?在指令譯碼時,關於指令的信息會放置在一個隊列中。當一條指令的所有操作都完成了,並且所有引起這條指令的分支點也都被確認為預測正確,那么這條指令就可以退役了,所有對程序寄存器的更新都可以實際被執行了。

4. 舉例

執行過程分析舉例,有如下代碼:

a=a*data[i];
i++;

設a存在寄存器%xmm0中,data+i存在寄存器&rdx中,則上面的代碼的匯編代碼如下:

vmulsd (%rdx),%xmm0,%xmm0
addq $8,%rdx

在控制單元中會將這兩個指令分解為三個操作,即1個加法操作,1個加載操作和1個乘法操作。這里乘法操作和加法操作更新時依賴的是不同的寄存器,所以兩者可以並行執行。而乘法操作依賴於加載操作,即需要先加載完%rdx處的值,再執行乘法操作。假設乘法操作所用時間為3個時鍾周期,加法和加載分別使用1個時鍾周期。則上面兩條指令總共會花費4個時鍾周期,而不是5個。

小結

通過對處理器運行原則的了解,我們會發現有兩種下界描述了程序的最大性能:
1. 延遲界限
延遲指的是一條指令從開始執行到結束所用時間,當一系列操作必須按照嚴格順序執行時,就會遇到延遲界限,因為在下一條指令執行之前,這條指令必須結束。當代碼中的數據相互關聯導致無法利用處理器指令級並行能力的時候,延遲界限會限制程序性能。
2. 吞吐量界限
要理解吞吐量界限,需要先理解一個概念:發射。發射指處理器某個功能單元通過流水線執行一系列相同操作時,兩個操作間隔的時鍾周期(想象一下流水線上的產品,一個產品從進入流水線到成品出來也許需要5分鍾,但兩個產品的間隔只有5秒,因為有大量產品同時處在流水線上)。吞吐量指每個時鍾周期所能執行的操作個數,當某個功能單元發射為T時,其吞吐量為1/T,當有C個執行相同操作的功能單元時,吞吐量為C/T。吞吐量界限為程序性能的終極限制。

四、低級優化——優化策略

低級優化的目的就是使操作能夠像流水線一樣執行,后一個操作不需要等前一個操作完成,即它不需要依賴前一個操作的數據,從而使代碼性能達到吞吐量界限。常用的方法如下:

1. 循環展開

循環展開是一種程序變換,通過增加每次迭代計算的元素數量,從而減少迭代次數。
舉例:

 1 sum=0
 2 for(i=0;i<len;i++){
 3   sum+=a[i];
 4 }
 5 //可展開如下
 6 for(i=0;i<len-1;i+=2){
 7   sum+=a[i];
 8   sum+=a[i+1];
 9 }
10 for(;i<len;i++){
11   sum+=a[i];
12 }

循環展開的主要目的是可以對循環體內的操作進行優化,從而使多個操作可以並行執行,常用的優化手段有定義多個累積變量和重新結合變換;

2. 多個累積變量

對於一個可結合和可交換的合並運算來說,比如說整數加法或乘法,我們可以通過將一組合並運算分割成兩個或更多部分,並在最后合並結果來提高性能。例如多個數乘積,可以將奇數項乘積和偶數項乘積放在不同的變量中,最后在對兩個變量求乘積。
舉例:上面循環展開循環體內的代碼可以優化如下:

 1 sum1=0;
 2 sum2=0;
 3 //循環展開如下
 4 for(i=0;i<len-1;i+=2){
 5   sum1+=a[i];
 6   sum2+=a[i+1];
 7 }
 8 for(;i<len;i++){
 9   sum1+=a[i];
10 }
11 sum=sum1+sum2;

上例中循環體內的兩個操作因為沒有依賴關系,所以會並行執行。

3. 重新結合變換

通過乘法和加法的結合律,將與寄存器無關的合並操作結合在一齊,可使多個操作並行執行。下例中第二種寫法比第一種快很多

r=r*a[i]*a[i+1]
r=r*(a[i]*a[i+1])

4. 條件數據傳送

為了避免分支預測錯誤造成的代價,可以采用條件數據傳送代替條件判斷

 


免責聲明!

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



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