Стратегия реализации алгоритма обхода дерева параллельно?

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

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

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

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

Обрабатываемые деревья несбалансированы: типичный узел будет иметь 2 листа плюс 0-6 дополнительных дочерних узлов. Таким образом, простое разбиение дерева на набор относительно сбалансированных поддеревьев неочевидно (для меня). Кроме того, деревья предназначены для использования всей доступной оперативной памяти: чем больше дерево, которое я могу обработать, тем лучше.

Моя последовательная реализация достигает порядка 1000 итераций в секунду только на моих маленьких тестовых деревьях; с «настоящими» деревьями я ожидаю, что это может замедлиться на порядок (или больше?). Учитывая, что алгоритм требует не менее 100 миллионов итераций (возможно, до миллиарда) для достижения приемлемого результата, я хотел бы распараллелить алгоритм, чтобы использовать преимущества нескольких ядер. У меня нет опыта параллельного программирования.

Каков рекомендуемый шаблон для распараллеливания с учетом характера моего алгоритма?


person travis    schedule 09.02.2010    source источник
comment
Первым шагом является анализ вашего алгоритма, чтобы определить, какие части, если таковые имеются, не зависят друг от друга. Это, вероятно, потребует, чтобы вы рассмотрели свой алгоритм на более низком уровне, чем здесь.   -  person Nate W.    schedule 09.02.2010
comment
Какие именно деревья вы обрабатываете и какие операции вам необходимо поддерживать? Прежде чем вы даже подумаете о параллельном обходе дерева, спросите себя, можете ли вы переписать свои деревья, используя лучшую структуру данных, или вместо этого улучшить операцию O(n) до O(log n).   -  person Juliet    schedule 09.02.2010
comment
Существуют ли какие-либо общие данные между узлами дерева или они логически разделены? Я могу предложить несколько приличных предложений, но более подробная информация облегчит задачу.   -  person Phil Miller    schedule 09.02.2010
comment
@Shakedown, Novelocrat: я добавил больше деталей, которые должны помочь. Дайте мне знать, что еще вам нужно; Я изо всех сил пытаюсь предоставить достаточно деталей, не перегружая деталями! @Juliet: Это современный алгоритм для решения сложных игр. Я не думаю, что серийную реализацию можно улучшить (по крайней мере, не мной).   -  person travis    schedule 09.02.2010
comment
Кроме того, структура дерева вообще меняется по мере выполнения ваших вычислений или она статична? Является ли объем работы, выполняемой на каждом узле, одинаковым или варьируется предсказуемым образом?   -  person Phil Miller    schedule 09.02.2010
comment
Прежде чем вы начнете работу по распараллеливанию, я бы также посоветовал профилировать ваш последовательный код и посмотреть, нет ли каких-либо горячих точек, которые выпрыгивают, из очевидных узких мест. Это гораздо проще сделать до того, как вы добавите мусор, необходимый для параллельного выполнения.   -  person Phil Miller    schedule 09.02.2010
comment
@Novelocrat: Спасибо за предложение; У меня уже есть последовательный алгоритм, профилированный и разумно оптимизированный (можно значительно ускорить, перейдя с Python на скомпилированный язык, но это отдельная проблема). Обновлен вопрос для решения статического/динамического и рабочего баланса в узлах. С нетерпением ждем ваших предложений.   -  person travis    schedule 09.02.2010
comment
Ответ опубликован. Я бы по-прежнему рекомендовал портировать на последовательный язык с оптимизирующей реализацией, прежде чем переходить к параллелизму. Соотношение усилий и отдачи гораздо лучше.   -  person Phil Miller    schedule 09.02.2010


Ответы (3)


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

