これは、いくつかの非常に独特な動作を示すC ++コードの一部です。奇妙な理由で、データを奇跡的に並べ替えると、コードがほぼ6倍速くなります。 #include#include #include int main() {{ //データを生成します const unsigned arraySize = 32768; int data [arraySize]; for(unsigned c = 0; c = 128) 合計+ =データ[c]; } } doublelapsedTime = static_cast (clock()-start)/ CLOCKS_PER_SEC; std :: cout << lapsedTime << std :: endl; std :: cout << "sum =" << sum << std :: endl; } std :: sort(data、data + arraySize);がない場合、コードは11.54秒で実行されます。 ソートされたデータを使用すると、コードは1.93秒で実行されます。 当初、これは単なる言語またはコンパイラの異常である可能性があると思ったので、Javaを試しました。 インポートjava.util.Arrays; インポートjava.util.Random; パブリッククラスメイン {{ public static void main(String [] args) {{ //データを生成します int arraySize = 32768; int data [] = new int [arraySize]; Random rnd = new Random(0); for(int c = 0; c = 128) 合計+ =データ[c]; } } System.out.println((System.nanoTime()-開始)/ 1000000000.0); System.out.println( "sum =" + sum); } } 同様ですが、それほど極端ではない結果になります。 私の最初の考えは、並べ替えによってデータがキャッシュに取り込まれることでしたが、配列が生成されたばかりであるため、それがどれほどばかげているかを考えました。 何が起こっている? ソートされた配列の処理が、ソートされていない配列の処理よりも速いのはなぜですか? コードはいくつかの独立した用語を要約しているので、順序は重要ではありません。
2020-12-07 12:58:09
あなたは分岐予測の失敗の犠牲者です。 分岐予測とは何ですか? 鉄道のジャンクションについて考えてみましょう。 ウィキメディアコモンズ経由のMecanismoによる画像。 CC-By-SA3.0ライセンスの下で使用されます。 議論のために、これが1800年代に戻ったと仮定します-長距離または無線通信の前に。 あなたはジャンクションの運営者であり、電車が来るのが聞こえます。あなたはそれがどちらの方向に進むべきか見当がつかない。あなたは電車を止めて、運転手にどちらの方向を望むか尋ねます。そして、スイッチを適切に設定します。 電車は重くて慣性が大きいです。そのため、起動と速度低下に永遠に時間がかかります。 もっと良い方法はありますか?あなたは電車がどちらの方向に行くかを推測します! あなたが正しく推測した場合、それは続きます。 あなたが間違っていると推測した場合、船長は停止し、後退し、スイッチを切り替えるようにあなたに怒鳴ります。その後、他のパスから再起動できます。 毎回正しく推測すれば、電車は止まる必要はありません。よく間違えると、列車は停車、バックアップ、再起動に多くの時間を費やします。 ifステートメントについて考えてみます。プロセッサレベルでは、これは分岐命令です。 あなたはプロセッサであり、ブランチが表示されます。あなたはそれがどちらの方向に進むのか分かりません。職業はなんですか?実行を停止し、前の命令が完了するまで待ちます。次に、正しいパスを続行します。 最新のプロセッサは複雑で、パイプラインが長くなっています。したがって、彼らは「ウォームアップ」と「スローダウン」に永遠にかかります。 もっと良い方法はありますか?あなたは枝がどちらの方向に行くかを推測します! あなたが正しく推測した場合、あなたは実行を続けます。 推測が間違っている場合は、パイプラインをフラッシュしてブランチにロールバックする必要があります。その後、他のパスから再開できます。 毎回正しく推測すれば、実行を停止する必要はありません。推測が多すぎると、ストール、ロールバック、再起動に多くの時間を費やします。 これが分岐予測です。列車は旗で方向を知らせることができるので、それは最良の例えではないことを認めます。しかし、コンピュータでは、プロセッサは最後の瞬間までブランチがどちらの方向に進むかを知りません。 では、列車が後退して他の経路を下る必要がある回数を最小限に抑えるために、どのように戦略的に推測しますか?あなたは過去の歴史を見ます!列車が99%の時間左に行く場合、あなたは左だと思います。それが交互になる場合、あなたはあなたの推測を交互にします。それが3回ごとに一方向に進む場合、あなたは同じことを推測します... 言い換えれば、あなたはパターンを特定し、それに従うことを試みます。これは多かれ少なかれ分岐予測子のしくみです。 ほとんどのアプリケーションには、正常に動作するブランチがあります。したがって、最新の分岐予測子は通常、90%を超えるヒット率を達成します。しかし、認識可能なパターンのない予測不可能な分岐に直面した場合、分岐予測子は事実上役に立ちません。 さらに読む:ウィキペディアの「分岐予測」の記事。 上から示唆されているように、原因は次のifステートメントです。 if(data [c]> = 128) 合計+ =データ[c]; データが0から255の間で均等に分散されていることに注意してください。データが並べ替えられると、反復のほぼ前半はifステートメントに入りません。その後、それらはすべてifステートメントに入ります。 分岐は何度も同じ方向に連続して進むため、これは分岐予測に非常に適しています。単純な飽和カウンターでも、方向を切り替えた後の数回の反復を除いて、ブランチを正しく予測します。 迅速な視覚化: T =分岐した N =分岐は行われません data [] = 0、1、2、3、4、... 126、127、128、129、130、... 250、251、252、..。 ブランチ= N N N N N ... N N T T T ... T T T..。 = NNNNNNNNNNNN ... NNNNNNNTTTTTTTTT ... TTTTTTTTTT(予測が容易) ただし、データが完全にランダムである場合、分岐予測子はランダムデータを予測できないため、役に立たなくなります。したがって、おそらく約50%の誤予測があります(ランダムな推測に勝るものはありません)。 data [] = 226、185、125、158、198、144、217、79、202、118、14、150、177、182、133、..。 ブランチ= T、T、N、T、T、T、T、N、T、N、N、T、T、T、N .. .. = TTNTTTTNTNNTTTN ...(完全にランダム-予測が難しい) では、何ができるでしょうか? コンパイラーがブランチを条件付き移動に最適化できない場合、パフォーマンスのために読みやすさを犠牲にしても構わないと思っているなら、いくつかのハックを試すことができます。 交換: if(data [c]> = 128) 合計+ =データ[c]; と: int t =(data [c] -128)>> 31; 合計+ = 〜t&data [c]; これにより、分岐が削除され、ビット単位の演算に置き換えられます。 (このハックは、元のifステートメントと厳密に同等ではないことに注意してください。ただし、この場合、data []のすべての入力値に対して有効です。) ベンチマーク:Core i7 920 @ 3.5 GHz C ++-Visual Studio2010-x64リリース //ブランチ-ランダム 秒= 11.777 //ブランチ-ソート済み 秒= 2.352 //ブランチレス-ランダム 秒= 2.564 //ブランチレス-ソート済み 秒= 2.587 Java-NetBeans 7.1.1 JDK 7-x64 //ブランチ-ランダム 秒= 10.93293813 //ブランチ-ソート済み 秒= 5.643797077 //ブランチレス-ランダム 秒= 3.113581453 //ブランチレス-ソート済み 秒= 3.186068823 観察: ブランチあり:ソートされたデータとソートされていないデータには大きな違いがあります。 ハックの場合:ソートされたデータとソートされていないデータに違いはありません。 C ++の場合、データがソートされると、ハッキングは実際にはブランチよりも少し遅くなります。 一般的な経験則は、重要なループ(この例のように)でのデータ依存の分岐を回避することです。 更新: x64で-O3または-ftree-vectorizeを指定したGCC4.6.1は、条件付き移動を生成できます。したがって、ソートされたデータとソートされていないデータの間に違いはありません。どちらも高速です。 (またはやや速い:すでにソートされている場合、特にGCCが単に追加するのではなくクリティカルパスに配置した場合、特にcmovに2サイクルのレイテンシがあるBroadwellより前のIntelでは、cmovが遅くなる可能性があります:gcc最適化フラグ-O3はコードを遅くします-O2より) VC ++ 2010は、/ Oxの下でも、このブランチの条件付き移動を生成できません。 インテルC ++コンパイラー(ICC)11は、奇跡的なことをします。 2つのループを交換し、予測できない分岐を外側のループに巻き上げます。したがって、予測ミスの影響を受けないだけでなく、VC ++およびGCCが生成できるものの2倍の速度もあります。言い換えれば、ICCはテストループを利用してベンチマークを打ち負かしました... インテル®コンパイラーにブランチレス・コードを与えると、それは完全にベクトル化されます...そしてブランチと同じくらい高速です(ループ交換あり)。 これは、成熟した最新のコンパイラでさえ、コードを最適化する能力が大きく異なる可能性があることを示しています... | 分岐予測。 ソートされた配列では、条件data [c]> = 128は、値のストリークに対して最初にfalseになり、その後のすべての値に対してtrueになります。それは簡単に予測できます。ソートされていない配列では、分岐コストを支払います。 | Mysticialの回答で美しく説明されているように、データを並べ替えるとパフォーマンスが大幅に向上する理由は、分岐予測のペナルティが削除されるためです。 さて、コードを見ると if(data [c]> = 128) 合計+ =データ[c]; この特定のif ... else ...ブランチの意味は、条件が満たされたときに何かを追加することであることがわかります。このタイプのブランチは、条件付き移動ステートメントに簡単に変換できます。これは、x86システムで条件付き移動命令cmovlにコンパイルされます。分岐、したがって潜在的な分岐予測ペナルティが削除されます。 C、つまりC ++では、x86の条件付き移動命令に(最適化なしで)直接コンパイルされるステートメントは、三項演算子です...? ...:....したがって、上記のステートメントを同等のステートメントに書き直します。 合計+ = data [c]> = 128? data [c]:0; 読みやすさを維持しながら、スピードアップファクターを確認できます。 Intel Core i7-2600K @ 3.4GHzおよびVisualStudio 2010リリースモードでは、ベンチマークは次のとおりです(Mysticialからコピーされた形式)。 x86 //ブランチ-ランダム 秒= 8.885 //ブランチ-ソート済み 秒= 1.528 //ブランチレス-ランダム 秒= 3.716 //ブランチレス-ソート済み 秒= 3.71 x64 //ブランチ-ランダム 秒= 11.302 //ブランチ-ソート済み 秒= 1.830 //ブランチレス-ランダム 秒= 2.736 //ブランチレス-ソート済み 秒= 2.737 結果は、複数のテストで堅牢です。分岐の結果が予測できない場合は大幅に高速化されますが、予測可能な場合は少し苦労します。実際、条件付き移動を使用する場合、パフォーマンスはデータパターンに関係なく同じです。 それでは、それらが生成するx86アセンブリを調査して、さらに詳しく見ていきましょう。簡単にするために、2つの関数max1とmax2を使用します。 max1は、次の場合に条件分岐を使用します... else ...: int max1(int a、int b){ if(a> b) を返す; そうしないと bを返す; } max2は三項演算子を使用しています...? ...:...: int max2(int a、int b){ a> bを返しますか? a:b; } x86-64マシンでは、GCC-Sは以下のアセンブリを生成します。 :max1 movl%edi、-4(%rbp) movl%esi、-8(%rbp) movl -4(%rbp)、%eax cmpl -8(%rbp)、%eax jle .L2 movl -4(%rbp)、%eax movl%eax、-12(%rbp) jmp .L4 .L2: movl -8(%rbp)、%eax movl%eax、-12(%rbp) .L4: movl -12(%rbp)、%eax 去る ret :max2 movl%edi、-4(%rbp) movl%esi、-8(%rbp) movl -4(%rbp)、%eax cmpl%eax、-8(%rbp) cmovge -8(%rbp)、%eax 去る ret max2は、命令cmovgeを使用するため、使用するコードがはるかに少なくなります。ただし、実際の利点は、max2にブランチジャンプjmpが含まれていないことです。これは、予測された結果が正しくない場合、パフォーマンスが大幅に低下します。 では、なぜ条件付き移動のパフォーマンスが向上するのでしょうか。 一般的なx86プロセッサでは、命令の実行はいくつかの段階に分けられます。大まかに言って、さまざまな段階を処理するためのさまざまなハードウェアがあります。したがって、新しい命令を開始するために1つの命令が終了するのを待つ必要はありません。これはパイプラインと呼ばれます。 分岐の場合、次の命令は前の命令によって決定されるため、パイプライン化はできません。待つか予測する必要があります。 条件付き移動の場合、実行条件付き移動命令はいくつかの段階に分かれていますが、フェッチやデコードなどの初期の段階は前の命令の結果に依存しません。後の段階だけが結果を必要とします。したがって、1つの命令の実行時間のほんの一部を待ちます。これが、予測が容易な場合に条件付き移動バージョンがブランチよりも遅い理由です。 『Computer Systems:A Programmer's Perspective』の第2版では、これについて詳しく説明しています。条件付き移動命令についてはセクション3.6.6、プロセッサアーキテクチャについては第4章全体、分岐予測と誤予測のペナルティの特別な扱いについてはセクション5.11.2を確認できます。 最近のコンパイラの中には、コードを最適化してパフォーマンスを向上させることができるものもあれば、できないものもあります(問題のコードはVisual Studioのネイティブコンパイラを使用しています)。予測できない場合のブランチと条件付き移動のパフォーマンスの違いを知ることは、シナリオが非常に複雑になり、コンパイラーがそれらを自動的に最適化できない場合に、パフォーマンスの高いコードを作成するのに役立ちます。 | このコードに対して実行できるさらに多くの最適化について知りたい場合は、次のことを検討してください。 元のループから開始します。 for(unsigned i = 0; i <100000; ++ i) {{ for(unsigned j = 0; j= 128) 合計+ =データ[j]; } } ループ交換を使用すると、このループを次のように安全に変更できます。 for(unsigned j = 0; j = 128) 合計+ =データ[j]; } } 次に、iループの実行中、if条件が一定であることがわかります。したがって、ifoutを上げることができます。 for(unsigned j = 0; j = 128) {{ for(unsigned i = 0; i <100000; ++ i) {{ 合計+ =データ[j]; } } } 次に、浮動小数点モデルで許可されていると仮定すると、内側のループを1つの式に折りたたむことができることがわかります(たとえば、/ fp:fastがスローされます)。 for(unsigned j = 0; j = 128) {{ 合計+ = data [j] * 100000; } } それは以前より10万倍速いです。 | 間違いなく、CPUの分岐予測に問題のあるコードを特定する方法に興味を持つ人もいるでしょう。 Valgrindツールのcachegrindには、-branch-sim = yesフラグを使用して有効化される分岐予測シミュレーターがあります。外側のループの数を10000に減らし、g ++でコンパイルして、この質問の例で実行すると、次の結果が得られます。 並べ替え: == 32551 ==ブランチ:656,645,130(656,609,208 cond + 35,922 ind) == 32551 ==誤予測:169,556(169,095 cond + 461 ind) == 32551 ==誤解率:0.0%(0.0%+ 1.2%) 未分類: == 32555 ==ブランチ:655,996,082(655,960,160 cond + 35,922 ind) == 32555 ==誤予測:164,073,152(164,072,692 cond + 460 ind) == 32555 ==誤解率:25.0%(25.0%+ 1.2%) 問題のループで見られるcg_annotateによって生成された行ごとの出力にドリルダウンします。 並べ替え: BcBcmビビン 10,001 4 0 0 for(unsigned i = 0; i <10000; ++ i) 。 。 。 。 {{ 。 。 。 。 //プライマリループ 327,690,000 10,016 0 0 for(unsigned c = 0; c = 128) 0 0 00合計+ =データ[c]; 。 。 。 。 } 。 。 。 。 } 未分類: BcBcmビビン 10,001 4 0 0 for(unsigned i = 0; i <10000; ++ i) 。 。 。 。 {{ 。 。 。 。 //プライマリループ 327,690,000 10,038 0 0 for(unsigned c = 0; c = 128) 0 0 00合計+ =データ[c]; 。 。 。 。 } 。 。 。 。 } これにより、問題のある行を簡単に特定できます。並べ替えられていないバージョンでは、if(data [c]> = 128)行がcachegrindの分岐予測モデルで164,050,007の誤って予測された条件分岐(Bcm)を引き起こしていますが、並べ替えられたバージョンでは10,006しか発生していません。 。 または、Linuxでは、パフォーマンスカウンターサブシステムを使用して同じタスクを実行できますが、CPUカウンターを使用したネイティブパフォーマンスを使用します。 perf stat ./sumtest_sorted 並べ替え: './sumtest_sorted'のパフォーマンスカウンター統計: 11808.095776タスククロック#0.998CPU使用率 1,062コンテキストスイッチ#0.090K /秒 14 CPU移行#0.001K /秒 337ページフォールト#0.029K /秒 26,487,882,764サイクル#2.243 GHz 41,025,654,322命令#1サイクルあたり1.55イン 6,558,871,379ブランチ#555.455M /秒 567,204ブランチ-すべてのブランチの#0.01%が欠落しています 11.827228330秒の経過時間 未分類: パフォーマンス'./sumtest_unsorted'のカウンター統計: 28877.954344タスククロック#0.998CPU使用率 2,584コンテキストスイッチ#0.089K /秒 18 CPU移行#0.001K /秒 335ページフォールト#0.012K /秒 65,076,127,595サイクル#2.253 GHz 41,032,528,741命令#1サイクルあたり0.63 insns 6,560,579,013ブランチ#227.183M /秒 1,646,394,749ブランチ-すべてのブランチの#25.10%が欠落しています 28.935500947秒経過 また、分解してソースコードの注釈を付けることもできます。 perf record -e branch-misss ./sumtest_unsorted perf annotate -d sumtest_unsorted パーセント| sumtest_unsortedのソースコードと逆アセンブル ------------------------------------------------ ..。 :合計+ =データ[c]; 0.00:400a1a:mov -0x14(%rbp)、%eax 39.97:400a1d:mov%eax、%eax 5.31:400a1f:mov -0x20040(%rbp、%rax、4)、%eax 4.60:400a26:cltq 0.00:400a28:%raxを追加、-0x30(%rbp) ..。 詳細については、パフォーマンスチュートリアルを参照してください。 | この質問とその答えを読んだところ、答えが欠けているように感じます。 マネージド言語で特にうまく機能することがわかった分岐予測を排除する一般的な方法は、分岐を使用する代わりにテーブルルックアップです(この場合はテストしていませんが)。 このアプローチは、一般的に次の場合に機能します。 これは小さなテーブルであり、プロセッサにキャッシュされる可能性があります。 非常にタイトなループで物事を実行しているか、プロセッサがデータをプリロードできます。 背景とその理由 プロセッサの観点からは、メモリは低速です。速度の違いを補うために、いくつかのキャッシュがプロセッサに組み込まれています(L1 / L2キャッシュ)。だから、あなたがあなたの素晴らしい計算をしていると想像して、あなたがメモリの一部を必要としていることを理解してください。プロセッサは「ロード」操作を取得し、メモリの一部をキャッシュにロードします。その後、キャッシュを使用して残りの計算を実行します。メモリは比較的遅いので、この「ロード」はプログラムの速度を低下させます。 分岐予測と同様に、これはPentiumプロセッサで最適化されました。プロセッサは、データの一部をロードする必要があると予測し、操作が実際にキャッシュに到達する前に、それをキャッシュにロードしようとします。すでに見てきたように、分岐予測がひどく間違っていることがあります-最悪のシナリオでは、戻って実際にメモリのロードを待つ必要があります。これには永遠に時間がかかります(言い換えると、分岐予測の失敗は悪いです、メモリ分岐予測が失敗した後のロードはひどいです!)。 幸いなことに、メモリアクセスパターンが予測可能な場合、プロセッサはそれを高速キャッシュにロードし、すべてが順調です。 私たちが最初に知る必要があるのは、何が小さいのかということです。一般的には小さい方が良いですが、経験則では、サイズが4096バイト以下のルックアップテーブルに固執することです。上限として:ルックアップテーブルが64Kより大きい場合は、おそらく再検討する価値があります。 テーブルの作成 これで、小さなテーブルを作成できることがわかりました。次に行うことは、ルックアップ関数を配置することです。ルックアップ関数は通常、いくつかの基本的な整数演算(および、または、xor、shift、add、remove、およびおそらく乗算)を使用する小さな関数です。ルックアップ関数によって入力をテーブル内のある種の「一意のキー」に変換する必要があります。これにより、実行したいすべての作業の答えが得られます。 この場合:> = 128は値を保持できることを意味し、<128は値を削除することを意味します。これを行う最も簡単な方法は、「AND」を使用することです。それを保持する場合は、7FFFFFFFでANDします。それを取り除きたい場合は、0とANDします。128は2の累乗であることに注意してください。つまり、32768/128整数のテーブルを作成し、1つのゼロと多数の整数で埋めることができます。 7FFFFFFFFの。 管理言語 なぜこれが管理された言語でうまく機能するのか不思議に思うかもしれません。結局のところ、マネージド言語は、ブランチを使用して配列の境界をチェックし、混乱しないようにします... まあ、正確には... :-) 管理言語のこのブランチを削除するためのかなりの作業がありました。例えば: for(int i = 0; i = 128)? c:0; } //テスト DateTime startTime = System.DateTime.Now; 長い合計= 0; for(int i = 0; i <100000; ++ i) {{ //プライマリループ for(int j = 0; j = 128) 合計+ =データ[c]; 問題は、ソートされたデータの場合のように、特定の場合に上記のステートメントが実行されない理由は何ですか?これが「分岐予測」です。分岐予測子は、これが確実にわかる前に、分岐(if-then-else構造など)がどちらの方向に進むかを推測しようとするデジタル回路です。分岐予測子の目的は、命令パイプラインのフローを改善することです。分岐予測は、高い効果的なパフォーマンスを達成する上で重要な役割を果たします。 それをよりよく理解するためにいくつかのベンチマーキングをしましょう ifステートメントのパフォーマンスは、その条件に予測可能なパターンがあるかどうかによって異なります。条件が常にtrueまたは常にfalseの場合、プロセッサの分岐予測ロジックがパターンを取得します。一方、パターンが予測できない場合、ifステートメントははるかに高価になります。 さまざまな条件でこのループのパフォーマンスを測定してみましょう。 for(int i = 0; i > 7); a [j] + =データ[c]; } } doublelapsedTime = static_cast (clock()-start)/ CLOCKS_PER_SEC; 合計= a [1]; このコードは追加の半分を無駄にしますが、分岐予測の失敗はありません。ランダムデータでは、実際のifステートメントを使用したバージョンよりも非常に高速です。 しかし、私のテストでは、明示的なルックアップテーブルはこれよりもわずかに高速でした。おそらく、ルックアップテーブルへのインデックス作成がビットシフトよりもわずかに高速だったためです。これは、私のコードがルックアップテーブル(コード内の「ルックアップテーブル」の場合は想像を絶するほどにlutと呼ばれます)を設定して使用する方法を示しています。 C ++コードは次のとおりです。 //ルックアップテーブルを宣言してから入力します int lut [256]; for(unsigned c = 0; c <256; ++ c) lut [c] =(c> = 128)? c:0; //ビルド後にルックアップテーブルを使用します for(unsigned i = 0; i <100000; ++ i) {{ //プライマリループ for(unsigned c = 0; c value) node = node-> pLeft; そうしないと node = node-> pRight; このライブラリは次のようになります。 i =(x <ノード->値); node = node-> link [i]; このコードへのリンクは次のとおりです。赤黒木、永遠に混乱 | ソートされたケースでは、成功した分岐予測や分岐のない比較トリックに頼るよりもうまくいくことができます。つまり、分岐を完全に削除します。 実際、配列はデータ<128の連続ゾーンとデータ> = 128の連続ゾーンに分割されています。したがって、二分検索(Lg(arraySize)= 15の比較を使用)で分割ポイントを見つけてから、その点。 (チェックなし)のようなもの int i = 0、j、k = arraySize; while(i > 1; if(data [j]> = 128) k = j; そうしないと i = j; } 合計= 0; for(; i > 1; for(i = 0、k = arraySize; i = 128?k:i)= j) j =(i + k)>> 1; for(sum = 0; i = 128) / \ / \ / \ 真/偽 / \ / \ / \ / \ B)合計+ =データ[c]; C)forループまたはprint()。 分岐予測がないと、次のことが起こります。 命令Bまたは命令Cに進むかどうかの決定は命令Aの結果に依存するため、命令Bまたは命令Cを実行するには、プロセッサは命令AがパイプラインのEXステージまで到達しないまで待機する必要があります。このようになります。 条件がtrueを返す場合: 条件がfalseを返す場合: 命令Aの結果を待った結果、上記の場合(分岐予測なし、trueとfalseの両方)に費やされた合計CPUサイクルは7です。 では、分岐予測とは何ですか? 分岐予測子は、これが確実にわかる前に、分岐(if-then-else構造)がどちらの方向に進むかを推測しようとします。命令AがパイプラインのEXステージに到達するのを待つことはありませんが、決定を推測してその命令(この例の場合はBまたはC)に進みます。 正しい推測の場合、パイプラインは次のようになります。 推測が間違っていたことが後で検出された場合、部分的に実行された命令は破棄され、パイプラインは正しい分岐で最初からやり直し、遅延が発生します。 分岐予測が誤っている場合に無駄になる時間は、フェッチステージから実行ステージまでのパイプラインのステージ数に等しくなります。最近のマイクロプロセッサはパイプラインが非常に長い傾向があるため、誤予測の遅延は10〜20クロックサイクルです。パイプラインが長いほど、優れた分岐予測子の必要性が高まります。 OPのコードでは、初めて条件付きの場合、分岐予測子には予測の基礎となる情報がないため、最初はランダムに次の命令を選択します。 forループの後半では、履歴に基づいて予測を行うことができます。 昇順でソートされた配列の場合、次の3つの可能性があります。 すべての要素が128未満です すべての要素が128より大きい いくつかの最初の新しい要素は128未満であり、後で128より大きくなります 予測子が最初の実行で常に真の分岐を想定すると仮定しましょう。 したがって、最初のケースでは、常に真になります歴史的にすべての予測が正しいので、ブランチ。 2番目のケースでは、最初は間違った予測をしますが、数回繰り返すと正しく予測します。 3番目のケースでは、要素が128未満になるまで、最初は正しく予測します。その後、しばらくの間失敗し、履歴に分岐予測の失敗が見られると、それ自体が修正されます。 これらすべての場合において、障害の数が少なすぎるため、部分的に実行された命令を破棄して正しいブランチからやり直す必要があるのは数回だけであり、CPUサイクルが少なくなります。 ただし、ランダムなソートされていない配列の場合、予測では部分的に実行された命令を破棄し、ほとんどの場合正しいブランチからやり直す必要があり、ソートされた配列と比較してCPUサイクルが多くなります。 | 公式の答えは Intel-分岐予測のコストの回避 Intel-誤予測を防ぐためのブランチとループの再編成 科学論文-分岐予測コンピュータアーキテクチャ 書籍:J.L。ヘネシー、D.A。パターソン:コンピューターアーキテクチャ:定量的アプローチ 科学出版物の記事:T.Y。ええ、Y.N。 Pattは、ブランチ予測でこれらの多くを作成しました。 この素敵な図から、分岐予測が混乱する理由もわかります。 元のコードの各要素はランダムな値です data [c] = std :: rand()%256; したがって、予測子はstd :: rand()が吹くとサイドを変更します。 一方、ソートされると、予測子は最初に強く取られない状態に移行し、値が高い値に変わると、予測子は3回の実行で、強く取られない状態から強く取られる状態に変わります。 | 同じ行で(これはどの回答でも強調されていなかったと思います)、時々(特に、Linuxカーネルのようにパフォーマンスが重要なソフトウェアで)次のようなifステートメントを見つけることができることを言及するのは良いことです。 if(likely(everything_is_ok)) {{ / *何かをする* / } または同様に: if(unlikely(very_improbable_condition)) {{ / *何かをする* / } 可能性()と可能性()はどちらも、実際にはGCCの__builtin_expectのようなものを使用して定義されたマクロであり、コンパイラがユーザーから提供された情報を考慮して条件を優先する予測コードを挿入するのに役立ちます。 GCCは、実行中のプログラムの動作を変更したり、キャッシュのクリアなどの低レベルの命令を発行したりする可能性のある他のビルトインをサポートしています。 通常、この種の最適化は、実行時間が重要で重要なハードリアルタイムアプリケーションまたは組み込みシステムで主に見られます。たとえば、1/10000000回しか発生しないエラー状態をチェックしている場合は、コンパイラにこれを通知してみませんか?このように、デフォルトでは、分岐予測は条件が偽であると想定します。 | C ++で頻繁に使用されるブール演算は、コンパイルされたプログラムに多くの分岐を生成します。これらのブランチがループ内にあり、予測が難しい場合、実行が大幅に遅くなる可能性があります。ブール変数は、値がfalseの場合は0、trueの場合は1の8ビット整数として格納されます。 ブール変数は、入力としてブール変数を持つすべての演算子が入力に0または1以外の値があるかどうかをチェックするという意味で過剰に決定されますが、出力としてブールを持つ演算子は0または1以外の値を生成できません。入力としてのブール変数は、必要以上に効率的ではありません。 例を考えてみましょう: bool a、b、c、d; c = a && b; d = a || b; これは通常、コンパイラによって次の方法で実装されます。 bool a、b、c、d; if(a!= 0){ if(b!= 0){ c = 1; } そうしないと { CFALSEに移動します。 } } そうしないと { CFALSE: c = 0; } if(a == 0){ if(b == 0){ d = 0; } そうしないと { DTRUEに移動します。 } } そうしないと { DTRUE: d = 1; } このコードは最適とはほど遠いです。予測を誤ると、ブランチに時間がかかる場合があります。オペランドに0と1以外の値がないことが確実にわかっている場合、ブール演算をはるかに効率的にすることができます。コンパイラがそのような仮定を行わない理由は、変数が初期化されていない場合、変数が他の値を持つ可能性があるためです。または未知のソースから来ています。上記のコードは、aとbが有効な値に初期化されている場合、またはブール出力を生成する演算子に由来する場合に最適化できます。最適化されたコードは次のようになります。 char a = 0、b = 1、c、d; c = a&b; d = a | b; ブール演算子(&&および||)の代わりにビット演算子(&および|)を使用できるようにするために、boolの代わりにcharが使用されます。ビット単位の演算子は、1クロックサイクルしかかからない単一の命令です。 OR演算子(|)は、aとbの値が0または1以外の場合でも機能します。オペランドの値が0と1以外の場合、AND演算子(&)と排他的論理和演算子(^)の結果に一貫性がない場合があります。 〜NOTには使用できません。代わりに、1とXORすることにより、0または1であることがわかっている変数に対してブールNOTを作成できます。 bool a、b; b =!a; 次のように最適化できます。 char a = 0、b; b = a ^ 1; bがfalseの場合に評価されるべきでない式である場合、a && bをa&bに置き換えることはできません(&&はbを評価しません、&will)。同様に、|| bを|に置き換えることはできませんbが真の場合に評価されるべきではない式である場合はb。 ビット単位の演算子を使用すると、オペランドが比較である場合よりも、オペランドが変数である場合の方が有利です。 bool a;ダブルx、y、z; a = x> y && z <5.0; ほとんどの場合(&&式が多くの分岐予測を生成すると予想しない限り)最適です。 | それは確かだ!... コードで切り替えが発生するため、分岐予測によってロジックの実行が遅くなります。まっすぐな道や曲がりくねった道を進んでいるようなものです。まっすぐな道の方が早くできるはずです!... 配列が並べ替えられている場合、最初のステップで条件はfalseになります:data [c]> = 128、その後、通りの終わりまでずっと真の値になります。これにより、ロジックの最後にすばやく到達できます。一方、ソートされていない配列を使用すると、コードの実行が確実に遅くなる多くの回転と処理が必要になります... 下の画像をご覧ください。どの通りが早く完成するのでしょうか? したがって、プログラム的に、分岐予測はプロセスを遅くします... また、最後に、それぞれがコードに異なる影響を与える2種類のブランチ予測があることを知っておくとよいでしょう。 1.静的 2.動的 静的分岐予測は、マイクロプロセッサによって初めて使用されます 条件分岐が発生し、動的分岐予測は 条件分岐コードの後続の実行に使用されます。 これらを利用するためにコードを効果的に書くために ルール、if-elseまたはswitchステートメントを書くときは、ほとんどをチェックしてください 最初に一般的なケースを作成し、最も一般的でないケースまで徐々に作業を進めていきます。 ループは必ずしもコードの特別な順序を必要としません ループイテレータの条件としてのみ、静的分岐予測 通常使用されます。 | この質問はすでに何度も見事に答えられています。それでも、グループの注意をさらに別の興味深い分析に向けたいと思います。 最近、この例(ごくわずかに変更)は、Windows上のプログラム自体の中でコードの一部をプロファイリングする方法を示す方法としても使用されました。途中で、作成者は、結果を使用して、ソートされた場合とソートされていない場合の両方で、コードがほとんどの時間を費やしている場所を判別する方法も示します。最後に、この記事では、HAL(Hardware Abstraction Layer)のあまり知られていない機能を使用して、ソートされていない場合に発生している分岐予測の量を判断する方法も示しています。 リンクはここにあります: 自己プロファイリングのデモンストレーション | 他の人がすでに言及しているように、謎の背後にあるのは分岐予測です。 私は何かを追加しようとしているのではなく、別の方法で概念を説明しています。 ウィキには、テキストと図を含む簡潔な紹介があります。 ダイアグラムを使用して分岐予測を直感的に詳しく説明する以下の説明が好きです。 コンピュータアーキテクチャでは、分岐予測は 分岐の方向を推測しようとするデジタル回路(例: if-then-else構造)は、これが確実に知られる前に実行されます。ザ・ 分岐予測の目的は、の流れを改善することです。 命令パイプライン。分岐予測はで重要な役割を果たします 多くの最新のパイプラインで高い効果的なパフォーマンスを達成 x86などのマイクロプロセッサアーキテクチャ。 双方向分岐は通常、条件付きジャンプで実装されます 命令。条件付きジャンプは、「実行されない」状態で続行できます。 直後に続くコードの最初のブランチでの実行 条件付きジャンプの後、または「取得」してにジャンプすることができます コードの2番目のブランチがあるプログラムメモリ内の別の場所 保存されます。条件付きジャンプが行われるかどうかは定かではありません 条件が計算され、 条件付きジャンプが命令の実行段階を通過しました パイプライン(図1を参照)。 説明したシナリオに基づいて、さまざまな状況でパイプラインで命令がどのように実行されるかを示すアニメーションデモを作成しました。 分岐予測なし。 分岐予測がないと、プロセッサは次のようになるまで待機する必要があります。 条件付きジャンプ命令が実行ステージを通過した後、 次の命令は、パイプラインのフェッチステージに入ることができます。 この例には3つの命令が含まれており、最初の命令は条件付きジャンプ命令です。後者の2つの命令は、条件付きジャンプ命令が実行されるまでパイプラインに入ることができます。 3つの命令が完了するまでに9クロックサイクルかかります。 分岐予測を使用し、条件付きジャンプを行わないでください。予測がとっていないと仮定しましょう条件付きジャンプ。 3つの命令が完了するまでに7クロックサイクルかかります。 分岐予測を使用して、条件付きジャンプを実行します。予測が条件付きジャンプを行っていないと仮定しましょう。 3つの命令が完了するまでに9クロックサイクルかかります。 分岐予測の場合に浪費される時間は、 フェッチステージからパイプラインまでのパイプラインのステージ数 ステージを実行します。最近のマイクロプロセッサはかなり長い傾向があります 誤予測遅延が10〜20クロックになるようにパイプライン サイクル。その結果、パイプラインを長くすると、 より高度な分岐予測。 ご覧のとおり、分岐予測を使用しない理由はないようです。 これは、分岐予測の非常に基本的な部分を明確にする非常に単純なデモです。これらのgifが煩わしい場合は、回答から自由に削除してください。訪問者は、BranchPredictorDemoからライブデモのソースコードを入手することもできます。 | 分岐予測ゲイン! 分岐予測がプログラムの速度を低下させないことを理解することが重要です。予測を逃した場合のコストは、分岐予測が存在せず、式の評価を待って実行するコードを決定するのと同じです(次の段落でさらに説明します)。 if(式) {{ //実行1 } そうしないと { //実行2 } if-else \ switchステートメントがある場合は常に、どのブロックを実行するかを決定するために式を評価する必要があります。コンパイラが生成するアセンブリコードには、条件分岐命令が挿入されています。 分岐命令は、コンピュータに異なる命令シーケンスの実行を開始させ、条件に応じて命令を順番に実行するデフォルトの動作から逸脱する可能性があります(つまり、式がfalseの場合、プログラムはifブロックのコードをスキップします)。この場合の式の評価です。 そうは言っても、コンパイラは実際に評価される前に結果を予測しようとします。 ifブロックから命令をフェッチし、式がtrueであることが判明した場合は、すばらしいです。私たちはそれを評価するのにかかる時間を稼ぎ、コードを進歩させました。そうでない場合は、間違ったコードを実行し、パイプラインがフラッシュされ、正しいブロックが実行されます。 視覚化: ルート1またはルート2を選択する必要があるとします。パートナーがマップを確認するのを待って、##で停止して待機しました。または、ルート1を選択して、運が良ければ(ルート1が正しいルートです)、そうすれば、パートナーがマップをチェックするのを待つ必要はありませんでした(パートナーがマップをチェックするのにかかる時間を節約できました)。そうしないと、元に戻ります。 パイプラインのフラッシュは超高速ですが、今日ではこのギャンブルをする価値があります。ソートされたデータまたはゆっくりと変化するデータを予測することは、速い変化を予測するよりも常に簡単で優れています。 Oルート1 / ------------------------------- / | \ / | --------- ## / / \ \ \ ルート2 \ -------------------------------- | ARMでは、すべての命令に4ビットの条件フィールドがあるため、分岐は必要ありません。このフィールドは、プロセッサステータスレジスタで発生する可能性のある16の異なる条件のいずれかを(ゼロコストで)テストし、命令の条件がfalseの場合、命令はスキップされます。これにより、短い分岐が不要になり、このアルゴリズムで分岐予測がヒットすることはありません。したがって、このアルゴリズムのソートされたバージョンは、ソートの余分なオーバーヘッドのために、ARMのソートされていないバージョンよりも実行が遅くなります。 このアルゴリズムの内部ループは、ARMアセンブリ言語では次のようになります。 MOV R0、#0 // R0 =合計= 0 MOV R1、#0 // R1 = c = 0 ADR R2、データ// R2 =データ配列のアドレス(この命令を外部ループの外側に配置) .inner_loop //内部ループブランチラベル LDRB R3、[R2、R1] // R3 =データ[c] CMP R3、#128 // R3を128と比較 ADDGE R0、R0、R3 // R3> = 128の場合、合計+ = data [c]-分岐は必要ありません! ADD R1、R1、#1 // c ++ CMP R1、#arraySize // cをarraySizeと比較 BLT inner_loop // c ()); for(unsigned c = 0; c = 128の場合 sum = sum + data1(j); 終わり 終わり 終わり toc; ExeTimeWithSorting = toc --tic; 上記のMATLABコードの結果は次のとおりです。 a:経過時間(ソートなし)= 3479.880861秒。 b:経過時間(ソートあり)= 2377.873098秒。 @GManNickGのようなCコードの結果は次のとおりです。 a:経過時間(ソートなし)= 19.8761秒。 b:経過時間(ソートあり)= 7.37778秒。 これに基づくと、MATLABはソートなしのC実装よりも約175倍遅く、ソートありの場合は350倍遅いようです。言い換えると、(分岐予測の)効果はMATLAB実装で1.46倍、C実装で2.7倍です。 | データをソートする必要があるという他の回答による仮定は正しくありません。 次のコードは、配列全体を並べ替えるのではなく、配列の200要素のセグメントのみを並べ替えるため、最も高速に実行されます。 k要素セクションのみを並べ替えると、配列全体を並べ替えるのに必要なO(n.log(n))時間ではなく、線形時間O(n)で前処理が完了します。 #include #include #include int main(){ int data [32768]; const int l = sizeof data / sizeof data [0]; for(unsigned c = 0; c = 128) 合計+ =データ[c]; } } std :: cout << static_cast (clock()-start)/ CLOCKS_PER_SEC << std :: endl; std :: cout << "sum =" << sum << std :: endl; } これはまた、ソート順などのアルゴリズムの問題とは何の関係もないことを「証明」し、実際には分岐予測です。 | この質問に対するBjarneStroustrupの回答: それは面接の質問のように聞こえます。それは本当ですか?どうやって知る?最初にいくつかの測定を行わずに効率に関する質問に答えることは悪い考えです。したがって、測定方法を知ることが重要です。 それで、私は百万の整数のベクトルで試し、得ました: すでにソート済み32995ミリ秒 シャッフルされた125944ミリ秒 すでに18610ミリ秒でソートされています シャッフルされた133304ミリ秒 すでにソート済み17942ミリ秒 シャッフルされた107858ミリ秒 確かにそれを数回実行しました。はい、現象は本物です。私のキーコードは次のとおりです。 void run(vector &v、const string&label) {{ 自動t0 = system_clock :: now(); sort(v.begin()、v.end()); 自動t1 = system_clock :: now(); cout <<ラベル << duration_cast (t1 — t0).count() << "ミリ秒\ n"; } void tst() {{ vector v(1'000'000); iota(v.begin()、v.end()、0); run(v、 "すでにソートされています"); std :: shuffle(v.begin()、v.end()、std :: mt19937 {std :: random_device {}()}); run(v、 "シャッフル"); } 少なくともこの現象は、このコンパイラ、標準ライブラリ、およびオプティマイザの設定では現実的です。実装が異なれば、答えも異なります。実際、誰かがより体系的な調査を行い(すばやくWeb検索するとそれが見つかります)、ほとんどの実装でその効果が示されています。 理由の1つは、分岐予測です。ソートアルゴリズムの主要な操作は、「if(v [i] > 7); a [j] + =データ[c]; } } doublelapsedTime = static_cast (clock()-start)/ CLOCKS_PER_SEC; 合計= a [1]; このコードは追加の半分を無駄にしますが、分岐予測の失敗はありません。ランダムデータでは、実際のifステートメントを使用したバージョンよりも非常に高速です。 しかし、私のテストでは、明示的なルックアップテーブルはこれよりもわずかに高速でした。おそらく、ルックアップテーブルへのインデックス作成がビットシフトよりもわずかに高速だったためです。これは、私のコードがルックアップテーブル(コード内の「ルックアップテーブル」の場合は想像を絶するほどにlutと呼ばれます)を設定して使用する方法を示しています。 C ++コードは次のとおりです。 //ルックアップテーブルを宣言してから入力します int lut [256]; for(unsigned c = 0; c <256; ++ c) lut [c] =(c> = 128)? c:0; //ビルド後にルックアップテーブルを使用します for(unsigned i = 0; i <100000; ++ i) {{ //プライマリループ for(unsigned c = 0; c value) node = node-> pLeft; そうしないと node = node-> pRight; このライブラリは次のようになります。 i =(x <ノード->値); node = node-> link [i]; それは素晴らしい解決策であり、多分それはうまくいくでしょう。 | 非常に活発な質問。この質問に答えるために10の評判を獲得してください。レピュテーション要件は、この質問をスパムや無回答のアクティビティから保護するのに役立ちます。 あなたが探している答えではありませんか? java c ++パフォーマンス最適化分岐予測のタグが付けられた他の質問を参照するか、独自の質問をしてください。