【问题标题】: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

标签: python performance python-3.x itertools cpython


【解决方案1】:

有几个因素会导致(共同)观察到的性能差异:

  • 如果 zip 在下一次调用 __next__ 时引用计数为 1,则 zip 会重新使用返回的 tuple
  • map 构建一个 new tuple,每次调用 __next__ 时都会传递给“映射函数”。实际上,它可能不会从头开始创建新的元组,因为 Python 为未使用的元组维护了一个存储。但在这种情况下,map 必须找到一个未使用的大小合适的元组。
  • starmap 检查可迭代对象中的下一项是否为 tuple 类型,如果是,则将其传递。
  • 在 C 代码中使用 PyObject_Call 调用 C 函数不会创建传递给被调用者的新元组。

所以starmapzip 只会一遍又一遍地使用一个传递给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)

是的,tuples 并不是真正不可变的,一个简单的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

所以有很多“巧合”导致starmapzip 在被调用函数也是C 函数时比使用多个参数调用map 更快...

【讨论】:

    【解决方案2】:

    我注意到的一个区别是map 如何从可迭代对象中检索项目。 mapzip 都从每个传递的迭代器中创建一个迭代器元组。现在 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 callAdd _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 次迭代之前应该很慢)
    • 是的,你是对的。我的“研究”让我得出了同样的结论。
    猜你喜欢
    • 1970-01-01
    • 2018-03-29
    • 2011-04-24
    • 2010-12-28
    • 2014-08-23
    • 1970-01-01
    • 2012-04-27
    • 2011-12-25
    • 2012-05-03
    相关资源
    最近更新 更多