如何評價《王垠:C 編譯器優化過程中的 Bug》?

C 編譯器優化過程中的 Bug


垠神這篇挺好的啊。寫C或C++程序的時候遇到前人給埋了一大堆UB坑那真是欲哭無淚。

我上周正好剛剛撞上一個因為我們的前人寫的C++代碼有UB坑而造成的bug…剛修。有時候有UB坑的代碼未必會立即顯現出問題,因為可能(C/C++)編譯器還沒利用上這塊UB信息;這種才是最坑爹的——前人一甩鍋,後面還不得不接。

我們內部在力求UBSan bug free,因為有些有問題的代碼就算沒有立即因為UB而被優化成錯誤的形式,它們常常也隱含著使用不正確的問題。例如說一個經典的,由於 &<&< 導致int overflow的問題。這種問題排查起來真是極其痛苦…

========================================

在給編譯器找bug方面,Zhendong Su老師的研究確實好玩。同 @Wish Night 的回答,推薦感興趣的同學去看看那系列研究。

然後推薦一組UB入門演示稿:

BKK16-503 Undefined Behavior and Compiler Optimizations – Why Your Program Stopped Working With A Newer Compiler - Linaro - SlideShare

裡面涉及的一些例子或許就是垠神會感興趣用來進一步說明的。

========================================

下面開始跑個題。垠神所引用的例子是C語言的:

void contains_null_check(int* p) {
int dead = *p;
if (p == 0) {
return;
}
*p = 4;
}

在C(以及C++)里,對空指針解引用確實是未定義行為,所以確實可以引出垠神所引用的Chris Lattner大大文章中所描述的問題——某個編譯器有沒有那樣做是它們的自由,關鍵是根據規範所述的UB它們是可以那樣做的。

那麼或許會有吃瓜群眾想了解一下像Java這樣的語言在同樣的場景下會是個什麼狀況。我就來跑一下這個題。

重點在於:在Java里,對null解引用是有明確定義其正確行為是怎樣的——要拋出NullPointerException——所以在Java里具體到這個場景沒有任何問題。放個傳送門:在Java中,return null 是否安全, 為什麼? - RednaxelaFX 的回答

用Java來寫一個類似形式的例子:

public class TrapDemo {
public static void demo(IntBox p) {
int dead = p.value;
if (p == null) return;
p.value = 42;
}

public static void main(String[] args) throws Exception {
demo(null);
}
}

class IntBox {
public int value;
}

這裡的TrapDemo.demo(IntBox)就跟垠神引用的contains_null_check(int*)例子對應。

運行這個程序的正確結果是:

Exception in thread "main" java.lang.NullPointerException
at TrapDemo.demo(TrapDemo.java:3)
at TrapDemo.main(TrapDemo.java:9)

而當我們用Oracle JDK8u101在Mac OS X / x86-64上,其中的JIT編譯器來編譯TrapDemo.demo(IntBox)方法,會發現用其中的Server Compiler(C2)會在第一次編譯時編譯出等價於下面形式的代碼:

public static void demo(IntBox p) {
p.value = 42;
}

(注意:強調了「第一次編譯時」。後面再展開解釋)

這個形式有沒有看似跟垠神引用的C語言例子的「錯誤形式」一樣?——實際上是不一樣的喔。

void contains_null_check_after_RNCE_and_DCE(int* p) {
//int dead = *p; // 死代碼消除
//if (false) { // 死代碼
// return; // 死代碼
//}
*p = 4;
}

上述Java例子的C1與C2初次編譯的詳細結果我放在gist里了,免得這個回答太長:https://gist.github.com/rednaxelafx/c474cadaa9057f909d48e7593b9e1483

上面的JIT編譯結果對Java來說為啥是正確的,待我慢慢道來。

解引用(dereference)動作隱含著null檢查,如果被解引用的引用為null則需要當場拋出NullPointerException。這個語義是完全定義好的,沒有迴避的餘地。

所以例子的原始形式,把null檢查顯式寫出來的話,是這個樣子的:

public static void demo(IntBox p) {
if (p == null) throw new NullPointerException(); // implied null check
int dead = p.value;
if (p == null) return;
if (p == null) throw new NullPointerException(); // implied null check
p.value = 42;
}

即便p.value的結果被賦值給了一個無用的局部變數(int dead),使得p.value的值自身並沒有被使用,但它的副作用——null檢查——則必須留下。

&<- 這個由規範所強制要求的行為,就是Java版例子與原本的C版例子最大的不同。

把 int dead = p.value; 這句無用代碼消除並留下null檢查的副作用之後,剩下的代碼是:

public static void demo(IntBox p) {
if (p == null) throw new NullPointerException(); // implied null check
if (p == null) return; // "return" now becomes unreachable code
if (p == null) throw new NullPointerException(); // implied null check
p.value = 42;
}

於是通過條件常量傳播(conditional constant propagation)把相同條件的代碼合併在一起,剩下的代碼就只有:

public static void demo(IntBox p) {
if (p == null) throw new NullPointerException(); // implied null check
p.value = 42;
}

然後從這裡就開始就有更有趣的事情了。

JVM對上面要實現JVM規範,而對下面則是依託於底層的具體平台。所以一個JVM實現可以用盡各種平台相關的辦法,來實現出對上層Java應用來說一致的、符合JVM規範的行為。

在Mac OS X(以及諸如Linux等各種POSIX平台)上,對0地址表示的空指針以及0地址附近的一定範圍內解引用(讀或者寫),會可靠地觸發SIGSEGV信號。

利用這個平台相關行為,JVM實現就可以採用「隱式空指針檢查」(implicit null check)方式來對通常非null的引用的解引用動作進行優化,而不需要顯式生成null檢查的代碼。JVM可以給這些使用了隱式空指針檢查的地方關聯上一定的符號信息,並且向OS註冊SIGSEGV信號的處理函數,在裡面查詢看fault pc是不是一個已知的隱式空指針檢查指令,如果是的話則根據關聯的符號信息分派到相應的處理代碼去。

回到上文的例子,C2初次編譯實際編譯出來的代碼邏輯是這樣的:

public static void demo(IntBox p) {
p.value = 42; // implicit null check: dispatch to Label_null_check
return;

Label_null_check:
uncommon_trap(Reason_null_check); // go back to interpreter and throw NPE
}

於是當p不是空指針的時候,這個代碼就可以最快速度完成有用的寫操作並返回;而當p真的是空指針的時候,它在嘗試對p.value做寫操作的時候就會觸發SIGSEGV,然後經由HotSpot VM註冊的信號處理函數跳轉到Label_null_check的地方去拋出NullPointerException。

(HotSpot VM在Windows上的實現則是通過SEH來達到同樣的隱式空指針檢查的效果。微軟自家的CLR里的編譯器也有同樣的優化)

細心的同學可能會留意到上文中的一些細節:如果在代碼中某個位置,被解引用的引用絕大多數情況都不是null,那麼用上面的隱式空指針檢查顯然是最快的,因為這個檢查是硬體完成的,無論是否利用它硬體都得做這個檢查,利用隱式檢查可以避免生成顯式的null檢查+分支。

但如果這個位置上時常會遇到對null解引用,隱式空指針檢查就不是最快的了。事實上如果null的情況占多數的話,這種需要通過發信號 -&> 信號處理 -&> 跳轉到空指針檢查的後續處理代碼的路徑,比起直接生成顯式檢查的路徑要長得多也慢得多。所以這種「優化」並不是總是值得的。

HotSpot VM的C1追求實現簡單,只針對常見情況優化,它在可以使用隱式空指針檢查的平台上會總是選擇生成這種形式的代碼。

Oracle JDK8u101的C1編譯出來的上面的例子是這樣的形式:

public static void demo(IntBox p) {
p.value; // implicit null check: dispatch to Label_null_check
if (p == null) return;
p.value = 42; // no null check here
return;

Label_null_check:
uncommon_trap(Reason_null_check); // go back to interpreter and throw NPE
}

嗯…有改善空間。

而C2則追求高性能,所以當它發現某個被C2 JIT編譯過的方法遇到了至少3次隱式空指針異常之後,就會拋棄這個JIT編譯的版本,然後重新JIT編譯並生成顯式空指針檢查的代碼:

