web-dev-qa-db-ja.com

結合ループよりも個別ループの方が要素ごとの加算がはるかに速いのはなぜですか?

a1b1c1、およびd1がヒープメモリを指していて、私の数値コードに次のコアループがあるとします。

const int n = 100000;

for (int j = 0; j < n; j++) {
    a1[j] += b1[j];
    c1[j] += d1[j];
}

このループは別の外側のforループを介して10,000回実行されます。スピードを上げるために、コードを次のように変更しました。

for (int j = 0; j < n; j++) {
    a1[j] += b1[j];
}

for (int j = 0; j < n; j++) {
    c1[j] += d1[j];
}

最初の例では、完全な最適化と Intel Core 2 Duo(x64)での32ビット対応の SSE2 が有効なMS Visual C++ 10.0 でコンパイルされています。二重ループの例では1.9秒しかかかりません。私の質問は次のとおりです。(一番下の私の言い換えられた質問を参照してください)

シモンズ:これが助けになるかどうか、私はよくわかりません:

最初のループの逆アセンブリは基本的には次のようになります(このブロックはプログラム全体で約5回繰り返されます)。

movsd       xmm0,mmword ptr [edx+18h]
addsd       xmm0,mmword ptr [ecx+20h]
movsd       mmword ptr [ecx+20h],xmm0
movsd       xmm0,mmword ptr [esi+10h]
addsd       xmm0,mmword ptr [eax+30h]
movsd       mmword ptr [eax+30h],xmm0
movsd       xmm0,mmword ptr [edx+20h]
addsd       xmm0,mmword ptr [ecx+28h]
movsd       mmword ptr [ecx+28h],xmm0
movsd       xmm0,mmword ptr [esi+18h]
addsd       xmm0,mmword ptr [eax+38h]

二重ループの例の各ループはこのコードを生成します(次のブロックは約3回繰り返されます)。

addsd       xmm0,mmword ptr [eax+28h]
movsd       mmword ptr [eax+28h],xmm0
movsd       xmm0,mmword ptr [ecx+20h]
addsd       xmm0,mmword ptr [eax+30h]
movsd       mmword ptr [eax+30h],xmm0
movsd       xmm0,mmword ptr [ecx+28h]
addsd       xmm0,mmword ptr [eax+38h]
movsd       mmword ptr [eax+38h],xmm0
movsd       xmm0,mmword ptr [ecx+30h]
addsd       xmm0,mmword ptr [eax+40h]
movsd       mmword ptr [eax+40h],xmm0

この動作は配列(n)のサイズとCPUキャッシュに大きく依存するため、問題は関係ありません。それでさらに興味があるならば、私は質問を言い換えます:

次のグラフの5つの領域に示されているように、キャッシュの動作が異なる原因となる詳細について、しっかりとした洞察を提供できますか。

CPU /キャッシュアーキテクチャの違いを指摘するのも、これらのCPUについて同様のグラフを提供することで興味深いことがあります。

PPS:これが完全なコードです。これは、より高い解像度のタイミングのために TBBTick_Countを使用します。これは、TBB_TIMINGマクロを定義しないことで無効にできます。

#include <iostream>
#include <iomanip>
#include <cmath>
#include <string>

//#define TBB_TIMING

#ifdef TBB_TIMING   
#include <tbb/tick_count.h>
using tbb::tick_count;
#else
#include <time.h>
#endif

using namespace std;

//#define preallocate_memory new_cont

enum { new_cont, new_sep };

double *a1, *b1, *c1, *d1;


void allo(int cont, int n)
{
    switch(cont) {
      case new_cont:
        a1 = new double[n*4];
        b1 = a1 + n;
        c1 = b1 + n;
        d1 = c1 + n;
        break;
      case new_sep:
        a1 = new double[n];
        b1 = new double[n];
        c1 = new double[n];
        d1 = new double[n];
        break;
    }

    for (int i = 0; i < n; i++) {
        a1[i] = 1.0;
        d1[i] = 1.0;
        c1[i] = 1.0;
        b1[i] = 1.0;
    }
}

