あなたは64ビットDIV命令は2による除算には良い方法だと思うならば、何の不思議は、コンパイラのアセンブラ出力でもして、あなたの手で書かれたコードを破っていない-O0
後にメモリに(高速コンパイル、余分な最適化、および店舗/リロード/デバッガーが変数を変更できるように、すべてのCステートメントの前。
効率的なasmの記述方法については、Agner Fogの最適化アセンブリガイドを参照してください。彼はまた、特定のCPUの特定の詳細についての指示表とmicroarchガイドを持っています。も参照してくださいx86 より多くのパフォーマンスリンクのためのタグウィキ。
手書きのasmでコンパイラーを打つことに関するこのより一般的な質問も参照してください:インラインアセンブリ言語はネイティブC ++コードより遅いですか?。TL:DR:(この質問のように)間違った場合はそうです。
通常は、特に効率的にコンパイルできるC ++を記述しようとする場合は特に、コンパイラーにその機能を実行させることができます。また、アセンブリはコンパイルされた言語よりも高速ですか?。回答の1つは、さまざまなCコンパイラーがいくつかの本当にシンプルな関数をクールなトリックでどのように最適化するかを示すこれらのきちんとしたスライドへのリンクです。 Matt GodboltのCppCon2017での講演「私のコンパイラは最近何ができましたか?コンパイラの蓋を外す」も同様です。
even:
mov rbx, 2
xor rdx, rdx
div rbx
Intel Haswellでは、div r64
36 uopsで、レイテンシは32〜96サイクル、スループットは21〜74サイクルあたり1です。(さらに、2つのuopsはRBXとゼロRDXをセットアップしますが、順不同の実行はそれらを早期に実行できます)。 DIVのような高uopカウントの命令はマイクロコード化されているため、フロントエンドのボトルネックを引き起こす可能性もあります。この場合、レイテンシはループキャリー依存チェーンの一部であるため、最も重要な要素です。
shr rax, 1
同じ符号なし除算を行います。これは1 uopで1cのレイテンシで、クロックサイクルごとに2つ実行できます。
比較すると、32ビット除算の方が高速ですが、シフトと比べて恐ろしいです。idiv r32
Haswellで9 uops、22〜29cのレイテンシ、8〜11cのスループットごとに1つです。
gccの-O0
asm出力(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だけで十分です。また、long
32ビットのみのシステム(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 =ビットをシフトアウトするのでcmovc
、test
/の代わりに使用できます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-zeroingでdepチェーンを壊すのがわかる理由です。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ではありません)。