Collat​​z予想を手書きのアセンブリよりも速くテストするためのC ++コード-なぜですか?


833

Project Euler Q14のこれら2つのソリューションは、アセンブリと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;
}

速度とすべてを改善するためのコンパイラー最適化について知っていますが、アセンブリーソリューションをさらに最適化する多くの方法がわかりません(数学的にではなくプログラムで話す)。

C ++コードは、学期ごとに係数、偶数学期ごとに除算を持ち、アセンブリは偶数学期ごとに1除算のみです。

しかし、アセンブリはC ++ソリューションよりも平均で1秒長くかかります。どうしてこれなの?主に好奇心を求めています。

実行時間

私のシステム:1.4 GHz Intel Celeron 2955U(Haswellマイクロアーキテクチャー)の64ビットLinux。


232
GCCがC ++プログラム用に生成するアセンブリコードを調べましたか?
ruakh

69
でコンパイルし-Sて、コンパイラが生成したアセンブリを取得します。コンパイラーは、モジュラスが同時に除算を行うことを認識するのに十分スマートです。
user3386109

267
私はあなたの選択肢は1だと思いますあなたの測定手法には欠陥があります。2。コンパイラーはあなたより優れたアセンブリーを作成します。または3.コンパイラーは魔法を使用します。
Galik、2016年


18
@jeffersonコンパイラはより高速なブルートフォースを使用できます。たとえば、おそらくSSE命令を使用します。
user253751 2016年

回答:


1896

