一條指令優化引發的血案,性能狂掉50%,clang使用-ffast-math選項後變傻了

//www.cnblogs.com/bbqzsl/p/15510377.html

近期在做優化時,對一些函數分別在不同編譯平台上進行bench測試。發現了不少問題。

現在拿其中一個問題來分享。

 1 typedef float MAFloat;
 2 
 3 MAFloat sma(const MAFloat* seq, const int cnt, const int N, const int M)
 4 {
 5     const MAFloat C1 = (MAFloat)M/N;
 6     const MAFloat C2 = (MAFloat)(N-M)/N;
 7     MAFloat result = 0.f;
 8     int total = cnt;
 9 
10 #pragma nounroll
11     for (int i = 0; i < total; ++i)
12     {
13         result = result * C2 + seq[i] * C1;
14     }
15 
16     return result;
17 }

測試代碼很簡單,只一個循環,循環內只做了算術運算,彙編代碼也很容易。

測試平台包括:

win10:平台,vc120,gcc10,clang11

centos8:平台,gcc8,gcc10,clang11

vc:使用選項 /arch:sse2 /O2,並且win32

gcc:使用選項 -ffast-math -O2 -m32

clang:使用選項 -ffast-math -O2 -m32

數組長度為 28884 = 7221 * 4;

cpu 是 core i5,3.5Ghz

測試結果:

win10:平台,vc120 (0.06x ms),gcc10 (0.06x ms),clang11 (0.09x ms)

centos8:平台,gcc8 (0.06x ms),gcc10 (0.06x ms),clang11 (0.09x ms)

不論在win10還是centos8平台上,clang編譯的代碼的性能居然比vc或gcc編譯的代碼性能差了50%。

現在我們來對比gcc10與clang11產出彙編代碼

## gcc 

.L149:            
    movss    (%edx,%eax,4), %xmm1    # xmm1 = seq[i]
    mulss    %xmm3, %xmm0         # xmm0 = result * C2
    addl    $1, %eax            # 
    mulss    %xmm2, %xmm1         # xmm1 = seq[i] * C1 
    addss    %xmm1, %xmm0         # result = xmm0 + xmm1
    cmpl    %ecx, %eax    
    jl    .L149               # next loop


## clang

LBB7_3:                                 # =>This Inner Loop Header: Depth=1            
    movss    (%eax,%edx,4), %xmm4            # xmm4 = mem[0],zero,zero,zero    
    mulss    %xmm1, %xmm3           # 
    incl    %edx    
    cmpl    %ecx, %edx    
    mulss    %xmm0, %xmm4    
    addss    %xmm4, %xmm3    
    mulss    %xmm2, %xmm3           # xmm2 = 1/N;
    jl    LBB7_3    

gcc生成的彙編代碼一共7條指令,clang生成的彙編代碼一共8條指令多出了一條mulss。

clang不知什麼原因自作聰明將

result * C2 + seq[i] * C1;

優化成

(1/N) * (result * (N-M) + seq[i] * M);

即使多出一條mulss指令,性能也不至於差了50%,就像7條指令與10.5條指令的差距。

現在來分析

我的機器使用i5 3.5Ghz, 1ns可以運行3.5指令周期。

數組長度為28884,即執行循環代碼28884次

運行時間為 28884 * (循環體指令周期)/  3.5

我現在粗略地將每條指令周期看作是1,gcc生成的代碼運行時間粗略地為 28884 * 7 / 3.5 = 57768ns,與測試結果在0.06ms基本相當。用同樣的方法估算,clang生成的代碼運行時間粗略地為 28884 * 8 / 3.5 = 66020ns。

但是不同的指令,執行不同數量的微指令(uop),也就是延遲,mulss為4或5,addss為3,上面彙編代碼的其它指令各為1。

    mulss    %xmm2, %xmm1         # xmm1 = seq[i] * C1 
    addss    %xmm1, %xmm0         # result = xmm0 + xmm1

在上面兩條指令,addss 依賴 mulss 的結果於 %xmm1,也就是說addss 必須在mulss開始執行後延遲4或5個周期才能執行。由於cpu的亂序機制,這時候延遲的周期數內可以在其他ALU執行其它指令。所以gcc生成的彙編代碼的情況可以看作沒有指令周期的損失。

再來看clang生成的彙編代碼

    mulss    %xmm0, %xmm4    
    addss    %xmm4, %xmm3    
    mulss    %xmm2, %xmm3           # xmm2 = 1/N;

addss 依賴 mulss 的結果於 %xmm4,然後mulss 依賴 addss 的結果於 %xmm3,這裡我們將第一個依賴等同於gcc彙編中的那個依賴,那麼下一個依賴的3個周期就必須等待,一次循環一共才8條指令,兩個依賴的延遲合計就8個指令周期,亂序也就沒有指令可以執行,所以就硬生生多出3或4個指令周期等待。

運行時間一下子就變成了 28884 * (8+3) / 3.5 = 90778ns。

估算的結果與測試的結果基本上吻合。

有興趣的朋友可以到godblot上測試彙編,一旦讓clang使用-ffast-math選項,編譯發生這一出傻事。