Инженерная методика адаптации приложения к гибридному кластеру с ускорителями на ПЛИС

Часть 7. Оформление текста программы унифицированным образом.

С. С. Андреев, С. А. Дбар, А. О. Лацис, Е. А. Плоткина

Структурная переработка программы, необходимая для ее гибридно - параллельной реализации, в целом завершена. Осталось написать текст схемы сопроцессора и функций доступа к нему в форме, пригодной для конкретной системы программирования. Например, вместо условного, произвольным образом записанного действия "скопировать в сопроцессор фрагмент массива f", в программе должно появиться обращение к конкретной, реально существующей в некоторой реальной библиотеке, функции копирования, а вместо текста программной модели сопроцессора - текст, пригодный для синтеза, при помощи реальной схемотехнической САПР, битовой последовательности, пригодной для "прожига" реальной ПЛИС. Любопытно отметить, что, в рассуждениях многих потенциальных пользователей гибридно - параллельных суперкомпьютеров, разработка гибридно - параллельного приложения начинается именно с этой точки. Необходимость всей предшествующей работы, которую мы продемонстрировали выше, от самого начала настоящего документа до этих строк, то ли игнорируется, то ли считается чем-то мелким и несущественным.

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

В нашем случае мечте суждено сбыться. Для написания текста схемы мы используем язык C++, аннотированный специальными прагмами для лучшего выражения в тексте схемотехнических сущностей. Первая, очень медленная, но формально работоспособная, версия сопроцессора может быть получена из текста на "голом" C, безо всяких прагм. Вопрос о том, удастся ли, действуя методом последовательных приближений, добавляя прагмы и, возможно, меняя стиль программирования, получить эффективную версию сопроцессора, написанную на C++, остается открытым в каждом конкретном случае. Чтобы на него ответить, придется много экспериментировать, меняя текст описания схемы, находя и исправляя ошибки, появление которых неизбежно при такого рода изменениях.

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

Как и на прошлом этапе преобразования программы, текст функции main() останется неизменным. Текст функции itstep() структурно не изменится, но некоторые вызываемые функции станут называться иначе. Вот этот текст:

#include <stdio.h>
#include "dimension.h"
#include "comm.h"
#define PORTION 15
    extern int itstep_accel_test( int my_number, int n_of_nodes, int mx, int my, 
                                                             int stw, int portion );
    
    int itstep_test( int my_number, int n_of_nodes, int mx, int my, int stw )
{
    if ( n_of_nodes > 8 )
     {
      fprintf( stderr, 
                      "there are only 8 accels in the machine, but %d are requested\n", 
                      n_of_nodes );
      return 1;
     }
    init_coprocessor( my_number%8, my_number%8 );
    if ( stw > (PORTION/3) ) // See comments below
     {
      fprintf( stderr, 
      "stencil width(%d) greater than 1/3 of the portion size (%d), this is not allowed\n", 
                             stw, PORTION );
      return 1;
     }
    else
#ifdef FOR_CPU
     return itstep_accel_test( my_number, n_of_nodes, mx, my, stw, PORTION );
#else
     return 0;
#endif
}
//     extern void itstep_accel( int mx, int my, double rdx2, double rdy2, 
//                                                     double beta, int niter );
    void itstep( int mx, int my, void *pf, void *pr, double rdx2, double rdy2, 
                                 double beta, int niter )
{
    DIM2( double, f, my ) = (typeof(f))pf;
    DIM2( double, r, my ) = (typeof(r))pr;
    int k, total, is, notlast, left, retv;
    unsigned long params[6];
/***/
// Fill in accel parameters that never change:
    params[1] = my;
    params[2] = *((unsigned long*)(&rdx2));
    params[3] = *((unsigned long*)(&rdy2));
    params[4] = *((unsigned long*)(&beta));
    params[5] = niter;
    total = mx;
// "is" is the number of rows at the beginning not to save back (i. e. from accel to CPU):
    is = 0;
    while ( total > 0 )
     {
// "k" is the number of rows to save back:
      k = total;
// "notlast" is the number of rows at the end not to save back:
      notlast = 0;
      if ( k > PORTION )
       {
        k = PORTION;
        left = total - k;
// In the line below, we silently assume that PORTION is greater than niter:
        if ( left < niter ) k -= (niter-left);
        notlast = niter;
       }
// Copy all the portion of "r" to accel:        
      logic_put( 1, 0, r-is, (k+is+notlast)*my );
// Copy portion of "f" without the starting "is" rows to accel:
      logic_put( 0, is*my, f, (k+notlast)*my );  
// For the very first portion, "is" is surely zero, so processing 
// immediately after the copy is OK:
//       itstep_accel( k+is+notlast, my, rdx2, rdy2, beta, niter );
      params[0] = k+is+notlast;
      logic_put_block_reg( params, 6 );
      logic_init( 1 );
      logic_wait( 0, &retv );
// In case the portion is not last, we need to copy "is" starting rows 
// of the next portion to accel, to let them be "old", not "new":
      if ( notlast )
       {
// But, in case the portion is the very first, we cannot copy "is" starting rows 
// of the next portion, because the place for it 
// in accel is occupied by useful data not saved back to CPU yet, so 
// handle the case of "is == 0" separately:
        if ( is )
         {
// Do it the usual way:
          logic_put( 0, 0, f+(k-is), is*my );
          logic_get( 0, is*my, f, k*my ); 
         }
        else
         {
// Perform a trick:
          logic_get( 0, 0, f, niter*my );
          logic_put( 0, 0, f+(k-niter), niter*my );
          logic_get( 0, niter*my, f+niter, (k-niter)*my );
// The trick above is OK iff niter is NOT GREATER THAN a half of the PORTION!!!
         } 
       }
      else 
       {
// In case the portion is last, just save back:
        logic_get( 0, is*my, f, k*my ); 
       }
// Save "k" processed rows back to CPU:
      is = niter;
      f += k;
      r += k;
      total -= k;
     }
}