あなたは64ビットDIV命令は2による除算には良い方法だと思うならば、何の不思議は、コンパイラのアセンブラ出力でもして、あなたの手で書かれたコードを破っていない-O0後にメモリに(高速コンパイル、余分な最適化、および店舗/リロード/デバッガーが変数を変更できるように、すべてのCステートメントの前。

効率的なasmの記述方法については、Agner Fogの最適化アセンブリガイドを参照してください。彼はまた、特定のCPUの特定の詳細についての指示表とmicroarchガイドを持っています。も参照してください より多くのパフォーマンスリンクのためのタグウィキ。

手書きのasmでコンパイラーを打つことに関するこのより一般的な質問も参照してください:インラインアセンブリ言語はネイティブC ++コードより遅いですか?。TL:DR:(この質問のように)間違った場合はそうです。

通常は、特に効率的にコンパイルできるC ++を記述しようとする場合は特に、コンパイラーにその機能を実行させることができます。また、アセンブリはコンパイルされた言語よりも高速ですか?。回答の1つは、さまざまなCコンパイラーがいくつかの本当にシンプルな関数をクールなトリックでどのように最適化するかを示すこれらのきちんとしたスライドへのリンクです。 Matt GodboltのCppCon2017での講演「私のコンパイラは最近何ができましたか?コンパイラの蓋を外す」も同様です。


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

Intel Haswellでは、div r6436 uopsで、レイテンシは32〜96サイクル、スループットは21〜74サイクルあたり1です。(さらに、2つのuopsはRBXとゼロRDXをセットアップしますが、順不同の実行はそれらを早期に実行できます)。 DIVのような高uopカウントの命令はマイクロコード化されているため、フロントエンドのボトルネックを引き起こす可能性もあります。この場合、レイテンシはループキャリー依存チェーンの一部であるため、最も重要な要素です。

shr rax, 1同じ符号なし除算を行います。これは1 uopで1cのレイテンシで、クロックサイクルごとに2つ実行できます。

比較すると、32ビット除算の方が高速ですが、シフトと比べて恐ろしいです。idiv r32Haswellで9 uops、22〜29cのレイテンシ、8〜11cのスループットごとに1つです。


gccの-O0asm出力(Godboltコンパイラエクスプローラー)を見るとわかるように、シフト命令のみを使用しています。clang -O0は、64ビットIDIVを2回使用しても、思ったように単純にコンパイルします。(最適化の際、ソースが除算と係数を使用する場合、コンパイラーはIDIVを使用する場合、IDIVの両方の出力を使用します(IDIVを使用する場合)。

GCCには完全に単純なモードはありません。常にGIMPLEを介して変換されますつまり、一部の「最適化」は無効にできません。これには、定数による除算の認識、およびシフト(2の累乗)または固定小数点乗算逆数(2の累乗以外)を使用してIDIVを回避することが含まれます(div_by_13上記のgodboltリンクを参照)。

gcc -Os(サイズの最適化)、残念ながら、乗法逆コードがわずかに大きいだけではるかに速い場合でも、2の累乗でない除算にIDIVを使用ます。


コンパイラーの支援

(この場合の概要:を使用uint64_t n

まず第一に、最適化されたコンパイラー出力を見るのは興味深いだけです。(-O3)。 -O0速度は基本的に意味がありません。

asm出力を確認します(Godboltで、またはGCC / clangアセンブリ出力から「ノイズ」を削除する方法を参照してください)。コンパイラーが最初から最適なコードを作成しない場合:コンパイラーがより良いコードを作成できるようにC / C ++ソースを作成することが、通常、最良のアプローチです。あなたはasmを知り、何が効率的かを知る必要がありますが、この知識を間接的に適用します。コンパイラーもアイデアの良い情報源です。時にはclangが何かクールなことをすることがあり、gccを手に持って同じことを行うことができます。この答えと、@ Veedracのコードのアンロールされていないループを使って何をしたかを参照してください。)

このアプローチは移植可能であり、20年後には将来のコンパイラーが将来のハードウェア(x86かどうかに関係なく)で効率的なものにコンパイルできるようになります。おそらく、新しいISA拡張または自動ベクトル化を使用します。15年前の手書きのx86-64 asmは、通常Skylake向けに最適に調整されません。たとえば、compare&branchマクロ融合は当時存在しませんでした。 1つのマイクロアーキテクチャーで手作りされたasmに現在最適なものは、他の現在および将来のCPUには最適ではない可能性があります。 @johnfoundの回答に対するコメントでは、このコードに大きな影響を与えるAMD BulldozerとIntel Haswellの主な違いについて説明しています。しかし、理論的に、g++ -O3 -march=bdver3かつg++ -O3 -march=skylake正しいことを行います。(または-march=native。)または-mtune=...、他のCPUがサポートしていない可能性のある命令を使用せずに、単にチューニングする。

私が思うに、あなたが気にする現在のCPUに適したものであるようにコンパイラーをガイドすることは、将来のコンパイラーでは問題にならないはずです。彼らはうまくいけば、コードを変換する方法を見つける点で現在のコンパイラよりも優れており、将来のCPUで機能する方法を見つけることができます。いずれにせよ、将来のx86はおそらく現在のx86の良い点でひどいものにはならず、将来のコンパイラーは、Cソースからのデータ移動のようなものを実装するときに、ASM固有の落とし穴を回避します。

手書きのasmはオプティマイザのブラックボックスであるため、インライン化によって入力がコンパイル時定数になると、定数伝播は機能しません。他の最適化も影響を受けます。asmを使用する前に、https: //gcc.gnu.org/wiki/DontUseInlineAsm をお読みください。(そしてMSVCスタイルのインラインasmを避けてください:入出力はメモリ経由する必要があり、オーバーヘッドが追加されます。)

この場合n署名された型があり、gccは正しい丸めを提供するSAR / SHR / ADDシーケンスを使用します。(IDIVと算術シフトの「丸め」は、負の入力では異なります。SARinsn set ref manual entryを参照してください)。(gcc nが負になり得ないことを証明しようとして失敗した場合のIDK 、または何。Signed-overflowは未定義の動作なので、可能だったはずです。)

を使用する必要がuint64_t nあったので、SHRだけで十分です。また、long32ビットのみのシステム(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サイクル、待ち時間のボトルネック。これと並行して、順不同の実行が他のすべてを処理します(理論的には、実際に5c / iterで実行されるかどうかを確認するためにパフォーマンスカウンターでテストしていません)。

cmov(TESTによって生成される)のFLAGS入力は、(LEA-> MOVからの)RAX入力よりも高速に生成されるため、クリティカルパス上にはありません。

同様に、CMOVのRDI入力を生成するMOV-> SHRは、LEAよりも高速であるため、クリティカルパスから外れています。IvyBridge以降のMOVのレイテンシはゼロです(レジスタ名変更時に処理されます)。(それでも、uopとパイプラインのスロットが必要なので、無料ではなく、レイテンシはゼロです)。LEA depチェーンの余分なMOVは、他のCPUのボトルネックの一部です。

cmp / jneもクリティカルパスの一部ではありません。クリティカルパスのデータ依存関係とは異なり、制御依存関係は分岐予測+投機的実行で処理されるため、ループ搬送されません。


コンパイラーを打ち負かす

GCCはここでかなり良い仕事をしました。のinc edx代わりにadd edx, 1を使用して、1つのコードバイトを節約できます。これは、部分フラグ変更命令のP4とその誤った依存関係について誰も気にしないためです。

また、すべてのMOV命令を保存することもでき、TEST:SHRはCF =ビットをシフトアウトするのでcmovctest/の代わりに使用できますcmovz

 ### 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を削除し、CMOVに使用します。(楽しい事実:Nehalem以前でcount!= 1のSHRを使用すると、フラグの結果を読み取るとストールが発生します。そのため、シングルUOPになっています。shift-by-1特殊エンコーディングは問題ありません。)

MOVを回避しても、Haswellのレイテンシはまったく改善されません(x86のMOVは本当に「無料」なのですか?なぜこれをまったく再現できないのですか?)。これは、Intel pre-IvBや、MOVがゼロレイテンシではないAMD BulldozerファミリなどのCPUで大きく役立ちます。コンパイラの無駄なMOV命令は、クリティカルパスに影響を与えます。BDの複雑なLEAとCMOVはどちらもレイテンシが低いため(それぞれ2cと1c)、レイテンシの大部分を占めています。また、整数のALUパイプが2つしかないため、スループットのボトルネックが問題になります。 AMDのCPUからのタイミング結果がある@johnfoundの回答を参照してください

Haswellでさえ、このバージョンは、クリティカルでないuopがクリティカルパス上の実行ポートから実行ポートを盗み、実行を1サイクル遅らせるという、時々の遅延を回避することで、少し役立つ場合があります。(これはリソースの競合と呼ばれます)。また、レジスタを節約します。これはn、インターリーブループで複数の値を並列に実行するときに役立ちます(以下を参照)。

LEAのレイテンシは、Intel SnBファミリCPU のアドレッシングモードによって異なります。3つのコンポーネントの場合は3c([base+idx+const]2つの個別の追加が必要)、ただし2つ以下のコンポーネントの場合は1cのみ(1つの追加)。一部のCPU(Core2など)は、単一コンポーネントの3コンポーネントLEAも実行しますが、SnBファミリは実行しません。さらに悪いことに、Intel SnBファミリはレイテンシを標準化しているため、2c uopsはありません。そうでない場合、3コンポーネントLEAはブルドーザーのように2cだけになります。(3コンポーネント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や他の静的分析ツールと同じ詳細ではなく、何らかのヒューリスティックを使用します。)


単純なループ展開は役に立ちません。このループは、ループオーバーヘッド/スループットではなく、ループキャリー依存チェーンのレイテンシをボトルネックにします。これは、CPUが2つのスレッドからの命令をインターリーブするのに多くの時間があるため、ハイパースレッディング(またはその他の種類のSMT)でうまく機能することを意味します。これは、でループを並列化することを意味しますがmain、各スレッドはn値の範囲をチェックし、結果として整数のペアを生成するだけなので、問題ありません。

1つのスレッド内で手動でインターリーブすることも可能です。たぶん、ペアの数値のシーケンスを並列に計算するかもしれません。それぞれのレジスタはカップルレジスタしかとらず、それらはすべて同じmax/を更新できるからmaxiです。これにより、命令レベルの並列処理が増えます

トリックは、別の開始値のペアを取得する前にすべてのn値が到達1するまで待機するnか、それとも他のシーケンスのレジスターに触れることなく、終了条件に達した1つだけのブレークアウトを開始して新しい開始点を取得するかを決定します。おそらく、各チェーンを有用なデータで機能させることをお勧めします。そうでない場合は、条件付きでカウンターをインクリメントする必要があります。


SSEのpacked-compareを使用してこれを実行し、まだ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つの後続ビットを一度に確認します(gnasherの答え)

@EOFは、tzcnt(またはbsf)を使用してn/=2、1つのステップで複数の反復を実行できることを指摘しています。それはおそらく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 uops、2cレイテンシ。(count = 0はフラグが変更されないことを意味するため、FLAGSに入力依存関係があります。これはデータ依存関係としてこれを処理し、uopは2つの入力しか持てないため、複数のuopsをとります(いずれにせよ、HSW / BDWより前))。これは、x86のcrazy-CISC設計について不満を言う人々が言及している種類です。これにより、ほとんど同じような方法でISAをゼロから設計した場合よりも、x86 CPUが遅くなります。(つまり、これは速度/電力を消費する「x86税」の一部です。)SHRX / SHLX / SARX(BMI2)は大きな勝利です(1 uop / 1cレイテンシ)。

また、クリティカルパスにtzcnt(Haswell以降では3c)を配置するため、ループキャリーされた依存関係チェーンの合計遅延が大幅に長くなります。n>>1ただし、CMOVの必要性や、を保持するレジスターの準備は不要です。@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(Skylake以前)上のTZCNTは、BSFと同様に、おそらく書き込み専用の出力オペランドに誤った依存関係を持ち、入力= 0のBSFが宛先を変更せずに残すという文書化されていない動作をサポートします。したがって、Skylakeのみに最適化しない限り、回避する必要があるため、余分なREPバイトから得るものはありません。(Intelは、多くの場合のx86 ISAマニュアルは、それはいけない、または遡及的に禁止されて何かに依存して広く使用されているコードを壊す避けるために、必要とするものの上と超えています。たとえば、Windows9x系のは、TLBエントリの投機的プリフェッチ負わないものとし、安全でした、コードが作成されたとき、インテルがTLB管理ルールを更新する前。)

とにかく、HaswellのLZCNT / TZCNTはPOPCNTと同じfalse depを持っています。このQ&Aを参照してください。これが、@ Veedracのコードのgccのasm出力で、dst = srcを使用しない場合にTZCNTの宛先として使用しようとしているレジスターのxor-zeroingdepチェーンを壊すのがわかる理由です。TZCNT / LZCNT / POPCNTは宛先を未定義または未変更のままにしないため、Intel CPUの出力に対するこの誤った依存関係は、パフォーマンスのバグ/制限です。おそらく、同じ実行ユニットに行く他のuopsのようにそれらを動作させることは、いくつかのトランジスタ/電力の価値があります。唯一のパフォーマンス向上は、もう1つのuarch制限との相互作用です。それらは、メモリオペランドをインデックス付きアドレッシングモードでマイクロフューズできます。 Haswellで、ただしSkylakeでIntelがLZCNT / TZCNTの誤ったdepを削除したところ、POPCNTは引き続き任意のaddrモードをマイクロヒューズ化できる一方で、それらは「非積層」のインデックス付きアドレッシングモードです。


他の回答からのアイデア/コードの改善:

@hidefromkgbの答えには、3n + 1の後に右シフトを1つ実行できることが保証されているという素晴らしい観察があります。これは、ステップ間のチェックを省略するだけの場合よりもさらに効率的に計算できます。ただし、その回答のasm実装は壊れており(カウントは1より大きく、SHRDの後には定義されていないOFに依存します)、slow:ROR rdi,2はより速くSHRD rdi,rdi,2、クリティカルパスで2つのCMOV命令を使用すると、追加のTESTよりも遅くなります並行して実行できます。

私は整頓された/改善されたC(コンパイラがより良いasmを生成するように導く)とテスト+動作の速いasm(Cの下のコメント)をGodboltに載せました:@hidefromkgbの回答のリンクを参照してください。(この回答は、大規模なGodbolt URLからの3万文字の制限に達しましたが、ショートリンクは回転する可能性があり、いずれにしてもgoo.glには長すぎます。)

また、文字列に変換し、一度write()に1つの文字を書き込む代わりに1つ作成するように出力印刷を改善しました。これにより、perf stat ./collatz(パフォーマンスカウンターを記録するための)プログラム全体のタイミングへの影響が最小限に抑えられ、重要ではないasmの一部が難読化されなくなりました。


@Veedracのコード

私たちのように私は限り右シフトからマイナーのスピードアップを持って知っているニーズをやって、ループを継続するためのチェック。アンロール係数が16のCore2Duo(Merom)では、limit = 1e8の7.5秒から7.275秒まで。

コード+ Godboltに関するコメント。このバージョンをclangで使用しないでください。それは遅延ループで愚かなことをします。tmpカウンターを使用し、kそれをcount後で追加すると、clangの動作が変わりますが、gccにわずかに傷が付きます。

コメントのディスカッションを参照してください:Veedracのコードは、BMI1を備えたCPUで優れています(つまり、Celeron / Pentiumではありません)。


4
私は少し前にベクトル化されたアプローチを試しましたが、役に立ちませんでした(tzcntベクトル化されたケースでは、ベクトル要素の中で最も実行時間が長いシーケンスにロックされているため)。
EOF 2016年

3
@EOF:いいえ、ときに任意の内部ループを抜け出し意味1ベクトル要素のヒットの1代わりに、彼らはときの、(PCMPEQ / PMOVMSKで容易に検出可能な)を有します。次に、PINSRQなどを使用して、終了した1つの要素(およびそのカウンター)をいじり、ループに戻ります。内側のループから頻繁に抜け出すと、それは簡単に損失につながる可能性がありますが、それは、内側のループの繰り返しごとに、常に2つか4つの要素の有用な作業が行われていることを意味します。しかし、メモ化についての良い点は。
Peter Cordes

4
@jefferson私が管理したベストはgodbolt.org/g/1N70Ibです。もっともっと賢いことができたらいいなと思っていましたが、できません。
Veedrac 2016年

87
このような信じられないほどの答えについて私を驚かせるのは、そのような詳細に示された知識です。私はそのレベルの言語やシステムを決して知りませんし、その方法も知りません。よくやった。
camden_kid 2016年

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 ++コードよりも約4倍高速です(-O0430 ms vs. 1900 msでコンパイルした場合)、2倍以上高速です(430 ms vs. 830 ms)。C++コードをでコンパイルした場合-O3

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


6
ええ、それは賢いです。SHRは、EAXが1(または0)の場合にのみZFを設定します。gccの-O3出力を最適化するときにそれを逃しましたが、内部ループに対して行った他のすべての最適化を見つけました。(しかし、なぜINCではなくLEAをカウンターのインクリメントに使用するのですか?その時点でフラグを上書きし、おそらくP4(INCとSHRの両方の古いフラグへの誤った依存関係)以外のスローダウンにつながることは問題ありません。)LEAはtは多くのポートで実行され、リソースの競合につながる可能性があり、クリティカルパスの遅延が頻繁に発生します。)
Peter Cordes '11

4
ああ、実際にはブルドーザーはコンパイラー出力のスループットにボトルネックがあるかもしれません。それはレイテンシーCMOVと3コンポーネントLEAをHaswell(私が検討していた)よりも低いので、ループで運ばれるdepチェーンはコードで3サイクルだけです。また、整数レジスターにはゼロレイテンシーのMOV命令がないため、g ++の無駄なMOV命令は実際にクリティカルパスのレイテンシーを増加させ、ブルドーザーにとって大きな問題となります。そう、そうです、手作業による最適化は、役に立たない命令をかき消すほど十分に最新ではないCPUにとって、重要な方法でコンパイラを打ち負かしています。
Peter Cordes

95
C ++コンパイラをより適切に主張することは非常に悪い間違いです。そして特にこの場合、人間は常にコードをよりよくすることができます。この特定の問題はこの主張の良い例です。」あなたはそれを元に戻すことができ、それは同じように有効です。「人間の方が優れていると主張することは非常に悪い間違いです。特にこの場合、人間は常にコードを悪化させる可能性があり、この特定の質問はこの主張の良い例証です。」だから、あなたはここでポイントを持っているとは思わない、そのような一般化は間違っています。
luk32

5
@ luk32-しかし、アセンブリ言語に関する知識がゼロに近いため、質問の作者はまったく論争になり得ません。人間対コンパイラーに関するすべての議論は、暗黙的に、少なくとも中間レベルのasm知識を持つ人間を想定しています。詳細:「人間が書いたコードは常にコンパイラが生成したコードよりも優れているか、同じである」という定理は、公式に証明するのが非常に簡単です。
johnfound

30
@ luk32:熟練した人間は、コンパイラーの出力から開始できます(通常は開始する必要があります)。したがって、実際に高速であることを確認するためのベンチマークを(ベンチマーク対象のハードウェア上で)する限り、コンパイラよりもパフォーマンスを低下させることはできません。しかし、ええ、私はそれが少し強い声明であることに同意しなければなりません。コンパイラは通常、初心者のasmコーダーよりもはるかに優れています。しかし、コンパイラが思いついたものと比較して、通常は1つまたは2つの命令を保存することが可能です。(ただし、uarchによっては、必ずしもクリティカルパス上にあるとは限りません)。これらは複雑な機械の非常に便利な部分ですが、「スマート」ではありません。
Peter Cordes、2016年

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ビットを見れば、あなたまでのすべてのステップが完全にそれらの8ビットによって決定されている2 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 + log2(n '/ n))/ log2(3)になります。

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


2
または、スイッチの代わりに、乗算用のデータルックアップテーブルを作成して定数を追加します。2つの256エントリテーブルのインデックス作成はジャンプテーブルよりも高速で、コンパイラはおそらくその変換を探していません。
Peter Cordes、

1
ええと、私はこの観察がCollat​​zの推測を証明するかもしれないとしばらく考えましたが、もちろんそうではありません。後続する可能性のあるすべての8ビットについて、すべてがなくなるまでのステップ数には限りがあります。しかし、これらの末尾の8ビットパターンの一部は、残りのビット文字列を8倍以上長くするため、無限の成長や繰り返しサイクルを除外できません。
Peter Cordes

を更新countするには、3番目の配列が必要ですよね? adders[]右シフトが何回行われたかはわかりません。
Peter Cordes

より大きなテーブルの場合、より狭いタイプを使用してキャッシュ密度を高めることは価値があります。ほとんどのアーキテクチャでは、aからのゼロ拡張ロードuint16_tは非常に安価です。x86では、それはちょうど、32ビットからゼロ拡張など安いようですunsigned intuint64_t。(Intel CPU上のメモリからのMOVZXはロードポートuopのみを必要としますが、AMD CPUもALUを必要とします。)ああBTW、なぜsize_tfor を使用しているのlastBitsですか?これは、32ビット型で-m32、さらに-mx32(32ビットポインター付きのロングモード)です。これは間違いなくのタイプではありませんn。だけを使用してくださいunsigned
Peter Cordes、

20

どちらかといえば無関係なメモ:パフォーマンスハックの増加!

  • [最初の«予想»は最終的に@ShreevatsaRによって明らかにされました。削除されました]

  • シーケンスをトラバースする場合、現在の要素の2近傍N(最初に表示)で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。最初の結果を右シフトすると、私たちが望んでいるとおりの結果が得られます。

    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シーケンスの合計の長さが奇数の場合、プロセスが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

Godboltの Peter Cordes による Cおよびasmの改良/バグ修正バージョンを参照してください。(編集者注:私の答えを私の答えに入れて申し訳ありませんが、私の答えはGodboltリンク+テキストからの3万文字の制限に達しました!)


2
そのQような積分はありません12 = 3Q + 1。あなたの最初の点は正しくない、と考えています。
Veedrac 2016年

1
@Veedrac:これで遊んでいます:ROR / TESTと1つのCMOVのみを使用して、この回答の実装よりも優れたasmで実装できます。私のCPU上でこのASMコードは無限ループ、それは明らかにしてSHRDまたはRORの後に定義されていないの、に依存するため、カウント> 1。また、回避しようとするために偉大な長さになりmov reg, imm32、明らかにバイトを保存するために、しかし、それは使用しています64ビットバージョンのレジスタは、どこにでもxor rax, raxあります。そのため、不要なREXプレフィックスがたくさんあります。nオーバーフローを回避するために、内部ループで保持しているregにREXのみが必要であることは明らかです。
Peter Cordes

1
タイミング結果(Core2Duo E6600から:Merom2.4GHz。Complex-LEA= 1cレイテンシ、CMOV = 2c)。最高のシングルステップasm内部ループ実装(Johnfoundから):この@mainループの実行ごとに111ms。難読化されていないこのCのコンパイラ出力(いくつかのtmp変数を含む):clang3.8 -O3 -march=core2:96ms。gcc5.2:108ms。私のclangのasm内部ループの改良バージョンから:92ms(複雑なLEAが1cではなく3cであるSnBファミリーではるかに大きな改善が見られるはずです)。このasmループの改善された+動作バージョンから(SHRDではなくROR + TESTを使用):87ms。印刷する前に5人で測定
Peter Cordes

2
最初の66のレコードセッター(OEISのA006877)を以下に示します。Iは太字でさえものをマークした: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 2016年

6

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

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

  1. システムが通常のアイドル状態であることを確認します。開始した、またはCPUを集中的に使用する(またはネットワーク経由でポーリングする)実行中のすべてのプロセス(アプリケーション)を停止します。
  2. データサイズのサイズを大きくする必要があります。
  3. テストは5〜10秒以上実行する必要があります。
  4. 1つのサンプルだけに依存しないでください。テストをN回実行します。結果を収集し、結果の平均または中央値を計算します。

はい、正式なプロファイリングは行っていませんが、両方とも数回実行しており、3秒から2秒を判別できます。とにかく答えてくれてありがとう。私はすでにかなりの量の情報をここで拾いました
jeffer son '11

9
それはおそらく単なる測定エラーではなく、手書きのasmコードは右シフトではなく64ビットのDIV命令を使用しています。私の答えを見てください。しかし、はい、正しく測定することも重要です。
Peter Cordes

7
箇条書きは、コードブロックよりも適切なフォーマットです。テキストはコードブロックに入れないでください。コードではないため、等幅フォントのメリットはありません。
Peter Cordes

16
これがどのように質問に答えるかは、私にはよくわかりません。これは、アセンブリコードまたはC ++コードがいるかどうかについて漠然と問題ではないかもしれない、それはについて非常に具体的な質問です---高速になり、実際のコードを、彼は親切に質問自体に提供されます。あなたの答えは、そのコードのどれも言及しておらず、どんなタイプの比較もしていません。確かに、ベンチマークの方法に関するヒントは基本的に正しいですが、実際の答えを出すには不十分です。
Cody Grey

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の答えは、テールをキャッシュするだけでなく、他にもできることを示しています。高ビットは次に何が起こるかには影響せず、キャリーを左に追加するだけで、高ビットは低ビットに何が起こるかに影響を与えません。つまり、LUTルックアップを使用して一度に8ビット(または任意の数)のビットに移動し、定数を乗算およ​​び追加して残りのビットに適用できます。テールをメモすることは、このような多くの問題で確かに役立ちます。また、より良いアプローチをまだ考えていない、または正しいことを証明していない場合のこの問題に役立ちます。
Peter Cordes

2
上記のグナッシャーのアイデアを正しく理解していれば、テールメモ化は直交最適化だと思います。したがって、おそらく両方を行うことができます。gnasherのアルゴリズムにメモを追加することでどれだけの利益が得られるかを調査することは興味深いでしょう。
Emanuel Landeholm 2016年

2
結果の密な部分のみを保存することで、メモ化をより安くすることができます。Nに上限を設定し、それを超えるとメモリをチェックしません。その下では、ハッシュ関数としてhash(N)-> Nを使用するので、key =配列内の位置であり、格納する必要はありません。のエントリは0まだ存在しません。テーブルに奇数のNを格納するだけでさらに最適化できるため、ハッシュ関数はn>>1で、1を破棄します。ステップコードを記述して、常にa n>>tzcnt(n)または何かで終了し、奇数であることを確認します。
Peter Cordes

1
これは、シーケンスの中央にある非常に大きなN値が複数のシーケンスに共通する可能性が低いという私の(予想外の)考えに基づいているので、それらをメモしないことで多くのことを逃しません。また、適度なサイズのNは、非常に大きなNで始まるものでも、多くの長いシーケンスの一部になります(これは希望的な考えかもしれません。それが間違っている場合、連続するNの密な範囲をキャッシュするだけで、ハッシュと比較して失われる可能性があります。任意のキーを格納できるテーブル。)近くの開始Nがシーケンス値に類似性がある傾向があるかどうかを確認するために、任意の種類のヒット率テストを行いましたか?
Peter Cordes

2
いくつかの大きなNに対して、すべてのn <Nについて事前に計算された結果を格納するだけで済みます。そのため、ハッシュテーブルのオーバーヘッドは必要ありません。そのテーブル内のデータがします、すべての開始値のために最終的に使用されます。Collat​​zシーケンスが常に(1、4、2、1、4、2、...)で終了することを確認したいだけの場合:これは、n> 1の場合、シーケンスが最終的に元のnより小さい。そしてそのためには、テールをキャッシュすることは役に立ちません。
gnasher729 2016年

5

コメントから:

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

多くの数値ではオーバーフローしません

それオーバーフローする場合-それらの不運な初期シードの1つでは、オーバーフローした数は、別のオーバーフローなしで1に収束する可能性が非常に高くなります。

それでも興味深い質問ですが、オーバーフローサイクルのシード数はありますか?

単純な最終収束シリーズは、2のべき乗の値で始まります(十分に明らかですか?)。

2 ^ 64はゼロにオーバーフローします。これは、アルゴリズムによれば未定義の無限ループです(1のみで終了します)が、shr raxZF = 1 が生成されるため、解の中で最も最適なソリューションが終了します。

2 ^ 64を生成できますか?開始番号が0x5555555555555555である場合、それは奇数であり、次の番号は3n + 1、つまり0xFFFFFFFFFFFFFFFF + 1= 0です。理論的にはアルゴリズムの定義されていない状態ですが、johnfoundの最適化された答えはZF = 1で終了することで回復します。cmp rax,1ピーターコルドのは、無限ループに終了する(QEDバリアント1、不定介して「安っぽい」0番号)。

より複雑な数値についてはどう0ですか?率直に言って、私にはわかりません。私の数学の理論は漠然としていて、真剣に考えて真剣に対処する方法はありません。しかし、直感的には、系列はすべての数値に対して1に収束すると言います:0 <数値。 。したがって、元のシリーズの無限ループを心配する必要はありません。オーバーフローのみが妨げになる可能性があります。

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

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

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

おかしなことに、私はチェックを行いました。これは、8ビットの切り捨ての27影響を受ける最初の数値であり、すでに影響を受けています!9232適切な切り捨てられていない系列の値に到達します(最初の切り捨てられた値は32212番目のステップにあります)。切り捨てられていない方法で2〜255の入力数値のいずれかに到達した最大値は、13120255それ自体の)最大ステップ数です。収束すること1は約です128(+ -2、「1」がカウントするかどうか不明...など)。

興味深いことに(私にとって)数9232は他の多くのソース番号の最大値ですが、何が特別なのですか?:-O 9232= 0x2410...うーん..わからない

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

しかし、278ビットのケースでオーバーフローする値は一種の警告です。これは、値に到達するまでのステップ数を数えると、1整数のkビットセットの合計から過半数の数値に対して誤った結果を得るように見えます。8ビット整数の場合、256のうち146の数値が切り捨てによって系列に影響を与えています(一部の数値は、偶然に正しいステップ数に達している可能性があります。チェックするのが面倒です)。


「オーバーフローした数値は、別のオーバーフローなしで1に収束する可能性が非常に高い」:コードが停止することはありません。(確かに、時間の終わりまで待つことができないので、それは推測です...)
イヴ・ダウスト

@YvesDaoustああ、でもそうですか?...たとえば、278bの切り捨てのある系列は次のようになります:82 41 124 62 31 94 47 142 71 214107 66(切り捨て)33100 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 2016年

1
元の問題の説明はまだ確認されていません(「Collat​​z問題」)はまだ証明されていませんが、すべての開始番号は1で終わると考えられています。」... [OK]を、私は私の限られたかすんで数学の知識でそれの把握を得ることができないのも不思議では...:Dそして、私のシートの実験からは、私はそれがすべてのために収束しないあなたを保証することはできません2- 255のいずれか切り捨てずに(に、数1、)または、8ビットの切り捨てを使用します(予期される、1または03つの数値に対する)。
Ped7g 2016年

ヘム、止まらないって言ったら、止まらないってこと。指定したコードは、必要に応じて永久に実行されます。
Yves Daoust、2016年

1
オーバーフローで何が起こるかを分析するために賛成。CMPベースのループはcmp rax,1 / jna(つまりdo{}while(n>1))を使用して、ゼロで終了することもできます。nオーバーフローがどれだけ近いかを知るために、見られた最大値を記録するループのインストルメントバージョンを作成することを考えました。
Peter Cordes

5

コンパイラーによって生成されたコードをポストしなかったため、ここでは推測が行われていますが、それを見ていなくても、次のように言うことができます。

test rax, 1
jpe even

...ブランチを誤って予測する可能性が50%あり、それは高くつくことになります。

コンパイラーはほぼ確実に両方の計算を行い(div / modはレイテンシが非常に長いため無視できるほど多くのコストがかかるため、乗算加算は「無料」です)、CMOVに従います。もちろん、これは誤って予測される可能性がゼロパーセントです。


1
分岐にはいくつかのパターンがあります。たとえば、奇数の後には常に偶数が続きます。しかし、3n + 1は複数の後続ゼロビットを残す場合があり、その場合はこれが誤った予測になります。私は私の答えで除算について書き始めました、そしてOPのコードでこの他の大きな赤い旗に対処しませんでした。(また、JZやCMOVZだけと比較して、パリティ条件の使用は実際に奇妙です。IntelCPUはTEST / JZをマクロフューズできますが、TEST / JPEはマクロフューズできないため、CPUにとっても悪いです。 JCCを使用したTEST / CMP。その場合、人間の読者にとってはさらに悪いことになります)
Peter Cordes

5

アセンブリを見ていなくても、最も明らかな理由は、多くのプロセッサが非常に迅速なシフト操作を持っているため、/= 2おそらく最適化され>>=1ていることです。ただし、プロセッサにシフト演算がない場合でも、整数除算は浮動小数点除算よりも高速です。

編集: あなたの走行距離は、上記の「整数除算は浮動小数点除算より速い」というステートメントで異なる場合があります。以下のコメントは、最近のプロセッサが整数除算よりもfp除算の最適化を優先していることを示しています。だから、誰かが、このスレッドの質問がについて尋ねる高速化のための最も可能性の高い理由は、コンパイラ最適化を探していた場合/=2など>>=1見た目に最高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で、部分的にパイプライン化されています)、レイテンシも優れています。
Peter Cordes

1
たとえば、OPのHaswell CPUの場合:DIVSDは1 uop、10〜20サイクルのレイテンシ、8〜14cのスループットごとに1つです。 div r6436 uops、32〜96cのレイテンシ、21〜74cあたり1つのスループットです。SkylakeのFP除算のスループットはさらに高速です(4cごとに1つでパイプライン化され、レイテンシはそれほど良くありません)が、整数のdivはそれほど高速ではありません。AMD Bulldozerファミリでも同様です。DIVSDは1Mオペレーション、9〜27cのレイテンシ、4.5〜11cのスループットごとに1つです。 div r6416M-ops、16-75cレイテンシ、16-75cスループットごとに1つです。
Peter Cordes

1
FP除算は基本的に整数減算指数、整数除算の仮数と同じではなく、非正規化を検出しますか?そして、これらの3つのステップは並行して実行できます。
MSalters 2016年

2
@MSalters:そうですね、そうですが、最後に正規化ステップがあり、指数とカマキリの間のビットをシフトします。 double53ビットの仮数がありますが、それでもdiv r32Haswell よりも大幅に低速です。つまり、整数とfpの両方の分周器に同じトランジスタを使用しないため、Intel / AMDが問題をどれだけハードウェアに投入するかが問題になります。整数1はスカラーであり(整数-SIMD除算はありません)、ベクトル1は(他のベクトルALUのように256bではなく)128bベクトルを処理します。大きなことは、整数のdivは多くのuopsであり、周囲のコードに大きな影響を与えるということです。
Peter Cordes、

Err、仮数と指数の間でビットをシフトせず、仮数をシフトで正規化し、シフト量を指数に追加します。
Peter Cordes

4

このタスクに特に向けられていない一般的な答えとして:多くの場合、高レベルで改善を行うことにより、プログラムを大幅にスピードアップできます。データを複数回ではなく1回計算するように、不要な作業を完全に回避する、最適な方法でキャッシュを使用するなど。これらは、高級言語で行う方がはるかに簡単です。

アセンブラコードを書くことは可能です、最適化コンパイラーが何をするかを改善するが、それは大変な作業です。そして、それが完了すると、コードを変更するのがはるかに難しくなるため、アルゴリズムを改善することははるかに困難になります。場合によっては、プロセッサに高水準言語では使用できない機能が備わっていることがあります。インラインアセンブリはこれらの場合に役立つことが多く、それでも高水準言語を使用できます。

オイラー問題では、ほとんどの場合、何かを構築すること、それが遅い理由を見つけること、より良いものを構築すること、それが遅い理由を見つけることなどによって成功します。これは、アセンブラを使用することは非常に困難です。可能な速度の半分でより良いアルゴリズムは通常、フルスピードでより悪いアルゴリズムを打ち負かし、アセンブラでフルスピードを取得することは簡単ではありません。


2
これに完全に同意します。 gcc -O3その正確なアルゴリズムに対して、Haswellで最適の20%以内のコードを作成しました。(これらの高速化を取得することが私の答えの主な焦点でした。それはそれが質問がしたものであり、興味深い答えがあるからです。それが正しいアプローチではないからです。)コンパイラーが探す可能性が非常に低い変換から、はるかに大きな高速化が得られました、右シフトを延期したり、一度に2ステップ実行したりします。メモ化/ルックアップテーブルを使用すると、それよりもはるかに高速になります。まだ徹底的なテストですが、総当たりではありません。
Peter Cordes

2
それでも、明らかに正しい単純な実装は、他の実装のテストに非常に役立ちます。私がやろうとしていることは、おそらくasmの出力を見て、gccが期待どおりに分岐せずに(ほとんどは不思議なことに)実行したかどうかを確認してから、アルゴリズムの改善に進むことです。
Peter Cordes

-2

簡単な答え:

  • MOV RBX、3、お​​よびMUL RBXの実行にはコストがかかります。RBXを追加する、RBXを2回追加する

  • ADD 1はおそらくここでINCよりも高速です

  • MOV 2とDIVは非常に高価です。ちょうど右にシフト

  • 通常、64ビットコードは32ビットコードよりも著しく遅く、配置の問題はさらに複雑です。このような小さなプログラムでは、並列計算を行って32ビットコードよりも高速になる可能性があるため、それらをパックする必要があります。

C ++プログラムのアセンブリリストを生成すると、アセンブリとの違いを確認できます。


4
1):3回追加すると、LEAに比べて馬鹿げたことになります。またmul rbx、OPのHaswell CPUは2 uopsで3cレイテンシ(および1クロックあたり1スループット)です。 imul rcx, rbx, 3同じ3cレイテンシの1 uopのみです。2つのADD命令は2 uopsで2cレイテンシになります。
Peter Cordes

5
2)ここでは、ADD 1はINCよりも高速ですいいえ、OPはPentium4を使用していません。あなたのポイント3)はこの答えの唯一の正しい部分です。
Peter Cordes

5
4)まったくナンセンスに聞こえる。64ビットコードは、ポインタの多いデータ構造では遅くなる可能性があります。これは、ポインタが大きくなるほど、キャッシュフットプリントが大きくなるためです。ただし、このコードはレジスターでのみ機能し、コードアライメントの問題は32ビットモードと64ビットモードで同じです。(したがって、データアライメントの問題も同様です。x86-64では、アライメントがより大きな問題であることについて、何を話しているのかわかりません)。とにかく、コードはループ内のメモリにさえ触れません。
Peter Cordes

コメンターは何について話しているのか分かりません。64ビットCPUでMOV + MULを実行すると、それ自体にレジスタを2回追加するよりも約3倍遅くなります。彼の他の発言も同様に間違っています。
タイラーダーデン、2016年

6
まあ、MOV + MULは間違いなく馬鹿げていますが、MOV + ADD + ADDはまだばかげています(実際にADD RBX, RBX2回実行すると、3ではなく4が乗算されます)。断然最善の方法はlea rax, [rbx + rbx*2]です。または、それを3コンポーネントのLEAにすることを犠牲にして、+ 1も実行しますlea rax, [rbx + rbx*2 + 1] (私の回答で説明したように、HSWでの3cレイテンシが1ではなく1)。私のポイントは、64ビット乗算はそれほど高価ではないということでした最近のIntel CPU、それはめちゃくちゃ速い整数乗算ユニットを持っているため(AMDと比較しても同じMUL r64で、6cレイテンシで、4cあたり1スループット:完全にパイプライン化されていません。)
Peter Cordes
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.