編譯器工程師眼中的好代碼:Loop Interchange

摘要:本文將以Loop Interchange的場景爲例,講述在編寫代碼時可以拿到更優性能的書寫方式。

本文分享自華爲雲社區《編譯器工程師眼中的好代碼(1):Loop Interchange》,作者:畢昇小助手。

編者按:C/C++代碼在編譯時,編譯器將源碼翻譯成CPU可識別的指令序列並生成可執行代碼,而最終代碼的運行效率取決於由編譯器生成的可執行代碼。在大部分情況下,編寫源代碼時就已經決定了程序可以在何種程度下被編譯器優化。即使對源代碼做微小改動也可能會對編譯器生成的代碼運行效率產生重大影響。因此,源代碼的優化可以在一定程度上幫助編譯器生成更高效的可執行代碼。

本文將以Loop Interchange的場景爲例,講述在編寫代碼時可以拿到更優性能的書寫方式。

1、Loop Interchange 相關基本概念

1.1 訪問局部性

訪問局部性指的是在計算機科學領域中應用程序在訪問內存的時候,傾向於訪問內存中較爲靠近的值。這種局部性是出現在計算機系統中的一種可預測行爲,我們可以利用系統的這種強訪問局部性來進行性能優化。訪問局部性分爲三種基本形式,分別爲時間局部性、空間局部性、循序局部性。

而本文主要講述的Loop Interchange主要是利用了空間局部性。空間局部性指的是,最近引用過的內存位置以及其周邊的內存位置容易再次被使用。比較常見於循環中,比如在一個數組中,如果第3個元素在上一個循環中被使用,那麼本次循環中極有可能會使用第4個元素;如果本次循環確實使用第4個元素,就是命中上一次迭代所prefetch到的cache數據。

所以對於數組循環運算,可以利用空間局部性這一特徵,保證兩次相鄰循環中對數組元素的訪問在內存上是更加靠近的,即循環訪問數組中的元素時stride越小,相應的性能可能會有所優化。

那麼,數組在內存上是如何存儲的呢?

1.2 Row-major 和 Column-major

Row-major 和 Column-major 是兩種將多維數組存儲在線性存儲中的方式。數組的元素在內存中是連續的;Row-major ordering代表行的連續元素在內存中彼此相鄰,而Column-major ordering則是代表列的連續元素彼此相鄰,如下圖所示。

雖然Row和Column的名稱看起來像是特指二維數組,但是Row-major和Column-major也可以推廣適用於任何維度的數組。

那麼在C/C++中,數組是以以上哪種方式存儲的呢?

舉一個小例子,用cachegrind工具來展示C使用兩種不同的訪問形式的CPU的cache丟失率對比。

按行訪問:

#include <stdio.h>
int main(){
 size_t i,j;
 const size_t dim = 1024 ;
    int matrix [dim][dim];
 for (i=0;i< dim; i++)
 for (j=0;j <dim;j++)
            matrix[i][j]= i*j;
 return 0;
}

按列訪問:

#include <stdio.h>
int main(){
 size_t i,j;
 const size_t dim = 1024 ;
    int matrix [dim][dim];
 for (i=0;i< dim; i++){
 for (j=0;j <dim;j++){
            matrix[j][i]= i*j;
 }
 }
 return 0;
}

根據上述C代碼中對相同數組的兩種不同訪問方式時cache丟失率的對比,可以說明在C/C++代碼中,數組是以Row-major形式儲存的。也就是說,如果前一步訪問了a[i][j],那麼對a[i][j+1]的訪問會命中cache。這樣就不會執行對主存儲器的訪問,而cache比主內存快,因此遵循相應編程語言的儲存形式使其可以命中cache可能會帶來優化。

至於其他常用的編程語言,Fortran、MATLAB等則是默認Column-major形式。

1.3 Loop Interchange

Loop Interchange利用系統傾向於訪問內存中較爲靠近的值的特徵以及C/C++ Row-major的特點,通過改變循環嵌套中兩個循環之間的執行順序,增加整體代碼空間局部性。此外,它還可以啓用其他重要的代碼轉換,例如,Loop Reordering就是Loop Interchange擴展到兩個以上循環被重新排序時的優化。在LLVM中,Loop Interchange需要通過使能-mllvm -enable-loopinterchange選項啓用。

