Реализация функционала на стороне основной программы

Реализация функционала на стороне основной программы также потребует от нас некоторых знаний в организации процесса и трудозатрат. Начнем с подготовительной работы. Прежде всего в нашем файле дефайнов добавим загрузку написанной выше OpenCL-программы в качестве ресурса и присвоим его содержимое переменной типа string. Тут же добавим к программе заготовленные макроподстановки типа данных и размера локального массива.

#resource "opencl_program.cl" as string OCLprogram
//---
#define TYPE                         float
#define LOCAL_SIZE                   256
const string ExtType = StringFormat("#define TYPE %s\r\n"
                                    "#define TYPE4 %s4\r\n"
                                    "#define LOCAL_SIZE %d\r\n",
                                     typename(TYPE),typename(TYPE),LOCAL_SIZE);
#define cl_program                   ExtType+OCLprogram

При объявлении кернелов в основной программе функция CLKernelCreate возвращает хендл для его вызова. Для работы с технологией OpenCL мы будем использовать класс CMyOpenCL, который является наследником от стандартного класса COpenCL. В указанных классах реализованы массивы для хранения хендлов. Доступ к конкретному кернелу осуществляется по индексу в массиве. Чтобы облегчить работу с этими индексами и сделать код программы более читаемым, добавим в константы индексы для всех созданных выше кернелов. Для явного выделения в коде программы индекса кернела будем начинать все именованные константы кернелов с def_k.

//+------------------------------------------------------------------+
//| OpenCL Kernels                                                   |
//+------------------------------------------------------------------+
#define def_k_PerceptronFeedForward    0
#define def_k_LineActivation           1
#define def_k_SigmoidActivation        2
#define def_k_SigmoidDerivative        3
#define def_k_TANHActivation           4
#define def_k_TANHDerivative           5
#define def_k_LReLuActivation          6
#define def_k_LReLuDerivative          7
#define def_k_SoftMAXActivation        8
#define def_k_SoftMAXDerivative        9
#define def_k_SwishActivation          10
#define def_k_SwishDerivative          11
#define def_k_CalcOutputGradient       12
#define def_k_CalcHiddenGradient       13
#define def_k_CalcDeltaWeights         14
#define def_k_SGDUpdate                15
#define def_k_MomentumUpdate           16
#define def_k_AdaGradUpdate            17
#define def_k_RMSPropUpdate            18
#define def_k_AdaDeltaUpdate           19
#define def_k_AdamUpdate               20

Для указания параметров при вызове кернелов также используются индексы. Только теперь они не задаются явно — вместо этого используется порядковый номер в списке параметров кернела программы OpenCL. Все кернелы используют свой набор параметров, поэтому определим именованные константы для всех созданных кернелов. Чтобы не путаться между идентичными параметрами различных кернелов, в наименование константы включим указатель на соответствующий кернел. К примеру, константы параметров для кернела прямого прохода базового полносвязного слоя будут начинаться с def_pff.

//--- прямой проход перцептрона
#define def_pff_inputs                 0
#define def_pff_weights                1
#define def_pff_outputs                2
#define def_pff_inputs_total           3

Аналогичным образом объявим константы для всех написанных кернелов.

//--- определение градиента ошибки слоя результатов
#define def_outgr_target               0
#define def_outgr_outputs              1
#define def_outgr_gradients            2
#define def_outgr_loss_function        3

//--- определение градиента ошибки скрытого слоя
#define def_hidgr_gradient_inputs      0
#define def_hidgr_weights              1
#define def_hidgr_gradients            2
#define def_hidgr_outputs_total        3

//--- определение градиента ошибки на уровне матрицы весов
#define def_delt_inputs                0
#define def_delt_delta_weights         1
#define def_delt_gradients             2

//--- оптимизация параметров стохастическим градиентным спуском
#define def_sgd_delta_weights          0
#define def_sgd_weights                1
#define def_sgd_total                  2
#define def_sgd_batch_size             3
#define def_sgd_learningRate           4
#define def_sgd_Lambda1                5
#define def_sgd_Lambda2                6

//--- оптимизация параметров методом моментов
#define def_moment_delta_weights       0
#define def_moment_weights             1
#define def_moment_momentum            2
#define def_moment_total               3
#define def_moment_batch_size          4
#define def_moment_learningRate        5
#define def_moment_beta                6
#define def_moment_Lambda1             7
#define def_moment_Lambda2             8

//--- оптимизация параметров методом AdaGrad
#define def_adagrad_delta_weights      0
#define def_adagrad_weights            1
#define def_adagrad_momentum           2
#define def_adagrad_total              3
#define def_adagrad_batch_size         4
#define def_adagrad_learningRate       5
#define def_adagrad_Lambda1            6
#define def_adagrad_Lambda2            7

//--- оптимизация параметров методом RMSProp
#define def_rms_delta_weights          0
#define def_rms_weights                1
#define def_rms_momentum               2
#define def_rms_total                  3
#define def_rms_batch_size             4
#define def_rms_learningRate           5
#define def_rms_beta                   6
#define def_rms_Lambda1                7
#define def_rms_Lambda2                8

//--- оптимизация параметров методом AdaDelta
#define def_adadelt_delta_weights      0
#define def_adadelt_weights            1
#define def_adadelt_momentumW          2
#define def_adadelt_momentumG          3
#define def_adadelt_total              4
#define def_adadelt_batch_size         5
#define def_adadelt_beta1              6
#define def_adadelt_beta2              7
#define def_adadelt_Lambda1            8
#define def_adadelt_Lambda2            9

//--- оптимизация параметров методом Adam
#define def_adam_delta_weights         0
#define def_adam_weights               1
#define def_adam_momentumM             2
#define def_adam_momentumV             3
#define def_adam_total                 4
#define def_adam_batch_size            5
#define def_adam_learningRate          6
#define def_adam_beta1                 7
#define def_adam_beta2                 8
#define def_adam_Lambda1               9
#define def_adam_Lambda2               10

//--- функции активации
#define def_activ_inputs               0
#define def_activ_outputs              1
#define def_activ_param_a              2
#define def_activ_param_b              3

//--- корректировка градиента на производную функции активации
#define def_deactgr_outputs            0
#define def_deactgr_gradients          1
#define def_deactgr_deact_gradient     2
#define def_deactgr_act_param_a        3
#define def_deactgr_act_param_b        4

Я специально выше представил полный набор, чтобы предоставить вам справочник констант. Он поможет в чтении и понимании кода наших последующих действий по внедрению технологии OpenCL в проект.

После описания констант перейдем к созданию классов, на которые будем возложена работа по обслуживанию инструментов OpenCL. Мы уже не раз о них упоминали. Настало времени больше узнать об их функционале.

Прежде всего, это класс CMyOpenCL. Он является наследником от класса COpenCL из стандартных библиотек MQL5. Стандартная библиотека написана хорошо и имеет достаточный функционал для организации работы. Но лично мне показался неудобным один момент: в процессе работы с буферами для обмена данными между основной программой и контекстом OpenCL используется подход аналогичный с другими объектами процесса — при создании буфера мы должны указать его индекс в общем массиве буферов. Это вполне рабочий вариант, когда у нас заранее известны все буферы и их количество. Но наш случай немного сложнее.

class CMyOpenCL   :  public COpenCL
  {
public:
                     CMyOpenCL(void)   {};
                    ~CMyOpenCL(void)   {};
   //--- initialization and shutdown
   virtual bool      Initialize(const string programconst bool show_log = true);
   //---
   template<typename T>
   int               AddBufferFromArray(T &data[], const uint data_array_offset,
                                   const uint data_array_countconst uint flags);
   int               AddBufferFromArray(MATRIX &data,
                                  const uint data_array_offsetconst uint flags);
   int               AddBuffer(const uint size_in_bytesconst uint flags);
   bool              CheckBuffer(const int index);
   //---
   bool              BufferFromMatrix(const int buffer_indexMATRIX &data,
                                  const uint data_array_offsetconst uint flags);
   bool              BufferRead(const int buffer_indexMATRIX &data,
                                                     const uint cl_buffer_offset);
   bool              BufferWrite(const int buffer_indexMATRIX &data,
                                                     const uint cl_buffer_offset);
  };

