譯者注:本文原始鏈接為https://johnysswlab.com/make-your-programs-run-faster-avoid-function-calls/,翻譯獲得作者同意。
這是程序底層優化的第二篇文章,第一篇文章緩存友好程序設計指南。
現代軟件設計像層(layer),抽象(abstractions)和接口(interfaces)。 這些概念被引入到編程中的初衷是好的,因為它們允許開發者編寫更容易理解和維護的軟件。 在編譯器的世界里,所有這些結構都轉化為對函數的調用:許多小函數相互調用,而數據逐漸從一層移動到另一層。
這個概念的問題是,原則上函數調用代價是昂貴的。為了進行調用,程序需要把調用參數放在程序棧上或放到寄存器中。它還需要保存自己的一些寄存器,因為它們可能被調用的函數覆蓋。被調用的函數不一定在指令緩存中,這可能導致執行延遲和性能降低。當被調用的函數執行完畢時,返回到原函數也會有性能上的損失。
一方面,函數作為一個概念是很好的,它使軟件更可讀,更容易維護。另一方面,過多地調用微小的函數,肯定會使程序變慢。
避免函數調用的一些技巧
讓我們來看看避免函數調用的一些技巧。
內聯
內聯是編譯器用來避免函數調用和節省時間的一種技術。簡單地說,內聯一個函數意味着把被調用的函數主體放在調用的地方。一個例子:
void sort(int* a, int n) {
for (int i = 0; i < n; i++) {
for (int j = i; j < n; j++) {
swap_if_less(&a[i], &a[j]);
}
}
}
template <typename T>
void swap_if_less(T* x, T* y) {
if (*x < *y) {
std::swap(*x, *y);
}
}
函數 sort 正在進行排序,而函數 swap_if_less 是 sort 使用的一個輔助函數。函數 swap_if_less 是一個小函數,並被 sort 多次調用,所以避免這種情況的最好辦法是將 swap_if_less 的主體復制到函數 sort 中,並避免所有與函數調用有關的開銷。內聯通常是由編譯器完成的,但你也可以手動完成。我們已經介紹了手動內聯,現在我們來介紹一下由編譯器進行的內聯。所有的編譯器都會默認對小函數進行內聯調用,但有些問題:
- 如果一個被調用的函數被定義在另一個 .C 或 .CPP 文件中,它不能被自動內聯,除非啟用了鏈接優化。
- 在C++中,如果類方法是在類聲明中定義的,那么它將被內聯,除非它太大。
- 標記為靜態的函數可能會被自動內聯。
- C++的虛方法不會被自動內聯(但也有例外)。
- 如果一個函數是用函數指針調用的,它就不能被內聯。另一方面,如果一個函數是作為一個lambda表達式被調用的,那么它很可能可以被內聯。
- 如果一個函數太長,編譯器可能不會內聯它。這個決定是出於性能考慮,長函數不值得內聯,因為函數本身需要很長的時間,而調用開銷很小。
內聯會增加代碼的大小,不小心的內聯會帶來代碼大小的爆炸,實際上會降低性能。因此,最好讓編譯器來決定何時內聯和內聯什么。
在 C 和 C++ 中,有一個關鍵字 inline。如果函數聲明中有這個前綴,就是建議編譯器進行內聯。在實踐中,編譯器使用啟發式方法來決定哪些函數需要內聯,並且經常不理會這個提示。
檢查你的代碼是否被內聯的方法,你可以通過對目標代碼反匯編(使用命令objdump -Dtx my_object_file.o)或以編程的方式(文章最后有介紹) 。GCC 和 CLANG 編譯器提供了額外屬性來實現內聯:
__attribute__((always_inline))
-強制編譯器總是內聯一個函數。如果不可能內聯,它將產生一個編譯警告。__attribute__((flatten))
- 如果這個關鍵字出現在一個函數的聲明中,所有從該函數對其他函數的調用將盡可能被替換為內聯版本。
內聯和虛函數
正如上文所述,虛函數是不能夠被內聯的。並且,使用虛函數被其他函數代價更大。有一些解決方案可以緩解這個問題:
- 如果一個虛擬函數只是簡單地返回一個值,可以考慮把這個值作為基類的一個成員變量,並在類的構造中初始化它。之后,基類的非虛擬函數可以返回這個值。
- 你可能正在將你的對象保存在一個容器中。與其將幾種類型的對象放在同一個容器中,不如考慮為每種對象類型設置單獨的容器。因此如果你有
base_class
和child_class1
、child_class2
和child_class3
,應當使用std::vector<child_class1>
、std::vector<child_class2>
和std::vector<child_class3>
而不是std::vector<base_class>
。這涉及到更多的設計上的問題,但實際上程序要快得多。
上述兩種方法都會使函數調用可內聯。
內聯實踐
在某些情況下,內聯是有用的,而在某些情況下卻沒用。是否應該進行內聯的第一個指標是函數大小:函數越小,內聯就越有意義。如果調用一個函數需要50個周期,而函數體需要20個周期來執行,那么內聯是完全合理的。 另一方面,如果一個函數的執行需要5000個周期,對於每一個調用,你將節省1%的運行時間,這可能不值得。
內聯的第二個標准是函數被調用的次數。 如果它被調用了幾次,那么就沒必要內聯。 另一方面,如果它被多次調用,內聯是合理的。然而,請記住,即使它被多次調用,你通過內聯得到的性能提升可能也不值得。
編譯器和鏈接器清楚地知道你的函數的大小,它們可以很好地決定是否內聯。就調用頻率這方面而言,編譯器和鏈接器在這方面的知識也是有限的,但為了獲得有關函數調用頻率的信息,有必要在真實世界的例子上對程序進行剖析。但是,正如我所說的,大型函數很可能不是內聯的好選擇,即便它們被多次調用。
因此,在實踐中,是否內聯的決定權大部分交給編譯器,你只要在影響性能關鍵函數明確其進行內聯。
如果通過剖析你的程序,你發現了一個對性能至關重要的函數,首先你應該用__attribute__((flatten))
來標記它,這樣編譯器就會內聯該函數對其他函數的所有調用,其整個代碼就變成了一個大函數。但即使你這樣做了,也不能保證編譯器真的會內聯所有的東西。你必須確保內聯沒有障礙,正如已經討論過的那樣:
- 打開鏈接時的優化,允許其他模塊的代碼被內聯。
- 不要使用函數指針來調用。在這種情況下,你會失去一些靈活性。
- 不要使用C++的虛擬方法來調用。你失去了一些靈活性,但有一些方法可以解決已經提到的這個問題。
只有當編譯器不能自動內聯一個函數時,你才會想手動內聯。如果自動內聯失敗,編譯器會發出警告,從那時起,你應該分析是什么原因阻止了內聯,並修復它,或者選擇手動內聯一個函數。
關於內聯的最后一句話:有些函數你不希望內聯。對於你的性能關鍵函數,有一些代碼路徑會經常被執行。但也有其他路徑,如錯誤處理,很少被執行。你想把這些放在單獨的函數中,以減少對指令緩存的壓力。用__attribute__((cold))標記這些函數,讓編譯器知道它們很少執行,這樣編譯器就可以把它們從經常訪問路徑中移開。
避免遞歸函數
遞歸函數是可以調用自己的函數。雖然帶有遞歸函數的解決方案通常更優雅,但從程序性能方面來看,非遞歸解決方案更有效率。因此,如果你需要優化帶有遞歸函數的代碼,有幾件事你可以做:
- 請確保你的遞歸函數是尾部遞歸。這將允許編譯器對你的函數進行尾部遞歸優化,並將對函數的調用轉換為跳躍。
- 使用堆棧數據結構將你的遞歸函數轉換成非遞歸。這將為你節省一些與函數調用有關的時間,但實現這個並不簡單。
- 在函數的每次迭代中做更多的事情。例如:
int factorial(int n) {
if (n <= 1) {
return 1;
} else {
return n * (n - 1) * factorial(n - 2);
}
}
上面的實現是在普通的代碼基礎上,做了更多的工作。
使用函數屬性來給編譯器提供優化提示
GCC 和 CLANG 提供了某些函數屬性,啟用后可以幫助編譯器生成更好的代碼。其中有兩個與編譯器相關的屬性:const 屬性和 pure 屬性。
屬性 pure 意味着函數的結果只取決於其輸入參數和內存的狀態。該函數不向內存寫東西,也不調用任何其他有可能這樣做的函數。
int __attribute__((pure)) sum_array(int* array, int n) {
int res = 0;
for (int i = 0; i < n; i++) {
res += a[i];
}
return res;
}
pure 函數的好處是,編譯器可以省略對具有相同參數的同一函數的調用,或者在參數未使用的情況下刪除調用。
屬性 const 意味着函數的結果只取決於其輸入參數。例子:
int __attribute__((const)) factorial(int n) {
if (n == 1) {
return 1;
} else {
return n * factorial(n - 1);
}
}
每個 const 函數也都是 pure 函數,所以關於pure 函數的一切說法也都適用於 const 函數。此外,對於 const 函數,編譯器可以在編譯過程中計算它們的值,並將其替換為常量,而不是實際調用該函數。
C++有成員函數的關鍵字 const ,但功能並不相同:如果一個方法被標記為 const,這意味着該方法不會改變對象的狀態,但它可以修改其他內存(例如打印到屏幕上)。編譯器使用這些信息來做一些優化; 如果該成員是常量,那么如果對象的狀態已經被加載,就不需要再重新加載。例如:
class test_class {
int my_value;
private:
test_class(int val) : my_value(val) {}
int get_value() const { return my_value; }
};
在這個例子中,方法 get_value
不會改變類的狀態,可以被聲明為 const。
如果你的函數要以庫的形式提供給其他開發者,那么將函數標記為 const 和 pure 就特別重要。你不知道這些人是誰,也不知道他們的代碼質量如何,這將確保編譯器在編程馬虎的情況下可以優化掉一些代碼。請注意,標准庫中的許多函數都有這些屬性。
實驗
ffmpeg – inline 與 no-inline
我們編譯了兩個版本的ffmpeg,一個是具有完全優化的默認版本,另一個是通過-fno-inline和-fno-inline-small-functions編譯器關閉內聯的削弱版本。我們用以下選項編譯了ffmpeg:
./configure --disable-inline-asm --disable-x86asm --extra-cxxflags='-fno-inline -fno-inline-small-functions' --extra-cflags='-fno-inline -fno-inline-small-functions'
看來內聯並不是ffmpeg性能大幅提升的根源。下面是結果:
Parameter | Inlining disabled | Inlining enabled |
---|---|---|
Runtime (s) | 291.8s | 285s |
常規編譯(帶內聯)只比禁用內聯的版本快2.4%。讓我們來討論一下。正如我們以前所說的,為了從內聯中獲得真正的好處,你的函數盡可能短。否則的話,內聯並不能帶來性能的提升。
我們對 ffmpeg 進行了分析,ffmpeg 本身也使用了 av_flatten 和 av_inline 宏,它們與 GCC 中的 flatten 和 inline 屬性相對應。當這些屬性被明確設置時,-finline 和 fno-inline 開關沒有任何作用。我想這就是我們看到性能差異如此之小的原因。
我們還嘗試對一些函數使用 flatten 屬性,以使轉換更快,但沒有任何函數會帶來性能上的顯著提高,因為沒有真正的小函數會有這樣的含義。
測試
我們使用 ffmpeg 結果並不好。因此為了明白 inline 是有效的,我們創建了一些測試用例。它們在我們的github倉庫里 。運行它們只需要到路徑 2020-06-functioncalls 下執行 make sorting_runtimes。
我們采用了一個常規的選擇排序算法,並對其進行了一些內聯處理,看看內聯對排序的性能有何影響。
void sort_regular(int* a, int len) {
for (int i = 0; i < len; i++) {
int min = a[i];
int min_index = i;
for (int j = i+1; j < len; j++) {
if (a[j] < min) {
min = a[j];
min_index = j;
}
}
std::swap(a[i], a[min_index]);
}
}
請注意,該算法由兩個嵌套循環組成。循環內部有一個 if 語句,檢查元素 a[j] 是否小於元素 min,如果是,則存儲新的最小元素的值。
我們在這個實現的基礎上創建了四個新函數。其中兩個是調用內聯版本的函數,另外兩個是調用非內聯版本的函數。我使用 GCC 的 __attribute__((always_inline)) 和 __attribute__((noinline)) 來確保當前狀態正確的(不會被編譯器自動內聯)。其中兩個叫sort_[no]inline_small
的函數將if(a[j]<min)
里的語句封裝成為函數調用。另外兩個sort_[no]inline_large
則將for (int j = i + 1; j < len; j++) { ... }
里面的語句全部封裝成函數。下面是具體的算法實現:
void sort_[no]inline_small(int* a, int len) {
for (int i = 0; i < len; i++) {
int min = a[i];
int min_index = i;
for (int j = i+1; j < len; j++) {
update_min_index_[no]inline(&a[j], j, &min, &min_index);
}
std::swap(a[i], a[min_index]);
}
}
void sort_[no]inline_large(int* a, int len) {
for (int i = 0; i < len; i++) {
int smallest = find_min_element_[no]inline(a, i, len);
std::swap(a[i], a[smallest]);
}
}
我們執行上述的五個函數,並且輸入的數組長度為 40000。下面是結果:
Regular | Small inline | Small Noinline | Large Inline | Large Noinline | |
---|---|---|---|---|---|
Runtime | 1829ms | 1850ms | 3667ms | 1846ms | 2294ms |
正如你所看到的,普通、小內聯和大內聯之間的差異都在一定的測量范圍內。在小內聯函數的情況下,內循環被調用了4億次,這在性能上的提升是可觀的。小的不內聯的實現比常規實現慢了2倍。在大型內聯函數的情況下,我們也看到了不內聯會導致性能下降,但這次的下降幅度較小約為20%。在這種情況下,內循環被調用了4萬次,比第一個例子中的4億次小得多。
總結
正如我們在上章節看到的那樣,函數調用是昂貴的操作,但幸運的是,現代編譯器在大多數時候都能很好地處理這個問題。開發者唯一需要確保的是,內聯沒有任何障礙,例如禁用的鏈接時間優化或對虛擬函數的調用。如果需要優化對性能敏感的代碼,開發者可以通過編譯器屬性手動強制內聯。
本文提到的其他方法可用性有限,因為一個函數必須有特殊的形式,以便編譯器能夠應用它們。盡管如此,它們也不應該被完全忽視。
如何檢查函數在運行時是否被內聯?
如果你想檢查函數是否被內聯,首先想到的是查看編譯器產生的匯編代碼。但你也可以以編程方式在程序執行過程中來確定。
假設你想檢查一個特定的調用是否被內聯。你可以這樣做。每個函數都需要維護一個非內聯可尋址的地址,方便外部調用。檢查你的函數my_function
是否被內聯,你需要將my_function
的函數指針(未被內聯)與PC的當前值進行比較。根據比較的差異就可獲得結論:
以下是我在我的環境中的做法(GCC 7,x86_64):
void * __attribute__((noinline)) get_pc () { return _builtin_return_address(0); }
void my_function() {
void* pc = get_pc();
asm volatile("": : :"memory");
printf("Function pointer = %p, current pc = %p\n", &my_function, pc);
}
void main() {
my_function();
}
如果一個函數沒有被內聯,那么PC的當前值和函數指針的值之間的差異應該很小,否則會更大。在我的系統中,當my_function
沒有被內聯時,我得到了以下輸出:
Function pointer = 0x55fc17902500, pc = 0x55fc1790257b
如果該函數被內聯,我得到的是:
Function pointer = 0x55ddcffc6560, pc = 0x55ddcffc4c6a
對於非內聯版本的差異是0x7b,對於內聯版本的差異是0x181f。
擴展閱讀
Smarter C/C++ inlining with __attribute__((flatten))