19

AVX -AVX2 命令セットを試して、連続した配列でのストリーミングのパフォーマンスを確認しました。したがって、基本的なメモリの読み取りと保存を行う例を以下に示します。

#include <iostream>
#include <string.h>
#include <immintrin.h>
#include <chrono>
const uint64_t BENCHMARK_SIZE = 5000;

typedef struct alignas(32) data_t {
  double a[BENCHMARK_SIZE];
  double c[BENCHMARK_SIZE];
  alignas(32) double b[BENCHMARK_SIZE];
}
data;

int main() {
  data myData;
  memset(&myData, 0, sizeof(data_t));

  auto start = std::chrono::high_resolution_clock::now();

  for (auto i = 0; i < std::micro::den; i++) {
    for (uint64_t i = 0; i < BENCHMARK_SIZE; i += 1) {
      myData.b[i] = myData.a[i] + 1;
    }
  }
  auto end = std::chrono::high_resolution_clock::now();
  std::cout << (end - start).count() / std::micro::den << " " << myData.b[1]
            << std::endl;
}

そして g++-4.9 -ggdb -march=core-avx2 -std=c++11 struct_of_arrays.cpp -O3 -o struct_of_arrays でコンパイルした後

ベンチマーク サイズ 4000 では、1 サイクルあたりの命令のパフォーマンスとタイミングが非常に良好であることがわかります。しかし、ベンチマーク サイズを 5000 に増やすと、1 サイクルあたりの命令が大幅に低下し、レイテンシーが急上昇することがわかります。私の質問は、パフォーマンスの低下が L1 キャッシュに関連しているように見えることはわかりますが、なぜこれが突然起こるのか説明できません。

ベンチマーク サイズ 4000 および 5000 で perf を実行すると、さらに洞察が得られます。

| Event                               | Size=4000 | Size=5000 |
|-------------------------------------+-----------+-----------|
| Time                                |    245 ns |    950 ns |
| L1 load hit                         |    525881 |    527210 |
| L1 Load miss                        |     16689 |     21331 |
| L1D writebacks that access L2 cache |   1172328 | 623710387 |
| L1D Data line replacements          |   1423213 | 624753092 |

私の質問は、haswell が 2* 32 バイトを読み取りに配信し、32 バイトを各サイクルに格納できる必要があることを考えると、なぜこの影響が発生しているのかということです。

編集1

このコードでは、gcc が 0 に設定されているため、myData.a へのアクセスをスマートに排除していることに気付きました。これを回避するために、a が明示的に設定されている、わずかに異なる別のベンチマークを実行しました。

#include <iostream>
#include <string.h>
#include <immintrin.h>
#include <chrono>
const uint64_t BENCHMARK_SIZE = 4000;

typedef struct alignas(64) data_t {
  double a[BENCHMARK_SIZE];
  alignas(32) double c[BENCHMARK_SIZE];

  alignas(32) double b[BENCHMARK_SIZE];

}
data;

int main() {
  data myData;
  memset(&myData, 0, sizeof(data_t));
  std::cout << sizeof(data) << std::endl;
  std::cout << sizeof(myData.a) << " cache lines " << sizeof(myData.a) / 64
            << std::endl;
  for (uint64_t i = 0; i < BENCHMARK_SIZE; i += 1) {
    myData.b[i] = 0;
    myData.a[i] = 1;
    myData.c[i] = 2;
  }

  auto start = std::chrono::high_resolution_clock::now();
  for (auto i = 0; i < std::micro::den; i++) {
    for (uint64_t i = 0; i < BENCHMARK_SIZE; i += 1) {
      myData.b[i] = myData.a[i] + 1;  
    }
  }
  auto end = std::chrono::high_resolution_clock::now();
  std::cout << (end - start).count() / std::micro::den << " " << myData.b[1]
            << std::endl;
}

2 番目の例では、1 つの配列が読み取られ、他の配列が書き込まれます。そして、これはさまざまなサイズに対して次の perf 出力を生成します。

| Event          | Size=1000   | Size=2000   | Size=3000   | Size=4000     |
|----------------+-------------+-------------+-------------+---------------|
| Time           | 86  ns      | 166 ns      | 734 ns      | 931    ns     |
| L1 load hit    | 252,807,410 | 494,765,803 | 9,335,692   | 9,878,121     |
| L1 load miss   | 24,931      | 585,891     | 370,834,983 | 495,678,895   |
| L2 load hit    | 16,274      | 361,196     | 371,128,643 | 495,554,002   |
| L2 load miss   | 9,589       | 11,586      | 18,240      | 40,147        |
| L1D wb acc. L2 | 9,121       | 771,073     | 374,957,848 | 500,066,160   |
| L1D repl.      | 19,335      | 1,834,100   | 751,189,826 | 1,000,053,544 |