Ранее мы уже обсуждали, что в зависимости от выбранного метода обновления весовых коэффициентов у нас меняется количество используемых буферов для накопления моментов. Вдобавок к вышесказанному, мы не можем знать заранее, какое количество нейронных слоев будет использовать пользователь для решения своих задач. Поэтому мне потребовался динамический массив для хранения хендлов буферов данных. Эта задача была решена путем добавления небольшого метода AddBufferFromArray. Параметры данного метода аналогичны параметрам метода BufferFromArray родительского класса за исключением индекса буфера. В теле метода организован цикл по поиску пустых ячеек в массиве хранения хендлов буферов. Первая незанятая ячейка используется для создания буфера. В том случае когда в массиве нет свободных элементов, метод расширяет массив. Непосредственно создание буфера осуществляется вызовом указанного выше метода родительского класса.

В результат выполнения операций метод возвращает индекс созданного буфера. В случае возникновения ошибок в процессе выполнения операций метод вернет константу INVALID_HANDLE.

Хочется обратить внимание еще на тот момент, что метод создан по паттерну шаблона функции. Это позволяет использовать один метод для создания буферов разных типов данных.

template<typename T>
int CMyOpenCL::AddBufferFromArray(T &data[], const uint data_array_offset,
                                  const uint data_array_countconst uint flags
                                 )
  {
   int result=INVALID_HANDLE;
   for(int i=0i<m_buffers_totali++)
     {
      if(m_buffers[i]!=INVALID_HANDLE)
         continue;
      result=i;
      break;
     }
//---
   if(result<0)
     {
      if(ArrayResize(m_buffers,m_buffers_total+1)>0)
        {
         m_buffers_total=ArraySize(m_buffers);
         result=m_buffers_total-1;
         m_buffers[result]=INVALID_HANDLE;
        }
      else
         return result;
     }
//---
   if(!BufferFromArray(result,data,data_array_offset,data_array_count,flags))
      return INVALID_HANDLE;
//---
   return result;
  }

Созданный выше метод позволяет создавать буферы из массивов любых типов данных, однако он не применим при использовании матриц. Поэтому была создана перегрузка метода. Алгоритм метода остался без изменений.

int CMyOpenCL::AddBufferFromArray(MATRIX &data,
                                  const uint data_array_offset,
                                  const uint flags
                                 )
  {
//--- Поиск свободного элемента в динамическом массиве указателей
   int result = -1;
   for(int i = 0i < m_buffers_totali++)
     {
      if(m_buffers[i] != INVALID_HANDLE)
         continue;
      result = i;
      break;
     }
//--- Если свободный элемент не найден, добавляем новый элемент в массив
   if(result < 0)
     {
      if(ArrayResize(m_buffersm_buffers_total + 1) > 0)
        {
         m_buffers_total = ArraySize(m_buffers);
         result = m_buffers_total - 1;

         m_buffers[result] = INVALID_HANDLE;
        }
      else
         return result;
     }
//--- Создаем буфер в контексте OpenCL
   if(!BufferFromMatrix(resultdatadata_array_offsetflags))
      return -1;
   return result;
  }

Забегая немного вперед хочу сказать, что мы не всегда будем создавать буферы по готовым массивам. Иногда нам потребуется просто создать буфер в контексте OpenCL без создания его дубликата в основной памяти. Или, к примеру, определенный буфер используется только для получения результатов, и нам нет необходимости загружать его данные в контекст до выполнения операций. А мы уже говорили, что процесс копирования данных является дорогой операцией. И мы бы хотели минимизировать такие операции. Поэтому нам было бы проще просто создать буфер данных в контексте определенного размера без копирования данных. Для таких случаев мы создадим метод AddBuffer. Как можно заметить, алгоритм метода практически полностью повторяет методы предыдущего метода. Только в параметрах метод получает не массив, а размер буфера в байтах. В конце метода мы вызываем метод BufferCreate, который создаст буфер заданного размера в контексте OpenCL.

int CMyOpenCL::AddBuffer(const uint size_in_bytesconst uint flags)
  {
//--- Поиск свободного элемента в динамическом массиве указателей
   int result = -1;
   for(int i = 0i < m_buffers_totali++)
     {
      if(m_buffers[i] != INVALID_HANDLE)
         continue;
      result = i;
      break;
     }
//--- Если свободный элемент не найден, добавляем новый элемент в массив
   if(result < 0)
     {
      if(ArrayResize(m_buffersm_buffers_total + 1) > 0)
        {
         m_buffers_total = ArraySize(m_buffers);
         result = m_buffers_total - 1;
         m_buffers[result] = INVALID_HANDLE;
        }

      else
         return result;
     }
//--- Создаем буфер в контексте OpenCL
   if(!BufferCreate(resultsize_in_bytesflags))
      return -1;
   return result;
  }

Также мы создали методы для чтения (BufferRead) и записи (BufferWrite) данных буфера контекста OpenCL в матрицу основной памяти. Алгоритм методов полностью идентичен. Для примера рассмотрим только метод чтения данных. В параметрах метод получает идентификатор буфера в динамическом массиве нашего класса, матрицу для записи данных и смещение в буфере контекста.

Прошу не путать идентификатор буфера в динамическом массиве класса и хендл буфера в контексте OpenCL. Работа класса построена таким образом, что внешней программе мы передаем только порядковый номер элемента в динамическом массиве нашего класса, который содержит хендл этого буфера. Поэтому при создании буфера в контексте с использованием класса внешняя программа не имеет прямого доступа к созданному буферу в контексте. Вся работа с буфером должна осуществляться через методы класса.

В теле метода мы сначала проверяем полученный идентификатор буфера на соответствие размеру нашего динамического массива. Затем мы проверяем действительность хендла указанного буфера. Кроме того, проверим действительность хендлов контекста и программы OpenCL. Только после успешного прохождения всех контролей вызываем функцию чтения данных из буфера. Не забываем на каждом шаге проверять результат выполнения операций. В завершение метода вернем логический результат выполнения операций.

bool CMyOpenCL::BufferRead(const int buffer_indexMATRIX &data,
                                     const uint cl_buffer_offset)
  {
//--- проверка параметров
   if(buffer_index < 0 || buffer_index >= m_buffers_total || data.Rows() <= 0)
      return(false);
   if(m_buffers[buffer_index] == INVALID_HANDLE)
      return(false);
   if(m_context == INVALID_HANDLE || m_program == INVALID_HANDLE)
      return(false);
//--- чтение данных буфера из контекста OpenCL
   if(!CLBufferRead(m_buffers[buffer_index], cl_buffer_offsetdata))
      return(false);
//---
   return(true);
  }

Второй класс, который мы создадим и будем использовать для передачи данных между основной программой и контекстом OpenCL, — класс буфера данных CBufferType. Класс создан наследником базового класса CObject. Так как родительский класс является базовым, то нам необходимо воссоздать весь необходимый функционал.

Помимо создания новых методов в новом классе появились две новые переменные:

  • m_cOpenCL — указатель на объект класса CMyOpenCL
  • m_myIndex — индекс текущего буфера в динамическом массиве хранения хендлов буферов в классе CMyOpenCL.

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

