Оптимизация кода на Python
Почему Python медленный?
Этот вопрос задают часто, но формулировка неточная. Python не медленный — он медленный для определённого класса задач. Веб-приложения, скрипты автоматизации, прототипы — Python тянет без проблем. Проблемы начинаются с тяжёлыми числовыми вычислениями, обработкой больших массивов данных в циклах или real-time задачами.
Чтобы понять, где теряется производительность, нужно загл януть внутрь CPython. Всё, что мы изучали в предыдущих лекциях — модель данных, протоколы, дескрипторы — имеет свою цену.
Динамическая типизация: цена гибкости
Когда вы пишете a + b, компилятор C генерирует одну машинную инструкцию ADD. CPython же выполняет цепочку:
- Извлечь объект
aиз фрейма (dict lookup по имени переменной) - Извлечь объект
b - Определить тип
a— прочитать полеob_typeизPyObject_HEAD - Найти метод
__add__в MRO типаa(поиск поtp_as_number->nb_add) - Проверить, поддерживает ли тип
bэту операцию - Если
a.__add__вернулNotImplemented— попробоватьb.__radd__ - Выполнить саму операцию
- Упаковать результат в новый
PyObject - Обновить счётчик ссылок для
a,bи результата
Одна строчка a + b — десятки вызовов C-функций внутри CPython. В C — одна инструкция.
Memory overhead: всё — объект
Вспомните модель данных из первой лекции: в Python всё — объект. Каждый объект содержит как минимум PyObject_HEAD:
ob_refcnt— счётчик ссылок (8 байт)ob_type— указатель на тип (8 байт)
Целое число 42 в C занимает 4 байта. В Python — 28 байт (sys.getsizeof(42)). Список из миллиона чисел хранит миллион указателей на миллион отдельных объектов, разбросанных по памяти — кеш процессора работает неэффективно.
NumPy-массив хранит данные компактно — один непрерывный блок памяти с заголовком (data, dimensions, strides). Python-список хранит массив указателей, каждый ведёт к отдельному PyObject в произвольном месте кучи. Разница в потреблении памяти и скорости итерации — на порядки.
Интерпретируемость: нет глобальной оптимизации
CPython компилирует исходный код в байткод, затем интерпретирует его инструкция за инструкцией. Компилятор видит одну строку за раз — никакой межпроцедурной оптимизации, инлайнинга функций или автовекторизации. Каждый вызов функции — это создание нового фрейма, dict для локальных переменных (до Python 3.11), обновление стека.