用于测试Collat​​z猜想的C ++代码比手写汇编要快-为什么?


832

我用汇编语言和C ++语言为Euler Q14项目编写了这两种解决方案。它们是用于测试Collat​​z猜想的相同相同的蛮力方法。组装解决方案与

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

C ++使用

g++ p14.cpp -o p14

部件, p14.asm

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

#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秒钟的时间。为什么是这样?我主要是出于好奇。

执行时间

我的系统:1.4 GHz Intel Celeron 2955U(Haswell微体系结构)上的64位Linux。


232
您是否检查了GCC为C ++程序生成的汇编代码?
ruakh

69
进行编译-S以获取编译器生成的程序集。编译器足够聪明,可以意识到模数同时进行除法。
user3386109 '16

267
我认为您的选择是1.测量技术有缺陷,2 .编译器编写的汇编程序比您更好,或者3.编译器使用魔术。
Galik '16


18
@jefferson编译器可以使用更快的蛮力。例如,可能带有SSE指令。
user253751 '16

Answers:


1896

如果您认为64位DIV指令是除以2的好方法,那么难怪编译器的asm输出会击败您的手写代码,即使-O0(编译速度快,没​​有额外的优化,并且在/在每个C语句之前,以便调试器可以修改变量)。

请参阅Agner Fog的“优化程序集”指南以了解如何编写高效的asm。他还提供了指令表和微体系结构指南,以了解特定CPU的特定详细信息。另请参阅 标记维基以获得更多性能链接。

另请参阅有关用手写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的-O0asm输出(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代替。testcmovz

 ### 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推迟多次迭代来克服了所有这些问题,这非常有效(请参见下文)。

我们可以安全地互换使用BSFTZCNT,因为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)上非常出色


4
我在一段时间前尝试了向量化方法,但没有用(因为您可以使用标量代码做得更好,tzcnt并且在向量化情况下,向量元素之间的运行时间最长)。
EOF

3
@EOF:不,我的意思是当任何一个矢量元素命中时1,而不是在它们全部都命中时(请使用PCMPEQ / PMOVMSK轻松检测到),打破内循环。然后,您使用PINSRQ和填充物来处理终止的一个元素(及其计数器),然后跳回到循环中。当您过于频繁地跳出内循环时,这很容易变成一种损失,但这确实意味着您总是在每次内循环迭代中获得2或4个有用的工作要素。不过,关于记忆的要点。
彼得·科德斯

4
我管理的最好的@jeffersongodbolt.org/g/1N70Ib。我希望我可以做些更聪明的事情,但事实并非如此。
Veedrac '16

86
如此惊人的答案令我惊讶的是如此详细的知识。我永远不会知道这样的语言或系统,也不知道如何。干得好先生。
camden_kid

8
传奇的答案!
Sumit Jain

104

声称C ++编译器比有能力的汇编语言程序员可以生成更多的最佳代码是一个非常严重的错误。特别是在这种情况下。人总是可以使代码比编译器更好,并且这种特殊情况很好地说明了这一主张。

您看到的时间差异是因为问题中的汇编代码与内循环中的最佳代码相差甚远。

(以下代码是32位,但可以轻松转换为64位)

例如,顺序功能可以优化为仅5条指令:

    .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

整个代码如下:

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 GHz AMD A4-1200处理器),以上代码比问题中的C ++代码快大约四倍(编译时为-O0:430 ms与1900 ms),并且快两倍以上(430) ms VS 830 ms),使用编译C ++代码-O3

这两个程序的输出是相同的:i = 837799时,最大序列= 525。


6
嗯,那很聪明。仅当EAX为1(或0)时,SHR才设置ZF。在优化gcc的-O3输出时,我错过了这一点,但是我确实发现了您对内部循环所做的所有其他优化。(但是为什么要使用LEA代替INC来增加计数器数量呢?此时可以清除标志,并导致除P4之外的任何事情都变慢(INC和SHR对旧标志的虚假依赖)。LEA可以吗? t在尽可能多的端口上运行,并且可能导致资源冲突,从而更频繁地延迟关键路径。)
Peter Cordes

4
哦,实际上,推土机可能会阻碍编译器输出的吞吐量。它的延迟CMOV和3分量LEA低于Haswell(我正在考虑),因此循环进行的dep链在您的代码中只有3个周期。它还没有用于整数寄存器的零延迟MOV指令,因此g ++浪费的MOV指令实际上会增加关键路径的延迟,这对于Bulldozer来说意义重大。所以,是的,对于那些还没有足够现代的CPU来检查无用指令的CPU,手动优化确实确实在很大程度上击败了编译器。
彼得·科德斯

95
声称C ++编译器更好是一个非常糟糕的错误。尤其是在这种情况下。人类总是可以使代码变得更好,而这个特殊问题正是这种说法的很好例证。 ”您可以将其取反,它同样有效。“ 声称一个更好是一个非常糟糕的错误。尤其是在这种情况下。那个人总是会使代码变得更糟,而这个特定的问题正是这种说法的很好例证。 ”因此,我认为您在这里没有道理,这样的概括是错误的。
luk32 '16

5
@ luk32-但是问题的作者根本不能是任​​何参数,因为他的汇编语言知识几乎为零。关于人与编译器的每一个论点都隐含地假设人至少具有中等程度的汇编知识。更多:定理“人工编写的代码将永远比编译器生成的代码更好或更相同”很容易被正式证明。
johnfound '16

30
@ luk32:熟练的人可以(通常应该)从编译器输出开始。因此,只要您基准测试以确保它们实际上更快(在要调整的目标硬件上),就不会比编译器更糟。但是,是的,我必须同意这是一个强有力的声明。编译器通常比新手asm编码器要好得多。但是与编译器提出的相比,通常可以保存一条或两条指令。(不过,取决于uarch,并不总是在关键路径上)。它们是复杂机械中非常有用的部分,但不是“智能”的。
彼得·科德斯

24

为了获得更高的性能:一个简单的变化就是观察n = 3n + 1之后,n将为偶数,因此您可以立即除以2。而且n不会为1,因此您不需要进行测试。因此,您可以保存一些if语句并编写:

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位。例如,如果最后八位是0x01(即二进制),则您的数字是???? 0000 0001,接下来的步骤是:

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  -> ???? ???? ????

因此可以预测所有这些步骤,并用81k + 1代替256k + 1。因此,您可以使用大的switch语句进行循环:

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,而除以2的次数将少于8,那么执行一次八步或更多步将使您错过第一次达到1的点。然后继续“正常”循环-或准备一个表来告诉您要达到1还需要多少步骤。

PS。我强烈怀疑彼得·科德斯的建议会使其更快。除了一个条件分支之外,将没有条件分支,除非循环实际结束,否则将正确预测一个条件分支。所以代码会像

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位是否更快。对于每一位,表中的条目数将增加一倍,当表不再适合L1缓存时,我预计会出现速度下降。

PPS。如果您需要操作数:在每次迭代中,我们将精确地进行8除以2,并进行可变数量的(3n + 1)操作,因此计算操作数的一个显而易见的方法是使用另一个数组。但是我们实际上可以计算步骤数(基于循环的迭代数)。

我们可以稍微重新定义问题:如果奇数为n,则用(3n +1)/ 2替换n,如果偶数为n,则将n替换为n / 2。然后每个迭代将精确地执行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≤1,000,000,并有一个预先计算的表,则从n≤1,000,000的任何起点需要进行多少次迭代,然后按上述方法计算r(四舍五入为最接近的整数)将得到正确的结果,除非s确实很大。


2
或为乘法和加常数而不是开关创建数据查找表。对两个256个条目的表进行索引比跳转表更快,并且编译器可能不需要这种转换。
彼得·科德斯

1
嗯,我认为这一观察可能证明了科拉兹的猜想,但没有,当然不是。对于每个可能的尾随8位,都有一定数量的步长,直到它们全部消失。但是,某些尾随的8位模式会将某些剩余的位串延长8以上,因此不能排除无限增长或重复周期。
彼得·科德斯

要更新count,您需要第三个数组,对吗? adders[]不会告诉您进行了多少次右移。
彼得·科德斯

对于较大的表,使用较窄的类型来增加缓存密度是值得的。在大多数体系结构中,来自a的零扩展负载uint16_t非常便宜。在x86上,它只是便宜,因为从32位零扩展unsigned intuint64_t。哦,顺便说一句,你为什么要使用(MOVZX从Intel CPU的内存只需要一个负载端口UOP公司,但AMD的CPU确实需要的ALU为好。)size_tlastBits?它是带有和的32位类型-m32,甚至是-mx32(带有32位指针的长模式)。绝对是错误的类型n。只需使用unsigned
彼得·科德斯

20

一点无关紧要的是:更多的性能骇客!

  • [第一个“猜想”终于被@ShreevatsaR揭穿了;删除]

  • 在遍历序列时,我们只能在当前元素的2邻域中获得3种可能的情况N(首先显示):

    1. [偶] [奇]
    2. [奇偶]
    3. [偶] [偶]

    飞跃过去,这些2层元素的方法来计算(N >> 1) + N + 1((N << 1) + N + 1) >> 1N >> 2分别。

    让我们证明对于情况(1)和(2),都可以使用第一个公式(N >> 1) + N + 1

    情况(1)很明显。情况(2)表示(N & 1) == 1,因此,如果我们假设(不失一般性)N为2位长,并且其位ba从最高有效到最低有效,则a = 1,以下成立:

    (N << 1) + N + 1:     (N >> 1) + N + 1:
    
            b10                    b1
             b1                     b
           +  1                   + 1
           ----                   ---
           bBb0                   bBb

    在哪里B = !b。右移第一个结果将给我们恰好我们想要的。

    QED :(N & 1) == 1 ⇒ (N >> 1) + N + 1 == ((N << 1) + N + 1) >> 1

    经证明,我们可以使用一个三元操作一次遍历序列2个元素。再减少2倍的时间。

生成的算法如下所示:

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\n", maxi, maxc);
    return 0;
}

这里我们进行比较,n > 2因为如果序列的总长度为奇数,则该过程可能会从2而不是1停止。

[编辑:]

让我们将其翻译成汇编!

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;

使用以下命令进行编译:

nasm -f elf64 file.asm
ld -o file file.o

请参阅Peter Cordes 在Godbolt上的C以及asm的改进/错误修正版本。(编者注:很抱歉将我的内容放入您的答案中,但是我的答案超出了Godbolt链接+文字的30k字符限制!)


2
没有Q这样的整数12 = 3Q + 1。方法论,您的第一点是不对的。
Veedrac '16

1
@Veedrac:一直在玩:可以使用比ROR / TEST和仅一个CMOV更好的asm来实现此答案。我的CPU上的这个asm代码无限循环,因为它显然依赖于OF,在count> 1的SHRD或ROR之后未定义OF。它还竭尽全力尝试避免mov reg, imm32,显然是为了节省字节,但是随后使用甚至到处都有64位版本的register xor rax, rax,因此它具有很多不必要的REX前缀。显然,我们只需要n在内循环中保存的regs上使用REX 即可避免溢出。
彼得·科德斯

1
计时结果(来自Core2Duo E6600:Merom 2.4GHz。Complex-LEA = 1c延迟,CMOV = 2c)。最佳的单步asm内部循环实现(来自Johnfound):此@main循环每次运行111ms。我对此版本的混淆版本(带有一些tmp var)的编译器输出:clang3.8 -O3 -march=core2:96ms。gcc5.2:108ms。从我改进的clang的asm内循环版本来看:92ms(应该看到SnB系列的改进更大,SnB系列的复杂LEA为3c而不是1c)。从我改进后的该版本asm循环的工作版本(使用ROR + TEST,而非SHRD):87毫秒。打印前以5次重复测量
Peter Cordes

2
这是前66位记录创造者(OEIS上的A006877);我已经标记为粗体的甚至是那些:2, 3,6, 7,9,18, 25,27,54, 73,97,129,171,231,313,327,649,703,871,1161, 2223、2463、2919、3711、6171、10971、13255、17647、23529、26623、34239、35655、52527、77031、106239、142587、156159、216367、230631、410011、511935、626331、837799、1117065、1501353, 1723519,2298025,3064033,3542887,3732423,5649499,6649279,8400511,11200681,14934241,15733191,31466382, 36791535,63728127,127456254, 169941673,226588897,268549803,537099606, 670617279,1341234558
ShreevatsaR

1
@hidefromkgb太好了!我现在也很欣赏您的其他观点:4k + 2→2k + 1→6k + 4 =(4k + 2)+(2k + 1)+1,和2k + 1→6k + 4→3k + 2 =( 2k + 1)+(k)+ 1.不错的观察!
ShreevatsaR

6

从源代码生成机器代码期间,C ++程序会转换为汇编程序。说汇编比C ++慢是错误的。而且,生成的二进制代码因编译器而异。因此,聪明的C ++编译器可能会产生比哑汇编程序代码更优化和高效的二进制代码。

但是,我相信您的性能分析方法存在某些缺陷。以下是概要分析的一般准则:

  1. 确保系统处于正常/空闲状态。停止所有已启动或正在大量使用CPU(或通过网络轮询)的正在运行的进程(应用程序)。
  2. 您的数据大小必须更大。
  3. 您的测试必须运行5-10秒以上。
  4. 不要仅仅依靠一个样本。进行N次测试。收集结果并计算结果的平均值或中位数。

是的,我没有进行任何正式的分析,但是我已经运行了两次,并且能够说出3秒中的2秒。无论如何,感谢您的回答。我已经在这里收集了很多信息
杰弗之子

9
可能不仅仅是测量错误,手写的asm代码使用的是64位DIV指令而不是右移。看我的答案。但是,是的,正确测量也很重要。
彼得·科德斯

7
项目符号点比代码块更合适的格式。请停止将文本放入代码块中,因为它不是代码,也不会从等宽字体中受益。
彼得·科德斯

16
我真的不知道这如何回答问题。关于汇编代码或C ++代码是否可能更快,这不是一个模糊的问题-这是一个关于实际代码的非常具体的问题,他在问题本身中提供了帮助。您的答案甚至没有提到任何代码,也没有进行任何类型的比较。当然,关于基准测试的技巧基本上是正确的,但不足以给出实际答案。
科迪·格雷

6

对于Collat​​z问题,通过缓存“尾巴”可以显着提高性能。这是时间/内存的权衡。请参阅:备忘录(https://en.wikipedia.org/wiki/Memoization)。您还可以研究动态编程解决方案,以进行其他时间/内存折衷。

示例python实现:

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))

1
gnasher的答案表明,您不仅可以缓存尾巴,还可以做更多的事情:高位不会影响接下来发生的事情,而add / mul只会向左传播进位,因此高位不会影响低位发生的事情。也就是说,您可以使用LUT查找来一次查找8位(或任意数量)的位,并使用乘和加常数将其应用于其余位。记住尾巴对于许多类似这样的问题肯定是有帮助的,对于这个问题,如果您还没有想到更好的方法,或者还没有证明它是正确的。
彼得·科德斯

2
如果我正确地理解了gnasher的想法,我认为尾部记忆是正交优化。因此,可以想象两者都可以。研究将记忆添加到gnasher的算法中可以带来多少收益,这将是很有趣的。
伊曼纽尔·兰德霍尔姆'16

2
通过仅存储结果的密集部分,我们也许可以使备忘录更便宜。设置N的上限,超过此上限,甚至不检查内存。在此之下,使用hash(N)-> N作为哈希函数,因此key =数组中的位置,并且不需要存储。0还没有输入手段。我们可以通过仅在表中存储奇数N来进一步优化,因此哈希函数为n>>1,舍弃1。编写步骤代码以始终以a n>>tzcnt(n)或某些东西结尾以确保其为奇数。
彼得·科德斯

1
这是基于我的(未尝试的)想法,即序列中间的N值非常大,对于多个序列来说不太可能出现,因此我们不会因遗忘它们而错过太多。同样合理大小的N将成为许多长序列的一部分,甚至是以非常大的N开头的序列。可以存储任意键的表。)您是否进行过某种命中率测试,以查看附近的起始N是否倾向于在序列值上具有相似性?
彼得·科德斯

