為什麼 const 無法讓 C 程式碼跑得更快?
在幾個月前的一篇文章裡,我曾說過“有個一個流行的傳言,const
有助於編譯器最佳化 C 和 C++ 程式碼”。我覺得我需要解釋一下,尤其是曾經我自己也以為這是顯然對的。我將會用一些理論並構造一些例子來論證,然後在一個真實的程式碼庫 Sqlite
上做一些實驗和基準測試。
一個簡單的測試
讓我們從一個最簡單、最明顯的例子開始,以前認為這是一個 const
讓 C 程式碼跑得更快的例子。首先,假設我們有如下兩個函式宣告:
void func(int *x);
void constFunc(const int *x);
然後假設我們如下兩份程式碼:
void byArg(int *x)
{
printf("%d\n", *x);
func(x);
printf("%d\n", *x);
}
void constByArg(const int *x)
{
printf("%d\n", *x);
constFunc(x);
printf("%d\n", *x);
}
呼叫 printf()
時,CPU 會透過指標從 RAM 中取得 *x
的值。很顯然,constByArg()
會稍微快一點,因為編譯器知道 *x
是常量,因此不需要在呼叫 constFunc()
之後再次獲取它的值。它僅是列印相同的東西。沒問題吧?讓我們來看下 GCC 在如下編譯選項下生成的彙編程式碼:
$ gcc -S -Wall -O3 test.c
$ view test.s
以下是函式 byArg()
的完整彙編程式碼:
byArg:
.LFB23:
.cfi_startproc
pushq %rbx
.cfi_def_cfa_offset 16
.cfi_offset 3, -16
movl (%rdi), %edx
movq %rdi, %rbx
leaq .LC0(%rip), %rsi
movl $1, %edi
xorl %eax, %eax
call __printf_chk@PLT
movq %rbx, %rdi
call func@PLT # constFoo 中唯一不同的指令
movl (%rbx), %edx
leaq .LC0(%rip), %rsi
xorl %eax, %eax
movl $1, %edi
popq %rbx
.cfi_def_cfa_offset 8
jmp __printf_chk@PLT
.cfi_endproc
函式 byArg()
和函式 constByArg()
生成的彙編程式碼中唯一的不同之處是 constByArg()
有一句彙編程式碼 call constFunc@PLT
,這正是原始碼中的呼叫。關鍵字 const
本身並沒有造成任何字面上的不同。
好了,這是 GCC 的結果。或許我們需要一個更聰明的編譯器。Clang 會有更好的表現嗎?
$ clang -S -Wall -O3 -emit-llvm test.c
$ view test.ll
這是 IR
程式碼(LCTT 譯註:LLVM 的中間語言)。它比彙編程式碼更加緊湊,所以我可以把兩個函式都匯出來,讓你可以看清楚我所說的“除了呼叫外,沒有任何字面上的不同”是什麼意思:
; Function Attrs: nounwind uwtable
define dso_local void @byArg(i32*) local_unnamed_addr #0 {
%2 = load i32, i32* %0, align 4, !tbaa !2
%3 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %2)
tail call void @func(i32* %0) #4
%4 = load i32, i32* %0, align 4, !tbaa !2
%5 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %4)
ret void
}
; Function Attrs: nounwind uwtable
define dso_local void @constByArg(i32*) local_unnamed_addr #0 {
%2 = load i32, i32* %0, align 4, !tbaa !2
%3 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %2)
tail call void @constFunc(i32* %0) #4
%4 = load i32, i32* %0, align 4, !tbaa !2
%5 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %4)
ret void
}
某些有作用的東西
接下來是一組 const
能夠真正產生作用的程式碼:
void localVar()
{
int x = 42;
printf("%d\n", x);
constFunc(&x);
printf("%d\n", x);
}
void constLocalVar()
{
const int x = 42; // 對本地變數使用 const
printf("%d\n", x);
constFunc(&x);
printf("%d\n", x);
}
下面是 localVar()
的彙編程式碼,其中有兩條指令在 constLocalVar()
中會被最佳化掉:
localVar:
.LFB25:
.cfi_startproc
subq $24, %rsp
.cfi_def_cfa_offset 32
movl $42, %edx
movl $1, %edi
movq %fs:40, %rax
movq %rax, 8(%rsp)
xorl %eax, %eax
leaq .LC0(%rip), %rsi
movl $42, 4(%rsp)
call __printf_chk@PLT
leaq 4(%rsp), %rdi
call constFunc@PLT
movl 4(%rsp), %edx # 在 constLocalVar() 中沒有
xorl %eax, %eax
movl $1, %edi
leaq .LC0(%rip), %rsi # 在 constLocalVar() 中沒有
call __printf_chk@PLT
movq 8(%rsp), %rax
xorq %fs:40, %rax
jne .L9
addq $24, %rsp
.cfi_remember_state
.cfi_def_cfa_offset 8
ret
.L9:
.cfi_restore_state
call __stack_chk_fail@PLT
.cfi_endproc
在 LLVM 生成的 IR
程式碼中更明顯一點。在 constLocalVar()
中,第二次呼叫 printf()
之前的 load
會被最佳化掉:
; Function Attrs: nounwind uwtable
define dso_local void @localVar() local_unnamed_addr #0 {
%1 = alloca i32, align 4
%2 = bitcast i32* %1 to i8*
call void @llvm.lifetime.start.p0i8(i64 4, i8* nonnull %2) #4
store i32 42, i32* %1, align 4, !tbaa !2
%3 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 42)
call void @constFunc(i32* nonnull %1) #4
%4 = load i32, i32* %1, align 4, !tbaa !2
%5 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %4)
call void @llvm.lifetime.end.p0i8(i64 4, i8* nonnull %2) #4
ret void
}
好吧,現在,constLocalVar()
成功的省略了對 *x
的重新讀取,但是可能你已經注意到一些問題:localVar()
和 constLocalVar()
在函式體中做了同樣的 constFunc()
呼叫。如果編譯器能夠推斷出 constFunc()
沒有修改 constLocalVar()
中的 *x
,那為什麼不能推斷出完全一樣的函式呼叫也沒有修改 localVar()
中的 *x
?
這個解釋更貼近於為什麼 C 語言的 const
不能作為最佳化手段的核心原因。C 語言的 const
有兩個有效的含義:它可以表示這個變數是某個可能是常數也可能不是常數的資料的一個只讀別名,或者它可以表示該變數是真正的常量。如果你移除了一個指向常量的指標的 const
屬性並寫入資料,那結果將是一個未定義行為。另一方面,如果是一個指向非常量值的 const
指標,將就沒問題。
這份 constFunc()
的可能實現揭示了這意味著什麼:
// x 是一個指向某個可能是常數也可能不是常數的資料的只讀指標
void constFunc(const int *x)
{
// local_var 是一個真正的常數
const int local_var = 42;
// C 語言規定的未定義行為
doubleIt((int*)&local_var);
// 誰知道這是不是一個未定義行為呢?
doubleIt((int*)x);
}
void doubleIt(int *x)
{
*x *= 2;
}
localVar()
傳遞給 constFunc()
一個指向非 const
變數的 const
指標。因為這個變數並非常量,constFunc()
可以撒個謊並強行修改它而不觸發未定義行為。所以,編譯器不能斷定變數在呼叫 constFunc()
後仍是同樣的值。在 constLocalVar()
中的變數是真正的常量,因此,編譯器可以斷定它不會改變 —— 因為在 constFunc()
去除變數的 const
屬性並寫入它將會是一個未定義行為。
第一個例子中的函式 byArg()
和 constByArg()
是沒有可能最佳化的,因為編譯器沒有任何方法能知道 *x
是否真的是 const
常量。
補充(和題外話):相當多的讀者已經正確地指出,使用
const int *x
,該指標本身不是限定的常量,只是該資料被加個了別名,而const int * const extra_const
是一個“雙向”限定為常量的指標。但是因為指標本身的常量與別名資料的常量無關,所以結果是相同的。僅在extra_const
指向使用const
定義的物件時,*(int*const)extra_const = 0
才是未定義行為。(實際上,*(int*)extra_const = 0
也不會更糟。)因為它們之間的區別可以一句話說明白,一個是完全的const
指標,另外一個可能是也可能不是常量本身的指標,而是一個可能是也可能不是常量的物件的只讀別名,我將繼續不嚴謹地引用“常量指標”。(題外話結束)
但是為什麼不一致呢?如果編譯器能夠推斷出 constLocalVar()
中呼叫的 constFunc()
不會修改它的引數,那麼肯定也能繼續在其他 constFunc()
的呼叫上實施相同的最佳化,是嗎?並不。編譯器不能假設 constLocalVar()
根本沒有執行。如果不是這樣(例如,它只是程式碼生成器或者宏的一些未使用的額外輸出),constFunc()
就能偷偷地修改資料而不觸發未定義行為。
你可能需要重複閱讀幾次上述說明和示例,但不要擔心,它聽起來很荒謬,它確實是正確的。不幸的是,對 const
變數進行寫入是最糟糕的未定義行為:大多數情況下,編譯器無法知道它是否將會是未定義行為。所以,大多數情況下,編譯器看見 const
時必須假設它未來可能會被移除掉,這意味著編譯器不能使用它進行最佳化。這在實踐中是正確的,因為真實的 C 程式碼會在“深思熟慮”後移除 const
。
簡而言之,很多事情都可以阻止編譯器使用 const
進行最佳化,包括使用指標從另一記憶體空間接受資料,或者在堆空間上分配資料。更糟糕的是,在大部分編譯器能夠使用 const
進行最佳化的情況,它都不是必須的。例如,任何像樣的編譯器都能推斷出下面程式碼中的 x
是一個常量,甚至都不需要 const
:
int x = 42, y = 0;
printf("%d %d\n", x, y);
y += x;
printf("%d %d\n", x, y);
總結,const
對最佳化而言幾乎無用,因為:
- 除了特殊情況,編譯器需要忽略它,因為其他程式碼可能合法地移除它
- 在 #1 以外的大多數例外中,編譯器無論如何都能推斷出該變數是常量
C++
如果你在使用 C++ 那麼有另外一個方法讓 const
能夠影響到程式碼的生成:函式過載。你可以用 const
和非 const
的引數過載同一個函式,而非 const
版本的程式碼可能可以被最佳化(由程式設計師最佳化而不是編譯器),減少某些複製或者其他事情。
void foo(int *p)
{
// 需要做更多的資料複製
}
void foo(const int *p)
{
// 不需要保護性的複製副本
}
int main()
{
const int x = 42;
// const 影響被呼叫的是哪一個版本的過載函式
foo(&x);
return 0;
}
一方面,我不認為這會在實際的 C++ 程式碼中大量使用。另一方面,為了導致差異,程式設計師需要假設編譯器無法做出,因為它們不受語言保護。
用 Sqlite3 進行實驗
有了足夠的理論和例子。那麼 const
在一個真正的程式碼庫中有多大的影響呢?我將會在程式碼庫 Sqlite
(版本:3.30.0)上做一個測試,因為:
- 它真正地使用了
const
- 它不是一個簡單的程式碼庫(超過 20 萬行程式碼)
- 作為一個資料庫,它包括了字串處理、數學計算、日期處理等一系列內容
- 它能夠在繫結 CPU 的情況下進行負載測試
此外,作者和貢獻者們已經進行了多年的效能最佳化工作,因此我能確定他們沒有錯過任何有顯著效果的最佳化。
配置
我做了兩份原始碼複製,並且正常編譯其中一份。而對於另一份複製,我插入了這個特殊的預處理程式碼段,將 const
變成一個空操作:
#define const
(GNU) sed
可以將一些東西新增到每個檔案的頂端,比如 sed -i '1i#define const' *.c *.h
。
在編譯期間使用指令碼生成 Sqlite
程式碼稍微有點複雜。幸運的是當 const
程式碼和非 const
程式碼混合時,編譯器會產生了大量的提醒,因此很容易發現它並調整指令碼來包含我的反 const
程式碼段。
直接比較編譯結果毫無意義,因為任意微小的改變就會影響整個記憶體佈局,這可能會改變整個程式碼中的指標和函式呼叫。因此,我用每個指令的二進位制大小和彙編程式碼作為識別碼(objdump -d libsqlite3.so.0.8.6
)。舉個例子,這個函式:
000000000005d570 <sqlite3_blob_read>:
5d570: 4c 8d 05 59 a2 ff ff lea -0x5da7(%rip),%r8 # 577d0 <sqlite3BtreePayloadChecked>
5d577: e9 04 fe ff ff jmpq 5d380 <blobReadWrite>
5d57c: 0f 1f 40 00 nopl 0x0(%rax)
將會變成這樣:
sqlite3_blob_read 7lea 5jmpq 4nopl
在編譯時,我保留了所有 Sqlite
的編譯設定。
分析編譯結果
const
版本的 libsqlite3.so
的大小是 4,740,704 位元組,大約比 4,736,712 位元組的非 const
版本大了 0.1% 。在全部 1374 個匯出函式(不包括類似 PLT 裡的底層輔助函式)中,一共有 13 個函式的識別碼不一致。
其中的一些改變是由於插入的預處理程式碼。舉個例子,這裡有一個發生了更改的函式(已經刪去一些 Sqlite
特有的定義):
#define LARGEST_INT64 (0xffffffff|(((int64_t)0x7fffffff)<<32))
#define SMALLEST_INT64 (((int64_t)-1) - LARGEST_INT64)
static int64_t doubleToInt64(double r){
/*
** Many compilers we encounter do not define constants for the
** minimum and maximum 64-bit integers, or they define them
** inconsistently. And many do not understand the "LL" notation.
** So we define our own static constants here using nothing
** larger than a 32-bit integer constant.
*/
static const int64_t maxInt = LARGEST_INT64;
static const int64_t minInt = SMALLEST_INT64;
if( r<=(double)minInt ){
return minInt;
}else if( r>=(double)maxInt ){
return maxInt;
}else{
return (int64_t)r;
}
}
刪去 const
使得這些常量變成了 static
變數。我不明白為什麼會有不瞭解 const
的人讓這些變數加上 static
。同時刪去 static
和 const
會讓 GCC 再次認為它們是常量,而我們將得到同樣的編譯輸出。由於類似這樣的區域性的 static const
變數,使得 13 個函式中有 3 個函式產生假的變化,但我一個都不打算修復它們。
Sqlite
使用了很多全域性變數,而這正是大多數真正的 const
最佳化產生的地方。通常情況下,它們類似於將一個變數比較代替成一個常量比較,或者一個迴圈在部分展開的一步。(Radare toolkit 可以很方便的找出這些最佳化措施。)一些變化則令人失望。sqlite3ParseUri()
有 487 個指令,但 const
產生的唯一區別是進行了這個比較:
test %al, %al
je <sqlite3ParseUri+0x717>
cmp $0x23, %al
je <sqlite3ParseUri+0x717>
並交換了它們的順序:
cmp $0x23, %al
je <sqlite3ParseUri+0x717>
test %al, %al
je <sqlite3ParseUri+0x717>
基準測試
Sqlite
自帶了一個效能迴歸測試,因此我嘗試每個版本的程式碼執行一百次,仍然使用預設的 Sqlite
編譯設定。以秒為單位的測試結果如下:
const | 非 const | |
---|---|---|
最小值 | 10.658s | 10.803s |
中間值 | 11.571s | 11.519s |
最大值 | 11.832s | 11.658s |
平均值 | 11.531s | 11.492s |
就我個人看來,我沒有發現足夠的證據來說明這個差異值得關注。我是說,我從整個程式中刪去 const
,所以如果它有明顯的差別,那麼我希望它是顯而易見的。但也許你關心任何微小的差異,因為你正在做一些絕對效能非常重要的事。那讓我們試一下統計分析。
我喜歡使用類似 Mann-Whitney U 檢驗這樣的東西。它類似於更著名的 T 檢驗,但對你在機器上計時時產生的複雜隨機變數(由於不可預測的上下文切換、頁錯誤等)更加健壯。以下是結果:
const | 非 const | |
---|---|---|
N | 100 | 100 |
Mean rank | 121.38 | 79.62 |
Mann-Whitney U | 2912 |
Z | -5.10 |
2-sided p value | <10-6 |
HL median difference | -0.056s |
95% confidence interval | -0.077s – -0.038s |
U 檢驗已經發現統計意義上具有顯著的效能差異。但是,令人驚訝的是,實際上是非 const
版本更快——大約 60ms,0.5%。似乎 const
啟用的少量“最佳化”不值得額外程式碼的開銷。這不像是 const
啟用了任何類似於自動向量化的重要的最佳化。當然,你的結果可能因為編譯器配置、編譯器版本或者程式碼庫等等而有所不同,但是我覺得這已經說明了 const
是否能夠有效地提高 C
的效能,我們現在已經看到答案了。
那麼,const 有什麼用呢?
儘管存在缺陷,C/C++ 的 const
仍有助於型別安全。特別是,結合 C++ 的移動語義和 std::unique_pointer
,const
可以使指標所有權顯式化。在超過十萬行程式碼的 C++ 舊程式碼庫裡,指標所有權模糊是一個大難題,我對此深有感觸。
但是,我以前常常使用 const
來實現有意義的型別安全。我曾聽說過基於效能上的原因,最好是儘可能多地使用 const
。我曾聽說過當效能很重要時,重構程式碼並新增更多的 const
非常重要,即使以降低程式碼可讀性的方式。當時覺得這沒問題,但後來我才知道這並不對。
via: https://theartofmachinery.com/2019/08/12/c_const_isnt_for_performance.html
作者:Simon Arneaud 選題:lujun9972 譯者:LazyWolfLin 校對:wxy
相關文章
- 面試官:如何寫出讓 CPU 跑得更快的程式碼?面試
- 讓容器跑得更快:CPU Burst 技術實踐
- 為什麼 Python 程式碼在函式中執行得更快?Python函式
- 讓你的 Node.js 應用跑得更快的 10 個技巧Node.js
- 為什麼對陣列排序讓Python迴圈執行更快陣列排序Python
- WebAssembly 系列(五)為什麼 WebAssembly 更快?Web
- 是什麼讓Node.js比Java更快?Node.jsJava
- 什麼程式碼是ChatGPT無法編寫的? - datachimpChatGPT
- 讓CNN跑得更快,騰訊優圖提出全域性和動態過濾器剪枝CNN過濾器
- 讓DB2跑得更快——DB2內部解析與效能最佳化DB2
- 為什麼說無程式碼開發比低程式碼開發更好?
- 為什麼索引無法使用is null和is not null索引Null
- 為什麼getYear()無法取得正確年份
- 讓 Python 程式碼執行更快的技巧Python
- 為什麼寫程式碼讓人家覺得你很厲害?
- 是什麼讓C#成為最值得學習的程式語言
- 如何寫出讓同事無法維護的程式碼?
- Vue原始碼中為什麼要const _toStr = Object.prototype.toString?Vue原始碼Object
- 為什麼python中使用模組無法識別Python
- 為什麼trigger()無法觸發hover事件事件
- 為什麼有時父元素無法包含子元素?
- 為什麼flexigrid無法顯示資料Flex
- 為什麼網路教育無法取代學校教育?
- 為什麼有人說無程式碼和低程式碼軟體行業破壞者?行業
- 用 PyPy 讓你的 Python 程式碼執行得更快!Python
- 讓Python程式碼更快執行的 5 種方法Python
- 為什麼要做程式碼審計?
- 為什麼大神級程式的C語言程式碼裡到處都是goto?C語言Go
- 獲取的 Cookie 為什麼無法用於爬蟲Cookie爬蟲
- 為什麼主流網站無法捕獲 XSS 漏洞?網站
- 為什麼無密碼認證能夠有效密碼
- 為什麼大多數程式設計師無法獨立完成桌面軟體程式設計師
- 什麼是無程式碼?無程式碼應用開發的12大好處!
- 21 個VSCode 快捷鍵,讓程式碼更快,更有趣VSCode
- C++是什麼意思,為什麼叫C++?C++
- 讓人崩潰的程式碼,無法預測的結果
- 為什麼為什麼為什麼為什麼為什麼你要做一名程式設計師?程式設計師
- 低程式碼與無程式碼真正的區別是什麼