淺談程式優化

發表於2015-03-27

當初在學校實驗室的時候,常常寫一個演算法,讓程式跑著四處去晃盪一下回來,結果也就出來了。可工作後,演算法效率似乎重要多了,畢竟得真槍實彈放到產品中,賣給客戶的;很多時候,還要搞到嵌入式裝置裡實時地跑,這麼一來真是壓力山大了~~~。這期間,對於程式優化也算略知皮毛,下面就針對這個問題講講。

首先說明一下,這裡說的程式優化是指程式效率的優化。一般來說,程式優化主要是以下三個步驟:

1.演算法優化

2.程式碼優化

3.指令優化

演算法優化


演算法上的優化是必須首要考慮的,也是最重要的一步。一般我們需要分析演算法的時間複雜度,即處理時間與輸入資料規模的一個量級關係,一個優秀的演算法可以將演算法複雜度降低若干量級,那麼同樣的實現,其平均耗時一般會比其他複雜度高的演算法少(這裡不代表任意輸入都更快)。

比如說排序演算法,快速排序的時間複雜度為O(nlogn),而插入排序的時間複雜度為O(n*n),那麼在統計意義下,快速排序會比插入排序快,而且隨著輸入序列長度n的增加,兩者耗時相差會越來越大。但是,假如輸入資料本身就已經是升序(或降序),那麼實際執行下來,快速排序會更慢。

因此,實現同樣的功能,優先選擇時間複雜度低的演算法。比如對影象進行二維可分的高斯卷積,影象尺寸為MxN,卷積核尺寸為PxQ,那麼

直接按卷積的定義計算,時間複雜度為O(MNPQ)

如果使用2個一維卷積計算,則時間複雜度為O(MN(P+Q))

使用2個一位卷積+FFT來實現,時間複雜度為O(MNlogMN)

如果採用高斯濾波的遞迴實現,時間複雜度為O(MN)(參見paper:Recursive implementation of the Gaussian filter,原始碼在GIMP中有)

很顯然,上面4種演算法的效率是逐步提高的。一般情況下,自然會選擇最後一種來實現。

還有一種情況,演算法本身比較複雜,其時間複雜度難以降低,而其效率又不滿足要求。這個時候就需要自己好好地理解演算法,做些修改了。一種是保持演算法效果來提升效率,另一種是捨棄部分效果來換取一定的效率,具體做法得根據實際情況操作。

 

程式碼優化


程式碼優化一般需要與演算法優化同步進行,程式碼優化主要是涉及到具體的編碼技巧。同樣的演算法與功能,不同的寫法也可能讓程式效率差異巨大。一般而言,程式碼優化主要是針對迴圈結構進行分析處理,目前想到的幾條原則是:

a.避免迴圈內部的乘(除)法以及冗餘計算

這一原則是能把運算放在迴圈外的儘量提出去放在外部,迴圈內部不必要的乘除法可使用加法來替代等。如下面的例子,灰度影象資料存在BYTE Img[MxN]的一個陣列中,對其子塊  (R1至R2行,C1到C2列)畫素灰度求和,簡單粗暴的寫法是:

但另一種寫法:

可以分析一下兩種寫法的運算次數,假設R=R2-R1,C=C2-C1,前面一種寫法i++執行了R次,j++和sum+=…這句執行了RC次,則總執行次數為3RC+R次加法,RC次乘法;同  樣地可以分析後面一種寫法執行了2RC+2R+1次加法,1次乘法。效能孰好孰壞顯然可知。

 

b.避免迴圈內部有過多依賴和跳轉,使cpu能流水起來

關於CPU流水線技術可google/baidu,迴圈結構內部計算或邏輯過於複雜,將導致cpu不能流水,那這個迴圈就相當於拆成了n段重複程式碼的效率。

另外ii值是衡量迴圈結構的一個重要指標,ii值是指執行完1次迴圈所需的指令數,ii值越小,程式執行耗時越短。下圖是關於cpu流水的簡單示意圖:

簡單而不嚴謹地說,cpu流水技術可以使得迴圈在一定程度上並行,即上次迴圈未完成時即可處理本次迴圈,這樣總耗時自然也會降低。

先看下面一段程式碼:

這段程式碼實現的功能很簡單,對陣列a的不同元素累加一個不同的值,但是在迴圈內部有3個分支需要每次判斷,效率太低,有可能不能流水;可以改寫為3個迴圈,這樣迴圈內部就不  用進行判斷,這樣雖然程式碼量增多了,但當陣列規模很大(N很大)時,其效率能有相當的優勢。改寫的程式碼為:

