Skip to main content

Автоматическая оптимизация кода на уровне АСТ

Project description

logo

Downloads Downloads codecov Hits-of-Code Tests Python versions PyPI version Ruff

Пакет Astrologic содержит ряд инструментов, позволяющих "в рантайме" оптимизировать работу ваших функций или даже конструировать новые функции. Все это достигается за счет парсинга и преобразования AST исходной функции. Парсинг происходит полностью "под капотом" и невидим для пользователя.

Оглавление

Дисклеймер

Все, что вы найдете в данном пакете - это демонстрация концепций. Существует огромное количество весьма разумных причин, почему код из данной библиотеки, или из других, содержащих подобные хаки, не должен использоваться в "реальности". Как минимум, манипуляции с AST приводят к тому, что у вас начинает реально исполняться совершенно не тот код, который вы видите в файлах проекта. Средства интроспекции Python тоже совершенно не ожидают таких усовершенствований и могут давать ложную информацию.

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

Если уж вы по собственной глупости решите практически использовать данную библиотеку, пожалуйста, хорошо тестируйте свой код. Иногда вы можете столкнуться с совершенно неожиданным его поведением. Учитывайте, что предложенные здесь функции плохо протестированы (ввиду чисто демонстрационных целей их написания), и содержащиеся в них потенциальные баги могут удивить даже автора.

Как это работает

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

Мы не внедряемся в этот процесс, а обходим его. К сожалению, после компиляции байт-кода, абстрактное синтаксическое дерево не сохраняется, поэтому, если мы хотим его как-то исправить, нам надо проделать весь путь заново: получить исходный код, построить на его основе AST, скорректировать AST как нам требуется и скомпилировать из него байт-код.

Встроенные средства интроспекции позволяют нам извлечь из объекта функции текст ее исходного кода. Этот текст мы скармливаем функции ast.parse() и получаем уже нужный нам объект дерева, с которым дальше будем производить все оптимизации. Пакет ast также предоставляет готовые классы для обхода дерева и замены отдельных нод, чем мы и пользуемся. После всех манипуляций мы скармливаем уже видоизмененное дерево встроенной функции compile(), чтобы получить порцию байт-кода. Байт-код мы выполняем через exec(), передав туда словарь "пространства имен". exec() выполняет код инициализации функции как бы в отдельном "виртуальном" модуле. После выполнения инициализации мы достаем функции по имени из переданного в exec() словаря. Инициализация некоторых функций дергает дополнительные объекты из глобального пространства имен, поэтому, если попытаться выполнить их инициализацию в "голом" модуле, мы получим ожидаемое исключение. Чтобы этого избежать, мы предварительно вручную (при помощи importlib) импортируем модуль, из которого извлечена обрабатываемая нами функция, и все его содержимое перекладываем в тот самый словарь, что мы передаем exec() в качестве "пространства имен".

Исходя уже из описания процесса ясно, что у него не может не быть побочных эффектов. Вот некоторые из них:

  1. Когда мы добавляем новые ноды в AST, они лишены привязки к конкретным строкам исходного кода. Если там случится исключение, в полученном трейсбэке вы получите искаженную информацию.

  2. Все эти повторные парсинги-компиляции довольно ресурсоемки.

  3. Как выяснилось опытным путем, директива global в измененных таким образом функциях работает не совсем ожидаемым образом. Она указывает на соответствующую переменную в том "виртуальном" модуле, что был для нее создан при выполнении инициализации через exec(). Вы можете воздействовать из своей функции на глобальную переменную там, в то время как глобальная переменная с тем же именем в том модуле, где фактически расположен исходный код вашей функции, никак затронута не будет.

  4. Фактически выполняется не тот код, что написан в вашем коде. Это, собственно, самая большая проблема. Вся суть программирования заключается в том, что вы пишете код и машина его выполняет. А тут она выполняет что-то другое, и вы толком не знаете, что (если хотите таки посмотреть, что именно выполнится, передайте в любой из декораторов данной библиотеки аргумент debug_mode_on=True, тогда в консоли вы увидите текст функции, которая была сгенерирована после изменения AST).

  5. Это не будет работать с функциями из замыканий.

  6. Нельзя навесить несколько модифицирующих AST декораторов на одну функцию.

  7. Особенность конкретно данной реализации, но не технологии в принципе. Модифицирующий декоратор должен быть первым в списке декораторов. Кроме того, все декораторы и строка с объявлением функции должны быть однострочными.

Это значительные минусы, и, как вы могли прочитать в дисклеймере, я считаю их достаточным основанием, чтобы никогда в практическом смысле не использовать модификации AST. Однако это не мешает нам в развлекательных целях создавать "магию".

Установка

Установите Astrologic через pip:

$ pip install astrologic

Включаем и отключаем блоки кода