Если каждая функция прозрачна по ссылкам --- она ​​зависит только от входных данных ( и без скрытого состояния) для вычисления своего вывода, и каждый вызов функции с одним и тем же вводом всегда дает один и тот же результат --- тогда вы в хорошей позиции для распараллеливания алгоритма: поскольку ваш код никогда не изменяет глобальные переменные (или файлы, серверы и т. д.) работа, которую выполняет функция, может быть безопасно повторена (для пересчета результата функции) или полностью проигнорирована (ни один будущий код не зависит от побочных эффектов этой функции, поэтому полный пропуск вызова ничего не сломает). Затем, когда вы запускаете свой набор функций (например, в какой-либо реализации MapReduce, hadoop и т. д.) цепочка функций вызовет волшебный каскад зависимостей, основанный исключительно на выводе одной функции и ввод другой функции, и то, ЧТО вы пытаетесь вычислить (через чистые функции), будет полностью отделено от ПОРЯДКА, в котором вы пытаетесь это вычислить (вопрос, на который отвечает реализация планировщика для такой структуры, как MapReduce).

Отличным местом для изучения этого способа мышления является написание вашего алгоритма на языке программирования Haskell (или что-то вроде F# или Ocaml), который отлично поддерживает параллельное/многоядерное программирование. Haskell заставляет ваш код быть чистым, поэтому, если ваш алгоритм работает, его, вероятно, легко распараллелить.

person Jared Updike    schedule 09.02.2010
comment
Несколько интересных идей здесь. Haskell выглядит интригующе. Если я потрачу время на его изучение, чтобы портировать туда алгоритм, мне также придется изучать MapReduce/hadoop, или это непересекающиеся подходы? - person travis; 09.02.2010
comment
Перекодирование в Haskell будет осуществляться отдельно от перекодирования в среде MapReduce/Hadoop. Выбор действительно зависит от масштаба дерева, с которым вы пытаетесь работать. - person Phil Miller; 09.02.2010
comment
Haskell немного сложен для изучения (в хорошем смысле). Одна из лучших книг была недавно опубликована O'Reilly и доступна бесплатно в Интернете: book.realworldhaskell.org/ read Глава 24 подробно описывает подходы к параллельному программированию с конкретными примерами использования MapReduce: book.realworldhaskell.org/read/ - person Jared Updike; 10.02.2010

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

person Chris Dodd    schedule 09.02.2010
comment
Обычный метод требует, чтобы каждый программист, требующий параллельного ускорения, работал непосредственно с двумя самыми сложными в использовании конструкциями в каталоге: потоками и общими структурами данных. Это также потребует чрезмерных усилий, как только программист захочет расширить параллелизм за пределы одной машины с общей памятью. - person Phil Miller; 09.02.2010

В этом ответе описывается, как я буду делать это с параллельным языком и системой выполнения, с которыми я работаю изо дня в день, Очарование++. Обратите внимание, что для последовательного кода в этой структуре используется язык C или C++, поэтому вам придется приложить некоторые усилия для переноса вычислительного кода. Charm++ имеет некоторые механизмы для взаимодействия с кодом Python, хотя я менее знаком с этими аспектами. Возможно, вы могли бы оставить код драйвера и интерфейса на Python и просто поместить тяжелый вычислительный код на C++. Несмотря на это, усилия по переносу последовательного кода, скорее всего, принесут вам хороший следующий прирост производительности.

Дизайн:

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

Каждому параллельному объекту потребуются два асинхронных удаленно вызываемых метода, известных как методы входа, passDown(float a, float b) и passUp(int nodeID, float f), которые будут точками связи между ними. passDown будет вызывать любой метод узла, используемый для выполнения вычислений в предварительном порядке, а узлы, у которых есть внеобъектные дочерние элементы, будут вызывать passDown для этих объектов-потомков.

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

Результаты выполнения:

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

Настройка:

Если вы пойдете этим путем и дойдете до настройки параллельной производительности, вы также можете установить приоритеты для сообщений, чтобы длина критического пути была короткой. Навскидку, расстановка приоритетов, которую я бы предложил, будет работать в этом порядке.

  1. Downward work that's non-zero
    • Closer to the root goes sooner
  2. Upward work
    • Closer to leaves goes sooner

Charm++ работает с инструментом анализа производительности, известным как Projections, чтобы лучше понять, как работает ваша программа.

person Phil Miller    schedule 09.02.2010