void ff(int cont)
{
    switch(cont){
      case new_sep:
        delete[] b1;
        delete[] c1;
        delete[] d1;
      case new_cont:
        delete[] a1;
    }
}

double plain(int n, int m, int cont, int loops)
{
#ifndef preallocate_memory
    allo(cont,n);
#endif

#ifdef TBB_TIMING   
    tick_count t0 = tick_count::now();
#else
    clock_t start = clock();
#endif

    if (loops == 1) {
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++){
                a1[j] += b1[j];
                c1[j] += d1[j];
            }
        }
    } else {
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                a1[j] += b1[j];
            }
            for (int j = 0; j < n; j++) {
                c1[j] += d1[j];
            }
        }
    }
    double ret;

#ifdef TBB_TIMING   
    tick_count t1 = tick_count::now();
    ret = 2.0*double(n)*double(m)/(t1-t0).seconds();
#else
    clock_t end = clock();
    ret = 2.0*double(n)*double(m)/(double)(end - start) *double(CLOCKS_PER_SEC);
#endif

#ifndef preallocate_memory
    ff(cont);
#endif

    return ret;
}


void main()
{   
    freopen("C:\\test.csv", "w", stdout);

    char *s = " ";

    string na[2] ={"new_cont", "new_sep"};

    cout << "n";

    for (int j = 0; j < 2; j++)
        for (int i = 1; i <= 2; i++)
#ifdef preallocate_memory
            cout << s << i << "_loops_" << na[preallocate_memory];
#else
            cout << s << i << "_loops_" << na[j];
#endif

    cout << endl;

    long long nmax = 1000000;

#ifdef preallocate_memory
    allo(preallocate_memory, nmax);
#endif

    for (long long n = 1L; n < nmax; n = max(n+1, long long(n*1.2)))
    {
        const long long m = 10000000/n;
        cout << n;

        for (int j = 0; j < 2; j++)
            for (int i = 1; i <= 2; i++)
                cout << s << plain(n, m, j, i);
        cout << endl;
    }
}

(さまざまな値のnに対するFLOP/sが表示されます。)

enter image description here

2117
Johannes Gerer

これをさらに分析すると、これは(少なくとも部分的には)4つのポインターのデータ位置合わせが原因であると私は考えます。これにより、ある程度のキャッシュバンク/ウェイの競合が発生します。

あなたがどのようにあなたがあなたの配列を割り振っているかについて正しく推測したならば、彼らはたぶんページ行に揃えられるでしょう

これは、各ループ内のすべてのアクセスが同じキャッシュ方法で行われることを意味します。しかし、Intelプロセッサはしばらくの間8ウェイL1キャッシュの結合性を持っています。しかし、実際には、パフォーマンスは完全に均一ではありません。 4方向へのアクセスは2方向と言うよりもまだ遅いです。

編集:実際には、すべての配列を別々に割り当てているように見えます。通常、このような大きな割り当てが要求されると、アロケータはOSから新しいページを要求します。したがって、大きな割り当てが表示される可能性が高くなります。ページ境界から同じオフセットで。

これがテストコードです:

int main(){
    const int n = 100000;

#ifdef ALLOCATE_SEPERATE
    double *a1 = (double*)malloc(n * sizeof(double));
    double *b1 = (double*)malloc(n * sizeof(double));
    double *c1 = (double*)malloc(n * sizeof(double));
    double *d1 = (double*)malloc(n * sizeof(double));
#else
    double *a1 = (double*)malloc(n * sizeof(double) * 4);
    double *b1 = a1 + n;
    double *c1 = b1 + n;
    double *d1 = c1 + n;
#endif

    //  Zero the data to prevent any chance of denormals.
    memset(a1,0,n * sizeof(double));
    memset(b1,0,n * sizeof(double));
    memset(c1,0,n * sizeof(double));
    memset(d1,0,n * sizeof(double));

    //  Print the addresses
    cout << a1 << endl;
    cout << b1 << endl;
    cout << c1 << endl;
    cout << d1 << endl;

    clock_t start = clock();

    int c = 0;
    while (c++ < 10000){

#if ONE_LOOP
        for(int j=0;j<n;j++){
            a1[j] += b1[j];
            c1[j] += d1[j];
        }
#else
        for(int j=0;j<n;j++){
            a1[j] += b1[j];
        }
        for(int j=0;j<n;j++){
            c1[j] += d1[j];
        }
#endif

    }

    clock_t end = clock();
    cout << "seconds = " << (double)(end - start) / CLOCKS_PER_SEC << endl;

    system("pause");
    return 0;
}

ベンチマーク結果:

編集:actual Core 2アーキテクチャマシンでの結果:

2 x Intel Xeon X5482 Harpertown @ 3.2 GHz:

#define ALLOCATE_SEPERATE
#define ONE_LOOP
00600020
006D0020
007A0020
00870020
seconds = 6.206

#define ALLOCATE_SEPERATE
//#define ONE_LOOP
005E0020
006B0020
00780020
00850020
seconds = 2.116

//#define ALLOCATE_SEPERATE
#define ONE_LOOP
00570020
00633520
006F6A20
007B9F20
seconds = 1.894

//#define ALLOCATE_SEPERATE
//#define ONE_LOOP
008C0020
00983520
00A46A20
00B09F20
seconds = 1.993

所見:

  • 6.206秒1ループあり、2.116秒2ループあり。これはOPの結果を正確に再現します。

  • 最初の2つのテストでは、配列は別々に割り当てられます。これらはすべて、ページに対して同じ配置になっています。

  • 2番目の2つのテストでは、配列を整列させるために配列がまとめられています。ここでは、両方のループが速いことに気付くでしょう。

@Stephen Cannon氏がコメントで指摘しているように、このアライメントによってロード/ストアユニットまたはキャッシュ内でfalse aliasing)が発生する可能性が非常に高いです。実際には部分アドレスエイリアシングのハードウェアカウンタがあります。

http://software.intel.com/sites/products/documentation/doclib/stdxe/2013/~amplifierxe/pmw_dp/events/partial_address_alias.html


5つの地域 - 説明

地域1:

これは簡単です。データセットが非常に小さいため、パフォーマンスはループや分岐などのオーバーヘッドによって左右されます。

リージョン2:

ここでは、データサイズが大きくなるにつれて、相対的なオーバーヘッドの量が減少し、パフォーマンスが「飽和」します。ここで2つのループは2倍のループと分岐のオーバーヘッドを持っているので遅いです。

ここで何が起こっているのか正確にはわかりません... Agner Fogが cache bank conflict に言及しているように、アライメントはまだ効果がある可能性があります。 (そのリンクはSandy Bridgeに関するものですが、その考え方はCore 2にも当てはまるはずです。)

リージョン3:

この時点で、データはL1キャッシュに収まりません。したがって、性能は、L1 - L2キャッシュ帯域幅によって制限される。

リージョン4:

シングルループでのパフォーマンス低下は私達が観察しているものです。そして前述したように、これは(おそらく)false aliasingを引き起こすアライメントがプロセッサロード/ストアユニットでストールするためです。

ただし、誤ったエイリアシングが発生するためには、データセット間に十分大きなストライドが必要です。これがあなたが地域3でこれを見ない理由です。

地域5:

この時点では、何もキャッシュに収まりません。だからあなたはメモリ帯域幅に縛られています。


2 x Intel X5482 Harpertown @ 3.2 GHzIntel Core i7 870 @ 2.8 GHzIntel Core i7 2600K @ 4.4 GHz

