it-swarm.cn

用于测试Collat​​z猜想的C++代码比手写程序集更快 - 为什么?

我为 Project Euler Q14 ,在Assembly和C++中编写了这两个解决方案。它们是用于测试 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;
}

我知道编译器优化以提高速度和一切,但我没有看到很多方法来进一步优化我的Assembly解决方案(以编程方式而不是数学方式)。

C++代码每个术语都有模数,每个偶数项都有一个除法,其中汇编每个偶数项只有一个除法。

但是大会平均比C++解决方案长1秒。为什么是这样?我主要是好奇地问。

执行时间

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

778
jeffer son

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

请参阅 Agner Fog的优化装配指南 以了解如何编写高效的asm。他还有针对特定CPU的具体细节的指令表和微指南指南。有关更多perf链接,另请参阅 x86 标记wiki。

另请参阅这个关于使用手写asm击败编译器的更一般的问题: 内联汇编语言比本机C++代码慢吗? 。 TL:DR:是的,如果你做错了(就像这个问题)。

通常你很好让编译器做它的事情,特别是如果你 尝试编写可以有效编译的C++ 。另见 is汇编比编译语言更快? 。其中一个答案链接到 这些简洁的幻灯片 ,展示各种C编译器如何使用很酷的技巧优化一些非常简单的功能。


even:
    mov rbx, 2
    xor rdx, rdx
    div rbx

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

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

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


从你看到gcc的-O0 asm输出( Godbolt编译器资源管理器 )可以看出,它只使用移位指令 。 clang -O0像你想象的那样天真地编译,即使使用64位IDIV两次也是如此。 (当进行优化时,编译器会在源进行除法时使用IDIV的两个输出,并使用相同的操作数使用模数,如果它们完全使用IDIV的话)

海湾合作委员会没有完全天真的模式; 它总是通过GIMPLE转换,这意味着某些“优化”不能被禁用 。这包括识别逐次除法和使用移位(2的幂)或 定点乘法逆 (非幂2)以避免IDIV(参见上面的godbolt链接中的div_by_13)。

gcc -Os(针对大小进行优化)does使用IDIV进行非2次幂除法,遗憾的是,即使在乘法逆码仅略大但速度更快的情况下也是如此。


帮助编译器

(本案例摘要:使用uint64_t n

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

看看你的asm输出(在Godbolt上,或者参见 如何从GCC/clang Assembly输出中删除“noise”? )。当编译器首先没有制作最佳代码时: 以指导编译器制作更好代码的方式编写C/C++源代码通常是最好的方法 。你必须知道asm,并知道什么是有效的,但你间接地应用这些知识。编译器也是一个很好的思想来源:有时候clang会做一些很酷的事情,你可以手动执行gcc做同样的事情:看看 this answer 以及我在@ Veedrac的代码中对非展开循环做了什么下面。)

这种方法是可移植的,并且在未来20年内,一些未来的编译器可以将其编译为在未来硬件(x86或不是)上有效的任何内容,可能使用新的ISA扩展或自动向量化。从15年前手写的x86-64 asm通常不会为Skylake进行最佳调整。例如当时并不存在比较和分支宏观融合。 对于一个微体系结构,手工制作的asm现在最理想的可能不是其他当前和未来CPU的最佳选择。 关于@ johnfound答案的评论 讨论AMD Bulldozer和Intel Haswell之间的主要区别,这对这段代码有很大的影响。但从理论上讲,g++ -O3 -march=bdver3g++ -O3 -march=skylake会做正确的事情。 (或-march=native。)或-mtune=...只是调整,而不使用其他CPU可能不支持的指令。

我的感觉是指导编译器asm对你关心的当前CPU有好处,对于未来的编译器来说应该不是问题。他们希望在寻找转换代码的方法方面比当前的编译器更好,并且可以找到适用于未来CPU的方法。无论如何,未来的x86在当前x86上的任何好处都可能不会很糟糕,并且未来的编译器将避免任何特定于asm的陷阱,同时实现类似于来自C源的数据移动,如果它没有看到更好的东西。

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

