函式呼叫的代價與優化

PcDack發表於2022-03-22

譯者注:本文原始連結為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_classchild_class1child_class2child_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))

Agner.org: Software Optimization Resources

Implications of pure and constant functions

相關文章