Новый язык обычного и параллельного программирования Planning C 2.0

от автора

Здравствуйте, уважаемые читатели.

Хочу написать здесь об одном из своих проектов — языке Planning C (v2.0). Он является расширением C++, дополняющим базовый язык рядом новых конструкций. В настоящее время проект доступен в репозитории (исходный код прототипного транслятора-препроцессора, множество примеров, конвертер простых программ MPI->Planning C). От других языков Planning C отличается тем, что многие его новые конструкции построены на базе так называемых процедур с планированием повторного входа, которые в первую очередь удобны для программирования некоторых алгоритмов, использующих стек, дек или очередь (но могут использоваться и для программирования произвольных алгоритмов). Язык содержит различные средства алгоритмизации и распараллеливания, более-менее унифицированные и для обычных в наше время компьютеров с многоядерными процессорами, и для видеокарт, и для кластерных систем. Во второй версии языка были введены стандартные средства расширения языка новыми конструкциями, «интеллектуальная» мемоизация и еще некоторые возможности. Надеюсь, кому-нибудь данный язык покажется интересным, может быть даже перспективным для применения и/или развития. Сам я иногда им пользуюсь для быстрого написания некоторых расчетных параллельных программ.

В этой статье напишу лишь о самых базовых возможностях языка, преимущественно на примерах. Если тема вызовет интерес, то, возможно, впоследствии напишу еще одну-две статьи о «продвинутых»/необычных возможностях.

Немного теории

Процедура с планированием повторного входа – это процедура с планом. Процедура исполняется столько раз (последовательно или параллельно), сколько элементов содержится в плане. Элементы извлекаются из начала плана, для каждого извлеченного элемента процедура вызывается заново (в неявно присутствующем цикле). Каждый элемент содержит набор значений параметров процедуры, которые передаются в нее при обработке текущего элемента плана. План может наполняться вне процедуры или внутри нее [с помощью специальных функций вставки в начало плана (plan_first) или в конец плана (plan_last)].

Немного особенным образом обрабатываются параметры, передаваемые по ссылке – их значения просто переходят с этапа на этап. Однако если в одном из элементов плана такой параметр был спланирован с отсечением (с указанием знака «!» после значения параметра), то в процедуру при исполнении данного элемента будет передано именно указанное значение.

Функция с планированием повторного входа отличается от процедуры тем, что имеет возвращаемое значение – оно определяется результатом последнего исполненного элемента плана.

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

Примеры обычных вычислений

Пример. Рассмотрим процедуру, выполняющую цикл от 0 до 5:

reenterable Loop(int x, int last) {   cout << x << “ “;   if (++x <= last) plan_first(x);  }  /* Вызов: Loop(0,5); */

Пример. Рассмотрим последовательную нумерацию узлов двоичного дерева по уровням сверху вниз и слева направо.

typedef struct TreeTag {    int Data;    struct TreeTag * Left;    struct TreeTag * Right;  } TreeNode;  int Number;  reenterable EnumerateByLevel(TreeNode * Cur) {   Cur->Data = Number++;   if (Cur->Left) plan_last(Cur->Left);   if (Cur->Right) plan_last(Cur->Right);  }  /* Вызов: Number = 1; EnumerateByLevel(Root); */

Ту же функцию EnumerateByLevel можно использовать и в виде лямбда-функции, с тем отличием, что вместо функций plan_first/plan_last используются reent_first/reent_last:

auto f = reenterable (TreeNode * Cur) {   Cur->Data = Number++;   if (Cur->Left) reent_last(Cur->Left);   if (Cur->Right) reent_last(Cur->Right);  }  /* Вызов: Number = 1; f(Root); */

Примеры параллельных вычислений

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

Пример. Параллельно просуммируем элементы дерева, при этом воспользуемся редукцией для параметра-суммы:

reenterable TreeSum(TreeNode * Cur, reduction(+) int & SumResult)  {   if (Cur==Root) plan_group_parallelize;   if (Cur->Left) plan_last(Cur->Left,SumResult);   if (Cur->Right) plan_last(Cur->Right,SumResult);   SumResult = Cur->Data;  }

Пример для работы на видеокарте (умножим массив чисел A[N] на k, получим массив B[N]):

#pragma plan vectorized  #pragma plan common begin  #define N 5  #pragma plan common end  reenterable Mul(bool init, int k, _global(N) int * A, int i, _local(1) int * out) {    int ii;    if (init) { // Заполняем план и далее запускаем на распараллеливание       for (ii = 0; ii < N; ii++) {           plan_last(false, k, A, ii, out);           out++;       }       plan_group_vectorize(NULL);    } else { // Собственно параллельная работа           *out = A[i]*k;    }  }  /* Вызов: Mul(true, k, A, 0, B); */

Пример параллельных вычислений на конвейере (для многоядерного процессора)

Пусть конвейер обрабатывает дерево, причем на первой его стадии находится максимальный элемент, а на второй – минимальный элемент. Первая стадия, в свою очередь, распараллеливается на 4 процессора, аналогично распараллеливается и вторая стадия. Таким образом, здесь мы имеем комбинированный параллелизм.