在这种情况下 :你的n有一个带符号的类型,gcc使用SAR/SHR/ADD序列给出正确的舍入。 (对于负输入,IDIV和算术移位“舍入”不同,请参见 SAR insn set ref manual entry )。 (IDK如果gcc尝试并且未能证明n不能为负,或者是什么。签名溢出是未定义的行为,所以它应该能够。)

你应该使用uint64_t n,所以它只能SHR。因此它可以移植到long只有32位的系统(例如x86-64 Windows)。


BTW, 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的FLAGS输入(由TEST生成)比RAX输入(来自LEA-> MOV)生成得更快,因此它不在关键路径上。

类似地,产生CMOV的RDI输入的MOV-> SHR不在关键路径上,因为它也比LEA快。 IvyBridge上的MOV以及之后的延迟为零(在寄存器重命名时间处理)。 (它仍然需要一个uop和一个插槽,因此它不是免费的,只是零延迟)。 LEA dep链中的额外MOV是其他CPU瓶颈的一部分。

Cmp/jne也不是关键路径的一部分:它不是循环携带的,因为控制依赖性是通过分支预测+推测执行来处理的,这与关键路径上的数据依赖性不同。


打败编译器

海湾合作委员会在这里做得很好。它可以使用 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)时才为零。 (有趣的事实:_/SHR带有计数!= 1在Nehalem上或更早,如果你读取标志结果 就会导致失速。这就是他们如何使它成为单uop。但是,按1转换特殊编码很好。 )

避免MOV对Haswell的延迟没有帮助( Can x86的MOV真的是“免费的”?为什么我不能重现这个? )。它确实对像英特尔pre-IvB和AMD Bulldozer系列这样的CPU有帮助显着,其中MOV不是零延迟。编译器浪费的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像Bulldozer。 (3组件LEA在AMD上也较慢,只是没有那么多)。

所以lea rcx, [rax + rax*2]inc rcx在像HASWELL这样的INTEL SNB系列CPU上只有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有很多时间来交错来自两个线程的指令。这意味着在main中并行化循环,但这很好,因为每个线程只能检查一系列n值并产生一对整数。

在单个线程内手动交错也可行 。也许并行计算一对数字的序列,因为每个数字只需要几个寄存器,它们都可以更新相同的maxmaxi。这会创建更多_ /指令级并行

诀窍是决定是否等到所有n值到达1之后才获得另一对起始n值,或者是否突破并为一个达到结束条件的一个获得新的起点,而不触及寄存器其他顺序。可能最好让每个链都处理有用的数据,否则你必须有条件地增加它的计数器。


您甚至可以使用SSE packed-compare stuff来有条件地增加n尚未达到1的向量元素的计数器。然后为了隐藏SIMD条件增量实现的更长延迟,您需要在空中保留更多n值的向量。也许仅值得使用256b矢量(4x uint64_t)。

我认为检测1“sticky”的最佳策略是屏蔽你添加的全部向量以增加计数器。因此,在元素中看到1之后,增量向量将为零,+ = 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

    # 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实现相同的逻辑外,还要寻找简化逻辑或避免冗余工作的方法。例如memoize检测序列的常见结尾。或者甚至更好,一次看8个尾随位(gnasher的答案)

@EOF指出tzcnt(或bsf)可以用于在一个步骤中进行多次n/=2迭代。这可能比SIMD矢量化更好,因为没有SSE或AVX指令可以做到这一点。但它仍然兼容在不同的整数寄存器中并行执行多个标量ns。

所以循环可能如下所示:

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延迟。 (它们对FLAGS有输入依赖性,因为count = 0表示标志未经修改。它们将此处理为数据依赖性,并且因为uop只能有2个输入(无论如何都是HSW/BDW),因此需要多次uop)。这是人们抱怨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运行)。

TZCNT在支持它的AMD CPU上比BSF表现要好得多,所以使用REP BSF是个好主意,即使你不关心如果输入为零而不是输出设置ZF。当你使用__builtin_ctzll甚至-mno-bmi时,一些编译器会这样做。

它们在Intel CPU上执行相同的操作,因此只需保存字节,如果这一切都很重要。英特尔(pre-Skylake)上的TZCNT仍然假定依赖于所谓的只写输出操作数,就像BSF一样,以支持输入= 0的BSF未修改的BSF的未记录行为。因此,除非仅针对Skylake进行优化,否则您需要解决这个问题,因此额外的REP字节无法获得任何好处。 (英特尔经常超出x86 ISA手册所要求的范围,以避免破坏广泛使用的代码,这些代码依赖于它不应该的东西,或者追溯不允许的东西。例如 Windows 9x假设没有推测预读TLB条目 ,这在编写代码时是安全的, 在英特尔更新TLB管理规则 之前。)

无论如何,Haswell的LZCNT/TZCNT与POPCNT具有相同的假设:见 此问答 。这就是为什么在@ Veedrac的代码的gcc的asm输出中,你看到 在它不使用dst = src的情况下使用作为TZCNT目的地的寄存器上的xor-zeroing 来破坏dep链。由于TZCNT/LZCNT/POPCNT永远不会使其目标未定义或未修改,因此对Intel CPU上的输出的这种错误依赖纯粹是性能错误/限制。据推测,值得一些晶体管/功率使它们像其他uops一样运行到同一个执行单元。唯一的软件可见上行点是与另一个微体系结构限制的交互: 他们可以在Haswell上使用索引寻址模式 对内存操作数进行微型融合,但在Skylake中,英特尔删除了对LZCNT/TZCNT的错误依赖性“取消层叠“索引寻址模式,而POPCNT仍然可以微融合任何地址模式。


从其他答案改进想法/代码:

@ hidefromkgb的答案 有一个很好的观察,你保证能够在3n + 1之后做一次右移。您可以更有效地计算它,而不仅仅是在步骤之间省略检查。然而,该答案中的asm实现被破坏了(它取决于OF,在SHRD之后未定义,计数> 1),并且慢:ROR rdi,2SHRD rdi,rdi,2快,并且在关键路径上使用两个CMOV指令比额外的TEST可以并行运行。

我把整理/改进的C(引导编译器生成更好的asm),并在Godbolt上测试+工作更快的asm(在C中的注释中):请参阅 @ hidefromkgb的答案 中的链接。 (这个答案达到了大型Godbolt网址的30k char限制,但是 shortlinks可以腐烂 并且对goo.gl而言太长了。)

还改进了输出打印以转换为字符串并制作一个write(),而不是一次写一个字符。使用perf stat ./collatz(记录性能计数器)可以最大限度地减少对整个程序计时的影响,并且我对一些非关键的asm进行了去混淆。


@ Veedrac的代码

我从知道需要做的事情中获得了一个非常小的加速,并检查继续循环。从CoresDuo(Merom)的7.5s for limit = 1e8下降到7.275s,展开因子为16。

代码+评论 关于Godbolt 。请勿将此版本与clang一起使用;它使用延迟循环做一些愚蠢的事情。使用tmp计数器k然后将其添加到count后面会改变clang的作用,但是会伤害gcc。

请参阅注释中的讨论:Veedrac的代码是优秀在具有BMI1的CPU上(即不是赛扬/奔腾)

1818
Peter Cordes

声称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毫秒与1900毫秒),并且速度提高了两倍多(当使用-O3编译C++代码时,430 ms与830 ms)。

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

95
johnfound

为了获得更好的性能:一个简单的变化是观察到在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次完全由这8位决定。例如,如果最后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  -> ???? ???? ????

所以这些步骤都可以预测,256k + 1被81k + 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,少于8个除以2,并且一次做8个或更多个步骤将使你错过第一次达到1的点。然后继续“正常”循环 - 或准备一个表格,告诉您需要多少步骤才能达到1。

PS。我强烈怀疑Peter Cordes的建议会让它变得更快。除了一个之外,根本没有条件分支,除非循环实际结束,否则将正确预测一个。所以代码就像是

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。如果你需要操作次数:在每次迭代中,我们完成八个除以2和一个可变数量的(3n + 1)操作,因此计算操作的一个明显方法是另一个数组。但我们实际上可以计算步数(基于循环的迭代次数)。

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

21
gnasher729

在一个相当无关的说明:更多的性能黑客!

  • [第一个«猜想»最终被@ShreevatsaR揭穿;删除]

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

    1. [甚至] [奇数]
    2. [奇偶]
    3. [甚至] [偶数]

    跳过这两个元素意味着分别计算(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。右移第一个结果给了我们我们想要的东西。

    Q.E.D。:(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

参见C和Peter Cordes的改进/修复版本的asm 在Godbolt上 。 (编者注:很抱歉把我的东西放在你的答案中,但是我的答案达到了Godbolt链接+文字的30k char限制!)

18
hidefromkgb

在从源代码生成机器代码期间,C++程序被转换为汇编程序。说大会比C++慢,这几乎是错误的。而且,生成的二进制代码因编译器而异。因此,智能C++编译器 可能 生成二进制代码比哑组装器代码更优化和高效。

但是我相信你的分析方法有一定的缺陷。以下是分析的一般准则:

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

对于Collat​​z问题,您可以通过缓存“尾巴”来显着提升性能。这是时间/记忆的权衡。请参阅:memoization( 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 __== "__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))
5
Emanuel Landeholm

即使不看装配,最明显的原因是/= 2可能优化为>>=1,并且许多处理器具有非常快速的移位操作。但即使处理器没有移位操作,整数除法也比浮点除法快。

编辑: 你的milage可能会因上面的“整数除法比浮点除法”更快而有所不同。下面的评论表明,现代处理器优先考虑优化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
Dmitry Rubanovich

来自评论:

但是,这段代码永远不会停止(因为整数溢出)!?! Yves Daoust

对于许多数字,它将溢出。

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

这仍然是一个有趣的问题,是否有一些溢出循环的种子数?

任何简单的最终收敛序列都以两个值的幂开始(显而易见?)。

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

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

如何更复杂的数字,这将创建没有0的循环?坦率地说,我不确定,我的数学理论太朦胧,无法得到任何认真的想法,如何以严肃的方式处理它。但直觉上我会说每个数字系列会收敛到1:0 <数字,因为3n + 1公式会慢慢将原始数字(或中间)的每个非2素数因子转换为2的幂,迟早。所以我们不需要担心原始系列的无限循环,只有溢出才会妨碍我们。

所以我只是将几个数字放入表中并查看了8位截断数字。

有三个值溢出到02271708585直接转到0,其他两个转向85)。

但是没有创造循环溢出种子的价值。

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

有趣的是(对我来说)数字9232对于许多其他源数字来说是最大的,它有什么特别之处呢? :-O 9232 = 0x2410 ...嗯......不知道。

不幸的是我无法深入掌握这个系列,它为什么会聚并且将它们截断到 k bits有什么含义,但是使用cmp number,1终止条件它当然可以将算法置于无限循环中截断后特定输入值以0结尾。

但是,对于8位大小写溢出的值27是一种警告,如果你计算达到值1的步数,你会得到错误的结果,对于整个k位整数集中的大多数数字。对于8位整数,256个中的146个数字通过截断影响了系列(其中一些可能仍然意外地达到正确的步数,我懒得检查)。

4
Ped7g

你没有发布编译器生成的代码,所以这里有一些猜测,但即使没有看到它,也可以这样说:

test rax, 1
jpe even

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

编译器几乎肯定会进行两种计算(由于div/mod的延迟相当长,所以成本可忽略不计,所以乘法加法是“自由”)并跟随CMOV。当然,这有一个百分比被错误预测的可能性。

4
Damon

作为一般性答案,并非专门针对此任务:在许多情况下,您可以通过在高级别上进行改进来显着加快任何程序的速度。像计算数据一次而不是多次,完全避免不必要的工作,以最好的方式使用缓存,等等。这些东西在高级语言中更容易做到。

编写汇编程序代码, 可能 改进优化编译器的功能,但这很难。一旦完成,您的代码就很难修改,因此添加算法改进要困难得多。有时处理器具有您无法从高级语言使用的功能,内联汇编在这些情况下通常很有用,并且仍然允许您使用高级语言。

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

3
gnasher729