Простейшим из декораторов библиотеки Astrologic является @switcher. Вот пример его использования:

from astrologic import switcher


@switcher(a=False, b=True)
def function():
  print('begin')
  if a:
    print('block a')
  if b:
    print('block b')
  print('end')

function() # Что будет выведено? Проверьте сами!

@switcher проходится по унарным условиям (условиям, в которых только один операнд). Если название переменной, которая фигурирует в данном условии, присутствует в именованных аргументах самого декоратора, он делает одну из двух вещей. Если именованный аргумент равен True, он достает данный блок кода из условия заменяет им само условие. Если аргумент равен False, он удаляет условие вместе с блоком кода. То есть функция из примера выше превращается в:

# Тот код, который будет исполнен на машине. Блок кода "if a:" полностью вырезан, а блок "if b:" вытащен из проверки, в то время как сама проверка тоже вырезана.
def function():
  print('begin')
  print('block b')
  print('end')

Использовать данный декоратор вы можете, к примеру, чтобы избежать каких-то проверок в вашем коде, основанных на константах. При инициализации функций в начале работы интерпретатора вы указываете, какие блоки кода вам нужны. По сути, if'ы в данном случае служат не по прямому назначению, а работают разметкой кода.

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

У @switcher есть ряд ограничений и правил использования, которые необходимо учитывать:

  • Декоратор @switcher должен быть первым в списке декораторов, все остальные можно накладывать только поверх. Если не соблюсти это правило, декораторы, лежащие под @switcher, могут просто исчезнуть, будто их не было.

  • Никаких else или else if у блоков if, которыми вы управляете через декоратор! У прочих блоков if использовать else можно, поскольку они никаких не модифицируются.

Оптимизируем хвостовую рекурсию

Еще один крутой трюк с AST - автоматическая оптимизация хвостовой рекурсии. Для этого нам понадобится декоратор @no_recursion:

from astrologic import no_recursion


@no_recursion
def c(b):
    if b == 100000000:
        return b
    return c(b + 1)

print(c(1)) # Попробуйте выполнить это сами!

Как вы, вероятно, знаете, максимальная глубина рекурсии в Python ограничена, и обычно составляет около 1000. Однако код из этого примера отработает и вернет корректный результат, поскольку декоратор @no_recursion автоматически преобразовал рекурсивный код в итеративный. В отличие от других решений данной задачи, здесь не потребовалась менять исходный код функции, кроме как навешиванием на нее декоратора.

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

Вот они:
  • Поддерживается только обычная хвостовая рекурсия. Она выглядит вот так:
def function():
  return function()

Функция вызывает сама себя в блоке return и никак иначе. Никакие другие кейсы, включая, скажем, перекрестную рекурсию (это когда функция А вызывает функцию Б, а та, в свою очередь - функцию А), не покрываются.

  • За рекурсию принимается вызов объекта с тем же именем, какое название у функции. Вызов того же имени от другого объекта будет принят за рекурсию!
def function():
  return obj.function() # Декоратор примет это за рекурсию, хотя, очевидно, obj.function != function.
  • Внутри функции нельзя использовать имена is_recursion и superfunction. Особенности реализации. Остальные имена можно.

  • Стек-трейсы, как и предупреждал Гвидо, могут не совсем корректно отражать реальность.

Инлайним функции (экспериментальная фича)

Следующая оптимизация - инлайновые функции. В данном пакете это работает немного иначе, чем вы, вероятно, ожидаете, если знаете про такую возможность в C++. Здесь метка inline вешается не на ту функцию, которую мы инлайним, а на ту, в которую.

from astrologic import inline


def a(c):
    d = c
    print(d)

@inline('a')
def b():
    print('lol')
    c = 'kek'
    a(c)

b()

На этом примере мы инлайним функцию a() в функцию b(). Имена функций, которые мы инлайним, должны быть перечислены при вызове декоратора @inline.

По итогу код из примера преобразовывается примерно вот в это:

def b():
    print('lol')
    c = 'kek'
    generated_ddea0cfb886b422891e7643ff31efbfa = c
    d = generated_ddea0cfb886b422891e7643ff31efbfa
    print(d)

Содержимое функции a() "вклеивается" в функцию b(), с попутным разрешением возможного конфликта имен.

Ограничения:

У функций, которые мы инлайним, не должно быть именованных параметров. Мне просто лень это прикручивать, для демонстрации возможности хватит и текущей версии.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

astrologic-0.0.8.tar.gz (19.7 kB view hashes)

Uploaded Source

Built Distribution

astrologic-0.0.8-py3-none-any.whl (14.5 kB view hashes)

Uploaded Python 3

Supported by

AWS AWS Cloud computing and Security Sponsor Datadog Datadog Monitoring Fastly Fastly CDN Google Google Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Sentry Sentry Error logging StatusPage StatusPage Status page