public static void demo(IntBox p) {
if (p == null) throw new NullPointerException(); // implied null check, explicit check
p.value = 42;
}

一個例子可以引出很多有趣的討論對不對? &>_&<


「現在有一個整數 x,我們不知道它是多少。但 x 出現在一個條件語句裡面,如果 x &> 1,那麼程序會進入未定義行為,所以我們可以斷定 x 的值必然小於或者等於 1,所以現在我們利用 x ≤ 1 這個事實來對相關代碼進行優化……」

並不是這個推理。正確的是: 如果x&>1, 那麼程序會觸發未定義行為,此時程序任何行為都是正確的,所以優化只需要按照x≤1的假設生成正確的優化,優化後的程序恰好在x&>1時有什麼行為都是正確的。

換句話說,優化只保證沒有ub的分支正確,有ub的分支如果在此過程遭到「損壞」概不負責。


沒想到一個技術上的討論變成了對一個人的討論和站隊。

其實這個問題本身的答案並不難,之所以大家會有爭論,主要是對「編譯器錯了」還是「人錯了」的爭論。

我的看法是,編譯器並沒有錯,既然編譯器已經提示UB行為,而你又不去修復這個UB行為的話,那麼最終優化成什麼樣子就不是編譯器能保證的了。

動物園提示你不要下車,你還非得下車,死了也是自己作死。

而且據我的經驗,打開O3之後所有出現的奇怪行為都是我自己的問題,仔細排查總會找到自己的失誤。

如果能舉出不出現UB行為的錯誤優化,這才是找到了編譯器的錯誤。我們總不能妄想編譯器幫我們把所有的warning解決掉還不出錯的。


一段UB可以優化成任何樣子。

--

當P是NULL的時候,讀*P是UB,按照標準編譯器沒有義務為UB代碼做任何保證,它可以隨便生成任何東西,比如寫個貪吃蛇什麼的或者生成一段一運行電腦就會爆炸的代碼。

當P不是NULL的時候,這段代碼可以直接優化成*P=4。

讀*P的副作用什麼的就不用扯了,按照標準你需要加volatile才有資格要求編譯器真的去讀一下。

他寫了一大堆吐槽一個「bug」,然而這不是bug,這是feature。


編譯器優化會有bug,這很常見,尤其是O3。O2的Bug也會有,一般會是編譯器在處理時,搞錯了Pointer Alias,但是O3則會有很多種優化錯誤,因為O3會依賴更激進的分析,內聯,類型。也許類型的不匹配,在O2不會錯,在O3則會錯,造成結果的錯誤。而使用標準未定義行為,以及編寫不規範的代碼更是大忌,很多時候都是不優化,或者低優化符合你的預期,但是高優化則會不符合。

不要相信編譯器以及其優化是對的,我現在運行我們編譯器也主要是看我們編譯器是否產生正確的行為。不誇張的說,我看現在市面上所有的編譯器,都是全身Bug。但是王垠所舉的那個例子,雖然我還沒有測試,但是我不認為編譯器優化會犯這個錯誤,無論是多高的優化級別。


undefined的意思是說,這個時候,編譯器可以作出任何事情,也都是符合語言標準的;或者說這時候語言規範給出的行為是lattice中的top,那麼作出任何事情(變換),都是sound的,那怕編譯器給出的代碼不做任何事情直接exit。

至於生成的代碼,我在http://gcc.godbolt.org/ 上跑了幾個測試,GCC從4.4.7到6.2,使用-O3優化,給出的x86-64彙編都是:

contains_null_check(int*):
testq %rdi, %rdi
je .L1
movl $4, (%rdi)
.L1:
rep ret

而Clang從3.0到3.9給出的代碼(同樣是-O3)也都是:

contains_null_check(int*): # @contains_null_check(int*)
testq %rdi, %rdi
je .LBB0_2
movl $4, (%rdi)
.LBB0_2:
retq

顯然代碼是相同的,也並沒有看到優化後只剩下*P=4的情況。

