Frontend 作者效能提示¶
摘要¶
本文檔的目標受眾是針對 LLVM IR 的語言前端開發者。本文檔匯集了關於如何產生優化良好的 IR 的提示。
IR 最佳實踐¶
與任何最佳化器一樣,LLVM 也有其優點和缺點。在某些情況下,來源 IR 中微小的變化可能會對產生的程式碼產生很大的影響。
除了下面列表中的具體項目外,值得注意的是,LLVM 最成熟的前端是 Clang。因此,您的 IR 越偏離 Clang 可能發出的內容,就越不可能被有效地最佳化。編寫一個快速 C 程式來模擬您嘗試建模的語義,並查看 Clang 的 IRGen 對於發出什麼 IR 做出什麼決策通常會很有用。研究 Clang 的 CodeGen 目錄也可能是尋找想法的好來源。請注意,Clang 和 LLVM 明確地版本鎖定,因此您需要確保您使用的 Clang 是從與您使用的 LLVM 庫相同的 git 修訂版或發行版建置的。與往常一樣,強烈建議您追蹤開發主線的進展,尤其是在啟動新專案期間。
基本概念¶
確保您的模組包含資料佈局規範和目標三元組。如果沒有這些部分,目標特定的最佳化將不會啟用。這可能會對產生的程式碼品質產生重大影響。
對於每個發出的函數或全域變數,請使用最私有的連結型別(最好是 private、internal 或 linkonce_odr)。這樣做將使 LLVM 的跨程序最佳化更有效。
避免高入度基本區塊(例如,具有數十甚至數百個前驅的基本區塊)。除了其他問題外,已知暫存器分配器在面對此類結構時效能不佳。此指南的唯一例外是具有高入度的統一返回區塊是可以的。
allocas 的使用¶
alloca 指令可用於表示函數作用域堆疊槽,但也可用於表示動態幀擴展。當表示函數作用域變數或位置時,應優先將 alloca 指令放置在入口區塊的開頭。特別是,將它們放在任何呼叫指令之前。呼叫指令可能會被內聯並替換為多個基本區塊。最終結果是,後續的 alloca 指令將不再位於入口基本區塊中。
SROA (聚合的純量替換) 和 Mem2Reg pass 僅嘗試消除入口基本區塊中的 alloca 指令。鑑於 SSA 是許多最佳化器預期的規範形式;如果 allocas 無法被 Mem2Reg 或 SROA 消除,則最佳化器的效果可能不如預期。
避免建立聚合型別的值¶
避免建立聚合型別(即結構體和陣列)的值。特別是,避免載入和儲存它們,或使用 insertvalue 和 extractvalue 指令操作它們。相反,僅載入和儲存聚合的個別欄位。
此規則有一些例外情況
在全域變數初始化器中使用聚合型別的值是可以的。
如果這樣做是為了表示在暫存器中返回多個值,則返回結構體是可以的。
使用 LLVM 內建函數返回的結構體是可以的,例如
with.overflow
系列內建函數。使用聚合型別而不建立值是可以的。例如,它們通常用於
getelementptr
指令或屬性(如sret
)。
避免載入和儲存非位元組大小的型別¶
避免載入或儲存非位元組大小的型別,如 i1
。相反,將它們適當地擴展到下一個位元組大小的型別。
例如,當處理布林值時,通過將 i1
零擴展到 i8
來儲存它們,並通過載入 i8
並截斷為 i1
來載入它們。
如果您確實對非位元組大小的型別使用載入/儲存,請確保您始終使用這些型別。例如,不要先儲存 i8
,然後載入 i1
。
在合法情況下優先使用 zext 而非 sext¶
在某些架構(X86_64 就是其中之一)上,符號擴展可能涉及額外的指令,而零擴展可以摺疊到載入中。LLVM 將嘗試在可以證明安全的情況下用 zext 替換 sext,但是如果您在源語言中具有關於整數值範圍的資訊,則使用 zext 而不是 sext 可能會更有利。
或者,您可以使用 metadata 指定值的範圍,LLVM 可以為您執行 sext 到 zext 的轉換。
將 GEP 索引零擴展至機器暫存器寬度¶
在內部,LLVM 通常將 GEP 索引的寬度提升到機器暫存器寬度。當它這樣做時,預設會使用符號擴展 (sext) 操作以確保安全。如果您的源語言提供有關索引範圍的資訊,您可能希望使用 zext 指令手動將索引擴展到機器暫存器寬度。
何時指定對齊¶
如果您不指定對齊,LLVM 將始終產生正確的程式碼,但可能會產生效率低下的程式碼。例如,如果您的目標是 MIPS(或較舊的 ARM ISA),則硬體不處理未對齊的載入和儲存,因此如果您執行低於自然對齊的載入或儲存,您將進入陷阱和模擬路徑。為了避免這種情況,對於 IR 中載入/儲存沒有足夠高對齊的所有情況,LLVM 將發出較慢的載入、移位和遮罩序列(或 MIPS 上的 load-right + load-left)。
對齊用於保證 allocas 和全域變數的對齊,儘管在大多數情況下這是沒有必要的(大多數目標具有足夠高的預設對齊,它們可以正常工作)。它也用於向後端提供契約,聲明「此載入/儲存具有此對齊,否則為未定義行為」。這意味著後端可以自由發出依賴於該對齊的指令(並且中階最佳化器可以自由執行需要該對齊的轉換)。對於 x86,它沒有太大區別,因為幾乎所有指令都與對齊無關。對於 MIPS,它可能會產生很大的差異。
請注意,如果您的載入和儲存是原子操作,則後端將無法將未對齊的存取降低為一系列原生對齊的存取。因此,對齊對於原子載入和儲存是強制性的。
其他需要考量的事項¶
謹慎使用 ptrtoint/inttoptr(它們會干擾指標別名分析),優先使用 GEP
優先使用全域變數而不是常數位址的 inttoptr - 這為您提供可解引用資訊。在 MCJIT 中,使用 getSymbolAddress 提供實際位址。
警惕 ordered 和 atomic 記憶體操作。它們很難最佳化,並且目前的最佳化器可能無法很好地最佳化它們。根據您的源語言,您可以考慮使用 fences 代替。
如果呼叫已知會拋出異常(unwind)的函數,請使用 invoke,其正常目標包含 unreachable 指令。此形式向最佳化器傳達呼叫異常返回。對於既不正常返回也不需要在目前函數中執行 unwind 程式碼的 invoke,如果需要,您可以使用 noreturn 呼叫指令。這通常不是必需的,因為最佳化器會將具有 unreachable unwind 目標的 invoke 轉換為 call 指令。
使用 profile metadata 來指示靜態已知的冷路徑,即使動態分析資訊不可用。這可能會在程式碼放置方面產生很大的差異,從而影響緊密迴圈的效能。
在為迴圈產生程式碼時,請盡量避免過早終止迴圈標頭區塊。如果迴圈標頭區塊的終止符是迴圈退出條件分支,則 LICM 的有效性將受到限制,對於不在標頭中的載入。(這是由於 LLVM 可能不知道這樣的載入可以安全地推測執行,因此除非它可以證明退出條件未被採用,否則無法提升原本迴圈不變的載入。)在某些情況下,即使這些指令沒有沿著很少執行的退出迴圈的路徑使用,將這些指令發出到標頭中也可能是有益的。如果終止迴圈標頭的條件本身是不變的,或者可以通過檢查迴圈索引變數輕鬆解除,則此指南不適用。
在熱迴圈中,考慮將來自小型基本區塊的指令複製到其後繼區塊中,這些基本區塊以高度可預測的終止符結尾。如果熱後繼區塊包含可以與複製的指令向量化的指令,則這可以提供顯著的吞吐量改進。請注意,這並不總是盈利的,並且確實涉及程式碼大小可能大幅增加。
當針對常數檢查值時,請使用一致的比較型別發出檢查。GVN pass 將最佳化冗餘等式,即使比較型別反轉,但 GVN 僅在管道後期執行。因此,您可能會錯過執行其他重要最佳化的機會。
除非您的源語言規範要求您發出特定的程式碼序列,否則請避免使用算術內建函數。最佳化器非常擅長推理一般控制流程和算術,但在推理各種內建函數方面遠不如前者。如果為了程式碼產生目的有利,最佳化器可能會在最佳化管道後期形成內建函數本身。在語言前端直接發出這些函數非常少有利可圖。此項目明確包括使用溢位內建函數。
在您確定 a) 沒有其他方法可以表達給定的事實,並且 b) 該事實對於最佳化目的至關重要之前,請避免使用 assume 內建函數。Assumes 是一種很好的原型機制,但它們可能會對編譯時間和最佳化效果產生負面影響。前者可以通過足夠的努力來解決,但後者對於它們的設計目的來說是相當根本的。如果您正在建立非終止符 unreachable 指令或傳遞 false 值,請使用
store i1 true, ptr poison, align 1
規範形式。
描述語言特定的屬性¶
當將源語言翻譯成 LLVM 時,找到表達源語言中可用的概念和保證的方法,這些概念和保證不是由 LLVM IR 本身提供的,將大大提高 LLVM 最佳化程式碼的能力。例如,C/C++ 將每個加法標記為「no signed wrap (nsw)」的能力,在協助最佳化器推理迴圈歸納變數方面大有幫助,從而為迴圈產生更最佳的程式碼。
LLVM LangRef 包含許多機制,用於使用額外的語義資訊註釋 IR。強烈建議您高度熟悉本文檔。下面的列表旨在重點介紹幾個特別感興趣的項目,但絕不是詳盡無遺的。
受限的操作語義¶
根據需要新增 nsw/nuw 標誌。推理溢位對於最佳化器來說通常很困難,因此從前端提供這些事實可能非常有效。
如果合法,請在浮點運算上使用 fast-math 標誌。如果您不需要嚴格的 IEEE 浮點語義,則可以執行許多額外的最佳化。對於浮點密集型計算,這可能會非常有效。
描述別名屬性¶
根據需要將 noalias/align/dereferenceable/nonnull 新增到函數參數和傳回值
使用指標別名 metadata,尤其是 tbaa metadata,以傳達其他方式無法推導出的指標別名事實
在 geps 上使用 inbounds。這可以幫助消除某些別名查詢的歧義。
未定義值¶
盡可能使用 poison 值而不是 undef 值。
盡可能使用 noundef 屬性標記函數參數。
建模記憶體效應¶
在已知時將函數標記為 readnone/readonly/argmemonly 或 noreturn/nounwind。最佳化器將嘗試推斷這些標誌,但可能並不總是能夠做到。手動註釋對於最佳化器無法分析的外部函數尤其重要。
在可能的情況下使用 lifetime.start/lifetime.end 和 invariant.start/invariant.end 內建函數。常見的有利用途是用於堆疊式資料結構(從而允許死儲存消除)和用於描述 allocas 的生命週期(從而允許更小的堆疊大小)。
使用 !invariant.load 和 TBAA 的常數標誌標記不變位置
Pass 順序¶
新的語言前端專案最常犯的錯誤之一是按原樣使用現有的 -O2 或 -O3 pass 管道。這些 pass 管道是任何語言的最佳化編譯器的良好起點,但它們是針對 C 和 C++ 而仔細調整的,而不是您的目標語言。您幾乎肯定需要使用自訂 pass 順序才能實現最佳效能。以下是一些具體建議
對於具有大量很少執行的 guard 條件(例如,空值檢查、型別檢查、範圍檢查)的語言,請考慮在您的 pass 順序中額外執行一兩次 LoopUnswitch 和 LICM。針對 C 和 C++ 應用程式調整的標準 pass 順序可能不足以從迴圈中刪除所有可解除的檢查。
如果您的語言使用範圍檢查,請考慮使用 IRCE pass。它目前不是標準 pass 順序的一部分。
要運行的有用健全性檢查是再次通過 -O2 管道運行您的最佳化 IR。如果您在結果 IR 中看到明顯的改進,您可能需要調整您的 pass 順序。
我仍然找不到我要找的東西¶
如果您在上面找不到您要找的東西,請考慮提出一條 metadata,以提供您需要的最佳化提示。此類擴展相對常見,並且通常受到社群的歡迎。如果您希望將其貢獻到上游,您需要確保您的提案足夠通用,以便其他人受益。
您也應該考慮在 Discourse 上描述您遇到的問題並尋求建議。完全有可能有人以前遇到過您的問題,並且可以提供很好的建議。如果有許多感興趣的參與者,這也會增加 metadata 擴展被整個社群廣泛接受的可能性。
新增至此文件¶
如果您遇到您認為應該在此處涵蓋的情況,請發送 patch 到 llvm-commits 進行審查。
如果您對這些項目有疑問,請在 Discourse 上提出。您能夠為您的問題提供越多的相關背景資訊,就越有可能得到解答。