web-dev-qa-db-ja.com

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

これは問題のプログラムからの抜粋です。行列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を知っていますが、問題は使用されるメモリの量ではなく、単に実行時間なので、それがどのように役立つかわかりません。

728
anon

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

しかしそれは、コードにもう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
919
Mysticial

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


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

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

この特定のケースでは、3つのピクセルグループの合計を水平に計算してキャッシュする方が良いでしょう。なぜなら、それらはそれぞれ3回使用されるからです。

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

User1615209によるオリジナルコード:

8193: 4392 ms
8192: 9570 ms

Mysticalのバージョン:

8193: 2393 ms
8192: 2190 ms

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

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

2次元1次元配列を使用して、次のようにアドレス指定します。

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倍速く達成することが可能です。

私はそれがはるかに良いことが可能であると確信しています。

NOTEMysticalの優れた答えで説明されているキャッシュの問題ではなく、一般的なパフォーマンスの問題をターゲットとしてこの答えを書いたことに注意してください。最初は疑似コードでした。私はコメントでテストをするように頼まれました...これはテストで完全にリファクタリングされたバージョンです。

54
bokan