上面的彙編代碼對dead的賦值被消除了,if的判斷則對應於testq %rdi %rdi檢查P是否等於0,如果等於0,那麼則跳轉到後面的label,直接return,如果不等於0,則將%rdi指向的地址置為4再return。


這次純技術討論,覺得說的很對啊。編譯器測試很重要的,PLDI,OOPSLA,ICSE近幾年都有論文。因為優化開一多,不同的優化相互影響會帶來大坑。看那些bug都是非常莫名其妙的,單跑一個pass都沒問題,組合起來就崩了。Zhendong Su 老師做過一系列研究,找了gcc 和 llvm 的上百個bug,雖然 Title 都取的無比炫酷,但是方法思路還是很簡單。隨機的改編譯器自帶的測試,刪掉測試沒有覆蓋的語句,或者加一些註定不會執行的條件判斷或循環,這樣。然後在所有的編譯選項組合上跑,測試跑掛了就說明編譯器有bug……兩個實驗分別花了11個月和13個月。老闆有錢又有閑,這些雲伺服器就不知道要多少銀子……


具體到這個問題,我認為如果編譯器這麼做的話,真正的錯誤是把返回值沒有用的int dead = *P;當成死代碼。正確的做法是,無論如何都去讀取以下*P,然後結果扔掉,不要有那個變數dead。這樣的話,後面的if還是可以變false然後刪掉,所有的步驟合併到一起都不會有問題。

