G ++が計算をホットループにプルし、結果のコードのパフォーマンスを大幅に低下させる、非常に奇妙なコンパイラの動作があります。ここで何が起こっているのですか?
この関数について考えてみましょう。
#include <cstdint>
constexpr bool noLambda = true;
void funnyEval(const uint8_t* columnData, uint64_t dataOffset, uint64_t dictOffset, int32_t iter, int32_t limit, int32_t* writer,const int32_t* dictPtr2){
// Computation X1
const int32_t* dictPtr = reinterpret_cast<const int32_t*>(columnData + dictOffset);
// Computation X2
const uint16_t* data = (const uint16_t*)(columnData + dataOffset);
// 1. The less broken solution without lambda
if (noLambda) {
for (;iter != limit;++iter){
int32_t t=dictPtr[data[iter]];
*writer = t;
writer++;
}
}
// 2. The totally broken solution with lambda
else {
auto loop = [=](auto body) mutable { for (;iter != limit;++iter){ body(iter); } };
loop([=](unsigned index) mutable {
int32_t t=dictPtr[data[index]];
*writer = t;
writer++;
});
}
}
ここでの問題は、G ++がどういうわけか計算X1
とX2
をホットメインループに引き込み、パフォーマンスを低下させることを好むことです。詳細は次のとおりです。
この関数は、配列data
を反復処理し、辞書dictPtr
で値を検索し、それをターゲットメモリ位置writer
に書き込みます。 data
とdictPtr
は、関数の先頭で計算されます。そのための2つのフレーバーがあります。1つはラムダあり、もう1つはラムダなしです。
(この関数は、はるかに複雑なコードの最小限の作業例にすぎないことに注意してください。したがって、ここではラムダは不要であるとコメントしないでください。私はこの事実を認識しており、元のコードでは残念ながら必要です。)
高い最適化レベル(-O3 -std=c++14
)で最新のg ++(8.1および7.2を試しました。古いg ++でも同じ問題が提供されています)でコンパイルする場合の問題は次のとおりです。
解決策2。(noLambda=false
)は、ループに対して非常に悪いコードを生成します。これは、「ナイーブ」ソリューションよりもさらに悪いコードです。これは、スーパーホットの外側にある計算X1およびX2をプルすることをお勧めするためです。メインループ、スーパーホットメインループに入ると、CPUで約25%遅くなります。
.L3:
movl %ecx, %eax # unnecessary extra work
addl $1, %ecx
addq $4, %r9 # separate loop counter (pointer increment)
leaq (%rdi,%rax,2), %rax # array indexing with an LEA
movzwl (%rax,%rsi), %eax # rax+rsi is Computation X2, pulled into the loop!
leaq (%rdi,%rax,4), %rax # rax+rdx is Computation X1, pulled into the loop!
movl (%rax,%rdx), %eax
movl %eax, -4(%r9)
cmpl %ecx, %r8d
jne .L3
通常のforループ(noLambda=true
)を使用する場合、X2はループに引き込まれなくなりますが、X1は引き込まれているため、コードの方が優れています。
.L3:
movzwl (%rsi,%rax,2), %ecx
leaq (%rdi,%rcx,4), %rcx
movl (%rcx,%rdx), %ecx # This is Computation X1, pulled into the loop!
movl %ecx, (%r9,%rax,4)
addq $1, %rax
cmpq %rax, %r8
jne .L3
ループ内のdictPtr
(計算X1)をdictPtr2
(パラメーター)に置き換えることで、これが実際にループ内のX1であることを試すことができます。命令は消えます。
.L3:
movzwl (%rdi,%rax,2), %ecx
movl (%r10,%rcx,4), %ecx
movl %ecx, (%r9,%rax,4)
addq $1, %rax
cmpq %rax, %rdx
jne .L3
これが私が望むループです。ランダムな計算を行わずに値をロードして結果を格納する単純なループ。
では、ここで何が起こっているのでしょうか?計算をホットループにプルすることはめったに良い考えではありませんが、G ++はそうですここでそう考える。これは私に実際のパフォーマンスを犠牲にしています。ラムダは状況全体を悪化させます。これにより、G ++はさらに多くの計算をループに引き込みます。
この問題を非常に深刻にしているのは、これが特別な機能のない本当に些細なC++コードであるということです。コンパイラがそのような些細な例の完璧なコードを生成することに頼ることができない場合は、コード内のすべてのホットループのアセンブリをチェックして、すべてが可能な限り高速であることを確認する必要があります。 これは、これによって影響を受けるプログラムがおそらく膨大な数あることも意味します。
配列インデックスに符号なし32ビット型を使用しています(21行目)。これにより、コンパイラはループの各ステップで、使用可能な範囲をオーバーフローした可能性があるかどうかを考慮する必要があります。オーバーフローした場合は、配列の先頭に戻る必要があります。表示される追加のコードは、このチェックに関連しています。コンパイラによるこの過度に慎重なアプローチを回避するには、少なくとも3つの方法があります。
ループが始まる前にコードについて不平を言っているわけではありませんが、ここでも同じ問題があります。 iterを作成し、int64_tを制限するだけで、コンパイラが配列のオーバーフローの可能性を考慮しなくなるため、かなり短くなることがわかります。
要約すると、サイズが膨らむ原因となるのは、ループに移動するX1とX2の計算ではなく、誤って型指定された配列インデックス変数の使用です。
おめでとうございます。gccバグが見つかりました。主な解決策は、「missed-optimization」キーワードを使用して GCCのbugzilla で報告することです。 MCVEはすでにバグの優れたテストケースであるため、作成するのにそれほど時間はかからないはずです。コードと説明をコピーして貼り付けます。このQ&Aへのリンク、および http://godbolt.org/ のコードへのリンクも良いでしょう。
xor
-popcnt
/lzcnt
またはbsf
の宛先をゼロにするなどの「追加の」命令を使用する微妙なマイクロアーキテクチャ上の理由がある場合があります Intel CPUへの誤った依存を避けてください 、しかしここではそうではありません。これは悪いことです。ループ内の_movl %ecx, %eax
_は、ポインターよりも狭い符号なし型を使用した結果である可能性がありますが、それでもより効率的に実行できます。最適化も見逃されています。
詳細については、GCCのGIMPLEまたはRTLダンプ(内部表現)を調べていません。計算された値の唯一の使用法はループの内側であるため、プログラムロジックのコンパイラの内部表現が、変換時にループの内側と外側の違いを失った可能性があることを想像できます。通常、ループ内にある必要のないものは、ループから持ち上げられるか、沈められます。
しかし残念ながら、gccがループ内に余分なmov
命令を残して、ループ外のコードをセットアップすることは珍しくありません。特に、同じ効果を得るためにループの外側で複数の命令が必要になる場合。これは通常、コードサイズではなくパフォーマンスを最適化する場合の悪いトレードオフです。プロファイルガイド最適化からのasm出力を、gccがどのループが本当にホットであるかを認識し、それらを展開するコードを確認するために、必要なだけ調べていません。しかし、残念ながら、ほとんどのコードはPGOなしでビルドされるため、_-fprofile-use
_なしのcode-genは依然として非常に重要です。
ただし、この質問の核心は、この特定の例をできるだけ早く取得する方法ではありません。代わりに、コンパイラがこのような単純なコードスニペットでこのような最適化解除を生成する方法にかなり驚いています。 私の主な問題は次のとおりです:コンパイラへの信頼を失ったので、これがどのように発生するかを理解して、それを取り戻すことができます。
gccを信頼しないでください!これは非常に複雑な機械であり、多くの場合、良好な結果が得られますが、最適な結果が得られることはめったにありません。
ただし、このケースは、オプティマイザーが行うのを見た中で最も明白で単純な間違った選択の1つです(そしてかなり残念です)。通常、見落とされた最適化はやや微妙です(そして、アドレッシングモードの選択やuops /実行ポートなどのマイクロアーキテクチャの詳細に依存します)、または少なくともそれほど明白ではありません-避けるように見えます。 (ループ全体のレジスタ割り当てを変更せずに、その1つの命令を巻き上げます。)
しかし、多くのループは、uopスループットではなく、メモリのボトルネックになります。最近のCPUは、コンパイラ、特にJITコンパイラが生成する無駄な命令を噛み砕くように設計されています。これが、このような最適化の失敗が通常マクロスケールで大きな影響を与えない理由であり、それらが重要な場合(ビデオエンコーダーや行列の乗算など)でも手書きのasmのブロックを使用することが多い理由です。
多くの場合、必要なasmのように構造化された方法でソースを実装することにより、gccを手に持ってNiceasmを作成することができます。 (この場合のように: ある位置以下のセットビットをカウントする効率的な方法は何ですか? 、そして なぜこのC++コードはCollatzをテストするための私の手書きのアセンブリよりも速いのですか?推測? 、コンパイラを支援することと、手書きのasmでコンパイラを打ち負かすことについてのより一般的な答えについては。)
しかし、コンパイラがこのように頭がおかしくなっているときは、何もできません。まあ、回避策を探すか、他のいくつかの回答が重要であると指摘しているポインタよりも狭いunsigned
整数を避けるようなものを探すことを除いて。
興味深いことに、最悪のケース(ループ内に2つの追加のLEA命令と、追加のループカウンターの使用)は、if (noLambda)
でのみ発生します。
関数の2つの別々のバージョンを作成し、if
を削除すると、nolambda
バージョンはすてきなクリーンループを作成します(ただし、ギャザーの自動ベクトル化を見逃します。これは、でコンパイルすると勝ちです。 _-march=skylake
_)
私はあなたのコードを置きます Godboltコンパイラエクスプローラーに 。 (また、興味深いことに、_-funroll-loops
_を使用して、展開されたループの反復ごとに再実行される部分と、ループ内に1回だけ存在する部分を確認します。)
_# gcc7.2: the nolamba side of the if, with no actual if()
.L3:
movzwl (%rsi,%rax,2), %ecx
movl (%rdx,%rcx,4), %ecx
movl %ecx, (%r9,%rax,4) # indexed store: no port 7
addq $1, %rax # gcc8 -O3 -march=skylake uses inc to save a code byte here.
cmpq %rax, %r8
jne .L3
_
Intel Sandybridgeファミリでは、これは5uopsにデコードされます。 (cmp/jccのマクロ融合は、そのペアを1uopに変換します。他の命令はすべて単一uopです。movzwl
は純粋な負荷であり、ALUポートを必要としません)。
ストアはSnB/IvBでラミネートを解除しますが(フロントエンドの主要なボトルネックの1つである4ワイド発行ステージに追加のuopがかかります)、HSW/SKLで融合したままにすることができます。ただし、ポート7は使用できません(インデックスが付けられているため)。つまり、HSW/SKLは、クロックあたり3ではなく2メモリ操作に制限されます。
ボトルネック:
フロントエンドは、クロックあたり4つの融合ドメインuopsの帯域幅を発行します。ループは5uopsで、1.25ごとにほぼ1回の反復で発行できます。 (4の倍数ではないループは完全ではありませんが、 少なくとも、Haswell/Skylakeでは5uopsが適切に処理されます 。Sandybridgeではない可能性があります。)
ロード/ストア実行ポート:Haswell以降は、クロックごとに2つのロード+ 1つのストアを実行できますが、ストアがインデックス付きアドレッシングモードを回避し、ストアアドレスuopをポート7で実行できる場合に限ります。
ラムダバージョンは2番目のループカウンター(ポインターインクリメント)とばかげた_movl %ecx, %eax
_を取得しますが、LEA命令はループから外れます。
しかし、それ自体は余分な計算ではなく、おそらくループを傷つけているのはuopスループットの合計です。辞書がほとんどキャッシュ内でホットなままである場合は、Haswell以降のCPU
もっと書くつもりでしたが、終わらせませんでした。一般的な初期/中期の部分は明らかに質問が本当に何であるかであるため、今投稿します。 gccを盲目的に信頼しないでください。
そして、ほとんどの場合、最適なコードが作成されるとは期待しないでください。 Cソースを微調整するだけで多くの場合10または20%を得ることができます(場合によってはそれ以上)。アドレッシングモードでディスプレイスメントを使用する代わりに、展開時に明確な理由なしに余分なlea
sを使用するなど、gccに手がかりがないように見える場合があります。少なくとも_-march=haswell
_/_-march=skylake
_については、そのアドレッシングモードのコストモデルは正確であってはならないと思います。
私はあなたのコードを実行しようとしました、そして...驚き:あなたがループにいるときに実行される命令はあなたが投稿したコンパイラエクスプローラーリンクに見られるものではありません。これをチェックしてください(メイン関数を追加しました) https://godbolt.org/g/PPYtQa ループ内で実行される命令は162-167です。
.L15:
movzwl 25(%rbx,%rdx), %ecx
movl 5(%rbx,%rcx,4), %ecx
movl %ecx, 0(%rbp,%rdx,2)
addq $2, %rdx
cmpq $180, %rdx
jne .L15
マシンでコンパイルすることでこれを再確認できます
g++ test.cpp -std=c++1z -g -O3
gdbで実行しています
> gdb a.out
(gdb) break funnyEval
(gdb) layout split #shows assebly
(gdb) stepi #steps to the next instruction
コンパイラーは、実際に使用されているものがインライン化されている場合でも、funnyEvalの異なる非インライン化バージョン(逆アセンブルされた出力で見たもの)を生成します。この2つが異なる理由は(まだ)わかりませんが、パフォーマンスの低下に見舞われた場合は、funnyEvalがインライン化されるようにすることで、ヘッダーファイルで定義するか、コンパイルしてリンクすることで修正できると思います。リンク時の最適化(-flto)。 FunnyEvalが別の翻訳ユニットにあるときに何が起こるかを試してみます...