Идеи о новых возможностях обычного-параллельного программирования (расширение C++)

в 19:00, , рубрики: c++, параллельное программирование, Программирование, процедуры с повторным входом, расширение

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

Предлагаю всем, кто заинтересуется, обсудить некоторые основные идеи классического и параллельного программирования в расширении C++, основанном на процедурах/функциях с планированием повторного входа (ПППВ/ФППВ). В минимальном варианте — это процедура или функция, у которой есть статический или динамический план исполнения. План является, вообще говоря, деком, состоящим из элементов-структур, каждая из которых имеет поля, аналогичные по типу и имени параметрам соответствующей функции/процедуры. План может пополняться как извне (до вызова процедуры/функции, с помощью некоторых простых приемов), так и изнутри (это основной подход) с помощью специальных вызовов помещения в начало plan_first(...) или в конец plan_last(...) плана.

ПППВ/ФППВ исполняется последовательно или параллельно, в соответствии с планом. В последовательном режиме она вызывается заново для каждого элемента плана (элемент по умолчанию извлекается из начала плана) и отрабатывает его полностью (до естественного завершения ПППВ/ФППВ или до return). При каждом вызове параметры ПППВ/ФППВ заполняются данными из одноименных полей текущего элемента плана. Как уже было сказано, в процессе исполнения любого из этапов, в план могут вставляться новые этапы, которые будут исполнены ПППВ/ФППВ далее. Для поддержки параллельного режима (в простейшем случае) в план помимо этапов могут вставляться специальные маркеры-барьеры, делящие план на группы. С помощью директивы plan_group_parallelize можно включить параллельное исполнение текущей (располагающейся в начале плана) группы, при этом она рассматривается как группа задач (task pool), из которой процессоры/ядра набирают себе на исполнение этапы-задачи. С помощью директивы plan_group_vectorize можно отправить группу задач на векторный вычислитель, такой как многоядерная видеокарта (правда, при этом в оформлении программы могут возникнуть некоторые особенности — например, может потребоваться явно отметить, какие из блоков программы предназначены только для векторного вычислителя, какие — только дя ЦПУ, какие — и для ЦПУ и для векторного
устройства).

Уже такой базовый подход дает, как минимум:

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

Чтобы не затруднять понимание, сразу приведу пару примеров.

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

reenterable[ARR_SIZE] TreeSum(TreeNode * Cur, reduction(+) DataItem & 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;
}

Волновой алгоритм Ли: поиск пути в лабиринте.

const int NL = 10; /* Размер лабиринта */
const unsigned char W = 0xFF; /* Стенка */
/* Лабиринт */
unsigned char Labirint[NL][NL] =
{
 {W,W,W,W,W,W,W,W,W,W},
 {W,0,0,0,0,0,0,0,0,W},
 {W,0,W,W,0,0,W,0,0,W},
 {W,0,0,W,0,0,W,0,0,W},
 {W,0,0,W,W,0,W,0,0,W},
 {W,0,0,0,W,W,W,0,0,W},
 {W,0,0,0,0,0,0,0,0,W},
 {W,0,0,0,0,0,0,0,0,W},
 {W,0,0,0,0,0,0,0,0,W},
 {W,W,W,W,W,W,W,W,W,W},
};
/* Приращения для сдвига относительно текущей клетки влево, вверх, вниз, вправо */
signed char OffsX[4] = {-1,0,0,+1};
signed char OffsY[4] = {0,-1,+1,0};

const char FirstX = 8; /* Точка отправления */
const char FirstY = 8;
const char LastX  = 5; /* Точка назначения */
const char LastY  = 4;

chain[NL*NL] FindLi(unsigned char X, unsigned char Y, int Num) throw(unsigned char X, unsigned char Y)
{
 char Found = 0;

 for (int i=0; !Found && i<4; i++) { /* Просматриваем клетки рядом */
   unsigned char X1 = X+OffsX[i];
   unsigned char Y1 = Y+OffsY[i];
   if (X1>=0 && X1<NL && Y1>=0 && Y1<NL && Labirint[Y1][X1]==0) {
      /* Если клетка еще не пронумерована */
      Labirint[Y1][X1] = Num; /* Нумеруем */
      if (X1==LastX && Y1==LastY) /* Если последняя */
         Found = 1; /* Сигнализируем окончание */
      else /* Если не последняя */
         plan_last(X1,Y1,Num+1); /* Помещаем в очередь */
   }
 }

 if (Found) {
    clear_plan; /* Очищаем план просмотра клеток */
    X = LastX; Y = LastY;
    throw_last(X,Y); /* Помещаем в "стек" точку назначения (последнюю) */
    while (X!=FirstX || Y!=FirstY) { /* Пока не дошли до точки отправления */
      char PointFound = 0; /* Поиск следующей клетки пути */
      for (int i=0; !PointFound && i<4; i++) {
        unsigned char X1 = X+OffsX[i];
        unsigned char Y1 = Y+OffsY[i]; /* Кандидат на следующую клетку пути */
        if (X1>=0 && X1<NL && Y1>=0 && Y1<NL && Labirint[Y1][X1] &&
            Labirint[Y1][X1]<Labirint[Y][X]) {
            /* Если клетка не пуста и имеет меньший номер */
            /* У клеток стенок самые большие номера, в рассмотрение не попадают */
           PointFound = 1;
           throw_last(X1,Y1); /* Добавляем в путь (стек) найденную клетку */
           X = X1; Y = Y1; /* На следующем шаге будем исходить из этой клетки */
        }
      }
    }
 } else if (plan_empty)
    cout<<"NO PATHn"; /* Не дошли до места назначения */
}

chain[NL*2] OutLi(unsigned char X, unsigned char Y) {
  cout<<"("<<(int)Y<<","<<(int)X<<") ";
}

int main() {
 cout<<"Find the path in the simple labirint (Li algorithm) :n";
 Labirint[FirstY][FirstX] = 1;
 plan_chain(0,FindLi(FirstX,FirstY,2),OutLi(0,0)); /* Конвейер из двух процедур */
 cout<<"n";

 return 0;
}

И еще один абстрактный пример работы с многоядерной видеокартой, который приведу без пояснений. Возможно, кому-нибудь будет интересно догадаться, как он работает.

#pragma plan vectorized

#include <iostream>

using namespace std;

#pragma plan common begin

#define N 5
#define threads 100

#pragma plan common end

#pragma plan gpu begin
#define addition 0.01
#pragma plan gpu end

float MUL = 3.14;

float * _OUT = NULL;

reenterable void proc(bool init, int k, _global(1) float * mul, _global(threads) int * sizes, int n, _local(__planned__.n) float * out) {
  int i;
  if (init) {
     for (i = 0; i < threads; i++) {
         plan_last(false, i, mul, sizes, sizes[i], out);
         out += sizes[i];
     }
     plan_group_vectorize(NULL);
  } else
     for (i = 0; i < n; i++) {
         *out = k*(*mul);
#ifdef __GPU__
         *out += addition;
#endif
         out++;
     }
}

int main() {
  int * sizes = new int[threads];

  int NN = 0;
  for (int i = 0; i < threads; i++) {
      sizes[i] = 1 + i % 2;
      NN += sizes[i];
  }

  _OUT = new float[NN];

  cout<<"MAX group size = "<<vector_max_size(NULL)<<endl;

  proc(true, 0, &MUL, sizes, 0, _OUT);

  for (int i = 0; i < NN; i++)
      cout<<_OUT[i]<<" ";
  cout<<endl;

  delete[] _OUT;
  delete[] sizes;

  return 0;
}

Теперь, отмечу, что если рассматривать ПППВ/ФППВ как некий элементарный узел вычислительной топологии (графа) и определить конструкции, позволяющие одной ПППВ/ФППВ пополнять план другой (соседней по графу) ПППВ/ФППВ, то можно дествительно работать с достаточно сложными вычислительными топологиями, причем как в случае общей, так и в случае раздельной памяти (например, на кластере — там передача элементов плана по графу будет выполняться с помощью простых операций передачи по сети). Операции пополнения плана другой ПППВ/ФППВ называются throw_first(...) и throw_last(...). Их параметры определяются параметрами вызова соответствующих принимающих ПППВ/ФППВ. Если какая-либо ПППВ/ФППВ имеет только одного соседа-приемника в топологии (например, в конвейере), то параметры самые обычные. Если соседей-приемников несколько, то один из параметров делается специальным — адресным. Все одноименные (соответствующие одной ПППВ/ФППВ) узлы графа-топологии нумеруются, поэтому в адресном параметре указывается имя принимающей ПППВ/ФППВ за которым в квадратных скобках идет ее номер. Топологии пока предлагается описывать или статически (особыми конструкциями для вектора/конвейера или списками дуг для произвольного графа) или полустатически — когда список дуг генерируется специальными (порождающими код) вставками — макромодулями (могут быть, например, написаны по схожей с PHP идеологией — вставки генерируют фрагмент текста программы, можно использовать любой язык в зависимости от задач, хоть PHP, хоть GNU Prolog). Технически не исключена и возможность обычного динамического порождения топологии с помощью вызовов неких функций.

Для полноценной работы дополнительно всегда могут использоваться каналы/ленивые переменные, транзакционная память, барьеры, семафоры и т.п.

Приведу несколько примеров с различными топологиями.

Вычисление на конвейере минимального и максимального элементов дерева.

chain[ARR_SIZE] TreeMax(TreeNode * Cur, reduction(max) DataItem & MaxResult)
{
 static DataItem 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[ARR_SIZE] TreeMin(TreeNode * Cur, reduction(min) DataItem & MinResult)
{
 if (Cur==Root) plan_group_parallelize;
 MinResult = Cur->Data;
}

void TreeMinMax(DataItem & Min, DataItem & Max)
{
 Max.fill(0.0);
 Min.fill(1000.0);
 
 plan_parallel_chain(0,TreeMax(Root,Max),TreeMin(Root,Min));
}

Пример с топологией «иголка с ушком», может применяться для тестирования производительности конкретной реализации

#include <iostream>

using namespace std;

bool stop;

chain A(bool init) throw(bool init, int N) {
  stop = false;
  throw_first(false, 1);
  Sleep(2000);
  stop = true;
}

chain B(bool init, int N) throw(bool init, bool _stop, int N) {
  if (!init) {
     if (stop) throw_first(false, true, N);
     else throw_first(false, false, N+1);
  }
}

chain C(bool init, bool _stop, int N) throw(bool init, int N) {
  if (!init) {
     if (_stop) {
        cout<<N;
        plan_topology_quit(); /* Завершение работы топологии */
     } else throw_first(false, N+1);
  }
}

int main() {
  plan_topology { /* Начало описания топологии */
    plan_parallel_chain(A(true)->B(true,0)->C(true,false,0)); /* Прямые связи топологии */
    plan_parallel_reverse(C(true,false,0)->B(true,0)); /* Одна обратная связь */
  }/3;

  return 0;
}   

Все, что описано выше, реализовано в качестве расширения C++ (Planning C). Есть простой демонстрационный транслятор, реализующий, помимо вышеизложенного, еще некоторые интересные вещи.

Автор: Vladimir Pekunov

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js