- PVSM.RU - https://www.pvsm.ru -
Представьте ситуацию: вы написали скрипт для обработки каких-то данных на ноутбуке, ушли попить кофе, а когда пятнадцать минут спустя вернулись, завершилось едва ли 10%.
Почему скрипт работает так медленно? Какая его часть тормозит? Дело в чтении данных, их обработке или сохранении? Как ускорить исполнение? Действительно ли скрипт вообще медленный?
Ответить на все эти вопросы поможет инструмент под названием «профилировщик» (profiler).
Профилировщик — это инструмент, запускающий код и собирающий информацию о времени, необходимом для вызова каждой функции, количестве вызовов и иерархии вызовов функций.
Проанализировав результаты работы инструмента, вы сможете понять, исполнение какой части кода требует больше всего времени (это называется «узким местом» или «бутылочным горлышком», bottleneck), а может, и придумать, как решить эту проблему. Вам нужно находить и устранять узкие места — это сильно повышает скорость программ.
Допустим, у нас есть большой текстовый файл, и нам нужно найти в нём множество вхождений по определённому паттерну. Сначала давайте сгенерируем большой файл со случайными алфавитно-цифровыми символами (построчно):
import random
import string
def generate_random_string(length):
"""Generate a random string of lowercase letters and digits."""
letters_and_digits = string.ascii_letters + string.digits
return ''.join(random.choice(letters_and_digits) for _ in range(length))
def generate_random_file(filename, num_lines, line_length):
"""Generate a file of random lines with the given number and length."""
with open(filename, 'w') as f:
for i in range(num_lines):
random_line = generate_random_string(line_length) + 'n'
f.write(random_line)
if __name__ == '__main__':
# Генерируем 1000000 строк по 1000 символов каждая
generate_random_file('random.txt', 1_000_000, 1000)
Теперь давайте зададим нашу первоначальную функцию, от которой будем отталкиваться — она считывает файл строка за строкой, а затем считает количество вхождений в них слов bob
и rob
, которым предшествует цифра:
import re
def baseline():
num_total_matches = 0
pattern1 = r"[0-9]{1}bob"
pattern2 = r"[0-9]{1}rob"
with open('random.txt', 'rt') as f:
for line in f:
line = line.lower() # преобразуем весь текст в нижний регистр
for pattern in [pattern1, pattern2]:
# Находим все вхождения паттерна в строке
matches = re.findall(pattern, line)
# Считаем количество совпадений
num_matches = len(matches)
num_total_matches += num_matches
return num_total_matches
Например, в строке abc1robdef02bob
есть два вхождения.
Мы выполняем функцию baseline
, вычисляем количество вхождений (в моём случае 10861) и замеряем время исполнения — на моей машине получилось 32 с.
Как ускорить работу кода?
Код потенциально могут замедлять четыре части. Вот они:
1) Медленные сырые паттерны? 2) Медленный ввод-вывод? 3) Медленное преобразование в нижний регистр? 4) Медленный двойной поиск?
Как узнать, что из этого оказалось узким местом? Нужно использовать профилировщик.
Можете порадоваться — вам не придётся ничего писать самостоятельно. В Python уже есть два встроенных модуля профилирования [3] — cProfile
и profile
. Они выполняют одну задачу, но cProfile
написан на C, а profile
— на чистом Python, но в нашем случае разницы нет. Можем использовать их и в исходном виде, однако мы сильно упростим себе жизнь, если воспользуемся несколькими внешними инструментами.
Нам необходим быстрый и удобный способ профилирования части кода (например, функции) и сохранения результата в файл. Модуль под названием profilehooks
предоставляет простой декоратор [4], которым мы можем обернуть интересующую нас функцию:
from profilehooks import profile
# stdout=False -> не выводить ничего в терминал
# filename -> путь к файлу с результатами профилирования
@profile(stdout=False, filename='baseline.prof')
def baseline():
...
Его можно установить простой командой pip install profilehooks
.
И нам нужно визуализировать этот файл удобным для человека образом. Для этого я пользуюсь двумя инструментами.
Snakeviz [5] — это работающий в браузере интерактивный визуализатор результатов профилирования Python. Его невероятно легко установить (pip install snakeviz
) и использовать (snakeviz <path-to-profiling-output>
). Давайте взглянем на результаты профилирования нашей функции baseline
.
Интерактивные результаты Snakeviz — графики icicle (слева) и sunburst (правый, менее читаемый). Можно наводить мышью и щёлкать на любую функцию, чтобы просматривать подробности. Сверху вниз идёт вложенная иерархия вызовов, а длина линии — это относительное время, потраченное кодом на их исполнение
Кроме того, Snakeviz демонстрирует интерактивную таблицу со статистикой времени исполнения функций
Мы видим, что больше всего времени исполнения тратится внутри функции findall
, то есть на выполнение поиска регулярными выражениями. Значит, если мы хотим ускорить код, то нужно сосредоточиться на ускорении этой функции, так как узким местом является она, а не другие части кода.
Давайте проверим результаты во втором инструменте.
Gprof2dot [6] предоставляет более читаемую визуализацию в виде блок-схемы, выполняет сохранение в файлы изображений, поэтому в нём легко делиться результатами (а по необходимости и автоматизировать это). Однако он не интерактивен и требует установки в систему Graphviz [7].
Для установки gprof2dot достаточно выполнить команду pip install gprof2dot
.
Для генерации выходного изображения с результатом профилирования нужно использовать следующую команду:
python -m gprof2dot -f pstats <profiling-results-file> | dot -Tpng -o output.png
Сначала мы представляем иерархию вызовов функций в виде графа в формате dot
, а затем генерируем изображение — визуализацию этого графа. Команда dot
поддерживает различные форматы вывода, в том числе .jpg
и .svg
, а результаты gprof2dot
также можно гибко настраивать.
Давайте посмотрим, как они выглядят.
Представление иерархии исполнения функций нашего кода в виде графа. В процентах указана общая доля времени исполнения, проведённая внутри функции, а также (%), но сюда включён только собственный код функции. Последнее значение — это количество вызовов функции в коде
Теперь картина выглядит гораздо более читаемой, граф показывает нам, что поиск регулярными выражениями занимает 88% от общего времени исполнения, поэтому нам нужно сделать его быстрее.
Если у вас нет доступа к Graphviz (команда dot
), то можно использовать соответствующий пакет Python для Graphviz [8] (pip install graphviz
), а также простой скрипт на Python для генерации результатов.
gprof2dot
в файл .dot
:
python -m gprof2dot -f pstats file.prof > file.dot
.dot
при помощи следующего кода:import graphviz
def make_png(input_file_name, output_file_name):
dot = graphviz.Source.from_file(input_file_name)
dot.render(outfile=output_file_name)
if __name__ == '__main__':
make_png('file.dot', 'file.svg') # также поддерживает .png, .jpg и так далее.
Давайте ускорим код, скомбинировав два регулярных выражения в одно:
def single_pattern():
num_total_matches = 0
pattern = r"[0-9][rb]ob" # Теперь у нас только одна операция поиска вместо двух
with open('random.txt', 'rt') as f:
for line in tqdm(f, total=1_000_000):
line = line.lower()
# Находим все вхождения паттерна в строке
matches = re.findall(pattern, line)
# Считаем количество совпадений
num_matches = len(matches)
num_total_matches += num_matches
return num_total_matches
Запустим и получим 15 с — скорость повысилась примерно в два раза!
Результаты профилирования нового кода. Обратите внимание, что % занимаемого findall времени снизился (88% -> 81.5%)
Профилирование нового кода подтвердило, что мы сделали правильный выбор — доля времени, занимаемого основной функцией findall
снизилась, а для всех других функций увеличилась (тоже приблизительно в два раза).
Если мы реализуем все три остальные идеи, а не сосредоточимся на узком месте, то время исполнения всё равно останется равным 32 с, как и в изначальном коде! Мы бы потратили столько времени и усилий, не добившись никаких улучшений! Именно поэтому важно сосредоточиться на узких местах.
def wasted_efforts():
num_total_matches = 0
# Используем скомпилированные регулярные выражения
pattern1 = re.compile(r"[0-9]{1}rob", flags=re.IGNORECASE)
# Также используем нечувствительность к регистру вместо изменения строк
pattern2 = re.compile(r"[0-9]{1}bob", flags=re.IGNORECASE)
with open('random.txt', 'rt') as f:
# Загружаем строки в блок и обрабатываем их за один проход
chunk = []
for line in tqdm(f, total=1_000_000):
chunk.append(line)
if len(chunk) == 1000:
chunk_str = ''.join(chunk)
for pattern in [pattern1, pattern2]:
# Находим все вхождения паттерна в строке
matches = re.findall(pattern, chunk_str)
# Считаем количество совпадений
num_matches = len(matches)
num_total_matches += num_matches
chunk = []
# Но несмотря на наши усилия, код всё равно такой же медленный, как и оригинальный
return num_total_matches
Функция findall
по-прежнему остаётся узким местом. Однако в нашем придуманном примере для её дальнейшего улучшения можно сделать только одну простую вещь: распараллелить код.
Совпадения в разных строках независимы друг от друга, поэтому мы можем выполнять поиск в них параллельно. Как нам это сделать?
Проще всего создать Process Pool [9] — объект, управляющий множеством параллельных процессов Python, работающих одновременно. Если у нас есть список значений и функция, применяемая к каждому из них, то мы просто можем вызывать метод пула map
, и он будет исполнять функцию параллельно для всех значений.
from multiprocessing import Pool
def calc_num_matches(string):
# Создаём отдельную функцию для объекта Pool
# Она будет параллельно применяться к каждой строки в файле
pattern = r"[0-9]{1}[rb]ob"
return len(re.findall(pattern, string))
def chunks_single_pool():
num_total_matches = 0
pool = Pool(8) # количество процессов должно быть <= количества ядер вашего CPU
with open('random.txt', 'rt') as f:
chunk = []
# Загружаем строки в список
for line in tqdm(f, total=1_000_000):
line = line.lower()
chunk.append(line)
if len(chunk) == 1000:
# Затем мы параллельно применяем к каждой строке функцию `calc_num_matches`
num_ind_matches = pool.map(calc_num_matches, chunk)
# И складываем все независимые совпадения
num_matches = sum(num_ind_matches)
num_total_matches += num_matches
chunk = []
return num_total_matches
Посмотрим… 6,1 с! Это новый рекорд. В 2,2 раза быстрее, чем оптимизированное решение, и примерно в 5 раз быстрее, чем изначальный код! Давайте посмотрим на результаты профилирования:
Результаты профилирования многопроцессорного кода. Обратите внимание, что доля regex-поиска продолжает уменьшаться (зелёные блоки)
Snakeviz демонстрирует при многопроцессорной обработке ту же картину
Оба результата подтверждают, что regex-поиск теперь занимает примерно 2/3 от общего времени — гораздо меньше, чем в исходной версии.
Важная особенность заключается в том, что профилировщик больше не знает, что происходит в подпроцессах, выполняющих regex-поиск. Он просто показывает, что на их выполнение потрачено 4 секунды времени, но он не имеет доступа к исполняемому в подпроцессах коду, потому что они, по сути, являются отдельными программами, слабо связанными с нашим основным процессом Python.
Если вы хотите профилировать то, что происходит в подпроцессе вашей программы, то необходимо поместить внутрь исполняемой подпроцессом функции/кода декоратор @profile
.
Обычно нам не нужно писать многопоточный код непосредственно на Python, потому что его применение ограничено Global Interpreter Lock (GIL) [10], который за раз позволяет выполнять только один поток кода на Python (в одном процессе Python).
Однако если вы работаете со многими библиотеками не для Python (например, NumPy, PyTorch, scipy) или с активным вводом-выводом (веб-коммуникации), то основная часть времени будет тратиться за пределами интерпретатора Python на исполнение кода, написанного на C, C++, Fortran и так далее.
В этих двух случаях использование нескольких потоков может быть практичным, поэтому нужно иметь в виду, что оба встроенных модуля профилирования Python — profile и cProfile [11] — профилируют только основной поток приложения. Если вы хотите профилировать код, исполняемые в других потоках, то можно попробовать запустить профилировщик в функции, которую будут исполнять потоки, или воспользоваться сторонними инструментами профилирования наподобие Yappi [12] или VizTracer [13].
Если вы используете для вычислений GPU, то учтите, что это отдельное устройство в системе, работающее асинхронно с CPU. Поэтому при исполнении кода в GPU нужно быть очень внимательным с измерением времени исполнения, ведь ваш код на Python не знает, что происходит в GPU. Он может только приказать сделать что-то и ждать, пока GPU завершит выполнение этой задачи.
Давайте рассмотрим следующий код:
import torch
from profilehooks import profile
@profile(filename='gpu_test.prof', stdout=False)
def compute_big_sum(tensor: torch.Tensor):
# выполняем много затратных вычислений, результатом которых становится одно число
a = tensor.sum()
b = tensor.pow(2).sum()
c = tensor.sqrt().sum()
sum_all = a + b + c
sum_all_cpu = sum_all.cpu() # передаём значение в память CPU
# возвращаем сумму всех операций (скалярную)
return sum_all_cpu
if __name__ == '__main__':
# создаём большую матрицу случайных чисел, уже находящуюся в памяти GPU
X = torch.rand((10000, 10000), dtype=torch.float64, device='cuda:0')
res = compute_big_sum(X)
print(float(res))
Мы создаём большую матрицу 10000x10000 на PyTorch (в GPU), а затем профилируем функцию, вычисляющую следующее:
На выходе эта функция возвращает одно число.
Как вы думаете, какая строка кода займёт больше всего времени?
Результаты профилирования функции compute_big_sum
Как ни удивительно, это метод .cpu()
, передающий данные из памяти GPU в память CPU! Но это не имеет никакого смысла. Мы передаём всего 8 байтов данных! Неужели передача в памяти настолько медленная?
Нет.
Так как внутри PyTorch вычисляются CUDA [14] в GPU, который является отдельным от CPU устройством, то при использовании функции наподобие .sum()
или torch.pow()
. Python просто приказывает GPU начать вычисления, но не ждёт, пока они закончатся! То есть интерпретатор Python запускает вычисления и сразу же переходит к следующей строке кода.
Ожидание происходит в методе .cpu()
, передающем данные из памяти GPU в память CPU. Он вынужден ждать, пока результаты всех предыдущих вычислений окажутся доступными в памяти GPU, то есть завершения исполнения всех функций, которые мы применили к нашей большой матрице.
Но как нам это профилировать?
torch.cuda.synchronize()
, что заставит PyTorch ждать, пока завершатся все операции в GPU.Давайте посмотрим, как результаты профилирования будут выглядеть с этой строкой:
Результаты профилирования при использовании torch.cuda.synchronize()
. Теперь .cpu()
занимает меньше 1% общего времени, а почти всё время исполнения тратится на ожидание завершения вычислений в GPU, как и ожидалось
Код нашей изменённой функции compute_big_sum
будет выглядеть так:
def compute_big_sum(tensor: torch.Tensor):
a = tensor.sum()
b = tensor.pow(2).sum()
c = tensor.sqrt().sum()
sum_all = a + b + c
torch.cuda.synchronize() # добавляем это после всех операций с GPU
# чтобы обеспечить корректность измерений времени
sum_all_cpu = sum_all.cpu()
return sum_all_cpu
Автор:
ru_vds
Источник [18]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/graphviz/386883
Ссылки в тексте:
[1] скомпилированных объектов регулярных выражений: https://docs.python.org/3/library/re.html#re-objects
[2] флага нечувствительности к регистру: https://docs.python.org/3/library/re.html#re.I
[3] два встроенных модуля профилирования: https://docs.python.org/3/library/profile.html
[4] простой декоратор: https://pypi.org/project/profilehooks/
[5] Snakeviz: https://github.com/jiffyclub/snakeviz
[6] Gprof2dot: https://github.com/jrfonseca/gprof2dot
[7] Graphviz: https://graphviz.org/download/
[8] соответствующий пакет Python для Graphviz: https://pypi.org/project/graphviz/
[9] Process Pool: https://docs.python.org/3/library/multiprocessing.html#multiprocessing.pool.Pool
[10] Global Interpreter Lock (GIL): https://realpython.com/python-gil/
[11] profile и cProfile: https://docs.python.org/3/library/profile.html#module-cProfile
[12] Yappi: https://github.com/sumerc/yappi
[13] VizTracer: https://github.com/gaogaotiantian/viztracer
[14] CUDA: https://www.turing.com/kb/understanding-nvidia-cuda
[15] встроенный профилировщик PyTorch: https://pytorch.org/tutorials/recipes/recipes/profiler_recipe.html
[16] Справка по регулярным выражениям Python: https://docs.python.org/3/library/re.html
[17] Документация по multiprocessing в Python: https://docs.python.org/3/library/multiprocessing.html
[18] Источник: https://habr.com/ru/companies/ruvds/articles/757336/?utm_source=habrahabr&utm_medium=rss&utm_campaign=757336
Нажмите здесь для печати.