Neuronale Netze leicht gemacht (Teil 5): Parallele Berechnungen mit OpenCL

Dmitriy Gizlyk | 5 Januar, 2021

Inhalt


Einführung

In früheren Artikeln haben wir einige Arten von Implementierungen neuronaler Netzwerke besprochen. Wie man sehen konnte, bestehen neuronale Netzwerke aus einer großen Anzahl von Neuronen des gleichen Typs, in denen die gleichen Operationen ausgeführt werden. Je mehr Neuronen ein Netzwerk jedoch hat, desto mehr Rechenressourcen verbraucht es. Infolgedessen wächst die Zeit, die zum Trainieren eines neuronalen Netzes benötigt wird, exponentiell, da das Hinzufügen eines Neurons zur versteckten Schicht das Lernen von Verbindungen mit allen Neuronen in den vorherigen und nächsten Schichten erfordert. Es gibt eine Möglichkeit, die Trainingszeit eines neuronalen Netzwerks zu reduzieren. Die Fähigkeiten des Multithreading moderner Computer ermöglichen die gleichzeitige Berechnung mehrerer Neuronen. Die Zeit wird durch eine Erhöhung der Anzahl von Threads erheblich reduziert.


1. Wie die parallele Berechnung in MQL5 organisiert ist

Das MetaTrader 5-Terminal verfügt über eine parallele Architektur. Die Verteilung der Threads im Terminal ist streng geregelt. Laut der Dokumentation werden Skripte und Expert Advisors in einzelnen Threads gestartet. Für Indikatoren sind für jedes Symbol eigene Threads vorgesehen. Die Tick-Verarbeitung und die Synchronisation der Historie werden in dem Thread mit den Indikatoren durchgeführt. Das bedeutet, dass das Terminal nur einen Thread pro Expert Advisor zuweist. Einige Berechnungen können in einem Indikator ausgeführt werden, wodurch ein zusätzlicher Thread bereitgestellt wird. Übermäßige Berechnungen in einem Indikator können jedoch den Terminalbetrieb im Zusammenhang mit der Verarbeitung von Tickdaten verlangsamen, was zu einem Verlust der Kontrolle über die Marktsituation führen kann. Diese Situation kann sich negativ auf die Leistung des EA auswirken.

Es gibt jedoch eine Lösung. Die MetaTrader 5 Entwickler haben die Möglichkeit geschaffen, DLLs von Drittanbietern zu verwenden. Die Erstellung von dynamischen Bibliotheken auf einer Multithreading-Architektur sorgt automatisch für ein paralleles Ausführen der in der Bibliothek implementierten Operationen. Dabei verbleibt die EA-Operation zusammen mit dem Datenaustausch mit der Bibliothek im Hauptthread des Expert Advisors.

Die zweite Option ist die Verwendung der OpenCL-Technologie. In diesem Fall können wir Standardmittel verwenden, um parallele Berechnungen sowohl auf dem von der Technologie unterstützten Prozessor als auch auf Grafikkarten zu organisieren. Bei dieser Option ist der Programmcode nicht von dem verwendeten Gerät abhängig. Es gibt viele Veröffentlichungen zur OpenCL-Technologie auf dieser Website. Insbesondere wird das Thema in den Artikeln [5] und [6] gut behandelt.

Daher habe ich mich für OpenCL entschieden. Erstens muss man bei der Verwendung dieser Technologie nicht zusätzlich das Terminal konfigurieren und eine Berechtigung zur Verwendung von Drittanbieter-DLLs setzen. Zweitens kann ein solcher Expert Advisor zwischen Terminals mit einer EX5-Datei übertragen werden. Dies ermöglicht die Übertragung des Berechnungsteils auf eine Videokarte, deren Fähigkeiten während des Betriebs des Terminals oft ungenutzt sind.


2. Die parallele Berechnungen in neuronalen Netzen

