Что именно оптимизирует "functools.partial"?

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 !