Numpyを使用して重い数値演算を行う分析コードがあります。好奇心のために、ほとんど変更を加えずにcythonでコンパイルしてみましたが、その後、派手な部分のループを使用して書き直しました。
驚いたことに、ループに基づくコードははるかに高速(8倍)でした。完全なコードをポストすることはできませんが、同様の動作を示す非常に単純な無関係の計算をまとめました(ただし、タイミングの差はそれほど大きくありません)。
バージョン1(cythonなし)
import numpy as np
def _process(array):
rows = array.shape[0]
cols = array.shape[1]
out = np.zeros((rows, cols))
for row in range(0, rows):
out[row, :] = np.sum(array - array[row, :], axis=0)
return out
def main():
data = np.load('data.npy')
out = _process(data)
np.save('vianumpy.npy', out)
バージョン2(cythonを使用したモジュールのビルド)
import cython
cimport cython
import numpy as np
cimport numpy as np
DTYPE = np.float64
ctypedef np.float64_t DTYPE_t
@cython.boundscheck(False)
@cython.wraparound(False)
@cython.nonecheck(False)
cdef _process(np.ndarray[DTYPE_t, ndim=2] array):
cdef unsigned int rows = array.shape[0]
cdef unsigned int cols = array.shape[1]
cdef unsigned int row
cdef np.ndarray[DTYPE_t, ndim=2] out = np.zeros((rows, cols))
for row in range(0, rows):
out[row, :] = np.sum(array - array[row, :], axis=0)
return out
def main():
cdef np.ndarray[DTYPE_t, ndim=2] data
cdef np.ndarray[DTYPE_t, ndim=2] out
data = np.load('data.npy')
out = _process(data)
np.save('viacynpy.npy', out)
バージョン3(cythonでモジュールをビルド)
import cython
cimport cython
import numpy as np
cimport numpy as np
DTYPE = np.float64
ctypedef np.float64_t DTYPE_t
@cython.boundscheck(False)
@cython.wraparound(False)
@cython.nonecheck(False)
cdef _process(np.ndarray[DTYPE_t, ndim=2] array):
cdef unsigned int rows = array.shape[0]
cdef unsigned int cols = array.shape[1]
cdef unsigned int row
cdef np.ndarray[DTYPE_t, ndim=2] out = np.zeros((rows, cols))
for row in range(0, rows):
for col in range(0, cols):
for row2 in range(0, rows):
out[row, col] += array[row2, col] - array[row, col]
return out
def main():
cdef np.ndarray[DTYPE_t, ndim=2] data
cdef np.ndarray[DTYPE_t, ndim=2] out
data = np.load('data.npy')
out = _process(data)
np.save('vialoop.npy', out)
10000x10の行列がdata.npyに保存されている場合、時間は次のとおりです。
$ python -m timeit -c "from version1 import main;main()"
10 loops, best of 3: 4.56 sec per loop
$ python -m timeit -c "from version2 import main;main()"
10 loops, best of 3: 4.57 sec per loop
$ python -m timeit -c "from version3 import main;main()"
10 loops, best of 3: 2.96 sec per loop
これは予期されたものですか、それとも欠けている最適化はありますか?バージョン1と2で同じ結果が得られるという事実はどういうわけか予想されますが、なぜバージョン3の方が速いのですか?
Ps.-これは私が行う必要がある計算ではなく、同じことを示す単純な例です。
他の回答で述べたように、cythonは最適化するために配列アクセス演算子を掘り下げることができないので、バージョン2は基本的にバージョン1と同じです。これには2つの理由があります
まず、最適化されたCコードと比較して、numpy関数への各呼び出しにはある程度のオーバーヘッドがあります。ただし、各操作が大きな配列を処理する場合、このオーバーヘッドはそれほど重要ではなくなります。
次に、中間配列の作成があります。 out[row, :] = A[row, :] + B[row, :]*C[row, :]
などのより複雑な操作を検討すると、これはより明確になります。この場合、配列全体B*C
をメモリに作成してから、A
に追加する必要があります。これは、データがCPUに保持されてすぐに使用されるのではなく、メモリから読み書きされるため、CPUキャッシュがスラッシングされていることを意味します。重要なのは、この問題が大きな配列を処理している場合に悪化することです。
特に、実際のコードは例よりも複雑で、はるかに高速化されていると述べているので、2番目の理由が主な原因であると考えられます。
余談ですが、計算が十分に単純な場合は、 numexpr を使用することでこの影響を克服できますが、もちろん、cythonはより多くの状況で有用であるため、より良いアプローチとなる可能性があります。
わずかな変更により、バージョン3は2倍の速度になります。
@cython.boundscheck(False)
@cython.wraparound(False)
@cython.nonecheck(False)
def process2(np.ndarray[DTYPE_t, ndim=2] array):
cdef unsigned int rows = array.shape[0]
cdef unsigned int cols = array.shape[1]
cdef unsigned int row, col, row2
cdef np.ndarray[DTYPE_t, ndim=2] out = np.empty((rows, cols))
for row in range(rows):
for row2 in range(rows):
for col in range(cols):
out[row, col] += array[row2, col] - array[row, col]
return out
計算のボトルネックはメモリアクセスです。入力配列はC順です。つまり、最後の軸に沿って移動すると、メモリ内で最小のジャンプが行われます。したがって、内部ループは軸0ではなく軸1に沿っている必要があります。この変更を行うと、実行時間が半分になります。
小さな入力配列でこの関数を使用する必要がある場合は、np.empty
の代わりにnp.ones
を使用することにより、オーバーヘッドを削減できます。オーバーヘッドをさらに減らすには、numpy C APIのPyArray_EMPTY
を使用します。
この関数を非常に大きな入力配列(2 ** 31)で使用すると、インデックス付け(およびrange
関数で)に使用される整数がオーバーフローします。安全に使用するために:
cdef Py_ssize_t rows = array.shape[0]
cdef Py_ssize_t cols = array.shape[1]
cdef Py_ssize_t row, col, row2
の代わりに
cdef unsigned int rows = array.shape[0]
cdef unsigned int cols = array.shape[1]
cdef unsigned int row, col, row2
タイミング:
In [2]: a = np.random.Rand(10000, 10)
In [3]: timeit process(a)
1 loops, best of 3: 3.53 s per loop
In [4]: timeit process2(a)
1 loops, best of 3: 1.84 s per loop
ここで、process
はバージョン3です。
-aフラグを使用して、cythonが何が純粋なcに変換されているかを示すhtmlファイルを生成するか、python API:
http://docs.cython.org/src/quickstart/cythonize.html
バージョン2はバージョン1とほぼ同じ結果をもたらします。なぜなら、すべての重い作業はPython API(numpyを介して)によって行われており、cythonは何もしていません。実際には私のマシン、numpyはMKLに対してビルドされているため、gccを使用してcythonで生成されたcコードをコンパイルすると、バージョン3は実際には他の2つよりも少し遅くなります。
Cythonは、numpyが「ベクトル化された」方法では実行できない配列操作を行っているとき、または大きな一時配列の作成を回避できるようにメモリを集中的に使用しているときに光ります。私は自分のコードのいくつかにcythonとnumpyを使用して115倍の高速化を実現しました。
https://github.com/synapticarbors/pylangevin-integrator
その一部は、numpy.random
を介して呼び出すのではなく、cコードのレベルでrandomkitディレクトリを呼び出すことでしたが、そのほとんどは、計算を多用するforループをpythonを呼び出さずに純粋なcに変換するcythonでした。
違いは、バージョン1と2が各行に対してnp.sum()
へのPythonレベルの呼び出しを行っているためである可能性がありますが、バージョン3はおそらくタイトで純粋なCループにコンパイルされます。
バージョン2とバージョン3のCythonで生成されたCソースの違いを研究することは、啓蒙的であるはずです。
あなたが節約している主なオーバーヘッドは、作成された一時的な配列だと思います。あなたは素晴らしい大きな配列を作成しますarray - array[row, :]
の場合、sum
を使用して、より小さな配列に縮小します。ただし、特にメモリを割り当てる必要がある場合は、その大きな一時配列の作成は無料ではありません。