`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 上有一个相关的拉取请求列表,如果您想了解更多相关信息。
这可能会同时影响 lambda
和 partial
但又因为 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 感兴趣,您可以 dis
assemble 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
时都会进行该查找!
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 上有一个相关的拉取请求列表,如果您想了解更多相关信息。
这可能会同时影响
lambda
和partial
但又因为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 感兴趣,您可以 dis
assemble 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
时都会进行该查找!