天天看點

call stack是什麼錯誤_為什麼 const 無法讓 C 代碼跑得更快? | Linux 中國

call stack是什麼錯誤_為什麼 const 無法讓 C 代碼跑得更快? | Linux 中國

我曾說過“有個一個流行的傳言,const 有助于編譯器優化 C 和 C++ 代碼”。我覺得我需要解釋一下,尤其是曾經我自己也以為這是顯然對的。-- Simon Arneaud

在幾個月前的一篇文章裡,我曾說過“有個一個流行的傳言,

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 [email protected]

movq %rbx, %rdi

call [email protected] # constFoo 中唯一不同的指令

movl (%rbx), %edx

leaq .LC0(%rip), %rsi

xorl %eax, %eax

movl $1, %edi

popq %rbx

.cfi_def_cfa_offset 8

jmp [email protected]

.cfi_endproc

函數 

byArg()

 和函數 

constByArg()

 生成的彙編代碼中唯一的不同之處是 

constByArg()

 有一句彙編代碼 

call [email protected]

,這正是源代碼中的調用。關鍵字 

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 [email protected]

leaq 4(%rsp), %rdi

call [email protected]

movl 4(%rsp), %edx # 在 constLocalVar() 中沒有

xorl %eax, %eax

movl $1, %edi

leaq .LC0(%rip), %rsi # 在 constLocalVar() 中沒有

call [email protected]

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 [email protected]

.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. 除了特殊情況,編譯器需要忽略它,因為其他代碼可能合法地移除它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){

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

本文由 LCTT 原創編譯,Linux中國 榮譽推出

call stack是什麼錯誤_為什麼 const 無法讓 C 代碼跑得更快? | Linux 中國

?:還 在看嗎?

繼續閱讀