[英]Order of assignment produces different assembly
這個實驗是使用 GCC 6.3 完成的。 有兩個函數的唯一區別在於我們在結構中分配 i32 和 i16 的順序。 我們假設兩個函數都應該生成相同的程序集。 然而,這種情況並非如此。 “壞”函數產生更多指令。 誰能解釋為什么會發生這種情況?
#include <inttypes.h>
union pack {
struct {
int32_t i32;
int16_t i16;
};
void *ptr;
};
static_assert(sizeof(pack)==8, "what?");
void *bad(const int32_t i32, const int16_t i16) {
pack p;
p.i32 = i32;
p.i16 = i16;
return p.ptr;
}
void *good(const int32_t i32, const int16_t i16) {
pack p;
p.i16 = i16;
p.i32 = i32;
return p.ptr;
}
...
bad(int, short):
movzx eax, si
sal rax, 32
mov rsi, rax
mov eax, edi
or rax, rsi
ret
good(int, short):
movzx eax, si
mov edi, edi
sal rax, 32
or rax, rdi
ret
編譯器標志是 -O3 -fno-rtti -std=c++14
這是/曾經是 GCC10.2 及更早版本中遺漏的優化。 它似乎已經在當前的 GCC 夜間版本中得到修復,因此無需在 GCC 的 bugzilla 上報告遺漏優化錯誤。 ( https://gcc.gnu.org/bugzilla/ )。 看起來它最初是作為從 GCC4.8 到 GCC4.9 的回歸出現的。 ( 神馬)
# GCC11-dev nightly build
# actually *better* than "good", avoiding a mov-elimination missed opt.
bad(int, short):
movzx esi, si # mov-elimination never works for 16->32 movzx
mov eax, edi # mov-elimination works between different regs
sal rsi, 32
or rax, rsi
ret
是的,您通常希望實現相同邏輯的 C++ 以基本相同的方式編譯為相同的 asm,只要啟用了優化,或者至少希望如此1 。 通常,您可以希望沒有無意義的遺漏優化,這些優化會無緣無故地浪費指令(而不是簡單地選擇不同的實現策略),但不幸的是,這也不總是正確的。
編寫同一對象的不同部分然后讀取整個對象對於編譯器來說通常很棘手,因此當您以不同的順序編寫完整對象的不同部分時,看到不同的 asm 並不令人震驚。
請注意, bad
asm 並沒有什么“聰明”之處,它只是執行了一個冗余的mov
指令。 必須在固定寄存器中獲取輸入並在另一個特定的硬寄存器中產生輸出以滿足調用約定是 GCC 的寄存器分配器並不令人驚訝的事情:浪費mov
錯過這樣的優化在小函數中比在更大函數的一部分中更常見.
如果你真的很好奇,你可以深入研究 GCC 轉換到這里的 GIMPLE 和 RTL 內部表示。 (Godbolt 有一個 GCC 樹轉儲窗格來幫助解決這個問題。)
腳注 1:或者至少希望如此,但錯過優化的錯誤確實會在現實生活中發生。 發現它們時報告它們,以防 GCC 或 LLVM 開發人員可以輕松教優化器避免這種情況。 編譯器是具有多次通過的復雜機器; 通常,優化器的一部分的極端情況只是在其他一些優化傳遞更改為執行其他操作之前不會發生,從而暴露出該代碼的作者在編寫/調整時沒有考慮的情況的糟糕最終結果它來改善其他一些情況。
請注意,盡管評論中有抱怨,但這里沒有未定義的行為:C 和 C++ 的 GNU 方言在 C89 和 C++ 中定義了聯合類型雙關的行為,而不僅僅是在 C99 和以后的 ISO C 中。 實現可以自由定義 ISO C++ 未定義的任何行為。
那么在技術上有一個讀未初始化,因為上2個字節的void*
對象尚未以書面pack p
。 但是用pack p = {.ptr=0};
修復它沒有幫助。 (並且不會更改 asm;GCC 碰巧已經將填充歸零,因為這很方便)。
另請注意,問題中的兩個版本都比可能的效率低:
(來自 GCC4.8 或 GCC11-trunk 的bad
輸出避免了浪費的mov
看起來是該策略選擇的最佳選擇。)
mov edi,edi
在 Intel 和 AMD 上都擊敗了 mov-elimination ,因此該指令具有 1 個周期延遲而不是 0 個,並且會消耗后端 µop。 選擇不同的寄存器進行零擴展會更便宜。 我們甚至可以在讀取 SI 后選擇 RSI,但任何調用破壞的寄存器都可以工作。
hand_written:
movzx eax, si # 16->32 can't be eliminated, only 8->32 and 32->32 mov
shl rax, 32
mov ecx, edi # zero-extend into a different reg with 0 latency
or rax, rcx
ret
或者,如果在 Intel 上優化代碼大小或吞吐量(低 µop 計數,而不是低延遲),則shld
是一個選項:Intel 上為 1 µop / 3c 延遲,但 Zen 上為 6 µops(不過也是 3c 延遲)。 ( https://uops.info/和https://agner.org/optimize/ )
minimal_uops_worse_latency: # also more uops on AMD.
movzx eax, si
shl rdi, 32 # int32 bits to the top of RDI
shld rax, rdi, 32 # shift the high 32 bits of RDI into RAX.
ret
如果您的結構以另一種方式排序,填充在中間,您可以執行一些涉及mov ax, si
以合並到 RAX。 這在非英特爾以及 Haswell 和更高版本上可能很有效,除了像 AH 這樣的高 8 regs 外,它們不進行部分寄存器重命名。
鑒於讀取未初始化的 UB,您可以將其編譯為任何字面意思,包括ret
或ud2
。 或者稍微不那么激進,你可以編譯它只為結構的填充部分留下垃圾,最后2個字節。
high_garbage:
shl rsi, 32 # leaving high garbage = incoming high half of ESI
mov eax, edi # zero-extend into RAX
or rax, rsi
ret
請注意,對 x86-64 System V ABI(clang 實際依賴的)的非官方擴展是窄參數被符號或零擴展到 32 位。 因此,指針的高 2 字節不是零,而是符號位的副本。 (這實際上可以保證它是 x86-64 上的規范 48 位虛擬地址!)
聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.