映射单个函数比两次映射两个单独的函数慢?

Ale*_*ane 3 python iteration performance nested-function python-2.7

下面的例子似乎暗示了我不明白的运行时优化

\n

谁能解释这种行为以及它如何适用于更一般的情况?

\n

例子

\n

考虑以下简单(示例)函数

\n
def y(x): # str output\n    y = 1 if x else 0\n    return str(y)\n\ndef _y(x): # no str\n    y = 1 if x else 0\n    return y\n
Run Code Online (Sandbox Code Playgroud)\n

假设我想将函数应用于y列表中的所有元素

\n
l = range(1000) # test input data\n
Run Code Online (Sandbox Code Playgroud)\n

结果

\n

操作map必须遍历列表中的所有元素。将函数分解为双map精度函数明显优于单个map函数,这似乎违反直觉

\n
%timeit map(str, map(_y, l))\n1000 loops, best of 3: 206 \xc2\xb5s per loop\n\n%timeit map(y, l)\n1000 loops, best of 3: 241 \xc2\xb5s per loop\n
Run Code Online (Sandbox Code Playgroud)\n

更一般地说,这也适用于非标准库嵌套函数,例如

\n
def f(x):\n    return _y(_y(x))\n\n%timeit map(_y, map(_y, l))\n1000 loops, best of 3: 235 \xc2\xb5s per loop\n%timeit map(f, l)\n1000 loops, best of 3: 294 \xc2\xb5s per loop\n
Run Code Online (Sandbox Code Playgroud)\n

这是一个 python 开销问题吗?map在可能的情况下编译低级 python 代码,从而在必须解释嵌套函数时受到限制?

\n

Mar*_*ers 5

不同之处在于它map()是用 C 代码实现的,调用其他 C 实现的函数很便宜,而调用 Python 代码很昂贵。最重要的是,从 Python 代码调用其他可调用对象的成本也很高:

>>> timeit.timeit('f(1)', 'def f(x): return str(x)')
0.21682000160217285
>>> timeit.timeit('str(1)')
0.140916109085083
Run Code Online (Sandbox Code Playgroud)

第三,您将函数对象传递给map()(因此无需进行进一步的查找),但每次都y()必须查找名称。str与本地查找相比,全局查找的成本相对较高;将全局变量绑定到函数参数以使其成为局部变量可以帮助稍微抵消这一点:

>>> timeit.timeit('f(1)', 'def f(x, _str=str): return _str(x)')
0.19425392150878906
Run Code Online (Sandbox Code Playgroud)

更接近版本str(1),尽管也必须使用全局;如果你也给时间测试一个本地,它仍然可以轻松击败函数调用:

>>> timeit.timeit('_str(1)', '_str = str')
0.10266494750976562
Run Code Online (Sandbox Code Playgroud)

因此,Python 字节码执行需要为每次调用创建一个额外的对象(堆栈帧)。当调用其他代码时,该堆栈帧对象必须在专用的 Python 调用堆栈上进行管理。此外,您的函数每次都会将该名称作为全局名称y进行查找,而调用则保留对该对象的单个引用并一遍又一遍地使用它。strmap(str, ...)

通过将str()调用移出y函数并让map()调用str()直接通过单个引用进行调用,您删除了堆栈处理和全局名称查找,并稍微加快了速度。

如图所示,map(y, l)每个输入值执行:

  • 创建堆栈帧y,执行主体
    • 寻找str全球
      • 将堆栈帧压y入堆栈
      • 执行str(...)
      • 从堆栈中弹出堆栈帧
    • 返回结果

map(str, map(_y, l))执行

  • 创建堆栈框架_y
    • 返回结果
  • 执行str(...)

这同样适用于您的f()函数设置:

>>> def f(x):
...     return _y(_y(x))
...
>>> timeit.timeit("map(_y, map(_y, l))", 'from __main__ import _y, testdata as l', number=10000)
2.691640853881836
>>> timeit.timeit("map(f, l)", 'from __main__ import f, testdata as l', number=10000)
3.104063034057617
Run Code Online (Sandbox Code Playgroud)

调用map()两次_y比将调用嵌套在另一个函数中更快_y(_y(x)),后者必须进行全局名称查找并对 ​​Python 堆栈施加更多压力;在您的f()示例中,每次map()迭代都必须创建 3 个堆栈帧,并将它们推入堆栈或从堆栈中弹出,而在您的map(_y, map(_y, ...))设置中,每个迭代项仅创建 2 个帧:

  • 创建堆栈帧f,执行主体
    • 寻找_y全球
      • 将堆栈帧压f入堆栈
      • 创建堆栈帧_y,执行主体
      • 从堆栈中弹出堆栈帧
    • 查找_y全局(是的,再次)
      • 将堆栈帧压f入堆栈
      • 创建堆栈帧_y,执行主体
      • 从堆栈中弹出堆栈帧
    • 返回结果

相对:

  • 创建堆栈帧_y,执行主体
    • 返回结果
  • 创建堆栈帧_y,执行主体
    • 返回结果

同样,使用当地人可以稍微抵消差异:

>>> def f(x, _y=_y):
...     return _y(_y(x))
...
>>> timeit.timeit("map(f, l)", 'from __main__ import f, testdata as l', number=10000)
2.981696128845215
Run Code Online (Sandbox Code Playgroud)

但额外的 Python 框架对象仍然阻碍了单个map(f, ...)调用。


TLDRy()与 double 版本相比,您的函数会遭受 O(N) 额外的全局名称查找和 O(N) 额外的堆栈帧对象被推入和推出 Python 堆栈的麻烦map()

如果这场比赛的速度很重要,请尽量避免在紧密循环中创建 Python 堆栈帧和全局名称查找。