1614
Mysticial

OK、正しい答えは間違いなくCPUキャッシュを使って何かをする必要があります。しかし、cache引数を使用することは、特にデータがないと、非常に困難になる可能性があります。

多くの答えがあり、それが多くの議論につながりましたが、それに直面しましょう。キャッシュの問題は非常に複雑になる可能性があり、一次元ではありません。それらはデータのサイズに大きく依存するので、私の質問は不公平でした:それはキャッシュグラフの中で非常に興味深い点にあることが判明しました。

@ Mysticialの答えは、おそらくそれが事実に頼っているように思われた唯一の人だったからかもしれませんが、それは真実のただ一つの "データポイント"だったからです。

だからこそ、私は彼のテスト(連続割り当てと個別割り当てを使用)と@James 'Answerのアドバイスを組み合わせました。

以下のグラフは、使用されている正確なシナリオとパラメータに応じて、ほとんどの回答、特に質問と回答に対するコメントの大部分が完全に間違っているか真であると見なすことができることを示しています。

私の最初の質問は n = 100.000 でした。この点(偶然)は特別な振る舞いをします。

  1. それは1と2のループされたバージョンの間の最大の食い違いを持っ​​ています(ほぼ3分の1)

  2. これが唯一のポイントです。ここでは、1ループ(つまり連続割り当て)が2ループのバージョンを上回ります。 (これにより、Mysticialの回答が可能になりました。)

初期化データを使用した結果:

Enter image description here

未初期化データを使用した結果(これはMysticialがテストしたものです):

Enter image description here

そしてこれは説明するのが難しいです。初期化されたデータ。一度割り当てられ、異なるベクトルサイズの次のテストケースごとに再利用されます。

Enter image description here

提案

スタックオーバーフローに関する低レベルのパフォーマンス関連の質問はすべて、キャッシュ関連のデータサイズの全範囲についてMFLOPS情報を提供するために必要です。答えを考え、特にこの情報なしで他の人とそれらについて議論するのは、みんなの時間の無駄です。

210
Johannes Gerer

2番目のループではキャッシュアクティビティが大幅に少なくなるため、プロセッサがメモリの要求に追いつくのが簡単になります。

73
Puppy

nが一度にメモリ内の2つのアレイを保持するためだけに適切な値であるマシンで作業しているが、ディスクキャッシングを介して利用可能なメモリの合計はまだ十分であると想像してください。 4つすべてを保持します。

単純なLIFOキャッシュポリシーを想定すると、このコードは次のようになります。

for(int j=0;j<n;j++){
    a[j] += b[j];
}
for(int j=0;j<n;j++){
    c[j] += d[j];
}

最初にaおよびbがRAMにロードされ、次に完全にRAMで処理されます。 2番目のループが開始すると、cdがディスクからRAMにロードされ、操作されます。

他のループ

for(int j=0;j<n;j++){
    a[j] += b[j];
    c[j] += d[j];
}

2つの配列をページアウトし、他の2つの配列をページングしますループのたびに。これは明らかにmuch遅くなります。

テストではディスクキャッシュは表示されませんが、他のキャッシュ形式の副作用が表示される可能性があります。


ここには少し混乱/誤解があるように思えるので、例を使用して少し詳しく説明してみます。

n = 2と言うと、バイトを処理しています。したがって、私のシナリオではRAMの4バイトのみとなり、残りのメモリは大幅に遅くなります(たとえば、アクセスが100倍長くなります)。

