Нейросети — это просто (Часть 5): Многопоточные вычисления в OpenCL

18 сентября 2020, 14:00
Dmitriy Gizlyk
37
9 282

Содержание


Введение

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


1. Организация многопоточных вычисления в MQL5

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

Однако, выход есть. Разработчики MetaTrader 5 дали нам возможность использовать сторонние dll. Построение динамических библиотек по многопоточной архитектуре автоматически дает многопоточность выполнения операций, вынесенных в библиотеку. При этом, работа самого советника и обмен данными с библиотекой остаются в основном потоке советника.

Второй вариант, это использование технологии OpenCL. В таком варианте, мы, практически стандартными средствами, получаем возможность организации многопоточных вычислений как на поддерживаемом технологию процессоре, так и на видеокартах. При этом, код нашей программы не зависит от используемого устройства. Об использовании технологии OpenCL уже не раз писалось на данном сайте. В частности, довольно хорошо раскрыта тема в статьях [5] и [6].

Лично я остановился на использовании OpenCL. Во-первых, использование этой технологии не требует от пользователя дополнительных действий по настройке терминала, а именно — разрешение на использование сторонних dll. Во-вторых, такой советник можно переносить между терминалами одним EX5 файлом. И конечно, это позволяет перенести расчетную часть на видео карту, мощности которой зачастую простаивают при работе терминала.


2. Многопоточные вычисления в нейронных сетях

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

Полносвязный перцептрон

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

Аналогичный подход применен и при разделения потоков обратного прохода. Конкретная реализация приведена ниже.

3. Реализация могопоточных вычислений с помощью OpenCL

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

3.1. Кернел прямого прохода.

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

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

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

__kernel void FeedForward(__global double *matrix_w,
                              __global double *matrix_i,
                              __global double *matrix_o,
                              int inputs, int activation)

Вначале кернела получим порядковый номер потока, который для нас определит порядковый номер вычисляемого нейрона. Объявим частные (внутренние) переменные, в т. ч. векторные переменные inp и weight. А также определим смещение до весовых коэффициентов нашего нейрона.

  {
   int i=get_global_id(0);
   double sum=0.0;
   double4 inp, weight;
   int shift=(inputs+1)*i;

Далее организуем цикл для получения суммы произведений входящих значений с их весовыми коэффициентами. Как уже говорилось выше, для вычисления суммы произведений были применены вектора из 4-х элементов inp и weight. Но не всегда полученные кернелом массивы будут кратны 4, поэтому недостающие элементы заменим нулевыми значениями. Следует обратить внимание на одну "1" в векторе входных данных, она будет соответствовать весовому коэффициенту байесовского смещения.

   for(int k=0; k<=inputs; k=k+4)
     {
      switch(inputs-k)
        {
         case 0:
           inp=(double4)(1,0,0,0);
           weight=(double4)(matrix_w[shift+k],0,0,0);
           break;
         case 1:
           inp=(double4)(matrix_i[k],1,0,0);
           weight=(double4)(matrix_w[shift+k],matrix_w[shift+k+1],0,0);
           break;
         case 2:
           inp=(double4)(matrix_i[k],matrix_i[k+1],1,0);
           weight=(double4)(matrix_w[shift+k],matrix_w[shift+k+1],matrix_w[shift+k+2],0);
           break;
         case 3:
           inp=(double4)(matrix_i[k],matrix_i[k+1],matrix_i[k+2],1);
           weight=(double4)(matrix_w[shift+k],matrix_w[shift+k+1],matrix_w[shift+k+2],matrix_w[shift+k+3]);
           break;
         default:
           inp=(double4)(matrix_i[k],matrix_i[k+1],matrix_i[k+2],matrix_i[k+3]);
           weight=(double4)(matrix_w[shift+k],matrix_w[shift+k+1],matrix_w[shift+k+2],matrix_w[shift+k+3]);
           break;
        }
      sum+=dot(inp,weight);
     }

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

   switch(activation)
     {
      case 0:
        sum=tanh(sum);
        break;
      case 1:
        sum=pow((1+exp(-sum)),-1);
        break;
     }
   matrix_o[i]=sum;
  }

