如果您认为64位DIV指令是除以2的好方法,那么难怪编译器的asm输出会击败您的手写代码,即使-O0
(编译速度快,没有额外的优化,并且在/在每个C语句之前,以便调试器可以修改变量)。
请参阅Agner Fog的“优化程序集”指南以了解如何编写高效的asm。他还提供了指令表和微体系结构指南,以了解特定CPU的特定详细信息。另请参阅x86 标记维基以获得更多性能链接。
另请参阅有关用手写asm击败编译器的更一般的问题:内联汇编语言是否比本机C ++代码慢?。TL:DR:是的,如果您做错了(像这个问题)。
通常,您可以让编译器执行其工作,尤其是当您尝试编写可以高效编译的C ++时。还可以看到汇编比编译语言要快吗?。答案之一链接到这些整洁的幻灯片,这些幻灯片显示了各种C编译器如何使用很酷的技巧优化一些非常简单的功能。 Matt Godbolt在CppCon2017上的演讲“ 最近我的编译器为我做了什么?解开编译器的盖子也与此类似。
even:
mov rbx, 2
xor rdx, rdx
div rbx
在Intel Haswell上div r64
为36 oups,延迟为 32-96个周期,吞吐量为每21-74个周期一个。(加上2个指令来设置RBX和0个RDX,但是乱序执行可以使它们早点运行)。 像DIV这样的高计数指令是经过微编码的,这也可能导致前端瓶颈。在这种情况下,延迟是最相关的因素,因为它是循环承载的依赖链的一部分。
shr rax, 1
执行相同的无符号除法:1 uop,延迟为1c,每个时钟周期可以运行2次。
相比之下,32位除法速度更快,但与移位相比仍然可怕。idiv r32
在Haswell上为9 oups,延迟为22-29c,每8-11c吞吐量为1。
从查看gcc的-O0
asm输出(Godbolt编译器浏览器)可以看出,它仅使用shifts指令。clang -O0
确实像您想的那样天真地进行编译,即使两次使用64位IDIV。(优化时,如果源完全使用IDIV,则当源使用相同的操作数进行除法和模数运算时,编译器会同时使用IDIV的两个输出)
GCC没有完全天真的模式。它总是通过GIMPLE进行转换,这意味着不能禁用某些“优化”。这包括识别按常数除法,并使用移位(2的幂)或定点乘法逆(非2的幂)来避免IDIV(请参见div_by_13
上面的Godbolt链接)。
gcc -Os
(针对大小进行优化)确实将IDIV用于非2的幂次除法,不幸的是,即使在乘法逆代码仅稍大而又快得多的情况下。
帮助编译器
(此情况的摘要:使用uint64_t n
)
首先,查看优化的编译器输出只是很有趣。(-O3
)。 -O0
速度基本上是没有意义的。
查看您的asm输出(在Godbolt上,或参阅如何从GCC / c装配件输出中消除“噪音”?)。如果编译器一开始没有编写最佳代码:以一种指导编译器编写更好代码的方式编写C / C ++源代码通常是最好的方法。您必须了解asm,并知道有效的方法,但是您可以间接应用此知识。编译器也是一个很好的想法来源:有时clang会做一些很酷的事情,并且您可以让gcc进行同样的事情:请参见以下答案以及我对@Veedrac的代码中的非展开循环所做的操作。)
这种方法是可移植的,并且在20年后,将来的编译器可以将其编译为在将来的硬件(无论是否为x86)上有效的东西,也许使用新的ISA扩展或自动向量化。15年前的手写x86-64 asm通常不会针对Skylake进行最佳调整。例如,compare&branch宏融合当时还不存在。 对于一个微体系结构而言,目前对于手工组装的最佳解决方案可能不适用于当前和将来的其他CPU。 @johnfound答案的注释讨论了AMD Bulldozer和Intel Haswell之间的主要区别,这些区别对该代码有很大影响。但在理论上,g++ -O3 -march=bdver3
而且g++ -O3 -march=skylake
会做正确的事。(或者-march=native
。)或者-mtune=...
只是在不使用其他CPU不支持的指令的情况下进行调整。
我的感觉是,将编译器引导到对您关心的当前CPU有利的asm不会对将来的编译器造成问题。希望它们比当前的编译器在寻找转换代码的方法方面更好,并且可以找到适用于未来CPU的方法。无论如何,将来的x86可能不会对当前的x86产生任何好处,并且将来的编译器在实现C语言中的数据移动之类的东西时,如果看不到更好的东西,它将避免任何特定于asm的陷阱。
手写的asm是优化程序的黑匣子,因此当内联使输入成为编译时常数时,常数传播不起作用。其他优化也会受到影响。使用asm之前,请先阅读https://gcc.gnu.org/wiki/DontUseInlineAsm。(并避免使用MSVC风格的嵌入式asm:输入/输出必须经过内存,这会增加开销。)
在这种情况下:您n
的类型为带符号,并且gcc使用SAR / SHR / ADD序列给出正确的舍入。(对于负输入,IDIV和算术移位“回合”有所不同,请参见SAR insn set ref手动输入)。(IDG如果gcc尝试并未能证明它n
不能为负,或者是什么,则为IDK 。签名溢出是未定义的行为,因此应该可以。)
您应该已经使用过uint64_t n
,因此只能使用SHR。因此,它可移植到long
只有32位的系统(例如x86-64 Windows)。
顺便说一句,gcc的优化的 asm输出看起来不错(使用unsigned long n
):它内联的内部循环可以main()
这样做:
# from gcc5.4 -O3 plus my comments
# edx= count=1
# rax= uint64_t n
.L9: # do{
lea rcx, [rax+1+rax*2] # rcx = 3*n + 1
mov rdi, rax
shr rdi # rdi = n>>1;
test al, 1 # set flags based on n%2 (aka n&1)
mov rax, rcx
cmove rax, rdi # n= (n%2) ? 3*n+1 : n/2;
add edx, 1 # ++count;
cmp rax, 1
jne .L9 #}while(n!=1)
cmp/branch to update max and maxi, and then do the next n
内部循环是无分支的,循环进行的依赖链的关键路径是:
- 3分量LEA(3个周期)
- cmov(在Haswell上运行2个周期,在Broadwell或更高版本上运行1c)。
总计:每个迭代5个周期,延迟瓶颈。乱序执行会与此同时进行其他所有工作(理论上:我尚未使用perf计数器进行测试,以查看它是否真的以5c / iter运行)。
cmov
(由TEST产生)的FLAGS输入比RAX输入(来自LEA-> MOV)产生的速度更快,因此它不在关键路径上。
同样,产生CMOV的RDI输入的MOV-> SHR也偏离了关键路径,因为它也比LEA快。IvyBridge及更高版本上的MOV具有零延迟(在寄存器重命名时处理)。(它仍然需要一个uop和一个管道中的插槽,因此它不是免费的,只是零延迟)。LEA dep链中额外的MOV是其他CPU瓶颈的一部分。
cmp / jne也不是关键路径的一部分:它不是循环携带的,因为控制依赖项是通过分支预测+推测执行来处理的,这与关键路径上的数据依赖项不同。
击败编译器
GCC在这里做得很好。使用inc edx
代替add edx, 1
可以节省一个代码字节,因为没有人关心P4及其对部分标志修改指令的虚假依赖。
它还可以保存所有MOV指令,并且TEST:SHR将CF =设置为移出的位,因此我们可以使用/ cmovc
代替。test
cmovz
### Hand-optimized version of what gcc does
.L9: #do{
lea rcx, [rax+1+rax*2] # rcx = 3*n + 1
shr rax, 1 # n>>=1; CF = n&1 = n%2
cmovc rax, rcx # n= (n&1) ? 3*n+1 : n/2;
inc edx # ++count;
cmp rax, 1
jne .L9 #}while(n!=1)
参见@johnfound的答案,这是另一个巧妙的技巧:通过分支SHR的标志结果以及将其用于CMOV来删除CMP:仅当n为1(或0)时才为零。(有趣的事实:如果您读取标志结果,Nehalem上计数为!= 1的SHR会导致停顿。这就是它们使它成为单循环的方式。不过,采用shift by by特殊编码还是可以的。)
避免使用MOV根本无法解决Haswell上的延迟问题(x86的MOV真的可以“免费”吗?为什么我根本无法重现此内容?)。它对诸如MOV不是零延迟的Intel pre-IvB和AMD Bulldozer系列的CPU 确实有很大帮助。编译器浪费的MOV指令确实会影响关键路径。BD的complex-LEA和CMOV都具有较低的延迟(分别为2c和1c),因此占延迟的比例较大。同样,吞吐量瓶颈也成为问题,因为它只有两个整数ALU管道。 请参阅@johnfound的答案,其中他具有AMD CPU的计时结果。
即使在Haswell上,此版本也可以通过避免一些偶尔的延迟而有所帮助,在这种情况下,非关键的uop会从关键路径上的某个关键端口窃取执行端口,从而将执行延迟1个周期。(这称为资源冲突)。它还可以保存一个寄存器,这n
在交错循环中并行执行多个值时可能会有所帮助(请参见下文)。
LEA的延迟取决于寻址模式,取决于Intel SnB系列CPU。3c用于3个组件([base+idx+const]
,需要两个单独的加法),但只有1c具有2个或更少的组件(一个加法)。某些CPU(例如Core2)甚至可以在一个周期内完成3组件LEA,但SnB系列却不这样做。更糟糕的是,英特尔SnB系列对延迟进行了标准化,因此没有2c uops,否则3分量LEA就像Bulldozer一样只有2c。(三分量LEA在AMD上也较慢,只是幅度不那么大)。
因此,在像Haswell这样的Intel SnB系列CPU上,lea rcx, [rax + rax*2]
/ inc rcx
延迟仅为2c,比快lea rcx, [rax + rax*2 + 1]
。BD上收支平衡,Core2上更差。它确实需要额外的uop,通常不值得节省1c的延迟,但是延迟是这里的主要瓶颈,Haswell有足够宽的管道来处理额外的uop吞吐量。
gcc,icc或clang(在godbolt上)都没有使用SHR的CF输出,始终使用AND或TEST。愚蠢的编译器。:P它们是复杂机器的重要组成部分,但是聪明的人经常可以在小规模问题上击败它们。(当然,考虑到它要花费数千到数百万倍!编译器不会使用穷举算法来搜索做事的每种可能方式,因为在优化许多内联代码时这将花费很长时间。他们最擅长。他们也没有在目标微体系结构中对管道进行建模,至少与IACA或其他静态分析工具没有相同的细节;它们只是使用了一些启发式方法。)
简单的循环展开将无济于事;此循环瓶颈取决于循环承载的依赖链的延迟,而不是循环开销/吞吐量。这意味着它与超线程(或任何其他类型的SMT)配合得很好,因为CPU有很多时间来交织来自两个线程的指令。这将意味着并行化in中的循环main
,但这很好,因为每个线程都可以检查n
值的范围并产生一对整数。
手工在单个线程中进行交织也是可行的。也许并行计算一对数字的顺序,因为每个数字只需要几个寄存器,它们都可以更新相同的max
/ maxi
。这会产生更多的指令级并行性。
诀窍在于确定是否要等到所有n
值都达到1
后再获取另一对起始值n
,或者是否要突破并仅为达到终止条件的一个获取新的起始点,而不用为另一个序列触摸寄存器。也许最好让每个链都在处理有用的数据,否则您将不得不有条件地增加其计数器。
您甚至可以使用SSE打包比较的东西来执行此操作,以有条件地增加n
尚未到达的矢量元素的计数器1
。然后,要隐藏SIMD条件增量实现的更长等待时间,您需要让更多的n
值向量悬而未决。也许只有256b向量(4x uint64_t
)值得。
我认为,检测1
“粘性” 的最佳策略是屏蔽添加的全1向量以增加计数器。因此,1
在元素中看到a之后,增量矢量将为零,而+ = 0为无操作。
手动矢量化的未经测试的想法
# starting with YMM0 = [ n_d, n_c, n_b, n_a ] (64-bit elements)
# ymm4 = _mm256_set1_epi64x(1): increment vector
# ymm5 = all-zeros: count vector
.inner_loop:
vpaddq ymm1, ymm0, xmm0
vpaddq ymm1, ymm1, xmm0
vpaddq ymm1, ymm1, set1_epi64(1) # ymm1= 3*n + 1. Maybe could do this more efficiently?
vprllq ymm3, ymm0, 63 # shift bit 1 to the sign bit
vpsrlq ymm0, ymm0, 1 # n /= 2
# FP blend between integer insns may cost extra bypass latency, but integer blends don't have 1 bit controlling a whole qword.
vpblendvpd ymm0, ymm0, ymm1, ymm3 # variable blend controlled by the sign bit of each 64-bit element. I might have the source operands backwards, I always have to look this up.
# ymm0 = updated n in each element.
vpcmpeqq ymm1, ymm0, set1_epi64(1)
vpandn ymm4, ymm1, ymm4 # zero out elements of ymm4 where the compare was true
vpaddq ymm5, ymm5, ymm4 # count++ in elements where n has never been == 1
vptest ymm4, ymm4
jnz .inner_loop
# Fall through when all the n values have reached 1 at some point, and our increment vector is all-zero
vextracti128 ymm0, ymm5, 1
vpmaxq .... crap this doesn't exist
# Actually just delay doing a horizontal max until the very very end. But you need some way to record max and maxi.
您可以并且应该使用内部函数而不是手写的asm来实现。
算法/实现改进:
除了仅以更有效的asm实现相同的逻辑外,还寻求简化逻辑或避免多余工作的方法。例如记住以检测序列的共同结尾。甚至更好,一次查看8个尾随位(咬咬人的答案)
@EOF指出tzcnt
(或bsf
)可用于n/=2
一步完成多次迭代。这可能比SIMD向量化更好。没有SSE或AVX指令可以做到这一点。但是,它仍然兼容n
在不同的整数寄存器中并行执行多个标量。
因此循环看起来像这样:
goto loop_entry; // C++ structured like the asm, for illustration only
do {
n = n*3 + 1;
loop_entry:
shift = _tzcnt_u64(n);
n >>= shift;
count += shift;
} while(n != 1);
这样可以减少迭代次数,但是在没有BMI2的Intel SnB系列CPU上,可变计数移位很慢。3 oups,2c延迟。(它们对FLAGS具有输入依赖性,因为count = 0表示未修改标志。它们将其作为数据依赖性进行处理,并且由于uop只能具有2个输入(无论如何,HSU / BDW仍然如此),因此需要多个uops)。抱怨x86疯狂的CISC设计的人们指的是这种类型。它使x86 CPU的速度比如果今天从头开始设计ISA的速度要慢,即使是采用类似的方式也是如此。(即,这是“ x86税”的一部分,它消耗了速度/功率。)SHRX / SHLX / SARX(BMI2)是一个巨大的胜利(1 uop / 1c延迟)。
它还将tzcnt(在Haswell和更高版本上为3c)放在关键路径上,因此,它显着延长了循环依赖链的总延迟。但是,它确实消除了对CMOV或准备寄存器持有的任何需要n>>1
。@Veedrac的答案通过将tzcnt / shift推迟多次迭代来克服了所有这些问题,这非常有效(请参见下文)。
我们可以安全地互换使用BSF或TZCNT,因为n
在那个时候永远不能为零。TZCNT的机器代码在不支持BMI1的CPU上解码为BSF。(无意义的前缀将被忽略,因此REP BSF作为BSF运行)。
在支持它的AMD CPU上,TZCNT的性能要比BSF好得多,因此REP BSF
即使您不关心将输入设置为零而不是设置ZF的情况,使用CNT也是一个好主意。当__builtin_ctzll
甚至使用时,某些编译器也会这样做-mno-bmi
。
它们在Intel CPU上执行相同的操作,因此只要保留字节就可以了。就像BSF一样,Intel(Skylake之前)上的TZCNT仍然对所谓的只写输出操作数具有虚假依赖关系,以支持输入为0的BSF使其目标保持不变的无证行为。因此,除非您仅针对Skylake进行优化,否则您需要解决此问题,因此额外的REP字节没有任何好处。(英特尔经常超越x86 ISA手册的要求,以避免破坏依赖于不应使用的东西或被追溯禁止的广泛使用的代码。例如Windows 9x假定TLC条目没有任何推测性的预取,这是安全的在编写代码时,在Intel更新TLB管理规则之前。)
无论如何,Haswell上的LZCNT / TZCNT具有与POPCNT相同的错误深度:请参阅此问答。这就是为什么在@Veedrac的代码的gcc的asm输出中,您会看到它在不使用dst = src的情况下将用作TZCNT的寄存器的xor- zeroing 破坏了dep链。由于TZCNT / LZCNT / POPCNT永远不会使目标保持未定义或未修改的状态,因此对Intel CPU输出的这种虚假依赖性是性能错误/限制。大概值得一些晶体管/电源使它们表现得像进入同一执行单元的其他微控制器一样。性能的唯一缺点是与另一个uarch限制的交互:它们可以将带有索引寻址模式的内存操作数微融合 在Haswell上,但是在Skylake上,Intel删除了LZCNT / TZCNT的伪装,它们“取消分层”索引寻址模式,而POPCNT仍然可以微熔合任何加法器模式。
其他答案对想法/代码的改进:
@hidefromkgb的答案很好地说明了您可以保证在3n + 1之后能够右移。您可以比仅省去步骤之间的检查来更有效地进行计算。但是,该答案中的asm实现被破坏了(它取决于OF,在SHRD后计数> 1时未定义),并且slow:ROR rdi,2
快于SHRD rdi,rdi,2
,并且在关键路径上使用两个CMOV指令比进行额外的TEST慢可以并行运行。
我将经过整理/改进的C(引导编译器生成更好的asm),并在Godbolt上测试并运行了更快的asm(在C下方的注释中):请参见@hidefromkgb的答案中的链接。(此答案从大型Godbolt URL达到了3万个字符限制,但是短链接可能会腐烂,而且对于goo.gl来说太长了。)
还改进了输出打印以将其转换为字符串并生成一个字符串,write()
而不是一次写入一个字符。这样可以最大程度地减少对整个程序的计时perf stat ./collatz
(记录性能计数器)的影响,并且我消除了一些非关键组件的混淆。
@Veedrac的代码
我有一个小加速从右移一样,我们知道需要做什么,并检查继续循环。在Core2Duo(Merom)上,限制为1e8的7.5s降至7.275s,展开系数为16。
代码+ 关于Godbolt的注释。不要将此版本与clang一起使用;它对延迟循环做一些愚蠢的事情。使用tmp计数器k
,然后将其添加到count
以后会更改clang的功能,但这会稍微损害gcc。
查看评论中的讨论:Veedrac的代码在具有BMI1的CPU(即非Celeron / Pentium)上非常出色。