Wir haben die Technologie ausgewählt. Jetzt müssen wir über den Prozess der Aufteilung der Berechnungen in Threads entscheiden. Erinnern Sie sich an den Algorithmus des vollständig verbundenen Perceptrons bei einem feed-forward Durchlauf? Das Signal bewegt sich sequentiell von der Eingabeschicht zu den versteckten Schichten und dann zur Ausgabeschicht. Es macht keinen Sinn, für jede Schicht einen Thread zuzuweisen, da die Berechnungen sequentiell durchgeführt werden müssen. Die Berechnung einer Schicht kann erst beginnen, wenn das Ergebnis der vorherigen Schicht vorliegt. Die Berechnung eines einzelnen Neurons in einer Schicht hängt nicht von den Berechnungsergebnissen der anderen Neuronen in dieser Schicht ab. Das bedeutet, dass wir für jedes Neuron separate Threads zuweisen und alle Neuronen einer Schicht zur parallelen Berechnung schicken können.  

Vollständig verbundenes Perzeptron

Wenn man auf die Operationen eines Neurons heruntergeht, könnte man die Möglichkeit der Parallelisierung der Berechnung der Produkte der Eingangswerte mit ihren Gewichtskoeffizienten in Betracht ziehen. Die weitere Summierung der resultierenden Werte und die Berechnung des Wertes der Aktivierungsfunktion werden jedoch in einem einzigen Thread zusammengefasst. Ich habe mich entschieden, diese Operationen in einem einzigen OpenCL-Kernel unter Verwendung von Vektorfunktionen zu implementieren.

Ein ähnlicher Ansatz wird für die Aufteilung der Feed-Backward-Threads verwendet. Die Implementierung ist unten dargestellt.

3. Implementierung der parallelen Berechnung mit OpenCL

Nachdem wir die grundlegenden Ansätze gewählt haben, können wir mit der Implementierung fortfahren. Beginnen wir mit der Erstellung der Kernels (ausführbare OpenCL-Funktionen). Nach der obigen Logik werden wir 4 Kernel erstellen.

3.1. Feed-forward-Kernel.

Ähnlich wie bei den Methoden, die in den vorherigen Artikeln besprochen wurden, wollen wir den Kernel für den Feed-Forward-Durchlauf, FeedForward, erstellen.

Vergessen wir nicht, dass der Kernel eine Funktion ist, die in jedem Thread läuft. Die Anzahl solcher Threads wird beim Aufruf des Kernels festgelegt. Operationen innerhalb des Kerns sind verschachtelte Operationen innerhalb einer bestimmten Schleife; die Anzahl der Iterationen der Schleife ist gleich der Anzahl der aufgerufenen Threads. Im Feed-Forward-Kernel können wir also die Operationen zur Berechnung eines separaten Neuronenzustands angeben, und die Anzahl der Neuronen kann beim Aufruf des Kerns vom Hauptprogramm aus festgelegt werden.

Der Kernel erhält in den Parametern Verweise auf die Matrix der Gewichte, ein Array mit Eingangsdaten und ein Array mit Ausgangsdaten, sowie die Anzahl der Elemente des Eingangsarrays und den Typ der Aktivierungsfunktion. Beachten Sie, dass alle Arrays in OpenCL eindimensional sind. Wenn also in MQL5 ein zweidimensionales Array für Gewichtskoeffizienten verwendet wird, müssen wir hier die Verschiebungen der Ausgangsposition berechnen, um die Daten des zweiten und der folgenden Neuronen zu lesen.

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


Am Anfang des Kernels erhalten wir die Sequenznummer des Threads, der die Sequenznummer des berechneten Neurons bestimmt. Wir deklarieren die 'private' (internen) Variablen, darunter die Vektorvariablen inp und weight. Wir definieren auch die Verschiebung zu den Gewichten unseres Neurons.

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


