給前端作者的效能提示

摘要

本文的目標讀者是開發以 LLVM IR 為目標的語言前端的開發人員。本文收集了一些關於如何產生最佳化良好的 IR 的技巧。

IR 最佳實務

與任何最佳化器一樣,LLVM 有其優缺點。在某些情況下,原始 IR 中令人驚訝的小變化可能會對產生的程式碼產生很大的影響。

除了下面清單中的特定項目之外,值得注意的是,LLVM 最成熟的前端是 Clang。因此,您的 IR 與 Clang 可能發出的程式碼相差越大,它就越不可能被有效地最佳化。通常,使用您嘗試建模的語義編寫一個快速的 C 程式,並查看 Clang 的 IRGen 做出哪些關於發出哪些 IR 的決定會很有幫助。研究 Clang 的 CodeGen 目錄也可以是一個很好的想法來源。請注意,Clang 和 LLVM 是顯式版本鎖定的,因此您需要確保您使用的 Clang 是從與您使用的 LLVM 函式庫相同的 git 版本或發行版本建構的。一如既往,強烈建議您追蹤樹狀開發的頂端,尤其是在啟動新專案期間。

基本概念

  1. 確保您的模組包含資料佈局規範和目標三元組。沒有這些部分,就不會啟用任何目標特定的最佳化。這可能會對產生的程式碼品質產生重大影響。

  2. 對於發出的每個函式或全域變數,請使用盡可能私密的連結類型(最好是私密、內部或 linkonce_odr)。這樣做將使 LLVM 的程序間最佳化更加有效。

  3. 避免入度高的基本區塊(例如,具有數十個或數百個前任的基本區塊)。除其他問題外,已知暫存器配置器在面對此類結構時效能不佳。此指南的唯一例外是具有高入度的統一返回區塊是可以的。

alloca 的使用

alloca 指令可以用於表示函數作用域的堆疊位置,但也可以表示動態框架擴展。當表示函數作用域變數或位置時,應優先將 alloca 指令放在進入區塊的開頭。特別是,將它們放在任何呼叫指令之前。呼叫指令可能會被內聯並替換為多個基本區塊。最終結果是後面的 alloca 指令將不再位於進入基本區塊中。

SROA(純量替換聚合)和 Mem2Reg 階段只嘗試消除位於進入基本區塊中的 alloca 指令。鑑於 SSA 是最佳化器大部分預期的規範形式;如果 Mem2Reg 或 SROA 無法消除 alloca,則最佳化器的效果可能會不如預期。

避免建立聚合類型的值

避免建立 聚合類型 的值(即結構和陣列)。特別是,避免載入和儲存它們,或使用 insertvalue 和 extractvalue 指令操作它們。相反,只載入和儲存聚合的個別欄位。

此規則有一些例外情況

  • 在全域變數初始化中使用聚合類型的值是可以的。

  • 如果透過傳回結構來表示傳回多個暫存器中的值,則傳回結構是可以的。

  • 使用 LLVM 內建函數傳回的結構是可以的,例如 with.overflow 系列內建函數。

  • 在不建立值的情況下使用聚合*類型*是可以的。例如,它們通常用於 getelementptr 指令和 sret 等屬性。

避免載入和儲存非位元組大小的類型

避免載入或儲存非位元組大小的類型,例如 i1。相反,應將它們適當擴展到下一個位元組大小的類型。

例如,當使用布林值時,通過將 i1 零擴展到 i8 來儲存它們,並通過載入 i8 並截斷為 i1 來載入它們。

如果確實對非位元組大小的類型使用載入/儲存,請確保*始終*使用這些類型。例如,不要先儲存 i8 然後載入 i1

將 GEP 索引零擴展到機器暫存器寬度

在內部,LLVM 通常會將 GEP 索引的寬度提升到機器暫存器寬度。當它這樣做時,為了安全起見,它預設會使用符號擴展 (sext) 操作。如果您的原始碼語言提供了有關索引範圍的信息,您可能希望使用 zext 指令手動將索引擴展到機器暫存器寬度。

何時指定對齊

