web-dev-qa-db-ja.com

512x512のマトリックスの転置が513x513のマトリックスの転置よりもはるかに遅いのはなぜですか?

サイズの異なる正方行列でいくつかの実験を行った後、パターンが現れました。常に、サイズ2^nは、サイズ2^n+1nの値が小さい場合、差は大きくありません。

ただし、512を超えると大きな違いが生じます(少なくとも私にとっては)。

免責事項:関数が要素の二重スワップのために実際に行列を転置しないことは知っていますが、違いはありません。

コードに従います:

#define SAMPLES 1000
#define MATSIZE 512

#include <time.h>
#include <iostream>
int mat[MATSIZE][MATSIZE];

void transpose()
{
   for ( int i = 0 ; i < MATSIZE ; i++ )
   for ( int j = 0 ; j < MATSIZE ; j++ )
   {
       int aux = mat[i][j];
       mat[i][j] = mat[j][i];
       mat[j][i] = aux;
   }
}

int main()
{
   //initialize matrix
   for ( int i = 0 ; i < MATSIZE ; i++ )
   for ( int j = 0 ; j < MATSIZE ; j++ )
       mat[i][j] = i+j;

   int t = clock();
   for ( int i = 0 ; i < SAMPLES ; i++ )
       transpose();
   int elapsed = clock() - t;

   std::cout << "Average for a matrix of " << MATSIZE << ": " << elapsed / SAMPLES;
}

MATSIZEを変更すると、サイズを変更できます(そうです!)。 ideoneに2つのバージョンを投稿しました。

私の環境(MSVS 2010、完全最適化)では、違いは似ています:

  • サイズ512-平均2.19 ms
  • サイズ513-平均0.57 ms

なぜこれが起こっているのですか?

203
Luchian Grigore

説明は、Agner Fogの C++でのソフトウェアの最適化 に基づいており、データへのアクセス方法とキャッシュへの保存方法に限定されます。

用語と詳細情報については、 キャッシュに関するwikiエントリ をご覧ください。ここで絞り込みます。

キャッシュはsetsおよびlinesで構成されています。一度に1つのセットのみが使用され、そこに含まれる行のいずれかを使用できます。ラインがミラーリングできるメモリとライン数を掛けると、キャッシュサイズが得られます。

特定のメモリアドレスに対して、次の式を使用して、どのセットをミラーリングするかを計算できます。

set = ( address / lineSize ) % numberOfsets

この種の式は、理想的には、各メモリアドレスが読み取られる可能性が高いため、セット全体に均一な分布を与えます(ideally)。

重複が発生する可能性があることは明らかです。キャッシュミスの場合、メモリはキャッシュから読み取られ、古い値が置き換えられます。各セットには多数の行があり、そのうち最も使用頻度の低い行が新しく読み取られたメモリで上書きされることに注意してください。

Agnerの例に少し従おうとします。

各セットには4行があり、それぞれが64バイトを保持すると仮定します。最初にアドレス0x2710の読み取りを試みます。これは、セット28に入ります。そして、アドレス0x2F000x37000x3F00、および0x4700の読み取りも試みます。これらはすべて同じセットに属します。 0x4700を読み取る前は、セット内のすべての行が占有されていました。そのメモリを読み取ると、セット内の既存の行(最初は0x2710を保持していた行)が削除されます。問題は、(この例では)0x800離れているアドレスを読み取るという事実にあります。これはクリティカルストライドです(この例でも)。

クリティカルストライドも計算できます。

criticalStride = numberOfSets * lineSize

間隔がcriticalStrideの変数、または複数の別々のキャッシュが同じキャッシュラインをめぐって競合します。

これが理論の一部です。次に、説明(また、Agner、ミスを避けるために私はそれを注意深く追っています):

64x64のマトリックス(キャッシュによって効果が異なることを思い出してください)を想定します。8kbキャッシュ、セットあたり4行* 64バイトの行サイズ。各行は、マトリックス内の8つの要素(64ビットint)を保持できます。

クリティカルストライドは2048バイトになります。これは、マトリックスの4行(メモリ内で連続)に対応します。

行28を処理していると仮定します。この行の要素を取得して、列28の要素と交換しようとしています。行の最初の8要素はキャッシュラインを構成しますが、8行になります28行目のキャッシュライン。重要なストライドは4行離れています(列内の4つの連続した要素)。

列の要素16に到達すると(セットごとに4つのキャッシュラインと4行の間隔=トラブル)、ex-0要素がキャッシュから削除されます。列の最後に到達すると、以前のキャッシュラインはすべて失われ、次の要素へのアクセス時にリロードが必要になります(ライン全体が上書きされます)。

クリティカルストライドの倍数ではないサイズを使用すると、災害のこの完璧なシナリオを台無しにします。垂直にまたがるので、キャッシュのリロード回数は大幅に削減されます。

別の免責事項-説明に頭を悩ませただけで、それを釘付けにしたいと思っていますが、間違っているかもしれません。とにかく、私は Mysticial からの応答(または確認)を待っています。 :)

184
Luchian Grigore

Luchianはwhyこの動作が発生する理由を説明しますが、この問題の可能な解決策を1つ示し、同時にキャッシュについて少し説明するのは良い考えだと思いました忘却型アルゴリズム。

あなたのアルゴリズムは基本的に以下を行います:

for (int i = 0; i < N; i++) 
   for (int j = 0; j < N; j++) 
        A[j][i] = A[i][j];