Посмотрим внимательно на текст функции itstep_test(). В данном, очень конкретном, случае, приложение предполагается запускать (командой mpirun) на единственном сервере, оснащенном восемью ПЛИС - сопроцессорами. Следуя упоминавшейся выше дисциплине "один MPI - процесс - один сопроцессор", мы обязаны проверить, что число запущенных MPI - процессов не превышает восьми. Далее следует обращение к функции init_coprocessor(). Это - функция общей инициализации системы доступа управляющей программы к сопроцессору. Подробное описание этой системы доступа можно найти в Руководстве программиста на языке Автокод. Аргументы функции init_coprocessor() - это диапазон (включительно) порядковых номеров сопроцессоров, которые данный процесс управляющей программы "берет на себя". Взяв в качестве аргументов при вызове этой функции не my_number, а my_number%8, мы учли то, что, возможно, когда-то в составе нашей установке появится более одного вычислительного узла. В этом случае надо было бы проконтролировать, что, при запуске приложения командой mpirun, было обеспечено размещение MPI - процессов в количестве именно восьми штук на узел, но, к сожалению, MPI не позволяет нам это сделать из программы.

Далее следует проверка корректности соотношения величин stw и PORTION, требования к которому мы подробно рассмотрели в предыдущем разделе. Функция itstep_accel_test() теперь приобрела еще один аргумент, необходимость в котором мы также обсуждали в предыдущем разделе. Обращение к этой функции компилируется условно, по ключу FOR_CPU. Этот ключ должен быть задан, если рассматриваемый нами текст компилируется в составе программной модели, а не реального гибридно - параллельного приложения. Дело в том, что сама функция itstep_accel_test() является, как мы увидим ниже, частью этой самой программной модели, и в сборке гибридно - параллельного варианта просто отсутствует.

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

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

В реально существующем сопроцессоре, реализованная в нем функция не вызывается по имени, поскольку она всегда одна. Вместо этого существует библиотечная функция доступа "запустить сопроцессор", обращение к которой, фактически, выполняет ту же роль, что и вызов реализованной в сопроцессоре функции. Функция "запустить сопроцессор" называется logic_init(), а функция "подождать, пока запущенный сопроцессор отработает", называется logic_wait(). Аргумент logic_init() в данном случае не используется, и его значение может быть задано любым. Первый аргумент logic_wait() задает время принудительного ожидания в секундах, по истечении которого считается, что сопроцессор отработал. Значение, равное нулю, означает "ждать, пока сопроцессор действительно отработает". Для реальной работы используется именно оно: ненулевые значения предназначены только для отладочных целей. Второй аргумент этой функции - возвращаемый, если первый аргумент задан равным нулю, то второй после возврата из функции всегда будет равен единице. Подробнее о функциях logic_init() и logic_wait() можно прочитать в Руководстве программиста на языке Автокод.