Als Nächstes organisieren wir einen Zyklus, um die Summe der Produkte der eingehenden Werte mit ihren Gewichten zu erhalten. Wie oben erwähnt, haben wir Vektoren mit 4 Elementen inp und weight verwendet, um die Summe der Produkte zu berechnen. Allerdings sind nicht alle vom Kernel empfangenen Arrays Vielfache von 4, so dass die fehlenden Elemente durch Nullwerte ersetzt werden sollten. Achten Sie auf eine "1" im Eingangsdatenvektor - sie entspricht einem Gewicht der Bayes'schen Verzerrung.

   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);
     }


Nachdem wir die Summe der Produkte erhalten haben, berechnen wir die Aktivierungsfunktion und schreiben das Ergebnis in das Ausgangsdaten-Array.

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

3.2. Backpropagation-Kernel.

Wir erstellen zwei Kernel für die Backpropagation des Fehlergradienten. Berechnen wir den Fehler der Ausgabeschicht im ersten CaclOutputGradient. Die Logik ist einfach. Die erhaltenen Referenzwerte werden innerhalb der Werte der Aktivierungsfunktion normalisiert. Dann wird die Differenz zwischen den Referenz- und Istwerten mit der Ableitung der Aktivierungsfunktion multipliziert. Schreiben Sie den resultierenden Wert in die entsprechende Zelle des Gradienten-Arrays.

__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;
  }


Wir berechnen im zweiten Kernel den Fehlergradienten des Neurons der versteckten Schicht CaclHiddenGradient. Der Kernelaufbau ist ähnlich wie der oben beschriebene Feed-Forward-Kernel. Er verwendet ebenfalls Vektoroperationen. Die Unterschiede liegen in der Verwendung des Gradientenvektors der nächsten Schicht anstelle der Ausgabewerte der vorherigen Schicht im Feed-Forward-Durchgang und in der Verwendung einer anderen Gewichtsmatrix. Außerdem wird anstelle der Berechnung der Aktivierungsfunktion die resultierende Summe mit der Ableitung der Aktivierungsfunktion multipliziert. Der Kernel-Code ist unten angegeben. 

__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. Aktualisieren der Gewichte.

Erstellen wir einen weiteren Kernel für die Aktualisierung der Gewichte - UpdateWeights. Die Prozedur zur Aktualisierung jedes einzelnen Gewichts hängt nicht von anderen Gewichten innerhalb eines Neurons und von anderen Neuronen ab. Dies ermöglicht das Senden von Aufgaben zur parallelen Berechnung aller Gewichte aller Neuronen in einer Schicht zur gleichen Zeit. In diesem Fall führen wir einen Kernel in einem zweidimensionalen Raum von Threads aus: eine Dimension gibt die Seriennummer des Neurons an und die zweite Dimension bedeutet die Anzahl der Verbindungen innerhalb des Neurons. Dies wird in den ersten 2 Zeilen des Kernel-Codes gezeigt, wo er Thread-IDs in zwei Dimensionen empfängt.  

__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;
  };


Als Nächstes wird die Verschiebung für das aktualisierte Gewicht im Array der Gewichte bestimmt, das Delta (Änderung) berechnet, dann der resultierende Wert in das Array der Deltas eingefügt und zum aktuellen Gewicht addiert.

Alle Kernel werden in einer separaten Datei NeuroNet.cl abgelegt, die als Ressource mit dem Hauptprogramm verbunden wird.

#resource "NeuroNet.cl" as string cl_program

3.4. Erstellen der Klassen des Hauptprogramms.

Nachdem wir die Kernel erstellt haben, kehren wir zu MQL5 zurück und beginnen mit dem Code des Hauptprogramms zu arbeiten. Der Datenaustausch zwischen dem Hauptprogramm und den Kerneln erfolgt über Puffer aus eindimensionalen Arrays (dies wird im Artikel [5] erklärt). Um solche Puffer auf der Seite des Hauptprogramms zu organisieren, legen wir die Klasse CBufferDouble an. Diese Klasse enthält einen Verweis auf das Objekt der Klasse für die Arbeit mit OpenCL und den Index des Puffers, den sie beim Anlegen in OpenCL erhält. 

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; }
  };