2
您只需要为所有n <N(对于某个较大的N)存储预先计算的结果。因此,您不需要哈希表的开销。该表中的数据最终用于每个起始值。如果您只想确认Collat​​z序列始终以(1、4、2、1、1、4、2 ...)结尾:可以证明这等于证明n> 1时,该序列将最终小于原始的 因此,缓存尾部将无济于事。
gnasher729

5

来自评论:

但是,此代码永远不会停止(因为整数溢出)!?!伊夫·达乌斯特(Yves Daoust)

对于许多数字,它不会溢出。

如果它溢出-对于那些不幸的初始种子之一,溢出的数字很可能会收敛到1而不会再次溢出。

还是提出了一个有趣的问题,是否有一些溢出循环的种子数?

任何简单的最终收敛级数都始于两个值的幂(足够明显吗?)。

2 ^ 64将溢出为零,这是根据算法未定义的无限循环(仅以1结尾),但是由于shr rax产生ZF = 1 ,因此最佳答案的最佳解决方案将完成。

我们可以产生2 ^ 64吗?如果起始编号为0x5555555555555555,则为奇数,下一个编号为3n + 1,即0xFFFFFFFFFFFFFFFF + 1= 0。从理论上讲,算法处于未定义状态,但是johnfound的优化答案将通过在ZF = 1处退出来恢复。的cmp rax,1彼得科尔德的将在无限循环结束(QED变体1,“小气鬼”通过未定义0数量)。

一些更复杂的数字怎么样,它将创建没有循环0?坦白说,我不确定我的数学理论太模糊了,无法提出任何认真的想法,如何认真地对待它。但是直觉上我会说该序列将对每个数字收敛为1:0 <数字,因为3n + 1公式会慢慢将原始数字(或中间数)的每个非2素数转化为2的幂,迟早。因此,我们不必担心原始系列的无限循环,只有溢出会妨碍我们。

因此,我只是在表中放入了几个数字,然后看了一下8位截断的数字。

有三个值溢出到02271708585直接进入0,另外两个朝向前进85)。

但是,创建循环溢出种子没有任何价值。

有趣的是,我进行了检查,这是第一个遭受8位截断并且已经27受到影响的数字!它确实达到9232了适当的非截断序列的值(第一个截断值322在第12步中),并且以非截断方式对2-255输入数中的任何一个达到的最大值是13120255本身)最大步数收敛到1大约128(+ -2,不确定是否要计算“ 1”,依此类推)。

有趣的是(对我而言)这个数字9232是许多其他来源编号的最大值,有什么特别之处?:-O 9232= 0x2410... hmmm ..不知道。

不幸的是,我不能让这个系列的任何深刻的把握,为什么它收敛,什么是截断他们的影响ķ位,但与cmp number,1中止条件的确可以把算法与特定输入值结束的无限循环0后截断。

但是278位情况下的值溢出是一种警报,这看起来像是如果您计算达到value的步数1,那么从总的k位整数集中,对于大多数数字,您将得到错误的结果。对于8位整数,其中256个数字中的146个数字已被截断影响了序列(其中有些可能仍然偶然偶然达到了正确的步数,也许我懒得检查)。