即使您沒有指定對齊,LLVM 也會始終產生正確的程式碼,但可能會產生效率低下的程式碼。例如,如果您以 MIPS(或較舊的 ARM ISA)為目標,則硬體無法處理未對齊的載入和儲存,因此如果您執行的載入或儲存的對齊程度低於自然對齊,則會進入陷阱和模擬路徑。為了避免這種情況,對於載入/儲存在 IR 中的對齊程度不夠高的所有情況,LLVM 都會發出一系列較慢的載入、移位和遮罩(或在 MIPS 上載入右邊 + 載入左邊)。

對齊用於保證 allca 和全域變數的對齊,但在大多數情況下,這是不必要的(大多數目標都有足夠高的預設對齊,因此它們會很好)。它還用於向後端提供一個合約,說明「此載入/儲存具有此對齊方式,否則即為未定義行為」。這表示後端可以自由地發出依賴於該對齊的指令(並且中級最佳化器可以自由地執行需要該對齊的轉換)。對於 x86,這沒有太大區別,因為幾乎所有指令都與對齊無關。對於 MIPS,它可能會產生很大的影響。

請注意,如果您的載入和儲存是原子的,則後端將無法將未對齊的存取降低為一系列原生對齊的存取。因此,對於原子載入和儲存,對齊是強制性的。

其他注意事項

  1. 謹慎使用 ptrtoint/inttoptr(它們會干擾指標別名分析),建議使用 GEP。

  2. 建議使用全域變數,而不是常數位址的 inttoptr - 這為您提供了可解除參照資訊。在 MCJIT 中,使用 getSymbolAddress 來提供實際位址。

  3. 注意有序和原子記憶體操作。它們難以最佳化,並且當前的最佳化器可能無法很好地最佳化它們。根據您的原始碼語言,您可以考慮改用 fences。

  4. 如果呼叫已知會引發異常(展開)的函式,請使用具有包含不可達指令的正常目的地的 invoke。這種形式向最佳化器傳達了呼叫異常返回。對於在當前函式中既不正常返回也不需要展開程式碼的 invoke,您可以根據需要使用 noreturn 呼叫指令。這通常不是必需的,因為最佳化器會將具有不可達展開目的地的 invoke 轉換為呼叫指令。

  5. 使用設定檔中繼資料來指示靜態已知的冷路徑,即使動態設定檔資訊不可用。這可能會對程式碼放置產生很大影響,從而影響緊密迴圈的效能。

  6. 在產生迴圈程式碼時,請盡量避免過早終止迴圈標頭區塊。如果迴圈標頭區塊的終止符是迴圈退出條件分支,則 LICM 對不在標頭中的載入的有效性將受到限制。(這是因為 LLVM 可能不知道這樣的載入可以安全地推測性執行,因此除非它可以證明退出條件不成立,否則無法提升原本迴圈不變的載入。)在某些情況下,將此類指令發出到標頭中可能是有益的,即使它們沒有沿著很少執行的退出迴圈的路徑使用。如果終止迴圈標頭的條件本身是不變的,或者可以通過檢查迴圈索引變數輕鬆地消除,則此指南特別不適用。

  7. 在熱循環中,請考慮將以高度可預測的終止符結尾的小基本塊中的指令複製到它們的後續塊中。如果一個熱後續塊包含可以與複製的指令進行向量化的指令,則可以顯著提高吞吐量。請注意,這並不總是有益的,並且可能會導致代碼大小大幅增加。

  8. 在根據常數檢查值時,請使用一致的比較類型發出檢查。即使比較類型反轉,GVN 傳遞 * 也會 * 優化冗餘的相等性,但 GVN 只在流水線的後期運行。因此,您可能會錯過運行其他重要優化的機會。

  9. 除非您的源語言規範 * 要求 * 您發出特定的代碼序列,否則請避免使用算術內建函數。優化器非常擅長推理一般的控制流程和算術,但在推理各種內建函數方面卻遠遠不如。如果對代碼生成有益,優化器可能會在優化流水線的後期自行形成內建函數。在語言前端直接發出這些內容 * 非常 * 少見。此項目明確包含使用 溢位內建函數

  10. 避免使用 假設內建函數,除非您已確定 a) 沒有其他方法可以表達給定的情況,並且 b) 該情況對優化至關重要。假設是一種很好的原型設計機制,但它們會對編譯時間和優化效果產生負面影響。前者可以通過足夠的努力來解決,但後者對於它們的設計目的來說是相當基本的。如果您要創建一個非終止符不可達指令或傳遞一個錯誤值,請使用 store i1 true, ptr poison, align 1 規範形式。

