【问题标题】:Mapping a single function is slower than twice mapping two separate functions?映射单个函数比映射两个单独函数两次要慢?
【发布时间】:2017-10-19 16:48:25
【问题描述】:

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

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

示例

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

def y(x): # str output
    y = 1 if x else 0
    return str(y)

def _y(x): # no str
    y = 1 if x else 0
    return y

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

l = range(1000) # test input data

结果

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

%timeit map(str, map(_y, l))
1000 loops, best of 3: 206 µs per loop

%timeit map(y, l)
1000 loops, best of 3: 241 µs per loop

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

def f(x):
    return _y(_y(x))

%timeit map(_y, map(_y, l))
1000 loops, best of 3: 235 µs per loop
%timeit map(f, l)
1000 loops, best of 3: 294 µs per loop

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

【问题讨论】:

  • 我刚刚使用 python 3.6 在我的机器上运行了你的示例。在这两个基准测试中,与双地图相比,单地图每个循环只需要大约一半的时间。你用的是哪个python版本?我的 timeit 输出每个循环大约需要 300 ns,这比您的版本快得多。你有一台 cpu / ram 受限的机器吗?
  • 我们在几台机器上运行并使用 Python 2.7 得到了相同的结果。在 Python 3 上,我认为 map 以不同的方式评估?对 RAM 或 CPU 也没有限制
  • python 2 中的map 函数返回一个列表。在 python 3 中,它会产生结果,因此它们是动态评估的。这解释了不同的基准测试结果。
  • @pschill:在 Python 3 中创建 map() 迭代器对象确实非常快,但不会进行任何迭代。在 Python 2 中,map() 立即进行迭代并返回一个列表。如果您想在 Python 3 中进行测试,请在 map() 调用周围添加 list() 调用。

标签: python performance python-2.7 iteration nested-function


【解决方案1】:

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

>>> timeit.timeit('f(1)', 'def f(x): return str(x)')
0.21682000160217285
>>> timeit.timeit('str(1)')
0.140916109085083

第三,您将函数对象传递给map()(因此不再进行查找),但y() 必须每次都查找str 名称。与本地查找相比,全局查找相对昂贵;将全局绑定到函数参数以使其成为本地可以帮助抵消一点:

>>> timeit.timeit('f(1)', 'def f(x, _str=str): return _str(x)')
0.19425392150878906

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

>>> timeit.timeit('_str(1)', '_str = str')
0.10266494750976562

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

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

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

  • y创建stackframe,执行body
    • 查找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

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

  • f创建stackframe,执行body
    • _y 查找为全局
      • f 堆栈帧推入堆栈
      • _y创建stackframe,执行body
      • 从堆栈中弹出堆栈帧
    • _y 查找为全局(是的,再次)
      • f 堆栈帧推入堆栈
      • _y创建stackframe,执行body
      • 从堆栈中弹出堆栈帧
    • 返回结果

对比:

  • _y创建stackframe,执行body
    • 返回结果
  • _y创建stackframe,执行body
    • 返回结果

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

>>> 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

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


TLDR:与双 @ 相比,您的 y() 函数有 O(N) 次额外的全局名称查找和 O(N) 次额外的堆栈框架对象被推入和推出 Python 堆栈。 987654364@版本。

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

【讨论】:

  • 是否有操作逻辑的顺序例如is this C func? => No => grab python function ?这是否也适用于包装到 python 中的C 代码,例如cython?关于我的第一个问题,是否必须从 python globals() dict 读取所有 python 函数?
  • @AlexanderMcFarlane:全局命名空间中的名称查找不区分实现细节,它只是一个名称 -> 对象映射。 Cython 是定义原生对象的另一种方式,与 C 代码中定义的原生对象没有区别。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2021-10-28
  • 2021-08-23
  • 2022-01-23
  • 1970-01-01
  • 2012-07-17
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多