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


108

好奇心から、私は自分の行列乗算関数と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. なぜ私の実装はとても遅いのですか?

17
BLASは、フィールドの専門家によって、片側を上に、もう片側を下に最適化されています。私はそれがあなたのチップのSIMD浮動小数点ユニットを利用し、キャッシュ動作を改善するためにたくさんのトリックをプレイしていると思います...
dmckee --- ex-moderator kitten

3
それでも、1.36秒で2.63E9サイクル/秒のプロセッサで1E10操作をどのように実行しますか?
DeusAduro 2009

9
複数の実行ユニット、パイプライン、および単一命令複数データ((SIMD)は、複数のペアのオペランドに対して同じ操作を同時に実行することを意味します)。一部のコンパイラーは、一般的なチップ上のSIMDユニットをターゲットにできますが、常に明示的にオンにする必要があり、すべてがどのように機能するかを知るのに役立ちます(en.wikipedia.org/wiki/SIMD)。キャッシュミスに対する保証は、ほぼ間違いなく難しい部分です。
dmckee ---元モデレーターの子猫

13
仮定が間違っています。既知のより優れたアルゴリズムがあります。Wikipediaを参照してください。
MSalters 2009

2
@DeusAduro:Eigenと競合できるマトリックスマトリックスプロダクトの記述方法についての私の答えではキャッシュ効率の高いマトリックスマトリックス製品を実装する方法に関する小さな例を投稿しました。
Michael Lehn

回答:


141

優れた出発点は、Robert A. van de GeijnとEnrique S.Quintana-Ortíによるすばらしい本、プログラミング行列計算の科学です。彼らは無料ダウンロード版を提供します。

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

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

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

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

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

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

私見の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;
    }

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

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

BLISの論文と合わせて、インテルMKLのようなライブラリーがどのようにしてこのようなパフォーマンスを実現できるかを理解するのはかなり簡単になります。また、行または列の主要なストレージを使用するかどうかが問題にならないのはなぜですか。

最終的なベンチマークはここにあります(私たちは私たちのプロジェクトをulmBLASと呼びました):

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

別の編集/更新:

また、線形方程式系を解くような数値線形代数問題にBLASがどのように使用されるかについてのチュートリアルも書きました。

高性能LU分解

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

チュートリアルを拡張して、PLASMAのようなLU因数分解の非常にスケーラブルな並列実装を実現する方法を説明およびデモする時間を見つけたいと思います

では、次のようにします:キャッシュ最適化並列LU因数分解のコーディング

PS:私はuBLASのパフォーマンスを改善するための実験も行いました。実際、uBLASのパフォーマンスを向上させるのは非常に簡単です(ええ、言葉で遊びます:))。

uBLASでの実験

ここでBLAZEを使用した同様のプロジェクト:

ブレイズの実験


3
「ulmBLAS、BLIS、MKL、openBLAS、Eigenのベンチマーク」への新しいリンク:apfel.mathematik.uni-ulm.de/~lehn/ulmBLAS/#toc3
Ahmed Fasih

IBMのESSLがStrassenアルゴリズムのバリエーションを使用していることが判明-ibm.com/support/knowledgecenter/en/SSFHY8/essl_welcome.html
ben-albrecht

2
リンクのほとんどが死んでいる
オーレリアンピエール・


コッパースミスウィノグラードアルゴリズムは紙面では時間的に複雑ですが、Big O表記は非常に大きな定数を隠しているため、途方もなく大きな行列に対してのみ実行可能になり始めます。
DiehardTheTryhard

26

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

最初に、ほとんど関係のないことについて述べます。

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

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

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

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

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

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

(ヒント:ATLASを使用している場合は、特定のマシン用に手動でライブラリをビルドして調整してから、ビルド済みのライブラリを使用した方がよい理由です。)


ATLASは、最も一般的に使用されているオープンソースのBLAS実装ではなくなりました。OpenBLAS(GotoBLASのフォーク)とBLIS(GotoBLASのリファクタリング)を上回っています。
Robert van de Geijn

1
@ ulaff.net:たぶん。これは6年前に書かれました。現在(もちろんIntel上で)最速のBLAS実装はIntel MKLだと思いますが、それはオープンソースではありません。
Andrew Tomazos

14

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

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

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

第三に、コードが最適とはほど遠いです。

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

おかげで、Justicleの提案に従って正しいコードに制限を追加しましたが、あまり改善は見られませんでした。ブロックごとのアイデアが好きです。好奇心から、CPUのキャッシュサイズを知らずに、どのようにして最適なコードを最適化できますか?
DeusAduro 2009

2
あなたはしません。最適なコードを取得するには、CPUのキャッシュサイズを知る必要があります。もちろん、これの欠点は、CPUの1つのファミリで最高のパフォーマンスが得られるようにコードを効果的にハードコーディングしていることです。
2009

2
少なくともここの内部ループは、ストライドされた負荷を回避します。これは、すでに転置されている1つの行列に対して記述されているようです。そのため、BLASよりも1桁遅いだけです。しかし、そうです、キャッシュブロッキングがないため、まだスラッシングが続いています。Fortranが大いに役立つと確信していますか?ここで得られるのはrestrict、C / C ++とは異なり、(エイリアシングなし)がデフォルトであることです。(残念ながらISO C ++にはrestrictキーワードがないため__restrict__、拡張機能として提供しているコンパイラーで使用する必要があります)。
Peter Cordes

11

BLASの実装について具体的にはわかりませんが、O(n3)よりも複雑な行列乗算のより効率的なアルゴリズムがあります。よく知られているのはStrassen Algorithmです


8
Strassenアルゴリズムは、次の2つの理由で数値では使用されません。1)安定していない。2)一部の計算を節約できますが、それにはキャッシュ階層を活用できる代償が伴います。実際には、パフォーマンスが低下します。
Michael Lehn 2013年

4
BLASライブラリのソースコードに緊密に構築されたStrassen Algorithmの実用的な実装については、SC16に「Strassen Algorithm Reloaded」という最近の出版物があり、問題サイズ1000x1000でもBLASよりも高いパフォーマンスを実現しています。
Jianyu Huang 2017

4

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ですべてを置き換えるよりも、私のコンピューターでの方が優れています(コンピューターで試してみてください!)。しかし、はるかに高速(1:4)には、Fortranライブラリのdgemm_を直接呼び出します。このルーチンは、実際にはFortranではなくアセンブラーコードだと思います(ライブラリーに何があるかわかりません。ソースはありません)。cblas_dgemmは、私の知る限りdgemm_のラッパーにすぎないので、なぜそれほど速くないのかはわかりません。


3

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

また、あなたのコードは「正しく制限されていません-コンパイラは、Cを変更してもAとBが変更されていないことをどのようにして知っていますか?


mmult(A ...、A ...、A);のような関数を呼び出したら、確かに期待どおりの結果は得られません。繰り返しますが、BLASを打ったり再実装したりするつもりはありませんでしたが、実際の速度を確認しただけなので、エラーチェックは考慮されておらず、基本的な機能のみでした。
DeusAduro 2009

3
申し訳ありませんが、明確に言うと、ポインタに "制限"を設定すると、コードがはるかに高速になるということです。これは、Cを変更するたびに、コンパイラーがAとBをリロードする必要がないためです。これにより、内部ループが大幅に高速化されます。信じられない場合は、分解を確認してください。
ジャスティクル2009

@DeusAduro:これはエラーチェックではありません-AとCのポインターがBにエイリアスしないことを理解できない可能性があるため、内部ループのB []配列へのアクセスをコンパイラーが最適化できない可能性がありますアレイ。エイリアシングがあった場合、B配列の値が内部ループの実行中に変更される可能性があります。内部ループからB []値へのアクセスを引き上げ、それをローカル変数に入れると、コンパイラーはB []への継続的なアクセスを回避できる場合があります。
マイケルバー

1
うーん、私は最初にVS 2008で '__restrict'キーワードを使用してA、B、Cに適用しようとしましたが、結果に変化はありませんでした。ただし、Bへのアクセスを最も内側のループから外側のループに移動すると、時間が10%向上しました。
DeusAduro 2009

1
申し訳ありませんが、VCについてはわかりませんが、GCCでは有効にする必要があります-fstrict-aliasing。ここでの「制限」のより良い説明もあります:cellperformance.beyond3d.com/articles/2006/05/...
Justicle

2

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

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

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


-24

多くの理由で。

第一に、Fortranコンパイラーは高度に最適化されており、言語はそのようにすることができます。CとC ++は、配列処理の点で非常に緩いです(たとえば、同じメモリ領域を参照するポインタの場合)。これは、コンパイラが事前に何をすべきかを知ることができず、一般的なコードを作成せざるを得ないことを意味します。Fortranでは、ケースがより合理化され、コンパイラーは何が起こるかをより適切に制御できるため、彼は(例えば、レジ​​スターを使用して)より最適化できます。

もう1つは、Fortranはデータを列ごとに格納し、Cはデータを行ごとに格納することです。コードを確認していませんが、製品の実行方法に注意してください。Cでは、行ごとにスキャンする必要があります。これにより、隣接するメモリに沿ってアレイをスキャンし、キャッシュミスを減らします。キャッシュミスは、非効率の最初の原因です。

第三に、それはあなたが使用しているブラスの実装に依存します。一部の実装はアセンブラーで作成され、使用している特定のプロセッサー用に最適化されている場合があります。netlibバージョンはfortran 77で書かれています。

また、あなたは多くの操作を行っています、それらのほとんどは繰り返され、冗長です。インデックスを取得するためのこれらの乗算はすべて、パフォーマンスにとって有害で​​す。BLASでこれがどのように行われるかは本当にわかりませんが、高価な操作を防ぐための多くのトリックがあります。

たとえば、この方法でコードを作り直すことができます

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, a1,a2,a3;
for ( cc2=0 ; cc2<BDim2 ; ++cc2 ) {
    a1 = cc2*ADim2;
    a3 = cc2*BDim1
    for ( cc1=0 ; cc1<ADim2 ; ++cc1 ) {
          a2=cc1*ADim1;
          ValT b = B[a3+cc1];
          for ( cr1=0 ; cr1<ADim1 ; ++cr1 ) {
                    C[a1+cr1] += A[a2+cr1]*b;
           }
     }
  }
} 

試してみてください、あなたは何かを保存するでしょう。

#1の質問の理由は、自明なアルゴリズムを使用すると、行列の乗算がO(n ^ 3)にスケーリングされるためです。より優れスケーリングを行うアルゴリズムがあります


36
この答えは完全に間違っています。BLAS実装はfortranで書かれていません。パフォーマンス重視のコードはアセンブリで記述されており、最近の最も一般的なコードはその上にCで記述されています。また、BLASは行/列の順序をインターフェイスの一部として指定し、実装は任意の組み合わせを処理できます。
Andrew Tomazos 2013年

10
はい、この答え完全に間違っています。残念ながら、それはありふれたナンセンスでいっぱいです。たとえば、BLASはFortranのために高速であるという主張です。20(!)の正の評価を持つことは悪いことです。Stackoverflowの人気により、このナンセンスはさらに広がります!
Michael Lehn

12
最適化されていないリファレンス実装と本番環境の実装を混同していると思います。リファレンス実装は、ライブラリのインターフェイスと動作を指定するためだけのものであり、歴史的な理由からFortranで記述されています。本番用ではありません。本番環境では、リファレンス実装と同じ動作を示す最適化された実装を使用します。私はATLAS(Octave-Linux "MATLAB"をサポート)の内部を調査しましたが、内部でC / ASMで記述されていることを確認できます。商用実装もほぼ確実です。
Andrew Tomazos 2013年

5
@KyleKanos:はい、ここにATLASのソースがあります:sourceforge.net/projects/math-atlas/files/Stable/3.10.1 私が知る限り、これは最も一般的に使用されているオープンソースのポータブルBLAS実装です。C / ASMで書かれています。Intelなどの高性能CPUメーカーも、特にチップ向けに最適化されたBLAS実装を提供しています。Intelsライブラリの低レベルの部分が(duuh)x86アセンブリで記述されていることを保証します。中レベルの部分はCまたはC ++で記述されると確信しています。
Andrew Tomazos 2013年

9
@KyleKanos:混乱しています。Netlib BLASはリファレンス実装です。リファレンス実装は、最適化された実装よりもはるかに低速です(パフォーマンス比較を参照)。誰かがクラスターでnetlib BLASを使用していると言っても、それは実際にnetlibリファレンス実装を使用しているという意味ではありません。それはばかげたことでしょう。それは、彼らがnetlib blasと同じインターフェースを持つlibを使用していることを意味します。
Andrew Tomazos 2013年
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.