Почему вызовы функций без аргументов выполняются быстрее?

Я настроил простую пользовательскую функцию, которая принимает некоторые аргументы по умолчанию (Python 3.5):

def foo(a=10, b=20, c=30, d=40):
    return a * b + c * d

и синхронизированные различные вызовы к нему с или без указания значений аргументов:

без указания аргументов:

%timeit foo()
The slowest run took 7.83 times longer than the fastest. This could mean that an intermediate result is being cached 
1000000 loops, best of 3: 361 ns per loop

аргументы:

%timeit foo(a=10, b=20, c=30, d=40)
The slowest run took 12.83 times longer than the fastest. This could mean that an intermediate result is being cached 
1000000 loops, best of 3: 446 ns per loop

как вы можете видеть, есть довольно заметное увеличение времени, необходимого для вызова Аргументы и за не указание их. В простых одноразовых вызовах это может быть незначительным, но накладные расходы масштабируются и становятся более заметными, если выполняется большое количество вызовов функции:

нет аргументов:

%timeit for i in range(10000): foo()
100 loops, best of 3: 3.83 ms per loop

С Аргументами:

%timeit for i in range(10000): foo(a=10, b=20, c=30, d=40)
100 loops, best of 3: 4.68 ms per loop

такое же поведение присутствует и в Python 2.7 где разница во времени между этими вызовами была на самом деле немного больше foo() -> 291ns и foo(a=10, b=20, c=30, d=40) -> 410ns


почему это случится? Должен ли я вообще пытаться избегать указания значений аргументов во время вызовов?

1 ответов


почему это происходит? Следует избегать указания значений аргументов во время вызова?

Вообще Нет. реальная причина, по которой вы можете видеть это, заключается в том, что функция, которую вы используете просто не вычислительноемких. Таким образом, время, необходимое для дополнительных команд байтового кода, выданных в случае, когда аргументы предоставляются, может быть обнаружено с помощью синхронизации.

если, например, у вас была более интенсивная функция формы:

def foo_intensive(a=10, b=20, c=30, d=40): 
    [i * j for i in range(a * b) for j in range(c * d)]

это в значительной степени не покажет никакой разницы в требуемом времени:

%timeit foo_intensive()
10 loops, best of 3: 32.7 ms per loop

%timeit foo_intensive(a=10, b=20, c=30, d=40)
10 loops, best of 3: 32.7 ms per loop

даже при масштабировании до большего количества вызовов время, необходимое для выполнения тела функции, просто превосходит небольшие накладные расходы, введенные дополнительными инструкциями байтового кода.


глядя на байтовый код:

один из способов просмотра сгенерированного байтового кода, выданного для каждого случая вызова, - создание функция, которая обертывает foo и называет это по-разному. А пока давайте создадим fooDefault для вызовов с использованием аргументов по умолчанию и fooKwargs() для функций, указывающих аргументы ключевого слова:

# call foo without arguments, using defaults
def fooDefault():
    foo()

# call foo with keyword arguments
def fooKw():
    foo(a=10, b=20, c=30, d=40)

теперь dis мы можем видеть различия в байтовом коде между ними. Для версии по умолчанию мы видим, что по существу выдается одна команда (игнорируя POP_TOP, который присутствует в обоих случаях) для вызова функции, CALL_FUNCTION:

dis.dis(fooDefaults)
  2           0 LOAD_GLOBAL              0 (foo)
              3 CALL_FUNCTION            0 (0 positional, 0 keyword pair)  
              6 POP_TOP
              7 LOAD_CONST               0 (None)
             10 RETURN_VALUE

С другой стороны, в случае использования слова 8 больше LOAD_CONST команд для загрузки имен аргументов (a, b, c, d) и ценностей (10, 20, 30, 40) в стек значений (даже при загрузке чисел < 256 - это, наверное, очень быстро, в этом случае, так как они кэшируются):

dis.dis(fooKwargs)
  2           0 LOAD_GLOBAL              0 (foo)
              3 LOAD_CONST               1 ('a')    # call starts
              6 LOAD_CONST               2 (10)
              9 LOAD_CONST               3 ('b')
             12 LOAD_CONST               4 (20)
             15 LOAD_CONST               5 ('c')
             18 LOAD_CONST               6 (30)
             21 LOAD_CONST               7 ('d')
             24 LOAD_CONST               8 (40)
             27 CALL_FUNCTION         1024 (0 positional, 4 keyword pair)
             30 POP_TOP                             # call ends
             31 LOAD_CONST               0 (None)
             34 RETURN_VALUE

дополнительно, несколько дополнительных шагов вообще необходимы для случая где аргументы ключевых слов не равны нулю. (например, в ceval/_PyEval_EvalCodeWithName()).

хотя это действительно быстрые команды, они подводят итог. Чем больше аргументов, тем больше сумма и, когда многие вызовы функции фактически выполняются, они накапливаются, чтобы привести к ощутимой разнице во времени выполнения.


прямым результатом этого является то, что чем больше значений мы указываем, тем больше команд должно быть выдано и функция работает медленнее. Кроме того, указание позиционных аргументов, распаковка позиционных аргументов и распаковка аргументов ключевых слов имеют различный объем накладных расходов, связанных с ними:

  1. аргументы foo(10, 20, 30, 40): требуются 4 дополнительные команды для загрузки каждого значения.
  2. список распаковке foo(*[10, 20, 30, 40]): 4 LOAD_CONST команды и дополнительный BUILD_LIST.
    • использование списка, как в foo(*l) немного сокращает выполнение, так как мы предоставляем уже построенный список, содержащий значения.
  3. распаковка словарь foo(**{'a':10, 'b':20, 'c': 30, 'd': 40}): 8 LOAD_CONST команды и BUILD_MAP.
    • как со списком распаковки foo(**d) сократит выполнение, потому что встроенный список будет поставляться.

всего заказ на выполнение различных случаях звонки:

defaults < positionals < keyword arguments < list unpacking < dictionary unpacking

я предлагаю использовать dis.dis по этим делам и видя их различия.


в заключение:

как отметил @goofd в комментарии, это действительно то, о чем не следует беспокоиться, это действительно зависит от варианта использования. Если вы часто вызываете "легкие" функции с точки зрения вычислений, указание значений по умолчанию приведет к небольшому увеличению скорости. Если вы часто предоставляете разные значения, это создает рядом с ничего.

таким образом, это, вероятно, незначительно и пытается получить повышение от неясных краевых случаев, как это действительно толкает его. Если вы обнаружите, что делаете это, вы можете посмотреть на такие вещи, как PyPy и Cython.