迴圈優化方法如數家珍

PcDack發表於2022-04-09

譯者注:原文<Loop Optimizations: taking matters into your hands>

你可以先閱讀 上一篇文章 來了解編譯器如何對迴圈進行優化,然後再繼續閱讀這篇文章。

在你瞭解了編譯器如何優化你的程式碼之後,接下來的兩個問題是:你如何幫助編譯器更好地完成它的工作,以及何時手動進行優化是有意義的? 在這篇文章中,我們試圖對這兩個問題給出答案,當然這並不容易。

優化“殺手”

首先你需要知道的是,兩個最大的優化殺手是函式呼叫和指標別名。這兩個是最大的殺手是因為,對於很多編譯器優化,編譯器必須假設某個變數在它試圖優化的關鍵程式碼中是常數。但是,當存在函式呼叫和指標別名的情況下,優化將不會發生,從而導致編譯器產生非優化版本程式碼。

函式呼叫

函式呼叫是效能殺手有兩個原因。第一個原因是,就編譯器而言,該函式可能已經改變了全域性記憶體的完整狀態。以下面為例:

for (int i = 0; i < n; i++) {
   ...
   if (debug) { 
       printf("The data is NaN\n"); 
   }
}

正如程式碼所示,debug 是一個全域性變數,或一個棧分配的變數或者一個類成員。本質上,其他的函式可以修改這個變數的值。作為開發者,我們知道變數debug 不會在程式碼中修改其值。因此,編譯器原則上可以進行優化:迴圈判斷外提 (編譯器會建立兩個版本的迴圈,一個版本的迴圈是debug值為true ,另一個版本迴圈其值為false ,通過迴圈外檢查debug 的值來確定具體進入哪一個迴圈)。

然而,編譯器並不知道這一點,因為,其假設printf可以修改debug的值。解決這個問題的一個方法是,在迴圈體內部,你把簡單的全域性可訪問變數複製到區域性變數。函式不能修改區域性變數的值,這段程式碼可以安全地進行優化。因此,程式碼如下所示:

bool debug_local = debug;
for (int i = 0; i < n; i++) {
   ...
   if (debug_local) { 
       printf("The data is NaN\n"); 
   }
}

編譯器會自動進行迴圈判斷外提優化。

函式導致無法優化的第二個原因是,在有函式呼叫的情況下,編譯器的優化能力下降。考慮下面的例子:

double add(double a, double b) {
   return a + b;
}
for (int i = 0; i < n; i++) {
    c[i] = add(a[i], b[i]);
}

如果編譯器可以行內函數add,它就可以安全地進行其他編譯器優化。例如,它可以將迴圈向量化。但如果該函式是不可l內聯的,那麼編譯器必須生成一個標量版本的迴圈,並在迴圈的每個迭代中呼叫函式add

你可以通過開啟連結時優化來提升內聯效能。

指標別名

指標別名也是編譯器優化殺手。在存在指標別名的情況下,編譯器不能使用暫存器來維持資料,取而代之的是,使用緩慢的記憶體來維持資料。也不能保證某一個值是常量(因為任何值都有可能被其他指向該值的指標所改變)。最後,指標別名抑制了向量化,其原因我們在詳細解釋。

為了解釋指標別名,考慮下面的例子:

for (int i = 0; i < n; i++) {
   b[i] = 0;
   for (int j = 0; j < n; j++) {
      b[i] += a[i][j];
   }
}

對於每一行 i,編譯器計算矩陣 a 的第 i 行所有元素的總和,並將其存入 b[i]。

假設編譯器沒有關於陣列 b 和 a 的指標別名資訊。我們還假設,矩陣 a 是動態分配的,即它是一個指標陣列,每個指標都指向另一行。這些都是相當合理的假設。

這段程式碼有幾個優化的潛力。內迴圈對 j 進行迭代,所以 b[i] 的值可以儲存在一個暫存器中。並且,內迴圈原則上是可向量化的。

但是,讓我們假設陣列 a 和 b 是以如下方式初始化的:

double** a = new double*[n];
double* b = new double[n];
for (int i = 0; i < n; i++) {
    a[i] = b;
}

在矩陣 a 中,所有的行指標都指向同一個記憶體塊。此外,a 的行和指標 b 的行相互別名。如果你將 5 寫入 b[5]中,這個值相當於寫入 a[3][5] 中。

在這種情況下,編譯器無法使用暫存器來維護 b[i] 的值。 另外,由於存在依賴關係,它不能對迴圈進行向量處理。其結果是生成的程式碼效能很差。

你會說,這種情況在真實的程式碼庫中從未發生過,但編譯器必須假設最壞的情況。編譯器不會丟擲警告,你需要檢視編譯器的優化報告以瞭解發生了什麼。我們將在下一篇文章中談及編譯器的優化報告。

如果你確定兩個指標沒有互相別名,那麼你可以使用 __restrict__ 關鍵字來告訴編譯器,指標是互相獨立的。此外,你可以用一個暫存器手動替換對陣列中同一元素的寫入,像這樣:

