役に立たないMOV命令を導入すると、x86_64アセンブリでタイトなループが加速するのはなぜですか?


222

バックグラウンド:

組み込みアセンブリ言語を使用して一部のPascalコードを最適化しているときに、不要なMOV命令に気づき、それを削除しました。

驚いたことに、不要な命令を削除すると、プログラムの速度が低下しました

任意の、役に立たないMOV命令追加すると、パフォーマンスがさらに向上することがわかりました。

効果は不安定で、実行順序に基づいて変化します。同じジャンク命令が1行で上または下に転置されると、速度が低下します。

CPUがあらゆる種類の最適化と合理化を行うことを理解していますが、これはより黒魔術のように見えます。

データ:

私のコードのバージョンは、時間を実行するループの途中で3つのジャンク操作を条件付きでコンパイルします2**20==1048576。(周囲のプログラムはSHA-256ハッシュを計算するだけです)。

私のかなり古いマシン(Intel(R)Core(TM)2 CPU 6400 @ 2.13 GHz)での結果:

avg time (ms) with -dJUNKOPS: 1822.84 ms
avg time (ms) without:        1836.44 ms

プログラムはループで25回実行され、実行順序は毎回ランダムに変化しました。

抜粋:

{$asmmode intel}
procedure example_junkop_in_sha256;
  var s1, t2 : uint32;
  begin
    // Here are parts of the SHA-256 algorithm, in Pascal:
    // s0 {r10d} := ror(a, 2) xor ror(a, 13) xor ror(a, 22)
    // s1 {r11d} := ror(e, 6) xor ror(e, 11) xor ror(e, 25)
    // Here is how I translated them (side by side to show symmetry):
  asm
    MOV r8d, a                 ; MOV r9d, e
    ROR r8d, 2                 ; ROR r9d, 6
    MOV r10d, r8d              ; MOV r11d, r9d
    ROR r8d, 11    {13 total}  ; ROR r9d, 5     {11 total}
    XOR r10d, r8d              ; XOR r11d, r9d
    ROR r8d, 9     {22 total}  ; ROR r9d, 14    {25 total}
    XOR r10d, r8d              ; XOR r11d, r9d

    // Here is the extraneous operation that I removed, causing a speedup
    // s1 is the uint32 variable declared at the start of the Pascal code.
    //
    // I had cleaned up the code, so I no longer needed this variable, and 
    // could just leave the value sitting in the r11d register until I needed
    // it again later.
    //
    // Since copying to RAM seemed like a waste, I removed the instruction, 
    // only to discover that the code ran slower without it.
    {$IFDEF JUNKOPS}
    MOV s1,  r11d
    {$ENDIF}

    // The next part of the code just moves on to another part of SHA-256,
    // maj { r12d } := (a and b) xor (a and c) xor (b and c)
    mov r8d,  a
    mov r9d,  b
    mov r13d, r9d // Set aside a copy of b
    and r9d,  r8d

    mov r12d, c
    and r8d, r12d  { a and c }
    xor r9d, r8d

    and r12d, r13d { c and b }
    xor r12d, r9d

    // Copying the calculated value to the same s1 variable is another speedup.
    // As far as I can tell, it doesn't actually matter what register is copied,
    // but moving this line up or down makes a huge difference.
    {$IFDEF JUNKOPS}
    MOV s1,  r9d // after mov r12d, c
    {$ENDIF}

    // And here is where the two calculated values above are actually used:
    // T2 {r12d} := S0 {r10d} + Maj {r12d};
    ADD r12d, r10d
    MOV T2, r12d

  end
end;

自分で試してください:

コードを自分で試してみたい場合は、GitHubでオンラインなっています。

私の質問:

  • レジスタの内容を無駄にRAMにコピーするとパフォーマンスが向上するのはなぜですか
  • なぜ同じ役に立たない命令が一部の回線でスピードアップを提供し、他の回線でスローダウンを提供するのですか?
  • この動作は、コンパイラによって予想通りに悪用される可能性があるものですか?

7
依存チェーンを壊したり、物理レジスターをリタイアしたものとしてマークしたりするのに役立つ、あらゆる種類の「役に立たない」命令があります。これらの操作を悪用するには、マイクロアーキテクチャーの知識が必要です。あなたの質問は、最小限の例として人々にgithubを案内するのではなく、短い一連の指示を提供する必要があります。
Brett Hale

