で前述したように、行列乗算のベンチマークを作成していますが、MATLABが行列乗算で非常に高速なのはなぜですか?
2つの2048x2048行列を乗算すると、C#と他の行列には大きな違いがあります。 2047x2047の行列のみを乗算しようとすると、正常に見えます。比較のために他にもいくつか追加しました。
1024x1024-10秒。
1027x1027-10秒。
2047x2047-90秒。
2048x2048-300秒。
2049x2049-91秒。 (更新)
2500x2500-166秒
これは、2k x 2kの場合の3.5分差です。
2dim配列の使用
//Array init like this
int rozmer = 2048;
float[,] matice = new float[rozmer, rozmer];
//Main multiply code
for(int j = 0; j < rozmer; j++)
{
for (int k = 0; k < rozmer; k++)
{
float temp = 0;
for (int m = 0; m < rozmer; m++)
{
temp = temp + matice1[j,m] * matice2[m,k];
}
matice3[j, k] = temp;
}
}
これはおそらく、L2キャッシュの競合に関係しています。
Matice1でのキャッシュミスは順次アクセスされるため、問題にはなりません。ただし、matice2では、列全体がL2に収まる場合(つまり、matice2 [0、0]、matice2 [1、0]、matice2 [2、0] ...などにアクセスした場合)、何も問題はありません。 matice2のいずれかでキャッシュミス。
変数のバイトアドレスがXの場合、キャッシュラインの(X >> 6)&(L-1)よりも、キャッシュのしくみを詳しく説明します。ここで、Lはキャッシュ内のキャッシュラインの総数です。 Lは常に2の累乗です。6は2 ^ 6 == 64バイトがキャッシュラインの標準サイズであることから来ています。
これはどういう意味ですか?そうですね、アドレスXとアドレスYがあり、(X >> 6)-(Y >> 6)がLで割り切れる場合(つまり、2のべき乗)、同じキャッシュラインに格納されます。
2048と2049の違いは何ですか?
2048があなたのサイズであるとき:
&matice2 [x、k]と&matice2 [y、k]を取る場合、差(&matice2 [x、k] >> 6)-(&matice2 [y、k] >> 6)は2048 * 4(sizeフロートの)。つまり、2の大きな指数です。
したがって、L2のサイズによっては、キャッシュラインの競合が多数発生し、L2のごく一部のみを使用して列を格納するため、実際には完全な列をキャッシュに格納できず、パフォーマンスが低下します。 。
サイズが2049の場合、差は2049 * 4であり、2の累乗ではないため、競合が少なくなり、列が安全にキャッシュに収まります。
この理論をテストするために、あなたができることがいくつかあります:
このmatice2 [razmor、4096]のように配列matice2配列を割り当て、razmor = 1024、1025または任意のサイズで実行すると、以前のパフォーマンスと比較して非常に悪いパフォーマンスが表示されます。これは、すべての列を強制的に配置して、互いに競合するためです。
次に、matice2 [razmor、4097]を試して、任意のサイズで実行すると、パフォーマンスが大幅に向上するはずです。
おそらくキャッシング効果。 2のべき乗である行列の次元と、2のべき乗でもあるキャッシュサイズを使用すると、L1キャッシュのごく一部しか使用できなくなり、処理速度が大幅に低下します。単純な行列の乗算は、通常、データをキャッシュにフェッチする必要があるため制約されます。タイリングを使用して最適化されたアルゴリズム(またはキャッシュを気にしないアルゴリズム)は、L1キャッシュをより効果的に使用することに重点を置いています。
他のペア(2 ^ n-1,2 ^ n)の時間を計ると、同様の効果が得られると思います。
より完全に説明すると、matice2 [m、k]にアクセスする内側のループでは、matice2 [m、k]とmatice2 [m + 1、k]が2048 * sizeof(float)だけ互いにオフセットされている可能性がありますしたがって、L1キャッシュ内の同じインデックスにマップされます。 Nウェイ連想キャッシュでは、通常、これらすべてに対して1〜8個のキャッシュの場所があります。したがって、これらのアクセスのほとんどすべてが、L1キャッシュの排除と、より遅いキャッシュまたはメインメモリからのデータのフェッチをトリガーします。
これはCPUキャッシュのサイズに関係している可能性があります。マトリックスマトリックスの2行が収まらない場合は、RAMからの要素の交換に時間がかかります。追加の4095要素は、行がフィットしないようにするのに十分な場合があります。
あなたの場合、2047 2d行列の2行はメモリの16KB以内に収まります(32ビットタイプを想定)。たとえば、64KBのL1キャッシュ(バス上のCPUに最も近い)がある場合、少なくとも4行(2047 * 32)を一度にキャッシュに収めることができます。より長い行では、行のペアを16KBを超えてプッシュするパディングが必要な場合、状況が乱雑になり始めます。また、キャッシュに「ミス」するたびに、別のキャッシュまたはメインメモリからデータをスワップインすると、遅延が発生します。
私の推測では、さまざまなサイズのマトリックスで見られる実行時間の違いは、オペレーティングシステムが利用可能なキャッシュをどの程度効果的に利用できるかによって影響を受けます(一部の組み合わせは問題があるだけです)。もちろん、これはすべて私の側での単純化です。
Louis Brandyは、この問題を正確に分析する2つのブログ投稿を書きました。
More Cache Craziness および Computational Performance-A Beginners Case Study いくつかの興味深い統計と動作をより詳細に説明しようとすると、実際にキャッシュサイズの制限に行き着きます。
より大きなサイズで時間が減少していることを考えると、特に問題のあるマトリックスサイズに対して2の累乗を使用すると、キャッシュの競合が発生する可能性が高くなりますか?私はキャッシュの問題の専門家ではありませんが、キャッシュ関連のパフォーマンスの問題に関する優れた情報 here です。
またはキャッシュスラッシング(用語を作成できる場合)。
キャッシュは、低次ビットでのインデックス付けと高次ビットでのタグ付けによって機能します。
キャッシュが4ワードで、マトリックスが4 x 4であるというイメージング。列がアクセスされ、行が2のべき乗の長さである場合、メモリ内の各列要素は同じキャッシュ要素にマップされます。
2の1の累乗が実際にはこの問題に最適です。新しい各列要素は、行でアクセスする場合とまったく同じように、次のキャッシュスロットにマップされます。
実際には、タグは複数の連続的に増加するアドレスをカバーし、隣接するいくつかの要素を連続してキャッシュします。新しい各行がマップするバケットをオフセットすることにより、列をトラバースしても前のエントリは置き換えられません。次の列がトラバースされると、キャッシュ全体が異なる行で埋められ、キャッシュに収まる各行セクションが複数の列にヒットします。
キャッシュはDRAMよりも非常に高速であるため(主にオンチップであるため)、ヒット率がすべてです。
垂直にmatice2
配列にアクセスしていると、キャッシュ内とキャッシュ外でより多くスワップされます。配列を斜めにミラーリングして、[k,m]
ではなく[m,k]
を使用して配列にアクセスできるようにすると、コードの実行速度が大幅に向上します。
私はこれを1024x1024マトリックスでテストしましたが、約2倍の速さです。 2048x2048マトリックスでは、約10倍高速です。
キャッシュサイズの制限に達したか、タイミングの再現性に問題がある可能性があります。
問題が何であれ、C#で行列乗算を自分で作成するのではなく、代わりにBLASの最適化バージョンを使用する必要があります。そのサイズの行列は、最新のマシンでは1秒未満で乗算する必要があります。
キャッシュ階層を効果的に利用することは非常に重要です。多次元配列にデータが適切に配置されていることを確認する必要があります。これはtilingで実行できます。これを行うには、2D配列を1D配列として、インデックスメカニズムとともに格納する必要があります。従来の方法の問題は、同じ行にある2つの隣接する配列要素がメモリ内で互いに隣接しているにもかかわらず、同じ列にある2つの隣接する要素が[〜# 〜] w [〜#〜]メモリ内の要素、ここで[〜#〜] w [〜#〜]は列の数です。タイリングは、パフォーマンスを10倍も大きくする可能性があります。
「シーケンシャルフラッディング」と呼ばれるものの結果だと思います。これは、キャッシュサイズよりわずかに大きいオブジェクトのリストをループしようとしているため、リスト(配列)へのすべてのリクエストはRAMから実行する必要があり、単一のキャッシュを取得しないということです。ヒット。
あなたの場合、あなたはあなたの配列を2048回インデックスで2048回ループしていますが、あなたは2047のためのスペースしか持っていないので(おそらく配列構造からのオーバーヘッドのため)、配列posにアクセスするたびにこの配列posを取得する必要がありますラムから。その後、キャッシュに格納されますが、再び使用される直前にダンプされます。したがって、キャッシュは本質的に役に立たず、実行時間がはるかに長くなります。