2つのストアがある単純なストアループのパフォーマンスが予想外に低くなっています。1つは16バイトの順方向ストライドで、もう1つは常に同じ場所にあります。1、 このような:
volatile uint32_t value;
void weirdo_cpp(size_t iters, uint32_t* output) {
uint32_t x = value;
uint32_t *rdx = output;
volatile uint32_t *rsi = output;
do {
*rdx = x;
*rsi = x;
rdx += 4; // 16 byte stride
} while (--iters > 0);
}
アセンブリでは、このループはおそらく3 次のようになります:
weirdo_cpp:
...
align 16
.top:
mov [rdx], eax ; stride 16
mov [rsi], eax ; never changes
add rdx, 16
dec rdi
jne .top
ret
アクセスされたメモリ領域がL2にある場合、これは反復ごとに3サイクル未満で実行されると思います。 2番目のストアは同じ場所にヒットし続け、約1サイクル追加する必要があります。最初のストアは、L2から行を取り込み、行を削除することを意味します4回の反復ごとに1回。 L2のコストをどのように評価するかはわかりませんが、控えめに見積もっても、L1はサイクルごとに次のいずれかしか実行できません:(a)ストアをコミットするか(b)L2から回線を受信するか(c) L2への行を削除すると、ストライド16ストアストリームに対して1 + 0.25 + 0.25 = 1.5サイクルのようなものが得られます。
実際、1つのストアをコメントアウトすると、最初のストアのみで反復ごとに最大1.25サイクル、2番目のストアで反復ごとに最大1.01サイクルになるため、反復ごとに2.5サイクルは控えめな見積もりのように見えます。
ただし、実際のパフォーマンスは非常に奇妙です。テストハーネスの一般的な実行は次のとおりです。
Estimated CPU speed: 2.60 GHz
output size : 64 KiB
output alignment: 32
3.90 cycles/iter, 1.50 ns/iter, cpu before: 0, cpu after: 0
3.90 cycles/iter, 1.50 ns/iter, cpu before: 0, cpu after: 0
3.90 cycles/iter, 1.50 ns/iter, cpu before: 0, cpu after: 0
3.89 cycles/iter, 1.49 ns/iter, cpu before: 0, cpu after: 0
3.90 cycles/iter, 1.50 ns/iter, cpu before: 0, cpu after: 0
4.73 cycles/iter, 1.81 ns/iter, cpu before: 0, cpu after: 0
7.33 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0
7.33 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0
7.34 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0
7.26 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0
7.28 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0
7.31 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0
7.29 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0
7.28 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0
7.29 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0
7.27 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0
7.30 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0
7.30 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0
7.28 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0
7.28 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0
ここでは2つのことが奇妙です。
最初はバイモーダルタイミングです。高速モードと低速モードがあります。 低速モードで開始し、反復ごとに約7.3サイクルかかり、ある時点で反復ごとに約3.9サイクルに移行します。この動作は一貫していて再現性があり、2つのタイミングは常に2つの値の周りにクラスター化されて非常に一貫しています。遷移は、低速モードから高速モードへの両方向に表示され、その逆もあります(1回の実行で複数の遷移が表示される場合もあります)。
他の奇妙なことは本当に悪いパフォーマンスです。 高速モードの場合でも、約3.9サイクルでは、パフォーマンスは1.0 + 1.3 = 2.3サイクルの最悪のキャストよりもはるかに悪くなります。両方のストアがループ内にある場合、完全にゼロの作業が重複する可能性があります)。 低速モードでは、最初の原則に基づいて期待するものと比較してパフォーマンスがひどいです。2つのストアを実行するのに7.3サイクルかかります。これを、L2ストアの帯域幅の条件で表すと、おおよそL2ストアごとに29サイクル(4回の反復ごとに1つの完全なキャッシュラインのみを格納するため)。
Skylakeは 記録 L1とL2の間に64B /サイクルのスループットがあります。これはwayここで観察されたスループットよりも高いです(遅い)で約2バイト/サイクルモード)。
スループットの低下とバイモーダルパフォーマンスの理由は何ですか?それを回避できますか?
これが他のアーキテクチャや他のSkylakeボックスでも再現されるかどうかも知りたいです。コメントにローカルの結果を自由に含めてください。
githubのテストコードとハーネス を見つけることができます。 LinuxまたはUnixライクなプラットフォーム用のMakefile
がありますが、Windows上でも比較的簡単に構築できるはずです。 asm
バリアントを実行する場合は、アセンブリにnasm
またはyasm
が必要です。4 -それがない場合は、C++バージョンを試すことができます。
これが私が考慮し、大部分を排除したいくつかの可能性です。多くの可能性は、パフォーマンスの遷移がランダムに表示されるという単純な事実によって排除されますベンチマークループの途中で、多くのものが単に変更されていない場合(たとえば、出力に関連している場合)配列の配置。同じバッファーが常に使用されているため、実行の途中で変更できませんでした)。以下では、これをデフォルトの除去と呼びます(デフォルトの除去である場合でも、別の議論が行われることがよくあります)。
stress -vm 4
を使用)。ベンチマーク自体はL2に収まるため、とにかく完全にコアローカルである必要があります。perf
は、反復ごとにL2ミスがほとんどないことを確認します(300〜400回の反復ごとに約1ミス、おそらくprintf
コード)。performance
モードのintel_pstate
です。テスト中に周波数変動は観察されません(CPUは基本的に2.59 GHzでロックされたままです)。perf
は、特に奇妙なTLBの動作を報告しません。私は toplev.py を使用しました。これはIntelの Top Down 分析方法を実装しており、当然のことながら、ベンチマークをストアバウンドとして識別します。
BE Backend_Bound: 82.11 % Slots [ 4.83%]
BE/Mem Backend_Bound.Memory_Bound: 59.64 % Slots [ 4.83%]
BE/Core Backend_Bound.Core_Bound: 22.47 % Slots [ 4.83%]
BE/Mem Backend_Bound.Memory_Bound.L1_Bound: 0.03 % Stalls [ 4.92%]
This metric estimates how often the CPU was stalled without
loads missing the L1 data cache...
Sampling events: mem_load_retired.l1_hit:pp mem_load_retired.fb_hit:pp
BE/Mem Backend_Bound.Memory_Bound.Store_Bound: 74.91 % Stalls [ 4.96%] <==
This metric estimates how often CPU was stalled due to
store memory accesses...
Sampling events: mem_inst_retired.all_stores:pp
BE/Core Backend_Bound.Core_Bound.Ports_Utilization: 28.20 % Clocks [ 4.93%]
BE/Core Backend_Bound.Core_Bound.Ports_Utilization.1_Port_Utilized: 26.28 % CoreClocks [ 4.83%]
This metric represents Core cycles fraction where the CPU
executed total of 1 uop per cycle on all execution ports...
MUX: 4.65 %
PerfMon Event Multiplexing accuracy indicator
これはあまり光を当てません。私たちは、それが物事を台無しにしている店でなければならないことをすでに知っていましたが、なぜですか? Intelの説明 条件の多くは言いません。
ここに L1-L2の相互作用に関連するいくつかの問題の合理的な要約。
2019年2月の更新:パフォーマンスの「バイモーダル」部分を再現できなくなりました:私にとって、同じi7-6700HQボックスでのパフォーマンスは今常に同じ場合に非常に遅いバイモーダルパフォーマンスが適用されます。つまり、次のように1行あたり約16〜20サイクルの結果になります。
この変更は、2018年8月のSkylakeマイクロコードアップデート、リビジョン0xC6で導入されたようです。以前のマイクロコード0xC2は、質問で説明されている元の動作を示しています。
1 これは私の元のループの大幅に簡略化されたMCVEであり、少なくとも3倍のサイズで、多くの追加作業を行いましたが、この単純なバージョンとまったく同じパフォーマンスを示し、同じ不思議な問題でボトルネックになりました。
3 特に、アセンブリを手動で記述した場合、またはgcc -O1
(バージョン5.4.1)、およびおそらく最も妥当なコンパイラ(volatile
は、ほとんど死んでいる2番目のストアがループの外に沈むのを防ぐために使用されます)。
4 アセンブリは非常に簡単なので、少し編集するだけでこれをMASM構文に変換できることは間違いありません。プルリクエストを受け付けました。
私がこれまでに見つけたもの。残念ながら、パフォーマンスの低下についての説明は実際には提供されておらず、バイモーダルディストリビューションについてもまったく説明されていませんが、パフォーマンスとそれを軽減するための注意事項が表示される場合の一連のルールです。
元の質問は任意に16のストライドを使用していましたが、おそらく最も単純なケースである64のストライド、つまり1つのフルキャッシュラインから始めましょう。さまざまな効果がどのストライドでも表示されることが判明しましたが、64はすべてのストライドでL2キャッシュミスを保証するため、いくつかの変数を削除します。
とりあえず2番目のストアも削除しましょう。64Kのメモリを超える単一の64バイトのストライドストアをテストしているだけです。
top:
mov BYTE PTR [rdx],al
add rdx,0x40
sub rdi,0x1
jne top
上記と同じハーネスでこれを実行すると、約3.05サイクル/ストアになります2、私が見慣れているものと比べるとかなりの違いがありますが(-そこには3.0もあります)。
ですから、純粋にL2までの持続的な店舗では、おそらくこれよりもうまくいくことはないでしょう。1。 Skylakeは明らかにL1とL2の間で64バイトのスループットを持っていますが、ストアのストリームの場合、その帯域幅はL1からの両方のエビクションで共有され、新しいラインをL1にロードする必要があります。 (a)L1からL2へのダーティビクティムラインの削除、(b)L2からの新しいラインでのL1の更新、および(c)ストアのL1へのコミットにそれぞれ1サイクルかかる場合、3サイクルは妥当と思われます。
ループ内の同じキャッシュライン(重要ではないことが判明しましたが、次のバイト)に2回目の書き込みを追加するとどうなりますか?このような:
top:
mov BYTE PTR [rdx],al
mov BYTE PTR [rdx+0x1],al
add rdx,0x40
sub rdi,0x1
jne top
上記のループのテストハーネスを1000回実行するタイミングのヒストグラムは次のとおりです。
count cycles/itr
1 3.0
51 3.1
5 3.2
5 3.3
12 3.4
733 3.5
139 3.6
22 3.7
2 3.8
11 4.0
16 4.1
1 4.3
2 4.4
したがって、ほとんどの時間は約3.5サイクルでクラスター化されます。つまり、この追加ストアはタイミングに0.5サイクルしか追加しませんでした。同じ行にある場合、ストアバッファが2つのストアをL1に排出できるようなものである可能性がありますが、これは約半分の時間でしか発生しません。
ストアバッファに1, 1, 2, 2, 3, 3
のような一連のストアが含まれていることを考慮してください。ここで、1
はキャッシュラインを示します。位置の半分には同じキャッシュラインからの2つの連続する値があり、残りの半分にはありません。ストアバッファがストアのドレインを待機しており、L1がL2への回線の立ち退きと受け入れに忙しいため、L1は「任意の」ポイントでストアに使用できるようになります。位置が1, 1
の場合店舗は1サイクルで排出されるかもしれませんが、1, 2
の場合は2サイクルかかります。
3.5ではなく3.1付近に結果の約6%の別のピークがあることに注意してください。それは、私たちが常に幸運な結果を得る定常状態である可能性があります。 〜4.0-4.1で約3%の別のピークがあります-「常に不運な」配置。
1番目と2番目のストア間のさまざまなオフセットを調べて、この理論をテストしてみましょう。
top:
mov BYTE PTR [rdx + FIRST],al
mov BYTE PTR [rdx + SECOND],al
add rdx,0x40
sub rdi,0x1
jne top
FIRST
とSECOND
のすべての値を0から256まで8ステップで試します。結果は、縦軸のFIRST
値とSECOND
を変化させたものです。水平方向:
特定のパターンが見られます。白い値は「高速」です(オフセット1について上記で説明した3.0〜4.1の値付近)。黄色の値は高く、最大8サイクル、赤は最大10です。紫色の外れ値が最も高く、通常、OPで説明されている「低速モード」が開始される場合です(通常は18.0サイクル/イターでクロッキング)。次のことに気づきました。
白血球のパターンから、2番目のストアが最初のストアと比較して同じキャッシュラインまたは次のにある限り、約3.5サイクルの高速結果が得られることがわかります。これは、同じキャッシュラインへのストアがより効率的に処理されるという上記の考え方と一致しています。次のキャッシュラインに2番目のストアがあることが機能する理由は、最初の最初のアクセスを除いて、パターンが同じになるためです:0, 0, 1, 1, 2, 2, ...
vs 0, 1, 1, 2, 2, ...
-2番目の場合は、各キャッシュラインに最初にアクセスする2番目のストア。ただし、ストアバッファは関係ありません。別のキャッシュラインに入るとすぐに、0, 2, 1, 3, 2, ...
のようなパターンが表示されますが、これはどうやらダメですか?
紫色の「外れ値」が白い領域に表示されることはないため、すでに遅いシナリオに限定されているようです(ここでの速度が遅いほど、約2.5倍遅くなります:約8〜18サイクル)。
少しズームアウトして、さらに大きなオフセットを見ることができます。
同じ基本パターンですが、2番目のストアが最初のストアから離れる(前または後ろ)と、約1700バイトのオフセットで再び悪化するまで、パフォーマンスが向上する(緑色の領域)ことがわかります。改善された領域でも、同じラインのパフォーマンスである3.5よりも、せいぜい5.8サイクル/反復しか達成できません。
追加した場合any先に実行される一種のロードまたはプリフェッチ命令3 店舗のうち、全体的な低速パフォーマンスと「低速モード」の外れ値の両方が消えます。
これを元のストライドバイ16の問題に移植して戻すことができます-コアループ内のあらゆるタイプのプリフェッチまたはロードで、距離にほとんど影響されません(実際にはbehindであっても)、問題を修正し、 2.3サイクル/反復が得られ、2.0の可能な限り最良の理想に近く、別々のループを持つ2つのストアの合計に等しくなります。
したがって、基本的なルールは、対応するロードのないL2へのストアは、ソフトウェアがそれらをプリフェッチする場合よりもはるかに遅いということです-ストアストリーム全体がsingleシーケンシャルパターンでキャッシュラインにアクセスしない限り。これは、このような線形パターンがSWプリフェッチの恩恵を受けることは決してないという考えとは反対です。
私は実際に具体的な説明を持っていませんが、それはこれらの要因を含む可能性があります:
これらのコメント IntelフォーラムのDr.McCalpinによるものも非常に興味深いものです。
ほとんどの場合、L2ストリーマーを無効にした場合にのみ達成可能です。それ以外の場合、L2での追加の競合により、3.5サイクルあたり約1行に速度が低下します。
1 これを、ロードあたりほぼ正確に1.5サイクルを取得するストアと比較してください。暗黙の帯域幅は、サイクルあたり約43バイトです。これは完全に理にかなっています。L1<-> L2帯域幅は64バイトですが、L1がどちらか L2からの回線を受け入れると仮定するとまたはコアからのロード要求を処理しますサイクルごとに(両方を並列にではなく)、異なるL2ラインへの2つのロードに対して3サイクルがあります。L2からのラインを受け入れるための2サイクルと、2つのロード命令を満たすための1サイクルです。
2 プリフェッチありoff。結局のところ、L2プリフェッチャーはストリーミングアクセスを検出すると、L2キャッシュへのアクセスを競合します。常に候補行を見つけて、L3に移動しない場合でも、コードの速度が低下し、変動性が高まります。結論は通常、プリフェッチをオンにした場合に当てはまりますが、すべてが少し遅くなります(これは、プリフェッチをオンにした場合の 結果の大きな塊 です-ロードごとに約3.3サイクルが表示されますが、変動が大きくなります)。
3 本当に先に進む必要はありません。数行後ろにプリフェッチすることもできます。プリフェッチ/ロードは、ボトルネックになっているストアよりもすぐに実行されるため、とにかく先に進みます。このように、プリフェッチは一種の自己修復であり、入力したほとんどすべての値で機能するようです。
Sandy Bridgeには、「L1データハードウェアプリフェッチャー」があります。これが意味するのは、最初にストアを実行するときに、CPUがL2からL1にデータをフェッチする必要があるということです。しかし、これが数回発生した後、ハードウェアプリフェッチャーはNiceシーケンシャルパターンに気づき、L2からL1へのデータのプリフェッチを開始します。そのため、データはL1にあるか、コードがお店。