web-dev-qa-db-ja.com

Eigen:コーディングスタイルのパフォーマンスへの影響

私がEigenについて読んだこと( here )から、operator=()は遅延評価の一種の「バリア」として機能しているようです-たとえばこれにより、Eigenは式テンプレートを返さなくなり、実際に(最適化された)計算を実行して、結果を=の左側に格納します。

これは、自分の「コーディングスタイル」がパフォーマンスに影響を与えることを意味しているようです。つまり、名前付き変数を使用して中間計算の結果を保存すると、計算の一部が「早すぎる」と評価され、パフォーマンスに悪影響を及ぼす可能性があります。 。

私の直感を確認するために、私は例を書き、結果に驚いた( 完全なコードはこちら ):

using ArrayXf  = Eigen::Array <float, Eigen::Dynamic, Eigen::Dynamic>;
using ArrayXcf = Eigen::Array <std::complex<float>, Eigen::Dynamic, Eigen::Dynamic>;

float test1( const MatrixXcf & mat )
{
    ArrayXcf arr  = mat.array();
    ArrayXcf conj = arr.conjugate();
    ArrayXcf magc = arr * conj;
    ArrayXf  mag  = magc.real();
    return mag.sum();
}

float test2( const MatrixXcf & mat )
{
    return ( mat.array() * mat.array().conjugate() ).real().sum();
}

float test3( const MatrixXcf & mat )
{
    ArrayXcf magc   = ( mat.array() * mat.array().conjugate() );

    ArrayXf mag     = magc.real();
    return mag.sum();
}

上記は、複素数値行列の係数の大きさの合計を計算する3つの異なる方法を示しています。

  1. test1は、計算の各部分を「一度に1ステップ」ずつ実行します。
  2. test2は、計算全体を1つの式で実行します。
  3. test3は、いくつかの中間変数を使用して、「ブレンドされた」アプローチを取ります。

test2は計算全体を1つの式にパックするため、Eigenはそれを利用して計算全体をグローバルに最適化し、最高のパフォーマンスを提供できると期待しています。

ただし、結果は驚くべきものでした(表示されている数値は、各テストの1000回の実行における合計マイクロ秒単位です)。

test1_us: 154994
test2_us: 365231
test3_us: 36613

(これはg ++ -O3でコンパイルされました-詳細は the Gist を参照してください。)

私が最速であると予想したバージョン(test2)は、実際には最も低速でした。また、私が最も遅いと予想したバージョン(test1)は、実際には中間にありました。

だから、私の質問は:

  1. なぜtest3は他の選択肢よりもパフォーマンスが優れているのですか?
  2. Eigenが実際に計算をどのように実装しているかをある程度把握するために(アセンブリコードに飛び込むことなく)使用できる手法はありますか?
  3. 固有コードのパフォーマンスと可読性(中間変数の使用)の間の適切なトレードオフを実現するために従うべき一連のガイドラインはありますか?

より複雑な計算では、1つの式ですべてを実行すると読みやすさが低下する可能性があるため、読みやすくてパフォーマンスの高いコードを記述する正しい方法を見つけることに興味があります。

37
jeremytrimble

GCCの問題のようです。インテルコンパイラーは期待される結果を提供します。

_$ g++ -I ~/program/include/eigen3 -std=c++11 -O3 a.cpp -o a && ./a
test1_us: 200087
test2_us: 320033
test3_us: 44539

$ icpc -I ~/program/include/eigen3 -std=c++11 -O3 a.cpp -o a && ./a
test1_us: 214537
test2_us: 23022
test3_us: 42099
_

icpcバージョンと比較して、gccは_test2_の最適化に問題があるようです。

より正確な結果を得るには、 ここ のように_-DNDEBUG_によるデバッグアサーションをオフにすることができます。

[〜#〜]編集[〜#〜]

質問1の場合

@ggaelは、gccが合計ループのベクトル化に失敗するという優れた答えを提供します。私の実験でも、_test2_は手書きの単純なforループと同じくらい高速であり、どちらもgcciccであり、ベクトル化が理由であり、一時的なメモリ割り当てが_test2_で検出されないことを示唆しています。下記の方法は、Eigenが式を正しく評価することを示唆しています。

質問2の場合

中間メモリを回避することが、Eigenが式テンプレートを使用する主な目的です。したがって、Eigenはマクロ EIGEN_RUNTIME_NO_MALLOC と、式の計算中に中間メモリが割り当てられているかどうかを確認できるようにする簡単な関数を提供します。サンプルコード here を見つけることができます。これはデバッグモードでのみ機能することに注意してください。

EIGEN_RUNTIME_NO_MALLOC-定義されている場合、set_is_malloc_allowed(bool)を呼び出すことでオンとオフを切り替えることができる新しいスイッチが導入されます。 mallocが許可されておらず、Eigenがメモリを動的に割り当てようとすると、アサーションエラーが発生します。デフォルトでは定義されていません。

質問3について

中間変数を使用する方法と、遅延評価/式テンプレートによって同時に導入されるパフォーマンスの向上を実現する方法があります。

正しいデータ型の中間変数を使用する方法です。評価するように式に指示する_Eigen::Matrix/Array_を使用する代わりに、式タイプ_Eigen::MatrixBase/ArrayBase/DenseBase_を使用して、式がバッファされるだけで評価されないようにする必要があります。これは、式の結果ではなく、式を中間として保存する必要があることを意味します。この中間は、次のコードで1回だけ使用されることを条件とします。

式タイプ_Eigen::MatrixBase/..._のテンプレートパラメータを決定するのは面倒なので、代わりにautoを使用できます。 このページauto/expression型を使用する/使用しない場合のヒントを見つけることができます。 別のページ は、式を評価せずに関数パラメーターとして渡す方法も示しています。

@ggaelの回答の.abs2()に関する有益な実験によると、別のガイドラインは、ホイールの再発明を避けることです。

15
kangshiyin

何が起こるかというと、.real()ステップのために、Eigenは明示的にtest2をベクトル化しません。したがって、標準のcomplex :: operator *演算子を呼び出しますが、これは残念ながらgccによってインライン化されることはありません。一方、他のバージョンでは、Eigen独自のベクトル化された複合製品の実装を使用します。

対照的に、ICCはcomplex :: operator *をインライン化するため、test2はICCの最高速になります。 test2は次のように書き換えることもできます。

return mat.array().abs2().sum();

すべてのコンパイラでさらに良いパフォーマンスを得るには:

gcc:
test1_us: 66016
test2_us: 26654
test3_us: 34814

icpc:
test1_us: 87225
test2_us: 8274
test3_us: 44598

clang:
test1_us: 87543
test2_us: 26891
test3_us: 44617

この場合のICCの非常に優れたスコアは、巧妙な自動ベクトル化エンジンによるものです。

test2を変更せずにgccのインライン化の失敗を回避する別の方法は、operator*に独自のcomplex<float>を定義することです。たとえば、ファイルの先頭に次を追加します。

namespace std {
  complex<float> operator*(const complex<float> &a, const complex<float> &b) {
    return complex<float>(real(a)*real(b) - imag(a)*imag(b), imag(a)*real(b) + real(a)*imag(b));
  }
}

そして私は得る:

gcc:
test1_us: 69352
test2_us: 28171
test3_us: 36501

icpc:
test1_us: 93810
test2_us: 11350
test3_us: 51007

clang:
test1_us: 83138
test2_us: 26206
test3_us: 45224

もちろん、grickバージョンとは異なり、オーバーフローまたは数値のキャンセルの問題につながる可能性があるため、このトリックは常に推奨されるわけではありませんが、icpcおよび他のベクトル化されたバージョンがとにかく計算するものです。

14
ggael

私が以前にやったことの1つは、autoキーワードをたくさん利用することです。ほとんどの固有式は特別な式のデータ型(例:CwiseBinaryOp)を返すことに注意してください。Matrixへの代入を代入すると、式が強制的に評価される場合があります(これは表示されているものです)。 autoを使用すると、コンパイラは戻り値の型を任意の式の型として推定できます。これにより、可能な限り評価が回避されます。

float test1( const MatrixXcf & mat )
{
    auto arr  = mat.array();
    auto conj = arr.conjugate();
    auto magc = arr * conj;
    auto mag  = magc.real();
    return mag.sum();
}

これは基本的に2番目のテストケースに近いはずです。場合によっては、読みやすさを保ちながらパフォーマンスを向上させることができました(式テンプレートの種類を詳しく説明する必要がありますnotにします)。もちろん、走行距離は異なる場合がありますので、慎重にベンチマークしてください:)

5
mindriot

私はあなたが最適ではない方法でプロファイリングを行ったことに注意してほしいので、実際には問題はあなたのプロファイリング方法である可能性があります。

考慮に入れるべきキャッシュの局所性のような多くのものがあるので、あなたはそのようにプロファイリングを行うべきです:

int warmUpCycles = 100;
int profileCycles = 1000;

// TEST 1
for(int i=0; i<warmUpCycles ; i++)
      doTest1();

auto tick = std::chrono::steady_clock::now();
for(int i=0; i<profileCycles ; i++)
      doTest1();  
auto tock = std::chrono::steady_clock::now();
test1_us = (std::chrono::duration_cast<std::chrono::microseconds>(tock-tick)).count(); 

// TEST 2


// TEST 3

適切な方法でテストを行ったら、結論に達することができます。

一度に1つの操作をプロファイリングしているため、コンパイラーによって操作が並べ替えられる可能性が高いため、3番目のテストでキャッシュバージョンを使用することになると思います。

また、別のコンパイラを試して、問題がテンプレートの展開にあるかどうかを確認する必要があります(テンプレートの最適化には深さの制限があります。単一の大きな式でヒットする可能性があります)。

また、Eigenが移動セマンティクスをサポートしている場合、式を最適化できるとは必ずしも保証されていないため、1つのバージョンが高速である必要がある理由はありません。

ぜひお試しください。面白いですね。また、-O3などのフラグを使用して最適化を有効にしてください。最適化なしのプロファイリングは無意味です。

コンパイラーがすべてを最適化しないようにするために、ファイルまたはcinからの初期入力を使用し、関数内の入力を再フィードします。

0
CoffeDeveloper