正確に8192要素をループするとプログラムが遅くなるのはなぜですか?


755

これは問題のプログラムからの抜粋です。行列img[][]のサイズはSIZE×SIZEで、次のように初期化されます。

img[j][i] = 2 * j + i

次に、マトリックスを作成res[][]します。ここの各フィールドは、imgマトリックス内のその周囲の9つのフィールドの平均になります。簡単にするため、境界線は0のままにしておきます。

for(i=1;i<SIZE-1;i++) 
    for(j=1;j<SIZE-1;j++) {
        res[j][i]=0;
        for(k=-1;k<2;k++) 
            for(l=-1;l<2;l++) 
                res[j][i] += img[j+l][i+k];
        res[j][i] /= 9;
}

これでプログラムは終わりです。完全を期すために、これが以前のものです。後にコードはありません。ご覧のとおり、それは初期化です。

#define SIZE 8192
float img[SIZE][SIZE]; // input image
float res[SIZE][SIZE]; //result of mean filter
int i,j,k,l;
for(i=0;i<SIZE;i++) 
    for(j=0;j<SIZE;j++) 
        img[j][i] = (2*j+i)%8196;

基本的に、このプログラムはSIZEが2048の倍数である場合、実行時間が長くなります。

SIZE = 8191: 3.44 secs
SIZE = 8192: 7.20 secs
SIZE = 8193: 3.18 secs

コンパイラはGCCです。私が知っていることから、これはメモリ管理のためですが、私はその主題についてあまり知らないので、私はここで尋ねています。

これを修正する方法もいいですが、誰かがこれらの実行時間を説明できれば、もう十分満足しています。

私はすでにmalloc / freeを知っていますが、問題は使用されるメモリの量ではなく、単に実行時間に過ぎないため、それがどのように役立つかわかりません。


67
@bokanこれは、サイズがキャッシュのクリティカルストライドの倍数である場合に発生します。
Luchian Grigore

5
@Mysticial、それは重要ではありません、それは同じ正確な問題を公開します。コードは異なる場合がありますが、基本的には両方の質問が同時に尋ねられます(そして、それらのタイトルは明らかに類似しています)。
Griwes、2012

33
高いパフォーマンスが必要な場合は、2次元配列を使用して画像を処理しないでください。すべてのピクセルがrawであると考え、それらを1次元配列のように処理します。2パスでこのぼかしを行います。最初に、3ピクセルのスライド合計を使用して、周囲のピクセルの値を追加します。slideSum+ = src [i + 1] -src [i-1]; dest [i] = slideSum;。次に、同じことを垂直方向に行い、同時に分割します。dest[i] =(src [i-width] + src [i] + src [i + width])/ 9。www-personal.engin.umd.umich.edu/~jwvm/ece581/18_RankedF.pdf
タイムボカン

8
ここで実際に行われていることが2つあります。それは単なるスーパーアラインメントではありません。
Mysticial 2012

7
(あなたの答えのちょっとしたちょっとしたひねり。最初のコードセグメントについては、すべてのforループが中かっこを持っているといいでしょう。)
Trevor Boyd Smith

回答:


954

違いは、次の関連する質問と同じスーパーアライメントの問題が原因です。

しかし、それはコードにもう1つの問題があるためだけです。

元のループから始めます。

for(i=1;i<SIZE-1;i++) 
    for(j=1;j<SIZE-1;j++) {
        res[j][i]=0;
        for(k=-1;k<2;k++) 
            for(l=-1;l<2;l++) 
                res[j][i] += img[j+l][i+k];
        res[j][i] /= 9;
}

最初に、2つの内部ループが取るに足らないものであることに注意してください。次のように展開できます。

for(i=1;i<SIZE-1;i++) {
    for(j=1;j<SIZE-1;j++) {
        res[j][i]=0;
        res[j][i] += img[j-1][i-1];
        res[j][i] += img[j  ][i-1];
        res[j][i] += img[j+1][i-1];
        res[j][i] += img[j-1][i  ];
        res[j][i] += img[j  ][i  ];
        res[j][i] += img[j+1][i  ];
        res[j][i] += img[j-1][i+1];
        res[j][i] += img[j  ][i+1];
        res[j][i] += img[j+1][i+1];
        res[j][i] /= 9;
    }
}