class CBufferTypepublic CObject
  {
protected:
   CMyOpenCL*        m_cOpenCL;     // объект контекста OpenCL
   int               m_myIndex;     // индекс буфера данных в контексте
public:
                     CBufferType(void);
                    ~CBufferType(void);
   //--- матрица данных
   MATRIX            m_mMatrix;
   //--- метод инициализации буфера начальными значениями
   virtual bool      BufferInit(const ulong rowsconst ulong columns,
                                                          const TYPE value = 0);
   //--- создание нового буфера в контексте OpenCL
   virtual bool      BufferCreate(CMyOpenCL *opencl);
   //--- удаление буфера в контексте OpenCL
   virtual bool      BufferFree(void);
   //--- чтение данных буфера из контекста OpenCL
   virtual bool      BufferRead(void);
   //--- запись данных буфера в контекст OpenCL
   virtual bool      BufferWrite(void);
   //--- получение индекса буфера
   virtual int       GetIndex(void);
   //--- изменение индекса буфера
   virtual bool      SetIndex(int index)
                       {
                        if(!m_cOpenCL.BufferFree(m_myIndex))
                           return false;
                        m_myIndex = index;
                        return true;
                       }
   //--- копирование данных буфера в массив
   virtual int       GetData(TYPE &values[], bool load = true);
   virtual int       GetData(MATRIX &valuesbool load = true);
   virtual int       GetData(CBufferTypevaluesbool load = true);
   //--- расчет среднего значения буфера данных
   virtual TYPE      MathMean(void);
   //--- операции с векторами
   virtual bool      SumArray(CBufferTypesrc);
   virtual int       Scaling(TYPE value);
   virtual bool      Split(CBufferTypetarget1CBufferTypetarget2,
                                                            const int position);
   virtual bool      Concatenate(CBufferTypetarget1CBufferTypetarget2,
                                    const int positions1const int positions2);
   //--- методы работы с файлами
   virtual bool      Save(const int file_handle);
   virtual bool      Load(const int file_handle);
   //--- идентификатор класса
   virtual int       Type(void)              const { return defBuffer;              }
   //--- методы работы с матрицей данных
   ulong             Rows(void)              const { return m_mMatrix.Rows();       }
   ulong             Cols(void)              const { return m_mMatrix.Cols();       }
   uint              Total(void)             const { return (uint)(m_mMatrix.Rows() * 
                                                                 m_mMatrix.Cols()); }
   TYPE              At(uint index)          const { return m_mMatrix.Flat(index);  }
   TYPE              operator[](ulong indexconst { return m_mMatrix.Flat(index);  }
   VECTOR            Row(ulong row)                { return m_mMatrix.Row(row);     }
   VECTOR            Col(ulong col)                { return m_mMatrix.Col(col);     }
   bool              Row(VECTORvec,  ulong row)  { return m_mMatrix.Row(vecrow);}
   bool              Col(VECTORvec,  ulong col)  { return m_mMatrix.Col(veccol);}
   bool              Activation(MATRIXmat_outENUM_ACTIVATION_FUNCTION func)
                                      { return m_mMatrix.Activation(mat_outfunc); }
   bool              Derivative(MATRIXmat_outENUM_ACTIVATION_FUNCTION func)
                                      { return m_mMatrix.Derivative(mat_outfunc); }
   bool              Reshape(ulong rowsulong cols)
                                      { return m_mMatrix.Reshape(rowscols);       }
//---
   bool              Update(uint indexTYPE value)
                       {
                        if(index >= Total())
                           return false;
                        m_mMatrix.Flat(indexvalue);
                        return true;
                       }

   bool              Update(uint rowuint colTYPE value)
                       {
                        if(row >= Rows() || col >= Cols())
                           return false;
                        m_mMatrix[rowcol] = value;
                        return true;
                       }
  };

Структура методов класса довольно разнообразна. Некоторые из них аналогичны матричным функциям и выполняют тот же функционал — созданы для работы с матрицей данных. Другие выполняют функционал взаимодействия с контекстом OpenCL. Рассмотрим некоторые из них подробнее.

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

CBufferType::CBufferType(void)  : m_myIndex(-1)
  {
   m_cOpenCL = NULL;
  }

В деструкторе класса мы выполним операции очистки памяти. Здесь мы очистим буфер в контексте OpenCL.

CBufferType::~CBufferType(void)
  {
   if(m_cOpenCL && m_myIndex >= 0 && m_cOpenCL.BufferFree(m_myIndex))
        {
         m_myIndex = -1;
         m_cOpenCL = NULL;
        }
  }

Метод инициализации буфера BufferInit мы уже использовали в конструкторе класса нейронного слоя. Основной функционал этого метода — создать матрицу заданного размера и заполнить его начальными значениями. Размер буфера и начальные значения указываются в параметрах метода. В рамках данного проекта мы будем заполнять массивы нулевыми значениями на этапе инициализации нейронной сети, а также обнулять буферы накопленных дельт после обновления матрицы весов.

bool CBufferType::BufferInit(ulong rowsulong columnsTYPE value)
  {
   if(rows <= 0 || columns <= 0)
      return false;
   m_mMatrix = MATRIX::Full(rowscolumnsvalue);
   if(m_cOpenCL)
     {
      CMyOpenCL *opencl=m_cOpenCL;
      BufferFree();
      return BufferCreate(opencl);
     }
//---
   return true;
  }

Следующим идет метод создания буфера в контексте OpenCL. В параметрах методу передается указатель на экземпляр класса CMyOpenCL, в контексте которого необходимо создать буфер.

Начинается метод с блока контролей. Сначала проверим действительность полученного указателя — в случае получения недействительного указателя удаляем буфер, ранее созданный в контексте OpenCL, и выходим из метода.

bool CBufferType::BufferCreate(CMyOpenCL *opencl)
  {
//--- блок проверки исходных данных
   if(!opencl)
     {
      BufferFree();
      return false;
     }

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

//--- если полученный указатель совпадает с ранее сохраненным,
//--- просто копируем содержимое буфера в память контекста
   if(opencl == m_cOpenCL && m_myIndex >= 0)
      return BufferWrite();

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

//--- проверяем наличие ранее сохраненного указателя на контекст OpenCL
//--- при наличии удаляем буфер из неиспользуемого контекста
   if(m_cOpenCL && m_myIndex >= 0)
     {
      if(m_cOpenCL.BufferFree(m_myIndex))
        {
         m_myIndex = -1;
         m_cOpenCL = NULL;
        }
      else
         return false;
     }

В заключение метода мы инициируем создание нового буфера данных в указанном контексте. Для этого вызовем выше рассмотренный метод AddBufferFromArray. Полученный в ответ на вызов индекс сохраним в переменной m_myIndex. Если операция открытия буфера была успешной, то перед выходом сохраним полученный на вход метода указатель экземпляра класса CMyOpenCL.

//--- создаем новый буфер в указанном контексте OpenCL
   if((m_myIndex = opencl.AddBufferFromArray(m_mMatrix0CL_MEM_READ_WRITE)) < 0)
      return false;
   m_cOpenCL = opencl;
//---
   return true;
  }

В рассмотренном методе мы использовали два новых метода: один для очистки буфера, второй для записи данных. За очистку буфера отвечает метод BufferFree. Алгоритм метода довольно прост. Вначале мы проверяем наличие сохраненных указателя на экземпляр класса CMyOpenCL и индекса в динамическом массиве буферов. При их наличии вызываем метод очистки буфера класса CMyOpenCL с указанием индекса буфера для удаления. При успешном удалении буфера из контекста очищаем указатель на экземпляр класса CMyOpenCL и переменную индекса буфера.

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

bool CBufferType::BufferFree(void)
  {
//--- проверяем наличие ранее сохраненного указателя на контекст OpenCL
//--- при наличии, удаляем буфер из неиспользуемого контекста
   if(m_cOpenCL && m_myIndex >= 0)
      if(m_cOpenCL.BufferFree(m_myIndex))
        {
         m_myIndex = -1;
         m_cOpenCL = NULL;
         return true;
        }
   if(m_myIndex >= 0)
      m_myIndex = -1;
//---
   return false;
  }

Далее предлагаю рассмотреть методы для передачи информации между основной программой и контекстом OpenCL. Эта работа выполняется в двух похожих методах — BufferRead и BufferWrite. Несмотря на разнонаправленность операций алгоритм методов идентичен. В начале методов организован контрольный блок, в котором проверяется действительность указателя на экземпляр класса CMyOpenCL и наличие индекса в динамическом массиве буферов. И только после успешного прохождения контрольного блока вызывается одноименный метод класса работы с контекстом OpenCL с указанием индекса буфера, матрицы и смещения в буфере OpenCL.

bool CBufferType::BufferRead(void)
  {
   if(!m_cOpenCL || m_myIndex < 0)
      return false;
//---
   return m_cOpenCL.BufferRead(m_myIndexm_mMatrix0);
  }

bool CBufferType::BufferWrite(void)
  {
   if(!m_cOpenCL || m_myIndex < 0)
      return false;
//---
   return m_cOpenCL.BufferWrite(m_myIndexm_mMatrix0);
  }

Отдельно созданы методы, позволяющие получить и напрямую указать индекс буфера в динамическом массиве хранения хендлов буферов GetIndex и SetIndex. Их код настолько прост, что я даже не стал их выносить за пределы блока объявления класса.

Мы добавили в класс три одноименных метода GetData. Все они выполняют один функционал — копирование данных матрицы в заданную структуру. Различие — в приемнике данных. Это может быть динамический массив, матрица или другой экземпляр класса CBufferType.

В первом случае в параметрах метода передается ссылка на массив и флаг, указывающий на необходимость считывания данных из контекста OpenCL перед копированием данных. Введение флага вынужденная мера. Как вы могли заметить при рассмотрении метода чтения данных из контекста, при отсутствии указателя на объект CMyOpenCL или индекса в динамическом массиве буферов метод вернет значение false. Это заблокирует получения данных из массива без созданного в контексте OpenCL буфера. Введение флага позволяет управлять этим процессов.

В начале метода мы проверяем флаг и при необходимости считываем данные из контекста. Только потом изменяем размер массива-приемника и создаем цикл копирования данных. В завершение метод возвращает количество скопированных элементов.

int CBufferType::GetData(TYPE &values[], bool load = true)
  {
   if(load && !BufferRead())
      return -1;
   if(ArraySize(values) != Total() &&
      ArrayResize(valuesTotal()) <= 0)
      return false;
//---
   for(uint i = 0i < Total(); i++)
      values[i] = m_mMatrix.Flat(i);
   return (int)Total();
  }

Два других метода построены по аналогичному алгоритму, но с учетом специфики объекта-приемника.

int CBufferType::GetData(MATRIX &valuesbool load = true)
  {
   if(load && !BufferRead())
      return -1;
//---
   values = m_mMatrix;
   return (int)Total();
  }

int CBufferType::GetData(CBufferType *valuesbool load = true)
  {
   if(!values)
      return -1;
   if(load && !BufferRead())
      return -1;
   values.m_mMatrix.Copy(m_mMatrix);
   return (int)values.Total();
  }

Теперь, когда мы подготовили константы и классы работы с контекстом OpenCL, мы можем продолжить работу по организации процесса непосредственно в классах нашей нейронной сети.

При создании методов нашего базового класса нейронной сети мы не дописали два метода, UseOpenCL и InitOpenCL. Как видно из названия методов, они предназначены для инициализации и управления процессом работы с технологией OpenCL. Первый служит для переключения режима работы, включает и выключает использование OpenCL. Второй инициализирует работу экземпляра класса CMyOpenCL.

Сделаем небольшой шаг назад и заполним эти пробелы. В параметрах методу UseOpenCL укажем новое состояние в виде логического значения. Использование логического значения для передачи бинарного состояния включения/выключения функции мне кажется интуитивно понятным. Вполне логично использование значения true для включения функционала и false для его выключения.

В теле метода организуем разветвление алгоритма в зависимости от устанавливаемого состояния. При поступлении команды на отключение функционала проверим текущий указатель на экземпляр класса CMyOpenCL, который хранится в переменной m_cOpenCL. Если указатель недействительный, то функционал не был инициализирован ранее и нам нечего отключать. В таком случае просто обновим состояние флага использования технологии и выйдем из метода.

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

void CNet::UseOpenCL(bool value)
  {
   if(!value)
     {
      if(!m_cOpenCL)
        {
         m_bOpenCL = value;
         return;
        }
      m_cOpenCL.Shutdown();
      delete m_cOpenCL;
      if(!!m_cLayers)
         m_cLayers.SetOpencl(m_cOpenCL);
      m_bOpenCL = value;
      return;
     }

Дальнейшие операции будут выполняться только при включении функционала OpenCL. При поступлении сигнала на включение использования технологии OpenCL мы запускаем процесс создания и инициализации нового экземпляра класса CMyOpenCL, который вынесен в отдельный метод InitOpenCL.

Перед выходом из метода сохраняем новый флаг использования технологии OpenCL и распространяем указатель на новый объект по всем объектам нейронной сети. Для этого передадим новый указатель в объект динамического массива хранения слоев нейронной сети, а оттуда указатель по иерархической цепочке будет передан до каждого объекта нейронной сети.

//---
   if(!!m_cOpenCL)
     {
      m_cOpenCL.Shutdown();
      delete m_cOpenCL;
     }
   m_bOpenCL = InitOpenCL();
   if(!!m_cLayers)
      m_cLayers.SetOpencl(m_cOpenCL);
   return;
  }

Непосредственно процесс создания нового экземпляра класса CMyOpenCL и его инициализация вынесены в отдельный метод InitOpenCL.

В начале метода мы проверяем наличие ранее сохраненного указателя на объект класса CMyOpenCL. И в этот момент возникает вопрос, что мы хотим сделать далее при наличии ранее запущенного объекта. Мы можем продолжить использовать ранее инициализированный экземпляр класса, а можем создать новый. Использование уже существующего объекта на данном этапе кажется менее трудозатратно. Но в таком случае нам может потребоваться дополнительный метод для перезапуска функционала в случае возникновения ошибки какого-либо рода. Это дополнительные трудозатраты, которые, вероятно, потребуют разработку дополнительной системы контролей по коду всего проекта.

Мы выбрали вариант принудительного перезапуска. Поэтому при наличии действительного указателя на ранее созданный экземпляр класса CMyOpenCL мы запускаем процесс удаления из памяти его содержимого, а следом и самого объекта. И только после очистки памяти запускаем процесс создания и инициализации нового объекта. Процесс создания контекста и программы OpenCL скрыт в методе COpenCL::Initialize. В параметрах этому методу мы передадим текстовую переменную, содержащую нашу программу. Помните, мы записали в нее код нашей программы из файлового ресурса?

bool CNet::InitOpenCL(void)
  {
//--- Удаляем созданные ранее объекты OpenCL
   if(!!m_cOpenCL)
     {
      m_cOpenCL.Shutdown();
      delete m_cOpenCL;
     }
//--- Создаем новый объект для работы с OpenCL
   m_cOpenCL = new CMyOpenCL();
   if(!m_cOpenCL)
      return false;
//--- Инициализируем объект работы с OpenCL
   if(!m_cOpenCL.Initialize(cl_programtrue))
     {
      m_cOpenCL.Shutdown();
      delete m_cOpenCL;
      return false;
     }

Далее укажем количество используемых кернелов и буферов. Выше мы объявили константы для 20 кернелов, каждый кернел использует не более 4 буферов данных. Я намеренно не указываю большое количество буферов на данном этапе, так как благодаря нашему новому методу при необходимости массив будет автоматически расширен при создании нового буфера данных. Количество же кернелов в программе статично и не зависит от архитектуры нейронной сети.

   if(!m_cOpenCL.SetKernelsCount(20))
     {
      m_cOpenCL.Shutdown();
      delete m_cOpenCL;
      return false;
     }
   if(!m_cOpenCL.SetBuffersCount(4))
     {
      m_cOpenCL.Shutdown();
      delete m_cOpenCL;
      return false;
     }

После этого мы инициализируем все кернелы программы и сохраним хендлы для их вызова в массив внутри объекта класса CMyOpenCL.

Мы не создаем сейчас все используемые буферы данных по одной простой причине — их количество зависит от архитектуры нейронной сети и может оказаться больше объема доступной в контексте OpenCL памяти. В случае ее недостатка можно использовать динамическое распределение памяти. Это подразумевает загрузку буферов по мере их необходимости и последующую очистку памяти, когда не планируется использование конкретного буфера данных. Но такой подход ведет к увеличению накладных расходов по копированию данных между основной памятью и контекстом OpenCL. Поэтому его использование оправдано только при недостатке памяти GPU.

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

   if(!m_cOpenCL.KernelCreate(def_k_PerceptronFeedForward"PerceptronFeedForward"))
     {
      m_cOpenCL.Shutdown();
      delete m_cOpenCL;
      return false;
     }

   if(!m_cOpenCL.KernelCreate(def_k_CalcOutputGradient"CalcOutputGradient"))
     {
      m_cOpenCL.Shutdown();
      delete m_cOpenCL;
      return false;
     }

   if(!m_cOpenCL.KernelCreate(def_k_CalcHiddenGradient"CalcHiddenGradient"))
     {
      m_cOpenCL.Shutdown();
      delete m_cOpenCL;
      return false;
     }

   if(!m_cOpenCL.KernelCreate(def_k_CalcDeltaWeights"CalcDeltaWeights"))
     {
      m_cOpenCL.Shutdown();
      delete m_cOpenCL;
      return false;
     }

Вот мы и подошли к этапу организации работы с контекстом OpenCL непосредственно в классе нейронного слоя. Напомню, что при создании многих методов класса мы делали разветвление алгоритма метода в зависимости от устройства выполнения операций. Тогда мы создали код организации процесса средствами MQL5 и оставили пробелы в организации процесса на стороне OpenCL. Давайте вернемся и заполним эти пробелы.

Начнем с метода прямого прохода. Ранее мы уже разбирали организацию операций средствами MQL5. Сейчас посмотрим на реализацию работы с контекстом OpenCL.

bool CNeuronBase::FeedForward(CNeuronBase * prevLayer)
  {
//--- блок контролей
   if(!prevLayer || !m_cOutputs || !m_cWeights ||
      !prevLayer.GetOutputs() || !m_cActivation)
      return false;
   CBufferType *input_data = prevLayer.GetOutputs();
//--- разветвление алгоритма в зависимости от устройства выполнения операций
   if(!m_cOpenCL)
     {
      if(m_cWeights.Cols() != (input_data.Total() + 1))
         return false;
      //---
      MATRIX m = input_data.m_mMatrix;
      if(!m.Reshape(1input_data.Total() + 1))
         return false;
      m[0m.Cols() - 1] = 1;
      m_cOutputs.m_mMatrix = m.MatMul(m_cWeights.m_mMatrix.Transpose());
     }

Вначале мы проверим наличие индекса буфера у массива исходных данных, матрицы весов и буфера результатов. Здесь логика проста. Если в параметрах метода мы получили указатель на массив данных с уже существующим буфером, то считаем что данные уже загружены в контекст OpenCL. Выше, при создании буфера данных в классе CBufferType, мы сразу создавали и буфер в контексте OpenCL. Следовательно, отсутствие индекса буфера может свидетельствовать об ошибке. Поэтому в подобном случаем мы завершаем метод с результатом false. Если же вы используете динамическое распределение памяти, то в этом месте вам нужно будет создать копии всех используемых на в данном кернеле буферов данных и скопировать их содержимое буферов исходных данных в контекст OpenCL.

   else // Блок OpenCL
     {
      //--- проверка буферов данных
      if(input_data.GetIndex() < 0)
         return false;
      if(m_cWeights.GetIndex() < 0)
         return false;
      if(m_cOutputs.GetIndex() < 0)
         return false;

Затем укажем параметры для кернела прямого прохода. Здесь для буферов мы укажем их индексы, а для дискретных параметров — конкретные значения.

      //--- передача аргументов кернелу
      if(!m_cOpenCL.SetArgumentBuffer(def_k_PerceptronFeedForwarddef_pff_inputs,
                                                           input_data.GetIndex()))
         return false;

      if(!m_cOpenCL.SetArgumentBuffer(def_k_PerceptronFeedForwarddef_pff_weights,
                                                            m_cWeights.GetIndex()))
         return false;

      if(!m_cOpenCL.SetArgumentBuffer(def_k_PerceptronFeedForwarddef_pff_outputs,
                                                            m_cOutputs.GetIndex()))
         return false;

      if(!m_cOpenCL.SetArgument(def_k_PerceptronFeedForwarddef_pff_inputs_total,
                                                               input_data.Total()))
         return false;

В массиве NDRange укажем количество требуемых параллельных потоков по числу нейронов в текущем слое и запустим кернел на выполнение. Следует обратить внимание, что метод Execute не запускает выполнение кернела в буквальном смысле, а лишь осуществляет постановку в очередь для выполнения. Непосредственно запуск кернела осуществляется при попытке считать данные результатов его работы. Однако мы не будем загружать результат выполнения операций каждого кернела. Вместо этого мы выстроим очередь выполнения прямого прохода всей подели и загрузим результат только работы модели с последнего слоя. Это потянет всю очередь выполнения операций. Таким образом, мы снизим объем передаваемых данных и затраты времени на их загрузку.

В случае же использования динамического распределения памяти, после постановки кернела в очередь необходимо будет загрузить все изменения из контекста OpenCL в матрицы данных и удалить неиспользуемые буферы из контекста. Обратите внимание, что загружать нужно содержимое всех буферов, данные которых изменяются в процессе работы кернела.

      //--- постановка кернела в очередь выполнения
      uint off_set[] = {0};
      uint NDRange[] = {m_cOutputs.Total()};
      if(!m_cOpenCL.Execute(def_k_PerceptronFeedForward1off_setNDRange))
         return false;
     }
//---
   return m_cActivation.Activation(m_cOutputs);
  }

После выполнения вышеописанных операций мы вызываем метод активации нейронов необходимого класса функции активации и выходим из метода.

Также нужно дополнить код методов обратного прохода. В кернеле вычисления градиента на выходе нейронной сети используется три буфера: целевых значений, результатов последнего прямого прохода и для записи полученных градиентов. Их мы проверим в начале блока OpenCL.

bool CNeuronBase::CalcOutputGradient(CBufferTypetargetENUM_LOSS_FUNCTION loss)
  {
//--- блок контролей
   if(!target || !m_cOutputs || !m_cGradients ||
      target.Total() < m_cOutputs.Total() ||
      m_cGradients.Total() < m_cOutputs.Total())
      return false;

//--- разветвление алгоритма в зависимости от устройства выполнения операций
   if(!m_cOpenCL)
     {
      switch(loss)
        {
         case LOSS_MAE:
            m_cGradients.m_mMatrix = target.m_mMatrix - m_cOutputs.m_mMatrix;
            break;
         case LOSS_MSE:
            m_cGradients.m_mMatrix = (target.m_mMatrix - m_cOutputs.m_mMatrix) * 2;
            break;
         case LOSS_CCE:
            m_cGradients.m_mMatrix=target.m_mMatrix/(m_cOutputs.m_mMatrix+FLT_MIN)*
                                     log(m_cOutputs.m_mMatrix) * (-1);
            break;
         case LOSS_BCE:
            m_cGradients.m_mMatrix = (target.m_mMatrix-m_cOutputs.m_mMatrix)/
                                     (MathPow(m_cOutputs.m_mMatrix,2) -
                                      m_cOutputs.m_mMatrix+FLT_MIN);
            break;
         default:
            m_cGradients.m_mMatrix = target.m_mMatrix - m_cOutputs.m_mMatrix;
            break;
        }
     }

   else // Блок OpenCL
     {
      //--- проверка буферов данных
      if(target.GetIndex() < 0)
         return false;
      if(m_cOutputs.GetIndex() < 0)
         return false;
      if(m_cGradients.GetIndex() < 0)
         return false;

Далее укажем их индексы в параметрах нашего кернела. Также в параметрах кернела укажем используемую функцию потерь.

      //--- передача аргументов кернелу
      if(!m_cOpenCL.SetArgumentBuffer(def_k_CalcOutputGradientdef_outgr_target
                                                                target.GetIndex()))
         return false;
      if(!m_cOpenCL.SetArgumentBuffer(def_k_CalcOutputGradientdef_outgr_outputs,
                                                            m_cOutputs.GetIndex()))
         return false;
      if(!m_cOpenCL.SetArgumentBuffer(def_k_CalcOutputGradient,def_outgr_gradients,
                                                          m_cGradients.GetIndex()))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_CalcOutputGradientdef_outgr_loss_function,
                                                                        (int)loss))
         return false;

Количество запускаемых независимых потоков операций равно количеству нейронов на выходе нашей модели.

Запускаем выполнение кернела и завершаем метод.

      //--- постановка кернела в очередь выполнения
      uint NDRange[] = { m_cOutputs.Total() };
      uint off_set[] = {0};
      if(!m_cOpenCL.Execute(def_k_CalcOutputGradient1off_setNDRange))
         return false;
     }
//---
   return true;
  }

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

