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

Posted

技术标签:

【中文标题】使用 -O3 的冒泡排序比使用 GCC 的 -O2 慢【英文标题】:Bubble sort slower with -O3 than -O2 with GCC 【发布时间】:2021-11-28 20:28:45 【问题描述】:

我在 C 中做了一个bubble sort 实现,并在测试它的性能时注意到-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 上完成的。

【问题讨论】:

@Abel: gcc -Ofast 只是-O3 -ffast-math 的快捷方式,但这里没有 FP 数学。如果您要尝试任何东西,请尝试-O3 -march=native 让它使用 AVX2,以防 GCC 的矢量化策略可以帮助更广泛的矢量而不是伤害,无论它试图做什么。虽然我不这么认为;它只是进行 64 位加载和随机播放,甚至没有使用 SSE2 的 128 位。 至少在旧版本的 gcc 上,-Os(优化空间)有时会产生最快的代码,因为 x86-64 上指令缓存的大小。我不知道这在这里是否重要,或者它是否仍然适用于当前版本的 gcc,但尝试和比较可能会很有趣。 @DavidConrad:-Os 会让 GCC 选择不自动矢量化,所以它与我期望的 -O2 大致相同,而不是用商店转发摊位自取其辱并在检测到分支错误预测之前增加了延迟。 您应该包含您的实际编译器输出的汇编代码,而不是来自 godbolt.org。 @user253751:不同意;只要查询者在 Godbolt 上选择了与本地相同的 GCC 版本,因此指令相同,Godbolt 对指令的良好过滤就更好了。并且在 Godbolt 上链接 source+asm 可以让任何想要查看其他 GCC 版本/选项的人更好。 【参考方案1】:

看起来 GCC 对store-forwarding 停顿的天真正在损害其自动矢量化策略。另请参阅 Store forwarding by example 了解有关英特尔硬件性能计数器的一些实用基准,以及 What are the costs of failed store-to-load forwarding on x86? 以及 Agner Fog's x86 optimization guides。

(gcc -O3 启用-ftree-vectorize-O2 未包含的一些其他选项,例如if-转换为无分支cmov,即another way -O3 can hurt,具有GCC 没有预料到的数据模式。通过相比之下,Clang 甚至在 -O2 也启用了自动矢量化,尽管它的一些优化仍然只在 -O3 上启用。)

它在整数对上执行 64 位加载(以及是否分支存储)。这意味着,如果我们交换了最后一次迭代,则此负载一半来自该存储,一半来自新内存,因此每次交换后我们都会遇到存储转发停止。但是冒泡排序通常有很长的交换链,因为元素冒泡很远,所以这真的很糟糕。

(Bubble sort is bad in general,特别是如果天真地实现而不将前一个迭代的第二个元素保留在寄存器中。分析 asm 细节以了解其糟糕的确切原因可能很有趣,因此想要尝试是公平的。)

无论如何,这显然是一种反优化,您应该使用“missed-optimization”关键字报告GCC Bugzilla。标量负载很便宜,而存储转发停顿的成本很高。 (Can modern x86 implementations store-forward from more than one prior store? 不,microarchitectures 也不能在与前一个存储部分重叠并且部分来自必须来自 L1d 缓存的数据时有效加载。 )

最好将buf[x+1] 保存在寄存器中,并在下一次迭代中将其用作buf[x],避免存储和加载。 (就像优秀的手写 asm 冒泡排序示例一样,其中一些存在于 *** 上。)

如果不是因为商店转发摊位(AFAIK GCC 在其成本模型中不知道这一点),这种策略可能会达到收支平衡。 SSE 4.1 用于无分支 pmind / pmaxd 比较器可能很有趣,但这意味着始终存储并且 C 源代码不会这样做。


如果这种双宽度加载策略有任何优点,最好在 64 位机器上使用纯整数实现,例如 x86-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 并且如果在没有mov-的CPU 上运行,则交换原始文件而不是副本可以节省延迟消除,如Ice Lake with updated microcode。)

因此,从加载到比较的总延迟只是整数加载 + 一次 ALU 操作(旋转)。比。 XMM 加载 -> movd。而且它的 ALU 微指令更少。 没有帮助解决存储转发停滞问题,但这仍然是一个障碍。这只是相同策略的整数 SWAR 实现,替换 2x pshufd和 2x movd r32, xmm 只需 mov + rol

实际上,这里没有理由使用 2x pshufd。即使使用 XMM 寄存器,GCC 也可以进行一次 shuffle,交换低两个元素,同时设置 store 和 movd。因此,即使使用 XMM regs,这也是次优的。但显然 GCC 的两个不同部分发出了这两个 pshufd 指令;一个甚至用十六进制打印洗牌常数,而另一个使用十进制!我假设一个交换,另一个只是试图获得vec[1],qword 的高元素。


比没有标志还慢

默认为-O0,即spills all variables to memory after every C statement 的一致调试模式,所以它非常可怕,并造成很大的存储转发延迟瓶颈。 (有点像如果每个变量都是volatile。)但它成功 存储转发,而不是停顿,所以“只有”~5 个周期,但仍然比寄存器的 0 差得多。 (包括Zen 2 在内的一些现代微架构有一些special cases that are lower latency)。必须通过管道的额外存储和加载指令没有帮助。

-O0 进行基准测试通常并不有趣。 -O1-Og 应该是编译器的首选基线,以执行正常人所期望的基本优化量,没有任何花哨的东西,但也不会通过跳过寄存器分配来故意 gimp asm。


半相关:为 size 而不是速度优化冒泡排序可能涉及内存目标旋转(为背靠背交换创建存储转发停顿)或内存目标 @987654367 @(隐式 lock 前缀 -> 非常慢)。见this Code Golf answer。

【讨论】:

"(冒泡排序通常很糟糕,特别是如果天真地实现而没有将前一次迭代的第二个元素保留在寄存器中。分析 asm 细节以了解它为什么很糟糕可能很有趣,所以公平足够想尝试了。)”当你这么说的时候,你的意思是甚至与其他 O(N^2) 排序算法相比,是吗? @KarlKnechtel:是的,确切地说,就像我在my answer 中解释的那样,从你引用的那句话的开头链接;这就是我链接它的原因。简单的排序算法在小问题中占有一席之地,例如作为 MergeSort 等分而治之排序的基本情况;此类算法通常使用低于可能 16 的大小阈值的 InsertionSort。或者像在这种情况下一样,只是作为一个实验,看看分支预测和其他 CPU 微架构特性在运行“简单”循环时的表现如何。还有编译器的表现如何。 优秀的答案,尤其是向 GCC 报告此问题的建议和理由。 @PeterMortensen - 感谢您的编辑,尽管我必须解决一些问题(例如,另一个 [] 中的 [] 链接不起作用,而且“汇编语言”也不起作用读得好,谈论编译器的输出。你可以说“汇编代码”,但我认为只说“asm”仍然是100%清晰,实际上更容易阅读。简洁是有价值的,所以 IMO 扩展事物并不总是更好。有时总体上更好,也许对初学者来说,所以即使我认为没有必要,我也忍受了一些。)

以上是关于使用 -O3 的冒泡排序比使用 GCC 的 -O2 慢的主要内容,如果未能解决你的问题,请参考以下文章

为什么插入排序比冒泡排序更受欢迎?

为啥插入排序比快速排序和冒泡排序更快?

插入排序比冒泡排序好?

面试只会冒泡排序?教你一个比冒泡排序还简单的排序算法!-Testfan打卡学测开1011

11 | 排序(上):为什么插入排序比冒泡排序更受欢迎?

算法的学习 — 冒泡排序