3.2. Кернелы обратного распространения градиента ошибки.

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

__kernel void CaclOutputGradient(__global double *matrix_t,
                                 __global double *matrix_o,
                                 __global double *matrix_ig,
                                 int activation)
  {
   int i=get_global_id(0);
   double temp=0;
   double out=matrix_o[i];
   switch(activation)
     {
      case 0:
        temp=clamp(matrix_t[i],-1.0,1.0)-out;
        temp=temp*(1+out)*(1-(out==1 ? 0.99 : out));
        break;
      case 1:
        temp=clamp(matrix_t[i],0.0,1.0)-out;
        temp=temp*(out==0 ? 0.01 : out)*(1-(out==1 ? 0.99 : out));
        break;
     }
   matrix_ig[i]=temp;
  }

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

__kernel void CaclHiddenGradient(__global double *matrix_w,
                              __global double *matrix_g,
                              __global double *matrix_o,
                              __global double *matrix_ig,
                              int outputs, int activation)
  {
   int i=get_global_id(0);
   double sum=0;
   double out=matrix_o[i];
   double4 grad, weight;
   int shift=(outputs+1)*i;
   for(int k=0;k<outputs;k+=4)
     {
      switch(outputs-k)
        {
         case 0:
           grad=(double4)(1,0,0,0);
           weight=(double4)(matrix_w[shift+k],0,0,0);
           break;
         case 1:
           grad=(double4)(matrix_g[k],1,0,0);
           weight=(double4)(matrix_w[shift+k],matrix_w[shift+k+1],0,0);
           break;
         case 2:
           grad=(double4)(matrix_g[k],matrix_g[k+1],1,0);
           weight=(double4)(matrix_w[shift+k],matrix_w[shift+k+1],matrix_w[shift+k+2],0);
           break;
         case 3:
           grad=(double4)(matrix_g[k],matrix_g[k+1],matrix_g[k+2],1);
           weight=(double4)(matrix_w[shift+k],matrix_w[shift+k+1],matrix_w[shift+k+2],matrix_w[shift+k+3]);
           break;
         default:
           grad=(double4)(matrix_g[k],matrix_g[k+1],matrix_g[k+2],matrix_g[k+3]);
           weight=(double4)(matrix_w[shift+k],matrix_w[shift+k+1],matrix_w[shift+k+2],matrix_w[shift+k+3]);
           break;
        }
      sum+=dot(grad,weight);
     }
   switch(activation)
     {
      case 0:
        sum=clamp(sum+out,-1.0,1.0);
        sum=(sum-out)*(1+out)*(1-(out==1 ? 0.99 : out));
        break;
      case 1:
        sum=clamp(sum+out,0.0,1.0);
        sum=(sum-out)*(out==0 ? 0.01 : out)*(1-(out==1 ? 0.99 : out));
        break;
     }
   matrix_ig[i]=sum;
  }

3.3. Обновление весовых коэффициентов.

Создадим еще один кернел для обновления весовых коэффициентов UpdateWeights. Процедура обновления каждого отдельного весового коэффициента никак не зависит от других весовых коэффициентов как внутри одного нейрона, так и других нейронов. Это позволяет нам отправить задания на параллельное вычисления одновременно всех весовых коэффициентов всех нейронов одного слоя. В таком варианте мы запускаем один кернел в двухмерном пространстве потоков: одно измерение указывает на порядковый номер нейрона, а второе измерение отвечает за номер связи внутри нейрона. Это и отображается в первых 2-х строчках кода кернела, где получает идентификаторы потока в двух измерениях.  

