web-dev-qa-db-ja.com

BLASはどのようにしてこのような極端なパフォーマンスを実現しますか?

好奇心から、私は独自の行列乗算関数とBLAS実装のベンチマークを行うことにしました...結果に驚くことはほとんどありませんでした。

カスタム実装、1000x1000行列乗算の10回の試行:

Took: 15.76542 seconds.

BLAS実装、1000x1000行列乗算の10回の試行:

Took: 1.32432 seconds.

これは、単精度浮動小数点数を使用しています。

私の実装:

template<class ValT>
void mmult(const ValT* A, int ADim1, int ADim2, const ValT* B, int BDim1, int BDim2, ValT* C)
{
    if ( ADim2!=BDim1 )
        throw std::runtime_error("Error sizes off");

    memset((void*)C,0,sizeof(ValT)*ADim1*BDim2);
    int cc2,cc1,cr1;
    for ( cc2=0 ; cc2<BDim2 ; ++cc2 )
        for ( cc1=0 ; cc1<ADim2 ; ++cc1 )
            for ( cr1=0 ; cr1<ADim1 ; ++cr1 )
                C[cc2*ADim2+cr1] += A[cc1*ADim1+cr1]*B[cc2*BDim1+cc1];
}

2つの質問があります。

  1. マトリックス-マトリックス乗算で次のように指定すると、nxm * mxnにはn * n * mの乗算が必要になるため、1000 ^ 3または1e9の演算を超える場合。 BLASの2.6Ghzプロセッサでは、1.32秒で10 * 1e9の操作を行うことができますか?乗算が単一の操作であり、他に何も実行されていない場合でも、〜4秒かかります。
  2. なぜ実装がこれほど遅くなるのですか?
92
DeusAduro

良い出発点は素晴らしい本です プログラミングマトリックス計算の科学 Robert A. van de GeijnとEnrique S.Quintana-Ortíによる。彼らは無料ダウンロード版を提供します。

BLASは3つのレベルに分かれています。

  • レベル1は、ベクトルのみで動作する線形代数関数のセットを定義します。これらの関数は、ベクトル化の恩恵を受けます(例:SSEの使用)。

  • レベル2関数は、行列ベクトル演算です。行列ベクトル積。これらの関数は、Level1関数の観点から実装できます。ただし、共有メモリを備えたマルチプロセッサアーキテクチャを利用する専用の実装を提供できる場合、この関数のパフォーマンスを向上させることができます。

  • レベル3の関数は、行列と行列の積のような操作です。ここでも、Level2関数の観点から実装できます。ただし、Level3関数はO(N ^ 2)データに対してO(N ^ 3)操作を実行します。プラットフォームにキャッシュ階層がある場合、cache optimized/cache friendlyの専用実装を提供すると、パフォーマンスを向上させることができます。これは本でうまく説明されています。 Level3関数の主な改善点は、キャッシュの最適化です。このブーストは、並列処理およびその他のハードウェア最適化による2番目のブーストを大幅に上回ります。

ちなみに、高性能BLAS実装のほとんど(またはすべて)は、Fortranでは実装されていません。 ATLASはCで実装されます。GotoBLAS/ OpenBLASはCで実装され、そのパフォーマンスの重要な部分はアセンブラーで実装されます。 Fortranには、BLASの参照実装のみが実装されています。ただし、これらのすべてのBLAS実装は、LAPACKにリンクできるFortranインターフェースを提供します(LAPACKはすべてのパフォーマンスをBLASから取得します)。

最適化されたコンパイラは、この点で小さな役割を果たします(GotoBLAS/OpenBLASの場合、コンパイラはまったく関係ありません)。

IMHO no BLAS実装では、Coppersmith–WinogradアルゴリズムやStrassenアルゴリズムなどのアルゴリズムを使用します。私は理由について正確にはわかりませんが、これは私の推測です:

  • これらのアルゴリズムのキャッシュに最適化された実装を提供することはできないかもしれません(つまり、勝つよりも多く失うでしょう)
  • これらのアルゴリズムは数値的に安定していません。 BLASはLAPACKの計算カーネルであるため、これは禁止事項です。

編集/更新:

このトピックの新しい画期的な論文は BLIS論文 です。それらは非常によく書かれています。私の講義「ハイパフォーマンスコンピューティングのソフトウェアの基礎」では、彼らの論文に従ってマトリックスマトリックス製品を実装しました。実際に、マトリックスマトリックス製品のいくつかのバリエーションを実装しました。最も単純なバリアントは、完全にプレーンCで記述されており、450行未満のコードです。他のすべてのバリアントは、単にループを最適化するだけです

    for (l=0; l<MR*NR; ++l) {
        AB[l] = 0;
    }
    for (l=0; l<kc; ++l) {
        for (j=0; j<NR; ++j) {
            for (i=0; i<MR; ++i) {
                AB[i+j*MR] += A[i]*B[j];
            }
        }
        A += MR;
        B += NR;
    }

行列-行列積onlyの全体的なパフォーマンスは、これらのループに依存します。ここで時間の約99.9%が費やされています。他のバリアントでは、パフォーマンスを改善するために組み込み関数とアセンブラコードを使用しました。チュートリアルは、すべてのバリエーションをここで見ることができます:

lmBLAS:GEMM(Matrix-Matrix Product)のチュートリアル

BLISの論文と合わせて、Intel MKLのようなライブラリがそのようなパフォーマンスをどのように得ることができるかを理解することはかなり簡単になります。そして、行または列のメジャーストレージを使用するかどうかは重要ではありません!

最終的なベンチマークはこちら(プロジェクトulmBLASと呼びます):

lmBLAS、BLIS、MKL、openBLAS、Eigenのベンチマーク

別の編集/更新:

また、線形方程式系の解法などの数値線形代数問題にBLASを使用する方法に関するチュートリアルも作成しました。

高性能LU分解

(このLU因数分解は、たとえば、線形方程式系を解くためにMatlabによって使用されます。)

時間を見つけたい チュートリアルを拡張して、LU [〜#〜] plasma [〜#〜] のような高度にスケーラブルな並列実装を実現する方法を説明および実証します。

わかりました、ここに行きます: キャッシュ最適化された並列のコーディングLU分解

追伸:また、uBLASのパフォーマンスを改善するための実験もいくつか行いました。実際に、ブーストするのはかなり簡単です(ええ、言葉で遊ぶ:))uBLASのパフォーマンス:

BLASの実験

