`functools.partial` 的优化到底是什么?

What exactly is the optimization `functools.partial` is making?

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 对任何优化保持沉默。毫不奇怪,"roughly equivalent to" 实现(在文档中给出)根本没有优化:

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)

以下参数实际上仅适用于 CPython,对于其他 Python 实现可能完全不同。你实际上说你的问题是关于 CPython 但我认为重要的是要认识到这些 in-depth 问题几乎总是取决于实现细节,这些细节可能因不同的实现而不同,甚至可能在不同的 C 之间不同Python 版本(例如 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))

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

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 会产生一些开销。 break-even 点用于 ~10 个列表元素,如果列表较短,则 lambda 会更快。

脚注

1 如果您从 Python 调用一个函数,它使用 OP-code CALL_FUNCTION 实际上是 wrapper (that's what I meant with Python layer) around the PyObject_Call* (or FASTCAL) functions。但它还包括创建参数 tuple/dictionary。如果您从 C 函数调用函数,您可以通过直接调用 PyObject_Call* 函数来避免这种薄包装。

如果您对 OP-Codes 感兴趣,您可以 disassemble the function:

import dis
    
dis.dis(max_lambda_default)

 0 LOAD_GLOBAL              0 (max)
 2 LOAD_FAST                0 (lst)
 4 LOAD_GLOBAL              1 (add)
 6 BUILD_TUPLE              1
 8 LOAD_CONST               1 (<code object <lambda>>)
10 LOAD_CONST               2 ('max_lambda_default.<locals>.<lambda>')
12 MAKE_FUNCTION            1 (defaults)
14 LOAD_CONST               3 (('key',))
16 CALL_FUNCTION_KW         2
18 RETURN_VALUE

Disassembly of <code object <lambda>>:
 0 LOAD_FAST                1 (add)      <--- (2)
 2 LOAD_CONST               1 (10)
 4 LOAD_CONST               2 (20)
 6 LOAD_CONST               3 (30)
 8 LOAD_FAST                0 (a)
10 CALL_FUNCTION            4            <--- (1)
12 RETURN_VALUE

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

顺便说一句:LOAD_FAST (2) 是造成 lambda_default 和没有默认的 lambda 之间的性能差异的原因(必须求助于较慢的查找) .这是因为加载名称实际上是从检查局部作用域(函数作用域)开始的,在 add=add 的情况下,添加函数在局部作用域中,因此它可以进行更快的查找。如果您在本地范围内没有它,它将检查每个周围的范围,直到找到名称,并且只有在到达全局范围时才会停止。每次调用 lambda 时都会进行该查找!