更多精彩內容,請關注微信公眾號:后端技術小屋
流水線技術
現代CPU為了提高執行指令執行的吞吐量,使用了流水線技術,它將每條指令分解為多步,讓不同指令的各步操作重疊,從而實現若干條指令並行處理。在流水線中,一條指令的生命周期可能包括:
- 取指:將指令從存儲器中讀取出來,放入指令緩沖區中。
- 譯碼:對取出來的指令進行翻譯
- 執行:知曉了指令內容,便可使用CPU中對應的計算單元執行該指令
- 訪存:將數據從存儲器讀出,或寫入存儲器
- 寫回:將指令的執行結果寫回到通用寄存器組
流水線技術無法提升CPU執行單條指令的性能,但是可以通過相鄰指令的並行化提高整體執行指令的吞吐量。
分支預測
我們都知道,程序的控制流程基本可分為三種:順序、分支和循環。對CPU流水線來說,順序比較好處理,一條路往前趟就行了。但是當程序中有了分支結構之后,CPU無法確切知道到底應該取分支1中的D指令,還是分支二中的E指令。此時CPU會根據指令執行的上下文,猜測那一路分支應該被執行。預測的結果有兩個,命中或者命不中。在前一種情況下,CPU流水線正常執行,不會被打斷。在后一種情況下,需要CPU丟掉為跳轉指令之后的所有指令所做的工作,再開始從正確位置處起始的指令去填充流水線,這會導致很嚴重的懲罰:大約20-40個時鍾周期的浪費,導致程序性能的嚴重下降。
什么是likely和unlikely
既然程序是我們程序員所寫,在一些明確的場景下,我們應該比CPU和編譯器更了解哪個分支條件更有可能被滿足。我們是否可將這一先驗知識告知編譯器和CPU, 提高分支預測的准確率,從而減少CPU流水線分支預測錯誤帶來的性能損失呢?答案是可以!它便是likely
和unlikely
。在Linux內核代碼中,這兩個宏的應用比比皆是。下面是他們的定義:
#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)
likely
,用於修飾if/else if分支,表示該分支的條件更有可能被滿足。而unlikely
與之相反
以下為示例。unlikely
修飾argc > 0
分支,表示該分支不太可能被滿足。
#include <cstdio>
#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)
int main(int argc, char *argv[])
{
if (unlikely(argc > 0)) {
puts ("Positive\n");
} else
{
puts ("Zero or Negative\n");
}
return 0;
}
likely/unlikely的原理
接下來,我們從匯編指令分析likely/unlikely到底是如何起作用的?
首先我們將上述代碼中的unlikely
去掉,然后反匯編,作為對照組
#include <cstdio>
#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)
int main(int argc, char *argv[])
{
if (argc > 0) {
puts ("Positive\n");
} else
{
puts ("Zero or Negative\n");
}
return 0;
}
匯編如下,我們看到,if分支中的指令被編譯器放置於分支跳轉指令jle相鄰的位置,即CPU流水線在遇到jle
指令所代表的的'岔路口'時,更傾向於走if分支
.LC0:
.string "Positive\n"
.LC1:
.string "Zero or Negative\n"
main:
sub rsp, 8
test edi, edi
jle .L2 ; 如果argc <= 0, 跳轉到L2
mov edi, OFFSET FLAT:.LC0 ; 如果argc > 0, 從這里執行
call puts
.L3:
xor eax, eax
add rsp, 8
ret
.L2:
mov edi, OFFSET FLAT:.LC1
call puts
jmp .L3
接着我們在if分支中加上unlikely, 反匯編如下。這里的情況正好與對照組相反,if分支下的指令被編譯器放置於遠離跳轉指令jg
的位置。這意味着CPU此時更傾向於走else分支。
.LC0:
.string "Positive\n"
.LC1:
.string "Zero or Negative\n"
main:
sub rsp, 8
test edi, edi
jg .L6
mov edi, OFFSET FLAT:.LC1
call puts
.L3:
xor eax, eax
add rsp, 8
ret
.L6:
mov edi, OFFSET FLAT:.LC0
call puts
jmp .L3
因此,通過對分支條件使用likely
和unlikely
,我們可給編譯器一種暗示,即該分支條件被滿足的概率比較大或比較小。而編譯器利用這一信息優化其機器指令,從而最大限度減少CPU分支預測失敗帶來的懲罰。
likely/unlikely的適用條件
CPU有自帶的分支預測器,在大多數場景下效果不錯。因此在分支發生概率嚴重傾斜、追求極致性能的場景下,使用likely/unlikely
才具有較大意義。
C++20中的likely/unlikely
C++20之前的,likely
和unlikely
只不過是一對自定義的宏。而C++20中正式將likely
和unlikely
確定為屬性關鍵字。
int foo(int i) {
switch(i) {
case 1: handle1();
break;
[[likely]] case 2: handle2();
break;
}
}
相關提案見:http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2016/p0479r0.html
參考
- 《深入理解計算機系統》
- likely — unlikely directives
推薦閱讀
- 一文讀懂clickhouse集群監控
- 30分鍾入門Vim
- 30分鍾入門GDB
- STL源碼分析--vector
- zookeeper client原理總結
- redis實現分布式鎖
- 推薦幾個好用的效率神器
- C/C++關鍵字之restrict
- 現代C++之右值語義
- Python亂碼九問
- Linux Shell腳本攻略讀書筆記
更多精彩內容,請掃碼關注微信公眾號:后端技術小屋。如果覺得文章對你有幫助的話,請多多分享、轉發、在看。