Redes neuronales: así de sencillo (Parte 5): Cálculos multihilo en OpenCL

Dmitriy Gizlyk | 22 enero, 2021

Contenido


Introducción

En artículos anteriores, ya hemos analizado algunos tipos de implementaciones de redes neuronales. Como podrá ver, las redes neuronales están formadas por un gran número de neuronas del mismo tipo, en las que se realizan las mismas operaciones. No obstante, cuantas más neuronas tiene una red, más recursos informáticos necesita. Como resultado, el tiempo necesario para entrenar una red neuronal aumenta de manera exponencial, ya que la adición de una neurona a la capa oculta requiere el entrenamiento de las conexiones con todas las neuronas en las capas anterior y siguiente. Y aunque no tenemos poder alguno sobre el propio tiempo, sí que existe una manera de reducir el tiempo de entrenamiento de una red neuronal. Las capacidades multihilo de las computadoras modernas nos permiten recalcular múltiples neuronas simultáneamente. De esta manera, podemos reducir considerablemente el tiempo gracias al aumento del número de hilos.


1. Organizando los cálculos multihilo en MQL5

MetaTrader 5 se posiciona como un terminal con una arquitectura multihilo. Al mismo tiempo, la distribución de hilos en el terminal está claramente regulada. En la documentación encontramos la definición: los asesores y los scripts se inician cada uno en un hilo individual, siéndole asignado un hilo a los indicadores de cada instrumento. En este caso, además, los ticks son procesados y la historia es sincronizada en el hilo con los indicadores. Es decir, el terminal no asignará más de un hilo por asesor. Podemos trasladar una parte de los cálculos al indicador, lo cual nos conseguirá un hilo adicional. Pero un número excesivo de cálculos en el indicador pueden ralentizar el funcionamiento del terminal a la hora de procesar la información de los ticks y, como resultado, terminaremos perdiendo el control sobre la situación del mercado. Esta situación solo puede influir negativamente en los resultados del funcionamiento del asesor.

No obstante, existe una salida a esta situación. Los desarrolladores de MetaTrader 5 nos han dado la posibilidad de usar dlls de terceros. La construcción de bibliotecas dinámicas en una arquitectura multihilo posibilita automáticamente la ejecución multiproceso de las operaciones realizadas en la biblioteca. Al mismo tiempo, el trabajo realizado por el propio asesor y el intercambio de datos con la biblioteca seguirán en el hilo principal del asesor.

La segunda opción es usar la tecnología OpenCL. En este caso, usando medios casi estándar, nosotros tenemos la capacidad de organizar la computación multihilo tanto en un procesador compatible con la tecnología como en tarjetas de vídeo. Además, el código de nuestro programa no depende del dispositivo usado. La utilización de la tecnología OpenCL ya ha sido descrita en este sitio web en más de una ocasión. Concretamente, los artículos [5] y [6] abarcan el tema bastante bien.

Personalmente, decidimos usar OpenCL. En primer lugar, el uso de esta tecnología no requiere acciones adicionales por parte del usuario a la hora de configurar el terminal, es decir, permiso para usar dlls de terceros. En segundo lugar, podemos trasladar este asesor entre terminales con un archivo EX5. Y, por supuesto, esto nos permitirá trasladar la parte computacional a una tarjeta de vídeo, cuyas capacidades permanecen a menudo inactivas durante el funcionamiento del terminal.


2. Los cálculos multihilo en las redes neuronales

Una vez hemos decidido la tecnología a usar, es momento de pensar en el proceso de división de los cálculos en subprocesos. Recordemos el algoritmo del perceptrón completamente conectado con la propagación hacia adelante. La señal se mueve secuencialmente desde la capa de entrada a las capas ocultas y luego hacia la capa de salida. La asignación de un hilo para cada capa no dará un resultado, ya que los cálculos deben realizarse de forma secuencial. No podemos empezar a calcular una capa hasta que obtengamos el resultado de la anterior. Al mismo tiempo, el recálculo de una neurona individual en una capa no depende de los resultados del recálculo de las otras neuronas en esta capa. Es decir, podemos asignar con seguridad un hilo para cada neurona y enviar inmediatamente todas las neuronas de la capa al cálculo paralelo.  

