天天看點

《高性能科學與工程計算》——2.4 編譯器作用

本節書摘來自華章計算機《高性能科學與工程計算》一書中的第2章,第2.4節,作者:(德)georg hager gerhard wellein 更多章節内容可以通路雲栖社群“華章計算機”公衆号檢視。

通過利用編譯器自動優化,高性能計算程式可以獲得不同程度的性能改進。幾乎每個現代編譯器都可以在指令行上設定編譯選項,以便對編譯器優化目标程式進行細粒度控制。有些情況下可以簡單地通過更換一個編譯器來檢查程式是否還存在性能提升空間。編譯器需要進行複雜的工作以将進階代碼編寫成的源程式編譯為機器代碼,同時要顧及到處理器内部資源。本章和下一章讨論的一些優化方法可以在某些簡單情況下被編譯器實作,但是涉及複雜的情況時就無法用編譯器自動完成優化工作。始終要注意的一點是編譯器可能足夠聰明但是又可能非常蠢笨。在讨論編譯器能力時,一條常用評價是“編譯器應該能夠識别”,這經常是一個錯誤的假設。

參考文獻[c91]概述了幾種目前常用c/c++編譯器的優化能力,并介紹了一些手工優化的技巧和指導。

2.4.1 通用優化選項

每種編譯器都提供了一組标準優化選項(-o0,-o1,…),每個級别的優化都包括哪些優化方法并沒有固定标準,需要參考相關手冊。但是,所有編譯器在-o0選項級别禁止大多數優化,是以這是調試和分析程式的正确方法,在更進階别的優化層次上,編譯器進行檢測和消除備援變量、重排算術表達式等優化,是以調試器不能在代碼和資料間提供一緻的視圖。

需要注意的是,某些問題隻在進階優化層次上才出現。這可能是編譯器的錯誤或缺陷,也可能是典型的錯誤,例如數組通路越界(讀寫索引超過了數組的界限),在-o0層次和-o3層次,資料被按照不同的方式組織,是以可能隻在某個優化層次出錯。這種錯誤很難被定位,有些情況下由于與優化器的沖突,甚至最常用的printf函數也不能幫助定位該類錯誤。

2.4.2 内聯

内聯通過插入被調用函數或子程式的全部代碼來減少程式運作時的調用開銷。例如每個被調用函數都會使用寄存器或者棧(具體要以來參數數量和調用方式決定)中資源來存儲和傳遞函數參數,内聯确實移除了将參數壓入棧中的必要,并且使編譯器可以在它認為需要的時候使用寄存器(而不是根據某些調用約定),進而減輕了寄存器壓力,寄存器壓力是指cpu沒有足夠的寄存器存儲複雜計算或者循環體内的所有操作數(更多介紹見第2.4.5節)。最後,内聯使得編譯器可見的代碼段增大并可以利用更多的在非内聯情況下不可用的優化手段。程式員不應該依賴編譯器優化内聯代碼,在性能關鍵段(例如循環體内),編譯器看不見“真正的”代碼反而無法優化。

函數調用是否會影響性能依賴于調用次數,通常情況下,内聯頻繁調用的小程式将會獲得最大的性能加速。在c++代碼中,内聯是提高性能的基本方法,因為對簡單資料類型的重載操作将會變為較小的函數,并且當内聯函數傳回一個對象時,臨時複制可以被省略(更多c++優化細節見2.5節)。

編譯器通常有不同的編譯選項來控制内聯的自動優化程度,例如,在什麼程度上(即代碼行數量)一個子程式可以作為一個被内聯的候選對象等。注意c99和c++ inline關鍵字隻是對編譯器的一個提示,應該檢查編譯器日志(如果可用,見2.4.6節)判斷函數是否真正被内聯。

相反,在多處内聯一個函數可能會增大目标代碼而導緻過量使用l1指令高速緩存,例如如果循環體内的指令不能存儲在l1指令高速緩存中,将會與資料傳輸一起競争更高層次的高速緩存或者主記憶體,是以讀取指令延遲就會增大。是以在設定内聯辨別時需要考慮正反兩個方面的效用。

2.4.3 别名

通過程式語言規則和對源代碼的了解,編譯器需要提出确切的假設來限制自身産生優化機器代碼的能力。典型的例子是在c(以及c++)語言中利用指針(或引用)形式參數:

《高性能科學與工程計算》——2.4 編譯器作用

假設被指針a和b指向的記憶體區域不重疊,即[a,a+n-1]與[b,b+n-1]不重疊,那麼該循環體中的讀取和存儲操作就可以按照任意順序重排,編譯器會應用它認為合适的任何軟體流水方式或者展開循環并将讀取和存儲打包在一個程式塊中,就像下面僞碼(忽略其餘循環):

《高性能科學與工程計算》——2.4 編譯器作用

此時循環可以簡單地進行simd–向量化優化(見2.3.3節)。

然而,c和c++标準允許指針的别名,是以在優化時不能假設兩個指針指向的區域不重疊,例如,如果a==b,該循環變成了1.2.3節的“真依賴”的fortran執行個體,讀取和存儲的執行順序必須與程式中聲明的一緻:

《高性能科學與工程計算》——2.4 編譯器作用

編譯器在缺少更多資訊的情況下必須按照這種方式生成代碼,simd向量化優化也必須被排除,處理器硬體在某些限制條件下允許讀取和存儲的重排[v104,v105],但是必須保證程式語義。

在fortran标準中禁止參數别名,這也是fortran程式比相同的c程式快的主要原因之一。所有的c/c++編譯器都有控制别名程度的指令行選項(例如intel編譯器中的-fno-fnalias選項和gcc中的-fargument-noalias,表明任何函數的兩個指針實參都不指向同一區域)。如果編譯器被告知沒有參數别名,那麼原則上就可以進行類似fortran代碼中的優化。但是應該注意,如果對優化過後的程式使用參數别名将會産生錯誤結果。

2.4.4 計算準确性

2.3.1節已經提到,當要求滿足結合律時,編譯器有時禁止重排算術表達式,除非極高層次的優化開關被打開。原因在于浮點運算不滿足結合律[135]:如果a、b和c是有限精度的浮點數,則(a+b)+c一般不等于a+(b+c)。如果必須保證優化前代碼的正确性,就不能使用結合律規則,是以應由程式員确定是否可以手工重組算術表達式。現代編譯器都有相關編譯選項用來控制算術表達式的重組,即使是在高層次的優化開關被打開的情況下。

同時要注意非正規數,即比用非零最高有效位表示的最小值還小的浮點數,會極大地影響計算性能,如果可能并且輕微的正确性損失是可接受的,那麼這些數應該在硬體計算中被設為零。

2.4.5 寄存器優化

這是編譯器優化(考慮使用寄存器)中最關鍵也是最複雜的任務之一,編譯器試圖将寄存器配置設定給使用最頻繁的操作數并将這些操作數盡可能長的保留在寄存器中(如果這樣做安全)。例如,如果一個變量的位址被通路,該變量的值很可能被改變(通過位址操作由程式其他部分改變),這種情況下編譯器要決定是否将該變量寫回記憶體中。

内聯(見2.4.2節)可以減輕寄存器優化負擔,因為編譯器可以将本應該在函數調用前寫入記憶體并随後再讀出的變量儲存在寄存器中。相反,優化擁有大量變量和算術表達式的循環體(可能出現在内聯優化後)對于編譯器來說非常困難,因為編譯器要保持的操作數數目過大而不能在一次疊代中同時存儲所有操作數。前面提到過,處理器中整數寄存器和浮點數寄存器的數量通常是有限的。目前典型的數目為8~128,如果寄存器數量不夠,将會帶來寄存器溢出,即将寄存器變量寫回記憶體以供後續使用。如果程式的性能瓶頸是算術操作,那麼寄存器溢出将會極大地降低性能。這種情況下可以劃分一個大循環為多個循環來減輕寄存器使用壓力。

一些處理器具備硬體支援可以處理寄存器溢出,例如intel itanium2處理器具有硬體性能計數器可以直接檢測存儲器溢出。

2.4.6 利用編譯日志

前面幾節指出編譯器在編寫高效程式中的關鍵作用。可以簡單地操作以阻止編譯器獲得重要資訊進而限制優化的級别和種類。為了更好地利用編譯器的智能,需要編譯器允許産生注釋源代碼表或者編譯日志來表示本次編譯過程都使用了哪些優化方法。代碼清單2-1展示了一個mips r14000處理器上(已過時)編譯注釋的例子,即代碼清單1-1中的标準三元組向量程式。該處理器是一個四路超标量處理器,在一個時鐘周期内可以同時執行一個讀取或存儲操作,兩個整數操作,一個浮點加和一個浮點乘操作(後兩個操作通過一條融合的乘–加指令“madd”實作)。假設所有的資料都存儲在最高層的高速緩存中,編譯器可以計算程式一次循環疊代所需的最小計算周期數(第3行)。4~9行展示了處理器峰值,即每類指令的最大吞吐量。

代碼清單2-1 流水化三元組軟體的編譯日表。其中“峰值”(peak)指該體系結構(mips r14000)上各種操作類型的最大執行速率

《高性能科學與工程計算》——2.4 編譯器作用

除此之外,寄存器利用和寄存器溢出(第11行和第12行),循環展開因子和軟體流水因子(第2行,見1.2.3節和3.5節)、simd指令的使用(見2.3.3節)以及循環次數的編譯器假定(第1行)都在表中展示,可以用來判斷生成的機器代碼的品質。然而,并不是所有編譯器都有産生如此豐富的代碼标注特性的能力,剩下的工作需要程式員完成。

也有人工檢查彙編代碼的編譯選項。所有編譯器提供指令行選項以輸出彙編而不是可連接配接檔案。然而,将該彙編檔案與源代碼進行對應并分析指令序列的效率需要很多經驗[o55]。畢竟這是摒棄編寫彙編語言代碼的原因。

繼續閱讀