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

时间:2016-11-01 06:12:06

标签: c++ performance assembly optimization x86

我在汇编和C ++中为Project Euler Q14编写了这两个解决方案。它们与测试Collatz conjecture的蛮力方法相同。装配解决方案用

组装
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 Linux 1.4 GHz Intel Celeron 2955U(Haswell微体系结构)。

10 个答案:

答案 0 :(得分:96)

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

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

答案 1 :(得分:21)

为了获得更好的性能:一个简单的改变是观察到在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  -> ???? ???? ????

因此可以预测所有这些步骤,并且用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而少于8个除以2,并且一次做8个或更多个步骤会让你错过第一次达到1的点。然后继续&#34;正常&#34;循环 - 或准备一张表,告诉你需要多少步骤才能达到1.

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

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&lt; -3n + 1和s个操作n&lt; -n / 2。结果将完全是n&#39; = n * 3 ^ r / 2 ^ s,因为n <-3n + 1意味着n <-3n *(1 + 1 / 3n)。取对数,我们发现r =(s + log2(n&#39; / n))/ log2(3)。

如果我们进行循环直到n≤1,000,000并且有一个预先计算的表,从任何起始点n≤1,000,000需要多少次迭代然后如上所述计算r,四舍五入到最接近的整数,将给出正确的结果,除非s是真的大。

答案 2 :(得分:18)

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

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

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

    1. [even] [odd]
    2. [odd] [even]
    3. [偶数] [偶数]
    4. 跳过这两个元素意味着分别计算(N >> 1) + N + 1((N << 1) + N + 1) >> 1N >> 2

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

      案例(1)显而易见。情况(2)暗示(N & 1) == 1,所以如果我们假设(不失一般性)N是2位长且其位从ba从最重要到最不重要,那么{{1} },以下是:

      a = 1

      其中(N << 1) + N + 1: (N >> 1) + N + 1: b10 b1 b1 b + 1 + 1 ---- --- bBb0 bBb 。右移第一个结果给了我们我们想要的东西。

      Q.E.D。:B = !b

      证明,我们可以使用单个三元运算一次遍历序列2个元素。另外2倍的时间缩短。

生成的算法如下所示:

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

这里我们比较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; } ,因为如果序列的总长度是奇数,则进程可能会停在2而不是1。

[编辑:]

让我们把它翻译成集会!

n > 2

使用以下命令编译:

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;

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

答案 3 :(得分:5)

在从源代码生成机器代码期间,C ++程序被转换为汇编程序。说汇编比C ++慢,这几乎是错误的。而且,生成的二进制代码因编译器而异。因此,智能C ++编译器可以生成二进制代码,而不是一个愚蠢的汇编程序代码。

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

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

答案 4 :(得分:5)

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

答案 5 :(得分:4)

来自评论:

  

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

对于许多数字,它溢出。

如果溢出 - 对于其中一个不幸的初始种子,溢出的数字很可能会收敛到1,而不会再出现溢出。

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

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

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

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

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

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

有三个值溢出到0022717085直接转到85,另外两个值正在进行走向0)。

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

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

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

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

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

答案 6 :(得分:4)

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

test rax, 1
jpe even

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

编译器几乎肯定会进行两种计算(由于div / mod的延迟相当长,因此成本可忽略不计,因此乘法加法是&#34; free&#34;)并跟进CMOV。当然,这有可能被错误预测的百分之百。

答案 7 :(得分:4)

即使不查看汇编,最明显的原因是/= 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;
}

答案 8 :(得分:3)

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

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

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

答案 9 :(得分:-2)

简单的答案:

  • 做一个MOV RBX,3和MUL RBX很贵;只需添加RBX,RBX两次

  • ADD 1可能比INC快

  • MOV 2和DIV非常昂贵;只是向右移动

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

如果为C ++程序生成程序集列表,则可以看到它与程序集的不同之处。