ループを処理していたところ、ループへのアクセスに大きな違いがあることがわかりました。どちらの場合でも、このような違いが生じる原因は何ですか?
最初の例:
実行時間; 8秒
for (int kk = 0; kk < 1000; kk++)
{
sum = 0;
for (int i = 0; i < 1024; i++)
for (int j = 0; j < 1024; j++)
{
sum += matrix[i][j];
}
}
2番目の例:
実行時間:23秒
for (int kk = 0; kk < 1000; kk++)
{
sum = 0;
for (int i = 0; i < 1024; i++)
for (int j = 0; j < 1024; j++)
{
sum += matrix[j][i];
}
}
交換するだけで実行時間に大きな違いが生じる原因
matrix[i][j]
に
matrix[j][i]
?
これはメモリキャッシュの問題です。
matrix[i][j]
は連続してメモリにアクセスする可能性が高いため、matrix[j][i]
はmatrix[i][j]
よりもキャッシュヒットが優れています。
たとえば、matrix[i][0]
にアクセスすると、キャッシュはmatrix[i][0]
を含むメモリの連続セグメントをロードする可能性があるため、matrix[i][1]
、matrix[i][2]
、...にアクセスすると、 matrix[i][1]
、matrix[i][2]
、...はmatrix[i][0]
に近いため、キャッシュ速度。
ただし、matrix[j][0]
にアクセスすると、matrix[j - 1][0]
からは遠く、キャッシュされない可能性があり、キャッシュ速度の恩恵を受けることができません。特に、マトリックスは通常、メモリの連続した大きなセグメントとして格納され、キャッシュ機能はメモリアクセスの動作を予測し、常にメモリをキャッシュします。
そのため、matrix[i][j]
の方が高速です。これは、CPUキャッシュベースのパフォーマンス最適化で一般的です。
パフォーマンスの違いは、コンピューターのキャッシュ戦略によって引き起こされます。
2次元配列matrix[i][j]
は、メモリ内の値の長いリストとして表されます。
たとえば、配列A[3][4]
は次のようになります。
1 1 1 1 2 2 2 2 3 3 3 3
この例では、A [0] [x]のすべてのエントリが1に設定され、A [1] [x]のすべてのエントリが2に設定されます...
最初のループがこの行列に適用される場合、アクセスの順序は次のとおりです。
1 2 3 4 5 6 7 8 9 10 11 12
2番目のループのアクセス順序は次のようになります。
1 4 7 10 2 5 8 11 3 6 9 12
プログラムが配列の要素にアクセスすると、後続の要素もロードされます。
例えば。 A[0][1]
にアクセスすると、A[0][2]
とA[0][3]
も読み込まれます。
これにより、必要なときに一部の要素がすでにキャッシュにあるため、最初のループで実行するロード操作が少なくて済みます。 2番目のループは、現時点では不要なエントリをキャッシュにロードするため、ロード操作が増えます。
他の人々は、ある形式のコードが他の形式よりもメモリキャッシュをより効率的に使用する理由を説明する良い仕事をしました。あなたが気づいていないかもしれないいくつかの背景情報を追加したいと思います:あなたはおそらく今日メインメモリアクセスがどれほど高価であるか気づいていないでしょう。
この質問 に投稿された数字は、私にとって正しい球場にあるように見えます。それらは非常に重要であるため、ここで再現します。
Core i7 Xeon 5500 Series Data Source Latency (approximate)
L1 CACHE hit, ~4 cycles
L2 CACHE hit, ~10 cycles
L3 CACHE hit, line unshared ~40 cycles
L3 CACHE hit, shared line in another core ~65 cycles
L3 CACHE hit, modified in another core ~75 cycles remote
remote L3 CACHE ~100-300 cycles
Local Dram ~60 ns
Remote Dram ~100 ns
最後の2つのエントリの単位の変更に注意してください。お使いのモデルに応じて、このプロセッサは2.9〜3.2 GHzで動作します。計算を簡単にするために、3 GHzと呼びましょう。つまり、1サイクルは0.33333ナノ秒です。したがって、DRAMアクセスも100〜300サイクルです。
ポイントは、CPUが数百の命令を読み取るのにかかる時間oneメインメモリからのキャッシュライン。これは memory wall と呼ばれます。そのため、最新のCPUの全体的なパフォーマンスでは、メモリキャッシュの効率的な使用がその他の要素よりも重要です。
答えは、matrix
がどのように定義されているかによって少し異なります。完全に動的に割り当てられた配列では、次のようになります。
_T **matrix;
matrix = new T*[n];
for(i = 0; i < n; i++)
{
t[i] = new T[m];
}
_
したがって、すべての_matrix[j]
_には、ポインタの新しいメモリルックアップが必要になります。 j
ループを外部で実行すると、内部ループ全体で_matrix[j]
_のポインターを再利用できます。
行列が単純な2D配列の場合:
_T matrix[n][m];
_
その場合、_matrix[j]
_は単に1024 * sizeof(T)
による乗算になります-これは、最適化されたコードにループインデックス1024 * sizeof(T)
を追加することで実行できるため、どちらの方法でも比較的高速です。
その上で、キャッシュの局所性要因があります。キャッシュには、通常、1行あたり32〜128バイトのデータの「行」があります。したがって、コードがアドレスX
を読み取る場合、キャッシュはX
の周りに32〜128バイトの値をロードします。したがって、必要なNEXTが現在の場所からsizeof(T)
だけ前方にある場合、それはすでにキャッシュ内にある可能性が高いです(そして最近のプロセッサは、すべてのメモリの場所を読み取るループでラウンドしていることを検出し、事前に-データをロードします]。
j
内部ループの場合、各ループのsizeof(T)*1024
距離の新しい場所を読み取っています(動的に割り当てられている場合は、おそらくより長い距離)。これは、読み込まれているデータが次の32〜128バイトではないため、次のループでは役に立たないことを意味します。
そして最後に、最初のループがSSE命令などのおかげで、計算をさらに高速に実行できるため、より最適化されている可能性があります。しかし、これはおそらく、このような大きな行列では限界です。 、パフォーマンスはこのサイズでメモリに強くバインドされているため。
メモリハードウェアは個別のアドレスを配信するように最適化されていません。代わりに、キャッシュラインと呼ばれる連続メモリのより大きなチャンクで動作する傾向があります。マトリックスの1つのエントリを読み取るたびに、それが存在するキャッシュライン全体も一緒にキャッシュに読み込まれます。
より速いループ順序付けは、メモリを順番に読み取るように設定されています。キャッシュラインをロードするたびに、そのキャッシュラインのすべてのエントリを使用します。外部ループを通過するたびに、各マトリックスエントリを1回だけ読み取ります。
ただし、より遅いループの順序では、先に進む前に各キャッシュラインからの単一のエントリのみを使用します。したがって、各キャッシュラインは、ラインの各マトリックスエントリに対して1回、複数回ロードする必要があります。例えばdouble
が8バイトで、キャッシュラインが64バイト長の場合、外側のループを通過するたびに、各マトリックスエントリを1回ではなく8回読み取る必要があります。
つまり、最適化をオンにした場合、おそらく違いは見られないでしょう。オプティマイザはこの現象を理解しており、優れたものは、この特定のループが内側のループと外側のループを入れ替えることができることを認識できます。コードスニペット。
(また、優れたオプティマイザは、最初の999回がsum
の最終値とは無関係であることを認識するため、最も外側のループを1回だけ通過します。)
行列はベクトルとしてメモリに保存されます。最初にアクセスすると、メモリに順次アクセスします。 2番目の方法でアクセスするには、メモリの場所をジャンプする必要があります。参照 http://en.wikipedia.org/wiki/Row-major_order
J-iにアクセスすると、j次元がキャッシュされるため、マシンコードで毎回変更する必要はありません。2番目の次元はキャッシュされないため、違いが発生するたびに実際にキャッシュを削除します。
参照の局所性の概念に基づくと、コードの一部が隣接するメモリ位置にアクセスする可能性が非常に高くなります。そのため、要求された値よりも多くの値がキャッシュにロードされます。これは、より多くのキャッシュヒットを意味します。最初の例はこれを十分に満たしていますが、2番目の例のコードはそうではありません。