Perceptrón completamente conectado

Si descendemos al nivel de las operaciones de una neurona, podemos analizar la posibilidad de paralelizar el cálculo de los productos de los valores de entrada por sus coeficientes de peso. Pero posterior la suma de los valores obtenidos y el cálculo del valor de la función de activación se unen en un solo hilo. Tras considerar todas las ventajas y desventajas, decidimos implementar estas operaciones en un solo núcleo OpenCL utilizando funciones vectoriales.

Se usa un enfoque similar para separar los hilos de retorno. A continuación, mostramos la implementación concreta.

3. Implementando los cálculos multihilo con ayuda de OpenCL

Tras decidir los enfoques principales, podemos proceder a una implementación de forma específica. Vamos a comenzar creando los kernels (funciones OpenCL ejecutables). Siguiendo la lógica anterior, crearemos 4 núcleos. 

3.1. Kernel de propagación hacia delante.

Por analogía con los métodos analizados en los artículos anteriores, crearemos el kernel de propagación hacia delante FeedForward.

Al escribir los kernels, debemos recordar que un kernel es una función que se ejecuta en cada hilo. El número de dichos subprocesos se establece al llamar al kernel. Así, por analogía con MQL5, las operaciones dentro del kernel serán operaciones anidadas dentro de un ciclo, cuyo número de iteraciones será igual al número de hilos llamados. De esta forma, en el kernel de propagación hacia delante, indicaremos las operaciones para calcular el estado de una neurona individual, mientras que el número de neuronas calculadas lo indicaremos al llamar al kernel desde el programa principal.

En los parámetros, el kernel obtiene las referencias a la matriz de coeficientes de peso, la matriz de datos de entrada y la matriz de datos de salida, así como el número de elementos de la matriz de entrada y el tipo de función de activación. En este punto, no debemos olvidar que en OpenCL todas las matrices son unidimensionales. Por consiguiente, si en MQL5 utilizamos una matriz bidimensional para los coeficientes de peso, aquí, para leer los datos de la segunda neurona y las siguientes, necesitaremos calcular el desplazamiento de la posición inicial.

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

Al inicio del kernel, obtenemos el número ordinal del hilo, que determinará para nosotros el número ordinal de la neurona calculada. A continuación, declaramos las variables (internas) privadas, incluyendo las variables vectoriales inp y weight. Asimismo, definimos el desplazamiento hasta los coeficientes de peso de nuestra neurona.

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

A continuación, organizamos un ciclo para obtener la suma de los productos de los valores de entrada con sus coeficientes de peso. Como ya mencionamos con anterioridad, para calcular la suma de los productos, se utilizaron los vectores de 4 elementos inp y weight. Pero las matrices obtenidas por el kernel no siempre serán múltiplos de 4, por lo que sustituimos los elementos faltantes con valores cero. Debemos prestar atención al "1" en el vector de entrada, este corresponderá al coeficiente de peso del sesgo bayesiano.

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

Después de obtener la suma de los productos, calculamos la función de activación y escribimos el resultado en la matriz de datos de salida.

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

3.2. Kernels de propagación inversa del gradiente de error.

Para propagar hacia atrás el gradiente de error, crearemos 2 núcleos. En el primer CaclOutputGradient, calcularemos el error de la capa de salida. Su lógica resulta bastante simple. Normalizamos los valores de referencia obtenidos dentro de los valores de la función de activación. A continuación, multiplicamos la diferencia entre los valores de referencia y los reales por la derivada de la función de activación. El valor resultante lo escribimos en la celda correspondiente de la matriz de gradientes.

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

