GCC 5.4.0での高価なジャンプ


171

私は次のような関数を持っています(重要な部分のみを示しています):

double CompareShifted(const std::vector<uint16_t>& l, const std::vector<uint16_t> &curr, int shift, int shiftY)  {
...
  for(std::size_t i=std::max(0,-shift);i<max;i++) {
     if ((curr[i] < 479) && (l[i + shift] < 479)) {
       nontopOverlap++;
     }
     ...
  }
...
}

このように書かれた場合、関数は私のマシンで最大34msかかりました。条件をブール乗算に変更した後(コードは次のようになります):

double CompareShifted(const std::vector<uint16_t>& l, const std::vector<uint16_t> &curr, int shift, int shiftY)  {
...
  for(std::size_t i=std::max(0,-shift);i<max;i++) {
     if ((curr[i] < 479) * (l[i + shift] < 479)) {
       nontopOverlap++;
     }
     ...
  }
...
}

実行時間は〜19msに短縮されました。

使用したコンパイラは-O3を使用したGCC 5.4.0であり、godbolt.orgを使用して生成されたasmコードを確認した後、最初の例ではジャンプが生成され、2番目の例ではジャンプされないことがわかりました。最初の例を使用するとジャンプ命令も生成するGCC 6.2.0を試すことにしましたが、GCC 7ではもう生成されないようです。

コードを高速化するためにこの方法を見つけることはかなり厄介で、かなりの時間がかかりました。コンパイラがこのように動作するのはなぜですか?それは意図されたものであり、プログラマーが注意しなければならないものですか?これに似たものは他にありますか?

編集:godbolt https://godbolt.org/g/5lKPF3へのリンク


17
コンパイラがこのように動作するのはなぜですか?生成されたコードが正しい限り、コンパイラは彼が望むように実行できます。一部のコンパイラは、他のコンパイラよりも最適化に優れています。
Jabberwocky

26
私の推測では、短絡評価は&&これを引き起こします。
Jens

9
これが私たちにもある理由&です。
rubenvb 16

7
@Jakubソートすると、おそらく実行速度が向上します。この質問を参照してください。
rubenvb 16

8
@rubenvb "評価してはならない"は、副作用のない式に対して実際に何も意味しません。ベクトルは境界チェックを行うので、GCCはそれが範囲外にならないことを証明できないと思います。編集:実際には、私はあなた i + shiftが範囲外にならないようにするために何かをしているとは思いません。
Random832 16

回答:


263

論理AND演算子(&&)は短絡評価を使用します。つまり、2番目のテストは、最初の比較がtrueと評価された場合にのみ行われます。多くの場合、これは正確に必要なセマンティクスです。たとえば、次のコードを考えます。

if ((p != nullptr) && (p->first > 0))

逆参照する前に、ポインタがnullでないことを確認する必要があります。これ短絡評価ではなかった場合、nullポインターを逆参照するため、動作が未定義になります。

条件の評価が高価なプロセスである場合、短絡評価がパフォーマンスの向上をもたらすことも可能です。例えば:

