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
оптимизируется на 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)
Ответ 1
Следующие аргументы фактически применимы только к 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 с скомпилированным модулем _functools
(все версии настольных версий CPython, которые я использую, имеют это).
Как вы уже выяснили, partial
часть Python будет значительно медленнее.
-
partial
реализуется в C и может вызывать функцию напрямую - без промежуточного слоя Python 1. С другой стороны, lambda
должна выполнить вызов уровня Python для "захваченной" функции.
-
partial
фактически знает, как аргументы совпадают. Таким образом, он может создавать аргументы, которые передаются функции более эффективно (он просто конкатенает сохраненный кортеж аргументов в переданный в аргументе tuple) вместо того, чтобы создавать абсолютно новый аргумент tuple.
-
В более поздних версиях Python несколько изменений были изменены, чтобы оптимизировать вызовы функций (так называемая оптимизация FASTCALL). У Виктора Стинера есть список связанных запросов на тягу в его блоге, если вы хотите узнать об этом больше.
Вероятно, это повлияет как на lambda
и на partial
но опять же, потому что partial
- это функция С, она знает, какой из них вызывать напрямую, не делая этого, как lambda
.
Однако очень важно понять, что для создания partial
есть некоторые накладные расходы. Точка безубыточности для ~ 10 элементов списка, если список короче, тогда lambda
будет быстрее.
Сноски
1 Если вы вызываете функцию из Python, она использует OP-код CALL_FUNCTION
который на самом деле является оболочкой (что я имел в виду с слоем Python) вокруг PyObject_Call*
(или FASTCAL). Но он также включает создание аргумента tuple/dictionary. Если вы вызываете функцию из функции 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
Как вы видите, код CALL_FUNCTION
на самом деле там.
Как и в стороне: LOAD_NAME
отвечает за разницы в производительности между lambda_default
и lambda
без умолчания. Это потому, что загрузка имени фактически начинается с проверки локальной области (области действия), в случае add=add
добавить функцию добавления в локальную область, и она может остановиться тогда. Если у вас его нет в локальной области, он будет проверять каждую окружение до тех пор, пока не найдет имя, и он остановится только при достижении глобальной области. И этот поиск выполняется каждый раз, когда вызывается lambda
!