Оптимизатор проекций CPython сканирует байт-код на предмет недостижимых блоков — последовательностей инструкций, следующих за безусловным переходом (JUMP_ABSOLUTE, JUMP_FORWARD, RETURN_VALUE, RAISE_VARARGS), которые не имеют точек входа из других веток. После их идентификации он удаляет эти мертвые инструкции, чтобы уменьшить давление на кэш и улучшить плотность инструкций.
Поскольку таблицы обработки исключений Python, конструкции циклов и условные переходы хранят целевые местоположения как абсолютные байтовые смещения в последовательности co_code объекта кода, оптимизатор должен создать таблицу перемещения, которая отслеживает, сколько байтов было удалено перед каждой выжившей инструкцией. Затем он проходит по всем инструкциям перехода и диапазонам обработчиков исключений, корректируя их целевые смещения, вычитая общее количество удаленных байтов на целевой позиции. Это гарантирует, что блоки SETUP_FINALLY, циклы FOR_ITER и пользовательские переходы попадают на правильный код операции, даже после того, как предыдущий байт-код был сжат.
Команда работа с данными заметила, что сценарий запуска их утилиты ETL содержал обширные блоки отладочного логирования, защищенные флагами if DEBUG:, где DEBUG был модульной константой, установленной в False. Несмотря на то, что условие было статически ложным, скомпилированный байт-код все еще содержал логику логирования после компиляции, увеличивая размер файла .pyc на 40% и слегка ухудшая локальность кэша инструкций на сервере.
Они оценили три разных подхода.
Первый, они рассмотрели использование C-препроцессора или шаблонизатора Jinja2 для удаления отладочного кода перед развертыванием. Этот подход гарантировал бы отсутствие отладочного байт-кода в производстве, но привел бы к сложной зависимости на этапе сборки и рисковал бы тонким расхождением между кодовыми базами разработки и производства, усложняя отладку производственных проблем, когда исходный код больше не совпадал с выполняемым байт-кодом.
Второй, они оценили рефакторинг всех блоков отладки в отдельные функции в подмодуле, надеясь, что неконтролируемые функции не будут загружены. Однако система импорта Python компилирует целые модули сразу, и неконтролируемые функции остаются как объекты кода в словаре модуля; оптимизатор проекций не выполняет межпроцедурное удаление мертвого кода, поэтому размер байт-кода остался неизменным.
Третий, они исследовали конвейер компиляции CPython и обнаружили, что оптимизатор проекций автоматически удаляет код после конструкций if False:, потому что компилятор выдает безусловный переход вокруг блока, и проход проекций удаляет недостижимый хвост. Проверив с помощью модуля dis, что RETURN_VALUE или JUMP_FORWARD не были следованы мертвым кодом, они подтвердили, что оптимизация была активна. Они решили полагаться на этот встроенный механизм, убедившись, что DEBUG является литеральным False, что уменьшило размер скомпилированного байт-кода на 35% без дополнительного инструментария.
Почему оптимизатор проекций отказывается удалять недостижимый код, когда предыдущая цель перехода адресована рассчитываемой инструкцией перехода?
Вычисляемые переходы определяют свое назначение во время выполнения на основе значения в стеке, как, например, в операторах MATCH или динамических шаблонах диспетчеризации. Поскольку оптимизатор не может статически знать, какие смещения могут быть целевыми, он должен консервативно предполагать, что любая инструкция может быть точкой входа. Следовательно, он удаляет только код, который недоказуемо недостижим через статический анализ безусловных переходов и графов управления потоком, сохраняя любой блок, который может быть целью динамической диспетчеризации, чтобы предотвратить неопределенное поведение.
Как оптимизатор обрабатывает таблицы обработчиков исключений (co_exceptiontable) при удалении NOP инструкций, используемых как заполнители для переходов?
Когда компилятор генерирует переходы на впереди не известные местоположения, он часто выдает NOP (инструкция без операции) в качестве заполнителей или подкладок, а затем позже исправляет целевые переходы. Во время оптимизации проекций эти NOP удаляются для экономии места. Оптимизатор поддерживает двунаправленное отображение между исходными и конечными смещениями. При обработке таблицы исключений, которая хранит смещения start, end и handler для блоков try/except, он применяет кумулятивную дельту удаленных байтов к каждой записи. Если NOP попадает в диапазон исключений, его удаление смещает смещение end влево, обеспечивая, чтобы защищаемый диапазон байт-кода оставался точным и исключения обрабатывались на правильных границах.
Что мешает оптимизатору проекций реорганизовывать независимые инструкции для повышения эффективности конвейера, как это делается в C-компиляторах?
Байт-код Python тесно связан с семантикой стека оценивания и таблицами номеров строк, используемыми для генерации трассировок. Реорганизация инструкций, например, перемещение LOAD_CONST вперед перед LOAD_NAME, может изменить состояние стека, когда происходит исключение, изменяя номер строки, который отображается в трассировках, или нарушая инварианты глубины стека, требуемые циклом интерпретатора. Более того, поскольку Python позволяет инспекцию объектов фрейма и f_lasti (указатель на инструкцию), произвольная реорганизация может сломать отладчики и профайлеры, которые полагаются на детерминированное сопоставление смещения к исходному коду. Таким образом, оптимизатор ограничивается удалением недостижимого кода и перенаправлением переходов без изменения относительного порядка выполняемых инструкций.