これで、関心のある2つの外部ループが残ります。

これで、この質問では問題が同じであることがわかります。2D配列を反復処理するとき、ループの順序がパフォーマンスに影響するのはなぜですか?

行ごとではなく列ごとに行列を繰り返します。


この問題を解決するには、2つのループを交換する必要があります。

for(j=1;j<SIZE-1;j++) {
    for(i=1;i<SIZE-1;i++) {
        res[j][i]=0;
        res[j][i] += img[j-1][i-1];
        res[j][i] += img[j  ][i-1];
        res[j][i] += img[j+1][i-1];
        res[j][i] += img[j-1][i  ];
        res[j][i] += img[j  ][i  ];
        res[j][i] += img[j+1][i  ];
        res[j][i] += img[j-1][i+1];
        res[j][i] += img[j  ][i+1];
        res[j][i] += img[j+1][i+1];
        res[j][i] /= 9;
    }
}

これにより、すべての非順次アクセスが完全に排除されるため、2のべき乗でランダムにスローダウンすることはなくなります。


Core i7 920 @ 3.5 GHz

元のコード:

8191: 1.499 seconds
8192: 2.122 seconds
8193: 1.582 seconds

交換された外部ループ:

8191: 0.376 seconds
8192: 0.357 seconds
8193: 0.351 seconds

217
また、内側のループを展開してもパフォーマンスには影響がないことにも注意してください。コンパイラはおそらくそれを自動的に行います。外側のループの問題を見つけやすくするために、それらを取り除く目的でのみ展開しました。
Mysticial 2012

29
そして、各行に沿って合計をキャッシュすることで、このコードをさらに3倍高速化できます。しかし、それと他の最適化は元の質問の範囲外です。
Eric Postpischil 2012

34
@ClickUpvoteこれは実際にはハードウェア(キャッシュ)の問題です。言語とは何の関係もありません。ネイティブコードにコンパイルまたはJITする他の言語で試した場合、おそらく同じ効果が見られます。
Mysticial 2012

19
@ClickUpvote:あなたはかなり見当違いのようです。その「2番目のループ」は、内部のループを手動で展開するMysticalに過ぎませんでした。これは、コンパイラーがほぼ確実に行うことであり、Mysticalは、外部ループの問題をより明確にするためだけに行いました。自分でやらなければならないことではありません。
リリーバラード

154
これは、SOの良い答えの完璧な例です。同様の質問を参照し、ステップバイステップでそれに対処する方法を説明し、問題を説明し、問題を修正する方法を説明し、優れた書式設定を行い、実行中のコードの例を示しますあなたのマシンで。ご協力いただき、ありがとうございます。
MattSayar

57

次のテストは、デフォルトのQt Creatorインストールで使用されるため、Visual C ++コンパイラで行われました(最適化フラグがないと思います)。GCCを使用する場合、Mysticalのバージョンと「最適化された」コードの間に大きな違いはありません。結論は、コンパイラの最適化は人間よりもマイクロ最適化をうまく処理するということです(ついに私)。残りの回答は参照用に残しておきます。


この方法で画像を処理することは効率的ではありません。1次元配列を使用することをお勧めします。すべてのピクセルの処理は1つのループで行われます。ポイントへのランダムアクセスは、以下を使用して実行できます。

pointer + (x + y*width)*(sizeOfOnePixel)

この特定のケースでは、3つのピクセルグループがそれぞれ3回使用されるため、水平方向に3つのピクセルグループの合計を計算してキャッシュすることをお勧めします。

私はいくつかのテストを行ったが、共有する価値があると思う。各結果は5つのテストの平均です。

user1615209による元のコード:

8193: 4392 ms
8192: 9570 ms

ミスティックのバージョン:

8193: 2393 ms
8192: 2190 ms

1D配列を使用した2つのパス:水平合計の最初のパス、垂直合計と平均の2番目のパス。2つのパスは3つのポインタを使用してアドレッシングを行い、次のように増分するだけです。