[〜#〜] blaze [〜#〜] を使用した同様のプロジェクト:

BLAZEの実験

124
Michael Lehn

したがって、まずBLASは約50の機能のインターフェイスにすぎません。インターフェースの多くの競合する実装があります。

まず、ほとんど関係のないことについて言及します。

  • FortranとC、違いはありません
  • Strassenなどの高度なマトリックスアルゴリズムは、実際には役に立たないため、実装では使用しません

ほとんどの実装では、各操作を多かれ少なかれ明白な方法で小次元行列またはベクトル操作に分割します。たとえば、1000x1000の大きな行列乗算は、50x50の行列乗算のシーケンスに分割される場合があります。

これらの固定サイズの小さな次元の操作(カーネルと呼ばれる)は、ターゲットのいくつかのCPU機能を使用して、CPU固有のアセンブリコードにハードコーディングされています。

  • SIMDスタイルの指示
  • 命令レベルの並列処理
  • キャッシュ認識

さらに、これらのカーネルは、一般的なmap-reduceデザインパターンで、複数のスレッド(CPUコア)を使用して相互に並列に実行できます。

最も一般的に使用されているオープンソースBLAS実装であるATLASを見てください。多くの異なる競合カーネルがあり、ATLASライブラリビルドプロセス中にそれらの間で競合が実行されます(一部はパラメータ化されているため、同じカーネルは異なる設定を持つことができます)。さまざまな構成を試行し、特定のターゲットシステムに最適なものを選択します。

(ヒント:それが、ATLASを使用している場合、特定のマシン用にライブラリを手作業で構築して調整し、事前に構築したものを使用する方が良い理由です。)

23
Andrew Tomazos

まず、使用しているものよりも行列乗算の効率的なアルゴリズムがあります。

第二に、CPUは一度に複数の命令を実行できます。

CPUはサイクルごとに3〜4命令を実行し、SIMDユニットが使用されている場合、各命令は4つの浮動小数点または2つの倍精度を処理します。 (もちろん、CPUはサイクルごとに1つのSIMD命令しか処理できないため、この数値も正確ではありません)

第三に、あなたのコードは最適とはほど遠いです:

  • 生のポインターを使用しているため、コンパイラーはそれらがエイリアスする可能性があると想定する必要があります。コンパイラーにエイリアスしないことを伝えるために指定できるコンパイラー固有のキーワードまたはフラグがあります。または、問題を処理する生のポインタ以外の型を使用する必要があります。
  • 入力行列の各行/列の単純な走査を実行して、キャッシュをスラッシングしています。ブロッキングを使用して、次のブロックに進む前に、CPUキャッシュに収まるマトリックスの小さなブロックで可能な限り多くの作業を実行できます。
  • 純粋に数値的なタスクの場合、Fortranはほとんど無敵であり、C++は同様の速度に到達するために多くの同軸を使用します。それを行うことができ、それを実証するいくつかのライブラリがあります(通常は式テンプレートを使用)が、それは簡単ではなく、just起こりません。
14
jalf

私はBLASの実装について具体的には知りませんが、O(n3)の複雑さよりも優れたマトリックス乗算のより効率的なアルゴリズムがあります。よく知られているのは Strassen Algorithm =

11
softveda

2番目の質問(アセンブラー、ブロックへの分割など)に対するほとんどの引数(ただし、N ^ 3未満のアルゴリズムではなく、実際に開発が進んでいます)が役割を果たします。しかし、アルゴリズムの速度が遅いのは、基本的に行列サイズと3つのネストされたループの不幸な配置が原因です。マトリックスが非常に大きいため、キャッシュメモリに一度に収まりません。キャッシュ内の行で可能な限りループが再配置されるようにループを再配置できます。これにより、キャッシュの更新が大幅に削減されます(BTWを小さなブロックに分割すると、アナログ効果があります。ブロック上のループが同様に配置される場合に最適です)。正方行列のモデル実装は次のとおりです。私のコンピューターでは、標準的な実装(あなたの実装)と比較して、その時間消費は約1:10でした。言い換えれば、学校で学んだ「行時間列」スキームに沿って行列乗算をプログラムしないでください。ループを再配置した後、ループ、アセンブラコードなどを展開することにより、さらに改善されます。

    void vector(int m, double ** a, double ** b, double ** c) {
      int i, j, k;
      for (i=0; i<m; i++) {
        double * ci = c[i];
        for (k=0; k<m; k++) ci[k] = 0.;
        for (j=0; j<m; j++) {
          double aij = a[i][j];
          double * bj = b[j];
          for (k=0; k<m; k++)  ci[k] += aij*bj[k];
        }
      }
    }

もう1つのコメント:この実装は、すべてのBLASルーチンcblas_dgemm(コンピューターで試してみてください!)で置き換えるよりも、私のコンピューターではさらに優れています。ただし、Fortranライブラリのdgemm_を直接呼び出す(1:4)方がはるかに高速です。このルーチンは、実際にはFortranではなくアセンブラーコードであると思います(ライブラリに何があるかわかりません。ソースがありません)。 cblas_dgemmがdgemm_の単なるラッパーに過ぎないため、なぜcblas_dgemmがそれほど高速ではないのか、まったくわかりません。

4
Wolfgang Jansen

これは現実的なスピードアップです。 C++コードを介したSIMDアセンブラーでできることの例については、いくつかの例を参照してください iPhoneマトリックス関数 -これらはCバージョンの8倍以上高速でしたが、また、「最適化された」アセンブリではありません-パイプライン処理がまだ行われておらず、不要なスタック操作があります。

また、あなたのコードは「 restrict correct 」ではありません-コンパイラは、Cを修正するとき、AとBを修正していないことをどのように知っていますか?

3
Justicle

MM乗算の元のコードに関しては、ほとんどの操作のメモリ参照がパフォーマンスの低下の主な原因です。メモリはキャッシュの100〜1000倍遅い速度で実行されています。

スピードアップの大部分は、MM乗算でこのトリプルループ関数にループ最適化手法を採用することからもたらされます。 2つのメインループ最適化手法が使用されます。展開とブロック。展開に関して、最も外側の2つのループを展開し、キャッシュでのデータの再利用のためにブロックします。外部ループの展開は、操作全体の異なる時間に同じデータへのメモリ参照の数を減らすことにより、データアクセスを一時的に最適化するのに役立ちます。特定の数でループインデックスをブロックすると、データをキャッシュに保持するのに役立ちます。 L2キャッシュまたはL3キャッシュの最適化を選択できます。

https://en.wikipedia.org/wiki/Loop_nest_optimization

2
Pari Rajaram