web-dev-qa-db-ja.com

行列の乗算:行列サイズの小さな違い、タイミングの大きな違い

次のような行列乗算コードがあります。

for(i = 0; i < dimension; i++)
    for(j = 0; j < dimension; j++)
        for(k = 0; k < dimension; k++)
            C[dimension*i+j] += A[dimension*i+k] * B[dimension*k+j];

ここで、行列のサイズはdimensionで表されます。ここで、行列のサイズが2000の場合、このコードを実行するのに147秒かかりますが、行列のサイズが2048の場合は447秒かかります。そのため、違いはありません。乗算の(2048 * 2048 * 2048)/(2000 * 2000 * 2000)= 1.073であり、タイミングの差は447/147 = 3です。誰かがこれが発生する理由を説明できますか?私はそれが線形にスケーリングすることを期待しました、それは起こりません。私は最速の行列乗算コードを作ろうとしているのではなく、なぜそれが起こるのかを理解しようとしています。

仕様:AMD Opteronデュアルコアノード(2.2GHz)、2G RAM、gcc v 4.5.0

gcc -O3 simple.cとしてコンパイルされたプログラム

私はこれをIntelのiccコンパイラでも実行し、同様の結果を確認しました。

編集:

コメント/回答で提案されているように、dimension = 2060でコードを実行したところ、145秒かかりました。

完全なプログラムは以下のとおりです。

#include <stdlib.h>
#include <stdio.h>
#include <sys/time.h>

/* change dimension size as needed */
const int dimension = 2048;
struct timeval tv; 

double timestamp()
{
        double t;
        gettimeofday(&tv, NULL);
        t = tv.tv_sec + (tv.tv_usec/1000000.0);
        return t;
}

int main(int argc, char *argv[])
{
        int i, j, k;
        double *A, *B, *C, start, end;

        A = (double*)malloc(dimension*dimension*sizeof(double));
        B = (double*)malloc(dimension*dimension*sizeof(double));
        C = (double*)malloc(dimension*dimension*sizeof(double));

        srand(292);

        for(i = 0; i < dimension; i++)
                for(j = 0; j < dimension; j++)
                {   
                        A[dimension*i+j] = (Rand()/(Rand_MAX + 1.0));
                        B[dimension*i+j] = (Rand()/(Rand_MAX + 1.0));
                        C[dimension*i+j] = 0.0;
                }   

        start = timestamp();
        for(i = 0; i < dimension; i++)
                for(j = 0; j < dimension; j++)
                        for(k = 0; k < dimension; k++)
                                C[dimension*i+j] += A[dimension*i+k] *
                                        B[dimension*k+j];

        end = timestamp();
        printf("\nsecs:%f\n", end-start);

        free(A);
        free(B);
        free(C);

        return 0;
}
74
jitihsk

ここに私の野生の推測があります:cache

2000 doublesの2行をキャッシュに収めることができます。これは32kb L1キャッシュよりもわずかに少ないです。 (他に必要なものを部屋に残しながら)

しかし、それを2048まで上げると、entireキャッシュが使用されます(他のスペースが必要なため、一部を流出します)もの)

キャッシュポリシーがLRUであると仮定すると、キャッシュをほんの少しだけスピルすると、行全体が繰り返しフラッシュされ、L1キャッシュに再ロードされます。

もう1つの可能性は、2の累乗によるキャッシュ結合性です。プロセッサは2ウェイL1アソシエイティブだと思いますが、この場合は重要ではないと思います。 (とにかく私はそこにアイデアを捨てます)

可能性のある説明2: L2キャッシュでのスーパーアラインメントによる競合キャッシュミス。

B配列が列で反復されています。したがって、アクセスはストライドされます。合計データサイズは2k x 2kこれは、マトリックスあたり約32 MBです。これは、L2キャッシュよりもはるかに大きくなります。

データが完全に整列していない場合、Bの空間的な局所性は適切です。行をホッピングし、キャッシュラインごとに1つの要素のみを使用していますが、キャッシュラインはL2キャッシュにとどまり、中間ループの次の反復で再利用されます。

ただし、データが完全に整列している場合(2048)、これらのホップはすべて同じ「キャッシュウェイ」に到達し、L2キャッシュの関連性をはるかに超えます。したがって、アクセスされたBのキャッシュラインは、次の反復でキャッシュに残りません。 代わりに、RAMから完全に引き出す必要があります。

81
Mysticial

あなたは間違いなく私がキャッシュresonanceと呼ぶものを得ています。これはaliasingに似ていますが、まったく同じではありません。説明させてください。

