web-dev-qa-db-ja.com

Collat​​z予想を手書きのAssemblyよりも速くテストするためのC++コード - なぜでしょうか。

私はアセンブリとC++で Project Euler Q14 のためにこれら二つの解決策を書きました。これらは コラッツ予想をテストするための同じ同一のブルートフォースアプローチです 。アセンブリソリューションは、

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++コードには、用語ごとにモジュラス、偶数用語ごとに除算があります。アセンブリは、偶数用語ごとに1つの除算しかありません。

しかし、総会はC++ソリューションよりも平均1秒長くかかります。どうしてこれなの?私は主に好奇心から頼みます。

実行時間

私のシステム:1.4 GHz Intel Celeron 2955U(Haswell microarchitecture)上の64ビットLinux。

778
jeffer son

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++コードより約4倍速く(-O0でコンパイルした場合:430ミリ秒対1900ミリ秒)、2倍以上高速です( C++コードが-O3でコンパイルされている場合は、430ミリ秒対830ミリ秒).

両方のプログラムの出力は同じです。最大シーケンス= 525、i = 837799です。

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;
    }
}

これが big winです。nの最下位8ビットを見ると、2×8で除算するまでのすべてのステップは、これら8ビットによって完全に決まります。たとえば、最後の8ビットが0x01の場合、それは2進数です。あなたの数は????です。 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は2で8分割よりも少ない数で1になる可能性があり、一度に8以上のステップを実行すると、初めて1に達するポイントを見逃すことになります。それから、「通常の」ループを続けるか、または1に到達するために必要なステップ数を示すテーブルを用意してください。

PS。私はPeter Cordesの提案がそれをもっと早くするだろうと強く疑う。 1つを除いて条件分岐はまったく存在せず、ループが実際に終了する場合を除いて、その分岐は正しく予測されます。そのため、コードは次のようになります。

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ビットを処理する方が速いかどうかを測定します。ビットごとに、テーブル内のエントリ数が2倍になり、テーブルがL1キャッシュに収まらなくなった場合は速度が低下します。

PPSもしあなたが操作の数を必要とするならば:各繰り返しで我々はちょうど2で8つの除算と(3n + 1)操作の可変数をするので、操作を数えるための明らかな方法は別の配列でしょう。しかし、実際には(ループの反復回数に基づいて)ステップ数を計算できます。

この問題を少し再定義することができます。奇数の場合はnを(3n + 1)/ 2に置き換え、偶数の場合はnをn/2に置き換えます。それで、すべての反復は正確に8つのステップをするでしょう、しかし、あなたはその不正行為を考えることができました:-)それで、r操作n < - 3n + 1とs操作n < - n/2があったと仮定します。 n < - 3n + 1はn < - 3n *(1 + 1/3n)を意味するため、結果はまったく正確にn '= n * 3 ^ r/2 ^ sになります。対数をとると、r =(s + log 2(n '/ n))/ log 2(3)となります。

N≤1,000,000までループし、任意の開始点n≤1,000,000から何回の反復が必要かを計算したテーブルがある場合、上記のようにrを計算して最も近い整数に丸めても、sが本当に大きくなければ正しい結果になります。

21
gnasher729

どちらかといえば無関係なメモとして:より多くのパフォーマンスハック!

  • [最初の「予想」は@ShreevatsaRによってようやくデビューされました。削除]

  • シーケンスをたどるとき、現在の要素Nの2近傍にある3つの可能なケースだけを得ることができます(最初に示されています):

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

    これら2つの要素を飛び越えることは、それぞれ(N >> 1) + N + 1((N << 1) + N + 1) >> 1およびN >> 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

    証明されているように、単一の3項演算を使用して、一度に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を比較します。シーケンスの全長が奇数の場合、プロセスは1ではなく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;

これらのコマンドを使ってコンパイルします。

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

Cと、Peter Cordesによるasmの改良/バグ修正版 on Godbolt を参照してください。 (編集者注:あなたの答えに私のものを入れてすみませんが、私の答えはGodboltリンク+テキストからの30,000文字の制限を打ちました!)

18
hidefromkgb

C++プログラムは、ソースコードからのマシンコードの生成中にアセンブリプログラムに変換されます。アセンブリがC++より遅いと言うのは事実上間違っているでしょう。さらに、生成されるバイナリコードはコンパイラによって異なります。スマートC++コンパイラ may は、ダムアセンブラのコードよりも最適で効率的なバイナリコードを生成します。

しかし、私はあなたのプロファイリング方法論には特定の欠陥があると思います。以下は、プロファイリングに関する一般的なガイドラインです。

  1. システムが正常/アイドル状態になっていることを確認してください。起動した、またはCPUを集中的に使用している(またはネットワークを介してポーリングする)実行中のプロセス(アプリケーション)をすべて停止します。
  2. あなたのデータサイズはもっと大きくなければなりません。
  3. あなたのテストは5-10秒以上の間実行されなければなりません。
  4. 1つのサンプルだけに頼らないでください。テストをN回実行します。結果を収集して、結果の平均または中央値を計算してください。

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

アセンブリを見なくても、最も明白な理由は/= 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

コメントから:

しかし、このコードは停止しません(整数オーバーフローのため)。イヴ・ダウスト

多くの数値では、{notがオーバーフローします。

それがwill overflow - これらの不運な初期シードの1つに対して、オーバーフロー数は別のオーバーフローなしに1に収束する可能性が非常に高いです。

それでも、これは興味深い疑問を投げかけます、オーバーフロー循環シード数はありますか?

単純な最終収束級数は2のべき乗で始まります(明らかに十分でしょうか?)。

2 ^ 64は0までオーバーフローします。これは、アルゴリズムに応じて未定義の無限ループです(1でのみ終了します)が、shr raxがZF = 1を生成するため、最も最適な解法は終了します。

2 ^ 64を生産できますか?開始番号が0x5555555555555555の場合、それは奇数であり、次の番号は3n + 1となり、これは0xFFFFFFFFFFFFFFFF + 1 = 0です。理論的には未定義のアルゴリズムの状態ですが、johnfoundの最適化された答えはZF = 1で終了することによって回復します。 Peter Cordes cmp rax,1は、無限ループで終わります(QED変種1、未定義の0番号を介した「cheapo」)。

0なしでサイクルを作成する、もっと複雑な数値はどうでしょうか。率直に言って、私は定かではありません。私の数学の理論はあまりにも漠然としていて、真剣な考えを得るにはあまりにも漠然としています。直感的には、3n + 1の公式が2のべき乗でない元の数(または中間)のすべてを2のべき乗にゆっくり変えるので、0 <numberのようにすべての数に対して1に収束すると思います。 。そのため、元のシリーズの無限ループを気にする必要はありません。オーバーフローだけが私たちの妨げになる可能性があります。

それで、私はいくつかの数字をシートに入れて、8ビットの切り捨てられた数字を調べました。

0にオーバーフローする3つの値があります:227170および8585は直接0に進み、他の2つは85に向かって進みます)。

しかし、循環オーバーフローシードを作成する価値はありません。

おかしなことに私はチェックをしました。これは8ビットの切り捨てに苦しむ最初の数字で、すでに27が影響を受けています。それは適切な非切捨てシリーズで値9232に達し(最初の切捨て値は12番目のステップで322です)、非切捨て方法で2から255の入力数のいずれかに達する最大値は13120です(255自体の場合)。 1に収束するための最大ステップ数は、128程度です(+ -2、 "1"がカウントされるかどうか、など)。

興味深いことに(私にとっては)9232という数字が他の多くのソース番号で最大になっていますが、それに関して何が特別なのでしょうか。 :-O 9232 = 0x2410 ...うーん、わかりません。

残念ながら、このシリーズを深く理解することはできません。なぜ収束するのでしょうか。それらをkビットに切り捨てることの意味は何ですか?cmp number,1の終了条件により、アルゴリズムを無限ループにすることは確かに可能です。切り捨て後の特定の入力値は0で終わります。

しかし、8ビットの場合にオーバーフローする値27は一種の警告です。これは、値の1に達するまでのステップ数を数えると、整数の大部分のkビットから誤った結果を得ることになります。 8ビット整数の場合、256個のうち146個の数値が切り捨てによって系列に影響を与えています(それらの中には、偶然に正しいステップ数に達する可能性があるものもあります)。

4
Ped7g

あなたはコンパイラによって生成されたコードを投稿しなかったので、ここにいくらかの推測があります、しかしそれを見たことがなくても、これは次のように言うことができます:

test rax, 1
jpe even

...ブランチを50%誤って予測する可能性がありますが、それには費用がかかります。

コンパイラはほぼ確実に両方の計算(div/modが非常に長い待ち時間であるため、乗算と加算が "自由"であるために無視できるほど多くの費用がかかります)とCMOVでフォローアップします。もちろん、これは予測ミスの可能性が ゼロ パーセントになります。

4
Damon

一般的な答えとして、特にこのタスクに向けられたものではありません。多くの場合、高度なレベルで改善を加えることで、プログラムを大幅にスピードアップすることができます。何度もデータを計算するのではなく、不要な作業を完全に回避したり、キャッシュを最善の方法で使用したりするのと同様です。これらのことは、高級言語でははるかに簡単です。

アセンブラコードを書くと、最適化コンパイラの動作を改善するのは 可能 ですが、大変な作業です。そして一度それが行われれば、あなたのコードは修正するのがはるかに難しいので、アルゴリズムの改良を加えることははるかに困難です。プロセッサには高水準言語からは使用できない機能がある場合があります。インラインアセンブリはこのような場合に便利で、それでも高水準言語を使用できるようにします。

オイラー問題では、ほとんどの場合、何かを構築し、それがなぜ遅いのかを見つけ、何かをよりよく構築し、そしてなぜそれが遅いのかを見つけることなどで成功します。アセンブラを使うのはとても、とても大変です。可能な半分の速度でより良いアルゴリズムを使用すると、通常、最悪のアルゴリズムをフルスピードで使用できなくなります。アセンブラでフルスピードを使用するのは簡単ではありません。

3
gnasher729