為什么const無法讓C代碼跑得更快?
在幾個月前的一篇文章里,我曾說過“有個一個流行的傳言,const
有助于編譯器優(yōu)化 C 和 C++ 代碼”。我覺得我需要解釋一下,尤其是曾經(jīng)我自己也以為這是顯然對的。我將會用一些理論并構(gòu)造一些例子來論證,然后在一個真實(shí)的代碼庫 Sqlite
上做一些實(shí)驗(yàn)和基準(zhǔn)測試。
一個簡單的測試
讓我們從一個最簡單、最明顯的例子開始,以前認(rèn)為這是一個 const
讓 C 代碼跑得更快的例子。首先,假設(shè)我們有如下兩個函數(shù)聲明:
void func(int *x);
void constFunc(const int *x);
然后假設(shè)我們?nèi)缦聝煞荽a:
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);
}
調(diào)用 printf()
時(shí),CPU 會通過指針從 RAM 中取得 *x
的值。很顯然,constByArg()
會稍微快一點(diǎn),因?yàn)榫幾g器知道 *x
是常量,因此不需要在調(diào)用 constFunc()
之后再次獲取它的值。它僅是打印相同的東西。沒問題吧?讓我們來看下 GCC 在如下編譯選項(xiàng)下生成的匯編代碼:
$ gcc -S -Wall -O3 test.c
$ view test.s
以下是函數(shù) 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
函數(shù) byArg()
和函數(shù) constByArg()
生成的匯編代碼中唯一的不同之處是 constByArg()
有一句匯編代碼 call constFunc@PLT
,這正是源代碼中的調(diào)用。關(guān)鍵字 const
本身并沒有造成任何字面上的不同。
好了,這是 GCC 的結(jié)果?;蛟S我們需要一個更聰明的編譯器。Clang 會有更好的表現(xiàn)嗎?
$ clang -S -Wall -O3 -emit-llvm test.c
$ view test.ll
這是 IR
代碼(LCTT 譯注:LLVM 的中間語言)。它比匯編代碼更加緊湊,所以我可以把兩個函數(shù)都導(dǎo)出來,讓你可以看清楚我所說的“除了調(diào)用外,沒有任何字面上的不同”是什么意思:
; 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
能夠真正產(chǎn)生作用的代碼:
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()
中會被優(yōu)化掉:
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
代碼中更明顯一點(diǎn)。在 constLocalVar()
中,第二次調(diào)用 printf()
之前的 load
會被優(yōu)化掉:
; 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
}
好吧,現(xiàn)在,constLocalVar()
成功的省略了對 *x
的重新讀取,但是可能你已經(jīng)注意到一些問題:localVar()
和 constLocalVar()
在函數(shù)體中做了同樣的 constFunc()
調(diào)用。如果編譯器能夠推斷出 constFunc()
沒有修改 constLocalVar()
中的 *x
,那為什么不能推斷出完全一樣的函數(shù)調(diào)用也沒有修改 localVar()
中的 *x
?
這個解釋更貼近于為什么 C 語言的 const
不能作為優(yōu)化手段的核心原因。C 語言的 const
有兩個有效的含義:它可以表示這個變量是某個可能是常數(shù)也可能不是常數(shù)的數(shù)據(jù)的一個只讀別名,或者它可以表示該變量是真正的常量。如果你移除了一個指向常量的指針的 const
屬性并寫入數(shù)據(jù),那結(jié)果將是一個未定義行為。另一方面,如果是一個指向非常量值的 const
指針,將就沒問題。
這份 constFunc()
的可能實(shí)現(xiàn)揭示了這意味著什么:
// x 是一個指向某個可能是常數(shù)也可能不是常數(shù)的數(shù)據(jù)的只讀指針
void constFunc(const int *x)
{
// local_var 是一個真正的常數(shù)
const int local_var = 42;
// C 語言規(guī)定的未定義行為
doubleIt((int*)&local_var);
// 誰知道這是不是一個未定義行為呢?
doubleIt((int*)x);
}
void doubleIt(int *x)
{
*x *= 2;
}
localVar()
傳遞給 constFunc()
一個指向非 const
變量的 const
指針。因?yàn)檫@個變量并非常量,constFunc()
可以撒個謊并強(qiáng)行修改它而不觸發(fā)未定義行為。所以,編譯器不能斷定變量在調(diào)用 constFunc()
后仍是同樣的值。在 constLocalVar()
中的變量是真正的常量,因此,編譯器可以斷定它不會改變 —— 因?yàn)樵?constFunc()
去除變量的 const
屬性并寫入它將會是一個未定義行為。
第一個例子中的函數(shù) byArg()
和 constByArg()
是沒有可能優(yōu)化的,因?yàn)榫幾g器沒有任何方法能知道 *x
是否真的是 const
常量。
補(bǔ)充(和題外話):相當(dāng)多的讀者已經(jīng)正確地指出,使用
const int *x
,該指針本身不是限定的常量,只是該數(shù)據(jù)被加個了別名,而const int * const extra_const
是一個“雙向”限定為常量的指針。但是因?yàn)橹羔槺旧淼某A颗c別名數(shù)據(jù)的常量無關(guān),所以結(jié)果是相同的。僅在extra_const
指向使用const
定義的對象時(shí),*(int*const)extra_const = 0
才是未定義行為。(實(shí)際上,*(int*)extra_const = 0
也不會更糟。)因?yàn)樗鼈冎g的區(qū)別可以一句話說明白,一個是完全的const
指針,另外一個可能是也可能不是常量本身的指針,而是一個可能是也可能不是常量的對象的只讀別名,我將繼續(xù)不嚴(yán)謹(jǐn)?shù)匾?ldquo;常量指針”。(題外話結(jié)束)
但是為什么不一致呢?如果編譯器能夠推斷出 constLocalVar()
中調(diào)用的 constFunc()
不會修改它的參數(shù),那么肯定也能繼續(xù)在其他 constFunc()
的調(diào)用上實(shí)施相同的優(yōu)化,是嗎?并不。編譯器不能假設(shè) constLocalVar()
根本沒有運(yùn)行。如果不是這樣(例如,它只是代碼生成器或者宏的一些未使用的額外輸出),constFunc()
就能偷偷地修改數(shù)據(jù)而不觸發(fā)未定義行為。
你可能需要重復(fù)閱讀幾次上述說明和示例,但不要擔(dān)心,它聽起來很荒謬,它確實(shí)是正確的。不幸的是,對 const
變量進(jìn)行寫入是最糟糕的未定義行為:大多數(shù)情況下,編譯器無法知道它是否將會是未定義行為。所以,大多數(shù)情況下,編譯器看見 const
時(shí)必須假設(shè)它未來可能會被移除掉,這意味著編譯器不能使用它進(jìn)行優(yōu)化。這在實(shí)踐中是正確的,因?yàn)檎鎸?shí)的 C 代碼會在“深思熟慮”后移除 const
。
簡而言之,很多事情都可以阻止編譯器使用 const
進(jìn)行優(yōu)化,包括使用指針從另一內(nèi)存空間接受數(shù)據(jù),或者在堆空間上分配數(shù)據(jù)。更糟糕的是,在大部分編譯器能夠使用 const
進(jìn)行優(yōu)化的情況,它都不是必須的。例如,任何像樣的編譯器都能推斷出下面代碼中的 x
是一個常量,甚至都不需要 const
:
int x = 42, y = 0;
printf("%d %d\n", x, y);
y += x;
printf("%d %d\n", x, y);
總結(jié),const
對優(yōu)化而言幾乎無用,因?yàn)椋?/p>
- 除了特殊情況,編譯器需要忽略它,因?yàn)槠渌a可能合法地移除它
- 在 #1 以外的大多數(shù)例外中,編譯器無論如何都能推斷出該變量是常量
C++
如果你在使用 C++ 那么有另外一個方法讓 const
能夠影響到代碼的生成:函數(shù)重載。你可以用 const
和非 const
的參數(shù)重載同一個函數(shù),而非 const
版本的代碼可能可以被優(yōu)化(由程序員優(yōu)化而不是編譯器),減少某些拷貝或者其他事情。
void foo(int *p)
{
// 需要做更多的數(shù)據(jù)拷貝
}
void foo(const int *p)
{
// 不需要保護(hù)性的拷貝副本
}
int main()
{
const int x = 42;
// const 影響被調(diào)用的是哪一個版本的重載函數(shù)
foo(&x);
return 0;
}
一方面,我不認(rèn)為這會在實(shí)際的 C++ 代碼中大量使用。另一方面,為了導(dǎo)致差異,程序員需要假設(shè)編譯器無法做出,因?yàn)樗鼈儾皇苷Z言保護(hù)。
用 Sqlite3 進(jìn)行實(shí)驗(yàn)
有了足夠的理論和例子。那么 const
在一個真正的代碼庫中有多大的影響呢?我將會在代碼庫 Sqlite
(版本:3.30.0)上做一個測試,因?yàn)椋?/p>
- 它真正地使用了
const
- 它不是一個簡單的代碼庫(超過 20 萬行代碼)
- 作為一個數(shù)據(jù)庫,它包括了字符串處理、數(shù)學(xué)計(jì)算、日期處理等一系列內(nèi)容
- 它能夠在綁定 CPU 的情況下進(jìn)行負(fù)載測試
此外,作者和貢獻(xiàn)者們已經(jīng)進(jìn)行了多年的性能優(yōu)化工作,因此我能確定他們沒有錯過任何有顯著效果的優(yōu)化。
配置
我做了兩份源碼拷貝,并且正常編譯其中一份。而對于另一份拷貝,我插入了這個特殊的預(yù)處理代碼段,將 const
變成一個空操作:
#define const
(GNU) sed
可以將一些東西添加到每個文件的頂端,比如 sed -i '1i#define const' *.c *.h
。
在編譯期間使用腳本生成 Sqlite
代碼稍微有點(diǎn)復(fù)雜。幸運(yùn)的是當(dāng) const
代碼和非 const
代碼混合時(shí),編譯器會產(chǎn)生了大量的提醒,因此很容易發(fā)現(xiàn)它并調(diào)整腳本來包含我的反 const
代碼段。
直接比較編譯結(jié)果毫無意義,因?yàn)槿我馕⑿〉母淖兙蜁绊懻麄€內(nèi)存布局,這可能會改變整個代碼中的指針和函數(shù)調(diào)用。因此,我用每個指令的二進(jìn)制大小和匯編代碼作為識別碼(objdump -d libsqlite3.so.0.8.6
)。舉個例子,這個函數(shù):
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
在編譯時(shí),我保留了所有 Sqlite
的編譯設(shè)置。
分析編譯結(jié)果
const
版本的 libsqlite3.so
的大小是 4,740,704 字節(jié),大約比 4,736,712 字節(jié)的非 const
版本大了 0.1% 。在全部 1374 個導(dǎo)出函數(shù)(不包括類似 PLT 里的底層輔助函數(shù))中,一共有 13 個函數(shù)的識別碼不一致。
其中的一些改變是由于插入的預(yù)處理代碼。舉個例子,這里有一個發(fā)生了更改的函數(shù)(已經(jīng)刪去一些 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
。同時(shí)刪去 static
和 const
會讓 GCC 再次認(rèn)為它們是常量,而我們將得到同樣的編譯輸出。由于類似這樣的局部的 static const
變量,使得 13 個函數(shù)中有 3 個函數(shù)產(chǎn)生假的變化,但我一個都不打算修復(fù)它們。
Sqlite
使用了很多全局變量,而這正是大多數(shù)真正的 const
優(yōu)化產(chǎn)生的地方。通常情況下,它們類似于將一個變量比較代替成一個常量比較,或者一個循環(huán)在部分展開的一步。(Radare toolkit 可以很方便的找出這些優(yōu)化措施。)一些變化則令人失望。sqlite3ParseUri()
有 487 個指令,但 const
產(chǎn)生的唯一區(qū)別是進(jìn)行了這個比較:
test %al, %al
je <sqlite3ParseUri+0x717>
cmp $0x23, %al
je <sqlite3ParseUri+0x717>
并交換了它們的順序:
cmp $0x23, %al
je <sqlite3ParseUri+0x717>
test %al, %al
je <sqlite3ParseUri+0x717>
基準(zhǔn)測試
Sqlite
自帶了一個性能回歸測試,因此我嘗試每個版本的代碼執(zhí)行一百次,仍然使用默認(rèn)的 Sqlite
編譯設(shè)置。以秒為單位的測試結(jié)果如下:
const | 非 const | |
---|---|---|
最小值 | 10.658s | 10.803s |
中間值 | 11.571s | 11.519s |
最大值 | 11.832s | 11.658s |
平均值 | 11.531s | 11.492s |
就我個人看來,我沒有發(fā)現(xiàn)足夠的證據(jù)來說明這個差異值得關(guān)注。我是說,我從整個程序中刪去 const
,所以如果它有明顯的差別,那么我希望它是顯而易見的。但也許你關(guān)心任何微小的差異,因?yàn)槟阏谧鲆恍┙^對性能非常重要的事。那讓我們試一下統(tǒng)計(jì)分析。
我喜歡使用類似 Mann-Whitney U 檢驗(yàn)這樣的東西。它類似于更著名的 T 檢驗(yàn),但對你在機(jī)器上計(jì)時(shí)時(shí)產(chǎn)生的復(fù)雜隨機(jī)變量(由于不可預(yù)測的上下文切換、頁錯誤等)更加健壯。以下是結(jié)果:
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 檢驗(yàn)已經(jīng)發(fā)現(xiàn)統(tǒng)計(jì)意義上具有顯著的性能差異。但是,令人驚訝的是,實(shí)際上是非 const
版本更快——大約 60ms,0.5%。似乎 const
啟用的少量“優(yōu)化”不值得額外代碼的開銷。這不像是 const
啟用了任何類似于自動矢量化的重要的優(yōu)化。當(dāng)然,你的結(jié)果可能因?yàn)榫幾g器配置、編譯器版本或者代碼庫等等而有所不同,但是我覺得這已經(jīng)說明了 const
是否能夠有效地提高 C
的性能,我們現(xiàn)在已經(jīng)看到答案了。
那么,const 有什么用呢?
盡管存在缺陷,C/C++ 的 const
仍有助于類型安全。特別是,結(jié)合 C++ 的移動語義和 std::unique_pointer
,const
可以使指針?biāo)袡?quán)顯式化。在超過十萬行代碼的 C++ 舊代碼庫里,指針?biāo)袡?quán)模糊是一個大難題,我對此深有感觸。
但是,我以前常常使用 const
來實(shí)現(xiàn)有意義的類型安全。我曾聽說過基于性能上的原因,最好是盡可能多地使用 const
。我曾聽說過當(dāng)性能很重要時(shí),重構(gòu)代碼并添加更多的 const
非常重要,即使以降低代碼可讀性的方式。當(dāng)時(shí)覺得這沒問題,但后來我才知道這并不對。