2D配列を反復処理するためのネストされたループの次の順序付けのうち、時間(キャッシュパフォーマンス)の面でより効率的なのはどれですか。どうして?
int a[100][100];
for(i=0; i<100; i++)
{
for(j=0; j<100; j++)
{
a[i][j] = 10;
}
}
または
for(i=0; i<100; i++)
{
for(j=0; j<100; j++)
{
a[j][i] = 10;
}
}
最初の方法は、セルが互いに隣接して配置されるため、少し優れています。
最初の方法:
[ ][ ][ ][ ][ ] ....
^1st assignment
^2nd assignment
[ ][ ][ ][ ][ ] ....
^101st assignment
2番目の方法:
[ ][ ][ ][ ][ ] ....
^1st assignment
^101st assignment
[ ][ ][ ][ ][ ] ....
^2nd assignment
Array [100] [100]の場合-L1キャッシュが100 * 100 * sizeof(int)== 10000 * sizeof(int)== [通常は] 40000より大きい場合、どちらも同じです。注記 Sandy Bridge -L1キャッシュは32kしかないので、100 * 100の整数は違いを見るための十分な要素です。
コンパイラはおそらくこのコードをまったく同じように最適化します
コンパイラの最適化がなく、行列がL1キャッシュに適合しないと仮定します。最初のコードは、[通常]キャッシュパフォーマンスにより優れています。要素がキャッシュで見つからない場合は常に キャッシュミス -となり、RAMまたはL2キャッシュ[はるかに遅い])に移動する必要があります。 RAMキャッシュする[キャッシュフィル]からの要素は、ブロック[通常8/16バイト]で実行されます-したがって、最初のコードでは、最大でが得られますミス率1/4
[16バイトのキャッシュブロック、4バイトの整数を想定]で、2番目のコードでは無制限で、1にすることもできます。すでにキャッシュにあった[隣接する要素のキャッシュフィルに挿入された]-取り出され、冗長キャッシュミスが発生する。
結論:私が知っているすべてのキャッシュ実装について-最初のものは2番目のものより悪くはないでしょう。それらは同じかもしれません-まったくキャッシュがないか、すべての配列が完全にキャッシュに収まる場合-またはコンパイラの最適化が原因です。
この種のマイクロ最適化はプラットフォームに依存するため、合理的な結論を導き出すことができるようにコードをプロファイルする必要があります。
2番目のスニペットでは、各反復でのj
の変化により、空間的局所性の低いパターンが生成されます。舞台裏では、配列参照は以下を計算します:
( ((y) * (row->width)) + (x) )
アレイの50行のみに十分なスペースがある単純化されたL1キャッシュを考えてみます。最初の50回の反復では、50回のキャッシュミスに対して不可避のコストを支払いますが、その後はどうなりますか? 50から99までの反復ごとに、ミスをキャッシュし、L2(および/またはRAMなど)からフェッチする必要があります。次に、x
が1に変わり、y
が最初からやり直され、配列の最初の行がキャッシュから追い出されたために、別のキャッシュミスが発生します。
最初のスニペットにはこの問題はありません。 row-major orderで配列にアクセスし、より優れた局所性を実現します-キャッシュミスに対して最大で1回のみ支払う必要があります(配列の行がキャッシュに存在しない場合)ループ開始)行ごと。
そうは言っても、これは非常にアーキテクチャに依存する質問であるため、結論を出すには、詳細(L1キャッシュサイズ、キャッシュラインサイズなど)を考慮する必要があります。また、両方の方法を測定し、ハードウェアイベントを追跡して、結論を導き出すための具体的なデータを取得する必要があります。
C++が行優先であることを考えると、最初の方法は少し高速になると思います。メモリでは、2D配列は1次元配列で表され、パフォーマンスは行メジャーまたは列メジャーを使用してアクセスするかどうかに依存します。
これはcache line bouncing
に関する典型的な問題です
ほとんどの場合、最初の方が優れていますが、正確な答えはIT DEPENDSであり、アーキテクチャが異なると結果も異なる可能性があります。
2番目の方法では、-キャッシュは連続したデータをキャッシュに格納したため、キャッシュミスです。したがって、最初の方法は2番目の方法よりも効率的です。
あなたの場合(すべての配列1の値を埋める)、それはより速くなります:
for(j = 0; j < 100 * 100; j++){
a[j] = 10;
}
また、a
を2次元配列として扱うこともできます。
[〜#〜] edit [〜#〜]:Binyamin Sharetが述べたように、a
が次のように宣言されていれば、それを行うことができます。
int **a = new int*[100];
for(int i = 0; i < 100; i++){
a[i] = new int[100];
}
一般的に、局所性の向上(ほとんどのレスポンダによって通知されます)は、ループ#1パフォーマンスの最初の利点にすぎません。
2番目の(ただし関連する)利点は、ループのような#1の場合、コンパイラーは通常効率的に自動ベクトル化 stride-1メモリーアクセスパターン(ストライド-1は、次の反復ごとに配列要素に1つずつ連続してアクセスすることを意味します)。逆に、#2のようなループの場合の場合、メモリ内の連続ブロックへのストライド1の反復アクセスがないため、自動ベクトル化は正常に機能しません。
まあ、私の答えは一般的です。 #1や#2とまったく同じように非常に単純なループの場合は、より単純で積極的なコンパイラの最適化が使用され(差異の評価)、コンパイラは通常、自動ベクトル化#2stride-1 forouterループ(特に#pragma simdまたは同様のもの).
最初のループ内にa[i] in a temp variable
を格納し、その中でjインデックスを検索できるため、最初のオプションの方が優れています。この意味で、これはキャッシュされた変数と言えます。