回答で指摘されているのと同じパターンが再び見られます。データセットのサイズが大きくなると、データが L1 に収まらなくなり、L2 がボトルネックになります。また興味深いのは、プリフェッチが役に立っていないようで、L1 ミスが大幅に増加していることです。ただし、読み取りのために L1 に持ち込まれた各キャッシュ ラインが 2 回目のアクセスでヒットすることを考慮すると、少なくとも 50% のヒット率が見込めると予想されます (64 バイトのキャッシュ ラインは、各反復で 32 バイトが読み取られます)。ただし、データセットが L2 にスピルオーバーすると、L1 ヒット率は 2% に低下するようです。配列が実際には L1 キャッシュ サイズとオーバーラップしていないことを考慮すると、これはキャッシュの競合によるものではありません。したがって、この部分はまだ私には意味がありません。

4

2 に答える 2

20

エグゼクティブ サマリー:
キャッシュ レベルが異なると、同じ基本ワークロードに対して異なるピーク帯域幅を維持できるため、異なるサイズのデータ​​セットを使用すると、パフォーマンスに大きな影響を与える可能性があります。

より長い説明:ハスウェルがこの記事によれば、例えばできること
を考えると、それほど驚くべきことではありません。

サイクルごとに 2 つのロードと 1 つのストアを維持する

しかし、それはL1に適用すると言われているだけです. 読み進めると、L2

サイクルごとにデータまたは命令キャッシュに完全な 64B ラインを提供できます。

反復ごとに 1 つのロードと 1 つのストアが必要なため、データセットを L1 に配置すると、L1 の帯域幅を利用でき、反復ごとのサイクルのスループットに達する可能性があります。より長く待つように強制します。これは、システム内の double の大きさによって異なりますが、最も一般的なのは 8 バイトであるため、4000 * 2 配列 * 8 バイト = 64k となり、現在のほとんどのシステムの L1 サイズを超えています。ただし、Peter Cords はコメントで、元のコードがゼロ データ配列を最適化した可能性があることを示唆しています (確信はありませんが、可能性はあります)。

次のキャッシュ レベルを超え始めると、次の 2 つのことが起こります。

  1. L1-writebacks : この記事では、帯域幅に関して支払わなければならない追加のペナルティである writebacks について言及していないことに注意してください (perf 出力からわかるように、少し急勾配に見えますが)。データを L1 に保持するということは、エビクションをまったく行う必要がないことを意味しますが、L2 にデータを保持するということは、L2 から読み取られるすべての行が L1 から既存の行をスローする必要があることを意味します。あなたのコードと明示的な書き戻しを必要とします。これらのトランザクションは、反復ごとに使用する 2 つのデータ要素の値を読み取ることに加えて、行の一部が使用されておらず、マージが必要なため、ストアも最初に古いデータを読み取る必要があることに注意してください。

  2. キャッシュ置換ポリシー- キャッシュは連想的に設定されており、LRU スキームを使用する可能性が最も高いため、配列を順番に調べるため、キャッシュの使用パターンはおそらく最初の連想方法を満たし、次に 2 番目の方法に進むことに注意してください。など - 最後の方法を埋めるまでに、L2 にまだ必要なデータがある場合 (より大きなデータ セットの場合)、最初の方法からすべての行を削除する可能性があります。 -used ですが、これは次に使用するものであることも意味します。これは、キャッシュよりも大きなデータ セットを持つ LRU の欠点です。

これは、キャッシュ サイズを少なくとも 1 つのウェイのサイズ (L1 キャッシュの 1/8) だけ超えると、このアクセス パターンが原因でパフォーマンスの低下が突然発生する理由を説明しています。

パフォーマンスの結果について最後に 1 つコメントします。5000 要素の場合、L1 のヒット率がほぼゼロになると予想していましたが、実際にそうなると思います。ただし、ハードウェアのプリフェッチは、実際のデータ読み取りよりも先に実行されるため、L1 でまだヒットしているように見える場合があります。これらのプリフェッチがデータをもたらすのを待つ必要があります。さらに重要なことは、帯域幅を測定しているためです。実際のロード/ストアと同じ帯域幅を使用しますが、パフォーマンスによって考慮されていないため、信じてしまいますあなたはずっとL1ヒットを持っていました。少なくともそれは私の最善の推測です-プリフェッチを無効にして再度測定することで確認できます(私はそのようなアドバイスを頻繁に与えているようです。そのようなドラッグで申し訳ありません)。


EDIT 1(あなたのものに従う)

