”危險“的restrict與GCC的編譯優化


restrict是C99標准中新添加的關鍵字,對於從C89標准開始起步學習C語言的同學來說(包括我),第一次看到restrict還是相當陌生的。Wikipedia給出的解釋如下:

In the C programming language, as of the C99 standard, restrict is a keyword that can be used in pointer declarations. The restrict keyword is a declaration of intent given by the programmer to the compiler. It says that for the lifetime of the pointer, only it or a value directly derived from it (such as ​pointer + 1​) will be used to access the object to which it points. This limits the effects of pointer aliasing, aiding caching optimizations. If the declaration of intent is not followed and the object is accessed by an independent pointer, this will result in undefined behavior.

簡單說來,restrict關鍵字是編程者對編譯器所做的一個“承諾”:使用restrict修飾過的指針,它所指向的內容只能經由該指針(或從該指針繼承而來的指針,如通過該指針賦值或做指針運算而得到的其他指針)修改,而不會被其他不相干的指針所修改。

那么這個承諾有什么用呢?有了編程者的承諾,編譯器便可以對一些通過指針的運算進行大膽的優化了。

觀察編譯器優化的最好辦法當然是查看編譯后的匯編代碼。Wikipedia上有一個很好的例子,我移植如下,特地在自己的機器上測試了一下,結論很有趣。

測試環境:Ubuntu 11.04 (x86-64) + Linux 2.6.38  + gcc 4.5.2

測試代碼如下:

 1 #include <stdio.h>
3
4 #ifdef RES
5 void multi_add(int* restrict p1, int* restrict p2, int* restrict pi)
6 #else
7 void multi_add(int* p1, int* p2, int* pi)
8 #endif
9 {
10 *p1 += *pi;
11 *p2 += *pi;
12 }
13
14 int main()
15 {
16 int a = 1, b = 2;
17 int inc = 1;
18
19 // increase both a and b by 1
20 multi_add(&a, &b, &inc);
21
22 // print the result
23 printf("a = %d, b = %d\n", a, b);
24 }

multi_add函數的功能很簡單,將p1和p2指針所指向的內容都加上pi指針的內容。為了測試方便,使用了條件編譯指令:如果定義RES宏,則使用帶restrict的函數聲明。(對於gcc,要開啟默認關閉的c99支持:--std=c99)

分別編譯出兩個版本的程序:

gcc restrict.c -o without_restrict
gcc restrict.c -o with_restrict -DRES --std=c99


使用objdump查看目標文件的匯編代碼(-d選項表示disassemble):

objdump -d without_restrict

PS:gcc默認使用的是AT&T匯編,與很多同學在初次學習匯編時接觸的Intel x86匯編有些不同

除了表示上的細微符號差別,最大的區別是src/dest的順序,兩者恰好相反:

Intel : mov  eax  2      (先dest后src)

AT&T  : mov  %2   %eax   (先src后dest)


然而這次的結果讓人失望:兩個版本的程序擁有一模一樣的multi_add函數,匯編代碼如下:

push   %rbp
mov %rsp,%rbp
mov %rdi,-0x8(%rbp)
mov %rsi,-0x10(%rbp)
mov %rdx,-0x18(%rbp)
mov -0x8(%rbp),%rax
mov (%rax),%edx
mov -0x18(%rbp),%rax
mov (%rax),%eax
add %eax,%edx
mov -0x8(%rbp),%rax
mov %edx,(%rax)
mov -0x10(%rbp),%rax
mov (%rax),%edx
mov -0x18(%rbp),%rax
mov (%rax),%eax
add %eax,%edx
mov -0x10(%rbp),%rax
mov %edx,(%rax)
leaveq
retq

其中寄存器rdi存放p1的地址,rsi存放p2的地址,rdx存放的是pi的地址。大段的匯編代碼,無非是將寄存器中的內容mov到棧上的臨時變量上,再把臨時變量的值mov進寄存器進行加法運算。


難道restrict關鍵字沒有任何作用?我懷疑很可能是編輯器優化程度不夠。這次,使用-O1重新編譯源代碼並反匯編,終於觀察到差別:

未使用restrict的版本:

mov (%rdx), %eax
add %eax, (%rdi)
mov (%rdx), %eax
add %eax, (%rsi)


使用了restrict的版本:

mov (%rdx), %eax
add %eax, (%rdi)
add %eax, (%rsi)

可以看出,-O1的編譯優化還是很給力的,所有運算直接在寄存器中進行,不再蛋疼地先mov進棧變量,再mov進寄存器進行add運算(在這個簡單的例子中,確實沒有必要)。

最大的區別在於將rdx寄存器間接引用的值mov進eax的語句只在一開始執行了1次。可以理解,當程序員“承諾”這些指針都是相互獨立不再干擾時,pi指針的內容在函數范圍內可以視之為常量,只需要load進寄存器一次。

而沒有restrict關鍵字時,即使程序中沒有對pi的內容進行操作,編譯器仍然不能保證pi的內容在函數范圍內是常量:因為有pointer aliasing的可能,即p1和p2指向的內容和pi相關(簡單情況:p1和pi實際是同一個指針)。


需要注意的是,restrict是程序員給出的“承諾“,編譯器沒有指針的合法使用進行檢查的職責,也沒有這樣的能力。

事實上,打開restrict關鍵字,如果這樣調用:

multi_add(&a, &b, &a);

編譯器不會報錯。(事實上編譯期完全有能力檢查出簡單alias的pointer)

而使用不同的編譯優化級別(不優化,-O1, -O2),則產生了相當不同的結果。

不優化   : a = 2, b = 4

-O1      : a = 2, b = 3

-O2以上: a = 2, b = 4

前面已經提到,沒有開啟-O選項時,gcc沒有對restrict關鍵字進行優化(至少在這個例子中),所以應當是正確的行為(盡管此行為可能與編寫multi_add函數的初衷不符合)

在O1下,restrict被優化,pi的值一開始即被緩存,所以產生了a和b都增加了1的結果

那么為什么O2以上,行為又開始變得正確了呢?


繼續反匯編代碼,發現-O2以上時,multi_add函數本身代碼保持不變(確實在O1已經優化的相當簡潔了),但main函數已經面目全非了:調用multi_add的代碼已經改變,准確地說:

multi_add函數已經不再被main調用了

這里不再列出相關的匯編代碼,因為這里的優化策略是相當復雜的。在這個例子中,由於a和b都是常量,a和b的值直接在編譯期被算了出來,並放入寄存器中進行后續printf的調用。


可以看出,restrict確實是優化的利器。但是如果不仔細使用,它還是相當危險的,甚至能夠導致在不同的優化級別下,出現完全不同的程序行為。






免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM