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


360

以下は、ij変数を入れ替えた以外はほぼ同じ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; }
   }
}


7
いくつかのベンチマーク結果を追加できますか?
naught101


14
@ naught101ベンチマークは、3〜10倍のパフォーマンスの違いを示します。これは基本的なC / C ++であり、これが非常に多くの票を獲得した方法については完全に困惑しています...
TC1

12
@ TC1:それは基本的なことではないと思います。多分中間。しかし、「基本的な」ものがより多くの人々にとって有用である傾向があることは当然のことであり、それゆえ多くの賛成票があります。さらに、これは「基本」であってもグーグルするのは難しい質問です。
LarsH 2012年

回答:


595

他の人が言ったように、問題は配列のメモリ位置へのストア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

あなたのコンピュータはそれを一行としてメモリに保存します:

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は、単一のキャッシュラインがロードするのにかかる時間で多くの作業を行うことができます。

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

最初の例は、(1)16整数のチャンクを取得する、(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


14
これはかなり完全な答えです。これは、キャッシュミスとメモリ管理を扱うときに私が教えたものです。

7
「最初」と「2番目」のバージョンが間違っている。最初の例は、内部ループの最初のインデックスを変更し、実行速度が遅くなります。
ca

すばらしい答えです。マークがそのような骨の折れる詳細についてもっと読みたいなら、私は「偉大なコードを書く」のような本を勧めます。
wkl 2012年

8
Cが行の順序をFortranから変更したことを指摘したボーナスポイント。科学計算では、L2キャッシュサイズがすべてです。すべての配列がL2に収まる場合、メインメモリにアクセスせずに計算を完了できるためです。
Michael Shopsin

4
@birryree:すべてのプログラマーがメモリについて知っておくべき自由に利用できることも良い読み物です。
ca

68

組み立てには関係ありません。これは、キャッシュミスが原因です。

C多次元配列は、最後の次元が最速として格納されます。したがって、最初のバージョンはすべての反復でキャッシュを見逃しますが、2番目のバージョンは見逃しません。したがって、2番目のバージョンはかなり高速になります。

参照:http : //en.wikipedia.org/wiki/Loop_interchange


23

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

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


これらの配列サイズでは、おそらくOSではなくCPUのキャッシュマネージャーが責任を負います。
krlmlr 2012年

12

その理由は、キャッシュローカルデータアクセスです。2番目のプログラムでは、キャッシュとプリフェッチの恩恵を受けるメモリ全体をリニアにスキャンしています。最初のプログラムのメモリ使用パターンははるかに広がっているため、キャッシュの動作が悪くなります。


11

キャッシュヒットに関する他の優れた回答に加えて、可能な最適化の違いもあります。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番目のインデックスがステップされる多次元配列でのみ発生します)。したがって、最適化の優先順位は低くなります。 。


「毎回4000でポインタ "p"をジャンプする必要があるため」という意味がわかりません。
Veedrac 2016年

:ポインタは、内側ループ内4000でインクリメントされる必要があるであろう@Veedrac p += 4000イソp++
fishinear

コンパイラが問題を見つけたのはなぜですか?iポインターの増分である場合、ユニット以外の値によって既に増分されています。
Veedrac 2016年

私はより多くの説明を追加しました
フィッシン2016年

gcc.godbolt.orgに入力int *f(int *p) { *p++ = 10; return p; } int *g(int *p) { *p = 10; p += 4000; return p; }してみてください。2つは基本的に同じようにコンパイルするようです。
Veedrac 2016年

7

この行の犯人:

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

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

私が試した

x[50000][50000];

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


4

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

のではi[y][x]の省略形です*(i + y*array_width + x)Cで(高級を試してみてくださいint P[3]; 0[P] = 0xBEEF;)。

繰り返し処理を行うとy、サイズのチャンクを繰り返し処理しますarray_width * sizeof(array_element)。あなたがそれをあなたの内側のループに持っているなら、あなたは持っているでしょうarray_width * array_heightそれらのチャンクに対する反復を。

順序を反転すると、array_heightチャンク反復のみが発生し、チャンク反復間では、array_width反復のみが発生します。sizeof(array_element)ます。

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

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.