1
@BrettHale良い点、ありがとう。いくつかのコメント付きのコードの抜粋を追加しました。レジスターの値を後で使用する場合でも、レジスターの値をコピーして、そのレジスターを廃止済みとしてマークしますか?
タンジェントストーム2013

9
これらの平均に標準偏差を適用できますか?この投稿には、実際の違いがあるという実際の兆候はありません。
13

2
rdtscp命令を使用して命令のタイミングを調整し、両方のバージョンのクロックサイクルを確認してください。
jakobbotsch 2013

2
また、メモリのアライメントが原因である可能性がありますか?私は自分で計算をしませんでした(レイジー:P)が、いくつかのダミー命令を追加すると、コードがメモリアラインメントされる可能性があります...
LorenzoDemattéJul

回答:


144

速度向上の最も可能性の高い原因は次のとおりです。

  • MOVを挿入すると、後続の命令が別のメモリアドレスにシフトされます。
  • それらの移動された命令の1つは、重要な条件分岐でした
  • 分岐予測テーブルのエイリアシングが原因で、その分岐が誤って予測されていました
  • ブランチを移動するとエイリアスが削除され、ブランチを正しく予測できるようになりました

Core2は、条件付きジャンプごとに個別の履歴レコードを保持しません。代わりに、すべての条件付きジャンプの共有履歴を保持します。グローバル分岐予測の 1つの欠点は、異なる条件ジャンプが無相関の場合、無関係な情報によって履歴が希薄になることです。

この小さな分岐予測チュートリアルは、分岐予測バッファがどのように機能するかを示しています。キャッシュバッファには、分岐命令のアドレスの下位部分によってインデックスが付けられます。これは、2つの重要な無相関ブランチが同じ下位ビットを共有しない限り、うまく機能します。その場合、エイリアシングが発生し、誤って予測された分岐が多数発生します(命令パイプラインが停止し、プログラムが遅くなります)。

ブランチの予測ミスがパフォーマンスにどのように影響するかを理解したい場合は、次の優れた回答をご覧ください。https//stackoverflow.com/a/11227902/1001643

コンパイラーは通常、どのブランチにエイリアスを付けるか、それらのエイリアスが重要かどうかを知るのに十分な情報を持っていません。ただし、その情報は、CachegrindVTuneなどのツールを使用して実行時に決定できます。


2
うーん。これは有望に聞こえます。このsha256実装での唯一の条件付き分岐は、FORループの終了のチェッ​​クです。当時、私はこのリビジョンにgitの奇妙なタグを付け、最適化を続けていました。私の次のステップの1つは、パスカルのFORループをアセンブリーで自分で書き直すことでしたが、その時点で、これらの追加の命令はもはや正の効果をもたらしませんでした。おそらく、無料のpascalで生成されたコードは、プロセッサーが予測するのが、私が置き換えた単純なカウンターよりも困難でした。
タンジェントストーム2013

1
@tangentstormいい要約ですね。分岐予測テーブルはそれほど大きくないため、1つのテーブルエントリが複数の分岐を参照する場合があります。これにより、一部の予測が役に立たなくなる可能性があります。競合するブランチの1つがテーブルの別の部分に移動すると、問題は簡単に修正されます。ほとんどすべての小さな変更がこれを実現できます:-)
レイモンドヘッティンガー2013

1
これが私が観察した特定の動作の最も合理的な説明だと思うので、これを答えとしてマークします。ありがとう。:)
タンジェントストーム2013

3
Bochsの貢献者の1人が遭遇した同様の問題については、非常に優れた議論があります。これを回答に追加することをお勧めします:emulators.com/docs/nx25_nostradamus.htm
レアンダー

3
Insnの整列は、ブランチターゲットだけでなく、より重要です。デコードのボトルネックは、Core2とNehalemにとって大きな問題です。実行ユニットをビジー状態に保つのに苦労することがよくあります。Sandybridgeによるuopキャッシュの導入により、フロントエンドのスループットが大幅に向上しました。この問題のため、ブランチターゲットの整列が行われますが、すべてのコードに影響します。
Peter Cordes