“溢出的数字很可能会收敛到1而不会再次溢出”:代码永不停止。(这是一个推测,因为我不能等到时光
倒流

@YvesDaoust哦,但是呢?...例如,27具有8b截断的序列看起来像这样:82 41 124 62 31 94 47 142 71 214 107 66(被截断)33 100 50 25 76 38 19 58 29 88 44 22 11 34 17 52 26 13 40 20 10 5 16 8 4 2 1(其余部分不会被截断)。对不起,我不明白你的意思。如果截断的值等于当前正在进行的序列中先前达到的值,它将永远不会停止,而我找不到这样的值与k位截断的值(但是我要么不知道背后的数学理论,为什么这可以保留8/16/32/64位截断,就直觉而言我认为它是可行的)。
Ped7g '16

1
我应该早点检查原始问题描述:“尽管尚未得到证明(Collat​​z问题),但可以认为所有起始数字都以1结尾。” ...好的,难怪我用我有限的朦胧数学知识无法掌握它...:D从我的工作表实验中,我可以确保它确实对所有2- 255数都收敛,或者不被截断(to 1),或使用8位截断(预期1或等于0三个数字)。
Ped7g's

下摆,当我说它永远不会停止时,我的意思是……它不会停止。如果您愿意,给定的代码将永远运行。
Yves Daoust '16

1
推荐用于分析溢出时发生的情况。基于CMP的循环可以使用cmp rax,1 / jna(即do{}while(n>1))以零终止。我考虑过要制作一个记录下所n看到的最大值的循环的检测版本,以使我们对溢出有多接近的想法。
彼得·科德斯

5

您没有发布由编译器生成的代码,因此这里有些猜测,但即使没有看过,也可以这样说:

test rax, 1
jpe even

...有50%的机会错误预测分支,这将变得昂贵。

编译器几乎可以肯定会执行这两种计算(由于div / mod的等待时间很长,因此花费更多的钱,因此乘法加法是“免费的”),并执行CMOV。当然,它被错误预测的可能性为零


1
分支有某种模式。例如,奇数始终跟在偶数之后。但是有时3n + 1会留下多个尾随的零位,而这将导致错误的预测。我开始在答案中写有关除法的文章,但没有在OP的代码中解决这个其他大麻烦。(还请注意,与仅使用JZ或CMOVZ相比,使用奇偶校验条件确实很奇怪。对于CPU而言,情况也更糟,因为Intel CPU可以宏熔接TEST / JZ,但不能熔断TEST /JPE。AgnerFog说AMD可以熔断任何TEST / CMP与任何JCC,因此在这种情况下,对人类读者来说只会更糟)
Peter Cordes

5

即使不看组装,最明显的原因是它/= 2可能已经过优化,因为>>=1许多处理器的移位操作非常快。但是,即使处理器没有移位运算,整数除法也会比浮点除法更快。

编辑: 您的里程可能因上面的“整数除法快于浮点除法”语句而异。下面的注释表明,现代处理器优先于优化fp除法而不是整数除法。因此,如果有人在寻找最有可能的原因是此线程的问题问一下,那么编译器优化加速/=2>>=1将是最好的第一名看。


无关的注释上,如果n为奇数,则表达式n*3+1将始终为偶数。因此,无需检查。您可以将该分支更改为

{
   n = (n*3+1) >> 1;
   count += 2;
}

所以整个陈述将是

if (n & 1)
{
    n = (n*3 + 1) >> 1;
    count += 2;
}
else
{
    n >>= 1;
    ++count;
}

4
在现代x86 CPU上,整数除法实际上并不比FP除法更快。我认为这是由于Intel / AMD在FP分压器上花费了更多的晶体管,因为它是更重要的操作。(可以将常量的整数除法优化为乘以模块逆的乘法)。检查Agner Fog的insn表,并将DIVSD(双精度浮点数)与DIV r32(32位无符号整数)或DIV r64(慢得多的64位无符号整数)进行比较。尤其是对于吞吐量,FP划分要快得多(单uop而不是微编码,并且部分流水线化),但是延迟也更好。
彼得·科德斯

1
例如在OP的Haswell CPU上:DIVSD为1 uop,延迟为10-20个周期,每8-14c吞吐量中的一个。 div r64是36 oups,延迟为32-96c,每21-74c吞吐量中有一个。Skylake具有更快的FP分区吞吐量(以每4c的速度进行流水化处理,并没有更好的延迟),但是没有更快的整数div。AMD Bulldozer系列的情况与此类似:DIVSD是1M-op,延迟为9-27c,每4.5-11c吞吐量中的一个。 div r64是16M-ops,16-75c延迟,每16-75c吞吐量一个。
彼得·科德斯

1
FP的除法与整数减法,整数除法的尾数基本不相同吗?这三个步骤可以并行完成。
MSalters

2
@MSalters:是的,这听起来不错,但是在指数和螳螂之间的移位位末尾有一个标准化步骤。 double尾数为53位,但比div r32Haswell 慢得多。因此,这绝对是英特尔/ AMD投入多少硬件的问题,因为整数和fp分频器不使用相同的晶体管。整数1是标量(没有整数-SIMD除法),向量1处理128b个向量(而不是其他向量ALU的256b)。最重要的是,整数div有很多微妙之处,对周围的代码影响很大。
彼得·科德斯

错误,不要在尾数和指数之间移位位,而是通过移位将尾数归一化,并将移位量添加到指数中。
彼得·科德斯

4

作为通用答案,并非专门针对此任务:在许多情况下,可以通过高水平的改进​​来显着加快任何程序的速度。就像一次而不是多次计算数据,完全避免不必要的工作,以最佳方式使用缓存等等。用高级语言,这些事情要容易得多。

编写汇编代码,可以改进优化编译器的功能,但这是艰巨的工作。并且一旦完成,您的代码将很难修改,因此添加算法改进也变得更加困难。有时,处理器具有您无法使用高级语言使用的功能,内联汇编在这些情况下通常很有用,但仍可以使用高级语言。

在Euler问题中,大多数时候您会通过构建某些东西,找到它为什么缓慢,构建更好的东西,找到为什么它缓慢等等来成功,等等。使用汇编程序非常非常困难。一个更好的算法(可能达到一半的速度)通常会在全速时击败一个较差的算法,并且在汇编器中获得全速并非易事。


2
完全同意这一点。 gcc -O3对于精确的算法,在Haswell上生成的代码在最佳代码的20%以内。(获得这些加速是我回答的主要重点,仅是因为这是问题的所在,并且有一个有趣的答案,而不是因为它是正确的方法。)从转换中获得了更大的加速,因此编译器极不可能寻找,例如延迟右移或一次执行2步。记忆/查找表所能提供的加速远远超过这种加速。仍进行详尽的测试,但不是纯暴力破解。
彼得·科德斯

2
尽管如此,拥有一个显然正确的简单实现对于测试其他实现非常有用。我要做的可能只是查看asm输出,以查看gcc是否按我的预期进行了无分支操作(主要出于好奇),然后继续进行算法改进。
彼得·科德斯

-2

简单的答案:

  • 进行MOV RBX,3和MUL RBX的成本很高;仅添加RBX,RBX两次

  • ADD 1可能比INC快

  • MOV 2和DIV非常昂贵;右移

  • 通常,64位代码比32位代码慢得多,并且对齐问题更复杂。对于像这样的小程序,您必须打包它们,以便进行并行计算,以使其有可能比32位代码更快

如果生成C ++程序的程序集列表,则可以看到它与程序集的区别。


4
1):与LEA相比,添加3倍将是愚蠢的。另外mul rbx,在OP的Haswell CPU上还具有2 uops的延迟(3c延迟)(每个时钟吞吐量1个)。 imul rcx, rbx, 3只有1 uop,具有相同的3c延迟。两条ADD指令为2微秒,延迟为2c。
彼得·科德斯

