Python 使用NumExpr提升NumPy代码的运行时:一个分析
由于NumPy不使用多核,我正在学习使用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
# 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")
Bne\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")