既然指針解引用是有可能引發異常(Access Violation)的,那他就不能被當成一個無副作用的表達式來看待。萬一我外面還可能__try之後,寫幾行彙編親手hack掉了這個錯誤呢(逃


在責怪編譯器優化之前應該先看看是否有編譯警告沒解決。

那個未引用局部變數dead是一定報警告的,各個編譯器可能會有不同的警告信息警告號,但是有警告事一定的。

我覺得工程上來說,首先要去警告,然後過靜態代碼檢查,最後才是開優化。

一般正規點的上點規模的C/C++項目基本都要走這個流程。

因為C/C++太複雜,各種稀奇古怪的玩法太多,但是能通過編譯器4級警告和靜態代碼檢查的古怪玩法就少多了。

void contains_null_check_after_RNCE(int *P) {
int dead = *P; // 未引用變數警告
if (P == 0)
return;
*P = 4;
}


我覺得垠神說的沒錯啊,你最後給優化成*P=4是什麼鬼。

你用DSE把dead那行去掉還有的討論,你認為引用空指針是未定義行為那麼不考慮副作用就可以刪。

你都用dead那行把if幹掉了,說明你已經確認那行有副作用了,這你DSE還給整行刪了那就是bug,不是feature,沒的洗。不管咋樣你得給我把那個load操作留著吧


我覺得王垠這篇文章寫的還行吧,留了些懸念。他在文中舉的例子,值得商榷的部分應該是

void contains_null_check_after_RNCE(int *P) {
int dead = *P;
if (false) // P 在上一行被訪問,所以這裡 P 不可能是 null
return;
*P = 4;
}

在大多數操作系統里,進程有自己的內存空間,對內存空間以外的地址操作會導致 segment fault。然而在一些比較初級的操作系統,對指針 null 的訪問不一定會導致 segment fault,而屬於未定義行為。

話說王垠自從去了微軟,人氣下降好快。不花些力氣寫些好文章,恐怕關注度會逐步下滑啊。


UB是C性能好的原因之一,因為優化的時候不需要考慮corner case。

見過很多次,一個程序換了compiler就出錯了 就讓 compiler來背鍋。逛逛逛一頓debug 發現還是UB


這篇文章表示王垠的漢語水平堪憂。

王垠寫道

Lattner 指出這樣的優化完全符合 C 語言的標準,這說明就算你符合國際標準,也有可能其實是錯的。

王垠這句話的「錯」字用錯了。正確的理解應該是這樣的:

  1. 如果一段C語言代碼符合C語言標準,它有可能觸發未定義行為,所以是有bug的壞代碼。

  2. 如果一個編譯器符合C語言標準,根據定義,這個編譯器一定是對的,不可能錯。
  3. 如果一個編譯器符合C語言標準,它有可能編譯出有未定義行為的代碼,所以是壞的。

王垠對好壞和對錯有點傻傻分不清啊。


按照一般編譯器後端實現的convention來說,死碼刪除是比較靠前的optimization,如果編譯器分析出ebx不會live-out且基本塊中沒有use點的話,基本前幾輪就被刪了...

mov ebx, [edi] ## int dead = *P;
cmp edi, $0x0 ## if (P == NULL)
je EXIT ## return
mov [edi], $0x4 ## *P = 4

... normally exit

EXIT:
xxx


"只要打開優化之後你的程序出現詭異的行為,請一定要懷疑編譯器的優化是 unsound 的。"

多麼完美的邏輯:

一旦出了問題,一定要懷疑編譯器的錯

一旦出了問題,一定要懷疑同事的錯

一旦出了問題,一定要懷疑領導的錯

一旦出了問題,一定要懷疑公司不好

這很王垠,順便,就事論事來說,這篇文章的內容還是正確的。


我覺得某些回答真是酸的不行。

1、王垠還沒說結論,這些人就已經構造了一個,以之為靶子開始評論之。

2、王垠在個人博客里寫文章尋求討論,在這些人嘴裡變成了留扣子。

3、王垠指出了別人的錯誤,這些人開始誅心,認為他覺得自己更牛逼。

4、王垠寫了一篇文章討論不難的問題,這些人就開始春秋筆法,向大家宣稱王垠水平也就如此了。

如果真覺得王垠名不副實,麻煩不要關注,不要在知乎評論以及回答,不要給王垠人氣。否則的話,自相矛盾,有點陰暗。


我覺得這個吐槽挺有道理的,最近遇到過一個類似的UB造成的問題。雖然嚴格來說不是編譯器的bug,但是編譯器的處理方式是值得商榷的。

有個用戶發現一個for循環沒終結,加了個列印loop counter的語句,就是類似於:

for(i = 0; i &< 10; i ++) { //其他語句,沒有改變i的值 dump(__LINE__, i); // 輸出 __LINE__:i }

然後一臉懵圈的發現輸出的i一直增加。於是過來找我們說,看來compiler似乎有bug。於是我幫忙debug了半天,發現是LLVM的SimplifyCFG一個「優化」:如果一個PHI的某個輸入是由讀取空指針得到的,直接把這個值對應的Predecessor從Control Flow里給去掉。

而這個循環裡面進行i&<10比較的操作恰好在這個Basic Block里,於是就悲劇了。而那個引發問題的空指針是由全局變數經過多層inline展開後constant propagate得到的,不是很好發現。個人感覺不改變Control Flow的話更容易定位問題所在。


一秒鐘想起:

Severe bug in g++ optimizator (-O2)

60766 – [4.7 Regression] Wrong optimization with -O2

——這還是真實的實現錯誤。


喜歡看這種文章。

但是這些模凌兩可的東西,不是應該用良好的編程習慣去規避掉的嗎?比如明知道有nullptr的危險,不是函數一開始就assert的嗎?這樣挑戰編譯器是不是有炫技的嫌疑,實際工程中然並沒有多少暖用?code review會很慘的。


同意王垠的觀點。

開 -O3 在 Release 下出各種狀況都不意外。


一大堆人連UB的後果都不知道,還在討論來討論去裝作很懂的樣子。程序有UB,所以出什麼結果都不奇怪,編譯器給你什麼結果,那都是符合契約的,有什麼好討論的?你要是不服,去C委員會抗議,UB的後果是他們訂的。


他所寫的代碼風格不對,本身在GCC里那樣的代碼是會警告的.將指針與整數比較是非法的..後續的處理談不上是bug。

全看如何寫代碼,要理解編譯器行為


I have seen the glint in their eyes when they discuss optimization techniques that you would not want your children to know about!

-- Paul E. McKenney


推薦閱讀:

C語言(GCC)如何編譯多個文件?
C 語言用 gcc 和 vs2013 編譯有什麼區別?
如何評價《編譯系統透視:圖解編譯原理》一書?

TAG:程序員 | C編程語言 | 王垠人物 | 編譯器優化 |