if ((DoLengthyCheck1(p) && (DoLengthyCheck2(p))

場合はDoLengthyCheck1失敗し、呼び出しても意味がありませんDoLengthyCheck2

ただし、結果のバイナリでは、コンパイラーがこれらのセマンティクスを保持する最も簡単な方法であるため、短絡操作によって2つの分岐が生じることがよくあります。(これは、コインの反対側で短絡評価が最適化の可能性を阻害することがある理由です。)これは、ifGCC 5.4によってステートメントに対して生成されたオブジェクトコードの関連部分を調べるとわかります。

    movzx   r13d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r13w, 478         ; (curr[i] < 479)
    ja      .L5

    cmp     ax, 478           ; (l[i + shift] < 479)
    ja      .L5

    add     r8d, 1            ; nontopOverlap++

ここには2つの比較(cmp指示)があり、それぞれに個別の条件付きジャンプ/分岐(ja、または上記の場合はジャンプ)が続きます。

分岐は遅いため、タイトなループでは回避することが一般的な経験則です。これは、控えめな8088(フェッチ時間が遅く、プリフェッチキューが非常に小さい[命令キャッシュと同等])から、実質的にすべてのx86プロセッサに当てはまります。分岐予測がまったくないため、分岐にはキャッシュのダンプが必要でした。 )最新の実装(パイプラインが長いため、予測が誤っている分岐も同様に高価になります)。私がそこに差し込んだ小さな警告に注意してください。Pentium Pro以降の最新のプロセッサには、分岐のコストを最小限に抑えるように設計された高度な分岐予測エンジンがあります。分岐の方向を適切に予測できれば、コストは最小限です。ほとんどの場合、これはうまく機能しますが、分岐予測子があなたの側にない病理学的なケースに遭遇した場合、コードが非常に遅くなる可能性があります。配列がソートされていないと言うので、これはおそらくあなたがここにいる場所です。

ベンチマークにより、&&をaに置き換えると*コードが著しく高速になることが確認されたと言います。この理由は、オブジェクトコードの関連部分を比較すると明らかです。

    movzx   r13d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    xor     r15d, r15d        ; (curr[i] < 479)
    cmp     r13w, 478
    setbe   r15b

    xor     r14d, r14d        ; (l[i + shift] < 479)
    cmp     ax, 478
    setbe   r14b

    imul    r14d, r15d        ; meld results of the two comparisons

    cmp     r14d, 1           ; nontopOverlap++
    sbb     r8d, -1

ここにはより多くの指示があるため、これがより高速になる可能性があることは少し直観に反しますが、最適化が機能する場合もあります。同じ比較(cmp)がここで行われているのがわかりますが、現在はそれぞれの前にxorとが付いていsetbeます。XORは、レジスタをクリアするための標準的なトリックにすぎません。これsetbeは、フラグの値に基づいてビットを設定するx86命令であり、ブランチレスコードの実装によく使用されます。ここでsetbeは、の逆ですja。比較が以下の場合は宛先レジスタを1に設定します(レジスタが事前にゼロ化されていたため、それ以外の場合は0になります)が、ja比較が上である場合は分岐します。でこれら2つの値が取得されるr15bと、r14bレジスタ、それらを使用して一緒に乗算されimulます。乗算は伝統的に比較的遅い演算でしたが、最近のプロセッサでは非常に速く、2バイトサイズの値を乗算するだけなので、これは特に高速になります。

乗算をビットごとのAND演算子(&)で置き換えるのと同じくらい簡単で、短絡評価を行いません。これにより、コードがより明確になり、コンパイラが一般的に認識するパターンになります。しかし、コードでこれを行い、GCC 5.4でコンパイルすると、最初のブランチが引き続き出力されます。

    movzx   r13d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r13w, 478         ; (curr[i] < 479)
    ja      .L4

    cmp     ax, 478           ; (l[i + shift] < 479)
    setbe   r14b

    cmp     r14d, 1           ; nontopOverlap++
    sbb     r8d, -1

この方法でコードを発行しなければならなかった技術的な理由はありませんが、何らかの理由で、その内部ヒューリスティックがこれがより高速であることを伝えています。それは考え分岐予測があなたの側にいたならば、おそらく高速になりますが、分岐予測がより頻繁にそれが成功するよりも失敗した場合、それはおそらく遅くなります。

新しい世代のコンパイラー(およびClangのような他のコンパイラー)はこの規則を知っており、手動で最適化するのと同じコードを生成するためにこの規則を使用することがあります。Clangが&&式を使用した場合に生成されたのと同じコードに式を変換するのを定期的に目にします&。以下は、通常の&&演算子を使用したコードでのGCC 6.2からの関連する出力です。

    movzx   r13d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r13d, 478         ; (curr[i] < 479)
    jg      .L7

    xor     r14d, r14d        ; (l[i + shift] < 479)
    cmp     eax, 478
    setle   r14b

    add     esi, r14d         ; nontopOverlap++

これがどれほど賢いかに注意してください!これは、(署名の条件を使用しているjgsetle)、符号なし条件(とは反対にjaしてsetbe)が、これは重要ではありません。古いバージョンのように、最初の条件の比較と分岐をsetCC実行し、同じ命令を使用して2番目の条件の分岐なしのコードを生成していることがわかりますが、増分の方法ははるかに効率的です。 。2番目の冗長な比較を行ってsbb操作のフラグを設定する代わりにr14d、1または0の知識を使用して、この値を無条件に単にに追加しますnontopOverlapr14dが0の場合、追加は何もしません。それ以外の場合は、想定どおりに1を追加します。

GCC 6.2 は、ビットごとの演算子よりも短絡演算子を使用すると、実際にはより効率的なコードを生成します。&&&

    movzx   r13d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r13d, 478         ; (curr[i] < 479)
    jg      .L6

    cmp     eax, 478          ; (l[i + shift] < 479)
    setle   r14b

    cmp     r14b, 1           ; nontopOverlap++
    sbb     esi, -1

ブランチと条件付きセットはまだそこにありますが、今はあまり賢くない方法のインクリメントに戻りますnontopOverlap。これは、コンパイラを上手にしようとするときに注意する必要がある重要な教訓です!

しかし、ベンチマークで分岐コードが実際に遅いことを証明できれば、コンパイラーを巧みに試してみる価値があるかもしれません。逆アセンブルを注意深く検査するだけで、そうする必要があります。また、コンパイラを新しいバージョンにアップグレードするときに、決定を再評価する準備をする必要があります。たとえば、次のように書き換えることができます。

nontopOverlap += ((curr[i] < 479) & (l[i + shift] < 479));

ここにはまったく何もif記述されておらず、コンパイラの大多数はこれのために分岐コードを発行することを決して考えません。GCCも例外ではありません。すべてのバージョンで次のようなものが生成されます。

    movzx   r14d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r14d, 478         ; (curr[i] < 479)
    setle   r15b

    xor     r13d, r13d        ; (l[i + shift] < 479)
    cmp     eax, 478
    setle   r13b

    and     r13d, r15d        ; meld results of the two comparisons
    add     esi, r13d         ; nontopOverlap++

前の例に従っている場合、これは非常に見慣れているはずです。両方の比較はブランチなしで行われ、中間結果がand一緒に編集され、次にこの結果(0または1のいずれかになります)がにadd編集されnontopOverlapます。ブランチレスコードが必要な場合は、これにより実質的に確実に取得できます。

GCC 7はさらに賢くなりました。これで、上記のトリックに対して、元のコードと実質的に同一のコード(命令の若干の再配置を除く)が生成されます。それで、あなたの質問への答えは、「コンパイラはなぜこのように振る舞うのですか?」、おそらく完璧ではないからです!彼らはヒューリスティックを使用して可能な限り最適なコードを生成しようとしますが、常に最良の決定を行うとは限りません。しかし、少なくとも彼らは時間とともに賢くなります!

この状況を見る1つの方法は、分岐コードの方が最良の場合のパフォーマンスが優れていることです。分岐予測が成功した場合、不要な操作をスキップすると、実行時間がわずかに速くなります。ただし、ブランチレスコードの方がワーストケースのパフォーマンスが優れています。分岐予測が失敗した場合、分岐を回避するために必要に応じていくつかの追加命令を実行すると、予測が誤っている分岐よりも確実に高速になります。最も賢く最も賢いコンパイラーでさえ、この選択をするのに苦労するでしょう。

そして、これがプログラマーが注意する必要がある何かであるかどうかのあなたの質問のために、答えはほとんど確実です。次に、分解して座って、それを微調整する方法を見つけます。そして、前に述べたように、新しいバージョンのコンパイラーに更新するときは、これらの決定を再検討する準備をしてください。トリッキーなコードで何かおかしなことをしたり、戻ることができるほど最適化ヒューリスティックを変更した可能性があるためです。元のコードを使用する。徹底コメント!


3
まあ、普遍的な「より良い」というものはありません。それはすべて状況に依存します。このため、この種の低レベルのパフォーマンス最適化を行う場合は、必ずベンチマークを行う必要があります。答えで説明したように、分岐予測のサイズが失われている場合、分岐の予測を誤ると、コードの速度が大幅に低下します。コードの最後のビットは使用されません任意の枝を(の不在に注意j*、それは速く、その場合になりますので、指示に従って)。[続く]
コーディ・グレイ


2
@ 8bit Bobは正しいです。私はプリフェッチキューを参照していました。私はおそらくそれをキャッシュと呼ぶべきではありませんでしたが、フレージングについてひどく心配したことはなく、詳細を思い出そうとするのにあまり時間をかけませんでした。詳細が必要な場合は、Michael AbrashのZen of Assembly Languageが非常に役立ちます。本全体がオンラインでさまざまな場所で入手できます。これは分岐の該当部分ですが、プリフェッチの部分も読んで理解する必要があります。
コーディグレイ

6
@Hurkyl答え全体がその質問について語っていると思います。私は明示的にそれを呼んだわけではありませんが、それはすでに十分に長いようでした。:-)時間をかけて全体を読む人は、その点を十分に理解する必要があります。ただし、何かが足りない、またはさらに明確にする必要があると思われる場合は、回答を編集してそれを含めるようにしてください。これを嫌う人もいますが、私はまったく気にしません。私はこれについて簡単なコメントを追加し、8bittreeによって提案された私の表現を変更しました。
コーディグレイ

