为什么Caltz猜想测试的C++代码比手写汇编运行得快?

我为Euler Q14编写了这两个解决方案,在汇编和C++中。他们实现了相同的蛮力方法来测试Collatz猜想。组装解决方案由以下部件组装而成:

nasm-felf64 p14.asm&amp&gcc p14.o-o p14

C++编写:

g++p14.cpp-o p14

组件,p14.asm

section.data
fmt db"%d",10, 0
全球主要
外部打印
第节.案文
主要内容:
mov rcx,1000000
异或rdi,rdi;马克斯一世
xor rsi,rsi;我
l1:
12月rcx
异或r10,r10;计数
mov-rax,rcx
l2:
测试rax,1
均价
mov rbx,3
mul rbx
拉克斯公司
jmp-c1
甚至:
mov rbx,2
异或rdx,rdx
分区rbx
c1:
公司r10
cmp-rax,1
jne l2
cmp-rdi,r10
cmovl rdi,r10
cmovl rsi,rcx
cmp rcx,2
jne l1
mov-rdi,fmt
xor-rax,rax
调用printf
ret

C++,p14.cpp

\include<iostream>
整数序列(长n){
整数计数=1;
而(n!=1){
如果(n%2==0)
n/=2;
其他的
n=3*n+1;
++计数;
}
返回计数;
}
int main(){
int max=0,max;
对于(int i=999999;i>0;--i){
int s=序列(i);
如果(s>最大值){
max=s;
maxi=i;
}
}
标准::cout<<maxi<<标准::endl;
}

我知道如何通过编译器优化来提高速度,但我看不到有多少方法可以进一步优化我的汇编解决方案(从编程角度讲,不是从数学角度讲)

C++代码每学期使用模数和每隔一个学期,而汇编代码每隔一个学期只用一个除法。
但是,该组件的平均长度比C++解决方案长1秒。为什么会这样?我问这个问题主要是出于好奇

执行时间

我的系统:1.4 GHz英特尔赛扬2955U(Haswell微体系结构)上的64位Linux

  • g++(未优化):平均1272毫秒
  • g++-O3:平均578毫秒
  • asm(div)(原件):平均2650毫秒
  • asm(shr):平均679毫秒
  • @johnfound asm(与NASM组装):平均501毫秒
  • @hidefromkgb asm:平均200毫秒
  • @hidefromkgb asm,由@Peter Cordes优化:平均145毫秒
  • @Veedrac C++:平均81毫秒,含-O3,305毫秒,含-O0

如果您认为64位DIV指令是一种很好的除以2的方法,那么编译器的asm输出就不足为奇了,即使使用-O0(快速编译,无需额外优化,并在每个C语句之后/之前将其存储/重新加载到内存中,以便调试器可以修改变量)

请参阅Agner Fog的优化汇编指南,了解如何编写高效的asm。他也有指导表和一个针对特定CPU的特定细节的微阵列指南。有关更多性能链接,请参见x86标记wiki

也看到了一个更一般的问题,即用手工编写的ASM击败编译器:内联汇编语言比本地C++代码慢吗?TL:DR:如果你做错了(比如这个问题),是的

通常你可以让编译器做它的事情,特别是如果你很强的话,试着写C++,可以有效编译。另请参见汇编语言比编译语言快吗?。其中一个答案链接到这些简洁的幻灯片,这些幻灯片展示了各种C编译器如何使用一些很酷的技巧优化一些非常简单的函数马特·戈博尔特(Matt Godbolt)的CppCon2017演讲“我的编译器最近为我做了什么?打开了编译器的盖子”也是类似的风格。


偶数:
mov rbx,2
异或rdx,rdx
分区rbx

在Intel Haswell上,div r64为36 uops,延迟为32-96个周期,吞吐量为每21-74个周期一个。(加上设置RBX和零RDX的2个UOP,但无序执行可以提前运行)。像DIV这样的高uop计数指令是微代码的,这也会导致前端瓶颈。在这种情况下,延迟是最相关的因素,因为它是循环携带的依赖链的一部分

shr-rax,1执行相同的无符号除法:1 uop,1c延迟,每个时钟周期可以运行2次

相比之下,32位除法速度更快,但与移位相比仍然很糟糕idiv r32是Haswell上的9个uops,22-29c延迟,每8-11c吞吐量一个


