究竟`functools.partial`正在制作什么?

时间:2018-04-22 12:14:59

标签: python performance cpython python-internals functools

CPython 3.6.4:

from functools import partial

def add(x, y, z, a):
    return x + y + z + a

list_of_as = list(range(10000))

def max1():
    return max(list_of_as , key=lambda a: add(10, 20, 30, a))

def max2():
    return max(list_of_as , key=partial(add, 10, 20, 30))

现在:

In [2]: %timeit max1()
4.36 ms ± 42.3 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

In [3]: %timeit max2()
3.67 ms ± 25.9 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

我认为partial只是记住部分参数,然后在使用其余参数调用时将它们转发到原始函数(所以它只不过是一个快捷方式),但它似乎是一些优化。在我的情况下,与max2相比,整个max1函数优化了15%,这非常好。

知道优化是什么会很棒,所以我可以更有效地使用它。 Docs对任何优化均保持沉默。毫不奇怪,"大致相当于"实施(在文档中给出),根本没有优化:

In [3]: %timeit max2()  # using `partial` implementation from docs 
10.7 ms ± 267 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

1 个答案:

答案 0 :(得分:10)

以下参数实际上仅适用于CPython,对于其他Python实现,它可能完全不同。你实际上说你的问题是关于CPython但是我认为重要的是要意识到这些深入的问题几乎总是依赖于实现细节,这些细节可能因不同的实现而有所不同,甚至可能在不同的CPython版本之间有所不同(例如CPython 2.7可能完全不同,但也可能是CPython 3.5)!

计时

首先,我无法重现15%甚至20%的差异。在我的电脑上,差异大约是10%。更改lambda时甚至更少,因此不必从全局范围中查找add(正如评论中已经指出的那样,您可以将add函数作为默认值传递函数的参数,以便查找在本地范围内发生。)

from functools import partial

def add(x, y, z, a):
    return x + y + z + a

def max_lambda_default(lst):
    return max(lst , key=lambda a, add=add: add(10, 20, 30, a))

def max_lambda(lst):
    return max(lst , key=lambda a: add(10, 20, 30, a))

def max_partial(lst):
    return max(lst , key=partial(add, 10, 20, 30))

我实际上对这些进行了基准测试:

enter image description here

from simple_benchmark import benchmark
from collections import OrderedDict

arguments = OrderedDict((2**i, list(range(2**i))) for i in range(1, 20))
b = benchmark([max_lambda_default, max_lambda, max_partial], arguments, "list size")

%matplotlib notebook
b.plot_difference_percentage(relative_to=max_partial)

可能的解释

很难找到差异的确切原因。但是,有一些可能的选项,假设你有一个CPython version with compiled _functools module(我使用的所有桌面版CPython都有它)。

正如您已经发现的那样Python version of partial会明显变慢。

  • partial在C中实现,可以直接调用函数 - 没有中间Python层 1 。另一方面,lambda需要对“捕获”函数进行Python级别调用。

  • partial实际上知道参数如何组合在一起。因此,它可以创建更有效地传递给函数的参数(它只是concatenats the stored argument tuple to the passed in argument tuple),而不是构建一个全新的参数元组。

  • 在最近的Python版本中,为了优化函数调用(所谓的FASTCALL优化),改变了几个内部。如果您想了解更多有关它的信息,Victor Stinner会在blog上列出相关的拉取请求。

    这可能会同时影响lambdapartial但又因为partial是一个C函数,它知道哪一个直接调用而不必推断就像lambda那样。

然而,要意识到创建partial会产生一些开销是非常重要的。收支平衡点是~10个列表元素,如果列表较短,则lambda会更快。

脚注

1 如果从Python调用函数,则使用OP代码CALL_FUNCTION,实际上是wrapper (that's what I meant with Python layer) around the PyObject_Call* (or FASTCAL) functions。但它还包括创建参数元组/字典。如果从C函数调用函数,则可以通过直接调用PyObject_Call*函数来避免使用此瘦包装。

如果您对OP代码感兴趣,可以disassemble the function

import dis

dis.dis("add(10, 20, 30, a)")

  1           0 LOAD_NAME                0 (add)
              2 LOAD_CONST               0 (10)
              4 LOAD_CONST               1 (20)
              6 LOAD_CONST               2 (30)
              8 LOAD_NAME                1 (a)
             10 CALL_FUNCTION            4
             12 RETURN_VALUE

正如您所见,CALL_FUNCTION操作码实际上就在那里。

暂且不说:LOAD_NAMElambda_defaultlambda之间的性能差异负责,没有默认值。这是因为加载名称实际上是通过检查本地范围(函数范围)开始的,在add=add的情况下,add函数在本地范围内,然后可以停止。如果您没有在本地范围内,它将检查每个周围的范围,直到找到名称,并且只有当它到达全局范围时才会停止。每次调用lambda时都会进行查找!