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 キャッシュ サイズとオーバーラップしていないことを考慮すると、これはキャッシュの競合によるものではありません。したがって、この部分はまだ私には意味がありません。