2
ああ、補足してくれてありがとう、@ green。具体的な提案はありません。すべてと同じように、あなたはやって、見て、経験して、専門家になります。x86アーキテクチャー、最適化、コンパイラーの内部、およびその他の低レベルのものに関して、私が手に入れることができるすべてのものを読みましたが、それでも、知っておくべきすべてのほんの一部しか知りません。学ぶための最良の方法は、手を汚して掘り下げることです。ただし、開始する前に、C(またはC ++)、ポインター、アセンブリ言語、およびその他すべての低レベルの基礎をしっかりと理解する必要があります。
コーディグレイ

23

注意すべき重要な点の1つは、

(curr[i] < 479) && (l[i + shift] < 479)

そして

(curr[i] < 479) * (l[i + shift] < 479)

意味的に同等ではありません!特に、次のような状況が発生した場合:

  • 0 <= iそしてi < curr.size()、どちらも本当です
  • curr[i] < 479 間違っている
  • i + shift < 0またはi + shift >= l.size()本当です

その後、式(curr[i] < 479) && (l[i + shift] < 479)は明確に定義されたブール値であることが保証されます。たとえば、セグメンテーション違反は発生しません。

ただし、これらの状況では、式(curr[i] < 479) * (l[i + shift] < 479)未定義の動作です。セグメンテーション違反を引き起こすこと許可されています。

これは、たとえば、元のコードスニペットの場合、コンパイラーが両方の比較を実行してand操作を実行するループを作成することはできないことをl[i + shift]意味します。

つまり、元のコードでは、後者よりも最適化の機会が少なくなります。(もちろん、コンパイラが機会を認識するかどうかはまったく別の問題です)

代わりに元のバージョンを修正するかもしれません

bool t1 = (curr[i] < 479);
bool t2 = (l[i + shift] < 479);
if (t1 && t2) {
    // ...

この!shift(およびmax)の値に応じて、ここにUBがあります...
Matthieu M.

18

&&オペレータは短絡評価を実装しています。つまり、最初のオペランドがに評価される場合にのみ、2番目のオペランドが評価されtrueます。これは確かにその場合のジャンプをもたらします。

これを示す小さな例を作成できます。

#include <iostream>

bool f(int);
bool g(int);

void test(int x, int y)
{
  if ( f(x) && g(x)  )
  {
    std::cout << "ok";
  }
}

アセンブラの出力はここにあります

生成されたコードを最初に呼び出しf(x)、次に出力をチェックして、g(x)いつだったかの評価にジャンプすることがわかりtrueます。それ以外の場合は、関数から離れます。

代わりに「ブール」乗算を使用すると、両方のオペランドが毎回評価されるため、ジャンプする必要はありません。

データによっては、ジャンプによってCPUのパイプラインや投機的実行などの他のものが妨げられるため、ジャンプが遅くなる可能性があります。通常は分岐予測が役立ちますが、データがランダムな場合、予測できるものは多くありません。


1
乗算で毎回両方のオペランドの評価が強制されると述べているのはなぜですか?xの値に関係なく、0 * x = x * 0 = 0。最適化として、コンパイラは乗算も「短絡」する場合があります。たとえば、stackoverflow.com / questions / 8145894 /…を参照してください。さらに、&&演算子とは異なり、乗算は最初の引数または2番目の引数のいずれかで遅延評価され、最適化の自由度が高くなります。
SomeWittyUsername

@イェンス-「通常は分岐予測が役立ちますが、データがランダムな場合、予測できるものは多くありません。」-良い答えになります。
シェプリン2016

1
@SomeWittyUsernameわかりました。コンパイラーはもちろん、監視可能な動作を維持する最適化を自由に実行できます。これは変換する場合としない場合があり、計算を省略します。計算0 * f()fて観察可能な動作がある場合、コンパイラはそれを呼び出す必要があります。違いは、短絡評価は必須です&&が、と同等であることを示すことができる場合は許可され*ます。
Jens

@SomeWittyUsernameは、変数または定数から0値を予測できる場合にのみ使用します。これらのケースは非常に少ないと思います。配列アクセスが含まれるため、OPの場合は確かに最適化を実行できません。
ディエゴセビリア

3
@Jens:短絡評価は必須ではありません。コードはそれが短絡しているように動作することのみが要求されます。コンパイラは、結果を達成するために好きな方法を使用できます。

-2

これは、論理演算子を使用し&&ている場合、ifステートメントが成功するためにコンパイラーが2つの条件をチェックする必要があるためと考えられます。ただし、2番目のケースでは、int値を暗黙的にboolに変換しているため、コンパイラーは、渡されるタイプと値に基づいて、(おそらく)単一のジャンプ条件に基づいていくつかの仮定を行います。コンパイラーがビットシフトを使用してjmpを完全に最適化することも可能です。


8
ジャンプは、最初の条件が真である場合にのみ、2番目の条件が評価されるという事実から生じます。コードはそれを別の方法で評価してはなりません。そのため、コンパイラーはこれを最適化できず、正しいままにすることができません(最初のステートメントが常にtrueであると推定できない限り)。
rubenvb 16
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.