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

paw*_*cki 13 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))
Run Code Online (Sandbox Code Playgroud)

现在:

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)
Run Code Online (Sandbox Code Playgroud)

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

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

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)
Run Code Online (Sandbox Code Playgroud)

MSe*_*ert 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))
Run Code Online (Sandbox Code Playgroud)

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

在此输入图像描述

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)
Run Code Online (Sandbox Code Playgroud)

可能的解释

很难找到差异的确切原因.但是有一些可能的选择,假设你有一个带有编译_functools模块CPython版本(我使用的CPython的所有桌面版本都有它).

正如您已经发现的那样,Python版本partial会慢得多.

  • partial在C中实现,可以直接调用函数 - 没有中间Python层1.在lambda另一方面,需要做一个Python级别调用"捕获"功能.

  • partial实际上知道参数如何组合在一起.因此,它可以创建更有效地传递给函数的参数(它只是将存储的参数元组连接到传入的参数元组),而不是构建一个全新的参数元组.

  • 在最近的Python版本中,为了优化函数调用(所谓的FASTCALL优化),改变了几个内部.Victor Stinner在他的博客上列出了相关的拉取请求,以便您想要了解更多相关信息.

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

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

脚注

1如果从Python调用函数,它使用OP代码CALL_FUNCTION,它实际上是围绕PyObject_Call*(或FASTCAL)函数包装器(这就是我对Python层的意思).但它还包括创建参数元组/字典.如果从C函数调用函数,则可以通过直接调用PyObject_Call*函数来避免使用此瘦包装器.

如果您对OP代码感兴趣,可以dis组装函数:

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
Run Code Online (Sandbox Code Playgroud)

正如您所看到的,CALL_FUNCTION操作码实际上就在那里.

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

  • @PeterNimroot这只是一个后备,如果你没有编译的`_functools`模块([该类被不久之后的导入覆盖](https://github.com/python/cpython/blob/master/Lib/ functools.py#L312-L315)).而你很少有这种情况. (2认同)