【问题标题】:Why is numpy sum 10 times slower than the + operator?为什么 numpy sum 比 + 运算符慢 10 倍?
【发布时间】:2019-04-05 04:38:00
【问题描述】:

我注意到非常奇怪的是,np.sum 比手写 sum 慢 10 倍。

np.sum 与轴:

p1 = np.random.rand(10000, 2)
def test(p1):
    return p1.sum(axis=1)
%timeit test(p1)

每个循环 186 µs ± 4.21 µs(7 次运行的平均值 ± 标准偏差,每次 1000 个循环)

np.sum 无轴:

p1 = np.random.rand(10000, 2)
def test(p1):
    return p1.sum()
%timeit test(p1)

每个循环 17.9 µs ± 236 ns(平均值 ± 标准偏差,7 次运行,每次 10000 次循环)

+:

p1 = np.random.rand(10000, 2)
def test(p1):
    return p1[:,0] + p1[:,1]
%timeit test(p1)

每个循环 15.8 µs ± 328 ns(平均值 ± 标准偏差,7 次运行,每次 100000 次循环)

乘法:

p1 = np.random.rand(10000, 2)
def test(p1):
    return p1[:,0]*p1[:,1]
%timeit test(p1)

每个循环 15.7 µs ± 701 ns(7 次运行的平均值 ± 标准偏差,每次 10000 个循环)

我看不出有什么理由。知道为什么吗?我的 numpy 版本是1.15.3

编辑: 10000000:

np.sum (with axis): 202 ms (5 x)
np.sum (without axis): 12 ms
+ : 46 ms (1 x)
* : 44.3 ms 

所以我猜想在某种程度上存在一些开销......

【问题讨论】:

  • 一小部分开销可能与成对求和实现有关。不过,只有一小部分 - 据我所知,prod 并没有做成对的事情,在我的测试中,prod 的运行时间大约是sum 的 5/6。我认为 NumPy 还在为+ 而不是sum 使用SIMD,但我还不确定。
  • 你的“乘法”正在做一些不同的事情......其他人只是使用p1而基本上忽略p2
  • Here's the source 用于成对求和例程。求和的元素数量足够小,以至于例程应该立即进入直接的非成对循环情况,但代码路径似乎仍然比用于prod 之类的代码路径有一些开销。如前所述,这只是相对于+ 的一小部分开销。
  • @beesleep 乘法和加法在浮点数上没有什么不同;如果有的话,乘法会更容易一些。当然,整数是不同的。
  • 这太奇怪了,主要是因为轴应该返回非连续元素的内存视图(这几乎不是优化的代码,因为你在这里对缓存非常不友好)。事实上,我可以通过将p1 = np.random.rand(10000, 2) 更改为p1 = np.random.rand(2, 10000)p1.sum(axis=1) 更改为p1.sum(axis=0) 来提高代码性能的10 倍。

标签: python performance numpy


【解决方案1】:

对于带轴与不带轴的 .sum() 来说,轴必须生成一个浮点数组,只要您的输入,每行都有一个元素。这意味着它必须沿轴=1 调用reduce() 10,000 次。如果没有轴参数,它会将每个元素的总和计算为一个浮点数,这只是通过数组的平面表示进行归约的一次调用。

我不确定为什么手动添加功能更快,而且我不想深入研究源代码,但我想我有一个很好的猜测。我相信开销来自它必须为每行执行跨轴 = 1 的减少,因此需要 10,000 次单独的减少调用。在手动添加函数中,在定义“+”函数的参数时只进行一次轴拆分,然后拆分列的每个元素可以并行添加在一起。

【讨论】:

  • 虽然猜得不错,但 NumPy 实际上并不需要复制浮点数组。 NumPy 有一个扩展的内存视图系统,它允许它存储一个数组,其中包含指向原始缓冲区、开始、长度和步幅的引用计数指针。要创建轴或进行花哨的索引,永远不需要重复数据...您可以通过检查 a = np.arange(100); b = a[::-1]; a.ctypes.data == b.ctypes.data 来验证这一点,它检查指向数组开头的指针 ab 相同,打印为 true。
  • 您也可以通过相同的代码验证这一点,除了设置一个值:a = np.arange(100); b = a[::-1]; a[4] = 5; b[2],它将打印 5,尽管该值通常为 4。
  • 所以开销不是来自拆分数组,而是加法函数确实可以并行添加数组,对吗?这比大量减少调用要快。
  • 老实说,我必须检查实现以确定,但如果 NumPy 这样做,我会感到惊讶。似乎效率极低,因为它可以只是将步幅加倍,将 1 指针增加 8,调用高度优化的 C add 例程,并在这两种情况下每天调用它(显然,它必须做更多的工作,因为索引(特别是花哨的索引)并且使用潜在的多维数组非常复杂)。
  • 等等……你说得对。它在后台调用reduce。不是 10,000 次,但仍然……令人震惊……github.com/numpy/numpy/blob/…