關於迴圈內部的依賴,見如下一段程式:

其中f,g,h都是一個函式,可以看到這段程式碼中x依賴於a[i],y依賴於x,z依賴於xy,每一步計算都需要等前面的都計算完成才能進行,這樣對cpu的流水結構也是相當不利的,盡  量避免此類寫法。另外C語言中的restrict關鍵字可以修飾指標變數,即告訴編譯器該指標指向的記憶體只有其自己會修改,這樣編譯器優化時就可以無所顧忌,但目前VC的編譯器似乎不支  持該關鍵字,而在DSP上,當初使用restrict後,某些迴圈的效率可提升90%。

 

c.定點化

定點化的思想是將浮點運算轉換為整型運算,目前在PC上我個人感覺差別還不算大,但在很多效能一般的DSP上,其作用也不可小覷。定點化的做法是將資料乘上一個很大的數後,將  所有運算轉換為整數計算。例如某個乘法我只關心小數點後3位,那把資料都乘上10000後,進行整型運算的結果也就滿足所需的精度了。

 

d.以空間換時間

空間換時間最經典的就是查表法了,某些計算相當耗時,但其自變數的值域是比較有限的,這樣的情況可以預先計算好每個自變數對應的函式值,存在一個表格中,每次根據自變數的  值去索引對應的函式值即可。如下例:

後面的查表法需要額外耗一個陣列double aSinTable[360]的空間,但其執行效率卻快了很多很多。

 

e.預分配記憶體

預分配記憶體主要是針對需要迴圈處理資料的情況的。比如視訊處理,每幀影象的處理都需要一定的快取,如果每幀申請釋放,則勢必會降低演算法效率,如下所示:

前一段程式碼在每幀處理都malloc和free,而後一段程式碼則是有上層傳入快取,這樣內部就不需每次申請和釋放了。當然上面只是一個簡單說明,實際情況會比這複雜得多,但整體思想  是一致的。

 

指令優化


對於經過前面演算法和程式碼優化的程式,一般其效率已經比較不錯了。對於某些特殊要求,還需要進一步降低程式耗時,那麼指令優化就該上場了。指令優化一般是使用特定的指令集,可快速實現某些運算,同時指令優化的另一個核心思想是打包運算。目前PC上intel指令集有MMX,SSE和SSE2/3/4等,DSP則需要跟具體的型號相關,不同型號支援不同的指令集。intel指令集需要intel編譯器才能編譯,安裝icc後,其中有幫助文件,有所有指令的詳細說明。

例如MMX裡的指令 __m64 _mm_add_pi8(__m64 m1, __m64 m2),是將m1和m2中8個8bit的數對應相加,結果就存在返回值對應的位元段中。假設2個N陣列相加,一般需要執行N個加法指令,但使用上述指令只需執行N/8個指令,因為其1個指令能處理8個資料。

實現求2個BYTE陣列的均值,即z[i]=(x[i]+y[i])/2,直接求均值和使用MMX指令實現2種方法如下程式所示:

使用指令優化需要注意的問題有:

a.關於值域,比如2個8bit數相加,其值可能會溢位;若能保證其不溢位,則可使用一次處理8個資料,否則,必須降低效能,使用其他指令一次處理4個資料了;

b.剩餘資料,使用打包處理的資料一般都是4、8或16的整數倍,若待處理資料長度不是其單次處理資料個數的整數倍,剩餘資料需單獨處理;

 

補充——如何定位程式熱點


程式熱點是指程式中最耗時的部分,一般程式優化工作都是優先去優化熱點部分,那麼如何來定位程式熱點呢?

一般而言,主要有2種方法,一種是通過觀察與分析,通過分析演算法,自然能知道程式熱點;另一方面,觀察程式碼結構,一般具有最大迴圈的地方就是熱點,這也是前面那些優化手段都針對迴圈結構的原因。

另一種方法就是利用工具來找程式熱點。x86下可以使用vtune來定位熱點,DSP下可使用ccs的profile功能定位出耗時的函式,更近一步地,通過檢視編譯保留的asm檔案,可具體分析每個迴圈結構情況,瞭解到該迴圈是否能流水,迴圈ii值,以及制約迴圈ii值是由於變數的依賴還是運算量等詳細資訊,從而進行有針對性的優化。由於Vtune剛給卸掉,沒法截圖;下圖是CCS編譯生成的一個asm檔案中一個迴圈的截圖:

最後提一點,某些程式碼使用Intel編譯器編譯可以比vc編譯器編譯出的程式快很多,我遇到過最快的可相差10倍。對於gcc編譯後的效率,目前還沒測試過。

相關文章