为什么 numpy 的 insum 比 numpy 内置的函数快?

让我们从三个 dtype=np.double数组开始。计时是在 Intel CPU 上执行的,使用 numpy 1.7.1和 icc一起编译,并链接到 Intel 的 mkl。一个 AMD CPU 与 numpy 1.6.1编译与 gcc没有 mkl也被用来验证时间。请注意时间刻度几乎与系统大小成线性关系,并不是因为数字函数 if语句引起的小开销,这些差异将以微秒而不是毫秒显示:

arr_1D=np.arange(500,dtype=np.double)
large_arr_1D=np.arange(100000,dtype=np.double)
arr_2D=np.arange(500**2,dtype=np.double).reshape(500,500)
arr_3D=np.arange(500**3,dtype=np.double).reshape(500,500,500)

首先让我们看看 np.sum函数:

np.all(np.sum(arr_3D)==np.einsum('ijk->',arr_3D))
True


%timeit np.sum(arr_3D)
10 loops, best of 3: 142 ms per loop


%timeit np.einsum('ijk->', arr_3D)
10 loops, best of 3: 70.2 ms per loop

权力:

np.allclose(arr_3D*arr_3D*arr_3D,np.einsum('ijk,ijk,ijk->ijk',arr_3D,arr_3D,arr_3D))
True


%timeit arr_3D*arr_3D*arr_3D
1 loops, best of 3: 1.32 s per loop


%timeit np.einsum('ijk,ijk,ijk->ijk', arr_3D, arr_3D, arr_3D)
1 loops, best of 3: 694 ms per loop

外部产品:

np.all(np.outer(arr_1D,arr_1D)==np.einsum('i,k->ik',arr_1D,arr_1D))
True


%timeit np.outer(arr_1D, arr_1D)
1000 loops, best of 3: 411 us per loop


%timeit np.einsum('i,k->ik', arr_1D, arr_1D)
1000 loops, best of 3: 245 us per loop

使用 np.einsum,以上所有操作的速度都是原来的两倍。这些应该是苹果对苹果的比较,因为一切都是特定的 dtype=np.double。我希望这样的行动能加快速度:

np.allclose(np.sum(arr_2D*arr_3D),np.einsum('ij,oij->',arr_2D,arr_3D))
True


%timeit np.sum(arr_2D*arr_3D)
1 loops, best of 3: 813 ms per loop


%timeit np.einsum('ij,oij->', arr_2D, arr_3D)
10 loops, best of 3: 85.1 ms per loop

对于 np.innernp.outernp.kronnp.sum,不管 axes的选择如何,Einsum 似乎至少要快一倍。主要的异常是 np.dot,因为它从 BLAS 库调用 DGEMM。那么为什么 np.einsum比其他等价的数字函数更快呢?

DGEMM 的完整性案例:

np.allclose(np.dot(arr_2D,arr_2D),np.einsum('ij,jk',arr_2D,arr_2D))
True


%timeit np.einsum('ij,jk',arr_2D,arr_2D)
10 loops, best of 3: 56.1 ms per loop


%timeit np.dot(arr_2D,arr_2D)
100 loops, best of 3: 5.17 ms per loop

主要的理论来自@sebergs 注释,np.einsum可以利用 SSE2,但 numpy 的 ufuns 要到 numpy 1.8才能使用(参见 更改日志)。我相信这是正确的答案,但是有 没有能够证实它。通过改变输入阵列的 dtype 和观察速度差,以及不是每个人都能观察到同样的定时趋势,可以找到一些有限的证明。

17134 次浏览

我认为这些时间点解释了发生了什么:

a = np.arange(1000, dtype=np.double)
%timeit np.einsum('i->', a)
100000 loops, best of 3: 3.32 us per loop
%timeit np.sum(a)
100000 loops, best of 3: 6.84 us per loop


a = np.arange(10000, dtype=np.double)
%timeit np.einsum('i->', a)
100000 loops, best of 3: 12.6 us per loop
%timeit np.sum(a)
100000 loops, best of 3: 16.5 us per loop


a = np.arange(100000, dtype=np.double)
%timeit np.einsum('i->', a)
10000 loops, best of 3: 103 us per loop
%timeit np.sum(a)
10000 loops, best of 3: 109 us per loop

