为什么这个numba代码比numpy代码慢了6倍?

21

为什么以下代码需要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 可以如此缓慢。是我使用方式不对吗?

我真的需要加速,因为我需要运行这个函数三百万次,而两秒太慢了。

我需要在 CPU 上运行它,因为你可以看到 x2 的维度非常巨大,无法加载到 GPU 上(或至少是我的 GPU),内存不足。


这可能涉及到系统配置(例如,您的 numpy 使用 OpenCL 利用 GPGPU)。 - Basile Starynkevitch
1
Numba文档指出它是纯Python,而NumPy使用了大量的C,我猜这就是最大的效率差异。 - Ofer Sadan
这只是猜测,但我怀疑Numba以这种方式取消了numpy的一些速度优势。我不知道是否有任何比numpy更快的向量和矩阵实现。 - Ofer Sadan
@hpaulj 优化 x1 不会有太大的帮助,因为它已经很小了。对于我的实现来说,(512,) 将会出错。这个函数在输入维度为 x1 和 x2 的情况下需要 2 秒钟,这合理吗? - user2675516
1
@MSeifert 好的。我在这里重新发布了:https://stackoverflow.com/questions/50675705/is-it-expected-for-numbas-efficient-square-euclidean-distance-code-to-be-slower. - user2675516
显示剩余5条评论
3个回答

30

numba速度变慢是很奇怪的。

其实不太奇怪。当你在numba函数中调用NumPy函数时,你调用的是这些函数的numba版本。这些版本可能比NumPy版本更快、更慢或者速度相同。你可能会运气好,也可能会运气差(你就是运气不太好!)。但即使在numba函数中,你仍然会创建大量的临时数组,因为你使用了NumPy函数(一个用于点积结果、一个用于每个平方和、一个用于点积加上第一个和),所以你没有充分利用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

是的,看起来相当简单,而且并不会更慢。

然而,在所有兴奋中,我忘了提到显而易见的解决方案: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)

注意:如果您有整数数组,您可能希望将numba函数中硬编码的0.0更改为0


2
嗯...很奇怪,我的scipy距离函数在测试中实际上比预期慢了2倍,大约需要4秒钟。请问您是否使用特殊选项编译了scipy? - user2675516
@user2675516 您的数组是什么数据类型?对于某些数据类型,scipy函数可能会慢一些 - 但这只是一个猜测。也有可能是您正在使用旧版本的scipy。 - MSeifert
我认为你不能(也不应该)重新编译scipy。那有点棘手...但如果你真的想要,这里是官方说明 - MSeifert
1
我找到了罪魁祸首,我正在使用float32,但是scipy.distance.cdist在这方面很慢。它只对float64快速。 - user2675516
1
@user2675516 是的,我也怀疑是这样。我认为在scipy错误跟踪器上开一个问题可能是值得的。 - MSeifert

13

尽管@MSeifert的回答使得这个回答相当过时,但我仍然发布它,因为它可以更详细地解释为什么numba版本比numpy版本慢。

正如我们将看到的那样,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. 将整个第二列添加到结果列
  4. 以此类推

这种方法的问题是什么?内存布局!数组以行优先顺序存储,因此按列读取会导致比按行读取(即numpy所做的)更多的缓存未命中。有一篇很好的文章解释了可能的缓存效应。

正如我们所看到的,numba的求和实现还不太成熟。然而,从上述考虑来看,对于列优先顺序(即转置矩阵),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求和:

 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 

我喜欢你提到的那个天真实现可能不如库函数“正确”。这通常是不必要的,但在极少数情况下,这可能会导致结果出现微妙(且难以跟踪)的问题。需要知道的是,NumPy也使用不精确的求和,只是因为它使用成对求和(或至少是展开的部分求和),所以它的“不正确”程度较小。如果需要非常高的精度,则应该使用Kahan或Neumaier求和 - MSeifert
2
这里可能不太相关,但是使用@nb.jit('double[:](double[:, :])', nopython=True)(声明可能非连续的数组)经常会破坏SIMD向量化。您可以使用自动类型检测或声明C(double[:, ::1])或Fortran(double[::1,:]连续数组)。 - max9111
@max9111 在这种特定情况下没有区别,但知道这一点很好! - ead

8
这是对@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连续的,那么您可以编写:

@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连续或不是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连续数组上比上面的版本慢得多(0.66秒 vs. 0.25秒)。请注意,您的问题受到内存带宽的限制。在CPU绑定的计算中,差异可能更大。

如果您让Numba为您完成工作,它会自动检测数组是否连续(在第一次尝试时提供连续输入数据,然后是非连续数据将导致重新编译)


你的回答中有错别字吗?float32 的计时比 float64 慢吗?Numpy 默认为 float64。因此,如果你没有给定 dtype,它就是 float64 而不是 32。 - user2675516
fastmath 的确有优点,但我不敢说它会提高精度。这取决于具体的操作,一般来说它会降低精度(至少与 IEEE 754 数学相比)。我也测试了并行计算,实际上速度稍慢(因为受到内存带宽限制),所以我觉得很有趣的是,在你的测试中它却更快了。也许这是因为 fastmath 或者不同的缓存速度造成的? - MSeifert
好奇问一下:你是怎么做基准测试的?也用了%timeit吗? - MSeifert
@max9111 我更新了帖子。我稍微修改了代码,使其能够处理(m, n)维 x1。不确定我是否做对了。你能帮忙验证一下吗?它还是有点慢。 - user2675516
我用一个循环测试了20次,并将结果除以20(在此之前进行了热身)。当测量性能时,我在处理内存绑定问题时遇到了一些时间测量的问题,其中测试数据适合缓存。在这种情况下,我使用至少两组形状相同的测试数据来消除缓存效应。(例如,在30 GB/s内存带宽下获得100 GB/s带宽)关于精度,我应该更清楚并更新我的答案。我还使用了Numba 0.39 dev进行了测试。在早期版本中,当使用并行化时,SIMD向量化通常不起作用。 - max9111
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接