Профилирование асинхронного Python

Общие слова

Профилирование приложений — это процесс анализа программы для определения её характеристик: времени выполнения различных частей кода и использования ресурсов.

Основные этапы профилирования всегда более-менее одинаковы:

  • Измерение времени выполнения. Cколько времени требуется для выполнения различных частей кода?

  • Анализ использования памяти. Сколько памяти потребляется различными частями программы?

  • Выявление узких мест. Какие части кода замедляют работу программы и/или используют слишком много ресурсов?

  • Оптимизация производительности. Принятие мер для улучшения скорости выполнения и эффективности использования ресурсов на основе полученных данных.

А как вообще работает профилировщик?

Детальному обзору будет посвящена отдельная статья, пока можно ограничится базовой классификацией:

  • Детерминированные (deterministic) профилировщики. Главный представитель — встроенный cProfile. Такой профилировщик считает количество вызовов каждой функции и потраченное функцией время. Проблема в том, что время ожидания асинхронных вызовов не учитывается.

  • Статистические (statistical) профилировщики. Распространённые представители — scalene, py-spy, yappi, pyinstrument, austin. Такие профилировщики с некоторой частотой снимают «слепок» с процесса и применяют методы статистического анализа для поиска узких мест.

Основные типы узких мест в асинхронном Python-коде

Для асинхронного кода существует небольшое количество специфических «узких мест», которые лучше перечислить заранее.

Каждому типу сопоставим пример кода.

Список допущений

Блокирующие операции

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

Блокирующие операции

a844e0ab0d682552a5556fa1616f6a60.png

Проблемную строку с блокирующим вызовов видно сразу — 2% времени на Python, 98% — на системные вызовы.

Последовательный вызов асинхронных задач

d43d62c11290bbae4d70abb0e990b254.png

Здесь чуть сложнее. Видно, что 90% времени уходит на системные вызовы, но поменялась строка — теперь это сам asyncio.run(). Такой паттерн вывода профилировщика лучше всего просто запомнить.

Слишком частое переключение контекста

a3cb144f513c3c33ebb6be492e789baa.png

Видим, как растёт потребление памяти в asyncio.gather() — делаем вывод о слишком сильном «дроблении» задач.

Неравномерное распределение ресурсов

42d1b6509fb4bfc2d0a4882b8c70a316.png

И снова соотношение времени system vs python не в пользу python-операций.

Чрезмерный расход памяти

202781fab80586408b6fdf2828a46b45.png

Здесь профилировщик сделал всё за нас и сразу показал проблемный код.

Заключение

Надо обратить внимание, что для трёх случаев — «блокирующие операции», «последовательный вызов асинхронных задач» и «неравномерное распределение ресурсов» профилировщик показал нам одну и ту же картину — system % >> python %. Для уточнения причины требуется, собственно, разработчик.

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

© Habrahabr.ru