for (int i = 0; i < n; i++) {
   double sum = 0;
   double* __restrict__ a_row = a[i];
   for (int j = 0; j < n; j++) {
      sum += a_row[j];
   }
   b[i] = sum;
}

通過對矩陣 a 的行使用 __restrict__ 關鍵字(第3行),並引入一個臨時標量變數來儲存中間結果,編譯器現在可以自由地更好地優化迴圈。

關於指標別名的補充說明

指標別名是編譯器所做的最複雜的分析之一,許多編譯器優化只能在編譯器能夠保證沒有指標別名的情況下進行。也就是說,通過使用區域性變數而不是全域性變數、使用 __restrict__ 關鍵字或使用編譯器 pragmas(例如#pragma ivdep)告訴編譯器忽略迴圈攜帶的依賴關係,從而簡化編譯器的分析。

在分析你的程式碼時,就指標別名而言,編譯器會區分三種型別:

  1. 編譯器確定該資料沒有被其他指標別名,所以對它進行編譯器優化是安全的。
  2. 編譯器確信該資料被其他指標別名,所以它必須假定其值可以改變,省略某些編譯器優化,並將資料儲存在記憶體中,而不是暫存器中。
  3. 編譯器不能保證資料不被指標所別名。

在(1)和(2)的情況下,解決方案是明確的。在(3)的情況下,編譯器有一個選項,可以生成兩個版本的程式碼:一個是優化版本的,另一個是未優化版本。然後,它執行時檢查指標別名,並相應地選擇優化或者未優化的版本。

然而,請注意,執行時別名分析有幾個方面的限制: 它只適用於編譯器可以推匯出長度的標量和陣列。由於每個指標都需要與其他指標進行檢查,執行分析所需的計算很快就會變得難以管理。你不應該過多地依賴它。

對於熱迴圈,檢查編譯器的優化報告,同時將全域性值複製到本地,使用 __restrict__ 關鍵字,使用 pragma(例如#pragma ivdep)告訴編譯器忽略熱迴圈的向量依賴,將允許編譯器優化。

從可讀性的角度來看,將 globals 變數複製到 locals 並使用 locals 來代替,可以得到更可讀的程式碼。__restrict__ 關鍵字並不為許多開發者所知,如果使用不當,將導致不正確的程式行為。

優化方法如數家珍

在把事情交給你處理之前,我必須提出一個警告。根據我的經驗,程式碼可讀性和程式碼可維護性的重要性高於速度。優化程式碼的非關鍵部分永遠不會帶來的速度提升。因此,你計劃對程式碼所做的所有修改應該只集中在關鍵迴圈上。

迴圈不變式外提和迴圈判斷外提

你應該儘量讓編譯器來完成迴圈不變式外提和迴圈判斷外提。然而,在某些情況下,你想手動完成這些工作:

  • 編譯器不能確定引數是一個常數:這裡適用的規則與指標別名的規則相同。要麼將迴圈不變的條件複製到一個臨時的區域性變數,要麼使用巨集或C++模板將其轉換為編譯時常量。
  • 引數太多,編譯器無法取消對迴圈的切換:迴圈判斷外提將增大程式碼的大小;若存在多個判斷,那麼不進行這個優化。使用 C++ 模板或巨集的手動取消切換將強制取消切換。

更多關於手動取消開關的資訊可以在 部落格 中找到。

去除迭代器變數的依賴性計算

如果在迴圈中存在一個條件,並且它不依賴於資料,而只依賴於迭代器變數,我們稱它為迭代器變數依賴條件。編譯器通常不會對這些條件進行優化,可以通過迴圈剝離或迴圈展開來優化它們。

迴圈展開

根據我的經驗,你幾乎不應該用手來做迴圈展開。手動展開迴圈會使編譯器的分析變得複雜,而且所產生的程式碼也會更慢。然而,你可以使用編譯器的 pragmas 來強制在編譯器上進行迴圈展開。(例如,LLVM提供了pragma clang loop unroll)。

更新:一些讀者反對這一意見,我覺得應該對其進行更新。通常情況下,開發人員會將迴圈展開幾次,然後重新排列迴圈中的語句,類似於迴圈流水優化。在後臺發生的情況是,與這些語句對應的指令與語句一起移動。如果你足夠幸運,早先是瓶頸的 CPU 單元將不再是瓶頸,你的迴圈將執行得更快。

然而,這種 "效能調整 "並不一定可以移植,在不同的編譯器之間,在同一編譯器上的不同硬體架構之間,以及同一編譯器的不同版本之間,這種調整並不一定都適用。只要結果不變,編譯器可以隨意安排指令。使用這種方法獲得的效能往往很小(例如,程式執行時間減少20%)並且不可移植。而且程式碼將變得複雜。

也就是說,有時你需要那額外的 20% 的效能提升,以這種方式它是有意義的。有時這可以節省金錢、時間或精力。也許在這種情況下,這比可維護性和可移植性更重要。

迴圈流水優化

譯:流水優化示例

就迴圈流水優化而言,最好讓編譯器來進行這種優化,而且只適用於那些能從中受益的硬體架構。

這條規則有一個例外:如果你的迴圈以一種不可預測的模式訪問資料,你可以預期會有大量的資料快取缺失,這會大大降低你的程式碼速度。如果是這種情況,明確的軟體預取與迴圈流水線相結合,可以幫助緩解一些問題。這種技術相當複雜,但可以說是值得努力的。你可以在這裡找到更多關於它的資訊。

向量化

手動將程式碼向量化的方法有使用向量化 pragmas,如:#pramga omp simd (然而,你需要向編譯器提供 -fopenmp 或 -fopenmp-simd 配置,以方便移植),或者按照不同編譯器的規則來進行編寫,如LLVM編寫為#pragma clang loop vectorize(enable) 。然而,我本人是反對使用這種方式的,下面是原因。

如果編譯器沒有對迴圈進行向量處理,肯定存在某個具體的原因。它可以是:

  1. 編譯器不知道如何對迴圈進行向量化:強制向量化不會產生任何影響,會產生編譯器警告。
  2. 有迴圈存在依賴:強制的向量化會產生錯誤。
  3. 成本模型預測,向量化並沒有得到回報:如果是這樣的話,強迫向量化可能會導致速度下降,而不是加速。
  4. 根據IEEE 754標準,其結果不精確:啟用允許放鬆IEEE 754語義的編譯器標誌,如-ffast-math、-Ofast等。當你這樣做的時候,編譯器會自動對迴圈進行向量處理而不需要 pragma。
  5. 編譯器不能保證沒有指標別名:如果是這種情況,我們在關於指標別名一節中提到的提示將幫助編譯器成功地進行指標別名分析,因此,如果成本模型預測了速度將會提高,它可以自動向量化迴圈。

誠然,有很少的情況,強制向量化可能會帶來效能上的好處(例如: 除了在一個特定的地方之外,你不想放寬 IEEE 754 浮點數學的精度),但這些在實踐中很少見到。

迴圈替換

編譯器很少進行迴圈互換,它是一個非常有價值的轉換,可以加快處理矩陣和影像的程式碼的速度。 對於熱迴圈,肯定要手動進行,因為它有能力將速度提高几倍。

迴圈拆分

迴圈拆分是一個有點爭議的優化方法。如果迴圈是不可向量的,優化可以將迴圈分成可向量的和不可向量的部分。一個部分的向量化將帶來速度的提高。然而,在實踐中,這種方法有一個問題。請考慮以下例子:

for (int i = 0; i < n; i++) {
    double val = (a[i] > 0) ? a[i] : 0;
    b[i] = sqrt(a[i]);
}

操作sqrt 是非常昂貴的操作並且這個迴圈將從向量化中受益。有些編譯器在向量化方面比其他編譯器要好。現在,我們可以像這樣進行手動迴圈分配:

for (int i = 0; i < n; i++) {
    val[i] = (a[i] > 0) ? a[i] : 0;
}
for (int i = 0; i < n; i++) {
    b[i] = sqrt(val[i]);
}

假設編譯器 A 沒有對原始迴圈進行向量處理。拆分後,編譯器 A 對第二個迴圈進行向量化處理,從而使效能得到提升。

然而,如果編譯器 B 對原始迴圈進行了向量處理,在拆分之後,拆分對效能的產生負面的影響。

儘管迴圈拆分可以對速度產生積極的影響,但重要的是測試對你的專案使用的所有編譯器對效能影響。

迴圈合併

譯:迴圈合併優化介紹

迴圈合併一般對效能有積極影響,只有一個例外:如果原始迴圈中的一個(或兩個)被編譯器向量化了,而在融合後它們停止了向量化,那麼影響可能是負面的。與迴圈拆分一樣,用各種編譯器檢查效能也很重要。

結論

當涉及到在編譯器優化方面自己動手的時候,有一些擔憂。首先是程式碼的可讀性和可維護性。程式碼的可讀性幾乎在任何時候都非常重要,因為它可以減少維護成本:寫得可讀性好的程式碼對另一個人來說更容易掌握,它出現錯誤的機會更少,從長遠來看,它更容易擴充套件。

手動優化程式碼會導致程式碼混亂,更難維護。要知道,效能優化只對熱點程式碼有意義,你應該只在熱迴圈上做優化,而且只在徹底剖析後做優化。

第二個問題是效能的可移植性:不能保證用一個編譯器取得的速度會在另一個編譯器上重現。 因此,考慮到這一點也很重要。

儘管如此,如果效能優化能夠促使產品上線,其成本能夠下降,或者使用更少的功率,那麼絕對值得關注被遺忘的編譯器優化。

在下一篇文章中,我們將討論如何使用 CLANG 的編譯器產生優化報告,使用一個非常漂亮的圖形化 UI,稱為 opt-viewer.py

相關文章