关于性能:C++代码用于测试Collatz猜想比手写汇编快-为什么?

C++ code for testing the Collatz conjecture faster than hand-written assembly - why?

我为Euler Q14编写了这两个解决方案,在汇编和C++中。它们是用于测试collatz猜想的相同的蛮力方法。组装溶液与

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

用C++编译

1
g++ p14.cpp -o p14

装配,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。


如果您认为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=bdver3g++ -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指出,tzcntbsf可用于一步进行多个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,2SHRD 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上非常出色(即,不是赛扬/奔腾)好的。好啊。


声称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。


为了获得更高的性能:一个简单的变化是观察到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真的很大。


在一个相当不相关的注释:更多的性能黑客!

  • [第一个?猜想?已被@shrevatsar最终揭穿;已移除]
  • 当遍历序列时,在当前元素N的2-邻域中只能得到3个可能的情况(首先显示):

  • [偶数] [奇数]
  • [奇数] [偶数]
  • [甚至]
  • 跳过这两个元素意味着分别计算(N >> 1) + N + 1((N << 1) + N + 1) >> 1N >> 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字符限制!)


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


在源代码生成机器代码期间,C++程序被翻译成汇编程序。说程序集比C++慢实际上是错误的。此外,生成的二进制代码因编译器而异。因此,智能编译器可以产生比哑汇编程序代码更为优化和高效的二进制代码。

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

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

  • 即使不考虑组装,最明显的原因是,/= 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;
    }


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

    1
    2
    test rax, 1
    jpe even

    …有50%的机会预测不到分支,这将是昂贵的。

    编译器几乎肯定会同时进行两种计算(由于DIV/mod的延迟时间很长,因此乘法加法是"免费的")并使用cmov进行后续操作。当然,它有百分之零的可能被误判。


    从评论:

    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位截断的数字。

    有三个值溢出到022717085(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个数字已经受到截断的影响(其中一些数字可能仍然意外地达到正确的步数,可能我太懒了,无法检查)。


    作为一个一般性的回答,不是专门针对这个任务:在许多情况下,通过在高水平上进行改进,您可以显著地加快任何程序的速度。像计算一次而不是多次数据,完全避免不必要的工作,以最佳方式使用缓存等等。用高级语言做这些事情要容易得多。

    编写汇编程序代码,可以改进优化编译器的功能,但这是一项艰巨的工作。一旦完成,您的代码就很难修改,所以添加算法改进就更加困难了。有时,处理器具有不能从高级语言使用的功能,在这些情况下,内联汇编通常很有用,但仍然允许您使用高级语言。

    在欧拉问题中,大多数时候你成功的方法是建立一些东西,找出为什么它是缓慢的,建立一些更好的,找到为什么它是缓慢的,等等。使用汇编程序非常非常困难。在可能的一半速度下,一个更好的算法通常会在全速下打败一个更差的算法,而在汇编程序中获得全速并不是一件小事。


    简单的答案:

    • 做mov-rbx,3和mul-rbx是昂贵的;只需添加两次rbx,rbx

    • 这里的加法1可能比inc快。

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

    • 64位代码通常明显慢于32位代码,并且对齐问题更复杂;对于像这样的小程序,您必须将它们打包,这样您就可以进行并行计算,从而有机会比32位代码更快。

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