【解决方案2】:

主要区别是计算a.sum(axis=1) 时的开销更大。计算减少量(在本例中为 sum)并非易事:

  • 必须考虑舍入误差,因此使用pairwise summation 来减少它。
  • 平铺对于更大的阵列很重要,因为它可以充分利用可用缓存
  • 为了能够使用现代 CPU 的 SIMD 指令/乱序执行能力,应该并行计算多行

我已经更详细地讨论了上述主题,例如herehere

但是,如果只需要添加两个元素,那么所有这些都不是必需的,而且并不比简单的求和更好 - 你会得到相同的结果,但开销要少得多,而且速度更快。

对于只有 1000 个元素,调用 numpy 功能的开销可能高于实际执行这 1000 个加法(或乘法,因为在现代 CPU 上流水线加法/乘法具有相同的成本) - 如您所见,对于 10^4,运行时间仅高出大约 2 倍,这肯定表明开销对 10^3 起着更大的作用!在this answer 中更详细地研究了开销和缓存未命中的影响。

我们看一下profiler-result,看看上面的理论是否成立(我用perf):

对于a.sum(axis=1)

  17,39%  python   umath.cpython-36m-x86_64-linux-gnu.so       [.] reduce_loop
  11,41%  python   umath.cpython-36m-x86_64-linux-gnu.so       [.] pairwise_sum_DOUBLE
   9,78%  python   multiarray.cpython-36m-x86_64-linux-gnu.so  [.] npyiter_buffered_reduce_iternext_ite
   9,24%  python   umath.cpython-36m-x86_64-linux-gnu.so       [.] DOUBLE_add
   4,35%  python   python3.6                                   [.] _PyEval_EvalFrameDefault
   2,17%  python   multiarray.cpython-36m-x86_64-linux-gnu.so  [.] _aligned_strided_to_contig_size8_src
   2,17%  python   python3.6                                   [.] lookdict_unicode_nodummy
   ...

使用reduce_looppairwise_sum_DOUBLE 的开销占主导地位。

对于a[:,0]+a[:,1])

   7,24%  python   python3.6                                   [.] _PyEval_EvalF
   5,26%  python   python3.6                                   [.] PyObject_Mall
   3,95%  python   python3.6                                   [.] visit_decref
   3,95%  python   umath.cpython-36m-x86_64-linux-gnu.so       [.] DOUBLE_add
   2,63%  python   python3.6                                   [.] PyDict_SetDef
   2,63%  python   python3.6                                   [.] _PyTuple_Mayb
   2,63%  python   python3.6                                   [.] collect
   2,63%  python   python3.6                                   [.] fast_function
   2,63%  python   python3.6                                   [.] visit_reachab
   1,97%  python   python3.6                                   [.] _PyObject_Gen

正如预期的那样:Python 开销起了很大的作用,使用了一个简单的DOUBLE_add


调用a.sum()时开销更少

  • 一次,reduce_loop 不是为每一行调用一次,而是只调用一次,这意味着开销大大减少。
  • 不会创建新的结果数组,不再需要将 1000 个双精度数写入内存。

因此可以预期,a.sum() 更快(尽管事实上,必须进行 2000 而非 1000 的加法 - 但正如我们所见,这主要是关于开销和实际工作 - 加法不是负责大部分运行时间)。


运行获取数据:

perf record python run.py
perf report

#run.py
import numpy as np
a=np.random.rand(1000,2)

for _ in range(10000):
  a.sum(axis=1)
  #a[:,0]+a[:,1]

【讨论】:

  • 很有趣,也让我有点惊讶。我本来预计会有一些开销,但不会这么多。如果轴大小低于某个阈值,那么几乎值得实现特殊情况的平凡缩减。
  • 我同意这感觉令人惊讶。我注意到与 linspace over arange 相同的 10 x 开销
猜你喜欢
  • 2011-08-22
  • 1970-01-01
  • 2018-01-16
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2014-05-29
  • 2018-11-12
相关资源
最近更新 更多