Achten Sie darauf, dass beim Erstellen des OpenCL-Puffers dessen Handle zurückgegeben wird. Dieses Handle wird im Array m_buffers der Klasse COpenCL gespeichert. In der Variablen m_myIndex wird nur der Index in dem angegebenen Array gespeichert. Das liegt daran, dass die gesamte Operation der Klasse COpenCL die Angabe eines solchen Indexes verwendet, nicht den Kernel oder das Pufferhandle. Beachten Sie auch, dass der einsatzbereite Algorithmus der Klasse COpenCL Operation die anfängliche Spezifikation der Anzahl der verwendeten Puffer und die weitere Erstellung von Puffern mit einem bestimmten Index erfordert. In unserem Fall werden wir beim Erstellen von neuronalen Schichten dynamisch Puffer hinzufügen. Aus diesem Grund wird die Klasse COpenCLMy von COpenCL abgeleitet. Diese Klasse enthält nur eine zusätzliche Methode. Deren Code finden Sie im Anhang.

In der Klasse CBufferDouble sind folgende Methoden für die Arbeit mit dem Puffer angelegt worden:

Die Architektur aller Methoden ist recht einfach und ihr Code nimmt 1-2 Zeilen ein. Der vollständige Code aller Methoden ist im Anhang unten zu finden.

3.5. Erstellen einer Basisklasse der Neuronen für die Arbeit mit OpenCL.

Gehen wir weiter und betrachten die Klasse CNeuronBaseOCL, die die wichtigsten Zusätze und den Operationsalgorithmus enthält. Es ist schwierig, das erstellte Objekt als Neuron zu bezeichnen, da es die Arbeit der gesamten vollverknüpften neuronalen Schicht enthält. Das Gleiche gilt für die früher betrachteten Convolutionalen Schicht und LSTM-Blöcke. Aber dieser Ansatz ermöglicht die Beibehaltung der zuvor erstellten Architektur des neuronalen Netzes.

Die Klasse CNeuronBaseOCL enthält einen Zeiger auf das Klassenobjekt COpenCLMy und vier Puffer: Ausgabewerte, eine Matrix von Gewichtskoeffizienten, letzte Gewichtsdeltas und Fehlergradienten.

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

Wir geben außerdem den Lern- und Impulskoeffizienten, die Ordnungszahl des Neurons in der Schicht und den Typ der Aktivierungsfunktion an.

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


Wir fügen dem 'protected' Block drei weitere Methoden hinzu: Feed-Forward, versteckte Gradientenberechnung und Aktualisierung der Gewichtsmatrix.

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


Deklarieren wir im 'public' Block den Klassenkonstruktor und -destruktor, die Initialisierungsmethode des Neurons und eine Methode zur Angabe der Aktivierungsfunktion.

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; }


Wir deklarieren für den externen Zugriff auf Daten von Neuronen Methoden zum Erhalten von Pufferindizes (sie werden beim Aufruf von Kerneln verwendet) und Methoden zum Empfangen von aktuellen Informationen aus Puffern in Form von Arrays. Fügen wir außerdem Methoden zum Abfragen der Anzahl der Neuronen und Aktivierungsfunktionen hinzu.

   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;                  }


Und natürlich erstellen wir Dispatching-Methoden für den Feedforward-Durchlauf, die Berechnung des Fehlergradienten und die Aktualisierung der Gewichtsmatrix. Vergessen wir nicht, die virtuellen Funktionen zum Speichern und Lesen von Daten neu zu schreiben. 

   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;                  }
  };


Betrachten wir die Algorithmen der Struktur der Methoden. Klassenkonstruktor und Destruktor sind ziemlich einfach. Ihr Code ist im Anhang verfügbar. Schauen wir uns die Initialisierungsfunktion der Klasse an. Die Methode erhält als Parameter die Anzahl der Neuronen in der nächsten Schicht, die Ordnungszahl des Neurons, einen Zeiger auf das Klassenobjekt COpenCLMy und die Anzahl der zu erstellenden Neuronen.