80

http://research.google.com/pubs/pub37077.htmlを読むことをお勧めします

TL; DR:プログラムにnop命令をランダムに挿入すると、パフォーマンスを5%以上簡単に向上させることができます。また、コンパイラーがこれを簡単に利用することはできません。これは通常、分岐予測子とキャッシュ動作の組み合わせですが、たとえばリザベーションステーションのストールになることもあります(壊れている依存関係チェーンがない場合や、明らかなリソースのオーバーサブスクリプションがない場合でも)。


1
面白い。しかし、プロセッサ(またはFPC)は、ramへの書き込みがこの場合のNOPであることを確認するのに十分スマートですか?
タンジェントストーム2013

8
アセンブラは最適化されていません。
マルコファンデフォールト2013

5
コンパイラーは、ビルドとプロファイリングを繰り返し行うなどの非常にコストのかかる最適化を行い、シミュレートされたアニーリングまたは遺伝的アルゴリズムでコンパイラーの出力を変化させることにより、それを悪用する可能性があります。その分野でのいくつかの仕事について読んだことがあります。しかし、我々はコンパイルする100%CPUの最小5-10分を話している、そして結果として生じる最適化はおそらくCPUコアモデル、さらにはコアまたはマイクロコードのリビジョンに固有であろう。
AdamIerymenko 2013

私はそれをランダムNOPとは呼びません。NOPがパフォーマンスにプラスの影響を与える可能性がある理由(tl; dr:stackoverflow.com/a/5901856/357198)と、NOPのランダムな挿入によりパフォーマンスが低下した理由を説明します。この論文の興味深い点は、GCCによる「戦略的」NOPの削除がパフォーマンス全体に影響を与えなかったことです。
PuercoPop 2013

15

現代のCPUでは、アセンブリ命令は、CPUに実行命令を提供するためのプログラマーにとって最後の可視層であると信じていますが、実際には、CPUによる実際の実行から数層です。

最新のCPUはRISC / CISCハイブリッドであり、CISC x86命令をよりRISCの動作が高い内部命令に変換します。さらに、命令をより大きなバッチの同時作業(VLIW / Itaniumのようなもの)にグループ化しようとする、アウトオブオーダーの実行アナライザー、分岐予測子、Intelの「micro-opsフュージョン」があります。 titanicの)ます。キャッシュ境界もあり、コードが大きくなるとgod-knows-whyのコードをより高速に実行できるようになります(キャッシュコントローラーがよりインテリジェントにスロット化するか、それをより長く維持する可能性があります)。

CISCには常にアセンブリからマイクロコードへの変換レイヤーがありましたが、重要な点は、最近のCPUでは物事がはるかに複雑になるということです。現代の半導体製造プラントにあるトランジスタの余地がすべてあるため、CPUはおそらくいくつかの最適化手法を並行して適用し、最後に最高のスピードアップを提供する手法を選択できます。余分な命令は、他よりも優れている1つの最適化パスを使用するようにCPUにバイアスをかけている可能性があります。

追加の命令の影響は、CPUモデル/世代/製造元に依存する可能性が高く、予測できない可能性があります。この方法でアセンブリ言語を最適化するには、おそらくCPU固有の実行パスを使用して、多くのCPUアーキテクチャ世代に対して実行する必要があり、本当に重要なコードセクションに対してのみ望ましいでしょうが、アセンブリを実行している場合は、おそらくすでに知っています。


6
あなたの答えはちょっと混乱しています。多くの場所で、あなたは推測しているように見えますが、あなたが言うことのほとんどは正しいです。
alcuadrado 2013

2
多分私は明確にする必要があります。私は混乱を見つけることは確実性の欠如である
alcuadrado

3
それが理にかなっていて、適切な議論があれば、完全に有効です。
jturolla 2013

7
OPが特別な診断機器にアクセスできたIntelのエンジニアでない限り、OPがこの奇妙な動作を観察している理由を誰も確実に知ることはできません。他の人ができることはすべて推測です。@cowarldlydragonのせいではありません。
Alex D

2
反対票; あなたが言うことのどれもOPが見ている行動を説明しません。あなたの答えは役に立たない。
fuz

