Причины низкой скорости Python

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

Слайды к главе. Причины медленности Python и способы измерения времени изложены также в первой и второй частях лекции «Оптимизация и параллельные вычисления» с демонстрациями в терминале; слайды лекции доступны на сайте книги и в PDF.

1. Динамическая типизация

Тип значения, связанного с именем, становится известен только во время исполнения. Во-первых, интерпретатор проверяет типы на каждом шаге, и эти проверки, повторяющиеся миллионы раз, требуют времени. Во-вторых, он не знает заранее, с какими данными работает, и потому обязан исполнять написанный код почти буквально: заранее вычисляются лишь выражения из литералов, например 60 * 60 * 24, и отбрасываются ветви с постоянным условием, такие как if False, а выражение с именами вычислить заранее нельзя, поскольку любое имя может быть переопределено.

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

Строки с ctypes нужны только в Python до 3.12 включительно. Начиная с 3.13 действует PEP 667: атрибут f_locals возвращает прокси-объект, запись в который сразу попадает в переменные кадра, а функция PyFrame_LocalsToFast сохранилась в C API лишь для совместимости и ничего не делает. Пример по-прежнему печатает hello, а запись в чужой кадр сводится к одной строке.

Поэтому интерпретатор не может отделить от цикла первую итерацию, на которой проверка i == 0 истинна: он не знает, не будут ли по ходу выполнения переопределены имена range и len. Оптимизацию, которую выполнил бы любой компилятор 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()
12.6 μs ± 253 ns per loop (mean ± std. dev. of 7 runs, 100 loops each)
9.57 μs ± 89.7 ns per loop (mean ± std. dev. of 7 runs, 100 loops each)

3. CPython

Третьей причиной является сама эталонная реализация языка, написанная давно.

  1. CPython начинали писать задолго до многоядерных процессоров.
  2. Производительность никогда не была его главной целью.
  3. Совместимость с C API ограничивает изменения внутреннего устройства.

Компилятор CPython переводит исходный текст в байт-код, а цикл интерпретатора выполняет инструкции по одной: для выражения a + b это загрузка двух имён и инструкция BINARY_OP, внутри которой выясняются типы операндов и вызывается нужная реализация сложения. Каждая инструкция — ветвь кода на C с проверками, работой со счётчиками ссылок и созданием объектов-результатов.

Начиная с версии 3.11 CPython заметно ускорился: специализирующий адаптивный интерпретатор (PEP 659) подменяет общие инструкции быстрыми вариантами для встреченных типов и вместе с другими изменениями дал в среднем около 25 % на наборе тестов pyperformance, см. раздел Faster CPython. В версии 3.13 появились экспериментальный JIT-компилятор (PEP 744, по умолчанию выключен) и сборка без глобальной блокировки интерпретатора (PEP 703), которая с версии 3.14 поддерживается официально (PEP 779) и рассматривается в главе «Многопоточность и GIL». С мая 2025 года проект Faster CPython развивается сообществом без прежнего финансирования Microsoft.

Момент оптимизации

Premature optimization is the root of all evil

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

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

Обратной крайностью является «большой комок грязи», архитектура, не продуманная вовсе.

If you think good architecture is expensive, try bad architecture.

Подробнее об этом написано в эссе Фута и Йодера и в статье Википедии.

Три правила оптимизации

  1. Не делать
  2. Делать это позже
  3. Делать это оптимально

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

Порядок оптимизации

Программисты тратят чудовищно много времени, размышляя о скорости некритичных частей программы, и эти попытки ускорения оказываются вредны, если учесть отладку и сопровождение. Про мелкую эффективность надо забыть в 97 % случаев: преждевременная оптимизация — корень всех зол. Но нельзя упускать возможности в тех критических 3 %.

Д. Кнут, Structured Programming with go to Statements, ACM Computing Surveys, 1974

Основная задача заключается в поиске места, к которому имеет смысл прикладывать усилия. Этому служат три правила.

Правило 1. Профилирование кода

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

Количественно цену неверного выбора выражает закон Амдала. Если доля \(p\) времени работы программы ускорена в \(s\) раз, вся программа ускоряется в

\[ S = \frac{1}{(1 - p) + p / s} \]

раз. Функция, занимающая 1 % времени и ускоренная в десять раз, даёт выигрыш около одного процента; даже бесконечно быстрая часть, занимающая 90 % времени, ускоряет программу лишь в десять раз. Тот же закон ограничивает ускорение параллельной программы на многих процессах; эта постановка вместе с законом Густафсона рассмотрена в главе «Кластеры и воспроизводимое окружение для расчётов».

Правило 2. Сохранение корректности

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

