Использование параллелизма
Предположим теперь, что мы научились каким-то образом строить и исследовать информационную историю выполнения программы. Выделив в ней множества информационно независимых операций, приходим к вопросу: каким же образом распределять эти множества между процессорами или другими обрабатывающими устройствами?
Достаточно очевидным способом будет следующий. Пометим в информационной истории те операции, которые зависят только от внешних данных программы и скажем, что такие операции принадлежат к первому ярусу информационной истории. На второй ярус поместим операции, зависящие только от операций первого яруса и внешних данных и так далее. Распределив таким образом операции информационной истории, получаем ее вид, называемый ярусно-параллелъной формой программы. Количество ярусов ярусно-параллельной формы называется длиной критического пути. По построению операции, попавшие на один ярус, не могут состоять в отношении информационной зависимости, а значит, могут быть выполнены одновременно. Таким образом, процесс получения параллельной программы может быть следующим: сначала распределяем между процессорами операции первого яруса, после из завершения -операции второго яруса и т.д. Поскольку любая операция n-го яруса зависит хотя бы от одной операции (п-1)-го яруса, то ее нельзя начать выполнять раньше, чем завершится выполнение операций предыдущего яруса, а значит, ярусно-параллельная форма представляет собой в определенном смысле максимально параллельную реализацию программы. При этом длина критического пути характеризует количество параллельных шагов, необходимых для ее выполнения.
Однако ярусно-параллельная форма почти никогда не используется для практического распараллеливания программ. Причиной этого является то, что описанный способ распараллеливания плохо согласован как с конструкциями реальных языков программирования, так и с архитектурными особенностями современных компьютеров. Чтобы убедиться в этом, можете попробовать сконструировать таким способом параллельную реализацию практически любого алгоритма и оценить как сложность написания такой программы, так и количество необходимых коммуникаций между процессорами.
В реальности гораздо чаще используются другие способы распараллеливания, использующие характерные особенности наиболее распространенных языков программирования. Так, самым простым вариантом распределения работ между процессорами является примерно следующая конструкция (крупноблочное распараллеливание):
if (MyProc = 0) {/* операции, выполняемые 0-ым процессором */}
if (MyProc = K) {/* операции, выполняемые K-ым процессором */}
При этом предполагается, что каждый процессор каким-то образом может получить уникальный номер, присвоить его переменной MyProc и использовать в дальнейшем для получения участка кода для независимого исполнения. Таким образом, в приведенном примере операции в первых фигурных скобках будут выполнены только процессором с номером 0, операции во вторых фигурных скобках - процессором с номером K и т.д. При этом, естественно, необходимо, чтобы одновременно разные процессоры могли выполнять только блоки информационно независимых операций, что может потребовать операций синхронизации процессоров, а также при необходимости нужно обеспечивать обмен данными между процессорами.
Однако далеко не всегда удается выделить в программе достаточно большое число блоков независимых операций, а значит, при значительном числе процессоров в используемом компьютере, часть из них будет простаивать. Поэтому наряду с предыдущим способом используют также более низкоуровневое распараллеливание. Как показывает практика, наибольший ресурс параллелизма в программах сосредоточен в циклах. Поэтому наиболее распространенным способом распараллеливания является то или иное распределение итераций циклов. Если между итерациями некоторого цикла нет информационных зависимостей, то их можно тем или иным способом раздать разным процессорам для одновременного исполнения. Условно это может быть выражено примерно следующей конструкцией:
for (i = 0; i < N;
{
if (i ~ MyProc)
{
/* операции i-й итерации для выполнения процессором MyProc */
}
}
Здесь конструкция i ~ MyProc применена для того, чтобы указать, что номер итерации i каким-то образом соотносится с номером процессора MyProc. Конкретный способ задания этого соотношения определяет то, какие итерации цикла на какие процессоры будут распределяться. В принципе, ничто не мешает, например, раздать всем процессорам по одной итерации, а все остальные итерации выполнить каким-то одним процессором. Однако очевидно, что в подавляющем числе случаев такое распределение неэффективно, поскольку все процессоры, кроме одного, выполнив свою итерацию, будут, скорее всего, простаивать. Таким образом, одним из требований к распределению итераций (как, впрочем, и к процессу распараллеливания вообще) является по возможности равномерная загрузка процессоров. Наиболее распространенные способы распределения итераций циклов в той или иной степени удовлетворяют этому требованию.
Блочное распределение итераций предполагает, что распределение итераций цикла по процессорам ведется блоками по несколько последовательных итераций. В простейшем случае количество итераций цикла n делится на число процессоров p, результат округляется до ближайшего целого сверху и число Tn/p1 определяет количество итераций в блоке. При этом почти все процессоры получают одинаковое количество итераций, однако один или несколько последних процессоров могут простаивать. Выбор меньшего размера блока может уменьшить этот дисбаланс, однако при этом часть итераций останется нераспределенной. Если нераспределенные итерации снова начать распределять такими же блоками, начиная с первого процессора, то получим блочно-циклическое распределение итераций. Если уменьшать количество итераций дальше, то дойдем до распределения по одной итерации, которое называется циклическим распределением. Циклическое распределение позволяет минимизировать дисбаланс в загрузке процессоров, возникавший при блочных распределениях.
Рассмотрим небольшой пример. Пусть требуется распределить по процессорам итерации следующего цикла:
for (i = 0; i < N; i++)
a[i] = a[i] + b[i];
Пусть в целевом компьютере имеется P процессоров с номерами 0...P-1. Тогда блочное распределение итераций этого цикла можно записать следующим образом:
k = (N-1)/P + 1; /* размер блока итераций */
ibeg = MyProc * k; /* начало блока итераций процессора MyProc */
iend = (MyProc + 1) * k - 1; /* конец блока итераций процессора MyProc */
if (ibeg >= N) iend = ibeg - 1; /* если процессору не досталось итераций */
else if (iend >= N) iend = N - 1; /* если процессору досталось меньше итераций */
for (i = ibeg; i <= iend; i++)
a[i] = a[i] + b[i];
Циклическое распределение итераций того же цикла можно записать так:
for (i = MyProc; i < N; i+=P)
a[i] = a[i] + b[i];
Нужно заметить, что все соображения о распределении итераций циклов с целью достижения равномерности загрузки процессоров имеют смысл только в предположении, что распределяемые итерации приблизительно равноценны по времени исполнения. Во многих реальных случаях (например, при решении матричных задач с треугольной матрицей) это может быть не так, а значит, могут потребоваться совершенно другие способы распределения.
Однако только равномерной загрузки процессоров обычно недостаточно для получения эффективной параллельной программы. Только в крайне редких случаях программа не сдержит информационных зависимостей вообще. Если же есть информационная зависимость между операциями, которые при выбранной схеме распределения попадают на разные процессоры, то потребуется пересылка данных. Обычно пересылки требуют достаточно большого времени для своего осуществления, поэтому другой важной целью при распараллеливании является минимизация количества и объема необходимых пересылок данных. Так, например, при наличии информационных зависимостей между i-й и (i+D-й итерациями некоторого цикла блочное распределение итераций может оказаться эффективнее циклического, потому что при блочном распределении соседние итерации попадают на один процессор, а значит, потребуется меньшее количество пересылок, чем при циклическом распределении.
До сих пор говорилось о распределении итераций одномерных циклов, однако в программах часто встречаются многомерные циклические гнезда, причем каждый цикл такого гнезда может содержать некоторый ресурс параллелизма. Для его использования производят анализ и разбиение пространства итераций исследуемого фрагмента. Пространством итераций гнезда тесновложенных циклов называют множество целочисленных векторов I, координаты которых задаются значениями параметров циклов данного гнезда. Задача распараллеливания при этом сводится к разбиению множества векторов I на подмножества, которые выполняются последовательно друг за другом, но в рамках каждого такого подмножества итерации могут быть выполнены одновременно и независимо.
Среди методов анализа пространства итераций можно выделить несколько наиболее известных: методы гиперплоскостей, координат, параллелепипедов и пирамид.
Метод гиперплоскостей заключается в том, что пространство итераций размерности n разбивается на гиперплоскости размерности n-1 так, что все операции, соответствующие точкам одной гиперплоскости, могут выполняться одновременно и асинхронно. Метод координат заключается в том, что пространство итераций фрагмента разбивается на гиперплоскости, ортогональные одной из координатных осей. Метод параллелепипедов является логическим развитием двух предыдущих методов и заключается в разбиении пространства итераций на n-мерные параллелепипеды, объем которых определяет результирующее ускорение программы. В методе пирамид выбираются итерации, вырабатывающие значения, которые далее в теле гнезда циклов не используются. Каждая такая итерация служит основанием отдельной параллельной ветви, в которую также входят все итерации, влияющие информационно на выбранную. При этом зачастую информация в разных ветвях дублируется, что может привести к потере эффективности.
Рассмотрим небольшой пример:
for (i = 1; i < N; i++)
for (j = 1; j < M;j++ )
a[i,j] = a[i-1,j]+a[i,j];
Пространство итераций данного фрагмента можно изобразить следующим образом:
На этом рисунке кружки соответствуют отдельным срабатываниям оператора присваивания, а стрелки показывают информационные зависимости. Сразу видно, что разбиение пространства итераций по измерению I приведет к разрыву информационных зависимостей. Однако информационных зависимостей по измерению J нет, поэтому возможно применение метода координат с разбиением пространства итераций гиперплоскостями, ортогональными оси J, например, как показано на рисунке пунктирными линиями. Затем операции, попавшие в одну группу, распределяются для выполнения на один процессор целевого компьютера.
Немного усложним пример:
for (i = 1; i < N; i++)
for (j = 1; j < M; j++)
a[i,j] = a[i-1,j] + a[i,j-1];
Пространство итераций данного фрагмента можно изобразить следующим образом:
Очевидно, что метод координат в данном случае неприменим, потому что любое разбиение как по измерению I, так и по измерению J приведет к разрыву информационных зависимостей. Однако на рисунке пунктирными линиями показаны гиперплоскости I + J = const, которые содержат вершины, между которыми нет информационных зависимостей. Это означает возможность применения метода гиперплоскостей, при котором осуществляется перебор гиперплоскостей I + J = const, и для каждой из них соответствующие операции распределяются между процессорами целевого компьютера.
Если переходить от распараллеливания отдельных циклических конструкций к распараллеливанию целой программы, то может оказаться невыгодным использовать весь найденный ресурс параллелизма (в особенности, на компьютерах с распределенной памятью), поскольку потребуется большое количество перераспределений данных между выполнением циклов.
В некоторых случаях можно добиться более эффективного распараллеливания программы при помощи эквивалентных преобразований - таких преобразований кода программы, при которых полностью сохраняется результат ее выполнения. Существует достаточно большое число подобных преобразований, полезных в различных случаях, например: перестановки циклов, схлопывание циклов, расщепление циклов и т.д.
Приведем один небольшой пример. Допустим, что в программе содержится такой цикл:
for (i = 1; i < N; i++)
{
a[i] = a[i] + b[i];
c[i] = c[i-1] + b[i];
}
В данном цикле есть информационные зависимости между срабатываниями второго оператора из тела цикла на i-й и (i-D-й итерациях. Поэтому нельзя просто раздать итерации исходного цикла для выполнения различным процессорам. Однако, достаточно очевидно, что этот цикл можно разбить на два, первый из которых станет параллельным:
for (i = 1; i < N; i++)
a[i] = a[i] + b[i];
for (i = 1; i < N; i++)
c[i] = c[i-1] + b[i];
В некоторых случаях и эквивалентные преобразования бессильны помочь в распараллеливании программы, но допустимы другие методы при предположении, что можно пренебречь ошибками округления. Например, пусть нужно произвести суммирование элементов массива:
for (i = 0, s = 0; i < N; i++)
s+=a[i];
Если подойти к этому циклу формально, то распараллелить его не получится, так как между итерациями цикла существуют информационные зависимости. Однако, если ошибками округления, вызванными разным порядком суммирования элементов массива a, допустимо пренебречь, то данный фрагмент можно распараллелить при помощи широко известной схемы сдваивания: на первом шаге первый процессор суммирует элементы a[0] и а[1], второй процессор суммирует элементы а[2] и а[3] и т.д., на следующем шаге попарно суммируются эти частичные суммы и так до получения окончательного результата. Если имеется N/2 процессоров, то весь алгоритм суммирования выполняется за log2N параллельных шагов.