bool CNeuronBase::CalcHiddenGradient(CNeuronBase *prevLayer)
  {
//--- корректировка входящего градиента на производную функции активации
   if(!m_cActivation.Derivative(m_cGradients))
      return false;
//--- проверка буферов предыдущего слоя
   if(!prevLayer)
      return false;
   CBufferType *input_data = prevLayer.GetOutputs();
   CBufferType *input_gradient = prevLayer.GetGradients();
   if(!input_data || !input_gradient ||
      input_data.Total() != input_gradient.Total())
      return false;
//--- проверка соответствия размера буфера исходных данных и матрицы весов
   if(!m_cWeights || m_cWeights.Cols() != (input_data.Total() + 1))
      return false;
//--- разветвление алгоритма в зависимости от устройства выполнения операций
   if(!m_cOpenCL)
     {
      MATRIX grad = m_cGradients.m_mMatrix.MatMul(m_cWeights.m_mMatrix);
      grad.Reshape(input_data.Rows(), input_data.Cols());
      input_gradient.m_mMatrix = grad;
     }

В начале блока OpenCL мы, как и ранее, проверяем наличие ранее созданных буферов в контексте OpenCL для работы текущего кернела.

  else // Блок OpenCL
     {
      //--- проверка буферов данных
      if(m_cWeights.GetIndex() < 0)
         return false;
      if(input_gradient.GetIndex() < 0)
         return false;
      if(m_cGradients.GetIndex() < 0)
         return false;

После успешного прохождения контрольного блока передадим в кернел хендлы буферов и количество нейронов в слое.

      //--- передача аргументов кернелу
      if(!m_cOpenCL.SetArgumentBuffer(def_k_CalcHiddenGradient,
                             def_hidgr_gradient_inputsinput_gradient.GetIndex()))
         return false;
      if(!m_cOpenCL.SetArgumentBuffer(def_k_CalcHiddenGradientdef_hidgr_weights,
                                                             m_cWeights.GetIndex()))
         return false;
      if(!m_cOpenCL.SetArgumentBuffer(def_k_CalcHiddenGradient,def_hidgr_gradients
                                                          m_cGradients.GetIndex()))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_CalcHiddenGradientdef_hidgr_outputs_total,
                                                             m_cGradients.Total()))
         return false;

Количество потоков в данном случае будет равняться количеству нейронов в предыдущем слое. Их значение мы и запишем в первый элемент массива NDRange. Запустим выполнение операций кернела.

      //--- постановка кернела в очередь выполнения
      uint NDRange[] = {input_data.Total()};
      uint off_set[] = {0};
      if(!m_cOpenCL.Execute(def_k_CalcHiddenGradient1off_setNDRange))
         return false;
     }
//---
   return true;
  }

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

Накопление градиентов ошибки осуществляется в методе CalcDeltaWeights. Для выполнения операций кернела этого метода нам потребуются три буфера:

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

bool CNeuronBase::CalcDeltaWeights(CNeuronBase *prevLayerbool read);
  {
//--- блок контролей
   if(!prevLayer || !m_cDeltaWeights || !m_cGradients)
      return false;
   CBufferType *Inputs = prevLayer.GetOutputs();
   if(!Inputs)
      return false;
//--- разветвление алгоритма в зависимости от устройства выполнения операций
   if(!m_cOpenCL)
     {
      MATRIX m = Inputs.m_mMatrix;
      m.Resize(1Inputs.Total() + 1);
      m[0Inputs.Total()] = 1;
      m = m_cGradients.m_mMatrix.Transpose().MatMul(m);
      m_cDeltaWeights.m_mMatrix += m;
     }

Вначале уже по традиции проверяем наличие используемых буферов в контексте OpenCL.

   else // Блок OpenCL
     {
      //--- проверка буферов данных
      if(m_cGradients.GetIndex() < 0)
         return false;
      if(m_cDeltaWeights.GetIndex() < 0)
         return false;
      if(Inputs.GetIndex() < 0)
         return false;

Передаем указатели на них в параметры кернела.

      //--- передача аргументов кернелу
      if(!m_cOpenCL.SetArgumentBuffer(def_k_CalcDeltaWeights,
                              def_delt_delta_weightsm_cDeltaWeights.GetIndex()))
         return false;
      if(!m_cOpenCL.SetArgumentBuffer(def_k_CalcDeltaWeightsdef_delt_inputs,
                                                               Inputs.GetIndex()))
         return false;
      if(!m_cOpenCL.SetArgumentBuffer(def_k_CalcDeltaWeightsdef_delt_gradients,
                                                         m_cGradients.GetIndex()))
         return false;

В данном случае для запуска кернела мы будем использовать двухмерное пространство задач. В одном измерении укажем количество нейронов к текущем слое, а во втором — количество нейронов в предыдущем слое.

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

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

И конечно, не забываем на каждом шаге контролировать процесс выполнения операций.

      //--- постановка кернела в очередь выполнения
      uint NDRange[] = {m_cGradients.Total(), Inputs.Total()};
      uint off_set[] = {00};
      if(!m_cOpenCL.Execute(def_k_CalcDeltaWeights2off_setNDRange))
         return false;
      if(read && !m_cDeltaWeights.BufferRead())
         return false;
     }
//---
   return true;
  }

