текст диплома

advertisement
Московский Государственный Университет им. М. В. Ломоносова
Механико-математический факультет
Кафедра вычислительной механики
5 курс, 524 группа
Адаптация комплекса программ
M2DGD для работы на МВС с
использованием среды параллельного
программирования OST
Павлухин Павел
Научный руководитель: д. ф-м. н. Меньшов И. С.
17.05.2010
Введение
В течение последних лет в области высокопроизводительных вычислений
сложилась следующая картина: интенсивное нарищивание вычислительной
мощности, приходящейся на одно ядро в CPU, приостановилось, т.к.
производство процессоров «уперлось» в частотные ограничения на отметке 3 —
4 Ггц. Даже переход на более совершенный техпроцесс (с 0.65мкм на 0.32мкм)
не дает значительного увеличения частоты, которая последние пять лет остается
практически на одном и том же уровне. Отчасти наращивание вычислительных
мощностей происходит за счет совершенствования архитектуры самих CPU, но
после появления семейства CoreDuo, когда его представитель работал на 50 —
100% быстрее своего предшественника Pentium 4(D) с той же частотой, таких
значительхых рывков по производительности больше не было. Увеличение
вычислительных мощностей как в сфере ПК, так и в сфере МВС стало
происходить в основном за счет увеличения количества ядер в одном CPU и
увеличения числа самих CPU в системе. За последние 5 лет количество ядер в
однопроцессорной системе увеличилось до 6 на текущий момент, причем в ОС
они могут представляться 12 ядрами(используя технологию Hiper-Threading,
которая каждое ядро процессора представляет двумя «виртуальными»,
благодаря этому достигается прирост до 25% в некоторых приложениях,
использующих многопоточность). Для современных МВС количество ядер
измеряется уже даже не тысячами, а десятками тысяч.
С другой стороны, объем вычислений в современных прикладных задачах
в масштабах предприятия таков, что на одном ядре эти задачи считались бы
месяцами и годами. К тому же объем данных в них нередко в несколько раз
превышает объем оперативной памяти, доступной на одном узле в МВС.
Поэтому особую важность в параллельном программировании на
сегодняшний день имеют два направления: адаптация для счета с
использованием нескольких CPU уже существующих расчетных задач, которые
выполняются в рамках одного процесса на одном CPU, и создание алгоритмов,
учитывающих изначальный параллелизм физических задач, когда значение
величин в каждой точке пространства определяется лишь ближайшим его
окружением-”соседями” и переносящих этот параллелизм на программноаппаратный уровень. Данная работа будет посвящена первому направлению, а
именно параллельной реализации задачи M2DGD в среде OST, так же будет
сделано сравнение разработанного алгоритма в вышеназванной программной
среде с реализацией на C/C++/MPI [1, 2].
Наряду с созданием алгоритмов для параллельных вычислений остается
важным также и вопрос об их программно-аппаратной реализации и
используемых при этом сред и инструментов. На сегодняшний день самой
распространенной технологией, используемой для программ, одновременно
выполняющихся на нескольких процессорах, является MPI (Message Passing
Interface), с помощью которой организуется обмен сообщениями между
параллельно выполняющимися процессами. Основной ее недостаток состоит в
том, что непосредственно организация удаленных вызовов — часть,
относящаяся к системному программированию, - целиком ложится на плечи
прикладного программиста, причем от задачи к задаче ему необходимо каждый
раз ее реализовывать. Назначение связей между процессами, синхронизация
времени их выполнения — все это должен организовывать сам прикладной
программист. Основная идея OST (Object, Space, Time), предлагаемая как
альтернатива MPI, - «вынести за скобки» системную часть и предоставить
прикладному программисту удобные средства для назначения связей между
частями параллельно выполняющихся программ, синхронизации вычислений и
межпроцессных обменов в рамках модели объектно-ориентированного
программирования, сведя при этом программирование распределенных частей
задачи к локальному с обращением к объектам- ”соседям”.
1. Описание последовательного алгоритма M2DGD
M2DGD(Menshov 2 Dimensional Gas Dynamics) – комплекс программ для
решения нестационарных двумерных задач газовой динамики в рамках модели
уравнений Навье-Стокса, основанный на модификации метода LU-SGS
решения систем алгебраических уравнений [2]. Пространственная
дискретизация исходных дифференциальных уравнений выполняется методом
конечного объема. Используются оригинальные интерполяционные схемы
восполнения сеточных функций на неструктурированных произвольных (в том
числе некомформных) сетках для повышения порядка пространственной
аппроксимации. Численные потоки определяются методом Годунова по точному
решению нелинейной задачи Римана. Для интегрирования уравнений по
времени используется оригинальная гибридная явно-неявная схема,
обладающая свойством абсолютной устойчивости при условии минимального
вклада диссипативной неявной компоненты и вторым порядком аппроксимации
при переходе на явную компоненту [1]. Эта схема позволяет эффективно решать
задачи на сетках с сильной пространственной неоднородностью (с большим
относительным удлинением в одном из направлений), которые неизбежно
возникают при решении задач в рамках уравнений Навье-Стокса. Основные
характеристики алгоритма:
• применим как к структурированным, так и неструктурированным
сеткам,
• второй порядок точности по времени и пространству,
• высокая надежность: абсолютно устойчив по отношению к выбору шага
по времени.
Дискретная модель, в конечном счете, неявная и сводится к решению
большой разреженной СЛАУ
Aq  R
которая решается методом LU-SGS (Lower-Upper Symmetric Gauss-Seidel)
- приближенной факторизацией матрицы

1
A

L

D

U

(
L

D
)

D

(
U

D
)
и решению двух систем с нижней и верхней треугольной матрицей,
которые решаются в 2 прохода:

(LD
)
q
R

(
UD
 )
qD

q
(forward)
(backward)
где forward и backward – названия обходов области с сеточным
разбиением для соответствующей СЛАУ.
Этот метод приводит к неявной схеме, но при этом с нижней и верхней
треугольной матрицами, это будет существенно для предлагаемой ниже
параллельной версии этого метода.
Расчетная область задачи делится на подобласти, на каждой из которой
строится сеточное разбиение, при этом каждая такая подобласть с разбиением
топологически эквивалентна четырехугольнику, для каждой ее стороны
задается граничное условие, определяемое индексом — типом граничного
условия и вектором граничных условий с четырьмя компонентами: давление, 2
компоненты скорости и плотность (в случае необходимости):
3 – для граничных условий типа стенка (нулевая скорость и нулевая
производная от давления по нормальному к границе направлению;
4 – для граничных условий, задающих все параметры (плотность, 2
компоненты вектора скорости и давление) на границе;
5 – для граничного условия экстраполяция нулевого порядка (простой
снос значений из прилегающей ячейки на границу;
6 – для граничного условия «ось симметрии»
7 – для характеристического граничного условия (т.н. неотражающее
условие на основе римановских инвариантов для выходных границ с
дозвуковым потоком).
Под ячейкой в дальнейшем изложении подразумевается элемент
сеточного
разбиения
(четырехугольник)
с
приписанными
ему
соответствующими вершинами и осредненным значением вектора-решения на
этом элементе.
После полного определения расчетной области для численного метода
она представляет из себя 2 матрицы: одна с координатами узлов сеточного
разбиения, другая — с осредненными значениями вектора решения на
соответствующих элементах; к каждой физической границе приписывается тип
граничного условия и вектор граничных условий (рис. 1).
Базовый вектор решения q состоит из 4-х компонент и включает либо
примитивные переменные (плотность , х-компонента вектора скорости Ux, yкомпонента вектора скорости Uy, давление P), либо консервативные
переменные (, Ux, Uy, E), где Е – полная удельная энергия. Полный вектор
решения f состоит из 33-х компонент и включает кроме базовых
дополнительные переменные, необходимые для проведения расчета.
Весь комплекс программ написан на языках C и Fortran. Общая схема
работы вычислительного ядра представляется в виде:
do is=1,NSTEP
do it=1,MAXITER
call omega(dt)
call slope(1)
call predicval(2)
call forward(dt,dtpseudo)
call backward(dt,dtpseudo)
call updateiter
end do
call updatetime
lstep=lstep+1
time=time+dt
end do
NSTEP задает число шагов по времени, которые необходимо выполнить,
MAXITER определяет число итераций внутри одного шага по времени, dt – шаг
по времени, dtpseudo – шаг по псевдовремени. При вызове каждой процедуры
(omega,slope,predicval,forward,backward,updateiter,updatetime)
осуществляется
полный обход по всем ячейкам расчетной области. Для получения данных о
ячейке используется функция getnewcell, написанная на C, которая через
параметры-ссылки передает информацию о геометрии ячейки и вектор решения
f, приписанный этой ячейке. Для записи нового значения f используется Cфункция setcurval, которая обновляет этот вектор для текущей ячейки. Часть
процедур использует данные из ячеек-соседей, определяемых по обычной
дискретной двумерной топологии (т.е. для текущей ячейки в общем случае
имеется 4 соседних: ”снизу”, ”справа”, ”сверху”, ”слева”), для получения
данных из таких ячеек используется C-функция getnewneib, которая вызывается
каждый раз для получения очередного соседа до тех пор, пока после своего
очередного вызова она не передаст через параметр флаг R=-1, означающий, что
обход всех соседей для текущей ячейки закончен. Для записи вектора f в
текущую ячейку-сосед используется C-функция setneighval. Для процедур
forward и backward, в которых происходит решение указанных выше СЛАУ,
устанавливается неизменяемый во время счета линейный порядок обхода всех
ячеек области: все ячейки нумеруются числами от 1 до N (N – число всех ячеек
в расчетной области), порядок при этом выбирается произвольным образом, что
также будет существенно при построении параллельного алгоритма; обход
ячеек в forward осуществляется по возрастанию номеров ячеек, в backward –
строго по убыванию, т.е. в обратном порядке. В начале каждой процедуры
вызывается rescell – она устанавливает текущей ячейку с первым номером.
Omega – подготовительная процедура, при обходе по ячейкам не
использует и не обновляет данные в ячейках-соседях; простой обход ячеек от 1
до N.
Slope, predicval – также подготовительные процедуры, но уже используют
данные ячеек-соседей, при этом не важно, была обсчитана ячейка-сосед в этой
процедуре на текущей итерации или еще не обсчитана, при этом данные в
ячейки-соседи не пишутся (ячейка считается обчитанной для данной
процедуры в текущей итерации, если данные о ней уже передавались для
обсчета посредством вызова getnewcell из данной процедуры, иначе ячейка не
обсчитана).
Forward – основная процедура; при обходе по соседям: если сосед был
обсчитан, то выполняется один блок вычислений, если еще не обсчитан –
другой блок, после проделанных вычислений обновляются вектор текущей
ячейки и, если сосед еще не был обсчитан, вектор ячейки-соседа.
Backward – основная процедура; при обходе по соседям: если сосед был
обсчитан, то выполняется один блок вычислений, если еще не обсчитан –
другой блок, после проделанных вычислений обновляется только вектор
текущей ячейки.
Updateiter, updatetime – вспомогательные процедуры, при обходе по
ячейкам не используют и не обновляют данные в ячейках-соседях; простой
обход ячеек от 1 до N.
2. Сложности распараллеливания M2DGD
Рассмотрим простейший случай, когда область поделена на две части
(блок 1 и блок 2) и вычисления ведутся параллельно на двух процессорах, счет
на каждом из них ведется над своей частью области с обращением за соседними
ячейками в другую часть, когда это необходимо. Если использовать какой-либо
простой порядок обхода ячеек, одинаковый в двух блоках (например, ”слеванаправо”, ”сверху-вниз”), то при запуске одной и той же задачи несколько раз
могут, вообще говоря, получаться разные результаты. Действительно, при
запросе соседних ячеек из блока 2 граничными ячейками из блока 1
получаемые ячейки из первого блока могут быть как обсчитанными, так еще и
не обсчитанными, т.е. иметь разные значения вектора f (рис. 2а,б).
а)
б)
Рис. 2 а) получаемые ячейки из блока 2 еще не посчитаны, б) получаемые ячейки из
блока 2 уже посчитаны
Поэтому рассмотренный выше случай являет собой пример
некорректного алгоритма, параллельно работающая программа должна
выдавать всегда один и тот же постоянный результат, который получается таким
же, как и в случае работы последовательной программы. Ясно, что для
выполнения этого свойства все обходы в блоках параллельной программы
должны быть эквивалентны некоторому обходу в последовательном решении.
Это означает, что задавать линейную нумерацию ячеек нужно не в пределах
одного блока, а глобально для всех блоков, из которых состоит расчетная
область, тем самым определяя однозначно статус ячеек (обсчитана/не
обсчитана), получаемых из соседних блоков. Но при этом наряду с проблемой
корректности счета необходимо учитывать и эффективность счета получаемого
алгоритма. Рассмотрим работу параллельной программы, пронумеровав от 1 до
N1 ячейки в блоке 1 ”снизу-вверх” и ”слева-направо” и от N1+1 до N1+N2 в
блоке 2 тем же образом (N1 – число ячеек в блоке 1, N2 – число ячеек в блоке
2). При такой нумерации для граничных ячеек блока 2 всегда должны
доставляться уже обсчитанные ячейки из блока 1 (т.к. номера ячеек в блоке 1
меньше номеров ячеек в блоке 2 и при последовательном обходе этих двух
блоков ячейки в блоке 1 всегда обсчитываются первыми). Поэтому при
одновременном запуске процедуры forward в этих блоках в первом из них будет
идти счет, а во втором процесс будет ожидать завершения обсчета граничных
ячеек-соседей в первом, причем это время ожидания будет практически равно
времени работы forward в блоке 1; после того, как второй блок наконец получит
обсчитанные ячейки из блока 1, в нем (во втором блоке) будет идти счет, а в
первом блоке процесс будет ожидать окончания счета во втором, причем опять
это время будет почти равно времени счета forward во втором блоке (для
корректной работы следующей процедуры необходимо, чтобы счет текущей
процедуры также завершился и в соседних блоках). Из-за этих простоев общее
время счета на двух процессорах будет мало отличаться от времени
последовательного счета на одном процессоре (forward и backward, для которой
так же важен порядок обхода, составляют основную часть времени счета на
одной итерации).
Из сказанного выше вытекает, что для корректного счета задачи на
нескольких процессорах необходимо однозначно определить статус получаемых
(передаваемых) ячеек (обсчитаны/не обсчитаны), что эквивалентно заданию
глобальной последовательной нумерации ячеек, а для повышения
эффективности счета (уменьшения простоев) использовать более сложные
обходы в блоках. Описание такого алгоритма дается ниже.
3. Параллельная реализация M2DGD
Необходимо заметить, что внутренние части блоков (часть блока без
ячеек, расположенных на границе (по периметру) блока) напрямую не связаны
соотношениями ячеек ”обсчитаны/не обсчитаны”, поэтому обходы на таких
частях можно проводить параллельно, сохраняя при этом корректность счета,
основная проблема здесь – упорядочить вычисления в граничных ячейках
блоков с минимальными потерями в эффективности счета. Как видно из
примера выше, одинаковый обход во всех блоках практически не дает
выигрыша по времени счета задачи на нескольких процессорах (для
конфигурации из 2 блоков одинаковые обходы в них с эффективным счетом
построить можно, но в общем случае при разбиении области на NxM блоков с
одинаковыми обходами появляются простои). Организовать корректный и
эффективный счет на нескольких блоках можно, разделя все блоки
определенным образом на два класса – black и white (выбор таких названий
станет понятен чуть позже), задав в них разные обходы ячеек. Усложнение
порядка обхода связано только с двумя процедурами – forward и backward, для
которых является существенным обсчитанность/необсчитанность соседних
ячеек, для всех прочих процедур это не важно, поэтому для них можно
использовать одинаковый (несмотря на разделение на два класса) ”простой”
порядок обхода, не заботясь о том, были или не были обсчитаны в текущей
процедуре полученные из соседнего блока ячейки.
Итак, распределим на два класса параллельно обсчитывающиеся блоки
так, чтобы они образовали «шахматную доску», т.е. чтобы у каждого блока
black все соседи были из класса white, а у каждого блока white все соседи были
из black (соседство блоков определяется так же, как и соседство ячеек) (рис. 3).
Black
White
Рис 3. Распределение по классам блоков
Среда OST предполагает использование объектно-ориентированного
подхода для написания прикладных задач, поэтому естественным выглядит
представление счета M2DGD в виде объектов, распределенных по имеющимся
процессорам, каждый объект при этом содержит блок ячеек и методы,
отвечающие как непосредственно за счет в блоке, так и за обмен ячейками
между объектами-соседями (соседство объектов определяется по соседству
содержащихся в них блоков).
Для того, чтобы упорядочить и синхронизировать вычислительные
операции, введем логическое время (прикладного) объекта – числовой
параметр, разделяющий операции в объекте; все операции по обмену ячейками
в соседних объектах в этом случае возможны только при равенстве в них
логических времен. Чтобы увеличить свое логическое время, объект вызывает
предоставленный монитором OST (элемент среды, контролирующий и
обслуживающий прикладные объекты) метод setXYZT, тем самым отправляя
заявку в монитор на продвижение своего локального времени, счет в объекте
приостанавливается до того момента, пока монитор не подтвердит эту заявку.
Монитор ее подтверждает в том случае, если от всех объектов-соседей
получены аналогичные заявки или логическое время в соседях больше
текущего времени объекта-заявителя. Таким образом, данный алгоритм
синхронизации не позволяет счету в каком-либо объекте «убежать вперед, не
дожидаясь» своих объектов-соседей.
После разделения блоков (и, соответственно, объектов) на классы,
рассмотрим последовательность действий, выполняемых за одну итерацию в
паре соседних объектов, которые всегда будут представителями двух разных
классов (следует непосредственно из введенного распределения объектов по
классам).
Рис 4. Omega в black и white
После завершения инициализации в объектах устанавливается логическое
время time = 1, и вызывается omega, не требующая данных от ячеек-соседей,
поэтому никаких обменов между объектами не происходит; в объектах
используется одинаковый обход «слева-направо, снизу-вверх» (назовем его
simplesweep), после завершения omega в монитор из объектов направляются
заявки на продвижение логического времени. Исходная область делится на
блоки так, чтобы число ячеек в них было по возможности равным, либо
отличалось не значительно, и сами блоки были приближены к квадратным (за
счет чего уменьшается относительная часть пересылаемых ячеек). Поэтому
заявки в монитор от двух объектов поступят почти в одно и то же время и
простои из-за ожидания счета в соседнем объекте будут сведены к минимуму.
Рис 5. Slope в black и white
После подтверждения заявок монитором на time=2 из каждого объекта
отсылаются граничные ячейки соответствующим соседям и запускается slope с
обходом по внутренним ячейкам, исключая граничные (innersweep). Затем
выполняется проверка флага, сигнализирующего о том, что граничные ячейки
от соседей получены, иначе происходит приостановка в объекте и последующая
проверка флага через некоторый интервал времени. Так как процесс пересылки
ячеек требует значительно меньше ресурсов CPU и занимает меньшее время по
сравнению со счетом внутренних ячеек в блоке, то к моменту проверки флага,
как правило, нужные граничные ячейки будут получены и без простоев
продолжится счет omega с обходом по граничным ячейкам (ringsweep), при этом
будут использоваться данные полученных от объектов-соседей ячеек. В
завершении в монитор почти в одно и то же время поступают заявки на
продвижение времени из обоих объектов.
Работа predicval при time=3 полностью аналогична работе slope при
time=2, после ее завершения отправляются заявки на продвижение времени в
объектах.
Рис 6. Forward в black и white
При работе forward в объектах уже используются разные обходы;
необходимо однозначно определить статус передаваемых ячеек (обсчитаны/не
обсчитаны). В объекте white запускается forward с обходом по внутренним
ячейкам (downinnersweep), но только по «нижней» их половине, т.е. обход будет
сделан только по части внутренних ячеек. Из black отсылается в white
соответствующая граничная часть ячеек, которые являются соответствующими
соседями для ячеек из white, при этом отсылаемые ячейки для white не
являются обсчитанными. В black запускается обход по всем ячейкам, кроме
граничных и кроме ячеек, являющихся соседними к граничным внутри блока, т.
е. обход по всем ячейкам, кроме «двойного» периметра ячеек (smallinnersweep).
Если бы был выбран обход innersweep, то изменялись бы и граничные ячейки, и
возникали ситуации, когда они (граничные ячейки) отсылались бы в white как с
обновленными компонентами (после вызова setneighval для граничной ячейки
из обхода innersweep), так и с необновленными. При обходе же smallinnersweep
граничные ячейки остаются неизмененными (рис. 6, все изменяемые ячейки
показаны пунктиром) и таковыми передаются соседним объектам. В white
после завершения обхода downinnersweep проверяется флаг, сигнализирующий
о том, что ячейки из соседнего объекта получены (это, как правило, так и будет
– по соображениям, описанным выше) и запускается forward по граничным
ячейкам(ringsweep), причем используется копия К ячеек полученных из black, в
этой копии также обновляются ячейки (полученные из black, они еще не
обсчитаны). После завершения этого обхода из white в black отправляются
граничные ячейки блока white, которые являются соседями для
соответствующих ячеек из black, и обновленная копия К ячеек из black. В black
граничные ячейки обновляются из присланной копии К (операция корректна,
т.к. никаких действий над граничными ячейками в black при time=4 еще не
производилось). Затем в white запускается forward с завершающим обходом
оставшейся «верхней» половины внутренних ячеек upinnersweep и подается в
монитор заявка на продвижение времени в white. В black по флагу проверяется,
обновились ли граничные ячейки в блоке и присланы ли ячейки из соседних
white-блоков, которые уже являются обсчитанными. После подтверждения этого
запускается forward с обходом bigringsweep по оставшимся ячейкам из
«двойного» периметра блока black с обращениями к присланным посчитанным
ячейкам из white. По завершении обхода подается заявка в монитор на
продвижение времени в black.
При работе backward в каждом объекте используется обход, обратный
таковому при forward, при этом все так же вычисления будут зависеть от
обсчитанности/необсчитанности ячеек-соседей, но сами они (ячейки-соседи)
уже не обновляются, происходит запись только в текущую ячейку.
Рис 7. Backward в black и white
Рис 8. Updateiter, updatetime в black и white
При time=5 в black запускается backward по обходу, обратному
bigringsweep – bigringsweepback, используя при этом ячейки от соседей black,
полученных еще при time=4, т.к. эти ячейки для black в backward будут со
статусом «не обсчитаны» и обновлять их не требуется, поскольку имеющаяся
копия совпадает с соответствующими ячейками в white. После завершения
обхода bigringsweepback из black в white отправляются только ячейки блока
black, которые являются соседними для соответствующих ячеек в white и в
black запускается backward для оставшихся внутренних ячеек с обходом
smallinnersweepback, обратным smallinnersweep. В white запускается backward с
обходом upinnersweepback (обратный для upinnersweep), затем проверяется по
флагу, получены ли соседи для граничных ячеек блока white из black, и, в
случае подтверждения, запускается backward c обратным обходом по
граничным ячейкам white – ringsweepback, с использованием ячеек-соседей,
полученных из black. В заключении backward обсчитывает оставшиеся ячейки с
обходом downinnersweepback, обратным downinnersweep. После этих процедур
в объектах запускаются с одинаковым обходом simplesweep updateiter и
updatetime (если текущая итерация – последняя на шаге по времени), не
требующие обращания к соседям.
Описанный алгоритм эффективно работает на блоках с примерно равным
числом ячеек, поскольку время счета между двумя вызовами setXYZT для всех
объектов будет мало отличаться и они не будут значительным образом
тормозить счет друг друга. Корректность алгоритма обеспечивается
механизмом синхронизации, предоставляемым OST (вызовы между объектами,
выполняющими разные процедуры (например, slope
и predicval), т.е.
находящимися в разных логических временах, блокируются монитором до
совпадения их времен) и существованием эквивалентного обхода всей области
при последовательной работе M2DGD. Существование такого обхода
необходимо только для forward и backward, поскольку для всех других
рассмотренных процедур он не важен. Построим такой обход, например, для
области с 2х2 блоками:
Рис 9. Эквивалентный последовательный обход
Числами обозначена последовательность обхода в forward частей области,
каждая часть соответствует обходу в параллельном счете: 1, 2 – smallinnersweep;
3, 5 – downinnersweep; 4, 6 – ringsweep; 7, 8 – bigringsweep, 9, 10 – upinnersweep.
При параллельном счете в black запускается счет в 1 и 2, используя при этом не
обсчитанные еще ячейки из 7 и 8; в white считается 3 и 5, используя также
необсчитанные ячейки из 4, 9, 6, 10; затем, используя полученные из 7 и 8
необсчитанные еще соответствующие копии граничных ячеек, в white считается
4 и 6, используя соответственно уже обсчитанные ячейки из 3 и 5, а также еще
не обсчитанные из 9 и 10, 7 и 8 (копии). Обновленные копии отсылаются
обратно для обновления своих оригиналов, что соответствует обходу 4 и 6 в
последовательном случае с обновлением соседних ячеек в 7 и 8. После, в white
счет 9 и 10 с использованием уже посчитанных 3 и 4, 5 и 6. В black считаются 7
и 8, используя уже полученные обсчитанные копии из 4 и 6. Таким образом,
работа параллельного алгоритма эквивалентна работе последовательного с
соответствующим обходом. Для backward обход осуществляется в порядке
убывания номеров частей области; аналогично показывается эквивалентность
последовательного и параллельного алгоритмов. В случае произвольной
конфигурации NxM блоков последовательный обход строится следующим
образом: сначала обходятся все части smallinnersweep во всех black в
произвольном порядке, затем в каждом white upinnersweep и ringsweep
(последовательность обхода самих блоков white произвольная), после обходятся
все bigringsweep в black произвольным образом, затем оставшиеся части
downinnersweep в white (также произвольно). Нетрудно видеть, что этот обход
будет эквивалентным для параллельного алгоритма.
4.Реализация алгоритма в OST
Текущая версия OST [1] написана на языке Python, поэтому прикладной
объект также пишется на этом языке, но т. к. Python — язык интерпретируемый,
то скорость работы программ на нем будет меньше скорости аналогичных
программ, написанных на компилируемых языках. Поэтому, чтобы свести эту
разницу к минимуму, вычислительное ядро алгоритма и обходы ячеек пишутся
на C/fortran, компилируются в виде динамической библиотеки, и она
подключается к объекту на Python, в самом объекте реализуются удаленные
вызовы к соседям, вызовы setXYZT, задается топология задачи.
В каждом объекте выделяются массивы под блок с ячейками и для
хранения полученных от соседей ячеек и граничных условий, если одна из
сторон блока — физическая граница. Структура элементов этих массивов
определяется в С:
#define NVAL 33
typedef
typedef
typedef
typedef
struct
struct
struct
struct
{double x, y;} point;
{double f[NVAL];} cell;
{int N,M; point *p; cell *c;} grid;
{int typeb,L;point *p; cell *c;} border;
point — структура узла сеточного разбиения с координатами x и y; cell —
содержит осредненное значение вектора-решения на ячейке (33 компоненты);
grid описывает параметры блока: N, M — число ячеек в двух сторонах
прямоугольного блока, p и c — указатели на массивы соответствующих
структур; border описывает структуру границы для блока: typeb определяет, что
соответствующая граница — физическая (если typeb>1) с соответствующим
индексом или определяет статус ячеек, полученных от соседа — необсчитаны
(typeb=0) или обсчитаны (typeb=1), L задает число ячеек в граничном массиве.
В соответствии с пользовательским описанием определяется топология
для назначения связей между объектами (на Python):
class OST_Plane_Point:
def __init__(self, x, y):
self.coord = None
self.neighbors = []
self.coord = [x,y]
self.time = 0
self.neighbors.append( { "coord":[x, y-1], "obj":None
} )
self.neighbors.append( { "coord":[x+1, y], "obj":None
} )
self.neighbors.append( { "coord":[x, y+1], "obj":None
} )
self.neighbors.append( { "coord":[x-1, y], "obj":None
} )
Все объекты будут определяться по двум своим координатам, а ссылки на
соседей будут отличаться на единицу в одной из координат (монитор OST
установит эти ссылки на фактических соседей, распределенным по
процессорам в МВС в процессе инициализации задачи).
Сам прикладной объект наследуется от определенного в OST и в нем
определяются все необходимые поля и методы для счета задачи:
class appObject(OST_Object_Abstract):
def __init__(self):
OST_Object_Abstract.__init__(self)
self.relate={} #словарь с записями номер соседа:номер
гр стороны в соседе
self.threads={} #хранилище потоков ввода-вывода
self.s={} #хранилище значений f для гр ячеек для
передачи соседу
self.f={} #хранилище значений f для гр ячеек для
принятия из соседа
self.g={} #хранилище геометрии для ячеек,получаемых
от соседа
self.tmpgeom={}
#вспомогательное
хранилище
для
геометрии
self.forw={} #вспом для forward
self.flag={} #флаги для проверки
получ
ячеек
от
соседей
Каждому направлению в блоке приписывается индекс: «снизу» - 0,
«справа» - 1 «сверху» - 2, «слева» - 3. В self.relate будут храниться соответствия
направления соседа и стороны в соседе, для которой отсылаются ячейки
(например в сосед «справа» отсылаются ячейки для той стороны, которая
является «левой» в соседе, следовательно, это соответствие 1 — 3).
В init определяются координаты объекта и его принадлежность к классу
(типу) black и white:
def init(self,x,y,kind):
self.kind=kind #тип объекта 0-black,1-white
self.point = OST_Plane_Point(x,y)
Для доступа к ячейкам блока не только из библиотечных функций, но и из
самого объекта, на Python описываются те же структуры блоков и границ:
class point(Structure):#структура узла сетки
_fields_ = [("x", c_double),("y", c_double)]
class cell(Structure):#структура вектора-решения на ячейке
_fields_ = [("f",c_double*NVAL)]
class grid(Structure):#структура,содерж инф о блоке(коорд
узлов и f)
_fields_ =
[("N",c_int),("M",c_int),("p",POINTER(point)),("c",POINTER(cell))]
class border(Structure):#структура границы-у блока их будет 4
#L-длина
границы,type
физ
гр(>=2)
или
гр
из
соседа(=0,1)
_fields_=
[("typeb",c_int),("L",c_int),("p",POINTER(point)),("c",POINTER(cel
l))]
Далее продолжается инициализация объекта:
self.lib=CDLL('./flib.so')
#подключается библиотека с с- и
fortran функциями
self.localtime=c_int(-1)
# параметр,определяющий обход
ячеек в блоке
self.grid=grid()
#структура блока
self.border
=
(border*4)()
#4
полосыграницы,инициализируются после
self.dt=c_double(0.0001)
#квант времени для процедур
fortran
self.dtpseudo=c_double(10000.)
#псевдовремя
для
процедур
fortran
self.sleeptime=0.0005 #интервал проверки флагов
self.forslope=c_int(1) #параметр для slope
self.initGrid() #инициализация блока ячеек
self.initBorder() #инициализация границ
self.initValues()
#передаются
ссылки
на
блок
ячеек
и
границы,тип объекта, localtime в библиотечные функции (в код на
c/fortran)
self.initRelate() #задается соответствие self.relate для всех
сторон, по которым у объекта есть соседи
self.geomPrepare() #подготовка геометрии гр ячеек для отсылки
соседям
self.point.time+=1
self.setXYZT() #для
свои гр ячейки
подтв
того,что
все
соседи
подготовили
self.getGeoms() # собирает гр ячейки от соседей
self.lib.input_()
self.lib.setinitvector_()
В initGrid() вызывается библиотечная функция для генерации блока ячеек,
в качестве параметров ей передаются путь к файлу, содержащему геометрию
всех блоков и граничные условия, ссылка на блок ячеек (self.grid) и координаты
объекта. В initBorder() также генерируются массивы с граничными ячейками,
дополнительно передается ссылка на self.border. Конструкция
self.point.time+=1
self.setXYZT()
увеличивает время объекта и отправляет заявку в монитор на
продвижение времени для того, чтобы ячейки, за которыми обращается
последующий метод getGeoms(), были подготовлены во всех соседях. Чтобы во
время счета не передавать между объектами-соседями каждый раз геометрию
ячеек, которая остается неизменной, она передается один раз перед началом
счета в getGeoms(). Во всех последующих обменах ячейками будут
передаваться только вектора f.
GetGeoms() вызывает getGeom() для каждого фактического объектасоседа:
def getGeom(self,i): #забир у соседа соотв геометрию гр ячеек
и уст-ет указатель на них в библиотеке
self.g[i]=self.point.neighbors[i]["obj"].takeGeom(self.relate[i])
self.lib.setgeompointer(self.g[i],i)
На этом примере показано, как обращаться к соседу: через простой вызов
его методов, как если бы он (сосед) был локальным объектом с именем
self.point.neighbors[i]["obj"], хотя фактически он может располагаться на другом
вычислительном узле (распределение счетных объектов — одна из функций
OST); ссылка на полученную геометрию ячеек(g[i]) передается в код на
с/fortran. Вызываемый метод takeGeom() в удаленном объекте необходимо
определить, а так как все счетные объекты (в том числе вызывающий и
вызываемый) принадлежат одному классу appObject, то этот метод нужно
определять в том же классе:
def takeGeom(self,i): #заполняет геометрию гр ячеек и отдает
их вызвавшему соседу
return self.tmpgeom[i]
Данный метод просто возвращает соответствующую подготовленную
геометрию ячеек вызываемому объекту.
Self.lib.input_() и self.lib.setinitvector_() - процедуры fortran, которые
подготавливают физические параметры задачи и начальные значения на
расчетной области.
Для передачи ячеек между соседями (векторов f) используется
self.setBorders(), которая для каждого соседа вызывает setBorder:
def setBorder(self,i): #заполняет гр ячейки
вызываемому соседу
self.lib.fillborder(self.s[i],i)
и
передает
их
self.point.neighbors[i]["obj"].acceptBorder(self.s[i],self.relate[
i])
В удаленном объекте вызывается acceptBorder(), передаются заполненные
fillborder ячейки — s[i] для соответствующей (self.relate[i]) стороны соседа.
AcceptBorder() обновляет ссылки на присланные ячейки и выставляет флаг о
получении ячеек:
def acceptBorder(self,income,i): #принимает ячейки от соседа
self.f[i]=income
self.lib.setpointer(self.f[i],i)
self.flag[i]=1 #значение флага - ячейки получены
Fillborder() для нижней стороны блока выглядит так:
void fillborder(cell* c,int i){
int n,j,index;
switch(i){
case(0):{//из нижней стороны блока
for
(n=0;n<N;n++)
for
c[n].f[j]=gridArray->c[n].f[j];
return;
}
…
…
(j=0;j<NVAL;j++)
Для других сторон изменяются только индексы ячеек gridArray->c[index].
Для forward аналогично определяются setBorderForw и acceptBorderForw,
отличие от предыдущих только в том, что дополнительно передаются соседу его
(соседа) обновленные копии ячеек.
AcceptBorder и acceptBorderForw являются односторонними функциями,
т.е. вызывающий их объект не дожидается от соседа результата выполнения
этих функций, а выполняет последующие операции, не тратя время на
ожидание результата указанных функций.
Для получения данных о ячейках в вычислительном ядре используется
вызов getnewcell, который через параметры-ссылки передает флаг R (отвечает за
обсчитанность/необсчитанность ячейки или определяет индекс физической
границы), g (геометрия ячейки), f (осредненный вектор ячейки). Для выбора
обхода используется параметр localtime (который может изменяться и из кода
Python):
void getnewcell_(int *R, double *g, double *f){
switch(*localtime){
case (1):{ simplesweep(R,g,f); return;} //omega
case (2):{ innersweep(R,g,f); return;} //slope
case (3):{ ringsweep(R,g,f); return;} //slope
case (4):{ innersweep(R,g,f); return;} //predicval
case (5):{ ringsweep(R,g,f); return;} //predicval
case (6):{if (*kind==black){//forward
smallinnersweep(R,g,f); return;
}
if (*kind==white){//forward
upinnersweep(R,g,f); return;
}
}
…
…
Все обходы из алгоритма реализуются одноименными функциями на C,
например:
void simplesweep(int *R, double *g, double *f){//обход по
ряду слева направо,переход на след верхний ряд
*R=1;
if
((icell==-1)&&(jcell==-1))
{icell=0;jcell=0;getgf(g,f,0,0);return;}//случай первой ячейки
if ((icell==N-1)&&(jcell==M-1)) {icell=0; jcell=0; *R=-1;
return;}//последн ячейка в блоке
if (icell==N-1){//последн ячейка в строке
jcell++;
}
icell=(icell+1)%N;
getgf(g,f,icell,jcell);
return;
}
После определения индексов текущей ячейки на обходе вызывается getgf,
которая непосредственно заполняет данные в g и f:
void getgf(double *g, double *f,int i,int j){//определение g
и f по jcell и icell
int n,index=(N+1)*j;
g[0]=gridArray->p[index+i].x;//нижн лев
g[1]=gridArray->p[index+i].y;
g[2]=gridArray->p[index+i+1].x;//нижн прав
g[3]=gridArray->p[index+i+1].y;
g[4]=gridArray->p[index+N+i+2].x;//верхн прав
g[5]=gridArray->p[index+N+i+2].y;
g[6]=gridArray->p[index+N+i+1].x;//верхн лев
g[7]=gridArray->p[index+N+i+1].y;
index=N*j+i;
for
(n=0;
n<NVAL;
n++)
f[n]=gridArray>c[index].f[n];
}
Для получения данных от ячеек-соседей в вычислительном ядре
вызывается getnewneig(), которая, в отличие от getnewcell(), передает еще и
данные о векторе общей стороны для текущей и соседней ячейки. Обход всех
соседних ячеек осуществляется в порядке 0, 1, 2, 3 (индексы сторон,
определенные выше), за индекс текущей стороны отвечает параметр neigh, при
этом рассматриваются три случая: ячейка-сосед лежит внутри блока, либо
получена от объекта-соседа, либо является физической границей для блока.
Определение нижнего соседа:
void getnewneig_(int *R,double *gn,double *e,double *fn){
int n;
switch(neigh){
case(0):{//сосед снизу
if (jcell>0) {//текущ ячейка не в нижней полосе
getgf(gn,fn,icell,jcell-1);//значения из нижней
ячейки
*R=treat[icell][jcell-1];//посчитан или нет сосед
e[0]=gn[6];//вектор общей грани(против часовой)
e[1]=gn[7];
e[2]=gn[4];
e[3]=gn[5];
break;
}
if (jcell==0){//нижняя полоса в блоке
*R=borders[0].typeb;//тип
ячейки,смежной
к
граничной
for
(n=0;
n<NVAL;
n++)
fn[n]=borders[0].c[icell].f[n];
if ((*R==0)||(*R==1)) {//ячейки - не физ граница
gn[0]=borders[0].p[icell].x;//нижн левая
gn[1]=borders[0].p[icell].y;
gn[2]=borders[0].p[icell+1].x;//нижн правая
gn[3]=borders[0].p[icell+1].y;
gn[4]=borders[0].p[icell+N+2].x;//верхняя
правая
gn[5]=borders[0].p[icell+N+2].y;
gn[6]=borders[0].p[icell+N+1].x;//верхняя
левая
gn[7]=borders[0].p[icell+N+1].y;
}
else{//ячейки - физ граница
gn[0]=borders[0].p[icell].x;//"нижн" левая
gn[1]=borders[0].p[icell].y;
gn[2]=borders[0].p[icell+1].x;//"нижн" правая
gn[3]=borders[0].p[icell+1].y;
gn[4]=gn[2];//верхняя правая
gn[5]=gn[3];
gn[6]=gn[0];//верхняя левая
gn[7]=gn[1];
}
e[0]=gn[6];//вектор общей грани(против часовой)
e[1]=gn[7];
e[2]=gn[4];
e[3]=gn[5];
}
break;
}
…
…
Для аписи обновленных значений текущей ячейки из вычислительного
ядра вызывается setcurval():
void setcurval_(double* f){
int n,index=N*jcell+icell;
for (n=0; n<NVAL; n++) gridArray->c[index].f[n]=f[n];
}
Для записи значений в ячейку-сосед вызывается setneival(),
рассматриваются случаи соседа в самом блоке и полученного из объектасоседа:(для соседа снизу)
void setneival_(double* f){
int n;
int ng=neigh-1;//номер текущего соседа
switch(ng){
case(0):{//сосед снизу
if (jcell>0) {//текущ ячейка не в нижней полосе
for (n=0; n<NVAL; n++) gridArray->c[N*(jcell1)+icell].f[n]=f[n];
}
if (jcell==0){//текущ ячейка в нижней полосе
if
(borders[0].typeb!=0)
printf("error
in
setneival:typeb!=0!!!\n");
for
(n=0;
n<NVAL;
n++)
borders[0].c[icell].f[n]=f[n];
}
return;
}
case(1):{//сосед справа
if (icell<N-1) {//текущ ячейка не в левой полосе
for
(n=0;
n<NVAL;
n++)
gridArray>c[N*(jcell)+icell+1].f[n]=f[n];
}
if (icell==N-1){//текущ ячейка в левой полосе
if
(borders[1].typeb!=0)
printf("error
in
setneival:typeb!=0!!!\n");
for
(n=0;
n<NVAL;
n++)
borders[1].c[jcell].f[n]=f[n];
}
return;
}
…
…
Наконец, сама функция счета:
def solve(self): #функция
счета
self.localtime.value=1 #выбирается обход simplesweep
for timestep in range (0,100): #задается число шагов
по времени(100)
for stepiniter in range (0,2): #число итераций на
один шаг(2)
for i in self.relate:
self.border[i].typeb=0 #для всех
нефизических границ ячейки объявляются необсчитанными
self.flag[i]=0 #сброс флагов (отвечающих
за получение ячеек от объектов-соседей)
#Цикл вида for i in self.relate осуществляется по тем
индексам, для которых имеются фактические объекты-соседи.
self.lib.omega_(byref(self.dt)) #запуск omega
из библиотеки
self.localtime.value=2 # выбирается обход для
следующей процедуры - innersweep
#заявка напродвижение времени:
self.point.time+=1
self.setXYZT()
#------------------------SLOPE---------------------------------------self.setBorders() #отсылка ячеек всем
объектам-соседям
self.lib.slope_(byref(self.forslope)) #slope
на innersweep
for i in self.relate:
while self.flag[i] != 1:
# если ячейки
от соседа еще не готовы для счета
time.sleep(self.sleeptime)
self.localtime.value=3 #выбирается обход
ringsweep
self.lib.slope_(byref(self.forslope)) #slope
на ringsweep
for i in self.relate:
self.flag[i] = 0
# сброс флагов
получения гр ячеек от соседей
self.localtime.value=4 #выбор innersweep
self.point.time+=1
self.setXYZT()
#-----------------------------PREDICVAL-------------------------------------#действия аналогичны slope
self.setBorders()
self.lib.predicval_(byref(self.dt)) #счет
внутренней части
for i in self.relate:
while self.flag[i] != 1:
# если ячейки
от соседа еще не готовы для счета
time.sleep(self.sleeptime)
self.localtime.value=5 #выбирается обход
ringsweep
self.lib.predicval_(byref(self.dt)) #счет
граничной части
for i in self.relate:
self.flag[i] = 0
получения гр ячеек от соседей
# сброс флагов
self.localtime.value=6
self.point.time+=1
self.setXYZT()
#------------------------------FORWARD_1-------------------------------------self.lib.resettreat() #сброс статуса всех
ячеек в состояние «необсчитаны»
# случай объекта black
if (self.kind==1):#black
self.setBorders() #отправка ячеек соседям
self.lib.forward_(byref(self.dt),byref(self.dtpseudo)) #forward на
smallinnersweep
self.localtime.value=7 #выбор
bigringsweep
#случай объекта white
if (self.kind==0):#white
self.lib.forward_(byref(self.dt),byref(self.dtpseudo)) #forward на
upinnersweep
for i in self.relate:
while self.flag[i] != 1:
# если
ячейки от соседа еще не готовы для счета
time.sleep(self.sleeptime)
self.localtime.value=7 #ringsweep
self.logger.startExp("ring forward_1 in
white:")
self.lib.forward_(byref(self.dt),byref(self.dtpseudo)) # forward
на ringsweep
for i in self.relate:
self.flag[i] = 0
# сброс флагов
получения гр ячеек от соседей
self.setBordersForw() #отсылка граничных
ячеек из блока и обновленных копий, полученных от соседей, обратно
соседям
self.localtime.value=8 #downinnersweep
#----------------------------------FORWARD_2--------------------------------------#случай объекта black
if (self.kind==1):#black
for i in self.relate:
self.border[i].typeb=1 #присылаемые
ячейки имеют статус «обсчитаны»
while self.flag[i] != 2:
# если
ячейки от соседа еще не готовы для счета
time.sleep(self.sleeptime)
for i in self.relate:
self.flag[i] = 0
# сброс флагов
получения гр ячеек от соседей
#обновление из полученных копий граничных
ячеек (невязки)
for tm in self.forw:
self.lib.resforward(self.forw[tm],tm)
self.lib.forward_(byref(self.dt),byref(self.dtpseudo)) #forward по
bigringsweep
self.localtime.value=8 #выбор
bigringsweepback
#продвижение по времени:
self.point.time+=1
self.setXYZT()
#случай объекта white
if (self.kind==0):#white
self.lib.forward_(byref(self.dt),byref(self.dtpseudo)) #forward по
downinnersweep
self.localtime.value=9 #выбор
downinnersweepback
#продвижение по времени
self.point.time+=1
self.setXYZT()
#----------------------BACKWARD_1------------------------------------------self.lib.resettreat() #сброс всех ячеек блока
в статус «необсчитаны»
for i in self.relate:
self.border[i].typeb=0 #сброс ячеек
границ в статус необсчитаны
#случай объекта black
if (self.kind==1):#black
self.lib.backward_(byref(self.dt),byref(self.dtpseudo)) #backward
на bigringsweepback
self.setBorders() #отсылка ячеек whiteсоседям
self.localtime.value=9 # выбор
smallinnersweepback
#случай объекта white
if (self.kind==0): #white
self.lib.backward_(byref(self.dt),byref(self.dtpseudo)) #backward
по downinnersweepback
self.localtime.value=10 #выбор
ringsweepback и upinnersweepback
#------------------------BACKWARD_2--------------------------------------#случай объекта black
if (self.kind==1):#black
self.lib.backward_(byref(self.dt),byref(self.dtpseudo)) #backward
по smallinnersweepback
self.lib.updateiter_() #обновление
компонентов f с обходом simplesweep
self.localtime.value=1 #выбор simplesweep
для omega.
#случай объекта white
if (self.kind==0):#white
for i in self.relate:
self.border[i].typeb=1 #принимаемые
ячейки от соседей уже посчитаны
while self.flag[i] != 1:
# если
ячейки от соседа еще не готовы для счета
time.sleep(self.sleeptime)
for i in self.relate:
self.flag[i] = 0
# сброс флагов
получения гр ячеек от соседей
self.lib.backward_(byref(self.dt),byref(self.dtpseudo))
self.lib.updateiter_() #обновление
компонентов f с обходом simplesweep
self.localtime.value=1 #выбор simplesweep
для omega.
#обновление компонент f после всех итераций на
данном шаге по времени
self.lib.updatetime_()
# обработка f после завершения счета
self.lib.setfinvector_()
Для «раскрутки» задачи прикладной программист также пишет класс
OST_Main_Appobj, в котором определяется число параллельно считаемых
объектов и их разбиение на black и white:
class OST_Main_Appobj( OST_Main_Class_Abstract ):
'''Класс раскрутки задачи M2DGD'''
# Задание, количества объектов
NN = 4 #число объектов в горизонтальном направлении
MM = 1 #число объектов в вертикальном направлении
def __init__(self):
'''Функция-конструктор объекта раскрутки'''
OST_Main_Class_Abstract.__init__(self)
def init(self):
'''Функция инициализации счетной модели'''
for n in range( 0, self.NN):
for m in range( 0,self.MM):
obj = self.createObject(appObject) #создается
прикладной объект класса appObject
obj.init(n,m,(n+m)%2) #так задаваемые
координаты и тип объекта(black-0,white-1) образуют на пространстве
блоков необходимое «шахматное» распределение по типам
5.Постановка тестовой задачи
Для проверки корректности алгоритма и оценки его эффективности
использовалась задача о коническом теле, мгновенно помещенном в
однородный сверхзвуковой поток газа. Тело представляет собой конус с углом
полураствора 20 градусов, сопряженный с цилиндром. Оно мгновенно
помещается в трубу круглого сечения со сверхзвуковым однородным потоком
газа (M=1.6). Отношение площади выходного отверстия к входному составляет
0.8 (рис.10).
Рис 10. Задача о коническом теле.
Рассматривалась эволюция потока на интервале времени от 0 до 0.5
(обезразмеренное) с dt=0.0001 и количеством шагов по времени 5000, на
каждом шаге выполнялось по 2 итерации. Сетка на области строилась с тремя
разрешениями: 450х135, 900х270, 1800х540. На рис.10 изображено разбиение
области на 16 блоков, обсчитывающихся параллельно.
6.Результаты счета, сравнение с MPI
Для проверки корректности было произведено сравнение результатов
работы последовательного и параллельного алгоритмов на сетке 450х135 с
числом шагов 500 и 5000, расчет параллельной версии проводился на 16 ядрах
на МВС RSC-4 в ИПМ им.М.В.Келдыша РАН (128 ядер).
Рис 11. Результат после 500 шагов для параллельного(вверху) и последовательного(внизу)
счета.
Выводы
Рис 12. Результат после 5000 шагов для параллельного(вверху) и последовательного(внизу)
счета
На рис. 11 и 12 показано распределение плотности после 500 и 5000
шагов (цветовые различия из-за разных цветовых шкал, используемых для
отображения). Как видно, результат работы двух версий программ совпадает.
В следующей таблице дается сравнение подходов OST и MPI с точки
зрения прикладного программиста:
OST
объектно-ориентированный подход для
построения параллельной модели;
обращение к удаленным объектам так
же,
как
к
локальным
с
непосредственным вызовом методов
автоматический
алгоритм
синхронизации,
предоставляемый
средой
автоматическое назначение связей
между
объектами
по
заданной
топологии
MPI
модель параллельно выполняющихся
процессов, взаимодействие между
ними – через рассылку сообщений
синхронизация
полностью
организуется
самим
прикладным
программистом
определение связей между процессами
организуется
самим
прикладным
программистом
OST «берет на себя» организацию всей низкоуровневой (системной)
части
программирования,
предоставляя
прикладному
программисту
высокоуровневые средства для написания параллельных программ.
Для оценки эффективности двух реализаций был проведен счет тестовой
задачи на трех различных разрешениях сеточного разбиения c 10 шагами по
времени (запуск выполнялся на RSC-4).
При счете области 450х135 на 2 и 4 ядрах обе реализации показывают
высокую эффективность, с дальнейшим увеличением числа ядер
эффективность MPI кода остается столь же высокой; для OST эффективность,
начиная с 8 ядер, начинает падать и уже на 32 ядрах возникает насыщение,
когда увеличение доступных ядер в 2 раза не дает никакого уменьшения
времени счета. Это связано с неэффективной работой потоков (threads) в Python,
когда вычисления в каждом блоке на одной итерации по времени становятся
сравнимы с временем, затрачиваемым на обслуживание потоков синхронизации
времени и передачи ячеек.
На области 900х270 наблюдается похожая картина, высокая
эффективность OST версии сохраняется уже вплоть до 16 ядер, при
дальнейшем увеличении числа ядер она значительно падает. Улучшение
эффективности в сравнении с областью 450х135 связано с тем, что отношение
времени счета в объекте к издержкам, связанным с обслуживанием потоков,
увеличилось (каждый объект содержит в 4 раза больше ячеек).
На области 1800х540 особенности работы потоков в Python практически
не сказываются на эффективности во всех протестированных конфигурациях, и
результаты OST оказываются близкими к MPI даже на 64 и 100 ядрах.
Выводы.
В работе был построен параллельный алгоритм для имеющегося
программного комплекса M2DGD, доказана его корректность и
эквивалентность существующей последовательной версии. Основным
достоинством алгоритма, помимо его эффективности, является точное
соблюдение его последовательной версии во всей расчетной области, а не
приближенное (как в большинстве публикаций на эту тему), когда
последовательный алгоритм реализуется только в определенных подобластях
исходной расчетной области. Так же этот алгоритм реализован в OST с
использованием средств, предлагаемых средой и облегчающих написание
параллельных
программ.
На
тестовой
задаче
были
проведены
верификационные расчеты, подтвердившие правильность работы параллельной
версии. В сравнении с MPI версией алгоритма для прикладного программиста
использование среды OST дает несколько важных преимуществ: более
естественное представление расчетной модели в виде множества объектов с
удаленным обращением между ними как при обычном локальном доступе;
наличие
простого
в
использовании
алгоритма
синхронизации,
предоставляемого средой, и задание топологии объектов для автоматического
назначения связей между объектами. По эффективности OST-версия
проигрывает MPI на грубых сетках (с малым разрешением) на большом числе
процессоров (что является следствием отсутствия контроля со стороны
прикладного программиста за низкоуровневой частью системы). Падение
эффективности – своего рода плата за высокоуровневые средства, доступные
для программиста в OST. Но на сетках с высоким разрешением (1800х540),
эффективность OST версии не уступает (а порой даже выше) эффективности
MPI кода. Поскольку в реальных прикладных задачах особое внимание
уделяется образованию мелкомасштабных структур в расчетной области,
которые проявляются только на сетках с очень высоким разрешением, для
расчета на которых требуется помимо значительного объема оперативной
памяти (возможно, даже не доступного в пределах одного вычислительного
узла) еще и значительное время (недели и месяцы), то использование OST в
этом случае выглядит вполне оправданным, так как эффективность близка к
MPI, но при этом доступны более удобные и естественные средства для
высокоуровневого параллельного программирования.
Список использованных источников:
1. http://ost.kiam.ru
2. I. Menshov, Y. Nakamura, Hybrid Explicit-Implicit, Unconditionally
Stable Scheme for Unsteady Compressible Flows, AIAA Journal, Vol. 42,
No. 3, pp. 551-559, 2004.
3. Документация по языку Python - http://docs.python.org
Download