簡體   English   中英

使用 -O3 的冒泡排序比使用 GCC 的 -O2 慢

[英]Bubble sort slower with -O3 than -O2 with GCC

我在 C 中做了一個冒泡排序實現,並在測試它的性能時注意到-O3標志使它運行得比沒有標志還要慢! 同時-O2使它運行得比預期的快得多。

沒有優化:

time ./sort 30000

./sort 30000  1.82s user 0.00s system 99% cpu 1.816 total

-O2

time ./sort 30000

./sort 30000  1.00s user 0.00s system 99% cpu 1.005 total

-O3

time ./sort 30000

./sort 30000  2.01s user 0.00s system 99% cpu 2.007 total

編碼:

#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <time.h>

int n;

void bubblesort(int *buf)
{
    bool changed = true;
    for (int i = n; changed == true; i--) { /* will always move at least one element to its rightful place at the end, so can shorten the search by 1 each iteration */
        changed = false;

        for (int x = 0; x < i-1; x++) {
            if (buf[x] > buf[x+1]) {
                /* swap */
                int tmp = buf[x+1];
                buf[x+1] = buf[x];
                buf[x] = tmp;

                changed = true;
            }
        }
    }
}

int main(int argc, char *argv[])
{
    if (argc != 2) {
        fprintf(stderr, "Usage: %s <arraysize>\n", argv[0]);
        return EXIT_FAILURE;
    }

    n = atoi(argv[1]);
    if (n < 1) {
        fprintf(stderr, "Invalid array size.\n");
        return EXIT_FAILURE;
    }

    int *buf = malloc(sizeof(int) * n);

    /* init buffer with random values */
    srand(time(NULL));
    for (int i = 0; i < n; i++)
        buf[i] = rand() % n + 1;

    bubblesort(buf);

    return EXIT_SUCCESS;
}

-O2生成的匯編語言(來自godbolt.org ):

bubblesort:
        mov     r9d, DWORD PTR n[rip]
        xor     edx, edx
        xor     r10d, r10d
.L2:
        lea     r8d, [r9-1]
        cmp     r8d, edx
        jle     .L13
.L5:
        movsx   rax, edx
        lea     rax, [rdi+rax*4]
.L4:
        mov     esi, DWORD PTR [rax]
        mov     ecx, DWORD PTR [rax+4]
        add     edx, 1
        cmp     esi, ecx
        jle     .L2
        mov     DWORD PTR [rax+4], esi
        mov     r10d, 1
        add     rax, 4
        mov     DWORD PTR [rax-4], ecx
        cmp     r8d, edx
        jg      .L4
        mov     r9d, r8d
        xor     edx, edx
        xor     r10d, r10d
        lea     r8d, [r9-1]
        cmp     r8d, edx
        jg      .L5
.L13:
        test    r10b, r10b
        jne     .L14
.L1:
        ret
.L14:
        lea     eax, [r9-2]
        cmp     r9d, 2
        jle     .L1
        mov     r9d, r8d
        xor     edx, edx
        mov     r8d, eax
        xor     r10d, r10d
        jmp     .L5

-O3一樣:

bubblesort:
        mov     r9d, DWORD PTR n[rip]
        xor     edx, edx
        xor     r10d, r10d
.L2:
        lea     r8d, [r9-1]
        cmp     r8d, edx
        jle     .L13
.L5:
        movsx   rax, edx
        lea     rcx, [rdi+rax*4]
.L4:
        movq    xmm0, QWORD PTR [rcx]
        add     edx, 1
        pshufd  xmm2, xmm0, 0xe5
        movd    esi, xmm0
        movd    eax, xmm2
        pshufd  xmm1, xmm0, 225
        cmp     esi, eax
        jle     .L2
        movq    QWORD PTR [rcx], xmm1
        mov     r10d, 1
        add     rcx, 4
        cmp     r8d, edx
        jg      .L4
        mov     r9d, r8d
        xor     edx, edx
        xor     r10d, r10d
        lea     r8d, [r9-1]
        cmp     r8d, edx
        jg      .L5
.L13:
        test    r10b, r10b
        jne     .L14
.L1:
        ret
.L14:
        lea     eax, [r9-2]
        cmp     r9d, 2
        jle     .L1
        mov     r9d, r8d
        xor     edx, edx
        mov     r8d, eax
        xor     r10d, r10d
        jmp     .L5

對我來說,唯一顯着的區別似乎是使用SIMD的明顯嘗試,這似乎應該是一個很大的改進,但我也無法判斷它到底在用那些pshufd指令嘗試什么......這只是一個SIMD 嘗試失敗? 或者也許這兩條額外的指令只是為了消除我的指令緩存?

計時是在 AMD Ryzen 5 3600 上完成的。

這是 GCC11/12 中的回歸。
GCC10 和更早的版本執行單獨的 dword 加載,即使它合並為一個 qword 存儲。

看起來 GCC 對 商店轉發攤位的天真正在損害其自動矢量化策略。 另請參閱通過示例存儲轉發,了解英特爾上帶有硬件性能計數器的一些實用基准,以及x86 上失敗的存儲到加載轉發的成本是多少? 還有Agner Fog 的 x86 優化指南

gcc -O3啟用-ftree-vectorize-O2不包含的一些其他選項,例如if -conversion to cmov ,這是-O3可能會損害 GCC 未預料到的數據模式的另一種方式。相比之下,Clang 啟用即使在-O2也自動矢量化,盡管它的一些優化仍然只在-O3 。)

它在成對的整數上進行 64 位加載(以及是否分支存儲)。 這意味着,如果我們交換了最后一次迭代,則此負載一半來自該存儲,一半來自新內存,因此我們在每次交換后都會遇到存儲轉發停頓 但是冒泡排序通常有很長的交換鏈,因為元素冒泡很遠,所以這真的很糟糕。

冒泡排序通常很糟糕,特別是如果天真地實現而沒有將先前迭代的第二個元素保留在寄存器中。分析 asm 詳細信息以了解其糟糕的確切原因可能很有趣,因此想要嘗試是足夠公平的。)

無論如何,這顯然是一種反優化,您應該使用“missed-optimization”關鍵字報告GCC Bugzilla 標量負載很便宜,而存儲轉發停頓的成本很高。 現代 x86 實現是否可以從多個先前的存儲中存儲轉發?不,當有序 Atom 與先前的存儲部分重疊並且部分來自必須來自 L1d 緩存的數據時,除了有序Atom之外的微架構也不能有效加載。 )

更好的做法是將buf[x+1]保存在寄存器中,並在下一次迭代中將其用作buf[x] ,避免存儲和加載。 (就像好的手寫 asm 冒泡排序示例一樣,其中一些存在於 Stack Overflow 上。)

如果不是因為商店轉發攤位(AFAIK GCC 在其成本模型中不知道這一點),這種策略可能會達到收支平衡。 用於無pmind / pmaxd比較器的SSE 4.1 可能很有趣,但這意味着始終存儲並且 C 源不這樣做。


如果這種雙寬度加載策略有任何優點,最好在 x86-64 這樣的 64 位機器上使用純整數來實現,在這種機器上,您可以只在低 32 位上操作垃圾(或有價值的數據)上半部分。 例如,

## What GCC should have done,
## if it was going to use this 64-bit load strategy at all

        movsx   rax, edx           # apparently it wasn't able to optimize away your half-width signed loop counter into pointer math
        lea     rcx, [rdi+rax*4]   # Usually not worth an extra instruction just to avoid an indexed load and indexed store, but let's keep it for easy comparison.
.L4:
        mov     rax, [rcx]       # into RAX instead of XMM0
        add     edx, 1
            #  pshufd  xmm2, xmm0, 0xe5
            #  movd    esi, xmm0
            #  movd    eax, xmm2
            #  pshufd  xmm1, xmm0, 225
        mov     rsi, rax
        rol     rax, 32   # swap halves, just like the pshufd
        cmp     esi, eax  # or eax, esi?  I didn't check which is which
        jle     .L2
        movq    QWORD PTR [rcx], rax   # conditionally store the swapped qword

(或者使用-march=native提供的 BMI2, rorx rsi, rax, 32可以在一個 uop 中進行復制和交換。沒有 BMI2,如果在沒有 mov-elimination 的 CPU 上運行,則mov和交換原始文件而不是副本可以節省延遲,例如帶有更新微碼的冰湖。)

因此,從加載到比較的總延遲只是整數加載 + 一個 ALU 操作(旋轉)。 比。 XMM 加載 -> movd 而且它的 ALU 微指令更少。 不過,這無助於解決商店轉發失速問題,這仍然是個大問題。 這只是相同策略的整數 SWAR 實現,將 2x pshufd 和 2x movd movd r32, xmm替換為mov + rol

實際上,這里沒有理由使用 2x pshufd 即使使用 XMM 寄存器,GCC 也可以進行一次 shuffle,交換低兩個元素,同時設置 store 和movd 因此,即使使用 XMM regs,這也是次優的。 但顯然 GCC 的兩個不同部分發出了這兩個pshufd指令; 一個甚至用十六進制打印洗牌常數,而另一個使用十進制! 我假設一個交換,另一個只是試圖獲得vec[1] ,qword 的高元素。


比沒有標志慢

默認值為-O0 ,一致的調試模式, 在每個 C 語句之后將所有變量溢出到內存中,所以它非常可怕,並且會產生很大的存儲轉發延遲瓶頸。 (有點像如果每個變量都是volatile 。)但它是成功的存儲轉發,而不是停頓,所以“只有”~5 個周期,但仍然比寄存器的 0 差得多。 (包括Zen 2在內的一些現代微架構有一些延遲較低的特殊情況)。 必須通過管道的額外存儲和加載指令無濟於事。

-O0進行基准測試通常並不有趣。 -O1 或-O1應該是編譯器的-Og基線,以執行普通人期望的基本優化量,沒有任何花哨的東西,但也不會故意通過跳過寄存器分配來削弱 asm。


半相關:針對大小而不是速度優化冒泡排序可能涉及內存目標旋轉(為背靠背交換創建存儲轉發停頓)或內存目標xchg (隱式lock前綴 -> 非常慢)。 請參閱Code Golf答案

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM