web-dev-qa-db-ja.com

なぜこのnumbaコードはnumpyコードより6倍遅いのですか?

次のコードが2秒で実行される理由はありますか、

def euclidean_distance_square(x1, x2):
    return -2*np.dot(x1, x2.T) + np.expand_dims(np.sum(np.square(x1), axis=1), axis=1) + np.sum(np.square(x2), axis=1)

次のnumbaコードは12秒で実行されますか?

@jit(nopython=True)
def euclidean_distance_square(x1, x2):
   return -2*np.dot(x1, x2.T) + np.expand_dims(np.sum(np.square(x1), axis=1), axis=1) + np.sum(np.square(x2), axis=1)

私のx1は次元(1、512)の行列で、x2は次元(3000000、512)の行列です。 numbaが非常に遅くなる可能性があるのはかなり奇妙です。私はそれを間違って使用していますか?

この関数を300万回実行する必要があるので、本当に高速化する必要があります。

X2の次元が非常に大きいことがわかるように、GPU(または少なくとも私のGPU)にロードできず、十分なメモリがないため、これをCPUで実行する必要があります。

14
user2675516

Numbaが非常に遅くなる可能性があるのはかなり奇妙です。

変ではありません。 numP関数内でNumPy関数を呼び出す場合、これらの関数のnumbaバージョンを呼び出します。これらはNumPyバージョンと同じか、それより速くても遅くてもかまいません。あなたは運がいいかもしれませんし、運が悪いかもしれません(運が悪か​​った!)。しかし、NumPy関数(ドットの結果には1つの一時配列、各正方形と合計に1つ、ドットと最初の合計に1つ)を使用するので、numba関数でも多くの一時変数を作成します。 numbaの可能性。

私はそれを間違って使用していますか?

基本的に:はい。

私は本当にこれをスピードアップする必要があります

はい、試してみます。

軸1の呼び出しに沿って平方和を展開することから始めましょう。

import numba as nb

@nb.njit
def sum_squares_2d_array_along_axis1(arr):
    res = np.empty(arr.shape[0], dtype=arr.dtype)
    for o_idx in range(arr.shape[0]):
        sum_ = 0
        for i_idx in range(arr.shape[1]):
            sum_ += arr[o_idx, i_idx] * arr[o_idx, i_idx]
        res[o_idx] = sum_
    return res


@nb.njit
def euclidean_distance_square_numba_v1(x1, x2):
    return -2 * np.dot(x1, x2.T) + np.expand_dims(sum_squares_2d_array_along_axis1(x1), axis=1) + sum_squares_2d_array_along_axis1(x2)

私のコンピューターでは、NumPyコードの2倍、元のNumbaコードの約10倍の速度です。

経験から言えば、NumPyよりも2倍速く取得することが一般的には限界です(少なくともNumPyバージョンが不必要に複雑または非効率的ではない場合)。ただし、すべてを展開することで、もう少し絞り込めます。

import numba as nb

@nb.njit
def euclidean_distance_square_numba_v2(x1, x2):
    f1 = 0.
    for i_idx in range(x1.shape[1]):
        f1 += x1[0, i_idx] * x1[0, i_idx]

    res = np.empty(x2.shape[0], dtype=x2.dtype)
    for o_idx in range(x2.shape[0]):
        val = 0
        for i_idx in range(x2.shape[1]):
            val_from_x2 = x2[o_idx, i_idx]
            val += (-2) * x1[0, i_idx] * val_from_x2 + val_from_x2 * val_from_x2
        val += f1
        res[o_idx] = val
    return res

ただし、最新のアプローチと比較して、10〜20%の改善しか得られません。

その時点で、コードを簡略化できることに気づくかもしれません(おそらく高速化されませんが)。

import numba as nb

@nb.njit
def euclidean_distance_square_numba_v3(x1, x2):
    res = np.empty(x2.shape[0], dtype=x2.dtype)
    for o_idx in range(x2.shape[0]):
        val = 0
        for i_idx in range(x2.shape[1]):
            tmp = x1[0, i_idx] - x2[o_idx, i_idx]
            val += tmp * tmp
        res[o_idx] = val
    return res

ええ、それはかなり単純明快に見え、それほど遅くはありません。

しかし、すべての興奮の中で、私はobviousソリューションに言及するのを忘れていました: scipy.spatial.distance.cdist にはsqeuclidean(二乗ユークリッド距離)オプションがあります。

from scipy.spatial import distance
distance.cdist(x1, x2, metric='sqeuclidean')

それはnumbaよりも実際には高速ではありませんが、独自の関数を記述することなく使用できます...

テスト

正確性をテストし、ウォームアップを行います。

x1 = np.array([[1.,2,3]])
x2 = np.array([[1.,2,3], [2,3,4], [3,4,5], [4,5,6], [5,6,7]])

res1 = euclidean_distance_square(x1, x2)
res2 = euclidean_distance_square_numba_original(x1, x2)
res3 = euclidean_distance_square_numba_v1(x1, x2)
res4 = euclidean_distance_square_numba_v2(x1, x2)
res5 = euclidean_distance_square_numba_v3(x1, x2)
np.testing.assert_array_equal(res1, res2)
np.testing.assert_array_equal(res1, res3)
np.testing.assert_array_equal(res1[0], res4)
np.testing.assert_array_equal(res1[0], res5)
np.testing.assert_almost_equal(res1, distance.cdist(x1, x2, metric='sqeuclidean'))

タイミング:

x1 = np.random.random((1, 512))
x2 = np.random.random((1000000, 512))

%timeit euclidean_distance_square(x1, x2)
# 2.09 s ± 54.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit euclidean_distance_square_numba_original(x1, x2)
# 10.9 s ± 158 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit euclidean_distance_square_numba_v1(x1, x2)
# 907 ms ± 7.11 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit euclidean_distance_square_numba_v2(x1, x2)
# 715 ms ± 15 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit euclidean_distance_square_numba_v3(x1, x2)
# 731 ms ± 34.5 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit distance.cdist(x1, x2, metric='sqeuclidean')
# 706 ms ± 4.99 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

注:整数の配列がある場合は、ハードコードされた0.0 numba関数で0

18
MSeifert

@MSeifertの回答によってこの回答は時代遅れになっているという事実にもかかわらず、numba-versionがnumpy-versionよりも低速であった理由を詳細に説明しているため、私はまだ回答を投稿しています。

後で見るように、主な原因はnumpyとnumbaの異なるメモリアクセスパターンです。

より単純な関数で動作を再現できます。

import numpy as np
import numba as nb

def just_sum(x2):
    return np.sum(x2, axis=1)

@nb.jit('double[:](double[:, :])', nopython=True)
def nb_just_sum(x2):
    return np.sum(x2, axis=1)

x2=np.random.random((2048,2048))

そして今タイミング:

>>> %timeit just_sum(x)
2.33 ms ± 71.9 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
>>> %timeit nb_just_sum(x)
33.7 ms ± 296 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

つまり、numpyは約15倍高速です。

Numbaコードを注釈付きでコンパイルするとき(例:numba --annotate-html sum.html numba_sum.py)numbaによって合計がどのように実行されるかを確認できます(付録の合計のリスト全体を参照):

  1. 結果列を初期化する
  2. 最初の列全体を結果列に追加する
  3. 2番目の列全体を結果列に追加する
  4. 等々

このアプローチの問題は何ですか?メモリレイアウト!配列は行優先順で格納されるため、列方向に読み取ると、行方向に読み取るよりもはるかにキャッシュミスが発生します(これはnumpyが行うことです)。可能なキャッシュ効果を説明する 素晴らしい記事 があります。

見てわかるように、numbaのsum実装はまだあまり成熟していません。ただし、上記の考察から、numbaの実装は列優先順位(つまり、転置行列)で競合する可能性があります。

>>> %timeit just_sum(x.T)
3.09 ms ± 66.6 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
>>> %timeit nb_just_sum(x.T)
3.58 ms ± 45.8 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

そしてそれは本当にです。

@MSeifertのコードが示しているように、numbaの主な利点は、一時的なnumpy配列の数を減らすことができるということです。しかし、簡単に見えるものはまったく簡単ではなく、単純な解決策はかなり悪い場合があります。合計を作成することはそのような操作です-単純なループで十分であると考えることはできません-たとえば この質問 を参照してください。


Numba-summationのリスト:

 Function name: array_sum_impl_axis
in file: /home/ed/anaconda3/lib/python3.6/site-packages/numba/targets/arraymath.py
with signature: (array(float64, 2d, A), int64) -> array(float64, 1d, C)
show numba IR
194:    def array_sum_impl_axis(arr, axis):
195:        ndim = arr.ndim
196:    
197:        if not is_axis_const:
198:            # Catch where axis is negative or greater than 3.
199:            if axis < 0 or axis > 3:
200:                raise ValueError("Numba does not support sum with axis"
201:                                 "parameter outside the range 0 to 3.")
202:    
203:        # Catch the case where the user misspecifies the axis to be
204:        # more than the number of the array's dimensions.
205:        if axis >= ndim:
206:            raise ValueError("axis is out of bounds for array")
207:    
208:        # Convert the shape of the input array to a list.
209:        ashape = list(arr.shape)
210:        # Get the length of the axis dimension.
211:        axis_len = ashape[axis]
212:        # Remove the axis dimension from the list of dimensional lengths.
213:        ashape.pop(axis)
214:        # Convert this shape list back to a Tuple using above intrinsic.
215:        ashape_without_axis = _create_Tuple_result_shape(ashape, arr.shape)
216:        # Tuple needed here to create output array with correct size.
217:        result = np.full(ashape_without_axis, zero, type(zero))
218:    
219:        # Iterate through the axis dimension.
220:        for axis_index in range(axis_len):
221:            if is_axis_const:
222:                # constant specialized version works for any valid axis value
223:                index_Tuple_generic = _gen_index_Tuple(arr.shape, axis_index,
224:                                                       const_axis_val)
225:                result += arr[index_Tuple_generic]
226:            else:
227:                # Generate a Tuple used to index the input array.
228:                # The Tuple is ":" in all dimensions except the axis
229:                # dimension where it is "axis_index".
230:                if axis == 0:
231:                    index_Tuple1 = _gen_index_Tuple(arr.shape, axis_index, 0)
232:                    result += arr[index_Tuple1]
233:                Elif axis == 1:
234:                    index_Tuple2 = _gen_index_Tuple(arr.shape, axis_index, 1)
235:                    result += arr[index_Tuple2]
236:                Elif axis == 2:
237:                    index_Tuple3 = _gen_index_Tuple(arr.shape, axis_index, 2)
238:                    result += arr[index_Tuple3]
239:                Elif axis == 3:
240:                    index_Tuple4 = _gen_index_Tuple(arr.shape, axis_index, 3)
241:                    result += arr[index_Tuple4]
242:    
243:        return result 
9
ead

