私が取り組んでいるプログラムでは、2つの行列を繰り返し乗算する必要があります。行列の1つがサイズであるため、この操作には時間がかかり、どの方法が最も効率的かを確認したいと思いました。行列の次元は_(m x n)*(n x p)
_です。ここで_m = n = 3
_および_10^5 < p < 10^6
_です。
最適化されたアルゴリズムで動作すると私が想定しているNumpyを除いて、すべてのテストは 行列乗算 の単純な実装で構成されています。
以下は私のさまざまな実装です。
Python
_def dot_py(A,B):
m, n = A.shape
p = B.shape[1]
C = np.zeros((m,p))
for i in range(0,m):
for j in range(0,p):
for k in range(0,n):
C[i,j] += A[i,k]*B[k,j]
return C
_
Numpy
_def dot_np(A,B):
C = np.dot(A,B)
return C
_
Numba
コードはPython 1と同じですが、使用される前にちょうど間に合うようにコンパイルされます。
_dot_nb = nb.jit(nb.float64[:,:](nb.float64[:,:], nb.float64[:,:]), nopython = True)(dot_py)
_
これまでのところ、各メソッド呼び出しはtimeit
モジュールを使用して10回計測されています。最良の結果が維持されます。行列はnp.random.Rand(n,m)
を使用して作成されます。
C++
_mat2 dot(const mat2& m1, const mat2& m2)
{
int m = m1.rows_;
int n = m1.cols_;
int p = m2.cols_;
mat2 m3(m,p);
for (int row = 0; row < m; row++) {
for (int col = 0; col < p; col++) {
for (int k = 0; k < n; k++) {
m3.data_[p*row + col] += m1.data_[n*row + k]*m2.data_[p*k + col];
}
}
}
return m3;
}
_
ここで、_mat2
_は私が定義したカスタムクラスであり、dot(const mat2& m1, const mat2& m2)
はこのクラスのフレンド関数です。 _Windows.h
_からQPF
およびQPC
を使用してタイミングが調整され、プログラムは_g++
_コマンドでMinGWを使用してコンパイルされます。この場合も、10回の実行から得られた最良の時間が保持されます。
結果
予想どおり、単純なPythonコードは低速ですが、非常に小さい行列の場合はNumpyよりも優れています。最大の場合のNumbaはNumpyよりも約30%高速であることがわかります。
Numbaよりも乗算にほぼ1桁長い時間がかかるC++の結果に驚いています。実際、私はこれらに同じくらいの時間がかかると思っていました。
これは私の主な質問につながります:これは正常ですか?そうでない場合、なぜC++はNumbaよりも遅いのですか?私はC++を学び始めたばかりなので、何か間違ったことをしている可能性があります。もしそうなら、私の間違いは何でしょうか、またはコードの効率を改善するために何ができますか(より良いアルゴリズムを選択する以外に)?
編集1
これが_mat2
_クラスのヘッダーです。
_#ifndef MAT2_H
#define MAT2_H
#include <iostream>
class mat2
{
private:
int rows_, cols_;
float* data_;
public:
mat2() {} // (default) constructor
mat2(int rows, int cols, float value = 0); // constructor
mat2(const mat2& other); // copy constructor
~mat2(); // destructor
// Operators
mat2& operator=(mat2 other); // assignment operator
float operator()(int row, int col) const;
float& operator() (int row, int col);
mat2 operator*(const mat2& other);
// Operations
friend mat2 dot(const mat2& m1, const mat2& m2);
// Other
friend void swap(mat2& first, mat2& second);
friend std::ostream& operator<<(std::ostream& os, const mat2& M);
};
#endif
_
編集2
多くの人が示唆しているように、最適化フラグを使用することは、Numbaに一致する欠落した要素でした。以下は、以前の曲線と比較した新しい曲線です。 _v2
_とタグ付けされた曲線は、2つの内部ループを切り替えることによって得られ、さらに30%から50%の改善を示しています。
最適化には必ず-O3
を使用してください。これにより、 vectorizations がオンになり、コードが大幅に高速化されます。
Numbaはすでにそれを行うことになっています。
最大の効率が必要な場合は、専用の線形代数ライブラリを使用する必要があります。そのclassicは [〜#〜] blas [〜 #〜] / [〜#〜] lapack [〜#〜] ライブラリ。いくつかの実装があります。 インテル®MKL 。あなたが書いているのは[〜#〜]ではなく[〜#〜]、超最適化されたライブラリよりも優れているということです。
行列行列の乗算はdgemm
ルーチンになります。dはdoubleを表し、geは一般を表し、mmは行列行列の乗算を表します。問題に追加の構造がある場合は、より具体的な関数を呼び出してさらに高速化することができます。
Numpydotはすでにdgemm
を呼び出していることに注意してください!あなたはおそらくもっとうまくやるつもりはないでしょう。
行列-行列乗算の古典的で直感的なアルゴリズムは、可能なものに比べて遅いことがわかります。プロセッサのキャッシュなどを利用するコードを作成すると、パフォーマンスが大幅に向上します。重要なのは、多くの賢い人々が行列行列を非常に速く乗算することに人生を捧げてきたということです。あなたは彼らの仕事を使うべきであり、車輪の再発明をするべきではありません。
現在の実装では、コンパイラはサイズが3であるため、最も内側のループを自動ベクトル化できない可能性があります。また、m2
は「ジャンピー」な方法でアクセスされます。 p
を反復することが最も内側のループになるようにループを交換すると、ループの動作が速くなり(col
は「ジャンピー」なデータアクセスを行いません)、コンパイラーはより良い仕事をすることができるはずです(autovectorize )。
for (int row = 0; row < m; row++) {
for (int k = 0; k < n; k++) {
for (int col = 0; col < p; col++) {
m3.data_[p*row + col] += m1.data_[n*row + k] * m2.data_[p*k + col];
}
}
}
私のマシンでは、g++ dot.cpp -std=c++11 -O3 -o dot
フラグを使用してビルドされたp = 10 ^ 6要素の元のC++実装は12ms
を取り、スワップされたループを使用した上記の実装は7ms
を取ります。