我寫了一個玩具程式來比較兩個非常相似的函式的性能。整個檔案(減去幾個宏)如下所示:
constexpr int iterations = 100000;
using u64 = uint64_t;
// Slow.
void test1()
{
u64 sum = 0;
for (int i = 0; i < iterations; i ) {
for (int j = 0; j < 4; j )
sum = i j;
doNotOptimize(sum);
}
}
// Fast.
void test2()
{
u64 sum = 0;
for (int i = 0; i < iterations; i ) {
for (int j = 0; j < 10; j )
sum = i j;
doNotOptimize(sum);
}
}
void driver()
{
int runs = 1000;
BENCH(runs, test1);
BENCH(runs, test2);
}
我正在使用__rdtsc并計算平均值來測量每個函式的 1000 次執行。使用這個公式,我看到test1和之間的性能差異約為 172,000(滴答聲?) test2。令人驚訝的是,test2它的速度更快。一個奇特的小細節是,唯一較慢的幻數test1是 4、8 和 16。如果我將內部回圈的條件更改為除了這 3 個數字之外的任何j < x地方,性能匹配。x
在程式集中,我觀察到這兩個函式中的內部回圈都被消除并被作為lea. 所以在這種情況下,如果兩個函式都同樣快,那將是有意義的。但這根本不是正在發生的事情。這是完整的反匯編和程式源:https ://godbolt.org/z/d5PsG4YeY
那么到底發生了什么?我的測量有問題嗎?
執行環境:
Processor: Intel(R) Core(TM) i5-7200U CPU @ 2.50GHz (Kaby Lake)
L1 Cache: 128Kib
OS: Linux (64-bit)
Toolchain: GCC Version 10.3.0
Compiler Options: -O3 -fno-tree-vectorize
謝謝!
uj5u.com熱心網友回復:
4 和 8 是 x86 尋址模式支持的比例因子,誘使 GCC在添加或時在關鍵路徑依賴鏈上使用慢 LEA以及或的常數總和(無論哪種方式只是一個常數,與它是什么無關) . 顯然也作為 16 的 dep 鏈的一部分。并且您使用行內 asm 強制dep 鏈包含存盤/重新加載。4*i8*isum1..41..8sum
分析程式集,我觀察到兩個函式中的內部回圈都被消除并被一些作為 lea 運算元的算術運算所取代。所以在這種情況下,如果兩個函式以相同的速度運行是有意義的。
它們都很快,但不同的倍數i采用不同的指令。所以幾乎沒有理由假設它們是相同的。有趣的是,總指令越多的指令越快,因為它的依賴鏈更短sum。
你用有點笨重的 強制存盤/重新加載它asm volatile("" :: "g"(&sum) : "memory"),而不是只要求編譯器在暫存器中使用asm volatile("" : " r"(sum)). 因此,該 dep 鏈包括存盤轉發延遲(通常為 3 到 5 個周期),因此它是比獨立作業的前端或 ALU 吞吐量更大的瓶頸。
test1():
xor eax, eax # i = 0
xor ecx, ecx # sum = 0
lea rsi, [rsp-8] # operand for inline asm
jmp .L3
.L5:
mov rcx, QWORD PTR [rsp-8] # load sum
mov rax, rdx # i = i 1
.L3:
lea rdx, [rax 1] # i 1, leaving the old value in RAX
lea rax, [rcx 6 rax*4] # sum = 4*i 6. (1 2 3)
mov QWORD PTR [rsp-8], rax # store sum
cmp rdx, 100000
jne .L5
ret
在尋址模式下具有 3個組件(兩個操作)的 LEA 在您的 Skylake 派生 CPU 上具有 3 個周期延遲。請參閱x86 乘以 3:IMUL 與 SHL ADD
所以回圈攜帶的依賴鏈是加載/存盤之間的慢 LEA(3 個周期)。
test2():
xor eax, eax
xor ecx, ecx
lea rdi, [rsp-8] # operand for inline asm
jmp .L8
.L9:
mov rcx, QWORD PTR [rsp-8] # load sum
mov rax, rdx
.L8:
lea rsi, [rax 36 rax*8]
lea rdx, [rax 1]
lea rax, [rax 9 rsi] # prepare some stuff to be added
add rax, rcx # first use of the RCX load result (sum)
mov QWORD PTR [rsp-8], rax # store sum
cmp rdx, 100000
jne .L9
ret
因此,通過存盤/重新加載的回圈攜帶的 dep 鏈僅包括add1 個周期的延遲而不是 3 個。
我假設你的性能比是 3:4 左右,從 5 1 周期 (6) 與 5 3 (8) 周期。
請參閱在預測現代超標量處理器上的操作延遲時需要考慮哪些注意事項以及如何手動計算它們?更多細節。
編譯器本可以花費更多指令test1來將關鍵路徑延遲減少到 1 個周期,而不是將更多作業折疊到lea關鍵路徑上。對于運行 100k 次迭代的回圈,這幾乎是一個錯過的優化。但是我假設優化器沒有針對行內匯編中人為引起的溢位/重新加載進行調整;通常,如果它在回圈中做很多事情而用盡了暫存器,它只需要引入存盤/重新加載,并且許多不同的值通常意味著存在一些指令級并行性。
GCC 從 Quick-bench 上更簡單的源代碼生成更好的 asm
@TedLyngmo在沒有, 的情況下鏈接了 Quick-bench 上的代碼-fno-tree-vectorize,并且使用benchmark::DoNotOptimize(sum);它只會強制 GCC 在暫存器中實作值,而不會阻止通過它的常量傳播或許多其他優化。不像獲取它的地址并告訴 GCC 這可能是全球可見的,就像當前的自定義匯編一樣。
如果您查看 Quickbench 上的 asm,則內部回圈體只是add %rdx,%rax/ (和 cmp rdx jne 作為回圈分支)。add $0x4,%rdx或者將 rdx =10 用于另一個回圈。所以相同的回圈,不同的常數。當然,同樣的表現。
當前源本質上是編譯為 asm
for (int i = 0 ; i<iterations ; i ){
sum = 8*i 1234;
force_store_reload(&sum);
}
但是如果你真的這樣寫(https://godbolt.org/z/4ja38or9j),我們會得到像 quick-bench 一樣的 asm,除了將值保存在記憶體中而不是暫存器中。(所以慢了大約 6 倍。)
.L6:
add QWORD PTR [rsp-8], rax # memory destination add
add rax, 4
cmp rax, 401234
jne .L6
GCC 沒有將您現有的源代碼編譯為該錯誤似乎是一個錯過的優化錯誤。具體來說,錯過了從8*i重新評估的強度減少i到tmp = 8。
順便說一句,看起來省略-fno-tree-vectorize會使您的原始test1()編譯變得更糟。它開始時不會跳到回圈的中間,但它有一個更長的 dep 鏈。
#gcc 10.3 -O3 (without -fno-tree-vectorize)
test1_orig():
mov QWORD PTR [rsp-8], 6 # init sum
lea rsi, [rsp-8] # operand for inline asm
mov eax, 1
.L2:
mov rdx, QWORD PTR [rsp-8] # load sum
mov rcx, rax
add rdx, rax # used: 1 cycle latency
add rax, 1
add rdx, rax # used: 1 cycle latency
lea rdx, [rdx 5 rcx*2] # used: 3 cycle latency
mov QWORD PTR [rsp-8], rdx # store
cmp rax, 100000
jne .L2
ret
轉載請註明出處,本文鏈接:https://www.uj5u.com/ruanti/472527.html
