Причины низкой скорости Python
Гибкость Python препятствует многим оптимизациям, поскольку всякая оптимизация опирается на предположения и заранее оговорённые ограничения. Чем меньше компилятору позволено считать заранее известным, тем меньше у него возможностей. Рассмотрим три основные причины.
1. Динамическая типизация
Тип значения, связанного с именем, становится известен только во время исполнения. Во-первых, интерпретатор проверяет типы на каждом шаге, и эти проверки, повторяющиеся миллионы раз, требуют времени. Во-вторых, он не знает заранее, с какими данными работает, и потому обязан исполнять написанный код буквально, не имея права отбросить заведомо ненужную ветвь или вычислить выражение заранее.
2. Изменяемость всего и вся
В Python почти всё может быть изменено во время исполнения: встроенные имена, тело уже определённой функции, даже локальные переменные чужого кадра стека.
import builtins
print(len("abc"))
len = lambda obj: "mock!"
print(len("abc"))
len = builtins.len
3
mock!
def my_func(a, b):
return a + b
print(my_func(1, 2))
def new_func(a, b):
return a * b
my_func.__code__ = new_func.__code__
print(my_func(1, 2))
3
2
import sys
import ctypes
def change_local_variable():
# берём объект предыдущего кадра стека у вызывающей стороны
frame = sys._getframe(1)
frame.f_locals['my_var'] = "hello"
# Force update
ctypes.pythonapi.PyFrame_LocalsToFast(ctypes.py_object(frame),
ctypes.c_int(0))
def do_smth():
my_var = 1
change_local_variable()
print(my_var)
do_smth()
hello
Пример рассчитан на Python до 3.12 включительно: начиная с 3.13
действует PEP 667. f_locals стал прокси-объектом и записывает непосредственно в переменные кадра, поэтому строки
с ctypes не нужны, а функция PyFrame_LocalsToFast в C API отсутствует, и
обращение к ней приводит к AttributeError. Запись в чужой кадр по-прежнему возможна, причём более коротким кодом.
Интерпретатор обязан выполнять написанное буквально. Он не может вынести проверку i == 0 из цикла, поскольку не знает, не изменятся ли a, i или сам range в процессе выполнения, и оптимизацию, разрешённую любому компилятору C, приходится выполнять вручную.
def do1():
a = [-1] * 1000
for i in range(len(a)):
if i == 0:
a[i] = 1
else:
a[i] = i
def do2():
a = [-1] * 1000
a[0] = 1
for i in range(1, len(a)):
a[i] = i
%timeit -n100 do1()
%timeit -n100 do2()
42.2 μs ± 970 ns per loop (mean ± std. dev. of 7 runs, 100 loops each)
30.6 μs ± 1.14 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
3. CPython
Третьей причиной является сама эталонная реализация языка, написанная давно.
- CPython начинали писать задолго до многоядерных процессоров.
- Производительность никогда не была его главной целью.
- Совместимость с C API ограничивает изменения внутреннего устройства.
Начиная с версии 3.11 CPython заметно ускорился, см. обзор нововведений и раздел Faster CPython. Работа продолжается в проекте faster-cpython, а отдельное направление, Multithreaded Python without the GIL, нацелено на глобальную блокировку GIL, которая рассматривается в отдельной главе.
Момент оптимизации
Premature optimization is the root of all evil
Фразу Кнута обычно понимают следующим образом: сначала пишется работающий код, а быстрым он делается позднее, когда функциональность готова. Следствие: производительность остаётся той, какая получилась случайно, пока кто-либо не обнаружит легко исправимое место, ускоряющее программу без переделки половины кода.
Если требуется быстрая программа, о скорости необходимо думать с самого начала, а прототип делать быстрым, иногда даже быстрее финальной версии. Начать с производительного решения и поддерживать его дешевле, чем ускорять написанное медленно.
Обратной крайностью является «большой комок грязи», архитектура, не продуманная вовсе.
If you think good architecture is expensive, try bad architecture.
Подробнее об этом написано в эссе Фута и Йодера и в статье Википедии.
Мантра оптимизаций
- Не делать
- Делать это позже
- Делать это оптимально
Противоречия с предыдущим разделом нет: думать о производительности необходимо с самого начала, а переписывать код ради скорости — лишь тогда, когда измерения профилировщика показали, что это требуется. Самой быстрой оптимизацией является та, которую не пришлось выполнять, поскольку программа и так укладывается в отведённое время.
Порядок оптимизации
Программисты тратят чудовищно много времени, размышляя о скорости некритичных частей программы, и эти попытки ускорения оказываются вредны, если учесть отладку и сопровождение. Про мелкую эффективность надо забыть в 97 % случаев: преждевременная оптимизация — корень всех зол. Но нельзя упускать возможности в тех критических 3 %.
Д. Кнут, Structured Programming with go to Statements, ACM Computing Surveys, 1974
Основная задача заключается в поиске места, к которому имеет смысл прикладывать усилия. Этому служат два правила.
Правило 1. Профилирование кода
Если функция ускорена в десять раз, а исполняется она в одном проценте случаев, выигрыш ничтожен.
Угадывать, какая часть программы работает дольше всего, бесполезно: профилировщик отвечает на этот вопрос точно, интуиция — почти никогда.
Правило 2. Сохранение корректности
Оптимизация легко нарушает корректность кода незаметно, оставляя результат правдоподобным, но неверным. Прежде чем переписывать фрагмент ради скорости, необходимо покрыть его тестами.
Профилирование
Базовый набор почти полностью входит в стандартную поставку: cProfile собирает профиль, pstats разбирает и сортирует собранное, а SnakeViz представляет результат в виде диаграммы в браузере.
Ниже приведены ещё два инструмента.
- py-spy снимает профиль с уже запущенной программы, не изменяя её код, что необходимо, когда расчёт продолжается третьи сутки и перезапуск недопустим.
- line_profiler профилирует построчно и показывает время, приходящееся на каждую строку.
Измерение времени
Когда требуется измерить время одной функции, а не снимать полный профиль, применяется модуль timeit из стандартной библиотеки.
import timeit
setup = '''
s='abcdefghijklmnopqrstuvwxyz'
def reverse_0(s: str) -> str:
reversed_output = ''
s_length = len(s)
for i in range(s_length-1, 0-1, -1):
reversed_output = reversed_output + s[i]
return reversed_output
def reverse_5(s: str) -> str:
return s[::-1]
'''
timeit.timeit('reverse_0(s)', setup, number=10000)
0.020173080999484228
timeit.timeit('reverse_5(s)', setup, number=10000)
0.001456363000215788
Функция timeit измеряет время по time.perf_counter, на время измерения отключает сборщик мусора и возвращает суммарное время N запусков, а не усреднённое по ним.
Код передан строками, поскольку внутри timeit устроен как шаблонная строка, принимающая параметры пользователя, и накладные расходы на вызов функции-обёртки из измерения исключаются. Настоящие функции timeit также принимает, однако в измерение тогда попадает и их вызов.
В IPython для той же цели предусмотрена магическая команда %timeit, выводящая, в отличие от функции, среднее время и стандартное отклонение, вычисленное по семи прогонам.
def reverse_0(s: str) -> str:
reversed_output = ''
s_length = len(s)
for i in range(s_length-1, 0-1, -1):
reversed_output = reversed_output + s[i]
return reversed_output
%timeit -n100 reverse_0('abcdefghijklmnopqrstuvwxyz')
2.09 μs ± 130 ns per loop (mean ± std. dev. of 7 runs, 100 loops each)
В следующей главе рассматривается, чего можно достичь средствами самого языка, не подключая сторонних инструментов, а за ней следует глава про компиляторы и векторизацию.