Общие слова

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

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

  • Измерение времени выполнения. 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 - несложная и достаточно приятная задача, если знать основные типы узких мест и быть готовым внимательно читать вывод профилировщика.

Комментарии (3)


  1. code000
    29.11.2023 12:33

    По моему мнению очень важна тема. Я, как начинающий разработчик, очень пренебрегаю тестированием, что в свою очередь очень грубая халатность. В частности, когда разрабатывал tg-бота на aiogram, впервые столкнулся с асинхронностью, где допускал аналогичные ошибки.


  1. Rubikoid
    29.11.2023 12:33

    Если нужно профилировать asyncio, вместо scalene с абсолютно бесполезным отчетом лучше взять pyinstrument, который умеет отделать асинхронный код от не-асинхронного и нормально считать для него затраченное время.


    1. budurli Автор
      29.11.2023 12:33

      Да, ‘pyinstrument’ - отличная штука. А как выглядит «отделение асинхронного кода от синхронного»?