#ifndef __min  #define __min(a,b) ((a)<(b) ? (a) : (b))  #endif  #ifndef __max  #define __max(a,b) ((a)>(b) ? (a) : (b))  #endif  chain TreeMax(TreeNode * Cur, reduction(__max) int & MaxResult)  { // Первая стадия конвейера   static int DummyMin;   throw_last(Cur, DummyMin);   if (Cur==Root) plan_group_parallelize;   if (Cur->Left) plan_last(Cur->Left, MaxResult);   if (Cur->Right) plan_last(Cur->Right, MaxResult);   MaxResult = Cur->Data;  }  chain TreeMin(TreeNode * Cur, reduction(__min) int & MinResult)  { // Вторая стадия конвейера   if (Cur==Root) plan_group_parallelize;   MinResult = Cur->Data;  }  void TreeMinMax(int & Min, int & Max)  {   Max = 0.0;   Min = 1000.0;  // Запуск ковейера в параллельном режиме  plan_parallel_chain(0, TreeMax(Root,Max)/4, TreeMin(Root,Min)/4);  }

Пример параллельных вычислений (абстрактный) на конвейере, на кластере

На узле с идентификатором 1 выводит в стандартный поток вывода числа от 0 до 29.

#pragma plan clustered  #include <iostream>  using namespace std;  chain A1() throw(int DATA) { // Первая стадия конвейера    for (int i = 0; i < 30; i++)        throw_last(i);  }  chain B1(int DATA) { // Вторая стадия конвейера    cout<<DATA<<endl;  }  int main() {    int IDs[2] = {0,1}; // MPI-Идентификаторы узлов, на которых будет запущен конвейер    clustered(IDs) plan_parallel_chain(0, A1(), B1(0));    return 0;  }

Немного о транзакционной памяти

Транзакционная память имеется, даже в двух вариантах: а) реализованная компилятором (если он ее поддерживает) и б) программная (частично транзакционная, реализованная в Planning C, предполагающая использование как транзакционной, так и обычной памяти в одном блоке кода). Программируется она очень похожим образом с вышеприведенным примером программы для видеокарты: формируется группа этапов плана, после чего дается директива включения параллельного расчета в режиме того или иного вида транзакционной памяти. Приведу пример с программной частично транзакционной памятью – пример стандартный (расчет гистограммы значений массива). Программа суммирует частоты и выводит сумму на экран – естественно, это количество элементов массива (в данном случае 10000).

#include <stdlib.h>  #include <iostream>  using namespace std;  const int N = 10000;  const int M = 600;  const int NF = 20;  reenterable calc_histo(bool init, int np, int * A, soft_transact_array(int) * F, double grain, int k) {       if (init) { // Формируем план для распараллеливания           for (int i = 0; i < N; i += np) {                for (int j = 0; j < np; j++)                     plan_last(false, np, A, F, grain, i + j);                plan_group_soft_atomize; // Включаем режим параллельной работы в программной транзакционной памяти           }       } else { // Работа, собственно, в программной транзакционной памяти           if (k < N) {                int _k = A[k] / grain;                if (_k >= NF) _k = NF - 1;                ++(*F)[_k];           }       }  }  int main() {       int * A = new int[N];       soft_transact_array(int) F(NF);       srand(184415);       for (int i = 0; i < N; i++)           A[i] = rand() % M;       double grain = 1.0 * M / NF;       F = 0;       calc_histo(true, 4, A, &F, grain, 0);  // Гистограмму подсчитали, делаем проверку       int SF = 0;       for (int i = 0; i < NF; i++)           SF += F[i];       cout << SF;       delete[] A;       return 0;  }

Еще немного о параллельных вычислениях

Закономерен вопрос, а есть ли в этом языке традиционные средства параллельного программирования в общей памяти – семафоры, мьютексы, барьеры, критические секции… Да, все это есть, просто я не стал приводить примеров с ними, поскольку каких-то существенных особенностей работы с ними в языке нет.

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

#pragma plan clustered  #include <iostream>>  using namespace std;  cvar(int) * DATA = NULL;  int Counter;  chain A(input_proc Src, int N) {    int id = plan_linear_num()-1;    if (Src == empty_proc) {       (*DATA)++;       Counter = 1;       for (int i = 0; i < N; i++)           if (i != id)              throw_first(A[i+1], N);    } else {       (*DATA)++;       Counter++;       if (Counter == N) {          plan_registered_barrier(topology);          if (id == N-1) {             cout<<**DATA<<endl;             plan_topology_quit();          }       }    }  }  int main() {    const int N = 5;    DATA = new cvar(int)(1);    if (plan_cluster_id() == 0) *DATA = 0;    int IDS[N];    for (int i = 0; i < N; i++)        IDS[i] = i;    clustered(IDS) clique(N)/N;    delete DATA;    return 0;  }

Вместо заключения

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


ссылка на оригинал статьи https://habr.com/ru/post/645533/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *