web-dev-qa-db-ja.com

2D配列を反復するためのネストされたループの順序は、より効率的です

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;    
   }
}
72
Sachin Mhetre

最初の方法は、セルが互いに隣接して配置されるため、少し優れています。

最初の方法:

[ ][ ][ ][ ][ ] ....
^1st assignment
   ^2nd assignment
[ ][ ][ ][ ][ ] ....
^101st assignment

2番目の方法:

[ ][ ][ ][ ][ ] ....
^1st assignment
   ^101st assignment
[ ][ ][ ][ ][ ] ....
^2nd assignment
64
MByD
  1. Array [100] [100]の場合-L1キャッシュが100 * 100 * sizeof(int)== 10000 * sizeof(int)== [通常は] 40000より大きい場合、どちらも同じです。注記 Sandy Bridge -L1キャッシュは32kしかないので、100 * 100の整数は違いを見るための十分な要素です。

  2. コンパイラはおそらくこのコードをまったく同じように最適化します

  3. コンパイラの最適化がなく、行列がL1キャッシュに適合しないと仮定します。最初のコードは、[通常]キャッシュパフォーマンスにより優れています。要素がキャッシュで見つからない場合は常に キャッシュミス -となり、RAMまたはL2キャッシュ[はるかに遅い])に移動する必要があります。 RAMキャッシュする[キャッシュフィル]からの要素は、ブロック[通常8/16バイト]で実行されます-したがって、最初のコードでは、最大でが得られますミス率1/4 [16バイトのキャッシュブロック、4バイトの整数を想定]で、2番目のコードでは無制限で、1にすることもできます。すでにキャッシュにあった[隣接する要素のキャッシュフィルに挿入された]-取り出され、冗長キャッシュミスが発生する。

    • これは ローカリティの原則 と密接に関連しています。これは、キャッシュシステムを実装するときに使用される一般的な仮定です。最初のコードはこの原則に従いますが、2番目のコードはそうではありません。そのため、最初のコードのキャッシュパフォーマンスは2番目のものよりも優れています。

結論:私が知っているすべてのキャッシュ実装について-最初のものは2番目のものより悪くはないでしょう。それらは同じかもしれません-まったくキャッシュがないか、すべての配列が完全にキャッシュに収まる場合-またはコンパイラの最適化が原因です。

43
amit

この種のマイクロ最適化はプラットフォームに依存するため、合理的な結論を導き出すことができるようにコードをプロファイルする必要があります。

13
Luchian Grigore

2番目のスニペットでは、各反復でのjの変化により、空間的局所性の低いパターンが生成されます。舞台裏では、配列参照は以下を計算します:

( ((y) * (row->width)) + (x) ) 

アレイの50行のみに十分なスペースがある単純化されたL1キャッシュを考えてみます。最初の50回の反復では、50回のキャッシュミスに対して不可避のコストを支払いますが、その後はどうなりますか? 50から99までの反復ごとに、ミスをキャッシュし、L2(および/またはRAMなど)からフェッチする必要があります。次に、xが1に変わり、yが最初からやり直され、配列の最初の行がキャッシュから追い出されたために、別のキャッシュミスが発生します。

最初のスニペットにはこの問題はありません。 row-major orderで配列にアクセスし、より優れた局所性を実現します-キャッシュミスに対して最大で1回のみ支払う必要があります(配列の行がキャッシュに存在しない場合)ループ開始)行ごと。

そうは言っても、これは非常にアーキテクチャに依存する質問であるため、結論を出すには、詳細(L1キャッシュサイズ、キャッシュラインサイズなど)を考慮する必要があります。また、両方の方法を測定し、ハードウェアイベントを追跡して、結論を導き出すための具体的なデータを取得する必要があります。

10

C++が行優先であることを考えると、最初の方法は少し高速になると思います。メモリでは、2D配列は1次元配列で表され、パフォーマンスは行メジャーまたは列メジャーを使用してアクセスするかどうかに依存します。

6
Habib

これはcache line bouncingに関する典型的な問題です

ほとんどの場合、最初の方が優れていますが、正確な答えはIT DEPENDSであり、アーキテクチャが異なると結果も異なる可能性があります。

4
llj098

2番目の方法では、-キャッシュは連続したデータをキャッシュに格納したため、キャッシュミスです。したがって、最初の方法は2番目の方法よりも効率的です。

4
Parag

あなたの場合(すべての配列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];
}
3
IProblemFactory

一般的に、局所性の向上(ほとんどのレスポンダによって通知されます)は、ループ#1パフォーマンスの最初の利点にすぎません。

2番目の(ただし関連する)利点は、ループのような#1の場合、コンパイラーは通常効率的に自動ベクトル化 stride-1メモリーアクセスパターン(ストライド-1は、次の反復ごとに配列要素に1つずつ連続してアクセスすることを意味します)。逆に、#2のようなループの場合の場合、メモリ内の連続ブロックへのストライド1の反復アクセスがないため、自動ベクトル化は正常に機能しません。

まあ、私の答えは一般的です。 #1や#2とまったく同じように非常に単純なループの場合は、より単純で積極的なコンパイラの最適化が使用され(差異の評価)、コンパイラは通常、自動ベクトル化#2stride-1 forouterループ(特に#pragma simdまたは同様のもの).

2
zam

最初のループ内にa[i] in a temp variableを格納し、その中でjインデックスを検索できるため、最初のオプションの方が優れています。この意味で、これはキャッシュされた変数と言えます。

1
Himanshu Goel