未定義行為驅動的最佳化(undefined-behavior-driven optimization)
在最佳化的 C 與 C++ 編譯器核心有一個微妙而影響深遠的把戲,誠實面對它比任何單一最佳化都更重要。C 標準說,如果一個程式執行了未定義行為,標準就「完全不施加任何要求」。最佳化器抓住這點大做文章:它假設你的程式從不發生未定義行為,並用這個假設來合理化它原本無法做的改寫。這就是未定義行為驅動的最佳化。
首先,三個「不一樣」的詞。未定義行為(undefined behavior,UB)意味著標準完全不施加任何要求——任何事都可能發生,而關鍵是編譯器可以假設它不會發生。未指定行為(unspecified behavior)意味著標準允許數種可能、由編譯器挑一種(例如函式引數的求值順序)。實作定義行為(implementation-defined behavior)意味著實作必須挑一個有文件記載的選擇(例如 int 有幾個位元)。只有第一種才授權最佳化器去「假設」。它如何咬人:因為有號整數溢位是 UB,編譯器可以假設對有號 int x 而言 x + 1 > x 永遠為真,並刪掉一個防溢位的檢查。因為解參考空指標是 UB,如果你的程式碼做了 p->field、「之後」才檢查 if (p != NULL),編譯器可能斷定 p 一直都非空(因為若它為空,解參考它就是 UB),並把那個空指標檢查當成多餘而「刪除」。因為到達某個分支會是 UB,編譯器可能把那個分支當成不可達而移除。最佳化器並無惡意;它只是從一個你違反了的前提做出健全的推理。
它重要在於:這正是為什麼一個有 UB 的程式能在 -O0「正常」、卻在 -O2 壞掉,為什麼一個安全檢查會默默消失,為什麼一個有 bug 的程式會以「編譯器背叛你」的方式被「誤編譯」。誠實的說法是:編譯器沒有引入 bug——UB 才是——但最佳化器的假設讓那個潛伏的 bug 變得可見而危險。逃生口是真實且值得知道的:-fwrapv 讓有號溢位環繞(有定義)而非 UB;-fno-strict-aliasing 關掉以型別為基礎的別名假設;而 UBSan(未定義行為消毒器,-fsanitize=undefined)對程式做插樁,在執行期抓出 UB,讓你找到它、而非任最佳化器利用它。真正的教訓不是「關掉最佳化」——而是「不要寫未定義行為」。
int *p = get(); int v = *p; // 解參考:若 p 為 NULL 則是 UB,所以編譯器假設 p != NULL if (p == NULL) // ……因此這個檢查「永遠為假」、可能被「刪除」 return -1; // 這個安全分支可能在 -O2 默默消失 return v;
先解參考 p 使編譯器假設 p 非空,於是稍後的空指標檢查被當成死碼刪除——這是有 bug 的程式碼真實的 UB 驅動「誤編譯」。
把未定義、未指定、實作定義行為嚴格區分:只有 UB 讓最佳化器「假設」它從不發生(從而刪除檢查)。UB 不是「隨機」或「依平台而定」——它是一張假設的許可證;-fwrapv、-fno-strict-aliasing、UBSan 是緩解手段,但真正的修法是不寫 UB。