从gcc的-O0asm输出(Godbolt编译器浏览器)可以看出,它只使用移位指令。clang-O0确实像您所想的那样天真地编译,甚至两次使用64位IDIV。(优化时,如果源代码使用相同的操作数进行除法和模运算,则编译器会同时使用IDIV的两个输出)

GCC没有一个完全幼稚的模式;它总是通过GIMPLE变换,这意味着一些;“优化”;不能被禁用。这包括通过常数识别除法,并使用移位(2的幂次)或定点乘法逆(非2的幂次)来避免IDIV(请参见上述锁销链接中的div_by_13

gcc-Os(针对大小进行优化)是否将IDIV用于非二次幂除法,
不幸的是,即使在乘法逆码只是稍微大一点,但要快得多的情况下


帮助编译器

(本例总结:使用uint64\u t n

首先,唯一有趣的是查看优化的编译器输出。(-O3)。
-O0速度基本上没有意义。

查看asm输出(在Godbolt上,或查看如何从GCC/clang组件输出中消除“噪音”)。当编译器没有首先生成最佳代码时:以一种引导编译器生成更好代码的方式编写C/C++源代码通常是最好的方法。你必须了解asm,知道什么是有效的,但是你可以间接地应用这些知识。编译器也是一个很好的想法来源:有时候clang会做一些很酷的事情,你可以用gcc来做同样的事情:看看这个答案和我在@Veedrac下面的代码中对非展开循环所做的。)

这种方法是可移植的,在20年后,一些未来的编译器可以将其编译成任何在未来硬件(x86或x86)上有效的东西,可能使用新的ISA扩展或自动矢量化。15年前手写的x86-64 asm通常不会针对Skylake进行最佳调优。e、 g.比较;分支宏融合在当时并不存在对于一个微体系结构而言,现在手工制作的asm的最佳选择对于当前和未来的其他CPU来说可能不是最佳选择。关于@johnfound答案的评论讨论了AMD推土机和Intel Haswell之间的主要差异,这对该代码有很大影响。但在理论上,g++-O3-march=bdver3g++-O3-march=skylake会做正确的事情。(或-march=native)或-mtune=…只进行调谐,而不使用其他CPU可能不支持的指令

我的感觉是,将编译器引导到asm,这对您关心的当前CPU是有益的,对于未来的编译器来说,这不应该是一个问题。在寻找转换代码的方法方面,他们有望比当前的编译器做得更好,并且能够找到一种适用于未来CPU的方法。无论如何,未来的x86可能不会在当前x86上做任何好的事情,未来的编译器将避免任何asm特定的陷阱,同时从C源代码实现数据移动之类的东西,如果看不到更好的东西

手工编写的asm对于优化器来说是一个黑盒,因此当内联使输入成为编译时常量时,常量传播不起作用。其他优化也会受到影响。阅读https://gcc.gnu.org/wiki/DontUseInlineAsm 在使用asm之前。(并避免MSVC风格的内联asm:输入/输出必须经过内存,这会增加开销。)

在本例中:您的n具有有符号类型,gcc使用SAR/SHR/ADD序列进行正确的舍入。(对于负输入,IDIV和算术移位“四舍五入”不同,请参见SAR insn set ref手动输入)。(IDK如果gcc试图证明n不能为负,或者什么。有符号溢出是未定义的行为,那么它应该能够。)

您应该使用uint64\u t n,这样它就可以直接SHR。因此,它可以移植到long只有32位的系统(例如x86-64 Windows)


顺便说一句,gcc的优化的asm输出看起来相当不错(使用无符号长n:它内联到main()的内部循环执行以下操作:

来自gcc5.4-O3以及我的评论
#edx=计数=1
#rax=uint64\u t n
.L9:#做什么{
lea rcx,[rax+1+rax*2]#rcx=3*n+1
莫夫尔迪,拉克斯
shr rdi#rdi=n>>1;
测试al,1#基于n%2设置标志(也称为n&1)
mov-rax,rcx
cmove-rax,rdi#n=(n%2)?3*n+1:n/2;
加上edx,1#++计数;
cmp-rax,1
jne.L9#}while(n!=1)
cmp/branch更新max和maxi,然后执行下一个n

内部循环是无分支的,循环承载的依赖链的关键路径是:

  • 三组分LEA(3个循环)
  • cmov(Haswell上2个循环,Broadwell上1c或更高版本)

总计:每次迭代5个周期,延迟瓶颈。无序执行与此并行处理所有其他问题(理论上:我还没有使用perf计数器测试它是否真的在5c/iter下运行)

发表评论