web-dev-qa-db-ja.com

2D配列を反復処理するときに、ループの順序がパフォーマンスに影響するのはなぜですか?

以下は、i変数とj変数を入れ替えた点を除いて、ほぼ同一の2つのプログラムです。両方とも異なる時間で実行されます。誰かがこれが起こる理由を説明できますか?

バージョン1

#include <stdio.h>
#include <stdlib.h>

main () {
  int i,j;
  static int x[4000][4000];
  for (i = 0; i < 4000; i++) {
    for (j = 0; j < 4000; j++) {
      x[j][i] = i + j; }
  }
}

バージョン2

#include <stdio.h>
#include <stdlib.h>

main () {
  int i,j;
  static int x[4000][4000];
  for (j = 0; j < 4000; j++) {
     for (i = 0; i < 4000; i++) {
       x[j][i] = i + j; }
   }
}
335
Mark

他の人が言ったように、問題は配列のメモリ位置へのストアです:x[i][j]。理由は次のとおりです。

2次元配列がありますが、コンピューターのメモリは本質的に1次元です。配列を次のように想像しながら:

0,0 | 0,1 | 0,2 | 0,3
----+-----+-----+----
1,0 | 1,1 | 1,2 | 1,3
----+-----+-----+----
2,0 | 2,1 | 2,2 | 2,3

コンピューターは、それを1行としてメモリーに保存します。

0,0 | 0,1 | 0,2 | 0,3 | 1,0 | 1,1 | 1,2 | 1,3 | 2,0 | 2,1 | 2,2 | 2,3

2番目の例では、最初に2番目の数値をループすることで配列にアクセスします。

x[0][0] 
        x[0][1]
                x[0][2]
                        x[0][3]
                                x[1][0] etc...

すべてを順番に叩いているという意味です。次に、最初のバージョンを見てください。あなたはやっている:

x[0][0]
                                x[1][0]
                                                                x[2][0]
        x[0][1]
                                        x[1][1] etc...

Cがメモリ内の2次元配列をレイアウトした方法のため、場所全体にジャンプするように要求しています。しかし今、キッカーにとっては、なぜこれが重要なのでしょうか?すべてのメモリアクセスは同じですよね?

いいえ:キャッシュが原因です。メモリからのデータは、通常64バイトの小さなチャンク(「キャッシュライン」と呼ばれる)でCPUに転送されます。 4バイトの整数がある場合は、きちんとした小さなバンドルで16個の連続した整数を取得していることを意味します。実際、これらのメモリチャンクをフェッチするのはかなり遅いです。 CPUは、1つのキャッシュラインのロードに要する時間で多くの作業を実行できます。

次に、アクセスの順序を振り返ります。2番目の例は、(1)16 intのチャンクを取得し、(2)すべてを変更し、(3)4000 * 4000/16回繰り返します。これは素晴らしく高速であり、CPUには常に何か対処すべきことがあります。

最初の例は、(1)16 intのチャンクを取得し、(2)それらの1つだけを変更し、(3)4000 * 4000回繰り返します。それには、メモリからの16倍の「フェッチ」数が必要になります。 CPUは実際にそのメモリが現れるのを待つのに時間を費やさなければならず、その間、貴重な時間を無駄にしています。

重要な注意:

答えが得られたので、興味深いメモを次に示します。2番目の例を高速にする必要があるという固有の理由はありません。たとえば、Fortranでは、最初の例は高速で、2番目の例は低速です。これは、Cのように物事を概念的な「行」に展開する代わりに、Fortranが「列」に展開するためです。

0,0 | 1,0 | 2,0 | 0,1 | 1,1 | 2,1 | 0,2 | 1,2 | 2,2 | 0,3 | 1,3 | 2,3

Cのレイアウトは「行メジャー」と呼ばれ、Fortranは「列メジャー」と呼ばれます。ご覧のとおり、プログラミング言語が行優先か列優先かを知ることは非常に重要です!詳細情報へのリンクはこちらです: http://en.wikipedia.org/wiki/Row-major_order

566
Robert Martin

アセンブリとは関係ありません。これは、 キャッシュミス が原因です。

C多次元配列は、最後の次元が最速として保存されます。そのため、最初のバージョンでは反復ごとにキャッシュが失われますが、2番目のバージョンでは失われません。したがって、2番目のバージョンは大幅に高速になります。

http://en.wikipedia.org/wiki/Loop_interchange も参照してください。

64

バージョン2は、バージョン1よりもコンピューターのキャッシュを使用するため、はるかに高速に実行されます。考えてみれば、配列は単なるメモリの連続した領域です。配列の要素を要求すると、OSはおそらくその要素を含むキャッシュにメモリページを取り込みます。ただし、次のいくつかの要素も(連続しているため)そのページにあるため、次のアクセスは既にキャッシュ内にあります!これは、バージョン2が速度を上げるために行っていることです。

一方、バージョン1は、行単位ではなく列単位で要素にアクセスします。この種のアクセスはメモリレベルで連続していないため、プログラムはOSキャッシングをそれほど活用できません。

22
Oleksi

その理由は、キャッシュローカルデータアクセスです。 2番目のプログラムでは、メモリを直線的にスキャンしており、キャッシュとプリフェッチの利点があります。最初のプログラムのメモリ使用パターンは、はるかに広範囲に分散しているため、キャッシュの動作が悪くなります。

12

キャッシュヒットに関する他の優れた回答に加えて、最適化の違いも考えられます。 2番目のループは、コンパイラによって次のようなものに最適化される可能性があります。

  for (j=0; j<4000; j++) {
    int *p = x[j];
    for (i=0; i<4000; i++) {
      *p++ = i+j;
    }
  }

最初のループでは、ポインター「p」を毎回4000ずつインクリメントする必要があるため、これはあまり起こりません。

編集:p++、さらには*p++ = ..は、ほとんどのCPUで単一のCPU命令にコンパイルできます。 *p = ..; p += 4000はできないため、最適化してもメリットが少なくなります。また、コンパイラは内部配列のサイズを認識して使用する必要があるため、より困難です。また、通常のコードの内側のループでは頻繁に発生しません(ループ内で最後のインデックスが一定に保たれ、最後から2番目のインデックスがステップされる多次元配列でのみ発生します)。 。

10
fishinear

この行の犯人:

x[j][i]=i+j;

2番目のバージョンは連続メモリを使用するため、大幅に高速になります。

私が試した

x[50000][50000];

実行時間は、バージョン1では13秒、バージョン2では0.6秒です。

7
Nicolas Modrzyk

私は一般的な答えをしようとします。

i[y][x]はCの*(i + y*array_width + x)の省略形であるため(上​​品なint P[3]; 0[P] = 0xBEEF;を試してください)。

yを反復処理すると、サイズarray_width * sizeof(array_element)のチャンクを反復処理します。内側のループにそれがある場合、それらのチャンクに対してarray_width * array_heightの反復があります。

順序を反転することで、array_heightチャンク反復のみが得られ、チャンク反復間でsizeof(array_element)のみのarray_width反復が得られます。

本当に古いx86-CPUではこれはさほど重要ではありませんでしたが、今日のx86はデータのプリフェッチとキャッシュをたくさん行います。おそらく、遅い反復順序で多くの cache misses が生成されます。

4
Sebastian Mach