![]() | ||||||||||||||||||||||||||||||
![]() |
![]() |
|
|
|||||||||||||||||||||||||||
![]() |
|
Параллельное программирование с помощью вычислительного графаИсточник: habrahabr krogozh
Есть приложения, которые хорошо реализуются как системы передачи сообщений. Сообщениями в широком смысле может быть что угодно - блоки данных, управляющие "сигналы" и т.д. Логика же состоит из узлов, обрабатывающих сообщения, и связей между ними. Такая структура естественно представляется графом, по рёбрам которого "текут" сообщения, обрабатываемые в узлах. Наиболее устоявшееся название такой модели - вычислительный граф. С помощью вычислительного графа можно установить зависимости между задачами и в какой-то мере программно реализовать "dataflow архитектуру". В этом посте я опишу, как реализовать такую модель на С++, используя библиотеку Intel® Threading Building Blocks (Intel® TBB), а именно класс tbb::flow::graph. Что такое Intel TBB и класс tbb::flow::graphIntel® Threading Building Blocks - библиотека шаблонов С++ для параллельного программирования. Распространяется она бесплатно в реализации с открытым исходным кодом, но есть и коммерческая версия. В бинарном виде выпускается для Windows*, Linux* и OS X*. В TBB есть множество готовых алгоритмов, конструкций и структур данных, "заточенных" для использования в параллельных вычислениях. В том числе, есть и конструкции, позволяющие реализовать вычислительный граф, о котором и пойдёт речь. Граф, как известно, состоит из вершин (узлов) и рёбер. Вычислительный граф tbb::flow::graph также состоит из узлов (node), рёбер (edge) и объекта всего графа.
Узлы графа имеют интерфейсы отправителя и получателя, управляют сообщениями или выполняют какие-то функции. Рёбра соединяют узлы графа и являются "каналами" передачи сообщений. Тело каждого узла представлено задачей TBB и может исполняться параллельно с другими, если между ними нет зависимостей. В TBB многие параллельные алгоритмы (или все) строятся на задачах - небольших элементах работы (инструкций), которые исполняются рабочими потоками. Между задачами могут быть зависимости, они могут динамически перераспределяться между потоками. Благодаря использованию задач можно достигнуть оптимальной гранулярности и баланса нагрузки на CPU, а также строить более высокоуровневые параллельные конструкции на их основе - такие как tbb::flow::graph.
Самый простой граф зависимостейГраф, состоящий из двух вершин, соединённых одним ребром, одна из которых печатает "Hello", а вторая "World", схематично можно изобразить так:
А в коде это будет выглядеть так:
Здесь создаётся объект графа g и два узла типа continue_node - h и w. Эти узлы принимают и передают сообщение типа continue_msg - внутренне управляющее сообщение. Они используются для построения графов зависимостей, когда тело узла исполняется лишь после того, как получено сообщение от предшественника. Каждый из continue_node исполняет некоторый условно полезный код - печать "Hello" и "World". Узлы объединяются ребром с помощью метода make_edge. Всё, структура вычислительного графа готова - можно запускать его на исполнение, подавая ему на вход сообщение методом try_put. Далее граф отрабатывает, и, чтобы убедиться, что все его задачи выполнены, ждём с помощью метода wait_for_all.
Простой граф передачи сообщенийПредставьте, что наша программа должна посчитать выражение x2+x3 для x от 1 до 10. Да, это не самая сложная вычислительная задача, но вполне сгодиться для демонстрации. Попробуем представить подсчёт выражения в виде графа. Первый узел будет принимать значения x из входящего потока данных и отсылать его узлам, возводящим в куб и в квадрат. Операции возведения в степень не зависят друг от друга и могут исполняться параллельно. Для сглаживания возможных дисбалансов они передают свой результат в буферные узлы. Далее идёт объединяющий узел, поставляющий результаты возведения в степень суммирующему узлу, на чём вычисление и заканчивается:
Код такого графа:
Функция Sleep(1000) добавлена для визуализации процесса (пример компилировался на Windows, используйте эквивалентные вызовы на других платформах). Далее всё как в первом примере - создаём узлы, объединяем их рёбрами и запускаем на исполнение. Второй параметр в function_node (unlimited или serial) определяет, сколько экземпляров тела узла может исполняться параллельно. Узел типа join_node определяет готовность входных данных/сообщений на каждой входе, и когда оба готовы - передаёт их следующему узлу в виде std::tuple.
Решение проблемы "обедающих философов" с помощью tbb::flow::graphИз википедии: В задаче несколько философов сидят за столом, и могут либо есть, либо думать, но не одновременно. В нашем варианте философы едят лапшу палочками - чтобы есть нужно две палочки, но в наличии на каждого по одной:
В такой ситуации может случиться deadlock (взаимная блокировка), если, например, каждый философ захватит левую от себя палочку, поэтому требуется синхронизация действий между обедающими. Попробуем представить стол с философами в виде tbb::flow::graph. Каждый философ будет представлен двумя узлами: join_node для захвата палочек и function_node для осуществления задач "есть" и "думать". Место для палочки на столе реализуем через queue_node. В очереди queue_node может быть не больше одной палочки, и если она там есть - она доступна для захвата. Граф будет выглядеть так:
Функция main с некоторыми константами и заголовочными файлами:
После обработки параметров командной строки библиотека инициализируется созданием объекта типа tbb::task_scheduler_init. Это позволяет управлять моментом инициализации и вручную задавать количество потоков-обработчиков. Без этого инициализация пройдёт автоматически. Далее создаётся объект графа g. "Места для палочек" queue_node помещаются в std::vector, и в каждую очередь помещается по палочке. Дальше похожим способом создаются и философы - помещаются в std::vector. Объект каждого философа передаётся функции run объекта графа. Класс philosopher будет содержать operator(), и функция run позволяет исполнить этот функтор в задаче, дочерней к корневой задаче объекта графа g. Так мы сможем дождаться исполнения этих задач во время вызова g.wait_for_all(). Класс philosopher:
У каждого философа есть имя, указатели на объект графа и на левую и правую палочки, узел join_node, функциональный узел function_node и счётчик my_count, отсчитывающий, сколько раз философ думал и ел. operator()(), вызываемый функцией run графа, реализован так, чтобы философ сначала думал, а потом присоединял себя к графу.
Метод make_my_node создаёт функциональный узел, и связывает и его, и join_node с остальным графом:
Обратите внимание, что граф создаётся динамически - ребро формируется методом register_successor. Не обязательно сначала полностью создавать структуру графа, а потом запускать его на исполнение. В TBB есть возможность менять эту структуру на лету, даже когда граф уже запущен - удалять и добавлять новые узлы. Это добавляет ещё больше гибкости концепции вычислительного графа. Класс node_body - простой функтор, вызывающий метод philosopher::eat_and_think():
Метод eat_and_think вызывает функцию eat() и декрементирует счётчик. Дальше философ кладёт свои палочки на стол и думает. А если он поел и подумал положенное число раз, он встаёт из-за стола - разрывает связи своего join_node с графом методом remove_successor. Здесь опять видна динамическая структура графа - часть узлов удаляется, пока остальные продолжают работать.
В нашем графе есть ребро от queue_node (места для палочки) к философу, точнее его join_node. А в обратную сторону нет. Тем не менее, метод eat_and_think может вызывать try_put для того, чтобы положить палочку обратно в очередь. В конце функции main() для каждого философа вызывается метод check, который удостоверяется, что философ поел и подумал правильное количество раз и делает необходимую "очистку":
Deadlock в этом примере не случается благодаря использованию join_node. Этот тип узлов создаёт std::tuple из полученных с обоих входов объектов. При этом входные данные не потребляются сразу при поступлении. join_node сначала дожидается, когда данные появятся на обоих входах, потом пытается их зарезервировать по очереди. Если эта операция успешна - только тогда они "потребляются" и из них создаётся std::tuple. Если резервирование хотя бы одного входного "канала" не получилось - те, что уже зарезервированы, отпускаются. Т.е. если философ может захватить одну палочку, но вторая занята - он отпустить первую и подождёт, не блокируя соседей понапрасну. Этот пример с обедающими философами демонстрирует несколько возможностей TBB графа:
Типы узловtbb::flow::graph предоставляет довольно широкий набор вариантов узлов. Их можно разделить на четыре группы: функциональные (functional), буферизующие, объединяющие и разделяющие, и прочие. Список типов узлов с условными обозначениями:
ЗаключениеС помощью графа, реализованного в Intel TBB, можно создать сложную и интересную логику параллельной программы, иногда называемую "неструктурированным параллелизмом". Вычислительный граф позволяет организовать зависимости между задачами, строить приложения, основанные на передаче сообщений и событий. Структура графа может быть как статической, так и динамической - узлы и рёбра могут добавляться и удаляться "на лету". Можно соединять отдельные подграфы в большой граф. Ссылки по теме
|
|