imgPointer1 = &avg1[0][0];
imgPointer2 = &avg1[0][SIZE];
imgPointer3 = &avg1[0][SIZE+SIZE];

for(i=SIZE;i<totalSize-SIZE;i++){
    resPointer[i]=(*(imgPointer1++)+*(imgPointer2++)+*(imgPointer3++))/9;
}

8193: 938 ms
8192: 974 ms

1D配列を使用し、次のようにアドレス指定する2つのパス:

for(i=SIZE;i<totalSize-SIZE;i++){
    resPointer[i]=(hsumPointer[i-SIZE]+hsumPointer[i]+hsumPointer[i+SIZE])/9;
}

8193: 932 ms
8192: 925 ms

ワンパスキャッシュの水平方向の合計は1行先にあるため、キャッシュにとどまります。

// Horizontal sums for the first two lines
for(i=1;i<SIZE*2;i++){
    hsumPointer[i]=imgPointer[i-1]+imgPointer[i]+imgPointer[i+1];
}
// Rest of the computation
for(;i<totalSize;i++){
    // Compute horizontal sum for next line
    hsumPointer[i]=imgPointer[i-1]+imgPointer[i]+imgPointer[i+1];
    // Final result
    resPointer[i-SIZE]=(hsumPointer[i-SIZE-SIZE]+hsumPointer[i-SIZE]+hsumPointer[i])/9;
}

8193: 599 ms
8192: 652 ms

結論:

  • 複数のポインターを使用して、増分だけを使用する利点はありません(もっと高速だったと思います)
  • 水平合計をキャッシュすることは、それらを数回計算するよりも優れています。
  • 2パスは3倍高速ではなく、2倍のみです。
  • シングルパスと中間結果のキャッシュの両方を使用すると、3.6倍速く達成することが可能です。

きっともっと良いことができると思います。

この回答は、Mysticalの優れた回答で説明されているキャッシュの問題ではなく、一般的なパフォーマンスの問題を対象としています。最初は、それは単なる擬似コードでした。コメントでテストを依頼されました...テスト付きの完全にリファクタリングされたバージョンです。


9
「少なくとも3倍速いと思います」—いくつかの指標や引用でその主張を裏付けることに注意してください。
Adam Rosenfield、2012

8
@AdamRosenfield "私は思う" =仮定!= "そうです" =主張。これについての指標はありません。テストを確認したいと思います。しかし、私は7インクリメント、2サブ、2追加、1ピクセルあたり1divを必要とします。各ループは、CPUにあるレジスタよりも少ないローカル変数を使用しています。もう1つは、コンパイラーの最適化に応じてアドレッシングに7インクリメント、6デクリメント、1 div、および10から20 mulを必要とします。また、ループ内の各命令には前の命令の結果が必要です。これにより、Pentiumのスーパースカラーアーキテクチャの利点が失われます。だから、もっと速くなければならない。
bokan 2012

3
元の質問に対する答えは、すべてメモリとキャッシュの効果に関するものです。OPのコードが非常に遅い理由は、OPのメモリアクセスパターンが行ではなく列で実行されるためです。これは、参照のキャッシュの局所性が非常に低いためです。それはだ、特にその後、連続した行は、低結合性との直接マップ・キャッシュまたはキャッシュ内の同一のキャッシュラインを使用して終了するので、キャッシュミス率がさらに高くなるように、8192で悪いです。ループを交換すると、キャッシュの局所性が大幅に向上するため、パフォーマンスが大幅に向上します。
Adam Rosenfield、2012

1
よくやった、それらはいくつかの印象的な数字です。ご存じのように、それはすべてメモリパフォーマンスに関するものです。インクリメント付きの複数のポインタを使用しても、メリットはありませんでした。
Adam Rosenfield、2012

2
@AdamRosenfieldテストを再現できなかったので、今朝はかなり心配でした。パフォーマンスの向上はVisual C ++コンパイラでのみ発生するようです。gccを使用しても、わずかな違いがあります。
bokan 2012
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.