Назад к блогу

Python 3.15: ленивые импорты, новые встроенные типы и переработанный JIT

Python 3.15: ленивые импорты, новые встроенные типы и переработанный JIT

Python 3.15 заметно меняет и сам язык, и его реализацию: появляются ленивые импорты через ключевое слово `lazy`, встроенные `frozendict` и `sentinel`, а также переработанный фронтенд JIT. Это интересно тем, кто следит за развитием CPython и хочет понять, как новые механизмы ускоряют запуск и выполнение кода.

В Python 3.15 принят набор PEP, меняющих язык и CPython сразу в нескольких направлениях. В языке появляются явные ленивые импорты через мягкое ключевое слово lazy, встроенные типы frozendict и sentinel, распаковка в генераторных выражениях. В реализации — переработанный фронтенд трассировки JIT, in-place операции над float и компактными int, сборка с frame pointers по умолчанию. Часть старых API объявлена устаревшей или удалена.

Что именно изменилось

Приняты PEP 782 (новый C API для создания объекта bytes), PEP 788 (защита от финализации в C API), PEP 810 (явные ленивые импорты через мягкое ключевое слово lazy), PEP 814 (неизменяемый встроенный тип frozendict), PEP 829 (файлы .start с точками входа вида pkg.mod:callable), PEP 831 (frame pointers по умолчанию), PEP 799 (модуль profiling), PEP 661 (встроенный тип sentinel) и PEP 803 (Stable ABI для free-threaded сборок, он же abi3t).

В стандартной библиотеке появился модуль math.integer с математическими функциями для целочисленных аргументов (PEP 791) и пакет profiling с двумя инструментами: profiling.tracing — детерминированная трассировка вызовов, перенесённая из модуля детерминированного профилирования, и profiling.sampling — новый статистический сэмплирующий профилировщик Tachyon. UTF-8 стал кодировкой по умолчанию для операций ввода-вывода без явного encoding.

Ленивые импорты: как это работает

lazy import разрешён только на глобальном (модульном) уровне — не внутри функций, тел классов, блоков try или import *. from __future__ import ленивым быть не может. При использовании lazy импорт становится потенциально ленивым: модуль не загружается сразу, вместо этого создаётся ленивый прокси-объект, а фактическая загрузка происходит при первом использовании имени.

Для lazy from ... import каждое импортируемое имя привязывается к своему прокси, и первое обращение к любому из них запускает загрузку всего модуля, но реификация затрагивает только это конкретное имя — остальные остаются прокси до первого обращения.

Реификация вызывает __import__ с текущим состоянием системы импорта (sys.path, sys.meta_path), а не тем, что было при выполнении оператора lazy import. При реификации модуль удаляется из sys.lazy_modules; если реификация не удалась, ленивый объект не заменяется и последующие обращения повторяют попытку.

Ленивый модуль не попадает в sys.modules до реификации, а после неё появляется там как при обычном импорте.

В байткоде ленивый импорт обозначается как IMPORT_NAME с установленным флагом. После нескольких обращений к имени модуля инструкция LOAD_GLOBAL специализируется в LOAD_GLOBAL_MODULE, а доступ к атрибуту модуля — в LOAD_ATTR_MODULE. Это убирает проверки на медленном пути и даёт нулевые накладные расходы после реификации. Наблюдать переход можно через dis.dis(use_json, adaptive=True).

Предыстория: чем были плохи прежние решения

До 3.15 для отложенной загрузки применялся штатный механизм стандартной библиотеки, подход, формализованный в SPEC 1 и принятый многими научными библиотеками, а также сторонний пакет lazy_loader. Эти решения не покрывают все случаи использования общего механизма ленивых импортов: нет чёткого стандарта, есть накладные расходы в неожиданных местах и ухудшение интроспекции во время выполнения.

Штатный механизм вынужден разрешать спецификацию модуля до создания ленивого загрузчика, что само по себе даёт накладные расходы и снижает выгоду от ленивости. Он работает на уровне механизма импорта, а не даёт синтаксиса уровня языка, поэтому у линтеров и проверщиков типов нет канонического способа распознавать ленивые импорты. Наконец, он требует значительного шаблонного кода с ручным управлением спецификациями модулей, загрузчиками и sys.modules, что делает его непрактичным, когда лениво нужно импортировать много модулей.

Почему это сделали

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

Основная проблема — каскад импортов при запуске главного модуля: загружается множество зависимостей, которые могут никогда не использоваться. Даже запуск команды с --help может загрузить десятки ненужных модулей и занять несколько секунд. Анализ стандартной библиотеки Python показывает, что примерно 17% всех импортов вне тестов (почти 3500 импортов в 730 файлах) уже размещены внутри функций или методов именно для отсрочки выполнения — то есть ленивые импорты давно реализуются вручную в чувствительном к производительности коде.

В реальных развёртываниях наблюдались значительные сокращения времени перезагрузки серверов, инициализации ML-обучения, запуска инструментов командной строки и загрузки Jupyter notebook, а также улучшения потребления памяти. Реализация ленивой инициализации для PySide6 показала улучшение времени запуска на 10–20% для приложений PySide.

JIT и оптимизации

JIT-компилятор 3.15 поддерживает значительно больше байткод-операций и потока управления, чем в 3.14. Простое создание объектов Python теперь понимается JIT-компилятором; перегруженные операции и генераторы поддерживаются частично. Это стало возможным благодаря переработанному фронтенду трассировки, который записывает фактические пути выполнения кода, а не оценивает их, как предыдущая реализация.

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

Генератор машинного кода теперь производит лучший машинный код для целей x86-64 и AArch64 macOS и Linux — ожидается меньшее потребление памяти для сгенерированного кода и более эффективный код по сравнению с 3.14. JIT публикует информацию о размотке для сгенерированного машинного кода в интерфейс GDB на поддерживаемых Linux ELF-платформах; при наличии регистрации фреймов libgcc та же информация регистрируется для обходчиков стека, что позволяет нативным отладчикам и обработчикам сбоев разматывать через JIT-фреймы вместо остановки на сгенерированном коде.

В интерпретаторе появились in-place операции над float и компактными int, выполняемые только на Tier 2. In-place операция мутирует левый операнд, если он уникально referenced, вместо создания нового объекта. Оптимизатор JIT выбирает вариант на основе уникальности ссылок: если левый операнд уникален — in-place слева, иначе если правый уникален — in-place справа, иначе обычная операция.

Специализация байткода — это подмена общих инструкций на их быстрые версии под конкретный тип значения, который встретился при выполнении: вместо одной универсальной проверки интерпретатор выполняет короткий путь, уже зная тип. Так, проверка истинности значения получает отдельные быстрые версии под каждый распространённый тип — bool, int, list, None и str, — чтобы в типичных случаях не проходить через общий медленный путь. Аналогично специализированные float-операции проверяют точный тип через _GUARD_TOS_FLOAT и _GUARD_NOS_FLOAT.

Новые встроенные типы

frozendict (PEP 814) — неизменяемый тип в builtins: после создания его нельзя изменять. Он не является подклассом dict, а наследуется напрямую от object. Хешируем, если все его ключи и значения хешируемы; сохраняет порядок вставки, но сравнение порядок не учитывает. Для его поддержки обновлены модули copy, decimal, json, marshal, plistlib (только для сериализации), pickle, pprint и xml.etree.ElementTree; кроме того, frozendict принимается там, где ожидается словарь глобальных имён при выполнении кода, а также в качестве словаря в ряде встроенных операций.

sentinel (PEP 661) — тип в builtins для создания уникальных sentinel-значений с кратким представлением. Sentinel-объекты сохраняют идентичность при копировании, поддерживают использование в выражениях типов с оператором | и могут быть сериализованы через pickle, если импортируемы по модулю и имени.

Распаковка в генераторных выражениях

Распаковка теперь работает в генераторных выражениях: (*L for L in lists) эквивалентно (x for L in lists for x in L). Это распространяется и на асинхронные генераторные выражения: (*a async for a in agen()) эквивалентно (x async for a in agen() for x in a). Аналогичная распаковка работает в списковых, множественных и словарных включениях: [*L for L in lists], {*s for s in sets}, {**d for d in dicts}.

C API и ABI

Новый C API для создания объекта bytes (PEP 782) позволяет расширениям собирать байтовые объекты по частям: буфер можно увеличивать и дописывать в него данные. Прежние способы создания и изменения байтового объекта объявлены soft deprecated с рекомендацией перейти на новый API.

PEP 788 вводит защиту C API от финализации интерпретатора. Кроме того, добавлены средства автоматического подключения и отключения состояния потока со встроенной защитой от финализации. Прежний способ работы с состоянием потока через GIL soft deprecated: удалять его не планируют, существующий код продолжит работать, но новых API в этой семье не будет. В Stable ABI добавлены средства критических секций и связанные с ними функции.

PEP 820 вводит унифицированную систему слотов C API: слоты описываются единообразно, а тип собирается из набора слотов. Прежние способы создания типов и модулей из спецификации soft deprecated. PEP 803 позволяет компилировать C-расширения под Stable ABI для free-threaded сборок (abi3t), делая их совместимыми с такими сборками CPython.

Frame pointers по умолчанию

CPython теперь собирается с frame pointers по умолчанию на платформах, которые их поддерживают, через флаги компилятора -fno-omit-frame-pointer и -mno-omit-leaf-frame-pointer. Это ускоряет и делает надёжнее разворачивание нативного стека для системных профилировщиков, отладчиков, инструментов анализа падений и eBPF-инструментов наблюдаемости.

Флаги доступны через sysconfig, поэтому расширения, собираемые инструментами, использующими конфигурацию сборки Python, наследуют frame pointers по умолчанию. Это распространение намеренно: смешанное профилирование Python и нативного кода требует непрерывной цепочки frame-pointer через интерпретатор, расширения, встраивающие приложения и нативные библиотеки. Один нативный компонент, собранный без frame pointers, может сломать разворачивание стека для всего процесса Python, поэтому авторам C-расширений с собственными системами сборки нужно добавлять -fno-omit-frame-pointer и аналогичные флаги в CFLAGS.

Что удалено и объявлено устаревшим

Удалены: collections.abc.ByteString из __all__ (устарел с 3.12, удаление запланировано в 3.17), недокументированная функция для настройки типа указателя в ctypes, недокументированные функции поиска по шаблону в glob, класс обработчика CGI и флаг --cgi из http.server, метод загрузки модуля у загрузчиков, параметр package из функции доступа к ресурсам пакета в importlib.resources, проверка зарезервированных имён путей в pathlib.

Объявлены устаревшими: модуль profile (удаление в 3.17, замена — profiling.tracing), ключевое слово string в конструкторах hashlib (удаление в 3.19), вывод JavaScript-представления у объектов cookie (удаление в 3.19), изменение файлового атрибута IMAP4 (удаление в 3.19), сопоставление с начала строки в re — мягко устарело в пользу нового API сопоставления с префиксом. Также устарели создание Struct без обязательного аргумента и повторная инициализация уже созданного Struct (удаление в 3.20), коды 'F' и 'D' в struct в пользу 'Zf' и 'Zd', проверки принадлежности к протоколам, не помеченным как проверяемые во время выполнения (в 3.20 будет ошибка типа), webbrowser.MacOSXOSAScript (удаление в 3.17) и атрибуты __version__, version, VERSION в ряде модулей стандартной библиотеки (удаление в 3.20).

Что меняется при миграции

В разделе Porting перечислены изменения, требующие правки кода. В sqlite3 при подключении к базе данных все параметры кроме имени базы стали передаваться только по имени; при регистрации пользовательских функций и агрегатов первые три параметра теперь передаются только по позиции; при настройке авторизации, обработчика прогресса и обратного вызова трассировки первый параметр также передаётся только по позиции. resource.RLIM_INFINITY теперь всегда положительно, а передача отрицательного значения при установке лимитов ресурсов устарела. Изменение размера отображённого в память файла удалено на платформах без соответствующего syscall. Для незакрытого итератора разбора XML-документа по частям, открывшего файл, теперь выдаётся resource warning. В argparse при передаче короткой опции и однодефисной длинной опции dest выводится из длинной опции.

Ограничения и открытые вопросы

Ленивые импорты не поддерживаются внутри функций, тел классов, блоков try и import *; from __future__ import ленивым быть не может. Ошибки, которые при обычном импорте возникли бы сразу, теперь происходят в момент использования ленивого имени. Побочные эффекты лениво импортируемого модуля выполняются при первом обращении к имени, а не при импорте, поэтому порядок импорта модулей может отличаться от порядка их появления в коде. Циклические импорты ленивость автоматически не решает: это может помочь только если циклическая ссылка не используется во время инициализации модуля. Для модулей с побочными эффектами при импорте рекомендуется использовать явные функции инициализации и не полагаться на порядок импорта.

Среди отклонённых предложений: автоматическая ленивость всех импортов по умолчанию (вне области действия PEP), поддержка __lazy_modules__ = ["*"] как встроенного синтаксиса (отклонена, так как __lazy_modules__ уже представляет неявное поведение на расстоянии, терпимое только как механизм обратной совместимости) и запрет ленивых импортов внутри with-блоков (отклонён, так как with имеет более широкую семантику, чем try/except). Предложение о блочном синтаксисе as lazy: рассмотрено, но не принято из-за необходимости вводить новую форму оператора.

Для ленивых импортов заявлены нулевые накладные расходы после первого использования, небольшая разовая стоимость создания прокси и отсутствие постоянных потерь производительности; бенчмарки pyperformance показывают нейтральность по производительности, когда ленивые импорты не используются. Единственная конкретная цифра — улучшение времени запуска на 10–20% для приложений PySide. Выгода масштабируется со сложностью кодовой базы: чем больше и взаимосвязаннее код, тем значительнее улучшения.

Источники

Похожее