Python 使用NumExpr提升NumPy代码的运行时:一个分析

Python 使用NumExpr提升NumPy代码的运行时:一个分析,python,numpy,multidimensional-array,polynomial-math,numexpr,Python,Numpy,Multidimensional Array,Polynomial Math,Numexpr,由于NumPy不使用多核,我正在学习使用NumExpr加速NumPy代码,因为它对多线程有很好的支持。下面是我正在使用的一个示例: # input array to work with x = np.linspace(-1, 1, 1e7) # a cubic polynomial expr cubic_poly = 0.25*x**3 + 0.75*x**2 + 1.5*x - 2 %timeit -n 10 cubic_poly = 0.25*x**3 + 0.75*x**2 + 1.5

由于NumPy不使用多核,我正在学习使用NumExpr加速NumPy代码,因为它对多线程有很好的支持。下面是我正在使用的一个示例:

# input array to work with
x = np.linspace(-1, 1, 1e7)

# a cubic polynomial expr
cubic_poly = 0.25*x**3 + 0.75*x**2 + 1.5*x - 2

%timeit -n 10 cubic_poly = 0.25*x**3 + 0.75*x**2 + 1.5*x - 2
# 657 ms ± 5.04 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
现在,我们可以使用NumExpr执行同样的操作:

cubic_poly_str = "0.25*x**3 + 0.75*x**2 + 1.5*x - 2"
# set number of threads to 1 for fair comparison
ne.set_num_threads(1)

%timeit -n 10 ne.evaluate(cubic_poly_str)
# 60.5 ms ± 908 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
从计时中我们可以看出,
NumExpr
快10倍以上,即使我们使用与NumPy相同数量的线程(即1个)


现在,让我们增加计算量,使用所有可用线程并观察:

# use all available threads/cores
ne.set_num_threads(ne.detect_number_of_threads())

%timeit -n 10 ne.evaluate(cubic_poly_str)
# 16.1 ms ± 82.4 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

# sanity check
np.allclose(cubic_poly, ne.evaluate(cubic_poly_str))
毫不奇怪且令人信服的是,这比仅使用单线程快5倍


为什么即使使用相同数量的线程(即1个),NumExpr的速度也要快10倍呢?

您认为只有/主要来自并行化的假设是错误的。正如@Brenella已经指出的,numexpr的最大加速份额通常来自更好地利用缓存。然而,还有一些其他原因

首先,numpy和numexpr对同一个表达式求值:

  • numpy将
    x**3
    x**2
    计算为
    pow(x,3)
    pow(x,2)
  • numexpr可以自由地将其评估为
    x**3=x*x*x
    x**2=x*x
pow
比一个或两个乘法更复杂,因此速度要慢得多,比较:

ne.set_num_threads(1)
%timeit ne.evaluate("0.25*x**3 + 0.75*x**2 + 1.5*x - 2")
# 60.7 ms ± 1.2 ms, base line on my machine

%timeit 0.25*x**3 + 0.75*x**2 + 1.5*x - 2
# 766 ms ± 4.02 ms
%timeit 0.25*x*x*x + 0.75*x*x + 1.5*x - 2 
# 130 ms ± 692 µs 
现在,numexpr的速度只有原来的两倍。我的猜测是,
pow
-版本受CPU限制,而乘法版本受内存限制更大

当数据较大时,Numexpr最为出色—比三级缓存(例如我的机器上的15Mb)大,这在您的示例中给出,因为
x
约为76Mb:

  • numexp按块计算-即,对一个块计算所有操作,每个块适合(至少)三级缓存,从而最大限度地提高缓存的利用率。只有在完成一个块之后,才能计算另一个块
  • numpy对整个数据进行一次又一次的计算,因此数据在可以重用之前会从缓存中移出
例如,我们可以使用
valgrind
(参见本文附录中的脚本)来查看缓存未命中:

我们感兴趣的部分是
LLd未命中
(即L3未命中,有关输出解释的信息,请参阅)-大约25%的读取访问是未命中

对numexpr的相同分析显示:

>>> valgrind --tool=cachegrind python ne_version.py 
...
==5145== D   refs:      2,612,495,487  (1,737,673,018 rd   + 874,822,469 wr)
==5145== D1  misses:      110,971,378  (   86,949,951 rd   +  24,021,427 wr)
==5145== LLd misses:       29,574,847  (   15,579,163 rd   +  13,995,684 wr)
==5145== D1  miss rate:           4.2% (          5.0%     +         2.7%  )
==5145== LLd miss rate:           1.1% (          0.9%     +         1.6%  )
...
只有5%的读取是未命中的

然而,numpy也有一些优点:在幕后,numpy使用mkl例程(至少在我的机器上是这样),而numexpr不使用。因此,numpy最终使用压缩SSE操作(
movups
+
mulpd
+
addpd
),而numexpr最终使用标量版本(
movsd
+
mulsd

这解释了numpy版本25%的未命中率:一次读取为128位(
movups
),这意味着在4次读取后,将处理缓存线(64字节)并产生未命中。可以在配置文件中看到它(例如Linux上的
perf
):

每四次
movups
需要更多的时间,因为它等待内存访问


Numpy适用于较小的阵列大小,适合一级缓存(但最大的份额是开销,而不是计算本身,后者在Numpy中速度更快,但这并没有起到很大作用):


作为旁注:将函数计算为
((0.25*x+0.75)*x+1.5)*x-2
)会更快

两者都是因为CPU使用率较低:

# small x - CPU bound
x = np.linspace(-1, 1, 10**3)
%timeit ((0.25*x + 0.75)*x + 1.5)*x - 2
#  9.02 µs ± 204 ns 
和更少的内存访问:

# large x - memory bound
x = np.linspace(-1, 1, 10**7)
%timeit ((0.25*x + 0.75)*x + 1.5)*x - 2
#  73.8 ms ± 3.71 ms

清单:

A
np\u版本.py

import numpy as np

x = np.linspace(-1, 1, 10**7)
for _ in range(10):
    cubic_poly = 0.25*x*x*x + 0.75*x*x + 1.5*x - 2
import numpy as np
import numexpr as ne

x = np.linspace(-1, 1, 10**7)
ne.set_num_threads(1)
for _ in range(10):
    ne.evaluate("0.25*x**3 + 0.75*x**2 + 1.5*x - 2")
B
ne\u version.py

import numpy as np

x = np.linspace(-1, 1, 10**7)
for _ in range(10):
    cubic_poly = 0.25*x*x*x + 0.75*x*x + 1.5*x - 2
import numpy as np
import numexpr as ne

x = np.linspace(-1, 1, 10**7)
ne.set_num_threads(1)
for _ in range(10):
    ne.evaluate("0.25*x**3 + 0.75*x**2 + 1.5*x - 2")

使用numpy时,计算的一些中间值存储在内存中(如
1.5*x
),对于如此大的
x
,这需要时间。另一方面,
numexpr
将数据分成小块并执行完整的计算,而不需要大量存储中间值。
import numpy as np
import numexpr as ne

x = np.linspace(-1, 1, 10**7)
ne.set_num_threads(1)
for _ in range(10):
    ne.evaluate("0.25*x**3 + 0.75*x**2 + 1.5*x - 2")