所以当你在 np.einsum上调用 np.sum的时候,你基本上有一个常量3us 的开销,所以它们基本上运行得一样快,但是运行起来需要更长的时间。为什么会这样?我打赌是这样的:

a = np.arange(1000, dtype=object)
%timeit np.einsum('i->', a)
Traceback (most recent call last):
...
TypeError: invalid data type for einsum
%timeit np.sum(a)
10000 loops, best of 3: 20.3 us per loop

不确定到底发生了什么,但是看起来 np.einsum跳过了一些检查来提取特定类型的函数来进行乘法和加法,并且直接使用 *+来处理标准 C 类型。


这些多层面的案例也没有什么不同:

n = 10; a = np.arange(n**3, dtype=np.double).reshape(n, n, n)
%timeit np.einsum('ijk->', a)
100000 loops, best of 3: 3.79 us per loop
%timeit np.sum(a)
100000 loops, best of 3: 7.33 us per loop


n = 100; a = np.arange(n**3, dtype=np.double).reshape(n, n, n)
%timeit np.einsum('ijk->', a)
1000 loops, best of 3: 1.2 ms per loop
%timeit np.sum(a)
1000 loops, best of 3: 1.23 ms per loop

所以基本上是恒定的开销,而不是一旦他们开始就跑得更快。

首先,在 numpy 列表中已经有很多关于这个问题的讨论,例如: Http://numpy-discussion.10968.n7.nabble.com/poor-performance-of-sum-with-sub-machine-word-integer-types-td41.html Http://numpy-discussion.10968.n7.nabble.com/odd-performance-of-sum-td3332.html

其中一些可以归结为 einsum是新的,并且可能正在试图更好地解决缓存对齐和其他内存访问问题,而许多较老的数字函数集中在一个容易移植的实现上,而不是一个经过大量优化的实现。我只是猜测。


然而,你正在做的一些事情并不完全是一个“苹果对苹果”的比较。

除了@Jamie 已经说过的,sum对数组使用了一个更合适的累加器

例如,sum在检查输入类型和使用合适的累加器时更加谨慎。例如,考虑以下情况:

In [1]: x = 255 * np.ones(100, dtype=np.uint8)


In [2]: x
Out[2]:
array([255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255], dtype=uint8)

请注意,sum是正确的:

In [3]: x.sum()
Out[3]: 25500

einsum会给出错误的结果:

In [4]: np.einsum('i->', x)
Out[4]: 156

但是,如果我们使用一个限制较少的 dtype,我们仍然会得到你所期望的结果:

In [5]: y = 255 * np.ones(100)


In [6]: np.einsum('i->', y)
Out[6]: 25500.0

既然 numpy 1.8已经发布了,根据文档,所有 ufuns 都应该使用 SSE2,我想再次检查 Seberg 关于 SSE2的评论是否有效。

为了执行测试,创建了一个新的 python 2.7安装—— numpy 1.7和1.8使用 icc编译,在运行 Ubuntu 的 AMD opteron 核心上使用标准选项。

这是在1.8升级之前和之后的测试运行:

import numpy as np
import timeit


arr_1D=np.arange(5000,dtype=np.double)
arr_2D=np.arange(500**2,dtype=np.double).reshape(500,500)
arr_3D=np.arange(500**3,dtype=np.double).reshape(500,500,500)


print 'Summation test:'
print timeit.timeit('np.sum(arr_3D)',
'import numpy as np; from __main__ import arr_1D, arr_2D, arr_3D',
number=5)/5
print timeit.timeit('np.einsum("ijk->", arr_3D)',
'import numpy as np; from __main__ import arr_1D, arr_2D, arr_3D',
number=5)/5
print '----------------------\n'




print 'Power test:'
print timeit.timeit('arr_3D*arr_3D*arr_3D',
'import numpy as np; from __main__ import arr_1D, arr_2D, arr_3D',
number=5)/5
print timeit.timeit('np.einsum("ijk,ijk,ijk->ijk", arr_3D, arr_3D, arr_3D)',
'import numpy as np; from __main__ import arr_1D, arr_2D, arr_3D',
number=5)/5
print '----------------------\n'