En el segundo kernel, calculamos el gradiente de error de la neurona de la capa oculta CaclHiddenGradient. La construcción de este kernel es similar a la construcción del kernel de propagación hacia adelante que hemos descrito anteriormente. También usa operaciones vectoriales. Las diferencias se encuentran en el uso del vector de gradientes de la siguiente capa en lugar de los valores de salida de la capa anterior en la propagación hacia delante, así como en el uso de una matriz de coeficientes de peso diferente. Además, en lugar de calcular la función de activación, la suma resultante se multiplica por la derivada de la función de activación. A continuación, vemos el código del kernel. 

__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. Actualizando los coeficientes de peso.

Vamos a crear otro kernel para actualizar los coeficientes de peso UpdateWeights. El procedimiento de actualización para cada coeficiente de peso por separado no depende de forma alguna de otros coeficiente de peso, tanto dentro de la neurona como en otras neuronas. Esto nos permite enviar tareas para el cálculo paralelo de todos los coeficientes de peso de todas las neuronas en una capa al mismo tiempo. En esta versión, iniciamos un kernel en un espacio bidimensional de hilos: una dimensión indica el número ordinal de la neurona, mientras que la otra se encarga del número de conexiones dentro de la neurona. Precisamente esto se muestra en las 2 primeras líneas del código del kernel, donde obtiene los identificadores de hilo en dos dimensiones.  

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

A continuación, determinamos el desplazamiento para el coeficiente de peso actualizado en la matriz de pesos, calculamos el delta (cambio), introducimos el valor resultante en la matriz de deltas y lo sumamos al coeficiente de peso actual.

Hemos sacado todos los kernels al archivo aparte NeuroNet.cl, que conectaremos al programa principal como recurso.

#resource "NeuroNet.cl" as string cl_program

3.4. Creando las clases del programa principal.

Después de crear los kernels, volvemos a MQL5 y comenzamos a trabajar con el código del programa principal. El intercambio de datos entre el programa principal y los kernels se efectúa a través de búferes de matrices unidimensionales (el tema se trata en el artículo [5]). Para organizar dichos búferes, creamos la clase CBufferDouble en el lado del programa principal. Esta clase contiene una referencia al objeto de la clase para trabajar con OpenCL y el índice del búfer que recibe cuando se crea en 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; }
  };

Aquí debemos aclarar que al crear un búfer OpenCL, se retorna su identificador, que luego se guarda en la matriz m_buffers de la clase COpenCL. En la variable m_myIndex, solo guardamos el índice en la matriz especificada. Esto se debe a que todo el trabajo de la clase COpenCL se construye precisamente especificando dicho índice, no el manejador del búfer o el kernel. También debemos añadir que el algoritmo de funcionamiento de la clase COpenCL listo para utilizar necesita que especifiquemos inicialmente el número de búferes a usar, y que luego creemos los búferes que especifiquen un índice concreto. En nuestro caso, añadiremos los búferes de forma dinámica al crear las capas neuronales. Por consiguiente, era necesario crear la clase COpenCLMy heredada de COpenCL. Esta clase solo contiene un método adicional, así que no nos detendremos en su descripción. El lector podrá consultar su código en el archivo adjunto.

Para trabajar directamente con el búfer, hemos creado los siguientes métodos en la clase CBufferDouble:

La arquitectura de todos los métodos no es complicada, y su código ocupa solo 1-2 líneas. Podrá encontrar el código completo de todos los métodos en el archivo adjunto.

3.5. Creando la clase básica de la neurona para trabajar con OpenCL.

Vamos a continuar analizando la clase CNeuronBaseOCL, que probablemente incluía la parte principal de las adiciones y el algoritmo de trabajo. Resulta difícil llamar neurona al objeto creado, ya que contiene el trabajo de toda la capa neuronal completamente conectada. Lo mismo puede decirse de las capas convolucionales y los bloques de LSTM analizados anteriormente. Pero este enfoque nos permite conservar la arquitectura de red neuronal construida previamente.

Bien, la clase CNeuronBaseOCL contiene un puntero a un objeto de la clase COpenCLMy y cuatro búferes: los valores de salida, la matriz de coeficientes de peso, los últimos deltas de los pesos y los gradientes de error.

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

También declaramos el coeficiente de aprendizaje y el impulso, el número ordinal de la neurona en la capa y el tipo de función de activación.

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

En el bloque protected, añadimos tres métodos más: la propagación hacia adelante, el cálculo del gradiente de la capa oculta y la actualización de la matriz de peso.

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

En el bloque público, declaramos el constructor y destructor de la clase, el método para inicializar la neurona y el método para indicar la función de activación.

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

Para acceder de forma externa a la información de las neuronas, declaramos los métodos para obtener los índices de búfer (se usarán al llamar a los kernels) y los métodos para obtener la información actual de los búferes en forma de matrices. También añadimos los métodos para sondear el número de neuronas y las funciones de activación.

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

Y, claro está, también creamos los métodos de despacho para la propagación hacia adelante, el cálculo de los gradientes de error y la actualización de la matriz de peso. No se olvide de reescribir las funciones virtuales para guardar y leer datos. 

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

Veamos los algoritmos para construir los métodos. En el constructor y el destructor de la clase todo resulta bastante trivial, por lo que no parece necesario analizarlos con detalle. Podrá encontrar el código de estos métodos en el archivo adjunto. Echemos un vistazo al método de inicialización de clases. Este método recibe en los parámetros el número de neuronas en la siguiente capa, el número ordinal de la neurona, un puntero a un objeto de la clase COpenCLMy y directamente el número de neuronas a crear.

Tenga en cuenta que el método en los parámetros recibe un puntero a un objeto de la clase COpenCLMy y no crea una instancia de un objeto dentro de la clase. Esto se hace así para que solo se utilice una instancia del objeto COpenCLMy cuando el asesor está funcionando. Todos los kernels y los búferes de datos serán creados en un solo objeto, lo cual nos permitirá no perder tiempo transfiriendo datos entre las capas de la red neuronal. Tendrán acceso directo a los mismos búferes de datos.

Al inicio del método, comprobamos la validez del puntero al objeto de la clase COpenCLMy y nos aseguramos de que se requiera crear al menos una neurona. Después, creamos las instancias de los objetos de búfer, inicializamos las matrices con los valores iniciales y creamos los búferes en OpenCL. El tamaño del búfer de salida es igual al número de neuronas creadas y el tamaño del búfer de gradientes es 1 elemento más grande. Y los tamaños de los búferes de la matriz de coeficientes de peso y sus deltas son iguales al producto del tamaño del búfer de gradientes por el número de neuronas en la siguiente capa. Y como dicho producto será igual a "0" para la capa de salida, estos búferes no se crean en él.

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

El método de despacho feedForward se construye a imagen y semejanza del método con el mismo nombre de la clase CNeuronBase. Hasta ahora, solo hemos enumerado un tipo de neurona, pero planeamos añadir otras.

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

El kernel de OpenCL se llama directamente en el método feedForward(CNeuronBaseOCL *NeuronOCL). Al inicio del método, comprobamos la validez del puntero al objeto de la clase COpenCLMy y el puntero obtenido a la capa anterior de la red neuronal.

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

Asimismo, indicamos la unidimensionalidad del espacio de hilos y establecemos un número de hilos necesarios igual al número de neuronas.

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

A continuación, establecemos los punteros a los búferes de datos usados y los argumentos para el funcionamiento del kernel.

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

Después de ello, llamamos al kernel.

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

Queríamos terminar aquí, pero durante la prueba topamos con un problema: el método COpenCL::Execute no inicia el kernel, sino que lo pone en la cola. La ejecución en sí tiene lugar al intentar leer los resultados del kernel. Por consiguiente, hemos tenido que cargar los resultados del procesamiento en una matriz antes de salir del método.

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