のかなり愚かなキャッシュポリシーを想定して、バイトがキャッシュにない場合は、そこに置いて、次のバイトも取得します次のようなシナリオを取得します。

  • for(int j=0;j<n;j++){
     a[j] += b[j];
    }
    for(int j=0;j<n;j++){
     c[j] += d[j];
    }
    
  • キャッシュa[0]およびa[1]次にb[0]およびb[1]をキャッシュし、キャッシュにa[0] = a[0] + b[0]を設定します。キャッシュにはa[0], a[1]およびb[0], b[1]。コスト= 100 + 100。

  • キャッシュにa[1] = a[1] + b[1]を設定します。コスト= 1 + 1。
  • cおよびdについても繰り返します。
  • 総費用= (100 + 100 + 1 + 1) * 2 = 404

  • for(int j=0;j<n;j++){
     a[j] += b[j];
     c[j] += d[j];
    }
    
  • キャッシュa[0]およびa[1]次にb[0]およびb[1]をキャッシュし、キャッシュにa[0] = a[0] + b[0]を設定します。キャッシュにはa[0], a[1]およびb[0], b[1]。コスト= 100 + 100。

  • キャッシュからa[0], a[1], b[0], b[1]をイジェクトし、c[0]c[1]を取り出し、次にd[0]d[1]をキャッシュし、c[0] = c[0] + d[0]をキャッシュに設定します。コスト= 100 + 100。
  • あなたは私がどこへ行くのか見始めているのではないでしょうか。
  • 総費用= (100 + 100 + 100 + 100) * 2 = 800

これは、古典的なキャッシュスラッシュシナリオです。

45
OldCurmudgeon

これはコードが異なることによるものではなく、キャッシュによるものです。RAMはCPUレジスタよりも遅く、キャッシュメモリはCPU内部にあり、変数ごとにRAMを書き込まないようにします。変わってきている。しかし、キャッシュはRAMのように大きくはないため、マッピングされるのはごく一部です。

最初のコードは、各ループで離れたメモリアドレスを交互に変更するため、キャッシュを無効にすることを継続的に要求します。

2番目のコードは変わりません。隣接するアドレスを2回流すだけです。これにより、すべてのジョブがキャッシュ内で完了され、2番目のループが開始された後にのみ無効になります。

30

ここで説明した結果を再現することはできません。

ベンチマークのコードが良くないのかどうかはわかりませんが、私のマシンでは2つの方法が10%以内で次のコードを使用しています。期待しています。

配列サイズは8つのループを使用して2 ^ 16から2 ^ 24の範囲でした。 +=代入が fpu _ にダブルとして解釈されるメモリのゴミを追加するように要求しないように、私はソース配列を初期化するように注意しました。

ループ内でb[j]d[j]の割り当てをInitToZero[j]に代入するなど、さまざまな方法で試したところ、+= b[j] = 1+= d[j] = 1を使用しても、かなり一貫した結果が得られました。

ご想像のとおり、ループ内でbdInitToZero[j]を使用して初期化すると、acへの代入の前ではあるが10%以内で行われるため、組み合わせたアプローチに利点があります。図に行きます。

ハードウェアは Dell XPS 8500 第3世代あり Core i7 @ 3.4 GHzおよび8 GBメモリ。 2 ^ 16から2 ^ 24の場合、8つのループを使用した場合、累積時間はそれぞれ44.987と40.965でした。完全に最適化されたVisual C++ 2010。

シモンズ:私はループをゼロまでカウントダウンするように変更し、そして組み合わせた方法はわずかに速くなりました。私の頭を掻く新しい配列サイズとループ数に注意してください。

// MemBufferMystery.cpp : Defines the entry point for the console application.
//
#include "stdafx.h"
#include <iostream>
#include <cmath>
#include <string>
#include <time.h>

#define  dbl    double
#define  MAX_ARRAY_SZ    262145    //16777216    // AKA (2^24)
#define  STEP_SZ           1024    //   65536    // AKA (2^16)

