論理AND演算子(&&
)は短絡評価を使用します。つまり、2番目のテストは、最初の比較がtrueと評価された場合にのみ行われます。多くの場合、これは正確に必要なセマンティクスです。たとえば、次のコードを考えます。
if ((p != nullptr) && (p->first > 0))
逆参照する前に、ポインタがnullでないことを確認する必要があります。これが短絡評価ではなかった場合は、nullポインターを逆参照するため、動作が未定義になります。
条件の評価が高価なプロセスである場合、短絡評価がパフォーマンスの向上をもたらすことも可能です。例えば:
if ((DoLengthyCheck1(p) && (DoLengthyCheck2(p))
場合はDoLengthyCheck1
失敗し、呼び出しても意味がありませんDoLengthyCheck2
。
ただし、結果のバイナリでは、コンパイラーがこれらのセマンティクスを保持する最も簡単な方法であるため、短絡操作によって2つの分岐が生じることがよくあります。(これは、コインの反対側で短絡評価が最適化の可能性を阻害することがある理由です。)これは、if
GCC 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++
これがどれほど賢いかに注意してください!これは、(署名の条件を使用しているjg
とsetle
)、符号なし条件(とは反対にja
してsetbe
)が、これは重要ではありません。古いバージョンのように、最初の条件の比較と分岐をsetCC
実行し、同じ命令を使用して2番目の条件の分岐なしのコードを生成していることがわかりますが、増分の方法ははるかに効率的です。 。2番目の冗長な比較を行ってsbb
操作のフラグを設定する代わりにr14d
、1または0の知識を使用して、この値を無条件に単にに追加しますnontopOverlap
。r14d
が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つの方法は、分岐コードの方が最良の場合のパフォーマンスが優れていることです。分岐予測が成功した場合、不要な操作をスキップすると、実行時間がわずかに速くなります。ただし、ブランチレスコードの方がワーストケースのパフォーマンスが優れています。分岐予測が失敗した場合、分岐を回避するために必要に応じていくつかの追加命令を実行すると、予測が誤っている分岐よりも確実に高速になります。最も賢く最も賢いコンパイラーでさえ、この選択をするのに苦労するでしょう。
そして、これがプログラマーが注意する必要がある何かであるかどうかのあなたの質問のために、答えはほとんど確実です。次に、分解して座って、それを微調整する方法を見つけます。そして、前に述べたように、新しいバージョンのコンパイラーに更新するときは、これらの決定を再検討する準備をしてください。トリッキーなコードで何かおかしなことをしたり、戻ることができるほど最適化ヒューリスティックを変更した可能性があるためです。元のコードを使用する。徹底コメント!