0

キャッシュの準備

メモリへの移動操作は、キャッシュを準備し、後続の移動操作をより高速にすることができます。通常、CPUには2つのロードユニットと1つのストアユニットがあります。ロードユニットはメモリからレジスタに読み取ることができ(サイクルごとに1回読み取り)、ストアユニットはレジスタからメモリに保存します。レジスタ間の操作を行う他のユニットもあります。すべてのユニットは並行して動作します。したがって、各サイクルで、一度に複数の操作を実行できますが、ロードは2つ、ストアは1つ、レジスタ操作は複数です。通常、プレーンレジスタを使用した最大4つの単純な演算、XMM / YMMレジスタを使用した最大3つの単純な演算、および任意の種類のレジスタを使用した1-2の複雑な演算です。コードにはレジスタを使用した多くの操作があるため、1つのダミーメモリストア操作は無料です(とにかく4つ以上のレジスタ操作があるため)。ただし、後続のストア操作のためにメモリキャッシュを準備します。メモリストアの機能については、Intel 64およびIA-32アーキテクチャー最適化リファレンスマニュアル

誤った依存関係を解消する

これはあなたのケースを正確に指しているわけではありませんが、64ビットプロセッサで(場合のように)32ビットのmov操作を使用して、上位ビット(32-63)をクリアし、依存関係チェーンを解除するために使用されます。

x86-64では、32ビットオペランドを使用すると、64ビットレジスタの上位ビットがクリアされることはよく知られています。インテル ®64 およびIA-32アーキテクチャーソフトウェア開発者向けマニュアル第1巻の関連セクション-3.4.1.1-をお読みください。

32ビットのオペランドは32ビットの結果を生成し、デスティネーションの汎用レジスターで64ビットの結果にゼロ拡張します

したがって、一見して役に立たないように見えるmov命令は、適切なレジスタの上位ビットをクリアします。それは私たちに何を与えますか?1995年のPentium Pro以降、CPUによって内部的に実装されたOut-of-Orderアルゴリズムによって、依存関係の連鎖が解消され、命令がランダムな順序で並列に実行されます。

以下からの引用インテル®64およびIA-32アーキテクチャー最適化リファレンス・マニュアル:セクション3.5.1.8

部分レジスターを変更するコードシーケンスは、依存関係チェーンで遅延が発生する可能性がありますが、依存関係を壊すイディオムを使用することで回避できます。インテルCoreマイクロアーキテクチャーに基づくプロセッサーでは、ソフトウェアがこれらの命令を使用してレジスターの内容をゼロにクリアするときに、いくつかの命令が実行の依存関係をクリアするのに役立ちます。部分的なレジスターではなく32ビットのレジスターを操作して、命令間のレジスターの部分の依存関係を解消します。移動の場合、これは32ビットの移動またはMOVZXを使用して実行できます。

アセンブリ/コンパイラコーディングルール37(Mの影響、MHの一般性):部分的なレジスターではなく32ビットのレジスターを操作することにより、命令間のレジスターの部分の依存関係を解消します。移動の場合、これは32ビットの移動またはMOVZXを使用して実行できます。

x64の32ビットオペランドを使用したMOVZXとMOVは同等です。これらはすべて依存関係チェーンを切断します。

そのため、コードの実行が速くなります。依存関係がない場合、CPUは内部的にレジスタの名前を変更できますが、一見すると、2番目の命令が最初の命令で使用されるレジスタを変更し、2つが並列に実行できないように見える場合があります。しかし、名前の変更を登録するため、それらは可能です。

名前の変更を登録は、CPUが内部で使用する手法であり、実際のデータ依存関係を持たない連続した命令によるレジスタの再利用から生じる誤ったデータ依存関係を排除します。

それはあまりにも明白であることがわかります。


これはすべて正しいですが、質問で提示されたコードとは何の関係もありません。
コーディグレイ

@CodyGray-フィードバックありがとうございます。私は返信を編集し、ケースに関する章を追加しました-レジスター操作に囲まれたメモリーへのmovはキャッシュを準備し、ストアユニットはとにかくアイドル状態なのでフリーです。したがって、後続のストア操作はより高速になります。
Maxim Masiutin、

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.