Мы успешно двигаемся вперед в процессе создания нашего проекта. Для завершения работы над полносвязным нейроном нам остается описать подпроцесс обновления матрицы весов. Но в своем проекте мы решили реализовать несколько алгоритмом обновления весовых коэффициентов. Для каждого алгоритма обновления матрицы весов мы создали свой кернел. Добавим вызовы этих кернелов в соответствующие методы нашего класса.

Начнем мы с метода стохастического градиентного спуска. Реализация данного метода требует лишь двух буферов: накопленных дельт и матрицы весов. Наличие данных буферов мы проверяем в контексте OpenCL.

bool CNeuronBase::SGDUpdate(int batch_sizeTYPE learningRateVECTOR &Lambda)
  {
//--- разветвление алгоритма в зависимости от устройства выполнения операций
   if(!m_cOpenCL)
     {
      TYPE lr = learningRate / ((TYPE)batch_size);
      m_cWeights.m_mMatrix -= m_cWeights.m_mMatrix * Lambda[1] + Lambda[0];
      m_cWeights.m_mMatrix += m_cDeltaWeights.m_mMatrix * lr;
      m_cDeltaWeights.m_mMatrix.Fill(0);
     }
   else // Блок OpenCL
     {
      //--- проверка буферов данных
      if(m_cWeights.GetIndex() < 0)
         return false;
      if(m_cDeltaWeights.GetIndex() < 0)
         return false;

Затем передадим в параметры кернела указатели на них. Кроме того, нам нужно передать в кернел параметры обучения:

  • размер пакета — batch_size
  • коэффициент обучения — learningRate
  • параметры регуляризации — вектор Lambda

      //--- передача аргументов кернелу
      if(!m_cOpenCL.SetArgumentBuffer(def_k_SGDUpdatedef_sgd_delta_weights,
                                                     m_cDeltaWeights.GetIndex()))
         return false;
      if(!m_cOpenCL.SetArgumentBuffer(def_k_SGDUpdatedef_sgd_weights,
                                                          m_cWeights.GetIndex()))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_SGDUpdatedef_sgd_total,
                                                        (int)m_cWeights.Total()))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_SGDUpdatedef_sgd_batch_sizebatch_size))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_SGDUpdatedef_sgd_learningRate,
                                                                   learningRate))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_SGDUpdatedef_sgd_Lambda1Lambda[0]))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_SGDUpdatedef_sgd_Lambda2Lambda[1]))
         return false;

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

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

      //--- постановка кернела в очередь выполнения
      int NDRange[] = { (int)((m_cWeights.Total() + 3) / 4) };
      int off_set[] = {0};
      if(!m_cOpenCL.Execute(def_k_SGDUpdate1off_setNDRange))
         return false;
     }
   return true;
  }

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

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

bool CNeuronBase::MomentumUpdate(int batch_sizeTYPE learningRate,
                                 VECTOR &BetaVECTOR &Lambda)
  {
   if(Beta[0] == 0)
      return SGDUpdate(batch_sizelearningRateLambda);
//--- блок контролей
   if(!m_cMomenum[0])
      return false;
   if(m_cMomenum[0].Total() < m_cWeights.Total())
      return false;
//--- разветвление алгоритма в зависимости от устройства выполнения операций
   if(!m_cOpenCL)
     {
      TYPE lr = learningRate / ((TYPE)batch_size);
      m_cWeights.m_mMatrix -= m_cWeights.m_mMatrix * Lambda[1] + Lambda[0];
      m_cMomenum[0].m_mMatrix = m_cDeltaWeights.m_mMatrix * lr + 
                                        m_cMomenum[0].m_mMatrix * Beta[0] ;
      m_cWeights.m_mMatrix += m_cMomenum[0].m_mMatrix;
      m_cDeltaWeights.m_mMatrix.Fill(0);
     }

   else // Блок OpenCL
     {
      //--- проверка буферов данных
      if(m_cWeights.GetIndex() < 0)
         return false;
      if(m_cDeltaWeights.GetIndex() < 0)
         return false;
      if(m_cMomenum[0].GetIndex() < 0)
         return false;

      //--- передача аргументов кернелу
      if(!m_cOpenCL.SetArgumentBuffer(def_k_MomentumUpdate,
                          def_moment_delta_weightsm_cDeltaWeights.GetIndex()))
         return false;
      if(!m_cOpenCL.SetArgumentBuffer(def_k_MomentumUpdatedef_moment_weights,
                                                         m_cWeights.GetIndex()))
         return false;
      if(!m_cOpenCL.SetArgumentBuffer(def_k_MomentumUpdate,
                                 def_moment_momentumm_cMomenum[0].GetIndex()))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_MomentumUpdatedef_moment_total,
                                                        (int)m_cWeights.Total()))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_MomentumUpdatedef_moment_batch_size,
                                                                    batch_size))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_MomentumUpdatedef_moment_learningRate,
                                                                  learningRate))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_MomentumUpdatedef_moment_Lambda1,
                                                                     Lambda[0]))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_MomentumUpdatedef_moment_Lambda2,
                                                                     Lambda[1]))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_MomentumUpdatedef_moment_betaBeta[0]))
         return false;

Задаем количество потоков в 4 раза меньше количества элементов в матрице весов и запускаем выполнение операций.

      //--- постановка кернела в очередь выполнения
      int NDRange[] = { (int)((m_cWeights.Total() + 3) / 4) };
      int off_set[] = {0};
      if(!m_cOpenCL.Execute(def_k_MomentumUpdate1off_setNDRange))
         return false;
     }
   return true;
  }

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

Перейдем к следующей реализации. Метод оптимизации AdaGrad реализован в методе AdaGradUpdate и соответствующем кернеле, который мы будем идентифицировать константой def_k_AdaGradUpdate. Чтобы исключить возможные ошибки при указании параметров, все константы параметров к данному кернелу начинаются с def_adagrad_. Как можно заметить, все наименования констант интуитивно понятны и имеют логическую связь. Это снижает риск возможной ошибки. Такой прием очень удобен при наличии большого количества констант.

Метод AdaGrad, как и метод накопительного импульса, использует буфер накопления моментов. Но в отличие от предыдущего метода здесь отсутствует коэффициент усреднения. На данном этапе нам не важны различия в использовании параметров и буферов. Нас интересует лишь их наличие — использование буферов и параметров уже описано в кернеле программы OpenCL, а здесь мы организовываем процесс передачи данных из основной программы в контекст OpenCL.

Алгоритм организации процесса роботы с контекстом OpenCL в методе AdaGradUpdate аналогичен примененному в ранее описанных методах.

  • Вначале проверяем наличие буферов в контексте OpenCL.
  • Затем в кернел передаем указатели на буферы и параметры оптимизации.
  • Запускаем выполнение кернела.

bool CNeuronBase::AdaGradUpdate(int batch_sizeTYPE learningRateVECTOR &Lambda)
  {
//--- блок контролей
   if(!m_cMomenum[0])
      return false;
   if(m_cMomenum[0].Total() < m_cWeights.Total())
      return false;
//--- разветвление алгоритма в зависимости от устройства выполнения операций
   if(!m_cOpenCL)
     {
      m_cWeights.m_mMatrix -= m_cWeights.m_mMatrix * Lambda[1] + Lambda[0];
      MATRIX delta = m_cDeltaWeights.m_mMatrix / ((TYPE)batch_size);
      MATRIX G = m_cMomenum[0].m_mMatrix = m_cMomenum[0].m_mMatrix + delta.Power(2);
      G = MathPow(MathSqrt(G) + 1e-32, -1);
      G = G * learningRate;
      m_cWeights.m_mMatrix += G * delta;
       m_cDeltaWeights.m_mMatrix.Fill(0);
    }

   else // Блок OpenCL
     {
      //--- проверка буферов данных
      if(m_cWeights.GetIndex() < 0)
         return false;
      if(m_cDeltaWeights.GetIndex() < 0)
         return false;
      if(m_cMomenum[0].GetIndex() < 0)
         return false;

      //--- передача аргументов кернелу
      if(!m_cOpenCL.SetArgumentBuffer(def_k_AdaGradUpdate,
                           def_adagrad_delta_weightsm_cDeltaWeights.GetIndex()))
         return false;
      if(!m_cOpenCL.SetArgumentBuffer(def_k_AdaGradUpdatedef_adagrad_weights,
                                                           m_cWeights.GetIndex()))
         return false;
      if(!m_cOpenCL.SetArgumentBuffer(def_k_AdaGradUpdatedef_adagrad_momentum,
                                                        m_cMomenum[0].GetIndex()))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_AdaGradUpdatedef_adagrad_total,
                                                          (int)m_cWeights.Total()))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_AdaGradUpdatedef_adagrad_batch_size,
                                                                      batch_size))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_AdaGradUpdatedef_adagrad_learningRate,
                                                                    learningRate))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_AdaGradUpdatedef_adagrad_Lambda1,
                                                                       Lambda[0]))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_AdaGradUpdatedef_adagrad_Lambda2,
                                                                       Lambda[1]))
         return false;

      //--- постановка кернела в очередь выполнения
      int NDRange[] = { (int)((m_cWeights.Total() + 3) / 4) };
      int off_set[] = {0};
      if(!m_cOpenCL.Execute(def_k_AdaGradUpdate1off_setNDRange))
         return false;
     }
   return true;
  }

Метод оптимизации RMSProp по своему функционалу напоминает AdaGrad, но в нем добавляется коэффициент усреднения накопленного момента.

Действуем по отработанной схеме. Проверяем наличие буферов контекста OpenCL. Затем передаем в кернел указатели на буферы и параметры оптимизации. При этом отслеживаем соблюдение имени метода и используемых констант:

  • метод RMSPropUpdate,
  • константа кернела def_k_RMSPropUpdate,
  • константы параметров def_rms_...

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

bool CNeuronBase::RMSPropUpdate(int batch_sizeTYPE learningRate,
                                VECTOR &BetaVECTOR &Lambda)
  {
//--- блок контролей
   if(!m_cMomenum[0])
      return false;
   if(m_cMomenum[0].Total() < m_cWeights.Total())
      return false;
//--- разветвление алгоритма в зависимости от устройства выполнения операций
   if(!m_cOpenCL)
     {
      TYPE lr = learningRate;
      m_cWeights.m_mMatrix -= m_cWeights.m_mMatrix * Lambda[1] + Lambda[0];
      MATRIX delta = m_cDeltaWeights.m_mMatrix / ((TYPE)batch_size);
      MATRIX G = m_cMomenum[0].m_mMatrix = m_cMomenum[0].m_mMatrix * Beta[0] +
                                                delta.Power(2) * (1 - Beta[0]);
      G = MathPow(MathSqrt(G) + 1e-32, -1);
      G = G * learningRate;
      m_cWeights.m_mMatrix += G * delta;
      m_cDeltaWeights.m_mMatrix.Fill(0);
     }

   else // Блок OpenCL
     {
      //--- проверка буферов данных
      if(m_cWeights.GetIndex() < 0)
         return false;
      if(m_cDeltaWeights.GetIndex() < 0)
         return false;
      if(m_cMomenum[0].GetIndex() < 0)
         return false;

      //--- передача аргументов кернелу
      if(!m_cOpenCL.SetArgumentBuffer(def_k_RMSPropUpdatedef_rms_delta_weights,
                                                      m_cDeltaWeights.GetIndex()))
         return false;
      if(!m_cOpenCL.SetArgumentBuffer(def_k_RMSPropUpdatedef_rms_weights,
                                                           m_cWeights.GetIndex()))
         return false;
      if(!m_cOpenCL.SetArgumentBuffer(def_k_RMSPropUpdatedef_rms_momentum,
                                                        m_cMomenum[0].GetIndex()))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_RMSPropUpdatedef_rms_total,
                                                          (int)m_cWeights.Total()))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_RMSPropUpdatedef_rms_batch_size,
                                                                      batch_size))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_RMSPropUpdatedef_rms_learningRate,
                                                                    learningRate))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_RMSPropUpdatedef_rms_Lambda1Lambda[0]))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_RMSPropUpdatedef_rms_Lambda2Lambda[1]))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_RMSPropUpdatedef_rms_betaBeta[0]))
         return false;

      //--- постановка кернела в очередь выполнения
      int NDRange[] = { (int)((m_cWeights.Total() + 3) / 4) };
      int off_set[] = {0};
      if(!m_cOpenCL.Execute(def_k_RMSPropUpdate1off_setNDRange))
         return false;
     }
//---
   return true;
  }

Разработчики методе AdaDelta отказались от использования обучающего коэффициента, но заплатили за это введение дополнительного буфера моментов дополнительным коэффициентом усреднения. Соответственно, в данном кернеле мы будем использовать на один буфер больше.

При задании параметров кернела, как и раньше, следим за соблюдением наименования констант:

  • метод AdaDeltaUpdate,
  • константа кернела def_k_AdaDeltaUpdate,
  • константы параметров def_adadelt...

Кроме того, для возможности безболезненного переноса построенной нейронной сети нам нужно проследить за соответствием использования буферов в части выполнения операций средствами MQL5 и в контексте OpenCL. При использовании в рамках одной платформы изменение последовательности использования массивов моментов не окажет влияния. Как бы мы их ни называли, их содержимое будет соответствовать контексту использования. Но при переносе предварительно обученной нейронной сети на другую платформу велика вероятность получить неожиданные результаты. При этом следует помнить о назначении и функционале массивов. Моменты используются только при обновлении матрицы весов в процессе обучения нейронной сети и не участвуют в прямом проходе. То есть влияние перепутанных буфером мы сможем увидеть только при попытке дообучить нейронную сеть. Но не стоит этим пренебрегать. При длительном использовании однажды построенной нейронной сети нам нужно будет периодически проводить ее дообучение. Это необходимо для поддержания актуальности весовых коэффициентов в нашем изменчивом мире.

Учтем вышесказанное и передадим в кернел указатели на загруженные буферы и параметры обучения.

Рассчитаем количество необходимых потоков и запустим кернел на выполнения.

bool CNeuronBase::AdaDeltaUpdate(int batch_sizeVECTOR &BetaVECTOR &Lambda)
  {
//--- блок контролей
   for(int i = 0i < 2i++)
     {
      if(!m_cMomenum[i])
         return false;
      if(m_cMomenum[i].Total() < m_cWeights.Total())
         return false;
     }
//--- разветвление алгоритма в зависимости от устройства выполнения операций
   if(!m_cOpenCL)
     {
      MATRIX delta = m_cDeltaWeights.m_mMatrix / ((TYPE)batch_size);
      MATRIX W = m_cMomenum[0].m_mMatrix = m_cMomenum[0].m_mMatrix * Beta[0] +
                                  m_cWeights.m_mMatrix.Power(2) * (1 - Beta[0]);
      m_cMomenum[1].m_mMatrix = m_cMomenum[1].m_mMatrix * Beta[1] + 
                                                 delta.Power(2) * (1 - Beta[1]);
      m_cWeights.m_mMatrix -= m_cWeights.m_mMatrix * Lambda[1] + Lambda[0];
      W = MathSqrt(W) / (MathSqrt(m_cMomenum[1].m_mMatrix) + 1e-32);
      m_cWeights.m_mMatrix += W * delta;
      m_cDeltaWeights.m_mMatrix.Fill(0);
     }

   else // Блок OpenCL
     {
      //--- создание буферов данных
      if(m_cWeights.GetIndex() < 0)
         return false;
      if(m_cDeltaWeights.GetIndex() < 0)
         return false;
      if(m_cMomenum[0].GetIndex() < 0)
         return false;
      if(m_cMomenum[1].GetIndex() < 0)
         return false;

      //--- передача аргументов кернелу
      if(!m_cOpenCL.SetArgumentBuffer(def_k_AdaDeltaUpdate,
                           def_adadelt_delta_weightsm_cDeltaWeights.GetIndex()))
         return false;
      if(!m_cOpenCL.SetArgumentBuffer(def_k_AdaDeltaUpdatedef_adadelt_weights,
                                                           m_cWeights.GetIndex()))
         return false;
      if(!m_cOpenCL.SetArgumentBuffer(def_k_AdaDeltaUpdatedef_adadelt_momentumW,
                                                        m_cMomenum[0].GetIndex()))
         return false;
      if(!m_cOpenCL.SetArgumentBuffer(def_k_AdaDeltaUpdatedef_adadelt_momentumG,
                                                        m_cMomenum[1].GetIndex()))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_AdaDeltaUpdatedef_adadelt_total,
                                                          (int)m_cWeights.Total()))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_AdaDeltaUpdatedef_adadelt_batch_size,
                                                                      batch_size))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_AdaDeltaUpdatedef_adadelt_Lambda1,
                                                                       Lambda[0]))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_AdaDeltaUpdatedef_adadelt_Lambda2,
                                                                       Lambda[1]))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_AdaDeltaUpdatedef_adadelt_beta1Beta[0]))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_AdaDeltaUpdatedef_adadelt_beta2Beta[1]))
         return false;

      //--- постановка кернела в очередь выполнения
      int NDRange[] = { (int)((m_cWeights.Total() + 3) / 4) };
      int off_set[] = {0};
      if(!m_cOpenCL.Execute(def_k_AdaDeltaUpdate1off_setNDRange))
         return false;
     }
//---
   return true;
  }

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

Повторим еще раз основные этапы нашего алгоритма и ключевые моменты контролей:

  • проверяем наличие необходимых данных в памяти контекста OpenCL;
  • передаем в кернел указатели на буферы данных и параметры обучения. При этом отслеживаем соответствие наименований Метод AdamUpdate а константа кернела def_k_AdamUpdate а константы параметров def_adam_...;
  • контролируем соответствие использования буферов средствами MQL5 и в контексте OpenCL;
  • запускаем выполнение кернела.

bool CNeuronBase::AdamUpdate(int batch_sizeTYPE learningRate,
                             VECTOR &BetaVECTOR &Lambda)
  {
//--- блок контролей
   for(int i = 0i < 2i++)
     {
      if(!m_cMomenum[i])
         return false;
      if(m_cMomenum[i].Total() != m_cWeights.Total())
         return false;
     }
//--- разветвление алгоритма в зависимости от устройства выполнения операций
   if(!m_cOpenCL)
     {
      MATRIX delta = m_cDeltaWeights.m_mMatrix / ((TYPE)batch_size);
      m_cMomenum[0].m_mMatrix = m_cMomenum[0].m_mMatrix * Beta[0] +
                                                      delta * (1 - Beta[0]);
      m_cMomenum[1].m_mMatrix = m_cMomenum[1].m_mMatrix * Beta[1] +
                                           MathPow(delta,2) * (1 - Beta[1]);
      MATRIX M = m_cMomenum[0].m_mMatrix / (1 - Beta[0]);
      MATRIX V = m_cMomenum[1].m_mMatrix / (1 - Beta[1]);
      m_cWeights.m_mMatrix -= m_cWeights.m_mMatrix * Lambda[1] + Lambda[0];
      m_cWeights.m_mMatrix += M * learningRate  / MathSqrt(V);
      m_cDeltaWeights.m_mMatrix.Fill(0);
     }

   else // Блок OpenCL
     {
      //--- проверка буферов данных
      if(m_cWeights.GetIndex() < 0)
         return false;
      if(m_cDeltaWeights.GetIndex() < 0)
         return false;
      if(m_cMomenum[0].GetIndex() < 0)
         return false;
      if(m_cMomenum[1].GetIndex() < 0)
         return false;

      //--- передача аргументов кернелу
      if(!m_cOpenCL.SetArgumentBuffer(def_k_AdamUpdatedef_adam_delta_weights,
                                                    m_cDeltaWeights.GetIndex()))
         return false;
      if(!m_cOpenCL.SetArgumentBuffer(def_k_AdamUpdatedef_adam_weights,
                                                         m_cWeights.GetIndex()))
         return false;
      if(!m_cOpenCL.SetArgumentBuffer(def_k_AdamUpdatedef_adam_momentumM,
                                                      m_cMomenum[0].GetIndex()))
         return false;
      if(!m_cOpenCL.SetArgumentBuffer(def_k_AdamUpdatedef_adam_momentumV,
                                                      m_cMomenum[1].GetIndex()))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_AdamUpdatedef_adam_total,
                                                       (int)m_cWeights.Total()))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_AdamUpdatedef_adam_batch_size,
                                                                    batch_size))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_AdamUpdatedef_adam_Lambda1Lambda[0]))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_AdamUpdatedef_adam_Lambda2Lambda[1]))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_AdamUpdatedef_adam_beta1Beta[0]))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_AdamUpdatedef_adam_beta2Beta[1]))
         return false;
      if(!m_cOpenCL.SetArgument(def_k_AdamUpdatedef_adam_learningRate,
                                                                  learningRate))
         return false;

      //--- постановка кернела в очередь выполнения
      int NDRange[] = { (int)((m_cWeights.Total() + 3) / 4) };
      int off_set[] = {0};
      if(!m_cOpenCL.Execute(def_k_AdamUpdate1off_setNDRange))
         return false;
     }
//---
   return true;
  }

Мы завершили описание процессов полносвязного нейронного слоя. Сейчас мы подошли к тому этапу, когда можно посмотреть на проделанную работу и оценить первые результаты. На самом деле уже созданных базовых классов достаточно, чтобы построить небольшую модель перцептрона из нескольких полносвязных слоев. Один из них будет выполнять роль приемника исходных данных (входной слой), последний нейронный слой будет выдавать результаты (выходной слой), а между ними будут скрытые слои.