Почему использование аргументов делает эту функцию намного медленнее?

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

В трех разных версиях становится ясно, что переменные значительно замедляют работу программы, и я хотел бы знать, почему.

Версия 1: 7.5 секунд

Здесь была оригинальная (несколько упрощенная для этого вопроса) функция:

def version1(n, p):
  return ((n*n - 2) & ((1 << p) - 1)) + ((n*n - 2) >> p)

При запуске с модулем timeit 100 раз:

timeit.timeit("version1(200, 500000000)", "from __main__ import version1", number=100)

требуется 7.5 секунд.

Версия 2: 0.0001 секунд

Однако, вот вторая версия, в которой нет параметров, и числа непосредственно помещаются в возвращаемое значение. Эквадор точно такой же, как версия 1:

def version2():
  return ((200*200 - 2) & ((1 << 500000000) - 1)) + ((200*200 - 2) >> 500000000)

При запуске с модулем timeit 100 раз:

timeit.timeit("version2()", "from __main__ import version2", number=100

в этом случае требуется только 0.00001 секунд!

Версия 3: 6,3 секунды

Наконец, для полноты я попробовал версию, которая не имела параметров, но сохранила ее значения как переменные:

def version3():
  n = 200
  p = 500000000
  return ((n*n - 2) & ((1 << p) - 1)) + ((n*n - 2) >> p)

При запуске с timeit:

timeit.timeit("version3()", "from __main__ import version3", number = 100)

потребовалось 6.3 секунд, что относительно близко к версии 1.

Почему одна и та же функция может занимать намного больше времени, когда есть переменные, и как я могу сделать Версию 1 более эффективной?

Ответы

Ответ 1

Python предварительно вычисляет вычисления при компиляции в виде так называемой оптимизации peep-hole:

>>> import dis
>>> def version2():
...   return ((200*200 - 2) & ((1 << 500000000) - 1)) + ((200*200 - 2) >> 500000000)
...
>>> dis.dis(version2)
  2           0 LOAD_CONST              13 (39998)
              2 RETURN_VALUE

version2() возвращает уже вычисленное значение и не работает. Возвращение константы, конечно, намного, намного быстрее, чем вычислять значение каждый раз.

Смотрите fold_binops_on_constants function в исходном файле peephole.c Python, чтобы узнать, как это делает компилятор.

В результате компиляция version2 занимает (много) больше времени, чем version1:

>>> import timeit
>>> version1_text = '''\
... def version1(n, p):
...   return ((n*n - 2) & ((1 << p) - 1)) + ((n*n - 2) >> p)
... '''
>>> version2_text = '''\
... def version2():
...   return ((200*200 - 2) & ((1 << 500000000) - 1)) + ((200*200 - 2) >> 500000000)
... '''
>>> timeit.timeit("compile(t, '', 'exec')", 'from __main__ import version1_text as t', number=10)
0.00028649598243646324
>>> timeit.timeit("compile(t, '', 'exec')", 'from __main__ import version2_text as t', number=10)
2.2103765579813626

Хорошо, что Python кэширует результаты байткода компиляции!

Промежуточные результаты каждого подвыражения также сохраняются в атрибуте co_consts объекта кода, а некоторые из них довольно большие:

>>> import sys
>>> consts = version2.__code__.co_consts
>>> for obj in consts:
...     size = sys.getsizeof(obj)
...     print(f'{type(obj)!s:<18} {size:<8} {"<too large to print>" if size > 100 else obj}')
...
<class 'NoneType'> 16       None
<class 'int'>      28       200
<class 'int'>      28       2
<class 'int'>      28       1
<class 'int'>      28       500000000
<class 'int'>      28       40000
<class 'int'>      28       39998
<class 'int'>      66666692 <too large to print>
<class 'int'>      66666692 <too large to print>
<class 'int'>      28       39998
<class 'int'>      28       40000
<class 'int'>      28       39998
<class 'int'>      24       0
<class 'int'>      28       39998

поэтому это сделало кеш байт-кода немного больше:

>>> import marshal
>>> len(marshal.dumps(version1.__code__))
129
>>> len(marshal.dumps(version2.__code__))
133333481

Это минимум 127 МБ для файла .pyc для модуля, который содержит вашу версию без аргументов!

Ответ 2

Как указывает подробный ответ Martijn, version2 является более быстрым из-за постоянного сгибания CPython, число переменных не вступает в игру.

Что касается version3 по сравнению с version1, version3 выполняется быстрее, потому что при вызове version1 выполняется дополнительный анализ аргументов.

Python должен выполнить этот разбор для каждого вызова. Когда вы это время и повторите вызов 100 раз, это отличие (что в противном случае не заметно заметно). Вы получите несколько разные результаты синхронизации, если вы определили аргументы как **kwargs, *args или со значением по умолчанию.