これは@MSeifert回答へのコメントです。パフォーマンスを向上させるには、さらにいくつかのことがあります。すべての数値コードと同様に、問題に対してどのデータ型が十分に正確であるかを考えることをお勧めします。多くの場合、float32でも十分ですが、float64でも十分でない場合があります。

ここでfastmathキーワードについても触れたいと思います。これにより、ここでさらに1.7倍高速化できます。

[編集]

単純な合計の場合、私はLLVMコードを調べたところ、合計がベクトル化で部分合計に分割されていることがわかりました。 (AVX2を使用したダブルの4つの部分合計とフロートの8つの合計)。これはさらに調査する必要があります。

コード

import llvmlite.binding as llvm
llvm.set_option('', '--debug-only=loop-vectorize')

@nb.njit
def euclidean_distance_square_numba_v3(x1, x2):
    res = np.empty(x2.shape[0], dtype=x2.dtype)
    for o_idx in range(x2.shape[0]):
        val = 0
        for i_idx in range(x2.shape[1]):
            tmp = x1[0, i_idx] - x2[o_idx, i_idx]
            val += tmp * tmp
        res[o_idx] = val
    return res

@nb.njit(fastmath=True)
def euclidean_distance_square_numba_v4(x1, x2):
    res = np.empty(x2.shape[0], dtype=x2.dtype)
    for o_idx in range(x2.shape[0]):
        val = 0.
        for i_idx in range(x2.shape[1]):
            tmp = x1[0, i_idx] - x2[o_idx, i_idx]
            val += tmp * tmp
        res[o_idx] = val
    return res

@nb.njit(fastmath=True,parallel=True)
def euclidean_distance_square_numba_v5(x1, x2):
    res = np.empty(x2.shape[0], dtype=x2.dtype)
    for o_idx in nb.prange(x2.shape[0]):
        val = 0.
        for i_idx in range(x2.shape[1]):
            tmp = x1[0, i_idx] - x2[o_idx, i_idx]
            val += tmp * tmp
        res[o_idx] = val
    return res

タイミング

float64
x1 = np.random.random((1, 512))
x2 = np.random.random((1000000, 512))

0.42 v3 @MSeifert
0.25 v4
0.18 v5 parallel-version
0.48 distance.cdist

float32
x1 = np.random.random((1, 512)).astype(np.float32)
x2 = np.random.random((1000000, 512)).astype(np.float32)

0.09 v5

型を明示的に宣言する方法

一般に、これはお勧めしません。入力配列は、C隣接(テストデータとして)Fortran隣接またはストライドにすることができます。データが常にC-contiguosであることがわかっている場合は、次のように記述できます。

@nb.njit('double[:](double[:, ::1],double[:, ::1])',fastmath=True)
def euclidean_distance_square_numba_v6(x1, x2):
    res = np.empty(x2.shape[0], dtype=x2.dtype)
    for o_idx in range(x2.shape[0]):
        val = 0.
        for i_idx in range(x2.shape[1]):
            tmp = x1[0, i_idx] - x2[o_idx, i_idx]
            val += tmp * tmp
        res[o_idx] = val
    return res

これはv4バージョンと同じパフォーマンスを提供しますが、入力配列がCに隣接していないか、dtype = np.float64でない場合は失敗します。

あなたも使うことができます

@nb.njit('double[:](double[:, :],double[:, :])',fastmath=True)
def euclidean_distance_square_numba_v7(x1, x2):
    res = np.empty(x2.shape[0], dtype=x2.dtype)
    for o_idx in range(x2.shape[0]):
        val = 0.
        for i_idx in range(x2.shape[1]):
            tmp = x1[0, i_idx] - x2[o_idx, i_idx]
            val += tmp * tmp
        res[o_idx] = val
    return res

これはストライド配列でも機能しますが、C連続配列での上記のバージョンよりもはるかに遅くなります。 (.66秒vs. 0.25秒)。また、問題はメモリ帯域幅によってかなり制限されることに注意してください。 CPUバウンドの計算では、差が大きくなる可能性があります。

Numbaにジョブを任せると、配列が連続しているかどうかが自動的に検出されます(最初の試行で連続した入力データが提供され、連続していないデータが提供されると、再コンパイルが行われます)。

8
max9111