Правило 3. Замер после правки

Выигрыш подтверждается замером на тех же данных, что и до правки, и без профилировщика: профилировщик сам замедляет программу, причём по-разному для разных конструкций, и сравнение двух версий под ним может ввести в заблуждение. Пример такой ловушки разобран в главе «Скорость выполнения программ».

Профилирование

Базовый набор входит в стандартную поставку: cProfile собирает профиль по функциям, а pstats разбирает и сортирует собранное. Профиль удобно сохранить в файл и затем вывести функции с наибольшим собственным временем (tottime):

python3 -m cProfile -o prof.out matrix.py
python3 -c "import pstats; pstats.Stats('prof.out').strip_dirs().sort_stats('tottime').print_stats(2)"

Для программы из главы «Скорость выполнения программ», шестнадцать раз перемножающей матрицы 64×64, начало отчёта выглядит так:

         66668 function calls (66640 primitive calls) in 0.309 seconds

   Ordered by: internal time
   List reduced from 134 to 2 due to restriction <2>

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
       16    0.294    0.018    0.294    0.018 matrix.py:23(matrix_product)
     8192    0.004    0.000    0.010    0.000 random.py:291(randrange)

Столбец ncalls — число вызовов, tottime — время в самой функции без вложенных вызовов, cumtime — вместе с ними. Метод strip_dirs убирает из имён файлов каталоги. Здесь 95 % времени приходится на matrix_product, и ускорять имеет смысл только её. Сторонний пакет SnakeViz показывает тот же профиль диаграммой в браузере.

cProfile — детерминированный профилировщик: он перехватывает каждый вызов и возврат, поэтому знает точное число вызовов, но заметно замедляет программу и ничего не сообщает о времени отдельных строк. Для других задач служат ещё два инструмента.

  1. line_profiler профилирует построчно: функция отмечается декоратором @profile, а программа запускается командой kernprof -l -v script.py. Пример построчного профиля приведён в главе «Скорость выполнения программ».
  2. py-spy — сэмплирующий профилировщик: он по умолчанию сто раз в секунду считывает стек работающей программы снаружи, из памяти процесса, почти не замедляя её, и снимает профиль с уже запущенной программы, не изменяя её код. Это необходимо, когда расчёт продолжается третьи сутки и перезапуск недопустим. Команда py-spy dump --pid N печатает текущий стек процесса, py-spy top --pid N — непрерывно обновляемую сводку, py-spy record -o profile.svg --pid N записывает flame graph. В Linux подключение к процессу, запущенному не из py-spy, требует прав root (sudo) или значения kernel.yama.ptrace_scope = 0. Для долгого расчёта beam.py, на каждом шаге вычисляющего силы пространственного заряда, py-spy dump показывает, что процесс занят функцией space_charge:
Process 12524: python3 beam.py
Python v3.12.3 (/usr/bin/python3.12)

Thread 12524 (active+gil)
    space_charge (beam.py:11)
    simulate (beam.py:19)
    <module> (beam.py:23)

Расход памяти показывает модуль tracemalloc из стандартной библиотеки, рассмотренный в главе «Оптимизация средствами самого Python».

Измерение времени

Когда требуется измерить время одной функции, а не снимать полный профиль, применяется модуль timeit из стандартной библиотеки. Из командной строки он запускается так:

python3 -m timeit -s "from rev import reverse_loop, s" "reverse_loop(s)"

Ключ -s задаёт подготовку, которая в замер не входит. Число запусков модуль подбирает сам, так чтобы прогон длился не меньше 0,2 секунды, повторяет прогон пять раз и печатает лучшее время одного запуска: 500000 loops, best of 5: 527 nsec per loop. Берётся именно минимум: более медленные прогоны замедлены не самим кодом, а другими процессами.

Из программы вызывается функция timeit.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.005110418002004735
timeit.timeit('reverse_5(s)', setup, number=10000)
0.000419921001594048

Функция timeit измеряет время по time.perf_counter, на время измерения отключает сборщик мусора и возвращает суммарное время N запусков, а не усреднённое по ним; timeit.repeat возвращает список таких сумм по нескольким прогонам. Отключение сборщика мусора делает замеры стабильнее, но если код создаёт много объектов и сборка мусора входит в его цену, сборщик включают первой строкой подготовки: gc.enable().

Код передан строками, поскольку внутри 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')
519 ns ± 60.2 ns per loop (mean ± std. dev. of 7 runs, 100 loops each)

Все замеры этой главы и следующих двух выполнены на виртуальной машине с двумя ядрами под Python 3.12, на которой записаны демонстрации лекции «Оптимизация и параллельные вычисления»; на другой машине числа будут иными.

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