描述語言特定屬性

將源語言轉換為 LLVM 時,找到表達源語言中可用但 LLVM IR 本身未提供的概念和保證的方法,將大大提高 LLVM 優化代碼的能力。例如,C/C++ 將每個加法標記為「無符號環繞 (nsw)」的能力,對於協助優化器推理循環歸納變量並因此為循環生成更優化的代碼大有幫助。

LLVM LangRef 包含許多使用額外語義資訊註釋 IR 的機制。* 強烈建議 * 您非常熟悉這份文件。以下列表旨在重點介紹一些特別感興趣的項目,但絕非詳盡無遺。

受限操作語義

  1. 適當添加 nsw/nuw 標記。推理溢位通常對優化器來說很困難,因此從前端提供這些資訊可能會非常有影響。

  2. 如果合法,請對浮點運算使用快速數學標記。如果您不需要嚴格的 IEEE 浮點語義,則可以執行許多其他優化。這對於浮點密集型計算可能會產生很大影響。

描述別名屬性

  1. 適當添加 noalias/align/dereferenceable/nonnull 到函數參數和返回值

  2. 使用指針別名中繼資料,尤其是 tbaa 中繼資料,來傳達否則無法推斷的指針別名資訊

  3. 在 gep 上使用 inbounds。這有助於消除一些別名查詢的歧義。

未定義值

  1. 盡可能使用 poison 值而不是 undef 值。

  2. 儘可能使用 noundef 屬性標記函數參數。

建模記憶體效應

  1. 在已知的情況下,將函數標記為 readnone/readonly/argmemonly 或 noreturn/nounwind。最佳化器會嘗試推斷這些標記,但可能無法始終如一。手動註釋對於最佳化器無法分析的外部函數尤其重要。

  2. 盡可能使用 lifetime.start/lifetime.end 和 invariant.start/invariant.end 內建函數。常見的獲利用途是用於堆疊式資料結構(從而允許消除無效存放)和描述配置的生命週期(從而允許更小的堆疊大小)。

  3. 使用 !invariant.load 和 TBAA 的常數標記來標記不變位置

遍歷順序

新語言前端專案最常犯的錯誤之一是按原樣使用現有的 -O2 或 -O3 遍歷管道。這些遍歷管道是任何語言最佳化編譯器的一個良好起點,但它們已經針對 C 和 C++ 進行了仔細調整,而不是您的目標語言。您幾乎肯定需要使用自定義遍歷順序才能獲得最佳效能。一些具體的建議

  1. 對於具有大量很少執行的防護條件(例如空檢查、類型檢查、範圍檢查)的語言,請考慮在您的遍歷順序中添加一或兩次額外的 LoopUnswitch 和 LICM 執行。針對 C 和 C++ 應用程式調整的標準遍歷順序可能不足以從迴圈中刪除所有可卸載的檢查。

  2. 如果您的語言使用範圍檢查,請考慮使用 IRCE 遍歷。它目前不是標準遍歷順序的一部分。

  3. 一個有用的完整性檢查是再次通過 -O2 管道運行您最佳化的 IR。如果您在生成的 IR 中看到顯著的改進,則可能需要調整遍歷順序。

我還是找不到我要找的東西

如果您在上面找不到您要查找的內容,請考慮提出一個提供您需要的最佳化提示的元資料。此類擴展相對常見,並且通常受到社群的歡迎。如果您希望將其貢獻到上游,則需要確保您的提案足夠通用,以便其他人也能受益。

您還應該考慮在 Discourse 上描述您遇到的問題並尋求建議。很可能以前有人遇到過您的問題,並且可以提供良好的建議。如果有 多個相關方,這也增加了元資料擴展被整個社群廣泛接受的可能性。

新增到此文件

如果您遇到您認為應該在此處涵蓋的案例,請將修補程式發送到 llvm-commits 以供審查。

如果您對這些項目有任何疑問,請在 Discourse 上提出。您能夠為您的問題提供的相關上下文越多,就越有可能得到解答。