__kernel void UpdateWeights(__global double *matrix_w,
                                __global double *matrix_g,
                                __global double *matrix_i,
                                __global double *matrix_dw,
                                int inputs, double learning_rates, double momentum)
  {
   int i=get_global_id(0);
   int j=get_global_id(1);
   int wi=i*(inputs+1)+j; 
   double delta=learning_rates*matrix_g[i]*(j<inputs ? matrix_i[j] : 1) + momentum*matrix_dw[wi];
   matrix_dw[wi]=delta;
   matrix_w[wi]+=delta;
  };

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

Все кернелы вынесены в отдельный файл NeuroNet.cl, который подключим в качестве ресурса к основной программе.

#resource "NeuroNet.cl" as string cl_program

3.4. Создание классов основной программы.

После создания кернелов вернемся в MQL5 и займемся работой с кодом основной программы. Обмен данными между основной программой и кернелами осуществляется через буферы одномерных массивов (тема раскрыта в статье [5]). Для организации таких буферов на стороне основной программы создадим класс CBufferDouble. Данный класс содержит ссылку на объект класса работы с OpenCL и индекс буфера, который он получает при создании в OpenCL. 

class CBufferDouble     :  public CArrayDouble
  {
protected:
   COpenCLMy         *OpenCL;
   int               m_myIndex;           
public:
                     CBufferDouble(void);
                    ~CBufferDouble(void);
//---
   virtual bool      BufferInit(uint count, double value);
   virtual bool      BufferCreate(COpenCLMy *opencl);
   virtual bool      BufferFree(void);
   virtual bool      BufferRead(void);
   virtual bool      BufferWrite(void);
   virtual int       GetData(double &values[]);
   virtual int       GetData(CArrayDouble *values);
   virtual int       GetIndex(void)                        {  return m_myIndex;      }
//---
   virtual int       Type(void)                      const { return defBufferDouble; }
  };

Здесь нужно уточнить, что при создании буфера OpenCL возвращается его хендл, который хранится в массиве m_buffers класса COpenCL. В переменной m_myIndex мы храним только индекс в указанном массиве. Это связано с тем, что вся работа класса COpenCL построена именно через указания такого индекса, а не хендла буфера или кернела. Также следует добавить, что алгоритм работы класса COpenCL "из коробки" требует изначально указать количество используемых буферов и потом создавать буферы с указанием конкретного индекса. В нашем же случае мы будем динамически добавлять буферы при создании нейронных слоев. Поэтому потребовалось создание класса COpenCLMy наследником от COpenCL. Данный класс содержит только один дополнительный метод, поэтому позвольте не останавливаться на его описании в статье, а с его кодом можно ознакомиться во вложении.

Для непосредственной работы с буфером в классе CBufferDouble были созданы следующие методы:

  • BufferInit — инициализация массива буфера заданным значением;
  • BufferCreate  — создание буфера в OpenCL;
  • BufferFree  — удаление буфера в OpenCL;
  • BufferRead  — чтение данных из буфера OpenCL в массив;
  • BufferWrite  — запись данных из массива в буфер OpenCL;
  • GetData  — получение данных массива по запросу. Реализована в двух вариантах для возврата данных в массив и класс CArrayDouble;
  • GetIndex  — возвращает индекс буфера.

Архитектура всех методов не сложная и их код практически укладывается в 1-2 строки. С полным кодом всех методов можно ознакомиться во вложении.

3.5. Создание базового класса нейрона для работы с OpenCL.

А мы будем двигаться дальше и рассмотри класс CNeuronBaseOCL, который, наверное, включил в себя основную часть дополнений и алгоритма работы. Сложно назвать созданный объект нейроном, так как в нем заключена работа всего полносвязного нейронного слоя. Это же можно сказать и о рассмотренных ранее сверточных слоях и LSTM блоках. Но такой подход позволяет нам сохранить построенную ранее архитектуру нейронной сети.

Итак, класс CNeuronBaseOCL содержит указатель на объект класса COpenCLMy и четыре буфера: выходных значений, матрицы весовых коэффициентов, последних дельт весов и градиентов ошибки.

class CNeuronBaseOCL    :  public CObject
  {
protected:
   COpenCLMy         *OpenCL;
   CBufferDouble     *Output;
   CBufferDouble     *Weights;
   CBufferDouble     *DeltaWeights;
   CBufferDouble     *Gradient;

Также объявим коэффициент обучения и моментума, порядковый номер нейрона в слое и тип функции активации.

   const double      eta;
   const double      alpha;
//---
   int               m_myIndex;
   ENUM_ACTIVATION   activation;

В блок protected еще добавим три метода: прямого прохода, расчета градиента скрытого слоя и обновления матрицы весов.

   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL);
   virtual bool      calcHiddenGradients(CNeuronBaseOCL *NeuronOCL);
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL);

В блоке public объявим конструктор и деструктор класса, метод инициализации нейрона и метод для указания функции активации.

public:
                     CNeuronBaseOCL(void);
                    ~CNeuronBaseOCL(void);
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons);
   virtual void      SetActivationFunction(ENUM_ACTIVATION value) {  activation=value; }

Для внешнего доступа к информации с нейронов объявим методы для получения индексов буферов (будут использованы при вызове кернелов) и методы для получения текущей информации с буферов в виде массивов. Также добавим методы опроса количества нейронов и функции активации.

   virtual int       getOutputIndex(void)          {  return Output.GetIndex();        }
   virtual int       getGradientIndex(void)        {  return Gradient.GetIndex();      }
   virtual int       getWeightsIndex(void)         {  return Weights.GetIndex();       }
   virtual int       getDeltaWeightsIndex(void)    {  return DeltaWeights.GetIndex();  }
//---
   virtual int       getOutputVal(double &values[])   {  return Output.GetData(values);      }
   virtual int       getOutputVal(CArrayDouble *values)   {  return Output.GetData(values);  }
   virtual int       getGradient(double &values[])    {  return Gradient.GetData(values);    }
   virtual int       getWeights(double &values[])     {  return Weights.GetData(values);     }
   virtual int       Neurons(void)                    {  return Output.Total();              }
   virtual ENUM_ACTIVATION Activation(void)           {  return activation;                  }

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

   virtual bool      feedForward(CObject *SourceObject);
   virtual bool      calcHiddenGradients(CObject *TargetObject);
   virtual bool      calcOutputGradients(CArrayDouble *Target);
   virtual bool      updateInputWeights(CObject *SourceObject);
//---
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   //---
   virtual int       Type(void)        const                      {  return defNeuronBaseOCL;                  }
  };

Посмотрим на алгоритмы построения методов. В конструкторе и деструкторе класса все тривиально и я не вижу смысла подробно на этом останавливаться. С кодом этих методов можно познакомиться во вложении. Заглянем в метод инициализации класса. Этот метод в параметрах получает количество нейронов в последующем слое, порядковый номер нейрона, указатель на объект класса COpenCLMy и непосредственно количество нейронов для создания.

Обратите внимание, что метод в параметрах получает указатель на объект класса COpenCLMy, а не создает экземпляр объекта внутри класса. Это сделано для того, чтобы при работе советника использовался только один экземпляр объекта COpenCLMy. В одном объекте будут созданы все кернелы и буферы данных, что позволит нам не тратить время на передачу данных между слоями нейронной сети. Они получат прямой доступ к одним и те же буферам данных.

В начале метода проверим действительность указателя на объект класса COpenCLMy и убедимся, что требуется создать хотя бы один нейрон. Далее создадим экземпляры объектов буферов, проинициализируем массивы начальными значениями и создадим буфера в OpenCL. Посмотрите, размер буфера выходных значений Output равен количеству создаваемых нейронов, а размер буфера градиентов больше на 1 элемент. А размеры буферов матрицы весовых коэффициентов и их дельт равны произведению размера буфера градиентов на количество нейронов в последующем слое. И так как такое произведение будет равно "0" для выходного слоя, то в нем эти буфера не создаются.

bool CNeuronBaseOCL::Init(uint numOutputs,uint myIndex,COpenCLMy *open_cl,uint numNeurons)
  {
   if(CheckPointer(open_cl)==POINTER_INVALID || numNeurons<=0)
      return false;
   OpenCL=open_cl;
//---
   if(CheckPointer(Output)==POINTER_INVALID)
     {
      Output=new CBufferDouble();
      if(CheckPointer(Output)==POINTER_INVALID)
         return false;
     }
   if(!Output.BufferInit(numNeurons,1.0))
      return false;
   if(!Output.BufferCreate(OpenCL))
      return false;
//---
   if(CheckPointer(Gradient)==POINTER_INVALID)
     {
      Gradient=new CBufferDouble();
      if(CheckPointer(Gradient)==POINTER_INVALID)
         return false;
     }
   if(!Gradient.BufferInit(numNeurons+1,0.0))
      return false;
   if(!Gradient.BufferCreate(OpenCL))
      return false;
//---
   if(numOutputs>0)
     {
      if(CheckPointer(Weights)==POINTER_INVALID)
        {
         Weights=new CBufferDouble();
         if(CheckPointer(Weights)==POINTER_INVALID)
            return false;
        }
      int count=(int)((numNeurons+1)*numOutputs);
      if(!Weights.Reserve(count))
         return false;
      for(int i=0;i<count;i++)
        {
         double weigh=(MathRand()+1)/32768.0-0.5;
         if(weigh==0)
            weigh=0.001;
         if(!Weights.Add(weigh))
            return false;
        }
      if(!Weights.BufferCreate(OpenCL))
         return false;
   //---
      if(CheckPointer(DeltaWeights)==POINTER_INVALID)
        {
         DeltaWeights=new CBufferDouble();
         if(CheckPointer(DeltaWeights)==POINTER_INVALID)
            return false;
        }
      if(!DeltaWeights.BufferInit(count,0))
         return false;
      if(!DeltaWeights.BufferCreate(OpenCL))
         return false;
     }
//---
   return true;
  }

Диспетчерский метод feedForward построен по образу и подобию одноименного метода класса CNeuronBase. Пока здесь указан только один тип нейронов, но планируется добавление и других.

bool CNeuronBaseOCL::feedForward(CObject *SourceObject)
  {
   if(CheckPointer(SourceObject)==POINTER_INVALID)
      return false;
//---
   CNeuronBaseOCL *temp=NULL;
   switch(SourceObject.Type())
     {
      case defNeuronBaseOCL:
        temp=SourceObject;
        return feedForward(temp);
        break;
     }
//---
   return false;
  }

Непосредственно же вызов кернела OpenCL осуществляется в методе feedForward(CNeuronBaseOCL *NeuronOCL). Вначале метода проверим действительность указателя на объект класса COpenCLMy и полученного указателя на предыдущий слой нейронной сети.

bool CNeuronBaseOCL::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(CheckPointer(OpenCL)==POINTER_INVALID || CheckPointer(NeuronOCL)==POINTER_INVALID)
      return false;

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

   uint global_work_offset[1]={0};
   uint global_work_size[1];
   global_work_size[0]=Output.Total();

Далее зададим указатели на используемые буфера данных и аргументы для работы кернела.

   OpenCL.SetArgumentBuffer(def_k_FeedForward,def_k_ff_matrix_w,NeuronOCL.getWeightsIndex());
   OpenCL.SetArgumentBuffer(def_k_FeedForward,def_k_ff_matrix_i,NeuronOCL.getOutputIndex());
   OpenCL.SetArgumentBuffer(def_k_FeedForward,def_k_ff_matrix_o,Output.GetIndex());
   OpenCL.SetArgument(def_k_FeedForward,def_k_ff_inputs,NeuronOCL.Neurons());
   OpenCL.SetArgument(def_k_FeedForward,def_k_ff_activation,(int)activation);

После этого осуществим вызов кернела.

   if(!OpenCL.Execute(def_k_FeedForward,1,global_work_offset,global_work_size))
      return false;

И на этом я хотел было закончить, но при тестировании столкнулся с проблемой, что метод COpenCL::Execute не осуществляет запуск кернела, а только становит его в очередь. Само же выполнение происходит при попытке считать результаты работы кернела. Поэтому пришлось загружать результаты отработки в массив перед выходом из метода.

   Output.BufferRead();
//---
   return true;
  }

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

3.6. Точечные дополнения в классе CNet.

После создания всех необходимых классов внесем точечные корректировки класса основной нейронной сети CNet.

В конструкторе класса добавим создание и инициализацию экземпляра класса COpenCLMy. Параллельно не забываем удалить объект класса в деструкторе. 

   opencl=new COpenCLMy();
   if(CheckPointer(opencl)!=POINTER_INVALID && !opencl.Initialize(cl_program,true))
      delete opencl;

Также в конструкторе в блоке добавления нейронов в слоях добавим код по созданию и инициализации объектов, созданного нами класса CNeuronBaseOCL.

      if(CheckPointer(opencl)!=POINTER_INVALID)
        {
         CNeuronBaseOCL *neuron_ocl=NULL;
         switch(desc.type)
           {
            case defNeuron:
            case defNeuronBaseOCL:
              neuron_ocl=new CNeuronBaseOCL();
              if(CheckPointer(neuron_ocl)==POINTER_INVALID)
                {
                 delete temp;
                 return;
                }
              if(!neuron_ocl.Init(outputs,0,opencl,desc.count))
                {
                 delete temp;
                 return;
                }
              neuron_ocl.SetActivationFunction(desc.activation);
              if(!temp.Add(neuron_ocl))
                {
                 delete neuron_ocl;
                 delete temp;
                 return;
                }
              neuron_ocl=NULL;
              break;
            default:
              return;
              break;
           }
        }

И ниже, в конструкторе допишем создание кернелов в OpenCL.

   if(CheckPointer(opencl)==POINTER_INVALID)
      return;
//--- create kernels
   opencl.SetKernelsCount(4);
   opencl.KernelCreate(def_k_FeedForward,"FeedForward");
   opencl.KernelCreate(def_k_CaclOutputGradient,"CaclOutputGradient");
   opencl.KernelCreate(def_k_CaclHiddenGradient,"CaclHiddenGradient");
   opencl.KernelCreate(def_k_UpdateWeights,"UpdateWeights");

В методе CNet::feedForward добавим запись исходных данных в буфер

     {
      CNeuronBaseOCL *neuron_ocl=current.At(0);
      double array[];
      int total_data=inputVals.Total();
      if(ArrayResize(array,total_data)<0)
         return false;
      for(int d=0;d<total_data;d++)
         array[d]=inputVals.At(d);
      if(!opencl.BufferWrite(neuron_ocl.getOutputIndex(),array,0,0,total_data))
         return false;
     }

И вызов аналогичного метода нашего нового класса CNeuronBaseOCL.

   for(int l=1; l<layers.Total(); l++)
     {
      previous=current;
      current=layers.At(l);
      if(CheckPointer(current)==POINTER_INVALID)
         return false;
      //---
      if(CheckPointer(opencl)!=POINTER_INVALID)
        {
         CNeuronBaseOCL *current_ocl=current.At(0);
         if(!current_ocl.feedForward(previous.At(0)))
            return false;
         continue;
        }

Для процесса обратного распространения ошибки создадим новый метод CNet::backPropOCL. Его алгоритм аналогичен основному методу  CNet::backProp, описанному в первой статье.

void CNet::backPropOCL(CArrayDouble *targetVals)
  {
   if(CheckPointer(targetVals)==POINTER_INVALID || CheckPointer(layers)==POINTER_INVALID || CheckPointer(opencl)==POINTER_INVALID)
      return;
   CLayer *currentLayer=layers.At(layers.Total()-1);
   if(CheckPointer(currentLayer)==POINTER_INVALID)
      return;
//---
   double error=0.0;
   int total=targetVals.Total();
   double result[];
   CNeuronBaseOCL *neuron=currentLayer.At(0);
   if(neuron.getOutputVal(result)<total)
      return;
   for(int n=0; n<total && !IsStopped(); n++)
     {
      double target=targetVals.At(n);
      double delta=(target>1 ? 1 : target<-1 ? -1 : target)-result[n];
      error+=delta*delta;
     }
   error/= total;
   error = sqrt(error);
   recentAverageError+=(error-recentAverageError)/recentAverageSmoothingFactor;

   if(!neuron.calcOutputGradients(targetVals))
      return;;
//--- Calc Hidden Gradients
   CObject *temp=NULL;
   total=layers.Total();
   for(int layerNum=total-2; layerNum>0; layerNum--)
     {
      CLayer *nextLayer=currentLayer;
      currentLayer=layers.At(layerNum);
      neuron=currentLayer.At(0);
      neuron.calcHiddenGradients(nextLayer.At(0));
     }
//---
   CLayer *prevLayer=layers.At(total-1);
   for(int layerNum=total-1; layerNum>0; layerNum--)
     {
      currentLayer=prevLayer;
      prevLayer=layers.At(layerNum-1);
      neuron=currentLayer.At(0);
      neuron.updateInputWeights(prevLayer.At(0));
     }
  }

Также незначительные изменения были внесены и в метод getResult.

   if(CheckPointer(opencl)!=POINTER_INVALID && output.At(0).Type()==defNeuronBaseOCL)
     {
      CNeuronBaseOCL *temp=output.At(0);
      temp.getOutputVal(resultVals);
      return;
     }

С полным кодом всех методов и функций можно ознакомиться во вложении.

4. Тестирование

Тестирование работы созданного нами класса осуществлялось в тех же условиях, что и все предыдущее тестирования. Для тестирования был создан советник Fractal_OCL, полный аналог ранее созданного советника Fractal_2. Тестовое обучение нейронной сети проводилось по паре EURUSD на таймфрейме H1. На вход нейронной сети подавались данные за 20 свечей. Обучение проводилось за период в 2 последних года. Эксперимент проводился на CPU device 'Intel(R) Core(TM)2 Duo CPU T5750 @ 2.00GHz' с поддержкой OpenCL.

За 5 ч. 27 мин. тестирования советник с использованием технологии OpenCL провел 75 эпох обучения. Что в среднем составило 4 мин. 22 сек. на эпоху из 12 405 свечей. Аналогичный советник без использования технологии OpenCL на том же ноутбуке с той же архитектурой нейронной сети затрачивает в среднем 40 мин 48 сек на одну эпоху. На лицо ускорение процесса обучения в 9,35 раза.


Заключение

В этой статье мы продемонстрировали возможность использования технологии OpenCL для организации многопоточных вычислений в нейронных сетях. Тестирование технологии показало почти 10 кратный прирост производительности на одном и том же CPU. Ожидается, что использование GPU сможет еще повысить производительность алгоритма, при этом для переноса расчетов на совместимую GPU не требуется внесение изменений в код советника.

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


Ссылки

  1. Нейросети - это просто
  2. Нейросети - это просто (Часть 2): обучение и тестирование сети
  3. Нейросети - это просто (Часть 3): сверточные сети
  4. Нейросети - это просто (Часть 4): рекуррентные сети
  5. OpenCL: Мост с параллельные миры
  6. OpenCL: от наивного кодирования - к более осмысленному

Программы, используемые в статье

# Имя Тип Описание
1 Fractal_OCL.mq5  Советник Советник с нейронной сетью классификации(3 нейрона в выходном слое) с использованием технологии OpenCL
2 NeuroNet.mqh Библиотека класса Библиотека классов для создания нейронной сети
3 NeuroNet.cl Библиотека Библиотека кода программы OpenCL


Прикрепленные файлы |
MQL5.zip (396.86 KB)
Последние комментарии | Перейти к обсуждению на форуме трейдеров (37)
Ivan Titov
Ivan Titov | 18 ноя 2020 в 07:48
Ясно, спасибо. Просьба также уточнить: recentAverageSmoothingFactor = 10000 - задано жестко. Надо ли менять его на количество значений в обучающей выборке?
Dmitriy Gizlyk
Dmitriy Gizlyk | 18 ноя 2020 в 12:58
Ivan Titov:
Ясно, спасибо. Просьба также уточнить: recentAverageSmoothingFactor = 10000 - задано жестко. Надо ли менять его на количество значений в обучающей выборке?

Если выборка меньше, то нужно уменьшить. Увеличивать есть смысл, если Вы хотите видеть ошибку по всей выборке.
Я использую жесткое значение, чтобы оценивать работу сети в разных рыночных условиях.

Aleksei Lesnikov
Aleksei Lesnikov | 29 ноя 2020 в 19:08

Дмитрий, не могу понять, почему не происходит обновление значений в массиве. При этом никаких ошибок нет - выход из метода по false не происходит. Можете проверить это у себя?

bool CNeuronBaseOCL::calcOutputGradients(CArrayDouble *Target)
  {
   if(CheckPointer(OpenCL)==POINTER_INVALID || CheckPointer(Target)==POINTER_INVALID)
      return false;
   uint global_work_offset[1]= {0};
   uint global_work_size[1];
   global_work_size[0]=Target.Total();
   for(uint i=0; i<global_work_size[0]; i++)
     {
      double z=Target.At(i);                    //<--В массиве Target три значения (1,0;0.0;0.0)
      if(!Gradient.Update(i,Target.At(i)))
         return false;
     }
                                                //Проверяем значения в массиве Gradient после обновления
   double target[];                             
   if(getGradient(target)<Gradient.Total())     //<--В массиве target три значения (0,0;0.0;0.0)
      return false;
//---
   Gradient.BufferWrite();
......
 }
Dmitriy Gizlyk
Dmitriy Gizlyk | 29 ноя 2020 в 22:57
Aleksei Lesnikov:

Дмитрий, не могу понять, почему не происходит обновление значений в массиве. При этом никаких ошибок нет - выход из метода по false не происходит. Можете проверить это у себя?

Алексей, метод update наследуется от класса CArrayDouble и записывает данные в массив, но не передает их в буфер GPU.

if(!Gradient.Update(i,Target.At(i)))
         return false;

В тоже время, при вызове метода getGradient, происходит считывание данных с буфера GPU, что и перезатирает внесенные ранее изменения.

virtual int       getGradient(double &values[])    {  return Gradient.GetData(values);    }
.......
int CBufferDouble::GetData(double &values[])
  {
   if(!BufferRead())
      return false;
   return ArrayCopy(values,m_data,0,0,m_data_total);
  }
Aleksei Lesnikov
Aleksei Lesnikov | 30 ноя 2020 в 03:57
Dmitriy Gizlyk:

Алексей, метод update наследуется от класса CArrayDouble и записывает данные в массив, но не передает их в буфер GPU.

В тоже время, при вызове метода getGradient, происходит считывание данных с буфера GPU, что и перезатирает внесенные ранее изменения.

Действительно, что-то я сглупил. Спасибо.
Сетка и мартингейл: что это такое и как их использовать? Сетка и мартингейл: что это такое и как их использовать?

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

Пишем Twitter-клиент для MetaTrader: Часть 2 Пишем Twitter-клиент для MetaTrader: Часть 2

Реализуем Twitter-клиент в виде MQL-класса, позволяющего отправлять твиты с картинками. Подключив всего один автономный include-файл, вы сможете публиковать твиты и выкладывать свои графики и сигналы.

Работа с таймсериями в библиотеке DoEasy (Часть 53): Класс абстрактного базового индикатора Работа с таймсериями в библиотеке DoEasy (Часть 53): Класс абстрактного базового индикатора

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

Работа с таймсериями в библиотеке DoEasy (Часть 54): Классы-наследники абстрактного базового индикатора Работа с таймсериями в библиотеке DoEasy (Часть 54): Классы-наследники абстрактного базового индикатора

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