python

Что я узнал про оптимизацию в Python

  • пятница, 28 июня 2019 г. в 00:15:48
https://habr.com/ru/company/otus/blog/457942/
  • Блог компании OTUS. Онлайн-образование
  • Python
  • Программирование


Всем привет. Сегодня хотим поделиться еще одним переводом подготовленным в преддверии запуска курса «Разработчик Python». Поехали!



Я использовал Python чаще, чем любой другой язык программирования в последние 4-5 лет. Python – преобладающий язык для билдов под Firefox, тестирования и инструмента CI. Mercurial также в основном написан на Python. Множество своих сторонних проектов я тоже писал на нем.

Во время своей работы я получил немного знаний о производительности Python и о его средствах оптимизации. В этой статье мне хотелось бы поделиться этими знаниями.

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

Нагрузка из-за особенностей запуска и импорта модулей


Запуск интерпретатора Python и импорт модулей – это достаточно долгий процесс, если речь идет о миллисекундах.

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

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

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

Не так много вещей, которые вы можете предпринять, чтобы уменьшить задержку запуска: исправление этом случае относится к манипуляциям с интерпретатором Python, поскольку именно он контролирует исполнение кода, которое занимает слишком много времени. Лучшее, что вы можете сделать – это отключить импорт модуля site в вызовах, чтобы избежать исполнения лишнего кода на Python во время запуска. С другой стороны, множество приложений используют функционал модуля site.py, поэтому использовать это можно на свой страх и риск.

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

Для импортирования модулей нужно предпринять несколько шагов. И в каждом из них есть потенциальный источник нагрузок и задержек.

Определенная задержка происходит за счёт поиска модулей и считывания их данных. Как я продемонстрировал с помощью PyOxidizer, заместив поиск и загрузку модуля из файловой системы архитектурно более простым решением, который заключается в чтении данных модуля из структуры данных в памяти, можно импортировать стандартную библиотеку Python за 70-80% от изначального времени решения этой задачи. Наличие одного модуля на файл файловой системы увеличивает нагрузку на файловую систему и может замедлить работу приложения Python в критические первые миллисекунды исполнения. Решения подобные PyOxidizer могут помочь этого избежать. Надеюсь, что сообщество Python видит эти издержки текущего подхода и рассматривает возможность перехода к механизмам распределения модулей, которые не так сильно зависят от отдельных фалов в модуле.

Другой источник дополнительных расходов на импорт модуля – это выполнение кода в этом модуле во время импорта. Некоторые модули содержат части кода в области вне функций и классов модуля, который и выполняется при импорте модуля. Выполнение такого кода увеличивает затраты на импорт. Способ обхода: исполнять не весь код во время импорта, а исполнять его только при надобности. Python 3.7 поддерживает модуль __getattr__, который будет вызван, в случае, если атрибут какого-либо модуля не был найден. Это может использоваться для ленивого заполнения атрибутов модуля при первом доступе.

Другой способ избавиться от замедления при импорте – это ленивый импорт модуля. Вместо того, чтобы непосредственно загружать модуль при импорте, вы регистрируете пользовательский модуль импорта, который возвращает вместо этого заглушку (stub). При первом обращении к этой заглушке, она загрузит фактический модуль и «мутирует», чтобы стать этим модулем.

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

Ленивый импорт модулей – это хрупкая вещь. Множество модулей имеют шаблоны, в которых есть следующие вещи: try: import foo; except ImportError:. Ленивый импортер модулей может никогда не выдать ImportError, поскольку если он это сделает, то ему придется искать в файловой системе модуль, чтобы узнать существует ли он в принципе. Это добавит дополнительную нагрузку и увеличит затраты времени, поэтому ленивые импортеры не делают этого в принципе! Эта проблема довольно неприятна. Импортер ленивых модулей Mercurial обрабатывает список модулей, которые не могут быть лениво импортированы, и он должен их обойти. Другая проблема это синтаксис from foo import x, y, который также прерывает импорт ленивого модуля, в случаях, когда foo является модулем (в отличие от пакета), поскольку для возврата ссылки на x и y, модуль все же должен быть импортирован.

PyOxidizer имеет фиксированный набор модулей вшитых в бинарник, поэтому он может быть эффективным в вопросе выдачи ImportError. Модуль __getattr__ из Python 3.7 обеспечивает дополнительную гибкость для ленивых импортеров модулей. Я надеюсь интегрировать надежный ленивый импортер в PyOxidizer, чтобы автоматизировать некоторые процессы.

Лучшее решение для избежания запуска интерпретатора и появления временных задержек – это запуск фонового процесса в Python. Если вы запускаете процесс Python в качестве демона (daemon process), скажем для веб-сервера, то вы сможете это сделать. Решение, которое предлагает Mercurial – это запуск фонового процесса, который предоставляет протокол сервера команд (command server protocol). hg является исполняемым файлом С (или же теперь Rust), который подключается к этому фоновому процессу и отправляет команду. Чтобы найти подход к командному серверу, нужно проделать много работы, он крайне нестабильный и имеет проблемы с безопасностью. Я рассматриваю идею доставки командного сервера с помощью PyOxidizer, чтобы исполняемый файл имел его преимущества, а сама по себе проблема стоимости программного решения решилась посредством создание проекта PyOxidizer.

Задержка из-за вызова функции


Вызов функций в Python относительно медленный процесс. (Это наблюдение менее применимо к PyPy, который может исполнять код JIT.)

Я видел десятки патчей для Mercurial, которые давали возможность выравнивать и комбинировать код таким образом, чтобы избежать лишней нагрузки при вызове функций. В текущем цикле разработки были предприняты некоторые усилия, чтобы уменьшить количество вызываемый функций при обновлении прогресс-бара. (Мы используем прогресс-бары для любых операций, которые могут занимать определенное время, чтобы пользователь понимал, что происходит). Получение результатов вызова функций и избежание простых поисков среди функций экономят десятки сотен миллисекунд при выполнении, когда мы говорим об одном миллионе выполнений, например.

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

Дополнительная нагрузка поиска атрибутов


Эта проблема сходна с накладными расходами из-за вызова функции, поскольку смысл практически один и тот же!

Нахождение (resolving) атрибутов в Python может быть медленным. (И снова, в PyPy это происходит быстрее). Однако обработка этой проблемы – это то, что мы делаем часто в Mercurial.

Допустим, у вас есть следующий код:

obj = MyObject()
total = 0

for i in len(obj.member):
    total += obj.member[i]

Опустим, что есть более эффективные способы написания этого примера (например, total = sum(obj.member)), и обратим внимание, что циклу необходимо определять obj.member на каждой итерации. В Python есть относительно сложный механизм для определения атрибутов. Для простых типов он может быть достаточно быстрым. Но для сложных типов этот доступ к атрибутам может автоматически вызывать __getattr__, __getattribute__, различные методы dunder и даже пользовательские функции @property. Это похоже на быстрый поиск атрибута, который может сделать несколько вызовов функции, что приведет к лишней нагрузке. И эта нагрузка может усугубиться, если вы используете такие вещи, как obj.member1.member2.member3 и т.д.

Каждое определение атрибута вызывает дополнительную нагрузку. И поскольку почти все в Python — это словарь, то можно сказать, что каждый поиск атрибута – это поиск по словарю. Из общих понятий о базовых структурах данных мы знаем, что поиск по словарю – это не так быстро, как, допустим, поиск по указателю. Да, конечно есть несколько трюков в CPython, которые позволяют избавиться от накладных расходов из-за поиска по словарю. Но основная тема, которую я хочу затронуть, так это то, что любой поиск атрибутов – это потенциальная утечка производительности.

Для жестких циклов, в особенности тех, которые потенциально превышают сотни тысяч итераций – вы можете избежать этих измеримых издержек на поиск атрибутов путем присвоения значения локальной переменной. Посмотрим на следующий пример:

obj = MyObject()
total = 0

member = obj.member
for i in len(member):
    total += member[i]

Конечно же, безопасно это можно сделать только в случае, если она не заменяется в цикле. Если это произойдет, то итератор сохранит ссылку на старый элемент и все может взорваться.
Тот же самый трюк можно провести при вызове метода объекта. Вместо