2、優化示例

2.1 基礎場景

簡單看下面一個矩陣運算的示例:

原始代碼:

for(int i = 0; i < 2048; i++) {
 for(int j = 0; j < 1024; j++) {
 for(int k = 0; k < 1024; k++) {
 C[i * 1024 + j] += A[i * 1024 + k] * B[k * 1024 + j];
 }
 }
 }

試着把jk兩層循環進行Loop Interchange之後的代碼:

for(int i = 0; i < 2048; i++) {
 for(int k = 0; k < 1024; k++) {
 for(int j = 0; j < 1024; j++) {
 C[i * 1024 + j] += A[i * 1024 + k] * B[k * 1024 + j];
 }
 }
 }

可以發現,在原始代碼中,最內層的k每次迭代,C要訪問的數據不變,A每次訪問的stride爲1,大概率命中cache,但B由於每次訪問的stride爲1024,幾乎每次都會cache miss。

Loop Interchange之後,j位於最內層循環,每次迭代時A每次要訪問的數據不變,C和B每次訪問的stride爲1,都會有很大概率命中cache,cache命中率大大增加。

那麼cache命中率是否真的增加,以及兩者的性能又如何呢?

原始代碼:

$ time -p ./a.out 
$ sudo perf stat -r 3 -e cache-misses,cache-references,L1-dcache-load-misses,L1-dcache-loads ./a.out
Loop Interchange後的結果如下:
$ time -p ./a.out 
$ sudo perf stat -r 3 -e cache-misses,cache-references,L1-dcache-load-misses,L1-dcache-loads ./a.out

兩者相比:

L1-dcache-loads的數目差不多,因爲要訪問的數據總量差不多;
L1-dcache-load-misses所佔L1-dcache-loads的比例在進行loop interchange代碼修改後降低了將近10倍。

同時,性能數據上也能帶來接近9.5%的性能提升。

2.2 特殊場景

當然,在實際使用時,並不是所有的場景都是如2.1中所示的那種工整的可Loop Interchange場景。

for ( int i = 0; i < N; ++ i )
{
 if( I[i] != 1 ) continue;
 for ( int m = 0; m < M; ++ m )
 {
    Res2 = res[m][i] * res[m][i];
    norm[m] += Res2;
 }
}

如上述場景,如果N是超大數組,那麼Loop Interchange理論上可以帶來較大收益;但由於兩層循環中間增加一個分支判斷,導致原本可以Loop Interchange的場景無法實現。

針對這種場景,可以考慮將中間的分支判斷邏輯剝離,可以先保證Loop Interchange使得數組res在連續內存上進行訪問;至於中間的判斷分支邏輯,可以在Loop Interchange兩層循環後再進行回退。

for ( int m = 0; m < M; ++ m )
{ 
 for ( int i = 0; i < N; ++ i)
 { 
      Res2 = res[m][i] * res[m][i];
      norm[m] += Res2;
 if( I[i] != 1 ) //補充邏輯,保證源代碼語義
 { 
        norm[m] -= Res2;
 continue;
 } 
 }
} 

當然,這樣的源碼修改也需要考慮cost是否值得,如果該if分支進入頻率非常高,那麼之後回退帶來的cost也會較大,可能就需要重新考慮Loop Interchange是否值得;反之,如果分支進入頻率非常低,那麼Loop Interchange的實現還是可以帶來可觀的收益的。

3、畢昇編譯器對Loop Interchange pass社區的貢獻

畢昇編譯器團隊在llvm社區中對Loop Interchange pass也做出了不小的貢獻。團隊從legality、profitability等方面對Loop Interchange pass做了全方位的增強,同時也對該pass所支持的場景做了大量的擴展。在Loop Interchange方面,近兩年來團隊小夥伴爲社區提供了二十餘個主要的patch,包含Loop Interchange,以及相關的dependence analysis、loop cache analysis、delinearization等分析和優化的增強。簡單舉幾個例子:

這兩個patch將Loop Interchange的應用場景擴展到內層或者外層循環中包含不止一個induction variable的情況:

 for (c = 0, e = 1; c + e < 150; c++, e++) {
     d = 5;
 for (; d; d--)
       a |= b[d + e][c + 9];
 }
 }

這兩個patch將Loop Interchange的應用場景擴展到支持浮點類型的reduction計算的場景:

double matrix[dim][dim];
for (i=0;i< dim; i++)
 for (j=0;j <dim;j++)
            matrix[i][j] += 1.0;

D120386 [LoopInterchange] Try to achieve the most optimal access pattern after interchange
(https://reviews.llvm.org/D120386)

這個patch增強了Interchange的能力使編譯器能夠將循環體permute成爲全局最優的循環順序:

void f(int e[100][100][100], int f[100][100][100]) {
 for (int a = 0; a < 100; a++) {
 for (int b = 0; b < 100; b++) {
 for (int c = 0; c < 100; c++) {
        f[c][b][a] = e[c][b][a];
 }
 }
 }
}

=>

void f(int e[100][100][100], int f[100][100][100]) {
 for (int c = 0;  c < 100; c++) {
 for (int b = 0; b < 100; b++) {
 for (int a = 0; a < 100; a++) {
        f[c][b][a] = e[c][b][a];
 }
 }
 }
}

D124926 [LoopInterchange] New cost model for loop interchange
(https://reviews.llvm.org/D124926)

這個patch爲loop interchange提供了一個全新的,功能更強的cost model,可以更準確地對loop interchange的profitability做出判斷。

此外,我們還爲社區提供了大量的bugfix的patch:

  • D102300 [LoopInterchange] Check lcssa phis in the inner latch in scenarios of multi-level nested loops
  • D101305 [LoopInterchange] Fix legality for triangular loops
  • D100792 [LoopInterchange] Handle lcssa PHIs with multiple predecessors
  • D98263 [LoopInterchange] fix tightlyNested() in LoopInterchange legality
  • D98475 [LoopInterchange] Fix transformation bugs in loop interchange
  • D102743 [LoopInterchange] Handle movement of reduction phis appropriately during transformation (pr43326 && pr48212)
  • D128877 [LoopCacheAnalysis] Fix a type mismatch bug in cost calculation

以及其他功能的增強:

  • D115238 [LoopInterchange] Remove a limitation in legality
  • D118102 [LoopInterchange] Detect output dependency of a store instruction with itself
  • D123559 [DA] Refactor with a better API
  • D122776 [NFC][LoopCacheAnalysis] Add a motivating test case for improved loop cache analysis cost calculation
  • D124984 [NFC][LoopCacheAnalysis] Update test cases to make sure the outputs follow the right order
  • D124725 [NFC][LoopCacheAnalysis] Use stable_sort() to avoid non-deterministic print output
  • D127342 [TargetTransformInfo] Added an option for the cache line size
  • D124745 [Delinearization] Refactoring of fixed-size array delinearization
  • D122857 [LoopCacheAnalysis] Enable delinearization of fixed sized arrays

結語

如果想要儘可能的利用Loop Interchange優化,那在書寫C/C++代碼時,請儘可能保證每個迭代之間對數組或數列的訪問stride越小越好;stride越接近1,空間局部性就越高,自然cache命中率也會更高,在性能數據上也可以拿到更理想的收益。另外,由於C/C++的存儲方式爲Row-major ordering,所以在訪問多維數組時,請注意內層循環要爲Column才能拿到更小的stride。

參考

[1] https://zhuanlan.zhihu.com/p/455263968
[2] https://valgrind.org/info/tools.html#cachegrind
[3] https://blog.csdn.net/gengshenghong/article/details/7225775
[4] https://en.wikipedia.org/wiki/Loop_interchange
[5] https://johnysswlab.com/loop-optimizations-how-does-the-compiler-do-it/#footnote_6_1738
[6] https://blog.csdn.net/PCb4jR/article/details/85241114
[7] https://blog.csdn.net/Darlingqiang/article/details/118913291
[8] https://en.wikipedia.org/wiki/Row-_and_column-major_order
[9] https://en.wikipedia.org/wiki/Locality_of_reference

 

點擊關注,第一時間瞭解華爲雲新鮮技術~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章