為什麼 const 無法讓 C 程式碼跑得更快?

2019-09-14 18:16:00

在幾個月前的一篇文章裡,我曾說過“有個一個流行的傳言,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 uwtabledefine 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 uwtabledefine 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 uwtabledefine 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. 除了特殊情況,編譯器需要忽略它,因為其他程式碼可能合法地移除它
  2. 在 #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。同時刪去 staticconst 會讓 GCC 再次認為它們是常數,而我們將得到同樣的編譯輸出。由於類似這樣的區域性的 static const 變數,使得 13 個函數中有 3 個函數產生假的變化,但我一個都不打算修復它們。

Sqlite 使用了很多全域性變數,而這正是大多數真正的 const 優化產生的地方。通常情況下,它們類似於將一個變數比較代替成一個常數比較,或者一個迴圈在部分展開的一步。(Radare toolkit 可以很方便的找出這些優化措施。)一些變化則令人失望。sqlite3ParseUri() 有 487 個指令,但 const 產生的唯一區別是進行了這個比較:

test %al, %alje <sqlite3ParseUri+0x717>cmp $0x23, %alje <sqlite3ParseUri+0x717>

並交換了它們的順序:

cmp $0x23, %alje <sqlite3ParseUri+0x717>test %al, %alje <sqlite3ParseUri+0x717>

基準測試

Sqlite 自帶了一個效能回歸測試,因此我嘗試每個版本的程式碼執行一百次,仍然使用預設的 Sqlite 編譯設定。以秒為單位的測試結果如下:

 const非 const
最小值10.658s10.803s
中間值11.571s11.519s
最大值11.832s11.658s
平均值11.531s11.492s

就我個人看來,我沒有發現足夠的證據來說明這個差異值得關注。我是說,我從整個程式中刪去 const,所以如果它有明顯的差別,那麼我希望它是顯而易見的。但也許你關心任何微小的差異,因為你正在做一些絕對效能非常重要的事。那讓我們試一下統計分析。

我喜歡使用類似 Mann-Whitney U 檢驗這樣的東西。它類似於更著名的 T 檢驗,但對你在機器上計時時產生的複雜隨機變數(由於不可預測的上下文切換、頁錯誤等)更加健壯。以下是結果:

 const非 const
N100100
Mean rank121.3879.62

 

  
Mann-Whitney U2912
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_pointerconst 可以使指標所有權顯式化。在超過十萬行程式碼的 C++ 舊程式碼庫裡,指標所有權模糊是一個大難題,我對此深有感觸。

但是,我以前常常使用 const 來實現有意義的型別安全。我曾聽說過基於效能上的原因,最好是盡可能多地使用 const。我曾聽說過當效能很重要時,重構程式碼並新增更多的 const 非常重要,即使以降低程式碼可讀性的方式。當時覺得這沒問題,但後來我才知道這並不對。