これは最新のCPUにとっては恐ろしいことです。 1つの解決策は、キャッシュシステムに関する詳細を把握し、アルゴリズムを調整してこれらの問題を回避することです。あなたがそれらの詳細を知っている限り素晴らしい動作をします。特にポータブルではありません。

それ以上のことはできますか?はい、できます:この問題に対する一般的なアプローチは cache oblivious algorithm です。名前が示すように、特定のキャッシュサイズに依存することを避けます[1]

ソリューションは次のようになります。

void recursiveTranspose(int i0, int i1, int j0, int j1) {
    int di = i1 - i0, dj = j1 - j0;
    const int LEAFSIZE = 32; // well ok caching still affects this one here
    if (di >= dj && di > LEAFSIZE) {
        int im = (i0 + i1) / 2;
        recursiveTranspose(i0, im, j0, j1);
        recursiveTranspose(im, i1, j0, j1);
    } else if (dj > LEAFSIZE) {
        int jm = (j0 + j1) / 2;
        recursiveTranspose(i0, i1, j0, jm);
        recursiveTranspose(i0, i1, jm, j1);
    } else {
    for (int i = i0; i < i1; i++ )
        for (int j = j0; j < j1; j++ )
            mat[j][i] = mat[i][j];
    }
}

少し複雑ですが、VS2010 x64リリースを使用した私の古いe8400での短いテストでは、MATSIZE 8192

int main() {
    LARGE_INTEGER start, end, freq;
    QueryPerformanceFrequency(&freq);
    QueryPerformanceCounter(&start);
    recursiveTranspose(0, MATSIZE, 0, MATSIZE);
    QueryPerformanceCounter(&end);
    printf("recursive: %.2fms\n", (end.QuadPart - start.QuadPart) / (double(freq.QuadPart) / 1000));

    QueryPerformanceCounter(&start);
    transpose();
    QueryPerformanceCounter(&end);
    printf("iterative: %.2fms\n", (end.QuadPart - start.QuadPart) / (double(freq.QuadPart) / 1000));
    return 0;
}

results: 
recursive: 480.58ms
iterative: 3678.46ms

編集:サイズの影響について:ある程度顕著ですが、それほど顕著ではありません。これは、1に再帰する代わりに反復解をリーフノードとして使用しているためです(再帰アルゴリズムの通常の最適化)。 LEAFSIZE = 1に設定すると、キャッシュは私に影響を与えません[8193: 1214.06; 8192: 1171.62ms, 8191: 1351.07ms-それは誤差範囲内にあり、変動は100msの領域にあります。この「ベンチマーク」は、完全に正確な値が必要な場合、私があまりにも快適なものではありません))

[1]このような情報源:ライサーソンと共同で作業した人から講義を受けられないなら、彼らの論文は良い出発点だと思います。これらのアルゴリズムについてはまだほとんど説明されていません-CLRにはそれらに関する1つの脚注があります。それでも、それは人々を驚かせる素晴らしい方法です。


Edit(注:私はこの答えを投稿した人ではありません。これを追加したかっただけです):
上記のコードの完全なC++バージョンを次に示します。

template<class InIt, class OutIt>
void transpose(InIt const input, OutIt const output,
    size_t const rows, size_t const columns,
    size_t const r1 = 0, size_t const c1 = 0,
    size_t r2 = ~(size_t) 0, size_t c2 = ~(size_t) 0,
    size_t const leaf = 0x20)
{
    if (!~c2) { c2 = columns - c1; }
    if (!~r2) { r2 = rows - r1; }
    size_t const di = r2 - r1, dj = c2 - c1;
    if (di >= dj && di > leaf)
    {
        transpose(input, output, rows, columns, r1, c1, (r1 + r2) / 2, c2);
        transpose(input, output, rows, columns, (r1 + r2) / 2, c1, r2, c2);
    }
    else if (dj > leaf)
    {
        transpose(input, output, rows, columns, r1, c1, r2, (c1 + c2) / 2);
        transpose(input, output, rows, columns, r1, (c1 + c2) / 2, r2, c2);
    }
    else
    {
        for (ptrdiff_t i1 = (ptrdiff_t) r1, i2 = (ptrdiff_t) (i1 * columns);
            i1 < (ptrdiff_t) r2; ++i1, i2 += (ptrdiff_t) columns)
        {
            for (ptrdiff_t j1 = (ptrdiff_t) c1, j2 = (ptrdiff_t) (j1 * rows);
                j1 < (ptrdiff_t) c2; ++j1, j2 += (ptrdiff_t) rows)
            {
                output[j2 + i1] = input[i2 + j1];
            }
        }
    }
}
75
Voo

Luchian Grigore's answer の説明の説明として、64x64および65x65マトリックスの2つの場合のマトリックスキャッシュの存在は次のとおりです(数値の詳細については上記のリンクを参照してください)。

以下のアニメーションの色は次を意味します。

  • white –キャッシュにない、
  • light-green –キャッシュ内、
  • bright green –キャッシュヒット、
  • orange – RAMから読み取るだけで、
  • red –キャッシュミス。

64x64の場合:

cache presence animation for 64x64 matrix

ほぼすべての新しい行へのアクセスの結果、キャッシュミスが発生することに注意してください。そして、通常のケースである65x65マトリックスの外観は次のとおりです。

cache presence animation for 65x65 matrix

ここでは、最初のウォームアップ後のアクセスのほとんどがキャッシュヒットであることがわかります。これは、一般的にCPUキャッシュが機能することを意図した方法です。

47
Ruslan