Pull to refresh
VK
Building the Internet

Профилирование и отладка Python, переходим к практике

Reading time14 min
Views44K
В прошлой статье мы определили понятия профилирования и оптимизации, познакомились с различными подходами к профилированию и видами инструментов. Немного коснулись истории профайлеров.

Сегодня я предлагаю перейти к практике и покажу на примерах способы ручного профилирования (и даже «метод пристального взгляда». Будут так же рассмотрены инструменты для статистического профилирования.

  • Введение и теория — зачем вообще нужно профилирование, различные подходы, инструменты и отличия между ними
  • Ручное и статистическое профилирование — переходим к практике
  • Событийное профилирование — инструменты и их применение
  • Отладка — что делать, когда ничего не работает

Тренируемся


Какая может быть практика без примеров и тренировок? Я долго думал, какой же проект стоит взять в качестве учебного пособия, чтобы он был одновременно показательным и не очень сложным. На мой взгляд таковым является Project Euler — сборник математических и компьютерных головоломок, поскольку для решения предложенных задач приходится применять числодробительные алгоритмы и без оптимизации поиск ответа может продолжаться годами.

Вообще, очень советую порешать головоломки из «Проекта Эйлера». Помогает отвлечься, помедитировать и расслабиться, и в то же время держать свой мозг в тонусе.

В качестве примера для статьи возьмём задачу 3:
Простые делители числа 13195 — это 5, 7, 13 и 29.
Какой самый большой делитель числа 600851475143, являющийся простым числом?

Пишем простое решение «в лоб»:
"""Project Euler problem 3 solve"""
from __future__ import print_function
import sys
 
 
def is_prime(num):
    """Checks if num is prime number"""
    for i in range(2, num):
        if not num % i:
            return False
    return True
 
 
def find_prime_factors(num):
    """Find prime factors of num"""
    result = []
    for i in range(2, num):
        if is_prime(i) and not num % i:
            result.append(i)
    return result
 
 
if __name__ == '__main__':
    try:
        num = int(sys.argv[1])
    except (TypeError, ValueError, IndexError):
        sys.exit("Usage: euler_3.py number")
    if num < 1:
        sys.exit("Error: number must be greater than zero")
 
    prime_factors = find_prime_factors(num)
    if len(prime_factors) == 0:
        print("Can't find prime factors of %d" % num)
    else:
        print("Answer: %d" % prime_factors[-1])

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

Для проверки запускаем программу c числом, делители которого нам известны:

rudnyh@work:~/work/python-profiling (venv: python-profiling)
 python euler_3.py 13195
Answer: 29

Ответ 29, выданный программой правильный (его можно найти в условии задачи). Ура! Теперь запускаем программу с интересующим нас числом 600851475143:
rudnyh@work:~/work/python-profiling (venv: python-profiling)
 python euler_3.py 600851475143

И… ничего не происходит. Загрузка процессора 100%, выполнение программы не завершилось даже после нескольких часов работы. Начинаем разбираться. Вспоминаем, что оптимизировать можно только тот код, который работает корректно, но мы проверили работу программы на небольшом числе и ответ был правильный. Очевидно, что проблема в производительности и нам нужно приступать к оптимизации.

Метод пристального взгляда


В реальной жизни я бы профилировал программу с аргументом 13195 (когда время её работы адекватно). Но поскольку мы тут тренируемся и вообще, just for jun, воспользуемся «методом пристального взгляда».

Открываем код и внимательно на него смотрим. Понимаем (если повезёт, то понимаем быстро), что для поиска делителей числа N нам нет смысла перебирать числа из интервала sqrt(N)+1…N-1, т.к. все делители из этого диапазона мы уже нашли при переборе чисел из интервала 2…sqrt(N). Слегка модифицируем код (см. строки 9 и 18):
"""Project Euler problem 3 solve"""
from __future__ import print_function
import math
import sys
 
 
def is_prime(num):
    """Checks if num is prime number"""
    for i in range(2, int(math.sqrt(num)) + 1):
        if not num % i:
            return False
    return True
 
 
def find_prime_factors(num):
    """Find prime factors of num"""
    result = []
    for i in range(1, int(math.sqrt(num)) + 1):
        if is_prime(i) and not num % i:
            result.append(i)
    if is_prime(num):
        result.append(i)
    return result
 
 
if __name__ == '__main__':
    try:
        num = int(sys.argv[1])
    except (TypeError, ValueError, IndexError):
        sys.exit("Usage: euler_3.py number")
    if num < 1:
        sys.exit("Error: number must be greater than zero")
 
    prime_factors = find_prime_factors(num)
    if len(prime_factors) == 0:
        print("Can't find prime factors of %d" % num)
    else:
        print("Answer: %d" % prime_factors[-1])

Для проверки ещё раз запускаем программу с числом, делители которого нам известны:

rudnyh@work:~/work/python-profiling (venv: python-profiling)
 python euler_3.py 13195
Answer: 29

Cубъективно программа отработала гораздо быстрее, значит снова запускаем её с интересующим нас числом 600851475143:
rudnyh@work:~/work/python-profiling (venv: python-profiling)
 python euler_3.py 600851475143
Answer: 6857

Проверяем ответ на сайте, он оказывается верным, задача решена, мы чувствуем моральное удовлетворение.

Программа выполнилась за приемлимое время (меньше минуты), ответ получен верный, смысла в дальнешей оптимизации в данном конкретном случае нет, т.к. поставленную задачу мы решили. Как мы помним, самое важное в оптимизации — уметь вовремя остановиться.

Да, я в курсе, что ребята из «Project Euler» просят не выкладывать ответы и решения в общий доступ. Но ответ к задаче 3 гуглится (например, по условию «project euler problem 3 answer» на раз, поэтому я считаю, что ничего страшного в том, что я написал ответ нет.

Ручное профилирование


Один из самых распространённых способов быстро прикинуть «что к чему». В самом элементарном случае, если мы используем unix-утилиту «time» это выглядит так (до оптимизации):
rudnyh@work:~/work/python-profiling (venv: python-profiling)
 time python euler_3.py 13195
Answer: 29
python euler_3.py 13195  3,83s user 0,03s system 99% cpu 3,877 total

И так (после оптимизации):
rudnyh@work:~/work/python-profiling (venv: python-profiling)
 time python euler_3.py 13195
Answer: 29
python euler_3.py 13195  0,03s user 0,02s system 90% cpu 0,061 total

Ускорение почти в 65 раз (с ~3,87 секунд до ~61 милисекунд)!

Так же ручное профилирование может выглядеть так:
import time
 
...
 
start = time.time()
prime_factors = find_prime_factors(num)
print("Time: %.03f s" % (time.time() - start))

Результат:
rudnyh@work:~/work/python-profiling (venv: python-profiling)
 python euler_3.py 600851475143
Answer: 6857
Time: 19.811 s

Либо с использованием специального модуля «timeit», который предназначен для измерения быстродействия небольших программ. Пример применения:
rudnyh@work:~/work/python-profiling (venv: python-profiling)
 python -m timeit -n 10 -s'import euler_3' 'euler_3.find_prime_factors(600851475143)'
10 loops, best of 3: 21.3 sec per loop

Когда же можно применять ручное профилирование? Во-первых, это отличный способ для проведения различного рода соревнований между разработчиками («Мой код теперь работает быстрее твоего, значит я более лучший программист!»), и это хорошо. Во-вторых, когда требуется «на глазок» определить быстродействие программы (20 секунд? долго!) или получить результаты достигнутых улучшений (ускорил код в 100 раз!).

Но самое важное применение — это сбор статистики времени выполнения кода практически в реальном времени прямо на продакшене. Для этого измеренное время отправляем в любую систему сбора метрик и отрисовки графиков (я очень люблю использовать Graphite и StatsD в качестве аггрегатора для графита).

Для этого можно воспользоваться простым менеджером контекстов:
"""Collect profiling statistic into graphite"""
import socket
import time
 
 
CARBON_SERVER = '127.0.0.1'
CARBON_PORT = 2003
 
 
class Stats(object):
    """Context manager for send stats to graphite"""
 
    def __init__(self, name):
        self.name = name
 
    def __enter__(self):
        self.start = time.time()
        return self
 
    def __exit__(self, *args):
        duration = (time.time() - self.start) * 1000  # msec
        message = '%s %d %d\n' % (self.name, duration, time.time())
 
        sock = socket.socket()
        sock.connect((CARBON_SERVER, CARBON_PORT))
        sock.sendall(message)
        sock.close()

Пример его использования:
from python_profiling.context_managers import Stats
 
...
 
with Stats('project.application.some_action'):
    do_some_action()

Или простым декоратором:
"""Collect profiling statistic into graphite"""
import socket
import time
 
 
CARBON_SERVER = '127.0.0.1'
CARBON_PORT = 2003
 
 
def stats(name):
    """Decorator for send stats to graphite"""
    def _timing(func):
        def _wrapper(*args, **kwargs):
            start = time.time()
            result = func(*args, **kwargs)
            duration = (time.time() - start) * 1000  # msec
            message = '%s %d %d\n' % (name, duration, time.time())
 
            sock = socket.socket()
            sock.connect((CARBON_SERVER, CARBON_PORT))
            sock.sendall(message)
            sock.close()
 
            return result
        return _wrapper
    return _timing

Пример использования декоратора:
from python_profiling.decorators import stats
 
...
 
@stats('project.application.some_action')
def do_some_action():
    """Doing some useful action"""

На выходе получаем график времени выполнения интересующего нас участка кода, например, вот такой:

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

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

Конечно, можно назвать метод профилирования через сбор метрик в графит «статистическим», ведь мы собираем статистику работы программы. Но я предлагаю придерживаться принятой нами в первой части терминологии и под «статистическим» профилированием понимать именно сбор информации (семплирование) через определённые промежутки времени.

Django StatsD


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

Ставим модуль:
 pip install django-statsd-mozilla

Добавляем настройки в settings.py: приложение и middleware:
INSTALLED_APPS += ('django_statsd',)
 
MIDDLEWARE_CLASSES += (
       'django_statsd.middleware.GraphiteRequestTimingMiddleware',
       'django_statsd.middleware.GraphiteMiddleware',
)
 
# send DB timings
STATSD_PATCHES = ['django_statsd.patches.db']

И всё! На выходе получаем вот такие вот графики:


Плюсы и минусы StatsD:
+ прост в установке и использовании
+ годен для продакшена (must-have, в общем-то)
– мало информации (количество/время)
– Нужен graphite и statsd (тоже must-have)

Статистические профайлеры


В отличие от событийных профайлеров, инструментов для статистического профилирования немного. Я расскажу о трёх:


StatProf


Пожалуй, самый известный статистический профайлер под питон — statprof. Ставим:
 pip install statprof

Используем, например. так:
import statprof
 
...
 
statprof.start()
try:
    do_some_action()
finally:
    statprof.stop()
    statprof.display()

Или как менеджер контекста (нет в версии 0.1.2 из pypi, есть только в версии из репозитория):
import statprof
 
...
 
with statprof.profile():
    do_some_action()

Попробуем отпрофилировать наш код:
rudnyh@work:~/work/python-profiling (venv: python-profiling)
 python euler_3.py 600851475143
  %   cumulative      self
 time    seconds   seconds  name
 44.42      8.63      8.63  euler_3.py:12:is_prime
 37.12      7.21      7.21  euler_3.py:11:is_prime
 16.90     19.40      3.28  euler_3.py:21:find_prime_factors
  0.95      0.18      0.18  euler_3.py:9:is_prime
  0.48      0.09      0.09  euler_3.py:13:is_prime
  0.06      0.01      0.01  euler_3.py:14:is_prime
  0.06      0.01      0.01  euler_3.py:20:find_prime_factors
  0.03      0.01      0.01  euler_3.py:23:find_prime_factors
  0.00     19.42      0.00  euler_3.py:37:<module>
— Sample count: 3575
Total time: 19.420000 seconds
Answer: 6857

Видим два «хотспота» в нашей программе. Первый: строки 12 и 11 функции «is_prime» (что логично), их выполнение занимает около 82% времени работы программы, и второй: строка 21 функции «find_prime_factors» (около 17% времени):
if is_prime(i) and not num % i:

Именно из этой строки вызывается самая «горячая» функция программы «is_prime». Просто поменяв местами операнды в условии мы значительно ускорим программу, т.к. операция получения остатка от деления (num % i) выполняется быстрее функции «is_prime», и в то же время достаточно часто остаток от деления одного числа на другое не равен нулю, и «not num % i» вернёт False. Таким образом, мы кардинально уменьшим количество вызовов функции «is_prime»:
if not num % i and is_prime(i):

Запускаем профилирование:
rudnyh@work:~/work/python-profiling (venv: python-profiling)
 python euler_3.py 600851475143
  %   cumulative      self
 time    seconds   seconds  name
 87.50      0.22      0.22  euler_3.py:21:find_prime_factors
  5.00      0.01      0.01  euler_3.py:20:find_prime_factors
  5.00      0.01      0.01  euler_3.py:11:is_prime
  2.50      0.01      0.01  euler_3.py:23:find_prime_factors
  0.00      0.25      0.00  euler_3.py:37:<module>
— Sample count: 40
Total time: 0.250000 seconds
Answer: 6857

Самое горячее место в нашей программе теперь строка 21 функции «find_prime_factors», то есть операция получения остатка от деления («num % i»). Функция «is_prime» теперь вызывается гораздо реже и выполняется всего 5% времени работы программы. Время работы программы значительно уменьшилось и наибольший простой делитель числа 600851475143 теперь находится всего за 0,25 секунд (ускорили программу почти в 80 раз).

Обратим внимание на то, как сильно упала точность работы профайлера: вместо 3575 семплов (в примере до оптимизации) было сделано всего 40 измерений и получена информация всего о пяти строках. Конечно, этого недостаточно. Собственно, в этом и заключается особенность статистических профайлеров: чем больше времени мы собираем данные, тем точнее анализ. Если бы мы, например, запустили программу десять или сто раз, мы бы получили гораздо более точные результаты.

Здесь я должен сделать небольшое лирическое отступление.
99% всех вспомогательных утилит, таких как профайлеры или инструменты для анализа покрытия кода тестами (code coverage), работают со строками в качестве минимальной единицы информации. Таким образом, если мы будем пытаться писать код как можно более компактно, например так:
result = foo() if bar else baz()

мы не сможем в профайлере увидеть, какая из функций вызывалась, а какая нет. И в отчёте coverage мы не увидим, покрыт ли тот или иной вариант развития событий тестами. Несмотря на более привычный и, казалось бы, удобный код, в некоторых случаях всё-таки лучше писать так:
if bar:
    result = foo()
else:
    result = baz()

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


Достоинства и недостатки statprof:
+ минимальный оверхед
+ простое использование
– реализация достаточно сырая и экспериментальная
– для адекватного результата нужен длительный анализ
– мало данных на выходе

Django-live-profiler


Отдельным пунктом стоит отметить django-live-profiler — профайлер приложений Django, использующий statprof. Для его работы необходимо сначала установить zeromq:
 brew install zmq

Ставим сам модуль:
 pip install django-live-profiler

И запускаем аггрегатор:
 aggregated --host 127.0.0.1 --port 5556

Дальше добавляем профайлер в settings.py:
# добавляем application
INSTALLED_APPS += ('profiler',)
 
# добавляем middleware
MIDDLEWARE_CLASSES += (
    'profiler.middleware.ProfilerMiddleware',
    'profiler.middleware.StatProfMiddleware',
)

И в urls.py:
url(r'^profiler/', include('profiler.urls'))

Запускаем сервер:
 python manage.py runserver --noreload --nothreading

Открываем в браузере профайлер: 127.0.0.1:8000/profiler/ и радуемся жизни, наблюдая за результатами профилирования живого проекта в реальном времени:


А ещё django-live-profiler умеет показывать SQL-запросы:


Достоинства и недостатки django-live-profiler:
+ небольшой оверхед
+ можно пускать в продакшн (очень осторожно)
+ профилирование SQL-запросов
– сложная установка, зависимости
– мало данных на выходе

Plop


Ещё один статистический профайлер называется plop (Python Low-Overhead Profiler). Автор сразу предупреждает, что реализация сырая и проект находится в стадии активной разработки. Установка тривиальна:
 pip install plop tornado

Запускаем профилирование:
rudnyh@work:~/work/python-profiling (venv: python-profiling)
 python -m plop.collector euler_3.py 600851475143
Answer: 6857
profile output saved to /tmp/plop.out
overhead was 5.89810884916e-05 per sample (0.00589810884916%)

Запускаем сервер для просмотра результатов:
 python -m plop.viewer --datadir=/tmp/

Открываем в баузере страницу http://localhost:8888/ и любуемся результатами:

Plop можно использовать для профилирования Django-приложений. Для этого нужно установить пакет django-plop:
 pip install django-plop

Добавить в settings.py middleware и параметр, указывающий профайлеру, куда складывать результаты:
MIDDLEWARE_CLASSES += (
    'django-plop.middleware.PlopMiddleware',
)
 
PLOP_DIR = os.path.join(PROJECT_ROOT, 'plop')

В большом проекте граф будет выглядеть более внушительно:

Картина достаточно психоделична, серьёзным инструментом для профилирование это назвать сложно, но тем не менее граф вызовов у нас есть, самые горячие участки кода видно, оверхед минимальный (по утверждениям авторов всего 2%), и в некоторых случаях этого инструмента оказывается достаточно для обнаружения участков кода, нуждающихся в профилировании. Сервис Dropbox использует plop прямо в продакшене, а это о многом говорит.

Достоинства и недостатки plop:
+ минимальный оверхед
+ можно пускать в продакшн
– сложная установка, зависимости
– очень мало данных на выходе

New Relic


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

Достоинства и недостатки:
+ предназначен для продакшена
+ много разного функционала (не только профилирование)
– платный (есть бесплатная версия)
– данные отправляются на чужие серверы

В следующей статье мы перейдём к изучению событийных профайлеров — основными инструментами при профилировании Python. Оставайтесь на связи!
Tags:
Hubs:
+56
Comments25

Articles

Information

Website
vk.com
Registered
Founded
Employees
5,001–10,000 employees
Location
Россия
Representative
Миша Берггрен