Armadilloによる行列乗算のSuper C ++最適化


9

私はArmadilloを使用して、辺の長さが非常に集中的な行列乗算を行っています。ここで、は最大20以上にすることができます。私が使用しているアルマジロをしてOpenBLAS私は、パフォーマンスの最適化のためのスーパーアルマジロでの乗算の形式主義に問題があることを除いて、並列コアで非常に良い仕事をしているように見える行列乗算、ため。 n2

次の形式のループがあるとします。

arma::cx_mat stateMatrix, evolutionMatrix; //armadillo complex matrix type
for(double t = t0; t < t1; t += 1/sampleRate)
{
    ...
    stateMatrix = evolutionMatrix*stateMatrix;
    ...
}

基本的なC ++では、私はここでの問題を見つけることはC ++での新しいオブジェクトに割り当てるということであるcx_mat店にしevolutionMatrix*stateMatrix、その後に新しいオブジェクトをコピーstateMatrixしてoperator=()。これは非常に非効率的です。大きなデータ型の複雑なクラスを返すのは悪い考えだということはよく知られていますよね?

この方法がより効率的であると私が考える方法は、次の形式で乗算を行う関数を使用することです。

void multiply(const cx_mat& mat1, const cx_mat& mat2, cx_mat& output)
{
    ... //multiplication of mat1 and mat2 and then store it in output
}

このようにして、戻り値を持つ巨大なオブジェクトをコピーする必要がなくなり、乗算ごとに出力を再割り当てする必要がなくなります。

質問:Armadilloを使用してBLASの優れたインターフェイスで乗算を実行し、行列オブジェクトを再作成して各操作でそれらをコピーする必要なく効率的にこれを行うには、どうすれば妥協点を見つけることができますか?

これはアルマジロの実装上の問題ではありませんか?


4
「超最適化」は実際にはあなたが参照するつもりでなかったものです。これは、まだキャッチされていない、非常に古く、高度なコンパイル時コードの特殊化形式です。
Andrew Wagner

1
ほとんどの回答(および質問自体!)は、行列の乗算が適切なものではないという要点を逃しているようです。

@hurkyl「インプレース」とはどういう意味ですか?
量子物理学者、2015年

あなたは計算すると、あなたは修正使用すると、内容のままという意味で、「場所に」彼らはメモリ内にあり、そのメモリを変更するすべての作業を行います。またははそのように計算されません。をメモリ内に残し、乗算の出力を計算されるメモリと同じメモリに書き込む、乗算の合理的なアルゴリズムはありません。更新はそので行われる必要があります-一時メモリは何らかの方法で使用する必要があります。A A A = A B A = B A A=+B=B=B

Armadilloのソースコードを見ると、式stateMatrix = evolutionMatrix*stateMatrixはまったくコピーを行いません。代わりに、Armadilloは素晴らしいメモリポインタの変更を行います。結果用の新しいメモリは引き続き割り当てられますが(それを回避する方法はありません)、コピーではなく、stateMatrixマトリックスは新しいメモリを使用し、古いメモリを破棄します。
mtall

回答:


14

基本的なC ++では、C ++がcx_matの新しいオブジェクトを割り当てて、evolutionMatrix * stateMatrixを格納し、その新しいオブジェクトをoperator =()を使用してstateMatrixにコピーするという問題があることがわかりました。

テンポラリーを作成しているのは遅いと思うが、それは遅すぎるが、それを行っている理由は間違っていると思う。

Armadilloは、優れたC ++線形代数ライブラリと同様に、式テンプレートを使用して式の遅延評価を実装します。以下のようにあなたは行列積を書き留めときはA*B何も一時は作成されず、代わりに、アルマジロは、(一時的なオブジェクトを作るx)への参照を保持していること AB、そしてその後、表現のように与えられC = x、行列積が直接結果を格納する計算Cいかなる作成せずに、一時。

また、この最適化を使用して、のような式を処理しますA*B*C*D。ここで、行列のサイズに応じて、特定の乗算の次数は他よりも効率的です。

これはアルマジロの実装上の問題ではありませんか?

Armadilloがこの最適化を実行していない場合、それは開発者に報告する必要があるArmadilloのバグです。

ただし、あなたのケースでは、より重要な別の問題があります。A=B*Cのストレージのような式では、エイリアスまたはがないA場合、入力データは含まれAません。あなたの場合、 出力行列に何かを書き込むと、入力行列の1つも変更されます。BCA = A*B

あなたの提案された機能を与えられても

multiply(const cx_mat&, const cx_mat&, cx_mat&)

その関数は式でどの程度正確に役立ちmultiply(A, B, A)ますか?その関数のほとんどの通常の実装では、バグが発生します。入力データが破損していないことを確認するために、一時ストレージを独自に使用する必要があります。あなたの提案は、Armadilloがすでに行列乗算を実装している方法とほぼ同じですがmultiply(A, B, A)、一時的な割り当てなどの状況を回避するためにおそらく注意が必要だと思います 。

Armadillo この最適化を行わない理由の最も可能性の高い説明は、それを行うのは正しくないということです。

最後に、次のように、やりたいことを実行する非常に簡単な方法があります。

cx_mat *A, *Atemp, B;
for (;;) {
  *Atemp = (*A)*B;
  swap(A, Atemp);
}

これは同じです

cx_mat A, B;
for (;;) {
  A = A*B;
}

ただし、反復ごとに1つの一時行列ではなく、1つの一時行列を割り当てます。


その「実行するのがはるかに簡単な方法」–あいまいに見えることは別として(そうですが、コピーの代わりにスワップは実際にはC ++のイディオムですが、幸いにもC ++ 11以降はほとんど必要ありません)、そうしないとクラッシュします。new-initialise Atemp–何も得られません。RVOで禁止されていない限り、新しい一時的な行列を生成してに(*A)*Bコピーする*Atemp必要があります。
レフトアラウンド

1
@leftaroundaboutいいえ、私の例で追加の一時ファイルが作成された場合、それはArmadilloのバグです。式テンプレートに依存する線形代数ライブラリは、中間結果に一時的なものを作成することを明示的に回避します。の値は一時的な行列で(*A)*Bなく、式とその入力を追跡する式オブジェクトです。この最適化が元の例で起動しない理由を説明しようとしましたが、RVOとは何の関係もありません(または別の答えのようにセマンティクスを移動します)。すべての初期化コードをスキップしました。この例では重要ではありません。タイプを示しただけです。
キリル2015

わかりました、あなたが何をしているのかはわかりますが、これはまだ非常にハッキングされ、信頼できない方法のようです。この方法で破壊的な乗算を最適化するオプションを設計者が考案していた場合、設計者は専用の方法で実装するか、少なくともカスタムを提供して、swapこのようなポインターのジャグリングを行う必要がないようにします。
レフトアラウンド

1
@leftaroundaboutまた、この例では行列を交換せず、行列へのポインターを交換して、コピーがまったく行われないようにします。2つの一時的な行列があり、それらの1つが一時的なものと見なされると、反復ごとに切り替わります。
キリル2015

2
@leftaroundabout:このポインターの使用によるメモリー管理はありません。これは、2つのオブジェクトがあり、どの目的でどのオブジェクトを使用しているかを追跡する必要があるコードの小さなブロックです。

8

@BillGreeneは、根本的な問題を回避する方法として「戻り値の最適化」を示していますが、これは実際にはその半分だけに役立ちます。次の形式のコードがあるとします。

struct ExpensiveObject { ExpensiveObject(); ~ExpensiveObject(); };

ExpensiveObject operator+ (ExpensiveObject &obj1,
                           ExpensiveObject &obj2)
{
   ExpensiveObject tmp;
   ...compute tmp based on obj1 and obj2...
   return tmp;
}

void f() {
  ExpensiveObject o1, o2, o3;
  ...initialize o1, o2...;
  o3 = o1 + o2;
}

素朴なコンパイラは

  1. プラス操作の結果を格納するスロットを作成します(一時的なもの)。
  2. operator +、
  3. operator +(2番目の一時ファイル)内に「tmp」オブジェクトを作成します。
  4. tmpを計算する
  5. tmpを結果スロットにコピーし、
  6. tmpを破壊し、
  7. 結果オブジェクトをo3にコピーする
  8. 結果オブジェクトを破棄する

戻り値の最適化では、「tmp」オブジェクトと「result」スロットのみを統合できますが、コピーの必要性を取り除くことはできません。そのため、一時ファイルの作成、コピー操作、一時ファイルの破棄が残ります。

これを回避する唯一の方法は、operator +がオブジェクトを返さないことですが、中間クラスのオブジェクトは、に割り当てられたときにExpensiveObject、追加およびコピー操作を実行します。これは、式テンプレートライブラリで一般的に使用されるアプローチです。


この情報に感謝。この問題を回避するために、Armadilloで使用できる例を提供できますか?
量子物理学者

そして私は尋ねたいと思います:これはアルマジロの実装問題ですよね?つまり、この方法で行うのはそれほどスマートではないということです。少なくとも、結果を参照オプションに渡す必要があります。正しい?
量子物理学者

6
この回答の重要な部分は終わりです。Armadilloは、可能な場合は式テンプレートを使用して式を遅延評価します。これにより、作成される一時ファイルの数が削減されます。OPが留意すべき主なことは、プロファイラーを実行してスローダウンが発生している場所を特定し、それらの最適化に焦点を当てることです。多くの場合、「遅いはず」のコードに関する理論は、真実であることがわかりません。
Jason R

私は信じていません任意の近代的なC ++コンパイラでコンパイル時に一時は、この例のために作成されます。これを示す簡単な例を作成し、投稿を更新しました。一般に、式テンプレートの手法の価値に異論はありませんが、上に示したような単純な単一演算子式には関係ありません。
Bill Greene

@BillGreene:コンストラクター、コピーコンストラクター、代入演算子、およびデストラクターを使用してクラスを作成し、例をコンパイルします。一時ファイルが作成されていることがわかります。また、コピー演算子、コンストラクタ、デストラクタをマージしないとコンパイラで削除できないため、作成する必要があります。これは、メモリ割り当てなどの重要な操作では不可能です。
Wolfgang Bangerth

5

Stackoverflow(https://stackoverflow.com/)は、おそらくこの質問のためのより良いディスカッションフォーラムです。しかし、ここに短い答えがあります。

上で説明したように、C ++コンパイラがこの式のコードを生成しているとは思いません。最新のC ++コンパイラーはすべて、「戻り値の最適化」と呼ばれる最適化を実装しています(http://en.wikipedia.org/wiki/Return_value_optimization)。戻り値を最適化すると、の結果evolutionMatrix*stateMatrixはに直接格納されstateMatrixます。コピーは作成されません。

このトピックには明らかにかなりの混乱があり、それがStackoverflowの方が優れたフォーラムになる可能性があると私が提案した理由の1つです。そこには多くのC ++の「言語弁護士」がいますが、ここで私たちのほとんどはCSEに時間を費やしたいと思っています。;-)

バンガース教授の投稿に基づいて、次の簡単な例を作成しました。

#ifndef NDEBUG
#include <iostream>

using namespace std;
#endif

class ExpensiveObject  {
public:
  ExpensiveObject () {
#ifndef NDEBUG
    cout << "ExpensiveObject  constructor called." << endl;
#endif
    v = 0;
  }
  ExpensiveObject (int i) { 
#ifndef NDEBUG
    cout << "ExpensiveObject  constructor(int) called." << endl;
#endif
    v = i; 
  }
  ExpensiveObject (const ExpensiveObject  &a) {
    v = a.v;
#ifndef NDEBUG
    cout << "ExpensiveObject  copy constructor called." << endl;
#endif
  }
  ~ExpensiveObject() {
#ifndef NDEBUG
    cout << "ExpensiveObject  destructor called." << endl;
#endif
  }
  ExpensiveObject  operator=(const ExpensiveObject  &a) {
#ifndef NDEBUG
    cout << "ExpensiveObject  assignment operator called." << endl;
#endif
    if (this != &a) {
      return ExpensiveObject (a);
    }
  }
  void print() const {
#ifndef NDEBUG
    cout << "v=" << v << endl;
#endif
  }
  int getV() const {
    return v;
  }
private:
  int v;
};

ExpensiveObject  operator+(const ExpensiveObject  &a1, const ExpensiveObject  &a2) {
#ifndef NDEBUG
  cout << "ExpensiveObject  operator+ called." << endl;
#endif
  return ExpensiveObject (a1.getV() + a2.getV());
}

int main()
{
  ExpensiveObject  a(2), b(3);
  ExpensiveObject  c = a + b;
#ifndef NDEBUG
  c.print();
#endif
}

最適化モードでコンパイルするときに、出力を出力するためのすべてのコードを完全に削除したかったため、実際よりも複雑に見えます。デバッグオプションでコンパイルしたバージョンを実行すると、次の出力が表示されます。

ExpensiveObject  constructor(int) called.
ExpensiveObject  constructor(int) called.
ExpensiveObject  operator+ called.
ExpensiveObject  constructor(int) called.
v=5
ExpensiveObject  destructor called.
ExpensiveObject  destructor called.
ExpensiveObject  destructor called.

通知に最初のものは、ということです何の一時のみ、B、およびCをconstructed--されていません。デフォルトのコンストラクターと代入演算子は、この例では必要ないため、呼び出されることはありません。

バンガース教授は表現テンプレートについて言及しました。確かに、この最適化手法は、マトリックスクラスライブラリで良好なパフォーマンスを得るために非常に重要です。ただし、オブジェクト式が単にa + bよりも複雑な場合にのみ重要です。たとえば、私のテストが代わりだった場合:

  ExpensiveObject  a(2), b(3), c(9);
  ExpensiveObject  d = a + b + c;

次の出力が得られます。

ExpensiveObject  constructor(int) called.
 ExpensiveObject  constructor(int) called.
 ExpensiveObject  constructor(int) called.
 ExpensiveObject  operator+ called.
 ExpensiveObject  constructor(int) called.
 ExpensiveObject  operator+ called.
 ExpensiveObject  constructor(int) called.
 ExpensiveObject  destructor called.
 v=14
 ExpensiveObject  destructor called.
 ExpensiveObject  destructor called.
 ExpensiveObject  destructor called.
 ExpensiveObject  destructor called.

このケースは、一時的な構成(コンストラクターへの5つの呼び出しと演算子+の2つの呼び出し)の望ましくない構成を示しています。式テンプレート(このフォーラムの範囲をはるかに超えるトピック)の適切な使用は、これを一時的に防止します。(非常にやる気のある人のために、式テンプレートの特に読みやすい議論はhttp://www.amazon.com/C-Templates-The-Complete-Guide/dp/0201734842の第18章にあります)。

最後に、コンパイラが実際に行っていることの実際の「証明」は、コンパイラによって出力されたアセンブリコードを調べることから得られます。最初の例では、最適化モードでコンパイルした場合、このコードは驚くほど単純です。すべての関数呼び出しは最適化され、アセンブリコードは基本的に2を1つのレジスタに、3を1つのレジスタにロードし、それらを追加します。


私は実際にここまたはスタックオーバーフローに配置することをためらっていました...私がスタックオーバーフローに配置した場合、誰かが私がここに配置する必要があるとコメントしていると思います:-)。とにかく; 戻り値の最適化は朗報であり、以前は知りませんでした(+1)。それをありがとう。残念ながら、アセンブリコードについては何も知りません。そのため、私が実行できるチェックではありません。
量子物理学者

1
私が間違っていない場合は、戻り値の最適化を考慮しても、コンパイラはメモリ内の2つではなく3つの行列を処理します。「AとBを乗算し、結果をCに入れる」は、「AとBを乗算し、結果でBを上書きする」とは異なる関数です。
Federico Poloni、2015

興味深い点。私はポスターの要望に焦点を合わせていました。行列の乗算の実装が彼のmultiply()関数と同じくらい効率的ですが、乗算演算子の優れたオーバーロードが必要です。3つの行列なしで一般的な行列乗算を実装する方法はありますか?もちろん、RVOを使用すると、出力行列のコピーを作成する必要がなくなります。
ビルグリーン

@BillGreeneの戻り値の最適化への参照は、2番目の一時変数の必要性を回避するだけですが、それでも必要です。これについては別の答えでコメントします。
Wolfgang Bangerth 2015

1
@BillGreene:あなたの例は単純すぎる。コンパイラーが対処しなければならない副作用がないため、一部の割り当て、一時ファイルの作成などを最適化することが可能です。本質的には、単一のスカラーで作業しているだけです。単一のスカラーではなく、クラスがメモリの割り当てと削除を必要とする例を試してください。このケースでは、呼び出す必要がありますmallocし、freeコンパイラは、メモリ等のモニターをトリップすることなく、それらのペアを離れて最適化することはできません
ヴォルフガングBangerthを

5

O(n2.8O2

つまり、コピーに膨大な定数が発生しない限り、実際にはそれほどフェッチされません。コピー付きのバージョン、別の点ではるかにコストがかかるためです。それは、より多くのメモリが必要です。したがって、ハードディスクとの間でスワップする必要がある場合は、コピーが実際にボトルネックになる可能性があります。ただし、自分で何もコピーしない場合でも、強力に並列化されたアルゴリズムは、独自のコピーをいくつか実行する場合があります。実際、各ステップでメモリが多すぎないことを確認する唯一の方法は、乗算をのstateMatrixに分割することです。そのため、一度に小さな乗​​算のみが実行されます。たとえば、次のように定義できます。

class HChunkMatrix // optimised for destructive left-multiplication updates
{
  std::vector<arma::cx_mat> colChunks; // e.g. for an m×n matrix,
                                      //  use √n chunks, each an m×√n matrix
 public:
  ...

  HChunkMatrix& operator *= (const arma::cx_mat& lhMult) {
    for (&colChunk: colChunks) {
      colChunk = lhMult * colChunk;
    }
    return *this;
  }
}

また、そもそもそれstateMatrixを1つに発展させる必要があるかどうかについても検討する必要があります。基本的には、独立してn状態キットの時間発展が必要な場合、それらを1つずつ進化させることもできます。これにより、メモリコストが大幅に削減されます。特にevolutionMatrixsparseの場合、必ず確認する必要があります。これは基本的にはハミルトニアンに過ぎませんね。ハミルトニアンは、しばしばスパースまたはほぼスパースです。


O2.38


1
これが最良の答えです。他の人は、行列の乗算は実際にはその場で行うようなことではないという重要な点を見落とします。

5

最新のC ++には、「移動コンストラクター」と「右辺値参照」を使用することによる問題の解決策があります。

「移動コンストラクター」は、クラスのコンストラクターです。たとえば、マトリックスクラスは、同じクラスの別のインスタンスを受け取り、データを他のインスタンスから新しいインスタンスに移動して、元のインスタンスを空のままにします。通常、行列オブジェクトには、サイズとデータへのポインターの2つの数値があります。通常のコンストラクターがデータを複製する場合、移動コンストラクターは2つの数値とポインターのみをコピーするため、これは非常に高速です。

通常の "matrix&"の代わりに "matrix &&"のように書かれた "rvalue参照"が一時変数に使用されます。行列の乗算を行列&&を返すものとして宣言します。それを行うことにより、コンパイラーは、非常に安価なmoveコンストラクターを使用して、それを呼び出す関数から結果を取得するようにします。したがって、result =(a + b)*(c + d)のような式(a、b、c、dは巨大な行列オブジェクト)は、コピーせずに発生します。

「右辺値参照と移動コンストラクター」をグーグル検索すると、例とチュートリアルが見つかります。


0

vMMMMMMMMMMv

次に、OpenBLASにはアーキテクチャ固有の最適化のより大きなコレクションがあるので、Eigenがあなたにとって有利な場合とそうでない場合があります。残念ながら、線形代数ライブラリーは非常に優れているため、パフォーマンスの「最後の10%」を目指して戦うときに、他のライブラリーを考慮する必要すらありません。ラッパーは100%のソリューションではありません。それらのほとんど(すべて?)は、この方法で計算をマージするeigenの機能を利用できません。


より洗練された処理を行うアプリケーション固有のライブラリがあることに注意してください。画像合成用のAppleのAPIは、eigenが行うのと同様のことを行い、さらに計算をGPUにマッピングすると思います...そして、オーディオストリームライブラリが同様の最適化を行うと思います...
Andrew Wagner
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.