Профилирование асинхронного Python
Общие слова
Профилирование приложений — это процесс анализа программы для определения её характеристик: времени выполнения различных частей кода и использования ресурсов.
Основные этапы профилирования всегда более-менее одинаковы:
Измерение времени выполнения. Cколько времени требуется для выполнения различных частей кода?
Анализ использования памяти. Сколько памяти потребляется различными частями программы?
Выявление узких мест. Какие части кода замедляют работу программы и/или используют слишком много ресурсов?
Оптимизация производительности. Принятие мер для улучшения скорости выполнения и эффективности использования ресурсов на основе полученных данных.
А как вообще работает профилировщик?
Детальному обзору будет посвящена отдельная статья, пока можно ограничится базовой классификацией:
Детерминированные (deterministic) профилировщики. Главный представитель - встроенный
cProfile
. Такой профилировщик считает количество вызовов каждой функции и потраченное функцией время. Проблема в том, что время ожидания асинхронных вызовов не учитывается.Статистические (statistical) профилировщики. Распространённые представители -
scalene
,py-spy
,yappi
,pyinstrument
,austin
. Такие профилировщики с некоторой частотой снимают "слепок" с процесса и применяют методы статистического анализа для поиска узких мест.
Основные типы узких мест в асинхронном Python-коде
Для асинхронного кода существует небольшое количество специфических "узких мест", которые лучше перечислить заранее.
Каждому типу сопоставим пример кода.
Список допущений
Используется один и только один event-loop
Python 3.12
Блокирующие операции
import asyncio
import time
async def main():
print('Start')
# Blocking call
time.sleep(3) # This blocks the entire event loop
print('End')
asyncio.run(main())
Последовательный вызов асинхронных задач
import asyncio
import aiohttp
async def fetch(session, url):
async with session.get(url) as response:
return await response.text()
async def main():
urls = ["http://habr.com"] * 10
async with aiohttp.ClientSession() as session:
# Inefficient: Sequential requests
for url in urls:
await fetch(session, url)
asyncio.run(main())
Слишком частое переключение контекста
import asyncio
async def tiny_task():
await asyncio.sleep(0.0001)
async def main():
# Excessive context switching due to many small tasks
await asyncio.gather(*(tiny_task() for _ in range(100000)))
asyncio.run(main())
Неравномерное распределение ресурсов
В англоязычной литературе такой сценарий называется "Resource Starvation".
import asyncio
async def long_running_task():
await asyncio.sleep(10)
print("Long task executed")
async def quick_task():
await asyncio.sleep(1)
print("Quick task executed")
async def main():
await asyncio.gather(
long_running_task(),
quick_task() # May be delayed excessively
)
asyncio.run(main())
Чрезмерный расход памяти
import asyncio
async def large_data_task():
data = "h" * 10**8 # Large memory usage
await asyncio.sleep(1)
async def main():
tasks = [large_data_task() for _ in range(100)] # High memory consumption
await asyncio.gather(*tasks)
asyncio.run(main())
Использование "scalene" для профилирования
Почему scalene
? Потому что этот инструмент позволяет профилировать и CPU, и GPU, и память; 10k+ звёзд на гитхабе, проект активно развивается.
Посмотрим что скажет scalene
для каждого "проблемного" кода из списка выше.
Запускать будем в режиме scalene --cpu --memory --cli script_name.py
Блокирующие операции
Проблемную строку с блокирующим вызовов видно сразу - 2% времени на Python, 98% - на системные вызовы.
Последовательный вызов асинхронных задач
Здесь чуть сложнее. Видно, что 90% времени уходит на системные вызовы, но поменялась строка - теперь это сам asyncio.run()
. Такой паттерн вывода профилировщика лучше всего просто запомнить.
Слишком частое переключение контекста
Видим, как растёт потребление памяти в asyncio.gather()
- делаем вывод о слишком сильном "дроблении" задач.
Неравномерное распределение ресурсов
И снова соотношение времени system vs python
не в пользу python-операций.
Чрезмерный расход памяти
Здесь профилировщик сделал всё за нас и сразу показал проблемный код.
Заключение
Надо обратить внимание, что для трёх случаев - "блокирующие операции", "последовательный вызов асинхронных задач" и "неравномерное распределение ресурсов" профилировщик показал нам одну и ту же картину - system % >> python %
. Для уточнения причины требуется, собственно, разработчик.
Профилировать Python - несложная и достаточно приятная задача, если знать основные типы узких мест и быть готовым внимательно читать вывод профилировщика.