5
2)这里的ADD 1可能比INC快不,OP没有使用Pentium4。您的观点3)是此答案的唯一正确部分。
彼得·科德斯

5
4)听起来像是胡说八道。对于指针重载的数据结构,64位代码可能会变慢,因为更大的指针意味着更大的缓存占用空间。但是此代码仅在寄存器中有效,并且代码对齐问题在32位和64位模式下相同。(数据对齐问题也是如此,不知道您在说什么对齐是x86-64的更大问题)。无论如何,代码甚至都没有触及循环内部的内存。
彼得·科德斯

评论者不知道在说什么。在64位CPU上执行MOV + MUL大约比向其自身添加寄存器两次的速度慢三倍。他的其他言论同样是不正确的。
泰勒·德顿

6
好吧,MOV + MUL绝对是愚蠢的,但是MOV + ADD + ADD仍然很愚蠢(实际上执行ADD RBX, RBX两次将乘以4,而不是3)。到目前为止,最好的方法是lea rax, [rbx + rbx*2]。或者,以使其成为3分量LEA为代价,也要同时执行+1 lea rax, [rbx + rbx*2 + 1] (如我在我的回答中所述,HSW上的3c延迟而不是1)我的观点是64位乘法在最近的Intel CPU,因为它们具有疯狂的快速整数乘法单元(甚至与AMD相比,AMD的MUL r646c延迟时间相同,每4c吞吐量中有一个:甚至没有完全流水线化。)
Peter Cordes
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.