obj = MyObject()

for i in range(1000000):
    obj.process(i)

Можно сделать следующее:

obj = MyObject()
fn = obj.process

for i in range(1000000:)
    fn(i)

Также стоит заметить, что в случае, когда поиску по атрибутам нужно вызвать метод (как в предыдущем примере), то Python 3.7 сравнительно быстрее, чем предыдущие релизы. Но я уверен, что здесь излишняя нагрузка связана, в первую очередь, с вызовом функции, а не с нагрузкой на поиск атрибута. Поэтому все будет работать быстрее, если отказаться от лишнего поиска атрибутов.

Наконец, поскольку поиск атрибутов вызывает для этого функции, то можно сказать, что поиск атрибутов – это в целом меньшая проблема, чем нагрузка из-за вызова функции. Как правило, чтобы заметить значительные изменения в скорости работы, вам понадобится устранить множество поисков атрибутов. При этом, как только вы дадите доступ ко всем атрибутам внутри цикла, вы можете говорить о 10 или 20 атрибутах только в цикле до вызова функции. А циклы с всего тысячами или менее чем с десятками тысяч итераций могут быстро обеспечить сотни тысяч или миллионы поисков атрибутов. Так что будьте внимательны!

Объектная нагрузка


С точки зрения интерпретатора Python все значения – это объекты. В CPython каждый элемент– это структура PyObject. Каждый объект, управляемый интерпретатором, находится в куче и имеет собственную память, содержащую счетчик ссылок, тип объекта и другие параметры. Каждый объект утилизируется сборщиком мусора. Это означает, что каждый новый объект добавляет накладные расходы из-за подсчета ссылок, механизма сбора мусора и т.п. (И снова, PyPy может избежать этой лишней нагрузки, поскольку «внимательнее относится» ко времени жизни краткосрочных значений.)

Как правило, чем больше уникальных значений и объектов Python вы создаете, тем медленнее у вас все работает.

Скажем, вы перебираете коллекцию из одного миллиона объектов. Вы вызываете функцию для сбора этого объекта в кортеж:

for x in my_collection:
    a, b, c, d, e, f, g, h = process(x)

В данном примере, process() вернет кортеж 8-tuple. Не имеет значения, уничтожим мы возвращаемое значение или нет: этот кортеж требует создания по крайней мере 9 значений в Python: 1 для самого кортежа и 8 для его внутренних членов. Хорошо, в реальной жизни там может быть меньше значений, если process() возвращает ссылку на существующий объект. Или же их наоборот может быть больше, если их типы не простые и требуют для представления множество PyObject. Я лишь хочу сказать, что под капотом интерпретатора происходит настоящее жонглирование объектами для полноценного представления тех или иных конструкций.

По своему опыту могу сказать, что эти накладные расходы актуальны лишь для операций, которые дают выигрыш в скорости при реализации на родном языке, таком как C или Rust. Проблема в том, что интерпретатор CPython просто неспособен выполнять байткод настолько быстро, чтобы дополнительная нагрузка из-за количества объектов имела значение. Вместо этого вы наиболее вероятно снизите производительность с помощью вызова функции, или за счет громоздких вычислений и т.п. прежде чем сможете заметить дополнительную нагрузку из-за объектов. Есть, конечно же несколько исключений, а именно построение кортежей или словарей из нескольких значений.

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

Способ обойти эту проблему – плодить меньше элементов в Python. Если вам нужно обратиться к единственному элементу, то пускай функция его и возвращает, а не кортеж или словарь из N элементов. Тем не менее, не переставайте следить за возможной нагрузкой из-за вызова функций!

Если у вас есть много кода, который работает достаточно быстро с использованием CPython C API, и элементы, которые должны быть распределены между различными модулями, обойдитесь без типов Python, которые представляют различные данные как структуры С и имеют уже скомпилированный код для доступа к этим структурам вместо того, чтобы проходить через CPython C API. Избегая CPython C API для доступа к данным, вы избавитесь от большого объема лишней нагрузки.

