地图与星图的性能?
Posted
技术标签:
【中文标题】地图与星图的性能?【英文标题】:Performance of map vs starmap? 【发布时间】:2017-09-12 08:53:40 【问题描述】:我试图对两个序列进行纯python(没有外部依赖项)逐元素比较。我的第一个解决方案是:
list(map(operator.eq, seq1, seq2))
然后我从itertools
中找到了starmap
函数,看起来和我很相似。但在最坏的情况下,它在我的电脑上的速度提高了 37%。由于这对我来说并不明显,我测量了从生成器中检索 1 个元素所需的时间(不知道这种方式是否正确):
from operator import eq
from itertools import starmap
seq1 = [1,2,3]*10000
seq2 = [1,2,3]*10000
seq2[-1] = 5
gen1 = map(eq, seq1, seq2))
gen2 = starmap(eq, zip(seq1, seq2))
%timeit -n1000 -r10 next(gen1)
%timeit -n1000 -r10 next(gen2)
271 ns ± 1.26 ns per loop (mean ± std. dev. of 10 runs, 1000 loops each)
208 ns ± 1.72 ns per loop (mean ± std. dev. of 10 runs, 1000 loops each)
在检索元素时,第二种解决方案的性能提高了 24%。之后,它们都为list
产生相同的结果。但是从某个地方我们获得了额外的 13% 时间:
%timeit list(map(eq, seq1, seq2))
%timeit list(starmap(eq, zip(seq1, seq2)))
5.24 ms ± 29.4 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
3.34 ms ± 84.8 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
我不知道如何更深入地分析这种嵌套代码?所以我的问题是为什么第一个生成器的检索速度如此之快,并且我们在list
函数中获得了额外的 13%?
编辑:
我的第一个意图是执行逐元素比较而不是all
,因此all
函数被替换为list
。此替换不影响时序比。
Windows 10(64 位)上的 CPython 3.6.2
【问题讨论】:
为什么不直接使用seq1 == seq2
?
@Błotosmętek 感谢您的指正!我的第一个意图是元素比较而不是all
,这在我的问题中并不明显:) 真的,如果你用list
代替all
,时间顺序将是相同的。
什么 Python 版本?这是 CPython 吗?
@MSeifert 是的,Windows 10(64 位)上的 CPython 3.6.2
【参考方案1】:
有几个因素会导致(共同)观察到的性能差异:
如果zip
在下一次调用 __next__
时引用计数为 1,则 zip
会重新使用返回的 tuple
。
map
构建一个 new tuple
,每次调用 __next__
时都会传递给“映射函数”。实际上,它可能不会从头开始创建新的元组,因为 Python 为未使用的元组维护了一个存储。但在这种情况下,map
必须找到一个未使用的大小合适的元组。
starmap
检查可迭代对象中的下一项是否为 tuple
类型,如果是,则将其传递。
在 C 代码中使用 PyObject_Call
调用 C 函数不会创建传递给被调用者的新元组。
所以starmap
和zip
只会一遍又一遍地使用一个传递给operator.eq
的元组,从而极大地减少函数调用开销。另一方面,map
将在每次调用 operator.eq
时创建一个新元组(或从 CPython 3.6 开始填充 C 数组)。所以实际上速度差异只是元组创建开销。
我将提供一些可用于验证这一点的 Cython 代码,而不是链接到源代码:
In [1]: %load_ext cython
In [2]: %%cython
...:
...: from cpython.ref cimport Py_DECREF
...:
...: cpdef func(zipper):
...: a = next(zipper)
...: print('a', a)
...: Py_DECREF(a)
...: b = next(zipper)
...: print('a', a)
In [3]: func(zip([1, 2], [1, 2]))
a (1, 1)
a (2, 2)
是的,tuple
s 并不是真正不可变的,一个简单的Py_DECREF
就足以“欺骗”zip
相信没有其他人持有对返回元组的引用!
至于“tuple-pass-thru”:
In [4]: %%cython
...:
...: def func_inner(*args):
...: print(id(args))
...:
...: def func(*args):
...: print(id(args))
...: func_inner(*args)
In [5]: func(1, 2)
1404350461320
1404350461320
所以元组被直接传递(只是因为这些被定义为 C 函数!)这不会发生在纯 Python 函数中:
In [6]: def func_inner(*args):
...: print(id(args))
...:
...: def func(*args):
...: print(id(args))
...: func_inner(*args)
...:
In [7]: func(1, 2)
1404350436488
1404352833800
请注意,如果被调用的函数不是 C 函数,即使是从 C 函数调用,也不会发生这种情况:
In [8]: %%cython
...:
...: def func_inner_c(*args):
...: print(id(args))
...:
...: def func(inner, *args):
...: print(id(args))
...: inner(*args)
...:
In [9]: def func_inner_py(*args):
...: print(id(args))
...:
...:
In [10]: func(func_inner_py, 1, 2)
1404350471944
1404353010184
In [11]: func(func_inner_c, 1, 2)
1404344354824
1404344354824
所以有很多“巧合”导致starmap
与zip
在被调用函数也是C 函数时比使用多个参数调用map
更快...
【讨论】:
【参考方案2】:我注意到的一个区别是map
如何从可迭代对象中检索项目。 map
和 zip
都从每个传递的迭代器中创建一个迭代器元组。现在 zip
在内部维护一个 result tuple ,每次调用 next 时都会填充它,另一方面,map
creates a new array* 每次调用 next 并释放它。
*正如 MSeifert 所指出的,直到 3.5.4 map_next
每次都用于分配一个新的 Python 元组。这在 3.6 中发生了变化,直到使用了 5 个可迭代的 C 堆栈,并且使用了大于该堆的任何东西。相关 PR:Issue #27809: map_next() uses fast call 和 Add _PY_FASTCALL_SMALL_STACK constant |问题:https://bugs.python.org/issue27809
【讨论】:
假设这是 3.6,请注意 3.5.4 中的代码看起来不同。 :) @MSeifert 我想知道 3.5.4 实现与 3.6.2 相比有多少慢/快。 (由于堆与 C-Stack 访问,在 5 次迭代之前应该很慢) 是的,你是对的。我的“研究”让我得出了同样的结论。以上是关于地图与星图的性能?的主要内容,如果未能解决你的问题,请参考以下文章