Команда Python for Devs подготовила перевод статьи о том, почему Python так медленный и что сообщество делает, чтобы это исправить. На PyCon 2024 множество докладов были посвящены простой задаче – ускорить работу Python. В статье собраны самые интересные идеи: использование статической типизации и Cython для ускорения вычислений, создание подмножеств языка вроде SPy, переписывание тяжёлых функций на C, статическая линковка расширений, immutable-объекты для обхода GIL и параллельные вычисления через субинтерпретаторы.


Язык программирования Python давно заработал репутацию довольно медлительного: хорош для начинающих, но лишён скорости своих более «продвинутых» собратьев.

Тем не менее, многие исследователи активно раздвигают границы возможностей языка.

Компиляция Python-кода для ускорения вычислений

Сакшам Шарма, директор по технологиям количественных исследований в Tower Research Capital, строит торговые системы на C++. «Я люблю быстрый код», — заявил Сакшам.

Ему хотелось бы привнести ту же стремительность и в Python.

Python — интерпретируемый язык (хотя эталонная реализация CPython написана на C). Интерпретатор преобразует исходный код в эффективный байткод из нулей и единиц. Затем он выполняет этот код построчно и формирует внутреннее состояние программы на основе всех объектов и переменных по мере их чтения (вместо того чтобы заранее компилировать их в машинный код, как делает компилятор).

«Мы проходим через целую цепочку косвенных обращений, и это замедляет процесс», — пояснил Шарма.

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

Cython — это оптимизирующий статический компилятор для Python, который позволяет писать код на C, компилировать его заранее, а затем использовать в Python-программе.

«Вы можете создавать внешние библиотеки и утилиты, которые подключаются к вашему интерпретатору и могут взаимодействовать с его внутренним состоянием», — сказал Шарма. «Если у вас есть функция, которую вы написали отдельно, интерпретатор можно настроить так, чтобы он вызывал эту функцию».

Шарма выяснил, что на его компьютере такая операция сложения около 70 наносекунд в Python, но всего около 14 наносекунд в Cython.

«Cython однозначно сделал код быстрее, потому что интерпретатор больше не участвует в процессе», — отметил Шарма. Например, при каждом сложении интерпретатор проверяет тип обеих переменных. Но если тип уже известен, зачем делать эту проверку? Именно поэтому программисты и указывают тип переменной в коде.

«Код с типами может быть гораздо, гораздо быстрее», — заключил Шарма.

Python, который летает благодаря статической типизации

Как отметил Шарма, статическая типизация может дать Python много преимуществ. При статической типизации вы заранее указываете, какие типы данных будут храниться в переменной: строка ли это, число или массив. Интерпретатору требуется время, чтобы определить это самостоятельно.

Главный инженер Anaconda Антонио Куни представил SPy — новый подмножество Python, в котором статическая типизация обязательна. Цель — обеспечить скорость C или C++, сохранив привычную для Python простоту.

Куни объяснил, что перед тем, как выполнить инструкции, Python должен проделать массу подготовительных шагов. И, как отметил Шарма, «в языках низкого уровня на этапе выполнения обычно происходит гораздо меньше работы».

Перед выполнением логики интерпретатор Python должен найти все необходимые инструменты — модули, классы, библиотеки, — чтобы затем исполнить саму логику. Эта промежуточная работа может занимать много времени.

Хорошая новость в том, что значительную её часть можно сделать заранее — на этапе компиляции.

В SPy все глобальные константы — классы, модули и глобальные данные — помечаются как immutable (неизменяемые) и могут быть оптимизированы (благодаря статической типизации) с помощью JIT-компилятора.

Сейчас Куни работает над реализацией SPy либо как расширения для CPython, либо с собственным JIT-компилятором. Он также исследует возможность запуска версии SPy внутри WebAssembly.

C-расширения, но со статической линковкой

Лорен Артур, менеджер по инженерии в Meta, утверждает, что переписывание ресурсоёмких функций на C может значительно повысить производительность — но важно внимательно подходить к тому, как эти функции загружаются в программу.

C-модуль, импортированный в Python, может обработать тестовый файл за 0,5 секунды — вместо 4 секунд, которые потребовались бы обычному Python-коду.

На первый взгляд выигрыш кажется небольшим. Но в масштабах компании уровня Meta это огромная разница. Перенос части функциональности с Python на более быстрый C-код для 90 000 Python-приложений сэкономил инженерам Meta около 5000 часов в неделю — только за счёт ускорения сборок.

Это сработало отлично. Команда Instagram создала тысячи C-расширений, чтобы ускорить работу.

Но потом возникла новая проблема. Время импорта C-расширений начало неконтролируемо расти по мере увеличения их количества в сборке. Странно, ведь большинство этих модулей очень маленькие — иногда содержат всего один метод и возвращают строку.

Используя Callgrind (часть набора инструментов динамического анализа Valgrind), Артур выяснил, что функция Python dlopen тратит 92% времени на открытие shared object.

«Загрузка динамических библиотек обходится дорого, особенно когда их очень много», — отметил он.

Решение Meta нашла в использовании встроенных C-расширений со статической линковкой вместо динамической. Вместо вызова внешней библиотеки C-код встраивается напрямую в исполняемый файл.

Объекты, которые живут вечно

Global Interpreter Lock (GIL), который не позволяет нескольким процессам одновременно выполнять Python-код, изначально вовсе не был «злодеем», считает Винисиус Губиани Феррейра, тимлид в Azion Technologies.

Наоборот, GIL был героем, который задержался слишком надолго — и со временем превратился в злодея.

В своём докладе Феррейра рассказал о PEP 683, в котором предлагается улучшить использование памяти в крупномасштабных приложениях. Итоговая библиотека вошла в Python 3.12, выпущенный в октябре.

GIL был создан, чтобы предотвращать состояние гонки, но в то же время он мешал Python выполнять настоящий многопоточный параллельный код на многоядерных системах. Идёт работа над тем, чтобы сделать GIL опциональным, но пройдёт ещё несколько лет, прежде чем эта возможность станет частью рантайма языка.

«В Python всё — это объект», — пояснил Феррейра. Переменные, словари, функции, методы и даже экземпляры классов — всё объекты. В самой простой форме объект состоит из типа, значения и счётчика ссылок, который показывает, сколько других объектов на него указывают.

Все объекты в Python изменяемы, даже те, что помечены как неизменяемые (например, строки). Счётчик ссылок меняется постоянно. И это действительно проблема. Каждое обновление сбрасывает кеш, усложняет форк процесса, создаёт состояние гонки — изменения могут перезаписывать друг друга, а если результат счётчика окажется равен нулю, то бах! — сборщик мусора удаляет объект.

Чем масштабнее приложение, тем сильнее проявляются эти проблемы.

Решение оказалось простым: создать неизменяемое состояние, где счётчик ссылок не меняется, установив его в очень большое число, которое невозможно изменить (теоретически программа могла бы досчитать до него, отметил Феррейра, но на это ушли бы дни). Рантайм будет управлять этими «суперособенными» объектами отдельно и сам отвечать за их удаление.

Ещё лучше: такие «бессмертные» объекты не требуют GIL. Их можно использовать где угодно и сразу из нескольких потоков.

Есть небольшое ухудшение по производительности — до 8 % в CPython, что неудивительно, ведь рантайму приходится хранить отдельную таблицу. Но в многопроцессорных средах (например, в Instagram) выигрыш от параллельного выполнения перекрывает этот минус.

«Вы должны измерять показатели, чтобы понимать, что действительно всё сделали правильно», — подчеркнул Феррейра.

Совместное использование неизменяемых объектов

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

Одним из примеров такой реализации является Python-фреймворк memhive, который создаёт пул воркеров на основе субинтерпретаторов и реализует RPC-механизм для обмена данными между ними. Его представил на PyCon создатель проекта Юрий Селиванов — core-разработчик Python и CEO/сооснователь EdgeDB.

Селиванов начал доклад с демонстрации программы на своём ноутбуке: она задействовала 10 ядер процессора для одновременного выполнения 10 асинхронных event-loop’ов. Они использовали общее пространство памяти с миллионом ключей.

Что мешает вам сделать то же самое на своём компьютере? Старый знакомый злодей — GIL.

Memhive создаёт основной субинтерпретатор, который затем может порождать столько дополнительных субинтерпретаторов, сколько нужно.

Неизменяемые объекты — серьёзный вызов, а их в Python много: строки, кортежи и другие. Если вы хотите их изменить, приходится создавать новый объект и копировать в него все элементы — операция дорогая с вычислительной точки зрения, а если учесть ещё и обновление кеша, то вдвойне затратная.

Memhive использует общую структуру данных — structure sharing (реализованную в hamt.c в стандартной библиотеке Python). При этом новые изменения фиксируются, но неизменные части старой структуры данных просто переиспользуются по ссылке, а не копируются — что значительно экономит ресурсы.

«Если вы хотите добавить новый ключ, вам не нужно копировать всё дерево, — пояснил Селиванов. — Достаточно создать только недостающие ветви, а остальные просто ссылаться на уже существующие. Так что если у вас коллекция с миллиардами ключей и вы хотите добавить ещё один, вы создадите лишь пару новых узлов, а всё остальное будет переиспользовано. Ничего с ними делать не придётся».

Использование structure sharing открывает возможность параллельной обработки данных, так как они становятся неизменяемыми, и несколько субинтерпретаторов могут работать с одним и тем же набором данных одновременно.

«Поскольку мы работаем с неизменяемыми объектами, мы можем безопасно обращаться к памяти напрямую, без блокировок или каких-либо дополнительных механизмов синхронизации», — отметил Селиванов. В зависимости от количества копирований это может ускорить работу от 6 до 150 000 раз.

Русскоязычное сообщество про Python

Друзья! Эту статью перевела команда Python for Devs — канала, где каждый день выходят самые свежие и полезные материалы о Python и его экосистеме. Подписывайтесь, чтобы ничего не пропустить!

Итог

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

Комментарии (0)


  1. evgenyk
    26.09.2025 09:27

    Вообще-то было бы неплохо, если бы эти ускорители оставили бы питон в покое и соредоточились на чем-нибудь полезном.

    Вот взять например статические типы в питоне. Есть Cython - компилируемый питон со статическими типами. Но почему-то никто не хочет на нем писать. Все хотять динамические типы.

    Питон для своих задач достаточно быстр.


    1. slashfast
      26.09.2025 09:27

      Наверное, переписать проекты на другой более производительный ЯП дороже, чем ускорять и оптимизировать интерпретатор языка, который вообще не про производительность.