ダブルサイズに関する謎を解決する、削除された配列に関する素晴らしいキャッチ-実際には64ビットであるため、4000要素の1つの配列、またはそれぞれ2000要素の2つの配列(修正後)は、L1に収まる限りです. スピルは 3000 要素で発生するようになりました。L1 が 2 つの異なるストリームの前に実行するのに十分なプリフェッチを発行できなかったため、L1 のヒット率は現在低くなっています。

各ロードが 2 回の反復で 64 バイト ラインをもたらすという予想については、非常に興味深いことがわかりました。メモリ ユニットから発行されたロードの数 (L1 ヒット + L1 ミス) を合計すると、 2000 要素の場合は 1000 要素のほぼ正確に 2 倍ですが、3000 と 4000 の場合はそれぞれ 3 倍と 4 倍ではなく、むしろ半分です。具体的には、配列ごとに 3000 要素を使用すると、2000 要素の場合よりもアクセスが少なくなります!
これは、メモリユニットが2つのロードを1つのメモリアクセスにマージできると思われますが、L2以降に移動する場合のみです。考えてみると、それは理にかなっています。その回線に対して保留中のアクセスが既にある場合、L2 をルックアップするために別のアクセスを発行する理由はなく、そのレベルでの帯域幅の低下を軽減する実行可能な方法です。何らかの理由で、2 番目のロードは L1 ルックアップとしてカウントされず、見たいヒット率には役立たないと推測しています (実行に合格したロードの数を示すカウンターを確認できます。おそらくそうすべきです)。本当だ)。これは単なる推測ですが、カウンターがどのように定義されているかはわかりませんが、アクセス数と一致しています。

于 2013-10-27T19:00:53.587 に答える
4

私も Haswell を使用していますが、同じ結果を再現できません。正しいパフォーマンス イベントを使用しましたか? 私はさらに調査し、コードを自分でプロファイリングするのに十分なほど興味がありました。しかし、最初に、コードを静的に分析するだけで予想されるロードとストアの数を決定し、得られた数と比較して、それらが意味をなすかどうかを確認しましょう。gcc 4.9 を使用しています。これは、次を使用してループ ネスト用に出力されるアセンブリ コードです-march=core-avx2 -O3

  4007a8:   48 8d 85 d0 2a fe ff    lea    -0x1d530(%rbp),%rax
  4007af:   90                      nop
  4007b0:   c5 f5 58 00             vaddpd (%rax),%ymm1,%ymm0
  4007b4:   48 83 c0 20             add    $0x20,%rax
  4007b8:   c5 fd 29 80 60 38 01    vmovapd %ymm0,0x13860(%rax)
  4007bf:   00 
  4007c0:   48 39 c2                cmp    %rax,%rdx
  4007c3:   75 eb                   jne    4007b0 <main+0x50>
  4007c5:   83 e9 01                sub    $0x1,%ecx
  4007c8:   75 de                   jne    4007a8 <main+0x48>

内部ループ反復ごとに、正確に 1 つの整列された 32 バイト ロード uop と 1 つの整列された 32 バイト ストア uop があります。外側のループ トリップ カウントは 100 万です。内部ループのトリップ カウントはBENCHMARK_SIZE/4 です (ベクトル化のため)。したがって、L1 へのロード リクエストの総数は約 100 万 * BENCHMARK_SIZE/4 になり、ストアの総数もほぼ同じになるはずです。たとえば、BENCHMARK_SIZEが 4000 の場合、ロード リクエストとストア リクエストの数はそれぞれ 10 億になります。ループ分岐は非常に予測可能であるため、リタイアしていない投機的ロードやコード フェッチについて心配する必要はありません。

Haswell の L1D には 2 つの 32 バイト ロード ポートと 1 つの 32 バイト ストア ポートがあることを思い出してください。次のグラフは、私が を使用して得たものを示していperfます。これらの測定を行ったとき、L1D プリフェッチャーと L2 プリフェッチャーの両方が有効になっていることに注意してください。ハイパースレッディングを無効にして、摂動の可能性を排除し、他の 4 つのプログラマブル パフォーマンス カウンターを利用できるようにしました。

ここに画像の説明を入力

最初に観察できることは、ロード ( MEM_UOPS_RETIRED.ALL_LOADS) とストア ( MEM_UOPS_RETIRED.ALL_STORES) の数が静的分析と一致することです。カッコいい。しかし、最初の重要な観察結果は、L1D ロードのヒット数 ( MEM_LOAD_UOPS_RETIRED.L1_HIT) が L1D ロードの数に非常に近いということです。これは、L1D ストリーミング プリフェッチャーがほとんどのmyData.a[i]アクセスをタイムリーにプリフェッチできたことを意味します。明らかに、L1D ロード ミス ( MEM_LOAD_UOPS_RETIRED.L1_MISS) の数は非常に少なくなければなりません。これは のすべての値に当てはまりますBENCHMARK_SIZE

