我试图制作一个纯python(没有外部依赖)两个序列的元素比较。我的第一个解决方案是:
list(map(operator.eq, seq1, seq2))
然后我从starmap
找到itertools
函数,这看起来和我很相似。但事实证明,在最糟糕的情况下,我的计算机上的速度要快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
答案 0 :(得分:6)
有几个因素会导致观察到的性能差异(与此相关):
zip
如果在下一次tuple
来电时参考次数为1,则重新使用返回的__next__
。map
构建一个 new tuple
,传递给"映射函数"每次__next__
来电。实际上它可能不会从头开始创建一个新的元组,因为Python为未使用的元组维护了一个存储空间。但在这种情况下map
必须找到一个大小合适的未使用的元组。starmap
检查迭代中的下一个项目是否为tuple
类型,如果是,则将其传递给它。PyObject_Call
在C代码中调用C函数不会创建传递给被调用者的新元组。所以带starmap
的{{1}}只会反复使用一个元组,传递给zip
,从而极大地减少了函数调用开销。另一方面,operator.eq
将在每次调用map
时创建一个新元组(或从CPython 3.6中填充C数组)。那么实际上速度差异只是元组创建开销。
不是链接到源代码,而是提供一些可用于验证的Cython代码:
operator.eq
是的,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
就足以“哄骗”#34; Py_DECREF
相信别人没有提到返回的元组!
至于" tuple-pass-thru":
zip
所以元组是直接传递的(因为它们被定义为C函数!)这对纯Python函数来说不会发生:
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函数调用,被调用函数不是C函数也不会发生:
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函数时,导致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
比调用map
更快...
答案 1 :(得分:1)
我可以注意到的一个区别是map
如何从迭代中检索项目。 map
和zip
都会从每个传递的迭代中创建一个迭代器元组。现在zip
在内部维护一个result tuple,每次调用next时都会填充,另一方面,map
creates a new array*每次调用并释放它。
* 正如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