Рассматривать элементы как данные (вместо того, чтобы иметь функции для доступа ко всему подряд) будет лучшим подходом для питониста. Другой обходной путь для уже скомпилированного кода – это ленивое создание экземпляров PyObject. Если вы создаете пользовательский тип в Python (PyTypeObject) для представления сложных элементов, вам необходимо определить поля tp_members или же tp_getset для создания пользовательских функций на С для поиска значение для атрибута. Если вы, скажем, пишите парсер и знаете, что заказчики получат доступ только к подмножеству проанализированных полей, то сможете быстро создать тип, содержащий необработанные данные, вернуть этот тип и вызвать функцию на С для поиска атрибутов Python, которая обрабатывает PyObject. Вы можете даже отложить парсинг до момента вызова функции, чтобы сэкономить ресурсы в случае, если парсинг никогда не понадобится! Эта техника достаточно редкая, поскольку она требует написания нетривиального кода, однако она дает положительный результат.

Предварительное определение размера коллекции


Это относится к CPython C API.

Во время создание коллекций, таких как списки или словари, используйте PyList_New() + PyList_SET_ITEM(), чтобы заполнить новую коллекцию, если ее размер уже определен на момент создания. Это предварительно определит размер коллекции, чтобы иметь возможность держать в ней конечное число элементов. Это помогает пропустить проверки на достаточный размер коллекции при вставке элементов. При создании коллекции из тысячи элементов это поможет сэкономить немного ресурсов!

Использование Zero-copy в C API


В Python C API действительно больше нравится создавать копии объектов, чем возвращать ссылки на них. Например, PyBytes_FromStringAndSize() копирует char* в память зарезервированную Python. Если вы делаете это для большого количества значений или больших данных, то мы могли бы говорить о гигабайтах ввода-вывода из памяти и связанной с этим нагрузкой на распределитель (allocator).

Если вам нужно написать высокопроизводительный код без C API, то вам следует ознакомиться с buffer protocol и соответствующими типами, такими как memoryview.

Buffer protocol встроен в типы Python и позволяет интерпретаторам приводить тип из/к байтам. Он также позволяет интерпретатору кода на С получать дескриптор void* определенного размера. Это позволяет связать любой адрес в памяти с PyObject. Многие функции, работающие с бинарными данными прозрачно принимают любой объект, реализующий buffer protocol. И если вы хотите принять любой объект, который может быть рассмотрен как байты, то вам необходимо использовать единицы формата s*, y* или w* при получении аргументов функции.

Используя buffer protocol, вы даете интерпретатору лучшую из имеющихся возможностей использовать zero-copy операции и отказаться от копирования лишних байтов в память.

С помощью типов в Python вида memoryview, вы также позволите Python обращаться к уровням памяти по ссылке, вместо создания копий.

Если у вас есть гигабайты кода, которые проходят через вашу программу на Python, то проницательное использование типов Python, которые поддерживают zero-copy, избавят вас от разницы в производительности. Однажды я заметил, что python-zstandard оказался быстрее, чем какие-нибудь биндинги Python LZ4 (хотя должно быть наоборот), поскольку я слишком интенсивно использовал buffer protocol и избегал чрезмерного ввода-вывода из памяти в python-zstandard!

Заключение


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

Наконец, эта статья не должна быть интерпретирована как наезд на Python и его общую производительность. Да, вы можете привести аргументы в пользу того, что Python должен или не должен использоваться в тех или иных ситуациях из-за особенностей производительности. Однако вместе с этим Python довольно универсален – особенно в связке с PyPy, который обеспечивает исключительную производительность для динамического языка программирования. Производительность Python возможно кажется достаточно хорошей большинству людей. Хорошо это или плохо, но я всегда использовал Python для кейсов, которые выделяются на фоне других. Здесь мне захотелось поделиться своим опытом, чтобы другим стало чуть понятнее какой может быть жизнь «на боевом рубеже». И может быть, только может быть, я смогу сподвигнуть умных людей, которые используют дистрибутивы Python, подумать о проблемах, с которыми я столкнулся, более подробно и представить лучшие решения.

По устоявшейся традиции ждем ваши комментарии ;-)