キャッシュは、ソフトウェアの配列とは異なり、アドレスの一部を抽出してテーブルのインデックスとして使用するハードウェアデータ構造です。 (実際、ハードウェアではそれらを配列と呼びます。)キャッシュ配列には、データのキャッシュラインとタグが含まれます。配列内のインデックスごとに1つのエントリ(直接マップされる)もあれば、複数ある(Nウェイセットの関連性)。アドレスの2番目の部分が抽出され、配列に格納されているタグと比較されます。インデックスとタグを組み合わせて、キャッシュラインのメモリアドレスを一意に識別します。最後に、残りのアドレスビットは、アクセスのサイズとともに、キャッシュラインのどのバイトがアドレス指定されているかを識別します。

通常、インデックスとタグは単純なビットフィールドです。したがって、メモリアドレスは次のようになります

  ...Tag... | ...Index... | Offset_within_Cache_Line

(インデックスとタグはハッシュである場合があります。たとえば、インデックスであるミッドレンジビットへの他のビットのいくつかのXORです。非常にまれに、インデックスとタグは、キャッシュラインアドレスをモジュロ化するようなものです。素数。これらのより複雑なインデックス計算は、ここで説明する共振の問題に対処するための試みです。すべてが何らかの形で共振しますが、最も簡単なビットフィールド抽出スキームは、一般的なアクセスパターンで共振します。

したがって、典型的な値...「Opteron Dual Core」には多くの異なるモデルがあり、どのモデルを使用しているかを示すものはここにはありません。 AMDのWebサイトにある最新のマニュアル、ランダムに1つを選択します AMDファミリ用のBIOSおよびカーネル開発者ガイド(BKDG)15hモデル00h-0Fh 、2012年3月12日。

(ファミリー15h =ブルドーザーファミリー、最新のハイエンドプロセッサー-BKDGはデュアルコアについて言及していますが、製品番号が正確に何なのかはわかりません。しかし、とにかく、同じ共振の考え方がすべてのプロセッサーに当てはまります。キャッシュサイズや結合性などのパラメータが少し異なる場合があります。)

33ページから:

AMD Family 15hプロセッサーには、2つの128ビットポートを備えた16Kバイトの4ウェイ予測L1データキャッシュが含まれています。これは、サイクルごとに最大2つの128バイトのロードをサポートするライトスルーキャッシュです。これは16バンクに分割され、それぞれ16バイト幅です。 [...] 1サイクルでL1キャッシュの特定のバンクから実行できるロードは1つだけです。

総括する:

  • 64バイトのキャッシュライン=>キャッシュライン内の6オフセットビット

  • 16KB/4-way =>共鳴は4KBです。

    つまりアドレスビット0〜5は、キャッシュラインオフセットです。

  • 16KB/64Bキャッシュライン=> 2 ^ 14/2 ^ 6 = 2 ^ 8 =キャッシュ内の256キャッシュライン。
    (バグ修正:私は最初にこれを128と誤って計算しました。すべての依存関係を修正したことになります。)

  • キャッシュ配列内の4ウェイ連想=> 256/4 = 64インデックス。私(インテル)はこれらを「セット」と呼びます。

    つまり、キャッシュは32のエントリまたはセットの配列と見なすことができ、各エントリには4つのキャッシュラインとタグが含まれます。 (これよりも複雑ですが、問題ありません)。

(ちなみに、「セット」および「ウェイ」という用語には さまざまな定義 があります。)

  • 6つのインデックスビットがあり、最も単純なスキームではビット6〜11です。

    つまり、インデックスビット(ビット6〜11)の値がまったく同じキャッシュラインは、同じキャッシュセットにマップされます。

次に、プログラムを見てください。

C[dimension*i+j] += A[dimension*i+k] * B[dimension*k+j];

ループkは最も内側のループです。基本タイプはdouble、8バイトです。 dimension = 2048、つまり2Kの場合、ループによってアクセスされるB[dimension*k+j]の連続する要素は2048 * 8 = 16Kバイト離れています。それらはすべて、L1キャッシュの同じセットにマップされます-それらはすべてキャッシュ内に同じインデックスを持ちます。つまり、使用可能なキャッシュに256のキャッシュラインがある代わりに、キャッシュの「4ウェイ連想性」である4のみが存在することになります。

つまりこのループでは、4回の反復ごとにキャッシュミスが発生する可能性があります。良くない。

(実際には少し複雑ですが、上記は最初の理解としては十分です。上記のBのエントリのアドレスは仮想アドレスです。そのため、物理アドレスが少し異なる場合があります。さらに、ブルドーザーには予測キャッシュがあり、おそらく仮想アドレスビットを使用して、仮想アドレスから物理アドレスへの変換を待つ必要がないようにします。ただし、いずれの場合も、コードの「共鳴」は16Kです。L1データキャッシュの共鳴は16Kです。 。)]

たとえば、寸法を少しだけ変更した場合。 2048 + 1の場合、配列Bのアドレスはキャッシュのすべてのセットに分散されます。また、キャッシュミスが大幅に少なくなります。

