Numpy 就地操作性能

jli*_*256 11 python numpy

我正在将 numpy 数组就地操作与常规操作进行比较。这是我所做的(Python 3.7.3 版):

    a1, a2 = np.random.random((10,10)), np.random.random((10,10))
Run Code Online (Sandbox Code Playgroud)

为了进行比较:

    def func1(a1, a2):
        a1 = a1 + a2

    def func2(a1, a2):
        a1 += a2

%timeit func1(a1, a2)
%timeit func2(a1, a2)
Run Code Online (Sandbox Code Playgroud)

因为就地操作避免了为每个循环分配内存。我期望func1func2.

但是我得到了这个:

In [10]: %timeit func1(a1, a2)
595 ns ± 14.4 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

In [11]: %timeit func2(a1, a2)
1.38 µs ± 7.87 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

In [12]: np.__version__
Out[12]: '1.16.2'
Run Code Online (Sandbox Code Playgroud)

这表明func1只有 1/2 的时间func2。任何人都可以帮助解释为什么会这样吗?

MSe*_*ert 15

我发现这很有趣,并决定自己计时。但我并没有只检查 10x10 数组,而是使用 NumPy 1.16.2 测试了许多不同的数组大小:

在此处输入图片说明

这清楚地表明,对于小数组大小,正常添加速度更快,并且仅对于中等大小的数组大小,就地操作更快。还有一个我无法解释的大约 100000 个元素的奇怪凸起(它接近我计算机上的页面大小,也许使用了不同的分配方案)。

分配临时数组预计会更慢,因为:

  • 必须分配内存
  • 必须迭代 3 个数组才能执行操作,而不是 2 个。

特别是第一点(分配内存)可能没有在基准测试中考虑(不与%timeit不与simple_benchmark.run)。那是因为一遍又一遍地请求相同的内存大小可能会非常优化。这将使额外数组的添加看起来比实际快一点。

这里要提到的另一点是就地添加可能具有更高的常数因子。如果您正在执行就地添加,则必须先进行更多代码检查,然后才能执行操作,例如重叠输入。这可能会给就地添加一个更高的常数因子。

作为一个更一般的建议:微基准测试可能会有所帮助,但它们并不总是非常准确。您还应该对调用它的代码进行基准测试,以对代码的实际性能做出更有根据的陈述。通常,此类微基准测试会遇到一些高度优化的情况(例如,重复分配相同数量的内存并再次释放),而在实际使用代码时不会(如此频繁)发生这种情况。

这也是我用于图形的代码,使用我的库simple_benchmark

from simple_benchmark import BenchmarkBuilder, MultiArgument
import numpy as np

b = BenchmarkBuilder()

@b.add_function()
def func1(a1, a2):
    a1 = a1 + a2

@b.add_function()
def func2(a1, a2):
    a1 += a2

@b.add_arguments('array size')
def argument_provider():
    for exp in range(3, 28):
        dim_size = int(1.4**exp)
        a1 = np.random.random([dim_size, dim_size])
        a2 = np.random.random([dim_size, dim_size])
        yield dim_size ** 2, MultiArgument([a1, a2])

r = b.run()
r.plot()
Run Code Online (Sandbox Code Playgroud)


lig*_*ist 7

因为您忽略了对小矩阵进行矢量化操作和预取的影响。

请注意,您的矩阵 (10 x 10) 的大小很小,因此分配临时存储所需的时间并不那么重要(目前),对于具有大缓存大小的处理器,这些小矩阵可能仍然完全适合 L1 缓存,因此,对这些小矩阵执行矢量化操作等所带来的速度增益将足以弥补分配临时矩阵所损失的时间以及直接添加到已分配内存位置之一的速度增益。

但是当你增加矩阵的大小时,故事就变得不同了

In [41]: k = 100

In [42]: a1, a2 = np.random.random((k, k)), np.random.random((k, k))

In [43]: %timeit func2(a1, a2)
4.41 µs ± 3.01 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

In [44]: %timeit func1(a1, a2)
6.36 µs ± 4.18 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

In [45]: k = 1000

In [46]: a1, a2 = np.random.random((k, k)), np.random.random((k, k))

In [47]: %timeit func2(a1, a2)
1.13 ms ± 1.49 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

In [48]: %timeit func1(a1, a2)
1.59 ms ± 2.06 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

In [49]: k = 5000
In [50]: a1, a2 = np.random.random((k, k)), np.random.random((k, k))

In [51]: %timeit func2(a1, a2)
30.3 ms ± 122 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

In [52]: %timeit func1(a1, a2)
94.4 ms ± 58.3 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
Run Code Online (Sandbox Code Playgroud)

编辑:这是为了k = 10表明您对小矩阵的观察在我的机器上也是正确的。

In [56]: k = 10

In [57]: a1, a2 = np.random.random((k, k)), np.random.random((k, k))

In [58]: %timeit func2(a1, a2)
1.06 µs ± 10.7 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

In [59]: %timeit func1(a1, a2)
500 ns ± 0.149 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
Run Code Online (Sandbox Code Playgroud)

  • 在我看来,这两个操作都是矢量化的。就地运算符不存在有什么原因吗? (2认同)
  • 我并没有说就地操作不是矢量化的。事实上,“func1”和“func2”中的操作很可能被编译器以不同的方式进行向量化(如果有的话)。我的意思是,人们不应该低估其中一些对完全适合顶级缓存的数据(包括临时存储)进行矢量化操作的性能。性能下降通常发生在每个级别的缓存大小限制下,导致缓存未命中以及需要在缓存之间移动数据,这就是当矩阵大小变大时我们会遇到的情况。 (2认同)