print 'Outer test:'
print timeit.timeit('np.outer(arr_1D, arr_1D)',
'import numpy as np; from __main__ import arr_1D, arr_2D, arr_3D',
number=5)/5
print timeit.timeit('np.einsum("i,k->ik", arr_1D, arr_1D)',
'import numpy as np; from __main__ import arr_1D, arr_2D, arr_3D',
number=5)/5
print '----------------------\n'




print 'Einsum test:'
print timeit.timeit('np.sum(arr_2D*arr_3D)',
'import numpy as np; from __main__ import arr_1D, arr_2D, arr_3D',
number=5)/5
print timeit.timeit('np.einsum("ij,oij->", arr_2D, arr_3D)',
'import numpy as np; from __main__ import arr_1D, arr_2D, arr_3D',
number=5)/5
print '----------------------\n'

傻瓜1.7.1:

Summation test:
0.172988510132
0.0934836149216
----------------------


Power test:
1.93524689674
0.839519000053
----------------------


Outer test:
0.130380821228
0.121401786804
----------------------


Einsum test:
0.979052495956
0.126066613197

傻瓜1.8:

Summation test:
0.116551589966
0.0920487880707
----------------------


Power test:
1.23683619499
0.815982818604
----------------------


Outer test:
0.131808176041
0.127472200394
----------------------


Einsum test:
0.781750011444
0.129271841049

我认为 SSE 在时间差异中起了很大的作用,这是相当确定的,应该指出的是,重复这些测试的时间只有约0.003秒。其余的差异应该在对这个问题的其他回答中涵盖。

Numpy 1.21.2的更新: 在几乎所有情况下,Numpy 的本机函数都比 einsum 快。只有 einsum 的外部变量和 sum23测试比非 einsum 版本更快。

如果可以使用 numpy 的本机函数,就这样做。

(图片由我的一个项目 完美情节创建。)

enter image description here

enter image description here

enter image description here

enter image description here

enter image description here

enter image description here


重现情节的代码:

import numpy
import perfplot




def setup1(n):
return numpy.arange(n, dtype=numpy.double)




def setup2(n):
return numpy.arange(n ** 2, dtype=numpy.double).reshape(n, n)




def setup3(n):
return numpy.arange(n ** 3, dtype=numpy.double).reshape(n, n, n)




def setup23(n):
return (
numpy.arange(n ** 2, dtype=numpy.double).reshape(n, n),
numpy.arange(n ** 3, dtype=numpy.double).reshape(n, n, n),
)




def numpy_sum(a):
return numpy.sum(a)




def einsum_sum(a):
return numpy.einsum("ijk->", a)




perfplot.save(
"sum.png",
setup=setup3,
kernels=[numpy_sum, einsum_sum],
n_range=[2 ** k for k in range(10)],
)




def numpy_power(a):
return a * a * a




def einsum_power(a):
return numpy.einsum("ijk,ijk,ijk->ijk", a, a, a)




perfplot.save(
"power.png",
setup=setup3,
kernels=[numpy_power, einsum_power],
n_range=[2 ** k for k in range(9)],
)




def numpy_outer(a):
return numpy.outer(a, a)




def einsum_outer(a):
return numpy.einsum("i,k->ik", a, a)




perfplot.save(
"outer.png",
setup=setup1,
kernels=[numpy_outer, einsum_outer],
n_range=[2 ** k for k in range(13)],
)




def dgemm_numpy(a):
return numpy.dot(a, a)




def dgemm_einsum(a):
return numpy.einsum("ij,jk", a, a)




def dgemm_einsum_optimize(a):
return numpy.einsum("ij,jk", a, a, optimize=True)




perfplot.save(
"dgemm.png",
setup=setup2,
kernels=[dgemm_numpy, dgemm_einsum],
n_range=[2 ** k for k in range(13)],
)




def dot_numpy(a):
return numpy.dot(a, a)




def dot_einsum(a):
return numpy.einsum("i,i->", a, a)




perfplot.save(
"dot.png",
setup=setup1,
kernels=[dot_numpy, dot_einsum],
n_range=[2 ** k for k in range(20)],
)




def sum23_numpy(data):
a, b = data
return numpy.sum(a * b)




def sum23_einsum(data):
a, b = data
return numpy.einsum("ij,oij->", a, b)




perfplot.save(
"sum23.png",
setup=setup23,
kernels=[sum23_numpy, sum23_einsum],
n_range=[2 ** k for k in range(10)],
)