私は、ヤコビアンを計算するための自動導関数を持つC++コードの大部分を高速化する方法を調査しています。これには、実際の残差である程度の作業を行う必要がありますが、作業の大部分は(プロファイルされた実行時間に基づいて)ヤコビアンの計算にあります。
驚いたことに、ほとんどのヤコビアンは0と1から前方に伝播されるため、作業量は関数の10〜12倍ではなく2〜4倍になります。大量のヤコビの仕事がどのようなものであるかをモデル化するために、コンパイラができるはずのドット積(sin、cos、sqrtなどの実際の状況ではなく)だけで超最小の例を作成しました単一の戻り値に最適化するには:
#include <Eigen/Core>
#include <Eigen/Geometry>
using Array12d = Eigen::Matrix<double,12,1>;
double testReturnFirstDot(const Array12d& b)
{
Array12d a;
a.array() = 0.;
a(0) = 1.;
return a.dot(b);
}
と同じである必要があります
double testReturnFirst(const Array12d& b)
{
return b(0);
}
高速演算が有効になっていないと、GCC 8.2、Clang 6、またはMSVC 19のいずれも、0で満たされたマトリックスを使用した単純な内積に対して最適化を行えなかったことに失望しました。 fast-math( https://godbolt.org/z/GvPXFy )であっても、GCCおよびClangでの最適化は非常に貧弱(まだ乗算と加算を伴う)であり、MSVCは最適化を行いませんまったく。
コンパイラの背景はありませんが、これには理由がありますか?科学的計算の大部分では、定数の折り畳み自体が高速化をもたらさなかったとしても、より良い定数の伝播/折り畳みを行うことができれば、より多くの最適化が明らかになると確信しています。
これがコンパイラー側で行われない理由の説明に興味がありますが、これらの種類のパターンに直面したときに自分のコードを高速化するために実用的な面でできることにも興味があります。
これは、Eigenがコードを3つのvmulpd、2つのvaddpd、および残りの4つのコンポーネントレジスタ内の1つの水平縮小として明示的にベクトル化するためです(これはAVXを想定し、SSEのみで6つのmulpdと5つのaddpdを取得します)。 -ffast-math
を使用すると、GCCとclangは最後の2つのvmulpdとvaddpdを削除できますが(これが行うことです)、Eigenによって明示的に生成された残りのvmulpdと水平縮小を実際に置き換えることはできません。
では、EIGEN_DONT_VECTORIZE
を定義してEigenの明示的なベクトル化を無効にするとどうなりますか?そうすると、期待どおりの結果が得られます( https://godbolt.org/z/UQsoeH )が、他のコードはかなり遅くなる可能性があります。
明示的なベクトル化をローカルで無効にし、Eigenの内部をいじることを恐れない場合は、DontVectorize
にMatrix
オプションを導入し、このMatrix
タイプにtraits<>
を特化することでベクトル化を無効にできます。
static const int DontVectorize = 0x80000000;
namespace Eigen {
namespace internal {
template<typename _Scalar, int _Rows, int _Cols, int _MaxRows, int _MaxCols>
struct traits<Matrix<_Scalar, _Rows, _Cols, DontVectorize, _MaxRows, _MaxCols> >
: traits<Matrix<_Scalar, _Rows, _Cols> >
{
typedef traits<Matrix<_Scalar, _Rows, _Cols> > Base;
enum {
EvaluatorFlags = Base::EvaluatorFlags & ~PacketAccessBit
};
};
}
}
using ArrayS12d = Eigen::Matrix<double,12,1,DontVectorize>;
高速演算が有効になっていないと、GCC 8.2、Clang 6、またはMSVC 19のいずれも、0で満たされたマトリックスを使用した単純な内積に対して最適化を行えなかったことに失望しました。
残念ながら他の選択肢はありません。 IEEE浮動小数点は符号付きゼロを持っているため、0.0
の追加はID操作ではありません。
-0.0 + 0.0 = 0.0 // Not -0.0!
同様に、ゼロを乗算しても常にゼロになるとは限りません。
0.0 * Infinity = NaN // Not 0.0!
そのため、コンパイラはIEEE浮動小数点コンプライアンスを維持しながら、ドット積でこれらの定数フォールドを実行できません-知っている限り、入力には符号付きゼロや無限大が含まれている可能性があります。
これらのフォールドを取得するには-ffast-math
を使用する必要がありますが、望ましくない結果になる可能性があります。特定のフラグ( http://gcc.gnu.org/wiki/FloatingPointMath から)を使用して、よりきめ細かな制御を取得できます。上記の説明によれば、次の2つのフラグを追加すると、定数の折りたたみが可能になります。-ffinite-math-only
、-fno-signed-zeros
実際、次のように-ffast-math
と同じアセンブリを取得します: https://godbolt.org/z/vGULLA 。符号付きゼロ(おそらく無関係)、NaN、および無限大のみを放棄します。おそらく、コード内でまだ生成する場合、未定義の動作が発生するため、オプションを検討してください。
-ffast-math
を使用しても例が最適化されない理由については、Eigenにあります。おそらく、行列演算にベクトル化があり、コンパイラーが透けて見えるのははるかに困難です。次のオプションを使用して、単純なループが適切に最適化されます。 https://godbolt.org/z/OppEhY
コンパイラに0と1の乗算を最適化させる1つの方法は、ループを手動で展開することです。簡単にするために使用しましょう
#include <array>
#include <cstddef>
constexpr std::size_t n = 12;
using Array = std::array<double, n>;
次に、フォールド式を使用して単純なdot
関数を実装できます(または、使用できない場合は再帰)。
<utility>
template<std::size_t... is>
double dot(const Array& x, const Array& y, std::index_sequence<is...>)
{
return ((x[is] * y[is]) + ...);
}
double dot(const Array& x, const Array& y)
{
return dot(x, y, std::make_index_sequence<n>{});
}
では、関数を見てみましょう
double test(const Array& b)
{
const Array a{1}; // = {1, 0, ...}
return dot(a, b);
}
-ffast-math
gcc 8.2の場合 produces :
test(std::array<double, 12ul> const&):
movsd xmm0, QWORD PTR [rdi]
ret
clang 6.0.0は同じ行に沿っています:
test(std::array<double, 12ul> const&): # @test(std::array<double, 12ul> const&)
movsd xmm0, qword ptr [rdi] # xmm0 = mem[0],zero
ret
たとえば、
double test(const Array& b)
{
const Array a{1, 1}; // = {1, 1, 0...}
return dot(a, b);
}
我々が得る
test(std::array<double, 12ul> const&):
movsd xmm0, QWORD PTR [rdi]
addsd xmm0, QWORD PTR [rdi+8]
ret
Addition。 Clangはこれらのすべてのフォールド式のトリックなしでfor (std::size_t i = 0; i < n; ++i) ...
ループを展開しますが、gccはそうではなく、いくつかの助けを必要とします。