L1D_PEND_MISS.REQUEST_FB_FULLデマンド ロード、ストア、またはソフトウェア プリフェッチ要求が L1D に到達しなかったが、使用可能なフィル バッファがないためにロード/ストア バッファから発行できなかったサイクル数を示します。これは重大な問題のようです。ただし、このイベントでは、ロード、ストア、またはその両方がブロックされているかどうかを判断できません。そのための別のイベントがありますが、これについては後ほど説明します。が 2000 以下の場合、このイベント数は無視できます。これBENCHMARK_SIZEは、内部ループの最初の繰り返しの後、以降のすべてのロードとストアがキャッシュにヒットし、フィル バッファーが不要になるためです。

L2_TRANS.RFOL2 にアクセスする RFO リクエストの数をカウントします。グラフをよく見ると、これがストア uops の総数の半分より少し少ないように見えることがわかります。これは、2 つの連続するストア uop がすべて同じキャッシュ ラインに対するものであるため、理にかなっています。したがって、一方が L1D に失敗した場合、もう一方は失敗し、同じ LFB エントリで書き込み結合され、L2 への同じ RFO 要求内で押しつぶされます。L2_TRANS.RFO正確に半分ではない理由がわかりません( > 2000MEM_UOPS_RETIRED.ALL_STORESの場合に予想したとおり)。BENCHMARK_SIZE

L2_RQSTS.ALL_DEMAND_DATA_RD、マニュアルによると、L1からのデマンドデータロードの数と、L2へのL1プリフェッチリクエストの数をカウントすることになっています。しかし、それは非常に小さいです。デマンド データ ロードの数だけをカウントするか、L1 ストリーミング プリフェッチャーが L3 と直接通信できると思います。とにかく、これはこの分析にとって重要ではありません。

このグラフから、ロード リクエストはクリティカル パス上になく、ストア リクエストはクリティカル パス上にあると結論付けることができます。RESOURCE_STALLS.SB次のステップは、店舗が実際にどれほど苦しんでいるかを判断するために明らかに測定することです. このイベントは、フル ストア バッファによるフル アロケーション ストール サイクルの数をカウントします。

ここに画像の説明を入力

(cyclesグラフ内は停止していないコア サイクルを参照しており、これは基本的に実行時間です。)

グラフは、実行時間の 60% 以上がストア バッファー エントリが解放されるのを待っているアロケーターで浪費されていることを示しています。なぜこうなった?どちらの L1D プリフェッチャーも、ロード要求を追跡し、S または E コヒーレンス状態でラインをフェッチします。ロードとストアが同じキャッシュ ラインに対して行われ、他のコアがラインの共有コピーを持っていない場合、L1 ストリーマーは E 状態でラインをプリフェッチし、ロードとストアの両方に効果的です。しかし、この例では、ストアは異なるキャッシュ ラインにあり、これらはいずれの L1D プリフェッチャーによっても追跡されません。書き込み結合 LFB は大いに役立ちますが、タイトなループが L1D コントローラーを圧倒し、屈服させ、ロード/ストア バッファー ユニットにそれ以上のストア リクエストの発行を停止するように求めます。ロード リクエストは、ほとんどがキャッシュにヒットし、実行されないため、引き続き発行できます。その場合、LFB は必要ありません。そのため、ストア バッファーがいっぱいになるまでストアが積み重なるため、アロケーターが停止します。LFB は、ストア ミスと L1 ストリーマーからの要求を組み合わせることで、ほとんどが競争的に占有されます。したがって、LFB の数とストア バッファー エントリはクリティカル パス上にあります。L1D 書き込みポートの数ではありません。そのクリティカル パスは、格納されているアレイのサイズが L1D の容量を超えたときに発生します。

完全を期すために、リタイアした命令の数と実行時間を秒単位で示すグラフを次に示します。

ここに画像の説明を入力

@PeterCordes は、問題のサイズで測定値を正規化することを提案しました。次のグラフは、さまざまな値の正規化された命令サイクル カウントをプロットしたものですBENCHMARK_SIZE。Cycles と命令は異なる単位であるため、それぞれに独自の軸を与える必要があると考えました。しかし、グラフは、正規化された命令数が大幅に変化しているように見えましたが、そうではなく、意味がありません。そこで、グラフに示されているように、両方を同じ軸にプロットすることにしました。IPC と CPI は、このグラフから簡単に観察できます。これは素晴らしいことです。

ここに画像の説明を入力

于 2018-09-06T23:58:44.657 に答える