129

好奇心から、私は独自の行列乗算関数と 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 プロセッサで 10*1e9 操作を 1.32 秒で実行するにはどうすればよいですか? 乗算が 1 回の操作で他に何も実行されていない場合でも、約 4 秒かかります。
  2. 実装が非常に遅いのはなぜですか?
4

8 に答える 8

178

良い出発点は、Robert A. van de Geijn と Enrique S. Quintana-Ortí による素晴らしい本The Science of Programming Matrix Computationsです。彼らは無料のダウンロード版を提供しています。

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

  • レベル 1 は、ベクトルのみを操作する一連の線形代数関数を定義します。これらの関数は、ベクトル化 (たとえば、SSE の使用) の恩恵を受けます。

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

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

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

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

IMHO no BLAS の実装では、Coppersmith–Winograd アルゴリズムや Strassen アルゴリズムなどのアルゴリズムを使用しています。考えられる理由は次のとおりです。

  • おそらく、これらのアルゴリズムのキャッシュ最適化された実装を提供することは不可能です (つまり、勝つよりも負けるほうが多い)
  • これらのアルゴリズムは数値的に安定していません。BLAS は LAPACK の計算カーネルであるため、これは不可能です。
  • これらのアルゴリズムは紙の上ではかなりの時間の複雑さを持っていますが、Big O 表記は大きな定数を隠しているため、非常に大きな行列に対してのみ実行可能になり始めます。

編集/更新:

このトピックの新しい画期的な論文は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を使用した同様のプロジェクト:

BLAZE の実験

于 2012-07-10T20:23:36.977 に答える
29

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

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

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

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

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

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

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

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

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

于 2013-06-26T14:10:15.830 に答える
16

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

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

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

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

  • 生のポインターを使用しています。つまり、コンパイラーはエイリアスの可能性があると想定する必要があります。エイリアスを作成しないことをコンパイラに伝えるために指定できる、コンパイラ固有のキーワードまたはフラグがあります。または、問題を処理する生のポインター以外の型を使用する必要があります。
  • 入力行列の各行/列の単純なトラバーサルを実行して、キャッシュをスラッシングしています。ブロッキングを使用すると、次のブロックに進む前に、CPU キャッシュに収まる行列の小さなブロックでできるだけ多くの作業を実行できます。
  • 純粋に数値的なタスクの場合、Fortran はほとんど無敵であり、C++ は同様の速度に達するまでに多くの調整が必要です。それは可能であり、(通常は式テンプレートを使用して) それを示すライブラリがいくつかありますが、それは簡単ではなく、ただ起こるわけでもありません。
于 2009-08-20T12:12:12.687 に答える
11

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

于 2009-08-20T00:15:17.457 に答える
3

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

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

于 2009-08-20T00:10:54.977 に答える
2

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

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

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

于 2016-05-02T12:07:50.533 に答える
-25

多くの理由で。

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

もう 1 つのことは、Fortran はデータを列ごとに格納するのに対し、C はデータを行ごとに格納することです。あなたのコードはチェックしていませんが、製品の実行方法には注意してください。C では、行ごとにスキャンする必要があります。この方法では、連続したメモリに沿って配列をスキャンし、キャッシュ ミスを減らします。キャッシュ ミスは、非効率性の最初の原因です。

第三に、使用している blas の実装によって異なります。一部の実装は、アセンブラーで記述され、使用している特定のプロセッサ用に最適化されている場合があります。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)としてスケーリングされるためです。はるかに優れたスケーリングのアルゴリズムがあります。

于 2009-08-19T23:36:42.470 に答える