我为Euler Q14编写了这两个解决方案,在汇编和C++中。它们是用于测试collatz猜想的相同的蛮力方法。组装溶液与
1
| nasm -felf64 p14.asm && gcc p14.o -o p14 |
用C++编译
装配,p14.asm。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| section .data
fmt db"%d", 10, 0
global main
extern printf
section .text
main:
mov rcx, 1000000
xor rdi, rdi ; max i
xor rsi, rsi ; i
l1:
dec rcx
xor r10, r10 ; count
mov rax, rcx
l2:
test rax, 1
jpe even
mov rbx, 3
mul rbx
inc rax
jmp c1
even:
mov rbx, 2
xor rdx, rdx
div rbx
c1:
inc 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
call printf
ret |
c++,p14-CPP
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| #include <iostream>
using namespace std;
int sequence(long n) {
int count = 1;
while (n != 1) {
if (n % 2 == 0)
n /= 2;
else
n = n*3 + 1;
++count;
}
return count;
}
int main() {
int max = 0, maxi;
for (int i = 999999; i > 0; --i) {
int s = sequence(i);
if (s > max) {
max = s;
maxi = i;
}
}
cout << maxi << endl;
} |
我知道编译器的优化可以提高速度和所有东西,但是我看不到进一步优化我的汇编解决方案的很多方法(用编程的方式而不是用数学的方式)。
C++代码每一项都具有模数,每个偶数项都是除法,其中汇编只是每一个项的一个除法。
但是这个程序比C++解决方案要长1秒。为什么会这样?我问这个问题主要是出于好奇。
执行时间
我的系统:64位Linux?1.4 GHz Intel Celeron 2955U(Haswell微体系结构)。
g++(未优化):平均1272 ms
g++ -O3平均578 ms
原始ASM(DIV)平均2650 ms
Asm (shr)平均679ms
@johnfound asm,装配NASM平均501 ms
@HideFromKGB ASM平均200毫秒
@hidefromkgb asm由@peter cordes优化,平均145 ms
VEEDRAC C++AVG 81 ms,EDCX1为4,EDCX1为5 ms,为305 ms。
- 您检查过GCC为C++程序生成的汇编代码吗?
- 使用-S编译以获得编译器生成的程序集。编译器足够聪明,可以意识到模数同时进行除法。
- 我认为你的选择是1。你的测量技术有缺陷,2.编译器可以编写比您更好的程序集,或者3。编译器使用魔法。
- 使用交织的C代码+行号生成ASM并比较注释。
- 我看了一下组件输出。我对汇编编程不太了解,我只是为了好玩而解决了一些ProjectEuler问题。几百行很难跟上。从逻辑上讲,编译器能在多大程度上提高蛮力?不设置优化标志
- @杰斐逊编译器可以使用更快的暴力。例如,可以使用SSE指令。
- 您的装配程序仍然可以通过循环展开进行优化。
- @KH40TIK:不容易;串行依赖性是瓶颈。为了从展开中获得任何好处,您必须同时交错多个对sequence的调用。(不过,如果你真的是这么快的核心,这是可能的,也是个好主意!查看我对答案的最后更新。)
- @immibis:您可以与sse或avx2并行执行多个n值,但这很复杂,我不希望编译器自己解决这个问题。(请参阅我的最新答案,了解可能正确的手矢量化方法:p)
- 但是,这段代码永远不会停止(因为整数溢出)!?!
- 您可以将代码输入gcc.godbolt.org,以查看结果程序集,以查看编译器对您的代码做了什么。(实际上,在多个编译器上)
- @Yves daoust的序列种子在一百万以下,它将溢出32位而不是64位。程序集正在使用64位寄存器
- @杰斐逊:最后一次,C代码不会停止,不管它是为32或64位编译的(这与数据类型是int没有区别)。
- "YVISDAUDST"C++代码使用ECOCX1×3对CelATZ迭代。在long是64位类型的平台上,它不会溢出。如果您使用的是Windows,那么无论您使用的是32位还是64位操作系统,long都是32位的。然后制作(unsigned) long long型。
- @伊夫·道斯特哦,我看到了混乱。首先,我使用的是long而不是int,我相信在64位系统上使用g++long的宽度是64位。或者编译器用无符号in t替换它。我用无符号in t进行了测试,它不会在32位无符号in t中溢出。
- @丹尼尔菲舍尔:既不适用于长窗口(32位或64位窗口)。顺便说一下,没有理由用无符号长整型来替换长整型。
- @杰斐逊计算的无符号类型可以通过定义而不是溢出,但对于某些起始值(我不记得是哪个),序列会离开(无符号)32位范围。如果起始值低于100万,它永远不会变为零,因此它将终止。你得到了最长链子的正确起始值。但是用无符号32位整数计算的各种序列长度是错误的。
- @那么你的编译器就坏了。或者您可能没有正确地重新编译并运行错误的二进制文件。当EDCOX1×3是64位时,C++代码是正确的。
- @丹尼尔·费舍尔啊,好吧,这是有道理的。因为我刚刚用无符号int替换了long,它得到了相同的结果。但是使用有符号int永远不会结束。
- @Jefferson是的,有符号32位整数,如果溢出导致换行(这是常见的,但它是ub),则会得到负数,其中一些以循环结束,如-1 -> -2 -> -1。
- @杰斐逊,你能为我的版本增加一个度量吗?
- 简短的回答:C++在汇编方面很好。
- 并非所有ASM指令的创建都相同。您可以看到一条指令,但它可能需要几个周期,因此它将慢于每一个周期使用一条指令的两条不同指令。优化编译器知道哪些指令很慢,而不是。这就是为什么我们将优化降级为编译器。
- 你的手写汇编foo不够强大。
- @Yvesdaoust:你说"没有理由用unsigned long long取代long"是什么意思?这在两种情况下肯定更好:1)windows,其中long只有32位,并且可能溢出。(x86-64系统V中的long为64位)。2)所有为已签名的n /= 2生成比未签名代码慢的编译器。(算术右移向无穷大取整,但C语义要求像IDIV指令一样向零取整。)因此,当n被签名时,编译器必须发出一些额外的指令,因为我猜它们无法证明n永远不会变为负数。
- 30年前,优秀的程序集优化能力是超越优化编译器的必要条件。今天,汇编更复杂,编译器更好,而且在某种程度上,CPU甚至被设计成擅长编译器容易输出的东西——这意味着你通常必须非常,非常好才能获胜。
- @彼得:你误解了我的意图,这句话是关于签名的。原始代码有一个长的,而不是无符号长的,因此建议的"修复"是不合逻辑的。我最初想引起大家的注意的是,代码,作为出版,没有工作,这似乎没有人相信。
- @Yvesdaoust:您认为发布的代码不可移植是正确的。它假设long是64位类型,标准不保证(Windows上不是这样)。long long工作,因为它保证至少64位。unsigned long long显然也可以工作,并且至少比2的补码hw上的有符号类型运行得更快。根据@daniel的说法,uint32_t将针对每个起始值终止,但这并不能正确测试collatz猜想,因为它终止得太早。
- @Hurkyl:请注意,OP正在将手写的asm与g++的默认-O0("未优化")进行比较。这通常很容易被击败,尤其是在代码延迟的情况下,因为在每个C++语句(或源代码行)之后额外的存储/重载往返。不过,它仍然可以用移位或模乘反相来除法常数。和模的二次幂与位和,当然。G++没有一个选项可以让它发出缓慢的除法代码,但有趣的是,clang-o0甚至对n/2也做了幼稚的除法。godbolt.org/g/isyjuj公司
- NITPoX:您的代码不是真正的C++,除了EDCOX1,13和EDCOX1,14,它是直C。
- "OALS,我想了几分钟,我不明白代码是如何变得更像C++的。因为没有容器,所以不能用基于范围的循环替换for循环。我知道江户十一〔十五〕里没有什么可以取代任何东西的。我看不到任何模板的用途…如果你把1-999999中的所有整数都放进一个容器中,然后用一个lambda(当前函数体)的std::transform循环它们的范围…但这在很多方面都令人震惊。
- @神秘的索引迭代器取代了循环。向后转。现在找到一个最大值的collatz迭代器,++进行迭代,==检查值的等价性;然后我们可以通过std::distance计算从collatz(n)到collatz(1)的范围长度。
- @哎呀,天哪…好吧。我知道这是怎么回事。这绝对比我的主意好。但我不认为我能说它比当前的代码更可读。
- @ MyStuple我同意它实际上不需要使用C++特性;我主要是表达了我对每个人都把它称为C++的惊讶。
- @Petercordes just to prevent a possible misunderstanding:using an unsigned 32-bit type,the code terminates for all starting values below one million.你进入了一个无限的开始值(1ull << 32 - 1) / 3,并且可能随时准备一些小开始值。但小的起始值比百万大。
- @Yvesdaoust nobody(well,hopefully)didn't believe the code doesn't work on platforms where longis 32 bits wide.但是,你还说,当long long被使用时,它不起作用,而且自从我们看着百万美元以下的起始值以来,任何执行都没有发生。从一百万以下的任何开始值中得出的最大值是EDOCX1[…]3,这比EDOCX1[…]4低。
- @Danielfischer:告诉你整个故事,在INTS中有一段很长的混杂,我没有升级。
- @oals:如果你把它编译成C++,它是C++。所有的东西在C++标准应用中,没有在C标准应用中。他们不得不说,语言特征中最重要的是这一代码,但这基本上是相关的。当C-like loops是表达积分算法的最佳方式时,C+letts you do that.但是,如果你想知道"什么是最小尺寸的long或EDOCX1〕〔2〕","举例来说,为了便携性原因,这将是一个错误,看标准C(或关于的一个如此的问题),而不检查C+中的相同。
- @Oals apart from the fact it's not all wrapped up in a class,most of that code is pure Java
- 与一个简单的EDOCX1相比,2比1真的很慢。在COVERS by non-powers-of-2(也译成idiv)之下。
- 说吧,如果你不控制过度流动,你就无法测试假设的崩溃。
如果您认为64位DIV指令是一种很好的方法将其除以2,那么难怪编译器的ASM输出会击败您的手写代码,即使使用-O0(快速编译,没有额外的优化,并且在每个C语句之后/之前存储/重新加载到内存中,以便调试器可以修改变量)。好的。
请参阅Agner Fog的优化装配指南,了解如何编写高效的ASM。他还拥有指令表和微搜索指南,以了解特定CPU的具体细节。有关更多性能链接,请参见x86标记wiki。好的。
还看到这个关于用手工ASM击败编译器的更一般的问题:内联汇编语言比本地C++代码慢吗?.tl:dr:是的,如果你做错了(比如这个问题)。好的。
通常情况下,你可以让编译器做它的事情,特别是如果你尝试编写C++,可以有效编译。另请看汇编语言是否比编译语言更快?.其中一个答案链接到这些整洁的幻灯片,展示了各种C编译器是如何用很酷的技巧优化一些真正简单的函数的。好的。
1 2 3 4
| even:
mov rbx, 2
xor rdx, rdx
div rbx |
在Intel Haswell上,div r64是36 Uops,延迟为32-96个周期,吞吐量为21-74个周期。(加上2个UOP来设置RBX和零RDX,但是无序执行可以提前运行它们)。像DIV这样的高UOP计数指令是微编码的,这也会导致前端瓶颈。在这种情况下,延迟是最相关的因素,因为它是循环携带依赖链的一部分。好的。
shr rax, 1执行相同的无符号除法:它是1uop,有1c的延迟,每个时钟周期可以运行2次。好的。
相比之下,32位除法更快,但与移位相比仍然很糟糕。idiv r32是9个uops,22-29c延迟,在haswell上每8-11c吞吐量一个。好的。
从GCC的-O0asm输出(godbolt编译器资源管理器)中可以看到,它只使用移位指令。clang -O0的确像您想象的那样简单地编译,即使使用64位IDIV两次。(在优化时,如果源代码使用相同的操作数进行除法和取模,则编译器会同时使用IDIV的两个输出,如果它们完全使用IDIV的话)好的。
GCC没有一个完全幼稚的模式;它总是通过gimple进行转换,这意味着一些"优化"不能被禁用。这包括通过常数识别除法,并使用移位(2的幂)或定点乘法逆(非2的幂)来避免IDIV(见上述Godbolt链接中的div_by_13)。好的。
gcc -Os(针对大小进行优化)确实使用IDIV进行非2次幂次除法,不幸的是,即使在乘法逆码只是稍微大一点,但要快得多的情况下。好的。帮助编译器
(本例小结:用uint64_t n)好的。
首先,只关注优化的编译器输出。(-O3号)。-O0速度基本上没有意义。好的。
查看您的ASM输出(在Godbolt上,或查看如何从GCC/Clang组件输出中消除"噪音")。当编译器最初不做最佳代码时,用一种指导编译器编写更好代码的方式编写C/C++源通常是最好的方法。你必须了解ASM,知道什么是有效的,但是你要间接地应用这些知识。编译器也是一个很好的思想来源:有时clang会做一些很酷的事情,你可以让gcc做同样的事情:看看这个答案,以及我在@veedrac的下面代码中对非展开循环做了什么。)好的。
这种方法是可移植的,在未来的20年里,一些编译器可以将其编译为未来硬件(x86或非x86)上的任何高效工具,可以使用新的ISA扩展或自动矢量化。15年前手写的x86-64ASM通常不会针对Skylake进行优化。例如,比较和分支宏融合在当时并不存在。对于手工制作的ASM,对于一个微体系结构来说,现在最理想的可能不是其他当前和未来CPU的最佳选择。关于@johnfound答案的评论讨论了AMD推土机和Intel Haswell之间的主要区别,这对代码有很大影响。但是理论上,g++ -O3 -march=bdver3和g++ -O3 -march=skylake会做正确的事情。(或-march=native或-mtune=...进行调整,而不使用其他CPU可能不支持的指令。好的。
我的感觉是,将编译器引导到适合您所关心的当前CPU的ASM,对于未来的编译器来说不应该是一个问题。他们希望在寻找转换代码的方法方面比当前的编译器更好,并且能够找到一种适合未来CPU的方法。无论如何,未来的x86可能不会对当前的x86有任何好处,而且未来的编译器将避免任何ASM特定的陷阱,同时实现一些类似于从C源代码中移动数据的功能(如果它看不到更好的功能)。好的。
手工编写的ASM是优化器的黑盒,因此当内联使输入成为编译时常量时,常量传播不起作用。其他优化也会受到影响。在使用ASM之前,请阅读https://gcc.gnu.org/wiki/dontuseinlineasm。(并避免使用MSVC风格的内联ASM:输入/输出必须经过内存,这会增加开销。)好的。
在这种情况下:您的n有一个带符号的类型,gcc使用sar/shr/add序列来给出正确的舍入。(对于负输入,IDIV和算术移位"round"不同,请参见sar insn set ref手动输入)。(IDK,如果GCC试图并且未能证明n不能为负数,或什么。有符号溢出是未定义的行为,因此它应该能够。)好的。
你应该使用uint64_t n,这样它就可以SHR了。因此它可以移植到long只有32位(如x86-64 Windows)的系统中。好的。
顺便说一句,GCC优化的ASM输出看起来相当不错(使用unsigned long n):它引入main()的内部循环执行以下操作:好的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| # 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 |
内部循环是无分支的,循环携带的依赖链的关键路径是:好的。
- 三组分LEA(3个循环)
- CMOV(哈斯韦尔上2个周期,布罗德韦尔或更高版本上1个周期)。
总共:每次迭代5个周期,延迟瓶颈。无序的执行处理所有与此并行的事情(理论上:我还没有用性能计数器来测试它是否真的以5C/ITER的速度运行)。好的。
cmov的标志输入(由测试生成)比rax输入(从lea->mov)生成更快,因此它不在关键路径上。好的。
同样,产生cmov的rdi输入的mov->shr偏离了关键路径,因为它也比lea快。ivybridge和更高版本上的mov具有零延迟(在寄存器重命名时处理)。(它仍然需要一个UOP和管道中的一个槽,所以它不是空闲的,只是零延迟)。LEADP链中的额外MOV是其他CPU瓶颈的一部分。好的。
CMP/JNE也不是关键路径的一部分:它不是循环执行的,因为控制依赖项是通过分支预测+推测性执行来处理的,与关键路径上的数据依赖项不同。好的。击败编译器
海湾合作委员会在这里做得很好。它可以通过使用inc edx而不是add edx, 1来保存一个代码字节,因为没有人关心p4及其对部分标志修改指令的错误依赖性。好的。
它还可以保存所有MOV指令和测试:shr设置cf=位移出,所以我们可以使用cmovc,而不是test/cmovz。好的。
1 2 3 4 5 6 7 8
| ### 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的标志结果上进行分支来移除cmp,并且仅当n是1(或0)时才将其用于cmov:zero。(有趣的事实:SHR和Count!如果读取标志结果,nehalem或更早版本上的=1将导致暂停。这就是他们使它成为单一UOP的方式。不过,shift-by-1特殊编码是可以的。)好的。
避免mov对haswell的延迟毫无帮助(x86的mov真的是"免费"的吗?为什么我不能复制这个?在诸如Intel Pre-IVB和AMD推土机系列(MOV不是零延迟)这样的CPU上,它确实有很大帮助。编译器浪费的MOV指令确实会影响关键路径。bd的复杂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系列没有。更糟糕的是,Intel snb系列标准化了延迟,因此没有2c Uops,否则3组件lea只会像推土机一样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输出,总是使用和或测试。愚蠢的编辑。:p它们是复杂机械的伟大组成部分,但一个聪明的人往往可以在小规模问题上击败它们。(当然,给了数千到数百万次的思考时间!编译器不使用详尽的算法来搜索每一种可能的方法来做事情,因为在优化大量的内联代码时,这将花费太长的时间,而这正是它们最擅长的。它们也不在目标微体系结构中建模管道,至少与IACA或其他静态分析工具的细节不同;它们只是使用一些启发式方法。)好的。
简单的循环展开不会有帮助;这种循环瓶颈在于循环所承载的依赖链的延迟,而不是循环开销/吞吐量。这意味着它可以很好地处理超线程(或任何其他类型的SMT),因为CPU有很多时间来交错来自两个线程的指令。这意味着在main中并行循环,但这很好,因为每个线程都可以检查n值的范围并生成一对整数。好的。
用手在一根线内穿插也是可行的。也许可以并行计算一对数字的序列,因为每个数字只取几个寄存器,它们都可以更新相同的max/maxi。这将创建更多的指令级并行。好的。
诀窍是决定是否等到所有的n值都达到1,然后再得到另一对开始的n值,或者是否只为一个达到结束条件的值中断并获得新的开始点,而不接触另一个序列的寄存器。也许最好让每个链处理有用的数据,否则必须有条件地增加其计数器。好的。
你甚至可以用SSE压缩的比较工具来做这个,在n还没有到达1的情况下,有条件地增加向量元素的计数器。然后为了隐藏simd条件增量实现的更长的延迟,您需要将更多的n值向量保持在空中。可能只值256b矢量(4x uint64_t)。好的。
我认为,检测1的"粘性"最好的策略是掩盖所有增加计数器的向量。因此,在元素中看到1之后,增量向量将有一个零,并且+=0是一个no-op。好的。人工矢量化的未经验证的思想
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| # 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
# There may be a better way to do this blend, avoiding the bypass delay for an FP blend between integer insns, not sure. Probably worth it
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个尾随位(gnaser的答案)好的。
@eof指出,tzcnt或bsf可用于一步进行多个n/=2迭代。这可能比SIMD矢量化更好,因为没有SSE或AVX指令可以做到这一点。不过,它仍然兼容在不同的整数寄存器中并行执行多个标量n。好的。
所以这个循环可能是这样的:好的。
1 2 3 4 5 6 7 8
| 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个Uops,2c延迟。(它们对标志具有输入依赖关系,因为count=0表示标志未被修改。它们将此作为数据依赖项处理,并接受多个UOP,因为一个UOP只能有两个输入(无论如何都是HSW/BDW之前的输入)。这就是人们抱怨x86疯狂的cisc设计所指的类型。它使得x86 CPU的速度比如果ISA今天从头开始设计的话要慢,即使是以类似的方式。(也就是说,这是"x86税"的一部分,它需要速度/功率。)shrx/shlx/sarx(bmi2)是一个大赢家(1uop/1c延迟)。好的。
它还将TZCNT(3c放在Haswell和更高版本)放在关键路径上,因此显著延长了循环携带依赖链的总延迟。不过,它确实消除了任何对CMOV的需要,或者准备一个保存n>>1的寄存器的需要。@Veedrac的答案通过将TZCNT/SHIFT延迟多次迭代来克服所有这些问题,这是非常有效的(见下文)。好的。
我们可以安全地交替使用BSF或TZCNT,因为n在这一点上永远不能为零。TZCNT的机器代码在不支持bmi1的CPU上解码为bsf。(忽略无意义的前缀,因此rep bsf作为bsf运行)。好的。
TZCNT在支持它的AMD CPU上的性能比BSF要好得多,因此使用REP BSF是一个好主意,即使在输入为零而不是输出时您不关心设置ZF。有些编译器在使用__builtin_ctzll时甚至使用-mno-bmi时也会这样做。好的。
它们在Intel CPU上执行相同的操作,因此,如果这是所有重要的,只需保存字节即可。Intel(pre-Skylake)上的TZCNT仍然对假定为只写的输出操作数(与BSF一样)存在错误依赖,以支持输入为0的BSF未修改其目标的未记录行为。所以您需要解决这个问题,除非只针对Skylake进行优化,所以没有什么可以从额外的rep字节中获得的。(英特尔经常超越x86 ISA手册的要求,以避免破坏广泛使用的代码,这些代码依赖于它不应该使用的代码,或者追溯性地不允许使用的代码。例如,在Intel更新TLB管理规则之前,Windows 9x不假设对TLB条目进行推测性预取,这在编写代码时是安全的。)好的。
不管怎样,haswell上的lzcnt/tzcnt与popcnt具有相同的假dep:请参阅此问题和解答。这就是为什么在gcc针对@veedrac代码的asm输出中,当不使用dst=src时,它会在将要用作tzcnt目标的寄存器上用xor零打破dep链的原因。由于TZCNT/LZCNT/PopCNT从不保留未定义或未修改的目标,因此对英特尔CPU输出的这种错误依赖纯粹是一种性能缺陷/限制。假设一些晶体管/电源的运行方式和其他UOP一样,进入同一执行单元是值得的。唯一可见的优点是与另一个微体系结构限制的交互:他们可以在Haswell上用索引寻址模式微融合一个内存操作数,但是在Skylake上,Intel消除了对lzcnt/tzcnt的错误依赖,他们"取消分层"索引寻址模式,而Popcnt仍然可以微融合任何addr模式。好的。其他答案对想法/代码的改进:
@HidefromKGB的答案有一个很好的观察,你可以保证在3N+1之后做一个正确的轮班。您可以更有效地计算这一点,而不仅仅是忽略步骤之间的检查。不过,该答案中的ASM实现是中断的(取决于,SHRD后未定义,计数>1),并且速度较慢:ROR rdi,2比SHRD rdi,rdi,2快,并且在关键路径上使用两个cmov指令比可以并行运行的额外测试慢。好的。
我把整理过的/改进过的C(它指导编译器生成更好的ASM),并在godbolt上测试了+工作更快的ASM(在C下面的注释中):参见@hidefromkgb答案中的链接。(这个答案达到了大型godbolt URL的30K字符限制,但是短链接可能会腐烂,对goo.gl来说太长了。)好的。
还改进了输出打印,将其转换为字符串并生成一个write(),而不是一次写入一个字符。这将减少对使用perf stat ./collatz对整个程序计时的影响(以记录性能计数器),并且我消除了一些非关键ASM的模糊。好的。
韦德拉克密码好的。
我有一个非常小的加速从右移,我们知道需要做的,并检查继续循环。在core2duo(merom)上,从极限值为1E8的7.5s降至7.275s,展开系数为16。好的。
关于Godbolt的代码+注释。不要在clang中使用这个版本;它在defer循环中做了一些愚蠢的事情。使用TMP计数器k,然后将其添加到count中,之后改变了clang的功能,但这对GCC有轻微的伤害。好的。
请参见评论中的讨论:Veedrac的代码在具有bmi1的CPU上非常出色(即,不是赛扬/奔腾)好的。好啊。
- @杰斐逊:刚刚注意到GCC的-O3循环毕竟不是最佳的。我可能没有保存任何延迟,除了通过避免偶尔的资源冲突,但保存了几个指令。
- 我在一段时间前就尝试过矢量化方法,但它没有帮助(因为使用tzcnt可以在标量代码中做得更好,而且在矢量化的情况下,您被锁定在向量元素中运行时间最长的序列中)。
- EOF:TZCNT?哦,对了,一步完成所有的n/2迭代!我甚至没有在寻找算法快捷方式;很好的一点。是的,像这样的东西或曼德布洛特的单独终止问题是一个问题。您可以检测何时完成一个元素,并替换它,但这是一个额外的逻辑,以及额外的循环中断。然而,当平均序列长度足够高时,这可能是值得的。(嗯,即使是AVX512CD也只有一个矢量化LZCNT,而不是TZCNT,对吗?所以PROB。与标量TZCNT版本相比不太有用。)
- @彼得卡兹:我不认为屏蔽部分向量会有帮助,因为最长的元素仍然需要完成(除非你预先知道它是哪个,并在上面使用了更快的标量方法)。这里的重点是矢量化并不能让你加速你的向量宽度,但只是它的一小部分,而且算法更糟糕,特别是当你使用记忆化和哈斯韦尔的散射/聚集时。
- @eof:不,我的意思是当任何一个向量元素击中1时,而不是当它们都有(很容易用pcmpeq/pmovmsk检测到)时,从内部循环中断。然后,使用pinsrq和其他东西来处理终止的一个元素(及其计数器),并跳回到循环中。当你太频繁地从内部循环中脱离时,这很容易变成一种损失,但这确实意味着你总是要在内部循环的每一次迭代中完成2到4个有用的工作元素。不过,记忆化很有意义。
- @彼得卡德啊,我没考虑过。不过,这听起来很难实施。另外,为了测试元素的all/any是否完成,我会在pmovmsk之前尝试ptest,如果您小心的话,它在一个指令中同时执行all和any。(哈斯韦尔的延迟时间也少了一个时钟)。
- @eof:hmm,即使使用@johnfound的想法来测试n==1,通过寻找n>>1 == 0,我认为你也不能避免使用pcmpeqq。此外,标量代码需要使用掩码来知道哪个元素符合终止条件。经验法则:ptest不是在pcmp掩码上进行分支的最佳方法。包括JCC在内的2+1个Uops。(也有2+1c延迟)。movmskpd+融合测试+jcc为1+1 Uop,2+1c延迟。(movmskpd值得从整数比较中绕过延迟,否则我们需要pext或其他东西,因为我们需要64位元素而不是pmovmskb 8位元素)。
- 像往常一样,回答得很好。几个小时前,我在"热门问题"中看到了这个标题,并开始写一个规范的答案。你已经这样做了,节省了我很多时间。:-)我有点困惑,虽然你的声明,你可以缩短依赖链分裂成lea+inc。鉴于我对依赖链的理解,事实上,lea的目的地是inc的来源,这意味着这将不会影响其长度。我是否误解了这里的一些内容,或者您的意思是只要这些insn不背靠背,这会缩短依赖链?
- 另外,就分析编译器输出而言,我认为这是一段相当有趣的代码片段。与Clang相比,这是GCC似乎能产生远远超过最佳代码的罕见案例之一,Clang最近(至少从我所见)在优化方面击败了GCC。(它的代码通常更为优化,而且几乎从不劣等。)显然,ICC会发出质量代码,但这是意料之中的。MSVC的输出也很有趣,主要是因为它很糟糕。内环充满了跳跃。我没有测试它,但我的鼻子告诉我不要期望太多。
- @Codygray:对于unsigned long n,gc6.2和clang3.9与-O3的asm非常相似。Clang少用了一个mov。你说的是OP的原始代码,clang-o3使用条件分支跳过签名的/= 2,而shr/add/sar就是这样做的?
- @codygray:不同的lea指令有不同的延迟。两种成分的LEA为1c延迟。LEA有3个组件,需要两个附加([base + idx*2 + disp]个)是3c延迟(不只是2c,因为snb家族的延迟标准化)。它也只在p1上运行。
- 除了1,您的优化解决方案不适用于偶数。您应该在循环之前添加一个tzcnt移位,并将循环从do while更改为while。
- 哦,愚蠢的我。我只是用了你答案顶部的"godbolt"链接,不费吹灰之力去检查是否进行了unsigned修复。当然,一旦您这样做了,gcc和clang确实会发出非常相似的代码。我在看原始代码,希望它是无符号的,并且想知道clang做的所有额外的事情是什么!不幸的是,它不能解决MSVC的问题。虽然它基本上使用相同系列的指令,但其中一半后面跟着一个分支。不知道为什么。它似乎没有重新排序指令以避免跳过它们。
- @凯文兹:很好,更新了C来修复这个问题,同时仍然匹配ASM的布局。(这就是为什么我用了do{}while())
- @科迪格雷:我考虑过添加另一个完整的godbolt链接,但决定只在-o0链接中添加一个注释。把unsigned long n放在代码块前面的黑体部分。我已经注意到,我并没有交流过unsigned long n很重要,但还没有解决。
- 这也解决了问题,但现在您还免费处理了n==1案例,因此您可能会失去对该警告的评论。很抱歉这么吹毛求疵。
- @杰斐逊虽然你不能给金子,但你可以给赏金。
- 不过,不要觉得你需要奖励什么,杰斐逊。写一个清晰的问题,很容易回答,让很多人看到我的答案,这已经帮了我很多。(如果我们深入了解如何进行实际优化,这个问题甚至有一定的深度。)我已经从中获得了一个金牌徽章:)
- @彼得·科德很好,你完全值得。我从阅读你的答案中学到的东西比在阅读组装教程的时候多得多。还阅读了一些关于优化您链接的ASM的PDF。再次感谢您抽出时间
- 这并不重要,但是fwiw通过迭代bit = n & -n; n = (n * 3 + bit) >> 1(n & -n编译为blsi),很容易将_mm_tzcnt移出循环。其余的可以通过偶尔离开循环来降低值来修复。
- @是的,那可能很好。关键路径上的TZCNT+移位可能比它值的延迟更大,因为长时间运行零是罕见的。bmi2使用shrx仅使可变计数移位1个周期(而不是3个周期)。TZCNT/SHIFT不需要任何CMOV,这很好。我一直在玩弄HideFromKGB的想法,Intel Pre-Broadwell有2c延迟CMOV。
- @彼得卡兹,我不确定你是否完全理解了我的建议;它比CMOV快得多。试试这个:godbolt.org/g/8qggom。
- @Veedrac:是的,我还在考虑我刚刚发布的两个步骤的更新。感谢上帝的链接,这就清楚了。我看到你想出了一个方法来决定什么时候可以安全地跳过正确的轮班。是的,那会有很大帮助。(没有办法从bit计算k或其他东西,是吗?gcc的.L11: mov esi, 5; jmp .L2块,对于每一个可能的k,它可以break块似乎是一种浪费,但我想它实际上比在完全展开的循环中增加一个计数器要好。)利用宽寄存器的好方法。
- @彼得卡德的跳跃看起来确实很浪费,但他们似乎没有花费那么多。您只需删除它们来检查它们的成本-您将得到错误的结果,但来自正确的代码路径。我发现差异是百分之几;分支预测器似乎在发挥作用。
- 在我的系统中添加了@hidefromkgb和您优化的版本时间,在145ms内运行。我非常羡慕您的ASM技能。我需要回到CS1,重新学习比特,哈哈。
- @杰斐逊如果你要增加问题的时间,为什么不也增加这个版本呢?
- @当然,你是怎么编译的?gcc -std=c++11 p14.cpp -o p14没有解析调用_tzcnt_u64()。
- @Jefferson添加了-march=native来使用内部函数,假设您的CPU支持tzcnt。(记住在计时之前将limit改回其原始值!)
- @Veedrac是的,我尝试过-march=native,但它仍然没有编译。我想这意味着我的CPU不支持它?
- @杰斐逊,等等,我来做一个不需要的版本。应该还是快一点。
- @我管理的杰斐逊最好的是godbolt.org/g/1n70ib。我希望我能做些更聪明的事情,但似乎不是。
- 哇,用-o3以81毫秒的平均速度跑步。约305,含-O0。
- @veedrac:celeon/pentium(甚至Skylake-celeon)不支持任何vex编码的指令(没有avx,没有bmi1/2)。因为它不支持所有的bmi1,所以它们可能遗漏了甚至不需要vex前缀的bmi1指令(如tzcnt)。即使它支持TZCNT,-march=native也肯定不会启用它,因为CPU没有报告bmi1支持。你最好的选择是BSF,它确实有内在的。当输入为非零时,它与TZCNT相同。(除了它设置不同的标志,而且在AMD上速度较慢。)有内置的CTZ和用于BSF的Intel内置。
- @杰斐逊:用-mbmi1编译会告诉编译器它可以使用TZCNT。即使您的CPU不支持它,它也会以rep bsf的形式运行,当输入为非零时,它会给出相同的答案。但是,这也会告诉编译器它可以使用BLSI,而您的CPU不支持它。可能最简单的测试实际上是lzcnt,因为即使在非零情况下,它也会给出不同于bsr的结果。(但它仍然在CPU上解码为rep-bsr,而CPU无法识别它)。BSF和BSR的dest=undefined on input=0,实际行为是dest=unmodified on intel。
- @杰斐逊:与-O0的时代并不有趣,也不相关。不要费心使用-O0进行计时(除非您实际上正在处理编译器内部或其他东西)。
- @杰斐逊·彼得卡德斯对BSF有很好的评价。这里有一个使用__builtin_ctzll的版本。不过,我想你的时代最受伤害的是缺少一个blsi指令。
- @Veedrac:Haswell上的blsi为1c延迟,但mov+neg+和序列为2c延迟(mov为零延迟)。循环中其余的关键路径是简单的加(1c),因此,它将关键路径延迟从3到4个周期中提高:(这种双策略方法使得实现一个并行交叉两个序列计算的版本变得更加棘手,这将隐藏该延迟。由于延迟瓶颈,如果您将调用sequence和prob的循环并行化,那么它应该是非常友好的超线程。从一个核心上的两个线程接近线性加速。
- @Veedrac:进一步的想法:当检测到进位时,打破延迟移位循环。(可以用C编写代码,让GCC只检查由计算n * 3 + bit的add指令设置的cf。IIRC,通过检查无符号结果是否小于一个输入。)然后我们只需要TZCNT&SHIFT,然后返回延迟移位循环。但是,这需要一个不可预测的分支,而不是完全展开的16个迭代。
- 另一种方法是在延迟的移位循环中保持更长的时间,或者更快地返回:执行总是需要的正确移位,例如每4次迭代右移位4次。对关键路径的影响很小,没有可变分支(除了bit == n退出条件,如果跳过while (n >> maximum_bit_size)检查并跳到专用的返回路径代码,BTW可以更高效地进行,以便更快地返回,因为决定是否运行非延迟循环的分支可以直接跳回到延迟的loo中。而不是跳到n==1支票上。短序列很重要。
- @Veedrac:我们也可以对非延迟循环应用hideFromKGB的2步方法的变体:count += 1 + (n&1); n = (n&1) ? (n>>1) + n + 1 : n>>1;。既然并设置了标志,gcc应该将其整理成好的代码。计数不在关键延迟路径上。此外,GCC将while(n >> maximum_bit_size)编译成shr rdi, 36/jne,而不是用另一个寄存器中的掩码进行测试/jne。宏融合应该减少检测分支预测失误的延迟。也可以展开它的一些迭代,这样分支会占用更多的空间。
- @Veedrac我运行了你的__builtin_ctzll版本,得到了与前一版本几乎相同的时间。我设法用gcc -mbmi -std=c++11 -O3 p14.cpp编译了_tzcnt_版本,但它崩溃了,并打印了Illegal instruction。GCC不承认-MBMI1是一个选项
- @实际上,VReedrac只是让它在没有优化的情况下工作(-o0)。大约280-290毫秒
- @杰斐逊:是的,是埃多克斯1号〔13号〕,不是BMI 1号,我错了。这告诉GCC它可以使用任何bmi1指令,包括blsi,这是CPU不支持的。(对于-o0,它不进行窥视孔优化)。唯一的问题是,它是将TZCNT和LZCNT作为自己的指令运行,还是将它们作为BSF和BSR运行(忽略rep前缀)。它们是bmi1的一部分,但它们的机器代码不使用vex编码。如果您很好奇,可以通过将它们放入.asm并单步执行调试器来尝试测试。(与LZCNT不同,TZCNT与BSF非常微妙,请参阅文档)。
- @溢出时出现的petercordes在主循环中添加了一个分支,这仍然是最昂贵的部分。偶尔换4个是有道理的,但这不会使循环时间长得多,所以不会真正为自己付出代价。额外的n >> maximum_bit_size支票回报并没有给我带来太多麻烦,因为它很容易预测,而且每次通话只会有一次伤害。我还没有发现进一步优化非延迟循环的内部也是有益的,主要是因为它是一个很大程度上很冷的分支。
- @Veedrac:分支不会延长循环所承载依赖链的关键路径延迟。如果它不起作用,我也不会感到惊讶,但是如果它很痛,那应该是因为预测失误。haswell可以宏fuse add/jc(并在一个块中进行两次融合),这样UOP计数就不会改变。(如果你设法以编译器喜欢的方式写C++!不总是一个给定的)。这应该100%用完UOP缓存,但是额外的机器代码大小会降低UOP缓存密度,并可能导致具有较少UOP的行/方式,从而降低前端吞吐量。
- @把当前版本的我的调整改为您的代码(godbolt.org/g/ijt9ee),启用了#if。当完全展开时,clang实际上会产生一些讨厌的代码,因此一个if(k%4 == 3) n>>=4;实际上会改进它。GCC也可以。假设循环分支完美地预测4/1 Taken/NotTaken模式,那么它的开销应该接近于零。我也处理了n>>max_size循环,得到了一些有趣的编译器行为。我不得不把GCC和Clang交给我想要的ASM。不过,有一点不错,那就是天气很冷。我没有检查过它是否跑过。对大N有用。
- @杰斐逊-在一个计算机打败国际象棋大师的世界里,99.9%的开发人员没有任何业务可以编写自己的程序集来"击败编译器"。如果您对二进制表示和CPU体系结构不太了解,不知道移位比除以2快得多,那么您肯定在99.9%的范围内。去做计算机不能为你做的事情(写一个好的队列或其他事情),让编译器做它擅长的事情。
- @彼得卡德,你的调整版本不正确;你需要通过增加计数器来减少班次。如果循环展开,用count += j + shift替换count += k + shift,分支中的j也会增加,则可以免费执行此操作;这会被推到双跳转中,编译结果也会相同。//clang的展开版本肯定比gcc的要差,但我仍然认为它比部分展开的版本快10%。我不知道为什么。有什么想法吗?
- 哦,顺便说一句,我从bmi1中计算了有/没有blsi的延迟:每次迭代2c,每次迭代3c,没有,因为blsi和lea可以并行运行,最后加一个。由于这段代码在延迟方面存在瓶颈,因此bmi1在运行时可以产生50%的差异!此外,我还发现了count+=一个或两个非延迟循环的版本,它编译为n的dep链(skl上的3c)的4c延迟。这与最佳手动调优版本的延迟相同,每次都执行2次。@Veedrac:10%以后再看。更新了我的答案。我注意到同样的计数错误。
- 现在我的答案是30k个字符,这要归功于godbolt链接(goo.gl太长了,不能缩短,所以我甚至不能在评论中发布它们:/)。我认为这意味着我已经完成了,除非我想开始写更多的答案:p不管怎样,我从循环中的展开计数的移位中得到了一个小小的加速(对于merom;我没有哈斯韦尔或skl来测试)。:(。为了让gcc和clang都不造成混乱,我必须使用cpp宏来复制块:/
- @T.E.D.我写程序集并不是为了打败编译器,而是为了好玩。我在ASM中解决的大多数不涉及划分的PE已经比我的C解决方案更快了;我之所以问这个问题,是因为它的简单性和长(蛮力)运行时间。
- @杰斐逊:听到你写这篇文章的动机很有趣。编写能够实际计算某些内容的ASM通常比主要问题是进行正确的系统调用时更有趣(就像你在很多这样的问题中看到的那样,初学者都会陷入用ASM编写整个程序的困境,包括读取输入和打印结果)。但是,对于速度,您是否再次将其与未优化的编译器输出进行比较?如果是这样的话,你可能会发现有趣的是,EDCOX1的0度对于不同的问题加速了C++的速度。这不是一个不变的因素…
- 是的,就像我说的,我从来没有真正关心过速度,但它变得有趣了。通常我的ASM时间介于-O0和-O3之间。我通常把-O0作为基线,如果我的asm代码慢得多,那么我开始想为什么。然而,凭借我的ASM知识,我从来没有想过能打败编译器-O3。我对那些能做到的人很着迷。我很好奇你是在嵌入式系统上工作,还是在ASM中从事与编码相关的其他工作?
- @彼得科在你的编辑中对新代码做了很好的处理;这个内部循环非常极端。在我的笔记本电脑上,每一个时钟周期我们的平均值都比1次迭代要好,这真是太好了:)。
- @veedrac:unroll-by-16几乎与gcc对您的代码所做的完全相同。我只需要手动复制循环体(使用宏),就可以在每16次迭代中执行更多的操作,而不会让编译器搞得一团糟。
- @杰斐逊:不,我没有工作做这些事情。我只是觉得很酷。(不过,我有兴趣从事这项工作。我现在花时间写这样的答案,并开发开源软件)。顺便说一句,当我学习asm时,我主要是通过查看编译器输出来了解它是如何工作的,并以此为起点来查看我是否可以看到不同的工作方式。这绝对是学习好习惯用法的一种方法(偶尔也会对您正在处理的问题进行一些非常巧妙的转换,就像我在上一次更新中对单迭代循环的评论中所描述的那样)
- 顺便说一句,刚刚发布了一个大的更新,它整理了早期的部分,并添加了一个关于指导/手动将编译器保存到所需的ASM输出的部分,而不是在生产代码中包含手工编写的ASM。
- 让我惊讶的是,这些令人难以置信的答案是如此详细的知识。我永远不会知道一种语言或系统达到那种水平,我也不知道如何做到。干得好,先生。
- 我不理解C++,ASM,或者你所说的任何东西,但是很容易找到一个高质量的答案。赞成票:)
- 您如何知道指令的运行速度以及它们的延迟是什么?
- 这个问题现在在ycombinator news.ycombinator.com/item上的第3个趋势?ID=15070244
- @D33TAH:阅读第2段。
- @"彼得科给出了我见过的最详细的答案,"先生解释得很好。
- 这是昨天链接的吗?在最后一天,超过50张赞成票,而这一答案通常每周只有几张。
- @我是通过tweet.com/wycats/status/929392342041182208来这里的。
- @CSCH:谢谢。我很高兴有这么多人从我的文章中得到了一些东西。我很自豪,我认为它很好地解释了一些与这个问题相关的优化基础和具体细节。
- 我对这个答案的深度和清晰度深表敬畏。
- @彼得卡德说,这确实帮助和鼓舞了我。在我写这个问题的时候,我正忙于学习如何重新制作旧的项目欧拉。什么都不知道。不久之后,我就可以为前100个问题编写比我的C解决方案更快的运行程序,甚至是一些新的500级问题。我特别发现问题571在ASM中进行优化非常有趣。我希望我选择这个问题来为lol打开这个问题。尽管问题14的简单性确实帮助了这个问题的发展。
- @杰斐逊:collatz非常容易摸索,非常适合x86的lea指令,以及延迟与吞吐量之间的权衡(两个简单的lea的延迟比snb系列上的一个3组件lea低)。这是一个很好的例子,因为它和算法的变化(延迟的右移,在一个右移烘焙等)可以自由地提出一个新的问题,优化N-超泛数字查找,即使它听起来像一个泛维的杂波爆炸机。
- 传奇般的答案!!
声称C++编译器能产生比熟练的汇编语言程序员更优化的代码是一个非常严重的错误。尤其是在这种情况下。人类总是可以使代码比编译器更好,这种特殊情况很好地说明了这种说法。
您看到的时间差异是因为问题中的程序集代码在内部循环中远不是最佳的。
(以下代码为32位,但可以轻松转换为64位)
例如,序列函数只能优化为5条指令:
1 2 3 4 5 6
| .seq:
inc esi ; counter
lea edx, [3*eax+1] ; edx = 3*n+1
shr eax, 1 ; eax = n/2
cmovc eax, edx ; if CF eax = edx
jnz .seq ; jmp if n<>1 |
整个代码看起来像:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| include"%lib%/freshlib.inc"
@BinaryType console, compact
options.DebugMode = 1
include"%lib%/freshlib.asm"
start:
InitializeAll
mov ecx, 999999
xor edi, edi ; max
xor ebx, ebx ; max i
.main_loop:
xor esi, esi
mov eax, ecx
.seq:
inc esi ; counter
lea edx, [3*eax+1] ; edx = 3*n+1
shr eax, 1 ; eax = n/2
cmovc eax, edx ; if CF eax = edx
jnz .seq ; jmp if n<>1
cmp edi, esi
cmovb edi, esi
cmovb ebx, ecx
dec ecx
jnz .main_loop
OutputValue"Max sequence:", edi, 10, -1
OutputValue"Max index:", ebx, 10, -1
FinalizeAll
stdcall TerminateAll, 0 |
为了编译这段代码,需要freshlib。
在我的测试中,(1和NGSP AMD A4-1200处理器),上述代码比问题的C++代码快大约四倍(当用EDCOX1引用0:430和nms;ms和1900和ms)时,当用EDCOX1或1进制编译C++代码时,两倍以上(430和ms和830和毫秒)。
两个程序的输出是相同的:max sequence=525 on i=837799。
- 啊,真聪明。只有EAX为1(或0)时,SHR才设置ZF。在优化gcc的-O3输出时,我错过了这一点,但我发现了您对内部循环所做的所有其他优化。(但是为什么用LEA代替INC来表示计数器增量?在那一点上击倒旗帜是可以的,并导致除P4(对inc和shr都错误地依赖于旧的旗帜)之外的任何事情的放缓。LEA无法在多个端口上运行,并且可能导致资源冲突,从而更频繁地延迟关键路径。)
- 好吧,在第一条指令中,不是LEA的特殊原因。你说得对,inc至少更具可读性。不过速度没有差别。
- 不幸的是,优化并不能真正帮助处理循环携带的依赖链的延迟。在编译器的-O3输出上获得大加速的唯一方法是并行交叉计算多个n值(见我的答案)。但这是展示如何适当优化的一个很好的第一步。您的时间安排是否与gcc -O0输出相比较?还是MSVC调试版本,或者其他什么?
- 当然,并行计算会使事情变得更快,但我们在这里讨论的是编译器与手工汇编。这就是为什么我实现了相同的非并行算法。
- 我不是指多线程并行,而是指在一组单独的寄存器中同时进行两次外部循环的迭代。在将函数内联到main之后,编译器将允许这样做(尽管当前的编译器可能不会为您这样做)。
- @彼得科德斯,我不知道C/C++,所以用谷歌中的第一个命令编译:g++ -Wall q14.c -o q14:)包括"-O3"使差异更小,但仍然是两倍慢:830MS vs 430MS。
- 你不会看到INC和LEA的速度差异,因为你的AMD CPU是推土机家族的UARCH,对吧?根据Agner Fog的测试,他们在任何一个ex01管道上运行lea,与Intel SNB系列产品包上的Inc.相同,简单lea只在3或4个ALU端口中的两个端口上运行,其中一个端口是复杂lea([3*eax + 1]所需的端口。
- 有趣的是,感谢您选择-O3的时间。是的,这是正确的命令。
- 来自op源代码的编译器输出仍然很糟糕,因为op使用了一个有符号的类型(并且2的有符号除法的C语义不能只用shr实现)。这可能是编译器输出仍在丢失的主要原因。试着把long改成unsigned long,就像我在回答中所做的一样。这就得到了一个非常合理的循环,它应该与我们手工优化的ASM循环具有相同的关键路径长度。
- @PeterOrders-我在速度优化方面不是很好,很少将我的代码优化到最新的速度扩展。所以,你可能是对的,所以我用inc代替lea。
- 实际上,推土机可能会限制编译器输出的吞吐量。它的延迟cmov和3组件lea比haswell(我正在考虑)要低,所以循环携带的dep链在代码中只有3个周期。对于整数寄存器,它也没有零延迟MOV指令,因此G++浪费的MOV指令实际上会增加关键路径的延迟,这对于推土机来说是一个很大的问题。所以,对CPU来说,手工优化确实以一种重要的方式击败了编译器,因为它不够现代,无法咀嚼无用的指令。
- "更好地宣称C++编译器是很坏的错误。尤其是在这种情况下。人类总是能使代码更好,而这个特定的问题很好地说明了这一主张。"你可以逆转它,它也同样有效。"声称一个人更好是非常严重的错误。尤其是在这种情况下。人类总是会使代码变得更糟,而这个特定的问题很好地说明了这一主张。"所以我认为你在这里没有观点,这样的概括是错误的。
- @但是这个问题的作者根本不能成为任何论据,因为他对汇编语言的了解接近于零。关于人类与编译器的每一个论点,都隐含地假定人类至少具有一些中等层次的ASM知识。更多:这个定理"人类编写的代码总是比编译器生成的代码更好或相同"很容易被正式证明。
- @Luk32:熟练的人可以(通常应该)从编译器输出开始。因此,只要您对尝试进行基准测试以确保它们实际上更快(在您正在调优的目标硬件上),就不能做得比编译器更差。但是,我必须承认这是一个有力的声明。编译器通常比新手ASM编码器做得更好。但是,通常可以将一条或两条指令与编译器的结果进行比较。(但并非总是在关键路径上,这取决于UARCH)。它们是复杂机械中非常有用的部件,但它们不是"智能"的。
- @johnfound的"汇编语言知识接近零"——可以证实。这个问题从来没有打算引发关于人类与编译器的广泛争论。实际上,我希望从ASMDev的经验中了解到编译器是如何在基线ASM代码上改进蛮力的。谢谢你和彼得·考兹在这件事上透露了一些信息
- 刚刚运行了代码,是的,比C解决方案快得多。通过用我的代码中的一个移位来替换除法,它已经比c快了至少3倍。使用进位标志可以提高它的效率。非常聪明。
- @杰斐逊,请写一些关于你的CPU型号/速度和执行时间的细节。比较不同硬件上的性能可能很有趣。
- 好的,我会用细节更新操作。我在英特尔赛扬2955U 1.4 GHz Chromebook上运行,因此延迟非常明显。
- JordMead增加了CPU信息和执行时间到OP。我用你的ASM解决方案和C++O3更快地得到AVG 70-80ms。转换成NASM语法,也许您的汇编程序更好?
- @杰斐逊没有更好的装配工。汇编语言的优点在于你所写的就是你所得到的。所有代码的好坏都是因为你。
- @杰斐逊:你的赛扬2955U是英特尔哈斯韦尔,所以我的答案中的性能分析完全适用于你的CPU。John的asm应该在每次迭代中以大约5个周期运行循环,对于我优化的asm也是如此,并且使用unsigned long n的-o3编译器输出。有趣的是,从您的原始源代码输出的带有长符号的编译器只慢了一点;haswell在仔细阅读gcc的额外insn时给人留下了深刻的印象。
- @彼得卡德奇怪的是,无论CPU速度更快,架构更好,我的ASM版本仍然比AMD-430ms和501ms的运行速度慢;
- @约翰:这并不奇怪,只是有趣。我已经指出haswell比推土机有更高的延迟cmov和复杂的lea,所以是的,在这个纯CPU限制的特定循环中,推土机比haswell运行得更快,足以克服1.4:1时钟速度差(省电的模件测量误差)。哈斯韦尔的平均速度通常更快,但这并不意味着它在所有方面都更快。(顺便说一句,Broadwell和Skylake每时钟运行速度比Haswell快20%,因为它们处理CMOV为1op,1c延迟。)
- AMD的CPU通常在类似这样的延迟限制的整数工作负载上表现良好。它们在扩展精度的ADC/SBB循环上也做得很好,因为(像cmov),这是另一个3输入指令(2个整数regs+flags),Intel Pre-Broadwell将其作为两个UOP处理。Intel CPU还具有较慢的可变计数移位指令(shl eax, cl:3 Uops,2c延迟与1Mop1c延迟)。然而,gmplib.org/gmpbench.html显示,在目前的gmp下,Intel CPU在每GHz的得分方面仍有相当大的领先优势。也许他们现在使用SSE/AVX,AMD的延迟更严重?
- 在我对这个答案的第一次评论中,我删去了一个词:inc不会"导致除p4之外的任何事情的放缓"。不幸的是,现在没有办法解决它,除非一个版主可以为我这样做。我认为删除对ZF分支为什么起作用的解释总体上会更糟。
- "@luk32-但是这个问题的作者根本不能成为任何论据,因为他对汇编语言的了解接近于零。"--嗯,一个很好的例子"没有真正的苏格兰人"的谬论。更贴切的是,众所周知,所有真正的苏格兰人都是世界级的ASM程序员。
- @达伦林格-是的,你看起来很聪明,知道NTS的谬论。但在这种情况下,它根本不适用。仔细阅读:"任何比较Man和编译器都隐含地假定Man至少知道一些中间级别的汇编语言。"如果可以的话,现在就反驳它。
- @当然,我不能,我主要是想减轻讨论的压力。但是编译器的全部目的是为了避免程序员不断地被测试其ASM的聪明性,这在大多数程序员中常常是低劣的。
为了获得更高的性能:一个简单的变化是观察到n=3n+1之后,n是偶数,所以你可以立即除以2。n不是1,所以你不需要测试它。因此,您可以保存一些if语句并编写:
1 2 3 4 5 6 7 8
| while (n % 2 == 0) n /= 2;
if (n > 1) for (;;) {
n = (3*n + 1) / 2;
if (n % 2 == 0) {
do n /= 2; while (n % 2 == 0);
if (n == 1) break;
}
} |
这是一个巨大的胜利:如果你看n的最低8位,所有的步骤,直到你除以2,8次,完全由这8位决定。例如,如果最后的8位是0x01,那么您的数字是二进制的吗?????0000 0001,接下来的步骤是:
1 2 3 4 5 6 7 8 9 10 11 12
| 3n+1 -> ???? 0000 0100
/ 2 -> ???? ?000 0010
/ 2 -> ???? ??00 0001
3n+1 -> ???? ??00 0100
/ 2 -> ???? ???0 0010
/ 2 -> ???? ???? 0001
3n+1 -> ???? ???? 0100
/ 2 -> ???? ???? ?010
/ 2 -> ???? ???? ??01
3n+1 -> ???? ???? ??00
/ 2 -> ???? ???? ???0
/ 2 -> ???? ???? ???? |
所有这些步骤都可以预测,256K+1替换为81K+1。所有组合都会发生类似的情况。所以你可以用一个大开关语句来做一个循环:
1 2 3 4 5 6 7 8 9 10 11
| k = n / 256;
m = n % 256;
switch (m) {
case 0: n = 1 * k + 0; break;
case 1: n = 81 * k + 1; break;
case 2: n = 81 * k + 1; break;
...
case 155: n = 729 * k + 425; break;
...
} |
运行循环直到n≤128,因为在该点n可以变成1,小于8除以2,并且一次执行8个或更多步骤会使您错过第一次达到1的点。然后继续"正常"循环-或者准备一个表,告诉您需要多少步骤才能达到1。
我强烈怀疑彼得·考兹的建议会使事情发展得更快。除了一个分支之外,将完全没有条件分支,并且除非循环实际结束,否则将正确预测该分支。所以代码应该是
1 2 3 4 5 6 7
| static const unsigned int multipliers [256] = { ... }
static const unsigned int adders [256] = { ... }
while (n > 128) {
size_t lastBits = n % 256;
n = (n >> 8) * multipliers [lastBits] + adders [lastBits];
} |
在实践中,您将测量一次处理N的最后9、10、11、12位是否更快。对于每一位,表中的条目数将增加一倍,当表不再适合一级缓存时,我会执行一个减速操作。
PPS。如果您需要操作的数量:在每个迭代中,我们只做8个2除,以及一个可变数量的(3n+1)操作,因此计算操作的明显方法是另一个数组。但实际上我们可以计算步骤的数量(基于循环的迭代次数)。
我们可以稍微重新定义这个问题:奇数时用(3n+1)/2替换n,偶数时用n/2替换n。然后每一次迭代都会执行8个步骤,但是你可以考虑作弊:—),所以假设有r个操作n<-3n+1和s个操作n<-n/2。结果将非常精确地为n’=n*3^r/2^s,因为n<-3n+1意味着n<-3n*(1+1/3n)。取对数,我们发现r=(s+log2(n'/n))/log2(3)。
如果循环到n≤1000000,并有一个预先计算的表,从n≤1000000的任何起始点需要多少次迭代,那么按照上面的方法计算r,四舍五入到最接近的整数,将得到正确的结果,除非s真的很大。
- 或者为乘法和加法常量创建数据查找表,而不是开关。索引两个256个条目表比跳转表快,编译器可能没有寻找这种转换。
- 嗯,我想有一分钟这一观察可能证明了collatz的猜想,但不,当然不是。对于每一个可能的尾随8位,都有有限的步数,直到它们全部消失。但是,其中一些跟踪的8位模式将使位串的其余部分延长8个以上,因此这不能排除无限增长或重复周期。
- 要更新count,您需要第三个数组,对吗?adders[]没有告诉你做了多少正确的轮班。
- 对于较大的表,使用较窄的类型来增加缓存密度是值得的。在大多数架构中,从uint16_t零扩展负载非常便宜。在x86上,从32位的unsigned int扩展到uint64_t,它的价格和零一样便宜。(来自Intel CPU内存的movzx只需要一个加载端口uop,而AMD CPU也需要一个ALU。)噢,顺便问一下,为什么您要使用size_t来处理lastBits?它是一个32位类型,带有-m32,甚至是-mx32(长模式,带有32位指针)。对于n来说,这绝对是错误的类型。只需使用unsigned。
- @PeterOrders:对于数组索引,大小是一个很好的类型。
在一个相当不相关的注释:更多的性能黑客!
- [第一个?猜想?已被@shrevatsar最终揭穿;已移除]
当遍历序列时,在当前元素N的2-邻域中只能得到3个可能的情况(首先显示):
- [偶数] [奇数]
- [奇数] [偶数]
- [甚至]
跳过这两个元素意味着分别计算(N >> 1) + N + 1、((N << 1) + N + 1) >> 1和N >> 2。
让我们证明在两种情况下(1)和(2)都可以使用第一个公式,(N >> 1) + N + 1。
案例(1)显而易见。case(2)表示(N & 1) == 1,因此,如果我们假设(不失去一般性)n是2位长,其位是从最高到最低有效的ba,那么a = 1,并且下列条件成立:
1 2 3 4 5 6 7
| (N << 1) + N + 1: (N >> 1) + N + 1:
b10 b1
b1 b
+ 1 + 1
---- ---
bBb0 bBb |
其中B = !b。正确的改变第一个结果会给我们想要的。
Q.E.D.:(N & 1) == 1 ? (N >> 1) + N + 1 == ((N << 1) + N + 1) >> 1。
如证明,我们可以使用一个三元运算一次遍历序列2元素。再减少2倍的时间。
生成的算法如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| uint64_t sequence(uint64_t size, uint64_t *path) {
uint64_t n, i, c, maxi = 0, maxc = 0;
for (n = i = (size - 1) | 1; i > 2; n = i -= 2) {
c = 2;
while ((n = ((n & 3)? (n >> 1) + n + 1 : (n >> 2))) > 2)
c += 2;
if (n == 2)
c++;
if (c > maxc) {
maxi = i;
maxc = c;
}
}
*path = maxc;
return maxi;
}
int main() {
uint64_t maxi, maxc;
maxi = sequence(1000000, &maxc);
printf("%llu, %llu
", maxi, maxc);
return 0;
} |
这里我们比较n > 2,因为如果序列的总长度是奇数,过程可能停止在2而不是1。
[编辑:]
让我们把这个翻译成汇编!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
| MOV RCX, 1000000;
DEC RCX;
AND RCX, -2;
XOR RAX, RAX;
MOV RBX, RAX;
@main:
XOR RSI, RSI;
LEA RDI, [RCX + 1];
@loop:
ADD RSI, 2;
LEA RDX, [RDI + RDI*2 + 2];
SHR RDX, 1;
SHRD RDI, RDI, 2; ror rdi,2 would do the same thing
CMOVL RDI, RDX; Note that SHRD leaves OF = undefined with count>1, and this doesn't work on all CPUs.
CMOVS RDI, RDX;
CMP RDI, 2;
JA @loop;
LEA RDX, [RSI + 1];
CMOVE RSI, RDX;
CMP RAX, RSI;
CMOVB RAX, RSI;
CMOVB RBX, RCX;
SUB RCX, 2;
JA @main;
MOV RDI, RCX;
ADD RCX, 10;
PUSH RDI;
PUSH RCX;
@itoa:
XOR RDX, RDX;
DIV RCX;
ADD RDX, '0';
PUSH RDX;
TEST RAX, RAX;
JNE @itoa;
PUSH RCX;
LEA RAX, [RBX + 1];
TEST RBX, RBX;
MOV RBX, RDI;
JNE @itoa;
POP RCX;
INC RDI;
MOV RDX, RDI;
@outp:
MOV RSI, RSP;
MOV RAX, RDI;
SYSCALL;
POP RAX;
TEST RAX, RAX;
JNE @outp;
LEA RAX, [RDI + 59];
DEC RDI;
SYSCALL; |
使用以下命令编译:
1 2
| nasm -f elf64 file.asm
ld -o file file.o |
参见C和改进/修正版的ASM,由PeterCordeson Godbolt提供。(编者按:很抱歉把我的东西放在你的答案里,但是我的答案达到了godbolt links+text的30k字符限制!)
- 我认为你的for (n = i = (size - 1) | 1; i > 2; n = i -= 2) {循环已经足够了,它可以更容易地将n=i拉到下一行,而不是在初始值设定项和i-=2部分重复该逻辑。
- 令人印象深刻的算法。当我有机会的时候,我会对它进行基准测试,并将结果添加到OP中。我知道有更好的方法来计算这个序列,因为我看到其他人在PE线程上报告了低于200毫秒的运行时间。
- 没有积分Q,因此12 = 3Q + 1。我想你的第一点是不对的。
- @维德拉:该死,你说得对。第一点的证明是错误的。然而,我仍然觉得这一点是正确的,但我今天没有足够的时间来提出更适合证明这一点的东西。
- @Veedrac:一直在玩这个:它可以用比这个答案中的实现更好的ASM来实现,使用ror/test,并且只使用一个cmov。这个ASM代码在我的CPU上无限循环,因为它显然依赖于,这在shrd或ror之后是未定义的,计数>1。它还需要花费大量的时间来尝试避免mov reg, imm32,显然是为了节省字节,但随后它在任何地方都使用64位版本的寄存器,甚至是xor rax, rax,因此它有许多不必要的rex前缀。显然,我们只需要REX在内部循环中保存n的regs上,以避免溢出。
- 我在godbolt上放了一个C模糊的版本,但是它的短链接生成现在被破坏了?我现在在答案的末尾插入了一个链接。编辑别人的答案自动取款机有点麻烦。不管怎样,它实际上编译成比原来更好的ASM,但是我基于这个想法和hide的ASM手工调优的ASM更快。
- @Veedrac和Hidefrom:好的,用基于此的工作代码更新了我的答案。
- 定时结果(来自core2duo e6600:merom 2.4GHz。复合lea=1c延迟,cmov=2c)。最佳的单步ASM内部循环实现(来自johnfound):每次运行这个@main循环111ms。编译程序输出从我的这个C的模糊版本(与一些tmp vars):clang3.8 -O3 -march=core296ms.gcc5.2:108ms。从我的clang's asm内部循环的改进版本:92ms(应该看到一个更大的改善,在snb家族,复杂的lea是3c而不是1c)。从我改进的+工作版的ASM循环(使用ROR+测试,而不是SHRD):87ms。打印前用5次重复测量
- 不仅是证据,而且你所说的"我们可以安全地假设我们正在寻找的元素是奇数"是不正确的:例如18和54是偶数,它们产生的链比任何较小的数都长。(不计算2和6,因为出于某种原因,您指定e≥10。)
- 这里是前66个创纪录者(在OEIS上是A006877);我用粗体标记偶数者:2、3、6、7、9、18、25、27、54、73、97、129、171、231、313、3277、649、703、871、1161、22223、2463、2919、3711、6171、10971、13255、17647、23529、17647、23529、26623、34239、35655、52527、77031、106239、1422587、1515159、216367、230631、4101、4102011、511935、511935、511935、511935、5151511935、5151511935、51511935、51515151511935、515151626331,837799,1117065,1501353、1723519、2298025、3064033、3542887、3732423、5649499、6649279、8400511、11200681、14934241、15733191、31466382、36791535、63728127、127456254、169941673、226588897、268549803、537099606、670617279、1341234558
- @克格勃的藏身之处太好了!我也很感激你的另一个观点:4K+2→2K+1→6K+4=(4K+2)+(2K+1)+1,和2K+1→6K+4→3K+2=(2K+1)+(K)+1。观察得很好!
- XOR RSI, RSI不是个好主意。用xor esi, esi代替
对于collatz问题,通过缓存"tails",可以显著提高性能。这是一个时间/内存权衡。参见:记忆化(https://en.wikipedia.org/wiki/memoization)。您还可以研究其他时间/内存权衡的动态编程解决方案。
Python实现示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| import sys
inner_loop = 0
def collatz_sequence(N, cache):
global inner_loop
l = [ ]
stop = False
n = N
tails = [ ]
while not stop:
inner_loop += 1
tmp = n
l.append(n)
if n <= 1:
stop = True
elif n in cache:
stop = True
elif n % 2:
n = 3*n + 1
else:
n = n // 2
tails.append((tmp, len(l)))
for key, offset in tails:
if not key in cache:
cache[key] = l[offset:]
return l
def gen_sequence(l, cache):
for elem in l:
yield elem
if elem in cache:
yield from gen_sequence(cache[elem], cache)
raise StopIteration
if __name__ =="__main__":
le_cache = {}
for n in range(1, 4711, 5):
l = collatz_sequence(n, le_cache)
print("{}: {}".format(n, len(list(gen_sequence(l, le_cache)))))
print("inner_loop = {}".format(inner_loop)) |
- gnaser的答案表明,您可以做的不仅仅是缓存尾部:高位不会影响接下来发生的事情,而add/mul只将进位传播到左侧,因此高位不会影响低位的情况。也就是说,您可以使用LUT查找一次执行8位(或任意数量)的位,用乘法和加法常量将其应用于其余的位。在很多这样的问题中,记忆尾部肯定是有帮助的,对于这个问题,当你还没有想到更好的方法,或者还没有证明它是正确的时候。
- 如果我能正确理解格纳谢尔的思想,我认为尾部记忆是一个正交优化。所以你可以同时做这两件事。研究在gnaser算法中添加memoization可以获得多大的收益是很有趣的。
- 哦,对了,当你低于n=128的时候,你可以再有一个LUT。好的一点是,如果很多序列很快降到较小的数目,那么一旦你加速一个很大的因素到达那里的过程,尾部可能会花费很多时间。
- 我怀疑CPU是否值得花时间计算哈希函数并管理一个巨大的哈希表来记忆整个事情,因为缓存未命中需要数百个周期。Gnasher方法的递归每次迭代在haswell上花费大约10个周期(一级加载使用延迟(5)+乘法(3)+加法+和延迟),每次做8位。不过,我想值得一试。IIRC,Skylake L1负载使用延迟对于整数负载来说是1c更快。
- 您可以通过只缓存特定范围内长度的尾部来优化memoization。(我有这样做的工作代码)。我不会为散列而烦恼。最近散列很快。散列足够接近O(1),您无需担心。只有在缓存丢失时才需要gnaser的代码。我理解gnaser的代码的方式是,它基本上是将固定数量的迭代压缩为一个迭代,这意味着collatz序列生成仍然是O(log n)或其他什么。o(1)比o(log n)大得多。
- 实际硬件上散列表的速度也随着大小而恶化,因为散列表的增长大于一级缓存,然后大于二级缓存,然后是三级缓存,最后是TLB未命中。(甚至大于RAM,需要磁盘访问)。你有一个很好的观点,但是不变的因素很重要。gnaser的lut实际上仍然是o(seq-len):它一次执行固定数量的迭代。乘法和加法可以增加n,而不是去掉最后8位,使其收缩。(用O(1)内存做任何比O(seq_len)更好的事情都可能需要一个collatz猜想的证明或其他东西,或者是一个证明。)
- 不过,散列是O(1)。记忆化的主要问题是内存的使用。对于无限输入,您真的需要可调的记忆。你需要从经验上证明缓存确实有帮助。这真的取决于用法。如果您所需要的只是collatz(range(1,n)),那么缓存可能(可以证明?)胜利。对于其他用例,幼稚的缓存很可能会丢失。
- 好吧,你说的是无边界输入,但我说的是n的范围足够小,可以检查所有的输入,只有一些简单的技巧,比如Veedrac的观察,所有的0..n/2都有一个更长的双精度。如果一个范围内的大多数值的序列都很短,足够短,以至于缓存丢失比使用巧妙的蛮力运行整个序列花费的时间更长,那么memoization就会失败。(如果没有足够的指令让rob在哈希函数为n+1计算地址之前无法填满,则无序执行可能会并行地给我们多个未完成的缓存未命中)
- 我们可以通过只存储结果的密集部分来降低记忆化的成本。在n上设置一个上限,超过这个上限,甚至不要检查内存。下面,使用hash(n)->n作为散列函数,因此key=数组中的位置,不需要存储。0的条目表示还没有出现。我们可以通过只在表中存储奇数n来进一步优化,因此散列函数是n>>1,而不是1。编写步骤代码时,始终以n>>tzcnt(n)或其他内容结尾,以确保它是奇数。
- 这是基于我(未经测试)的想法,即序列中间非常大的n值不太可能是多个序列的共同点,因此我们不会错过太多不记得它们。同样,一个合理大小的n将是许多长序列的一部分,甚至是以非常大的n开始的序列(这可能是一厢情愿的想法;如果它是错误的,那么只缓存一个密集的连续n范围可能会与一个可以存储任意键的哈希表相比较)您是否做过任何类型的命中率测试,以查看附近的起始n是否倾向于它们的序列值有相似之处吗?
- 好吧,对于有界输入,您总是可以预先计算所有序列:)我没有做过任何硬的和快速的命中率测试。不可知地模拟"真实"使用是不平凡的。但我发现,通过只缓存某些尾部,您确实可以调整内存/性能。
- 对于一些大的n,您可以只存储所有n1,序列最终将小于原始n。因此,缓存尾不会有帮助。
- @彼得卡德斯:如果一个人在一个比机器字大得多的值上运行一个算法,给定一个算法来产生给定底N位的缩放幂和余数,那么他可以使用递归算法从底2N位产生这些东西,而只需要一个乘法和加法。
在源代码生成机器代码期间,C++程序被翻译成汇编程序。说程序集比C++慢实际上是错误的。此外,生成的二进制代码因编译器而异。因此,智能编译器可以产生比哑汇编程序代码更为优化和高效的二进制代码。
但是我相信你的分析方法有一定的缺陷。以下是分析的一般准则:
确保系统处于正常/空闲状态。停止所有正在运行的进程(应用程序),您启动或集中使用CPU(或在网络上轮询)。
数据大小必须更大。
您的测试必须运行超过5-10秒。
不要只依赖一个样本。测试N次。收集结果并计算结果的平均值或中位数。
- 是的,我没有做过任何正式的分析,但是我已经运行了几次,并且能够从3秒中分辨出2秒。不管怎样,谢谢你的回答。我已经在这里收集了很多信息
- 这可能不仅仅是一个测量错误,手工编写的ASM代码使用64位DIV指令而不是右移位。看看我的答案。但是的,正确的测量也很重要。
- 项目符号点的格式比代码块更合适。请停止将您的文本放入代码块,因为它不是代码,也不会受益于单空格字体。
- 我真的不明白这是怎么回答这个问题的。这不是一个关于汇编代码或C++代码是否会更快的模糊问题——这是一个关于实际代码的非常具体的问题,他在问题本身中提供了这个问题。你的答案甚至没有提到任何代码,也没有做任何类型的比较。当然,关于如何进行基准测试的提示基本上是正确的,但不足以给出实际的答案。
即使不考虑组装,最明显的原因是,/= 2可能是优化的,因为>>=1和许多处理器具有非常快速的移位操作。但是,即使处理器没有移位操作,整数除法也比浮点除法快。
编辑:在上面的"整数除法比浮点除法更快"语句中,您的里程可能会有所不同。下面的注释显示,现代处理器优先优化fp除法,而不是整数除法。因此,如果有人在寻找这个线程问题所询问的加速最可能的原因,那么编译器优化/=2作为>>=1将是最好的第一个查找位置。
在不相关的注释中,如果N是奇数,那么表达式n*3+1将始终是偶数。所以不需要检查。你可以把那家分店改成
1 2 3 4
| {
n = (n*3+1) >> 1;
count += 2;
} |
那么整个陈述就是
1 2 3 4 5 6 7 8 9 10
| if (n & 1)
{
n = (n*3 + 1) >> 1;
count += 2;
}
else
{
n >>= 1;
++count;
} |
- 在现代x86 CPU上,整数除法实际上并不比fp除法快。我认为这是由于英特尔/AMD在他们的FP分频器上花费了更多的晶体管,因为这是一个更重要的操作。(整数除以常量可以优化为模逆乘法)。检查agner fog的insn表,并将divsd(双精度浮点)与DIV r32(32位无符号整数)或DIV r64(慢得多的64位无符号整数)进行比较。特别是对于吞吐量,fp划分更快(单UOP而不是微编码,并且部分流水线),但是延迟也更好。
- 例如,在操作的haswell CPU上:divsd是1uop,10-20个周期的延迟,每8-14c吞吐量一个。DIV r64是36个uops,32-96c延迟,每21-74c吞吐量一个。Skylake具有更快的fp除法吞吐量(以每4c一个流水线传输,延迟不太好),但整数除法速度不太快。AMD推土机系列的情况类似:divsd为1M op,9-27C延迟,每4.5-11C一个吞吐量。DIV r64是16M操作,16-75C延迟,每16-75C吞吐量一个。
- fp除法基本上和整数减去指数、整数除尾数、检测非规格化不一样吗?这三个步骤可以并行进行。
- @msalters:是的,听起来不错,但是在指数和mantiss之间的位移动结束时有一个标准化步骤。double有53位尾数,但它仍然比haswell上的DIV r32慢得多。所以,这绝对只是英特尔/AMD投入多少硬件来解决这个问题的问题,因为他们不使用相同的晶体管来进行整数和浮点分割。整数一个是标量(没有整数simd除法),向量一个处理128B向量(不像其他向量alus那样256B)。最重要的是整数DIV是多个UOP,对周围的代码影响很大。
- 错误,不是在尾数和指数之间移动位,而是使用移位来规范尾数,并将移位量添加到指数中。
- 我之前评论中的另一个编辑错误:divpd比haswell上的DIV r32快一些。divps是1uop,10-20c延迟,每8-14c吞吐量一个。idiv r32是9个Uops,22-29c延迟,每8-11c吞吐量一个。(DIV r32是10个Uops,相同的延迟,每9-11c吞吐量一个。)
- 相比之下,单精度24位尾数分隔是1 UOP,10-13C延迟,每7C吞吐量一个。fdiv(80位)是1oop,10-24c lat,每8-18c tput一个。所以浮点精度在性能上有很大的差别,但是fp除法总是一个UOP,而不是微编码。有趣的是,Skylake更加积极地为FP分隔器铺设管道,每4c分隔器一个,每3c分隔器一个。(仍然是256B向量的一半吞吐量,但延迟相同,所以我猜这不是额外的流水线,而是另一个部分流水线的128B除法器,YMM向量可以并行使用)。
- 我刚在Merom上测试过。FP和整数除法器确实相互竞争。例如,一个仅对divsd和DIV r32的吞吐量产生瓶颈的循环,对于DIV r32的吞吐量为1/36C,对于divsd的吞吐量为1/31C的操作数,当单独测试时,该循环的运行频率为1/67=36+31。所以它们完全不能重叠。(这是Agner Fog列出的延迟的上端,因为我使用的输入会给出一个大的商。)我猜整数除法使用的硬件与fp除法相同,甚至是divss。也许这就是它需要多个UOP的原因。
- (抱歉收到垃圾邮件,德米特里。我应该找个别的地方来放这些结果/评论)。
- 回复:你的编辑。任何CPU设计都不可能在硬件上有整数和/或fp除法,但没有有效的转换。有些架构(如8位RISC AVR)只有一个移位1指令,但这些架构肯定没有硬件分隔器。(8086最初没有立即计数移位,但是您可以使用大于1的编译时常量计数来移位1(0)/shr reg/m16, cl),当然这只需要每个ISA都有1的移位。
- @PeterCordes,但我并不认为SHR可能不存在于运行测试的体系结构中。实际上,我是说编译器很可能决定使用它。最初的问题是"为什么?"就像"为什么会这样"。我只是回答了"可能是因为……",编辑实际上收回了我提出的一种可能性。我想,"但是"应该是"所以"。我会改变一下来澄清……关于整个collatz猜想…里面可能有足够的纸张。:)
您没有发布编译器生成的代码,因此这里有一些猜测,但即使没有看到它,也可以这样说:
…有50%的机会预测不到分支,这将是昂贵的。
编译器几乎肯定会同时进行两种计算(由于DIV/mod的延迟时间很长,因此乘法加法是"免费的")并使用cmov进行后续操作。当然,它有百分之零的可能被误判。
- 分支有一些模式;例如奇数后面总是跟一个偶数。但有时3n+1会留下多个尾随的零位,这将导致预测失误。我开始在我的答案中写关于除法的内容,但没有在操作码中写下另一个大的红旗。(还要注意,与JZ或CMOVZ相比,使用奇偶校验条件确实很奇怪。这对CPU来说也更糟,因为Intel CPU可以宏融合test/jz,而不是test/jpe。Agner Fog说AMD可以将任何测试/CMP与任何JCC融合在一起,因此在这种情况下,对人类读者来说情况更糟)
从评论:
But, this code never stops (because of integer overflow) !?! Yves Daoust
对于许多数字,它不会溢出。
如果它将溢出——对于那些不幸的初始种子之一,溢出的数字很可能会收敛到1,而没有另一个溢出。
这仍然是一个有趣的问题,是否有一些溢出的循环种子数?
任何简单的最终聚合序列都以两个值的幂开始(足够明显?).
2^64将溢出到零,根据算法,这是未定义的无限循环(仅以1结尾),但由于shr rax产生zf=1,因此答案中的最佳解将完成。
我们能生产2^64吗?如果起始编号是0x5555555555555555,则为奇数,下一个编号是3n+1,即0xFFFFFFFFFFFFFFFF + 1=0。理论上是在算法的未定义状态下,但在zf=1时,johnfound的优化答案将恢复。PeterCordes的cmp rax,1将以无限循环结束(qed变量1,"cheapo"通过未定义的0数字)。
如果没有0,会产生循环的更复杂的数字呢?坦率地说,我不确定,我的数学理论太模糊了,没有什么严肃的想法,如何严肃地处理它。但凭直觉我会说,这个数列会收敛到1,因为3n+1公式迟早会把原数(或中间数)的非2素数变成2的幂。所以我们不需要担心原始序列的无限循环,只有溢出会阻碍我们。
所以我只是把一些数字放进了表中,并查看了8位截断的数字。
有三个值溢出到0:227、170和85(85直接指向0,其他两个朝85方向发展)。
但创建循环溢出种子没有价值。
有趣的是,我做了一个检查,这是第一个遭受8位截断的数字,并且已经影响了27!在适当的非截断序列中,它确实达到了9232(第12步的第一个截断值是322,在非截断方式下,2-255个输入数中的任何一个达到的最大值是13120(对于255本身),收敛到1的最大步数大约是128(+-2,而不是su如果"1"是要计数,等等。
有趣的是(对我来说)对于许多其他的源代码来说,9232是最大的,它有什么特别之处?:-o 9232=0x2410…六羟甲基三聚氰胺六甲醚。。不知道。
不幸的是,我无法深入理解这个系列,它为什么收敛,以及将它们截断为k位的含义是什么,但是在cmp number,1终止条件下,在截断后,一定可以将算法放入无限循环,特定的输入值以0结束。
但是对于8位的情况,值27溢出是一种警告,如果您计算达到值1的步数,就会从整数的K位集合中得到大多数数字的错误结果。对于8位整数,256个整数中的146个数字已经受到截断的影响(其中一些数字可能仍然意外地达到正确的步数,可能我太懒了,无法检查)。
- "溢出数很可能会在没有另一个溢出的情况下收敛到1":代码永远不会停止。(这是一个猜测,因为我不能等到时间结束才能确定…)
- @Yvesdaoust哦,但它是吗?…例如,带8b截断的27系列如下:82 41 124 62 31 94 47 142 71 214 107 66(截断)33 100 50 76 38 19 58 29 88 44 22 11 34 17 52&zwnj;&8203;26 13 40 20 10 5 16&zwnj;&8203;8 4 2 1(其余部分在不截断的情况下工作)。对不起,我不明白。如果截断值等于当前正在进行的系列中以前达到的某些值,它将永远不会停止,并且我找不到任何此类值与k位截断(但我也无法找出背后的数学理论,为什么这可以支持8/16/32/64位截断,我认为这是直观的)。
- 我应该早点检查一下原始的问题描述:"虽然它还没有被证明(collatz问题),但它被认为所有的起始数字都是1。"好吧,难怪我不能用有限的模糊数学知识来理解它…:d从我的单张实验中,我可以向您保证,它对每个2-255号都是收敛的,要么没有截断(到1),要么有8位截断(到预期的1,要么有3个数的0。
- 哼哼,当我说它永远不会停止的时候,我是说…它不会停止。如果您愿意,给定的代码将永远运行。
- 对溢出发生的情况进行分析。基于CMP的循环可以使用cmp rax,1 / jna(即do{}while(n>1))也在零终止。我想制作一个记录了所见的max n的循环的仪器版本,来给出接近溢出的程度。
作为一个一般性的回答,不是专门针对这个任务:在许多情况下,通过在高水平上进行改进,您可以显著地加快任何程序的速度。像计算一次而不是多次数据,完全避免不必要的工作,以最佳方式使用缓存等等。用高级语言做这些事情要容易得多。
编写汇编程序代码,可以改进优化编译器的功能,但这是一项艰巨的工作。一旦完成,您的代码就很难修改,所以添加算法改进就更加困难了。有时,处理器具有不能从高级语言使用的功能,在这些情况下,内联汇编通常很有用,但仍然允许您使用高级语言。
在欧拉问题中,大多数时候你成功的方法是建立一些东西,找出为什么它是缓慢的,建立一些更好的,找到为什么它是缓慢的,等等。使用汇编程序非常非常困难。在可能的一半速度下,一个更好的算法通常会在全速下打败一个更差的算法,而在汇编程序中获得全速并不是一件小事。
- 完全同意这一点。对于这个精确的算法,gcc -O3生成的代码在haswell上的最优值的20%以内。(获得这些加速是我答案的主要焦点,仅仅是因为这就是问题的问题所在,并且有一个有趣的答案,而不是因为它是正确的方法。)编译器极不可能从转换中获得更大的加速,例如延迟右移或一次执行两个步骤。比记忆化/查找表的速度快得多。仍然是详尽的测试,但不是纯粹的蛮力。
- 尽管如此,拥有一个明显正确的简单实现对于测试其他实现非常有用。我要做的可能只是查看asm输出,看看gcc是否像我预期的那样(主要出于好奇)毫无分支地完成了它,然后继续改进算法。
简单的答案:
如果为C++程序生成程序集列表,则可以看到它与程序集的区别。
- 1):与LEA相比,增加3倍是愚蠢的。另外,在操作的haswell CPU上,mul rbx是2个uops,具有3c延迟(每个时钟吞吐量为1)。imul rcx, rbx, 3只有1uop,3c延迟相同。两个加法指令是2uops,延迟2c。
- 2)这里加1可能比inc快。不,OP没有使用Pentium4。你的第3)点是这个答案唯一正确的部分。
- 4)听起来完全是胡说八道。对于指针密集的数据结构,64位代码可能较慢,因为较大的指针意味着更大的缓存占用空间。但是这个代码只在寄存器中工作,在32位和64位模式下,代码对齐问题是相同的。(数据对齐问题也是如此,不知道您所说的对齐是x86-64的一个更大问题)。不管怎样,代码甚至都不会触及循环中的内存。
- 解说员不知道在说什么。在64位CPU上执行mov+mul的速度大约是向自身添加寄存器两次速度的三倍。他的其他言论同样错误。
- 好吧,mov+mul绝对是愚蠢的,但是mov+add+add仍然是愚蠢的(实际上,执行ADD RBX, RBX两次会乘以4,而不是3)。到目前为止,最好的方法是lea rax, [rbx + rbx*2]。或者,以使其成为3组件LEA为代价,使用lea rax, [rbx + rbx*2 + 1](hsw上的3c延迟而不是1,正如我在回答中所解释的那样),我的观点是64位乘法在最近的Intel CPU上并不昂贵,因为它们具有非常快的整数乘法单元(甚至与amd相比,相同的MUL r64是6c延迟,每4c吞吐量一个:甚至没有完全流水线。