1. 程式人生 > >常數優化之迴圈展開

常數優化之迴圈展開

常數優化之迴圈展開

背景

各位讀者可能在興高采烈要死要活地碼完一道題興奮地交題後也遇到過下面的情況:

或者更OI一點:

大家大概都是一邊抱怨毒瘤出題人,一邊真香地改程式碼。如果複雜度是對的,那就要考慮程式的常數是不是太大了,進而考慮怎麼優化。啥,你說開O2吸個氧不就完了?yysy,確實問題是正式比賽不知道給不給開,而且編譯器優化最重要的是在不改變程式行為的前提下優化,總不能優化錯吧,所以如果程式寫得實在是很難優化,編譯器也無能為力,還是要自己來。考慮到OI選手都是初、高中生巨佬小學生巨佬暫且不考慮,還沒有接觸大學課程,尤其是深入理解計算機系統這門課,可能對這種方法瞭解的也比較少,下面就介紹一種不知道常不用的常數優化方法——迴圈展開就是介紹點深入理解計算機系統(CSAPP)的內容

,下文的圖片也都出自該書

觀前提示:以下內容僅供參考,本大學狗也只是剛學完這門課,有些內容可能有疏漏,為了能將內容講得更容易理解,說法也不是很嚴謹

迴圈展開——CPU友好型程式碼

原理

流水線

現代CPU採用流水線設計(可以類比汽車生產流水線,分多階段),把一條機器指令的執行分成多個階段,然後每個時鐘週期儘可能多地執行不同指令的不同階段,我們可以從下面兩張圖看出流水線和不流水線的區別

  • \(I1、I2、I3\)表示待三條不同的機器指令,\(A、B、C\)代表指令的階段(當然不止3個,由CPU設計者決定)

顯然流水線只要流起來可以大大提高CPU的工作效率,前提是流起來,如果\(I2\)的\(A\)階段涉及的資料\(a\)和\(I1\)的一致,且\(I1\)操作又會修改\(a\),那麼就可能出現\(I1\)的\(C\)階段還沒完成對\(a\)的修改就被\(I2\)用了,就會出現錯誤;到這裡讀者可以類比編譯器的優化,編譯器優化不改變程式行為,流水線也不能讓程式出錯。怎麼解決涉及到資料轉發等知識,有興趣的讀者可以去看CSAPP原書,這裡不做介紹。結論是CPU的設計者設計的硬體能讓流水線很好地運作,即使在少數情況下也只犧牲一點效率。為了讓流水線更好地流,我們可以在寫程式時可以地減少程式相鄰行的資料相關。比如

x += a;
tmp = x * b;
// 可以改為
tmp = a * b + x * b;

理解現代處理器——以Intel Core i7 Haswell為例

現代處理器中有許多功能單元,它們有著各自的功能,比如Intel Core i7 Haswell,它有8個功能單元,如下圖所示

我們不需要理解載入、分支等的具體意思,我們需要理解的就是,不同功能單元能同時工作,只是它們的“業務”範圍不一定相同,理解了這些之後,就開始介紹本文的重點內容——迴圈展開

迴圈展開

迴圈展開是一種程式變換,通過增加每次迭代計算元素的數量,減少迴圈迭代次數。以求和函式為例:

//非迴圈展開
for(int i = 1; i <= n; i++)
    sum += a[i];

//2*1迴圈展開
int i;
for(i = 1; i <= n - 1; i += 2) {
    sum += a[i];
    sum += a[i + 1];
}
for(; i <= n; i++)
    sum += a[i];

//3*1迴圈展開
int i;
for(i = 1; i <= n - 2; i += 3) {
    sum += a[i];
    sum += a[i + 1];
    sum += a[i + 2];
}
for(; i <= n; i++)
    sum += a[i];

2×1和3×1迴圈展開中的2和3很好理解,就是步長的意思,至於1是什麼意思,懇請眾看官耐心看下去。

大家能都很好奇,就簡單地改改步長,能讓程式碼變快??回答這個問題之前,我們先講講迴圈展開如何改程序序的效能。迴圈展開主要從兩個方面改程序序效能:

  • 它減少了不直接有助於程式結果的運算元的數量,如2×1展開中,i += 2的執行次數小於i++的次數,i <= n的條件判斷次數也是2×1展開少
  • 它提供了一些方法,可以進一步變化程式碼,使其效能更高

對於第二點,我們可以通過一些手段來提高程式碼的效率:2×2迴圈展開

先看看程式碼怎麼寫的:

//2*2迴圈展開
int i, sum0, sum1, sum;
for(i = 1; i <= n - 1; i += 2) {
    sum0 = sum0 + a[i];
    sum1 = sum1 + a[i + 1];
}
for(i; i <= n; i++)
    sum0 += a[i];
sum = sum0 + sum1;

對比2×1迴圈展開,我們發現2×2循壞展開採用了兩個變數sum0、sum1來累積和,這就是2×2中的後面的2的含義。為啥要這樣呢?在流水線部分,我們提到過程式碼相鄰行的資料相關越小流水線越好流,效率也就更高,普通的2×1迴圈展開中,資料相關較大,sum += a[i]得到的sum值還要參與sum += a[i + 1]的運算,導致流水線的效率降低,改用兩個變數,消除了資料相關,所以程式碼執行的效率變高了

筆者在資料量為3e6的情況下隨機生成了50組測試資料,分別採用上述4種迴圈方式以及5×5的迴圈展開跑程式,取平均後的結果如下:(以下結果均是在沒開O2的情況下測的,僅供參考)

從資料上來5×5展開的效果最好,比1×1快2倍多(讀者可以嘗試3×3展開、4×4展開等),需要注意的是,不是迴圈展開級數增加了就一定能提高效能,因為常用暫存器就那麼幾個,超過一定數量後,累積變數就不能存在暫存器裡了,需要存在記憶體裡,所以效果並不一定會好,具體可以去看CSAPP原書

題外話

其實CSAPP中還有其他許多的優化方法,如利用區域性性原理——編寫快取記憶體友好型程式以及編寫編譯器友好型程式,前者優化效果較優,但技巧性教迴圈展開要強,而且因機而異,後者不知道怎麼寫能寫出來不錯了,還要編譯器友好?,但是前者涉及到的知識和儲存器的結構有較大關聯,要想講清楚可能得把CSAPP中的一、兩章都講掉,如果有空,會考慮再寫一篇利用區域性性原理的優化方法

說了這麼多,最重要的還是程式設計師友好型——自己咋舒服咋寫,對於我這種CE選手常數優化是實在沒辦法的時候的下下之選。ACM賽場上風雲莫測,常數優化也要謹慎,萬一壓根不是常數的問題不白忙活嘛,還吃罰時;OI的時候也一樣,別捨本逐末,別為了貪那一兩個資料點打上一些常數優化的騷操作結果爆零

參考文獻

深入理解計算機系統 第三