配列にパディングすることはかなり一般的な最適化です。 2048を2049に変更して、この共鳴のsrtを回避します。しかし、「キャッシュブロッキングはさらに重要な最適化です。 http://suif.stanford.edu/papers/lam-asplos91.pdf


キャッシュラインのレゾナンスに加えて、他にもさまざまなことが行われています。たとえば、L1キャッシュには16バンクがあり、それぞれ16バイト幅です。ディメンション= 2048の場合、内側のループで連続するBアクセスは常に同じバンクに行きます。したがって、それらを並行して実行することはできません。Aのアクセスが偶然に同じバンクに移動した場合は、失われます。

私はそれを見て、これがキャッシュのレゾナンスと同じくらい大きいとは思いません。

そして、はい、おそらく、エイリアシングが行われている可能性があります。例えば。 STLF(Store To Load Forwardingバッファ)は、小さなビットフィールドを使用してのみ比較し、誤った一致を取得している可能性があります。

(実際に考えると、キャッシュ内の共振は、ビットフィールドの使用に関連するエイリアシングのようなものです。共振は、同じセットをマッピングする複数のキャッシュラインが原因で、全体に広がっていないために発生します。ビット)


全体として、チューニングに関する私の推奨事項:

  1. これ以上の分析をせずにキャッシュブロッキングを試してください。キャッシュブロッキングは簡単であり、これで十分な可能性が高いためです。

  2. その後、VTuneまたはOProfを使用します。またはCachegrind。または...

  3. さらに良いことに、よく調整されたライブラリルーチンを使用して行列の乗算を行います。

31
Krazy Glew

考えられる原因はいくつかあります。 1つの可能性のある説明はMysticialが示唆するものです:限られたリソース(キャッシュまたはTLB)の枯渇。別の可能性としては、偽のエイリアシングストールがあります。これは、連続するメモリアクセスが2のべき乗の倍数(多くの場合4KB)で分離されている場合に発生する可能性があります。

値の範囲に対して時間/次元^ 3をプロットすることで、現在の作業を絞り込むことができます。キャッシュをブローしたか、TLBの範囲を使い果たした場合は、ほぼ平坦なセクションが表示され、その後2000〜2048で急激に上昇し、その後に別のフラットセクションが表示されます。エイリアシング関連のストールが表示されている場合は、2048で上向きの狭いスパイクがあり、ほぼフラットなグラフが表示されます。

もちろん、これには診断力がありますが、決定的なものではありません。スローダウンの原因を明確に知りたい場合は、パフォーマンスカウンターについて学習することをお勧めします。これにより、この種の質問に明確に答えることができます。

17
Stephen Canon

これは古すぎるのはわかっていますが、少し食べます。 (言われているように)2の累乗でスローダウンを引き起こすのはキャッシュの問題です。しかし、これには別の問題があります。遅すぎるからです。あなたがあなたの計算ループを見れば。

for(i = 0; i < dimension; i++)
    for(j = 0; j < dimension; j++)
        for(k = 0; k < dimension; k++)
            C[dimension*i+j] += A[dimension*i+k] * B[dimension*k+j];

最も内側のループは、反復ごとにkを1ずつ変更します。つまり、Aで使用した最後の要素から1倍の距離でアクセスしますbut「次元」全体がBの最後の要素から2倍で距離を移動します。これはBの要素のキャッシュを利用していません。

これを次のように変更した場合:

for(i = 0; i < dimension; i++)
    for(j = 0; j < dimension; j++)
        for(k = 0; k < dimension; k++)
            C[dimension*i+k] += A[dimension*i+j] * B[dimension*j+k];

まったく同じ結果が得られますが(モジュロダブル加算の関連性エラー)、キャッシュフレンドリーになります(local)。私はそれを試しました、そしてそれはかなりの改善を与えます。これは次のように要約できます。

行列を定義で乗算するのではなく、行で乗算する


高速化の例(ディメンションを引数として取るようにコードを変更しました)

$ diff a.c b.c
42c42
<               C[dimension*i+j] += A[dimension*i+k] * B[dimension*k+j];
---
>               C[dimension*i+k] += A[dimension*i+j] * B[dimension*j+k];
$ make a
cc     a.c   -o a
$ make b
cc     b.c   -o b
$ ./a 1024

secs:88.732918
$ ./b 1024

secs:12.116630

おまけとして(そしてこれがこの質問に関連する理由)、このループは前の問題の影響を受けません。

すでにご存知の場合は、お詫び申し上げます。

8
Guido

いくつかの回答がL2キャッシュの問題に言及しています。

あなたは実際にこれをキャッシュシミュレーション検証できます。 Valgrindの cachegrind ツールはそれを行うことができます。

valgrind --tool=cachegrind --cache-sim=yes your_executable

コマンドラインパラメータ を設定して、CPUのL2パラメータと一致するようにします。

さまざまなマトリックスサイズでテストすると、L2ミス率が急激に増加します。

8
Karoly Horvath