Функции, реализованной в сопроцессоре, можно передать входные скалярные аргументы и аргументы - массивы.

Начнем с порядка передачи входных скалярных аргументов. Все такие аргументы должны иметь одинаковый размер (в смысле sizeof()), причем вариантов такого размера три: 32, 64 или 128 разрядов. О выборе одного из этих трех вариантов будет сказано ниже, пока же примем на веру, что выбран 64 - разрядный вариант. При таком выборе, целое число должно быть представлено как [unsigned ]long, а вещественное - как double. Все скалярные аргументы следует сложить по порядку в служебный массив, состоящий из слов выбранной разрядности, и затем передать этот массив в сопроцессор обращением к функции logic_put_block_reg(). Первый аргумент этой функции - массив со значениями передаваемых параметров, второй - его длина в словах выбранной разрядности. Передача параметров действует однократно, только на один предстоящий запуск сопроцессора: перед каждым запуском сопроцессора обращением к logic_init() ее (передачу параметров) надо повторять. Передать можно только сразу все параметры, ожидаемые сопроцессором: нельзя, например, передать только первые два параметра из шести, надеясь, что остальные четыре не изменились с прошлого раза.

В приведенном выше тексте программы, служебный массив для передачи входных скалярных параметров называется params, имеет тип unsigned long. Значения всех передаваемых параметров, кроме первого (задающего число строк в блоке), по ходу работы программы не меняются,и записываются в служебный массив один раз, в начале работы программы:

// Fill in accel parameters that never change:
    params[1] = my;
    params[2] = *((unsigned long*)(&rdx2));
    params[3] = *((unsigned long*)(&rdy2));
    params[4] = *((unsigned long*)(&beta));
    params[5] = niter;

Сам массив, тем не менее, всякий раз передается перед запуском сопроцессора с указанием полной длины - 6 параметров:

      params[0] = k+is+notlast;
      logic_put_block_reg( params, 6 );
      logic_init( 1 );
      logic_wait( 0, &retv );

Передача в сопроцессор аргументов - массивов реализована практически так же, как мы это делали в предыдущем варианте программы. Отличие только в том, что массивы - параметры внутри сопроцессора нумеруются подряд от нуля, и при копировании данных между программой и сопроцессором указывается сопроцессорный номер массива, данные которого копируются. В нашем случае массив f имеет номер 0, массив r - номер 1. Для копирования массива из памяти программы в сопроцессор используется функция logic_put(), для копирования в обратном направлении - функция logic_get(). Аргументы у этих функций одинаковы:


- сопроцессорный номер массива;
- смещение от начала массива в памяти сопроцессора;
- указатель на массив в памяти программы;
- длина копируемых данных.

Смещение и длина измеряются в словах выбранной разрядности.

Подробнее о функциях logic_put() и logic_get() можно прочитать в Руководстве программиста на языке Автокод.

Мы завершили обзор программной части нашего гибридно - параллельного приложения. Остальная его часть предназначена для аппаратной реализации. При этом, по счастливому стечению обстоятельств, имеется возможность закодировать аппаратную часть на том же языке C, что и программную, но транслироваться она будет совсем другим транслятором, и превратится в итоге не в машинный код для процессора общего назначения, а прямо в схему сопроцессора. Понимая все это в общих чертах, мы не можем, тем не менее, избавиться от некоторого чувства недосказанности, которое с неизбежностью должно было появиться у всякого внимательного читателя еще при рассказе о том, как устроена передача параметров в сопроцессор при помощи стандартных коммуникационных функций. В самом деле, для того, чтобы это действительно работало так, как рассказано выше, должна существовать какая-то "механика" установления соответствия между номерами и видами параметров, передаваемых программой через функции "logic_", и параметрами в заголовке функции, текст которой написан на языке C, но транслируется в схему сопроцессора. Такая "механика", действительно, существует. Чтобы понять ее с наименьшей затратой сил, построим сначала обещанную выше, в начале настоящего раздела, программную модель сопроцессора, то есть такие варианты функций "logic_", которые ни к каким ПЛИС не обращаются, а лишь имитируют их работу программными средствами. Соответствующий текст приводится ниже:

#include <string.h>
#define MAXPARAMS 100
#include "dimension.h"
#define MAXMY 10000
#define LOCALMEM 50000
#include <stdio.h>
// The arrays below mimics the accelerator's local memory.
    static double localmem[2][LOCALMEM];
    static unsigned long localparams[MAXPARAMS];
    void init_coprocessor( int from, int to ) {}
    void set_coprocessor( int ncopr ) {}
    void logic_put_block_reg( unsigned long *params, int count )
{
    int i, n;
//
    n = count;
    if ( n > MAXPARAMS ) n = MAXPARAMS;
    for ( i = 0; i < n; i++ ) localparams[i] = params[i];
}     
    extern void itstep_accel( long mx, long my, double *pf, double *pr, 
                                     double rdx2, double rdy2, double beta, long niter );
    void logic_init( int n )
{
    itstep_accel( 
     (localparams[0]), 
     (localparams[1]), 
     localmem[0],
     localmem[1],
     *((double*)(localparams+2)), 
     *((double*)(localparams+3)),
     *((double*)(localparams+4)),
     (localparams[5]) 
        );
}
    void logic_wait( int tim, int *val ) 
     {
      if ( tim ) sleep( tim );
      *val = 1;
     }
    void logic_put( int narr, int internaloffset, void *array, int length )
{
    memcpy( localmem[narr]+internaloffset, array, length*sizeof(localmem[0][0]) );
}
    void logic_get( int narr, int internaloffset, void *array, int length )
{
    memcpy( array, localmem[narr]+internaloffset, length*sizeof(localmem[0][0]) );
}
    int itstep_accel_test( int my_number, int n_of_nodes, int mx, int my, 
                                                  int stw, int portion )
{
    if ( ((portion+2*stw)*my) > LOCALMEM )
     {
      fprintf( stderr, 
" ((portion(%d)+2*stw(%d))*my(%d))>localmem(%d), no memory for arrays in accel\n", 
                portion, stw, my, LOCALMEM );
      return 1;
     }
    if ( my > MAXMY )
     {
      fprintf( stderr, " my (%d) greater than maxmy (%d)\n", my, MAXMY );
      return 1; 
     }
    else return 0;
}

Текст этот исключительно прост, и может служить прекрасным дополнением к описанию того подмножества (и тех режимов использования) функций "logic_", которые предусмотрены в настоящем документе. Единственное, на что хотелось бы обратить внимание - так это на порядок установления соответствия между видами и номерами параметров, передаваемых через функции "logic_", с одной стороны, и аргументами, заданными при обращении к уже прекрасно известной нам функции itstep_accel() - с другой. Соответствие это задано автором приведенного только что текста, причем задано "волевым" образом. Например, в этом тексте написано, что в качестве аргумента - массива "f" в функцию itstep_accel() передается localmem[0], а в качестве массива "r" - localmem[1]. Именно поэтому мы в программе, запускающей сопроцессор, обязаны были полагать, что массив "f" при обращениях к logic_put()/logic_get() имеет номер 0, а массив "r" - номер 1, а не наоборот. Аналогично обстоит дело и со скалярными входными параметрами.

Рассмотренный программный текст является "действующей моделью" интерфейсной логики "настоящего", реализованного в ПЛИС, сопроцессора. При замене "действующей модели" настоящим сопроцессором, этот текст просто выбрасывается за ненадобностью, поскольку функции "logic_" берутся из реальной библиотеки доступа к реальному "железу" ПЛИС. Но библиотечные варианты функций "logic_" не могут ничего "знать" о том, что массив "f" имеет номер 0, а массив "r" - номер 1, как и вообще об именах, видах и количестве параметров, передаваемых программой в сопроцессор. Стало быть, в описании настоящего сопроцессора тоже должно быть что-то, устанавливающее соответствие между видами и номерами параметров, передаваемых через функции "logic_", с одной стороны, и аргументами, заданными при объявлении функции itstep_accel() - с другой. Но описание "настоящего" сопроцессора в нашем случае - это просто текст функции itstep_accel(), который сам по себе никаких соответствий ни с чем не устанавливает! Значит, это загадочное "что-то" может быть только дополнением к тексту на C, имеющим вид или прагмы, или комментария специального вида. Посмотрев на текст функции itstep_accel(), убедимся в том, что так оно и есть:

/*--vector_proc_64
void itstep_accel(
double pf,      : ram0,inout, wblock=1,size=50000
double pr,      : ram1,in, wblock=1,size=50000
long mx,        : reg0
long my,        : reg1
double rdx2,    : reg2
double rdy2,    : reg3
double beta,    : reg4
long niter      : reg5
)--*/

    void itstep_accel( long mx, long my, 
                                       double pf[LOCALMEM], double pr[LOCALMEM], 
                                       double rdx2, double rdy2, double beta, long niter )
{
//     DIM2( double, f, my ) = (typeof(f))pf;
//     DIM2( double, r, my ) = (typeof(r))pr;
    static double  lower[MAXMY];
    static double middle[MAXMY];
    int i, j, k, mx1, my1, n;
//
    mx1 = mx - 1;
    my1 = my - 1;
    for ( k = 0; k < niter; k++ )
     {
      n = 0;
      for ( i = 0; i < mx1; i++ )
       {
        for ( j = 0; j < my; j++ )
         {
          lower[j] = middle[j];
          middle[j] = pf[n+j];
         } 
        if ( i )
         {
     for ( j = 1; j < my1; j++ )
      {
                 pf[n+j] = ((lower[j]+pf[n+j+my])*rdx2+(middle[j-1]+middle[j+1])*rdy2
                                            - pr[n+j])*beta;
               }
          }
         n += my;
       }
     }
}

Комментарий, с которого начинается этот текст, имеет специальный вид, и является описанием интерфейсной части схемы сопроцессора. Он обрабатывается специальной программой - препроцессором, которая транслирует его в текст на VHDL. Затем работает транслятор основного текста функции на VHDL, который изготавливает внутреннюю, содержательную часть схемы сопроцессора. В момент работы этого, основного, транслятора, описание интерфейсной части схемы воспринимается, как комментарий, то есть игнорируется. Затем схемотехническая САПР объединяет все полученные тексты на VHDL вместе, и синтезирует схему сопроцессора в виде битовой последовательности, пригодной для прожига ПЛИС.

Теперь разберем приведенный текст описания схемы сопроцессора в деталях. Начнем с описания интерфейсной части схемы.

/*--vector_proc_64
void itstep_accel(
double pf,      : ram0,inout, wblock=1,size=50000
double pr,      : ram1,in, wblock=1,size=50000
long mx,        : reg0
long my,        : reg1
double rdx2,    : reg2
double rdy2,    : reg3
double beta,    : reg4
long niter      : reg5
)--*/

Слово vector_proc_64 является ключевым. Задает разрядность параметров, передаваемых из программы в сопроцессор. Допустимые варианты: vector_proc_32, vector_proc_64 или vector_proc_128. Далее следует текст, похожий на объявление заголовка функции, где про каждый параметр говорится, какой именно вид и номер (в терминах функций "logic_" этот параметр имеет. Например, параметр mx является входным скалярным, типа long, и передается через logic_put_block_reg() под номером 0. Параметр pr является входным (in) массивом (ram1) типа double, при копировании посредством logic_put()/logic_get() имеет номер 1, размер его (size) 50000 элементов, а векторность массива (wblock) равна 1. О том, что такое векторность сопроцессорного массива, можно прочитать в Руководстве программиста на языке Автокод, пока же будем считать этот параметр "заклинанием", значение которого - всегда 1. Остальные атрибуты массивов - параметров в комментариях не нуждаются. Обратим внимание на то, что размер массива задавать обязательно. В сопроцессор массив нельзя "передать по указателю", его (массив) можно только синтезировать в составе схемы, а для этого необходимо знать его размер.

Теперь посмотрим на текст функции itstep_accel(), описывающей внутреннюю логику сопроцессора. Обратим внимание на то, что аргументы - массивы в заголовке функции заданы с указанием длины, причем длина задана такая же, как в описании интерфейсной части схемы. Это всегда должно быть именно так. В остальном текст функции отличается от прошлого своего варианта тем, что массивы f и r теперь называются pf и pr, и адресуются как одномерные. Это сделано из опасения, что транслятор языка C в схему может не понимать объявления массивов с размерами, задаваемыми динамически, а также в надежде на синтез более эффективной схемы. Опасение, что транслятор не понимает объявления массивов с динамически задаваемыми размерами, впоследствии было проверено, и не подтведилось, так что использовать многомерные массивы, заданные описанным выше способом, в схеме можно.

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

◄ Часть 6 Часть 8 ►
 
 
 
 
 
 
 
 
  Тел. +7(499)220-79-72; E-mail: inform@kiam.ru