Beachten wir, dass die Methode als Parameter einen Zeiger auf das Klassenobjekt COpenCLMy erhält und kein Objekt innerhalb der Klasse instanziiert. Dadurch wird sichergestellt, dass nur eine Instanz des Objekts COpenCLMy während der EA-Operation verwendet wird. Alle Kernel und Datenpuffer werden in einem Objekt erstellt, so dass wir keine Zeit mit der Übergabe von Daten zwischen den Schichten des neuronalen Netzwerks verschwenden müssen. Sie werden direkten Zugriff auf dieselben Datenpuffer haben.

Wir überprüfen zu Beginn der Methode die Gültigkeit des Zeigers auf das Objekt der Klasse COpenCLMy und stellen sicher, dass mindestens ein Neuron erstellt werden soll. Als Nächstes legen Sie Instanzen von Pufferobjekten an, initialisieren Arrays mit Anfangswerten und erzeugen Puffer in OpenCL. Die Größe des Puffers 'Output' ist gleich der Anzahl der zu erstellenden Neuronen und die Größe des Gradientenpuffers ist um 1 Element größer. Die Größen der Gewichtsmatrix und ihrer Delta-Puffer sind gleich dem Produkt aus der Größe des Gradienten-Puffers und der Anzahl der Neuronen in der nächsten Schicht. Da dieses Produkt für die Ausgangsschicht "0" sein wird, werden für diese Schicht keine Puffer angelegt.

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;
  }


Die Dispatcher-Methode feedForward ist ähnlich der gleichnamigen Methode der Klasse CNeuronBase. Jetzt wird hier nur ein Typ von Neuronen angegeben, aber weitere Typen können später hinzugefügt werden.

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;
  }

Der OpenCL-Kernel wird direkt in der Methode feedForward(CNeuronBaseOCL *NeuronOCL) aufgerufen. Wir überprüfen zu Beginn der Methode die Gültigkeit des Zeigers auf das Klassenobjekt COpenCLMy und des empfangenen Zeigers auf die vorherige Schicht des neuronalen Netzes.

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


Wir geben die Eindimensionalität des Threads-Raums an und setzen die Anzahl der benötigten Threads gleich der Anzahl der Neuronen.

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

Als Nächstes setzen wir Zeiger auf die verwendeten Datenpuffer und Argumente für die Kernel-Operation.

   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);


Danach wird der Kernel aufgerufen.

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


Eigentlich sollte hier alles fertig sein, aber ich bin beim Testen auf ein Problem gestoßen: Die Methode COpenCL::Execute startet den Kernel nicht, sondern stellt ihn nur in die Warteschlange. Die Ausführung selbst erfolgt beim Versuch, die Ergebnisse des Kernels zu lesen. Deshalb müssen die Verarbeitungsergebnisse vor dem Verlassen der Methode in ein Array geladen werden.

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


Die Methoden zum Starten anderer Kernel sind dem obigen Algorithmus ähnlich. Der vollständige Code aller Methoden und Klassen ist im Anhang verfügbar.

3.6. Ergänzungen in der Klasse CNet.

Nachdem alle erforderlichen Klassen erstellt wurden, nehmen wir einige Anpassungen in der Klasse CNet des neuronalen Hauptnetzwerks vor.

Im Klassenkonstruktor müssen wir die Erstellung und Initialisierung einer Instanz der Klasse COpenCLMy hinzufügen. Vergessen wir nicht, das Klassenobjekt im Destruktor zu löschen. 

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


Außerdem fügen wir im Block des Konstruktors, der Neuronen in Schichten hinzufügt, einen Code hinzu, der Objekte der zuvor erstellten Klasse CNeuronBaseOCL erzeugt und initialisiert.

      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;
           }
        }


Außerdem ergänzen wir das Erstellen von Kerneln in OpenCL im Konstruktor.

   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");


Hinzufügen des Schreibens der Quelldaten in den Puffer in der Methode 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;
     }


Fügen wir auch den entsprechenden Methodenaufruf der neu erstellten Klasse CNeuronBaseOCL hinzu.

   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;
        }


Für das Backpropagation-Verfahren legen wir eine neue Methode CNet::backPropOCL an. Ihr Algorithmus ist ähnlich der Hauptmethode CNet::backProp, die im ersten Artikel beschrieben wurde.

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));
     }
  }


An der Methode getResult wurden einige kleinere Änderungen vorgenommen.

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


Der vollständige Code aller Methoden und Funktionen steht im Anhang zur Verfügung.

4. Tests

Die Tätigkeit der erstellten Klassen wurde unter den gleichen Bedingungen getestet, die wir in früheren Tests verwendet haben. Zum Testen wurde der Fractal_OCL EA erstellt, der ein vollständiges Analogon des zuvor erstellten Fractal_2 ist. Das Testtraining des neuronalen Netzwerks wurde für das Paar EURUSD auf dem Zeitrahmen H1 durchgeführt. Daten von 20 Kerzen wurden in das neuronale Netz eingegeben. Das Training wurde mit Daten aus den letzten 2 Jahren durchgeführt. Das Experiment wurde auf einem CPU-Gerät 'Intel(R) Core(TM)2 Duo CPU T5750 @ 2.00GHz' mit OpenCL-Unterstützung durchgeführt.

In einer Testdauer von 5 Stunden und 27 Minuten führte der EA mit der OpenCL-Technologie 75 Trainingsepochen aus. Dies ergab im Durchschnitt 4 Minuten und 22 Sekunden für eine Epoche mit 12.405 Kerzen. Der gleiche Expert Advisor ohne OpenCL-Technologie, auf dem gleichen Laptop mit der gleichen neuronalen Netzwerkarchitektur, benötigt durchschnittlich 40 Minuten und 48 Sekunden pro Epoche. Der Lernprozess ist also mit OpenCL um den Faktor 9,35 schneller.


Schlussfolgerung

Dieser Artikel hat die Möglichkeit aufgezeigt, die OpenCL-Technologie für die Organisation von Multithreading-Berechnungen in neuronalen Netzen zu nutzen. Tests haben eine fast 10-fache Leistungssteigerung auf der gleichen CPU gezeigt. Es wird erwartet, dass die Verwendung einer GPU die Leistung des Algorithmus weiter verbessern kann - in diesem Fall erfordert die Übertragung der Berechnungen auf eine kompatible GPU keine weiteren Änderungen im Code des Expert Advisors.

Im Allgemeinen beweisen die Ergebnisse, dass die weitere Entwicklung in dieser Richtung gute Aussichten hat.


Links

  1. Neuronale Netze leicht gemacht
  2. Neuronale Netze leicht gemacht (Teil 2): Netzwerktraining und Tests
  3. Neuronale Netze leicht gemacht (Teil 3): Convolutional Neurale Netzwerke
  4. Neuronale Netze leicht gemacht (Teil 4): Rekurrente Netze
  5. OpenCL: Die Brücke zu parallelen Welten
  6. OpenCL: Von naivem zum intelligenten Programmieren

Programme, die im diesem Artikel verwendet werden

# Name Typ Beschreibung
1 Fractal_OCL.mq5  Expert Advisor Ein Expert Advisor mit dem klassifizierenden neuronalen Netz (3 Neuronen in der Ausgabeschicht) unter Verwendung der OpenCL-Technologie
2 NeuroNet.mqh Klassenbibliothek Eine Bibliothek mit Klassen zum Erstellen eines neuronalen Netzwerks
3 NeuroNet.cl Bibliothek Die Bibliothek mit dem Programm-Code für OpenCL