使用 -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 慢的主要内容,如果未能解决你的问题,请参考以下文章