为什么NumPy有时会比NumPy +纯Python循环慢?

时间:2019-06-28 12:25:45

标签: python performance numpy

这是基于this question在2018-10年度提出的。

请考虑以下代码。三个简单的函数可以计算NumPy 3D数组(1000×1000×1000)中的非零元素。

import numpy as np

def f_1(arr):
    return np.sum(arr > 0)

def f_2(arr):
    ans = 0
    for val in range(arr.shape[0]):
        ans += np.sum(arr[val, :, :] > 0)
    return ans

def f_3(arr):
    return np.count_nonzero(arr)

if __name__ == '__main__':

    data = np.random.randint(0, 10, (1_000, 1_000, 1_000))
    print(f_1(data))
    print(f_2(data))
    print(f_3(data))

我的机器上的运行时(Python 3.7.?、Windows 10,NumPy 1.16。?):

%timeit f_1(data)
1.73 s ± 21.7 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

%timeit f_2(data)
1.4 s ± 1.36 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

%timeit f_3(data)
2.38 s ± 956 µs per loop (mean ± std. dev. of 7 runs, 1 loop each)

因此,f_2()f_1()f_3()的工作速度更快。但是,较小的data并非如此。问题是-为什么呢?是NumPy,Python还是其他?

3 个答案:

答案 0 :(得分:7)

这是由于内存访问和缓存。每个功能都在做两件事,以第一个代码为例:

np.sum(arr > 0)

它首先进行比较,以找出arr大于零(或非零,因为arr包含非负整数)的地方。这将创建一个与arr相同形状的中间数组。然后,将这个数组求和。

直截了当,对吧?好吧,当使用np.sum(arr > 0)时,这是一个大数组。当它足够大而不能容纳在高速缓存中时,性能将降低,因为当处理器开始执行时,大多数数组元素的总和将被从内存中逐出并需要重新加载。

由于f_2在第一个维度上进行迭代,因此它正在处理较小的子数组。完成相同的复制和求和,但是这次中间数组适合内存。它是在不离开内存的情况下创建,使用和销毁的。这要快得多。

现在,您会认为f_3最快(使用内置方法和所有方法),但是查看source code则表明它使用以下操作:

a_bool = a.astype(np.bool_, copy=False)
return a_bool.sum(axis=axis, dtype=np.intp

a_bool只是查找非零条目的另一种方法,它会创建一个大的中间数组。

结论

经验法则就是这样,而且经常是错误的。如果您想要更快的代码,请对其进行概要分析,然后查看问题出在哪里(有关此方面的出色工作)。

Python做得很好。在优化的情况下,它可能比numpy更快。不要害怕将普通的旧python代码或数据类型与numpy结合使用。

如果经常发现自己手动编写循环以提高性能,则可能需要看看numexpr-它会自动执行其中的一些操作。我本人并没有使用太多,但是如果中间数组使您的程序变慢,它应该可以提供很好的加速效果。

答案 1 :(得分:5)

所有有关数据在内存中的布局方式以及代码如何访问它的问题。本质上,数据是从内存中以块的形式获取的,然后将其缓存。如果算法设法使用缓存中的块中的数据,则无需再次从内存中读取数据。这样可以节省大量时间,尤其是当缓存远小于您要处理的数据时。

考虑这些变化,这些变化仅在我们要迭代的轴上有所不同:

def f_2_0(arr):
    ans = 0
    for val in range(arr.shape[0]):
        ans += np.sum(arr[val, :, :] > 0)
    return ans

def f_2_1(arr):
    ans = 0
    for val in range(arr.shape[1]):
        ans += np.sum(arr[:, val, :] > 0)
    return ans

def f_2_2(arr):
    ans = 0
    for val in range(arr.shape[2]):
        ans += np.sum(arr[:, :, val] > 0)
    return ans

然后在笔记本电脑上显示结果:

%timeit f_1(data)
2.31 s ± 47.7 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

%timeit f_2_0(data)
1.88 s ± 60 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

%timeit f_2_1(data)
2.65 s ± 142 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

%timeit f_2_2(data)
12.8 s ± 650 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

您可以看到f_2_1几乎与f_1一样快,这让我认为numpy没有使用最佳访问模式(f_2_0使用的访问模式)< / del>。 other answer解释了缓存如何精确影响时序。

答案 2 :(得分:3)

让我们完全删除临时数组

正如@ user2699在其答案中已经提到的那样,分配和写入不适合高速缓存的大型数组可能会大大减慢该过程。为了显示这种行为,我使用Numba(JIT编译器)编写了两个小函数。

在编译语言(C,Fortran,..)中,通常避免使用临时数组。在解释型Python(不使用Cython或Numba)中,您通常要在更大的数据块上调用已编译的函数(向量化),因为解释型代码中的循环非常慢。但这也会带来视图方面的不利影响(例如临时数组,不良的缓存使用)

没有临时分配数组的功能

@nb.njit(fastmath=True,parallel=False)
def f_4(arr):
    sum=0
    for i in nb.prange(arr.shape[0]):
        for j in range(arr.shape[1]):
            for k in range(arr.shape[2]):
                if arr[i,j,k]>0:
                    sum+=1
    return sum

具有临时数组

请注意,如果您打开并行化parallel=True,则编译器不仅会尝试并行化代码,还会打开其他优化功能,例如循环熔合。

@nb.njit(fastmath=True,parallel=False)
def f_5(arr):
    return np.sum(arr>0)

时间

%timeit f_1(data)
1.65 s ± 48.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit f_2(data)
1.27 s ± 5.66 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit f_3(data)
1.99 s ± 6.11 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

%timeit f_4(data) #parallel=false
216 ms ± 5.45 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit f_4(data) #parallel=true
121 ms ± 4.85 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit f_5(data) #parallel=False
1.12 s ± 19 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit f_5(data) #parallel=true Temp-Array is automatically optimized away
146 ms ± 12.5 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)