Los métodos para iniciar el resto de los kernels se construyen según un algoritmo similar. Podrá encontrar el código completo de todos los métodos y clases en el archivo adjunto.

3.6. Adiciones puntuales en la clase CNet.

Una vez creadas todas las clases necesarias, vamos a realizar ajustes puntuales a la clase de la red neuronal principal CNet.

En el constructor de la clase, añadimos la creación e inicialización de una instancia de la clase COpenCLMy. Paralelamente, tenemos que eliminar el objeto de clase en el destructor. 

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

También en el constructor, en el bloque encargado de añadir neuronas en las capas, agregamos el código para crear e inicializar objetos, la clase CNeuronBaseOCL creada por nosotros.

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

Y a continuación, en el constructor, añadimos la creación de kernels en 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");

En el método CNet::feedForward, añadimos el registro de los datos originales al búfer

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

Y una llamada al método similar de nuestra nueva clase 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;
        }

Creemos el nuevo método CNet::backPropOCL para procesar la propagación inversa. Su algoritmo es similar al método CNet::backProp principal descrito en el primer artículo.

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

Además, hemos realizado cambios menores en el método getResult.

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

Podrá familiarizarse con el código de todos los métodos y funciones en los anexos.

4. Simulación

La simulación del funcionamiento de la clase creada se ha llevado a cabo en las mismas condiciones que todas las pruebas anteriores. Para las pruebas, hemos creado el asesor Fractal_OCL, un análogo completo del asesor Fractal_2 creado anteriormente. El entrenamiento de prueba de la red neuronal se ha realizado con la pareja EURUSD en el marco temporal H1. A la entrada de la red neuronal hemos suministrado los datos de 20 velas. El entrenamiento se ha realizado utilizando los últimos 2 años. El experimento se ha llevado a cabo en un CPU device 'Intel(R) Core(TM)2 Duo CPU T5750 @ 2.00GHz' con soporte de OpenCL.

Durante 5 horas y 27 minutos, el asesor de prueba con tecnología OpenCL ha sido sometido a 75 épocas de entrenamiento. Esto significa una media de 4 minutos y 22 segundos para una época de 12 405 velas. Un asesor experto similar sin tecnología OpenCL en la misma computadora portátil con la misma arquitectura de red neuronal invierte un promedio de 40 minutos y 48 segundos por época. Podemos ver un aumento en la velocidad del proceso de aprendizaje de 9,35 veces.


Conclusión

En este artículo, hemos mostrado la posibilidad de usar la tecnología OpenCL para organizar cálculos multihilo en las redes neuronales. La prueba de la tecnología ha mostrado un aumento de casi 10 veces en el rendimiento de la misma CPU. Esperamos que el uso de una GPU pueda mejorar aún más el rendimiento del algoritmo; en este caso, además, la transferencia de los cálculos a una GPU compatible no requiere de cambios en el código del asesor.

En general, los resultados logrados muestran las buenas perspectivas de desarrollo de esta rama de estudio.


Enlaces

  1. Redes neuronales: así de sencillo
  2. Redes neuronales: así de sencillo (Parte 2): Entrenamiento y prueba de la red
  3. Redes neuronales: así de sencillo (Parte 3): Redes convolucionales
  4. Redes neuronales: así de sencillo (Parte 4): Redes recurrentes
  5. OpenCL: El puente hacia mundos paralelos
  6. OpenCL: de una programación simple a una más intuitiva

Programas utilizados en el artículo

# Nombre Tipo Descripción
1 Fractal_OCL.mq5  Asesor Asesor con la red neuronal de clasificación (3 neuronas en la capa de salida) con uso de la tecnología OpenCL
2 NeuroNet.mqh Biblioteca de clase Biblioteca de clases para crear la red neuronal
3 NeuroNet.cl Biblioteca Biblioteca de código del programa OpenCL