int _tmain(int argc, _TCHAR* argv[]) {
    long i, j, ArraySz = 0,  LoopKnt = 1024;
    time_t start, Cumulative_Combined = 0, Cumulative_Separate = 0;
    dbl *a = NULL, *b = NULL, *c = NULL, *d = NULL, *InitToOnes = NULL;

    a = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    b = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    c = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    d = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    InitToOnes = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    // Initialize array to 1.0 second.
    for(j = 0; j< MAX_ARRAY_SZ; j++) {
        InitToOnes[j] = 1.0;
    }

    // Increase size of arrays and time
    for(ArraySz = STEP_SZ; ArraySz<MAX_ARRAY_SZ; ArraySz += STEP_SZ) {
        a = (dbl *)realloc(a, ArraySz * sizeof(dbl));
        b = (dbl *)realloc(b, ArraySz * sizeof(dbl));
        c = (dbl *)realloc(c, ArraySz * sizeof(dbl));
        d = (dbl *)realloc(d, ArraySz * sizeof(dbl));
        // Outside the timing loop, initialize
        // b and d arrays to 1.0 sec for consistent += performance.
        memcpy((void *)b, (void *)InitToOnes, ArraySz * sizeof(dbl));
        memcpy((void *)d, (void *)InitToOnes, ArraySz * sizeof(dbl));

        start = clock();
        for(i = LoopKnt; i; i--) {
            for(j = ArraySz; j; j--) {
                a[j] += b[j];
                c[j] += d[j];
            }
        }
        Cumulative_Combined += (clock()-start);
        printf("\n %6i miliseconds for combined array sizes %i and %i loops",
                (int)(clock()-start), ArraySz, LoopKnt);
        start = clock();
        for(i = LoopKnt; i; i--) {
            for(j = ArraySz; j; j--) {
                a[j] += b[j];
            }
            for(j = ArraySz; j; j--) {
                c[j] += d[j];
            }
        }
        Cumulative_Separate += (clock()-start);
        printf("\n %6i miliseconds for separate array sizes %i and %i loops \n",
                (int)(clock()-start), ArraySz, LoopKnt);
    }
    printf("\n Cumulative combined array processing took %10.3f seconds",
            (dbl)(Cumulative_Combined/(dbl)CLOCKS_PER_SEC));
    printf("\n Cumulative seperate array processing took %10.3f seconds",
        (dbl)(Cumulative_Separate/(dbl)CLOCKS_PER_SEC));
    getchar();

    free(a); free(b); free(c); free(d); free(InitToOnes);
    return 0;
}

MFLOPSが適切な指標であると判断された理由はわかりません。私の考えはメモリアクセスに集中することでしたので、私は浮動小数点計算時間の量を最小にすることを試みました。私は+=を去りました、しかし私はなぜかわかりません。

計算なしの直接代入は、メモリアクセス時間のよりクリーンなテストであり、ループカウントに関係なく一様なテストを作成するでしょう。会話の中で何かを逃したかもしれませんが、二度考えることは価値があります。プラスが割り当てから除外されている場合、累積時間はそれぞれ31秒でほぼ同じです。

19
user1899861

これは、CPUにキャッシュミスがそれほど多くないためです(配列データがRAMチップから来るのを待たなければならない場合)。 CPUの レベル1キャッシュ (L1)、次に レベル2キャッシュ (L2)のサイズを超えるように、配列のサイズを継続的に調整することは興味深いことです。そして、コードの実行にかかる時間を配列のサイズに対してプロットします。グラフは、予想どおりに直線になってはいけません。

16
James

最初のループは各変数への書き込みを交互に行います。 2番目と3番目のものは要素サイズの小さなジャンプをするだけです。

20 cm離れた2本の平行線をペンと紙で書いてみます。 1つの行を終えてからもう1つの行を終えてから、各行に交互に十字を書いてもう一度試してください。

13
Guillaume Kiz

それは古いC++と最適化かもしれません。私のコンピュータでは、私はほぼ同じ速度を得ました:

1ループ:1.577ミリ秒

2つのループ:1.507ミリ秒

16 GBのRAMを搭載したE5-1620 3.5 GHzプロセッサでVisual Studio 2015を実行します。

1
mathengineer