神经网络变得轻松(第五部分):OpenCL 中的多线程计算

概述在之前的文章中,我们讨论过某些类型的神经网络实现。 如您所见,神经网络由大量相同类型的神经元组成,并在其中执行相同的操作。 然而,网络拥有的神经元越多,它消耗的计算资源也就越多。 结果就是,训练神经网络所需的时间呈指数增长,这是因为在隐藏层添加一个神经元,需要了解上一层和下一层中所有神经元的连接。 有一种减少神经网络训练时间的方法。 现代计算机的多线程功能可以同时计算多个神经元。 由于线程数量的增加,时间将可预见地大大减少。 1. MQL5 中如何组织多线程计算MetaTrader 5 终端具有多线程体系架构。 终端中的线程分布受到严格控制。 根据文档,脚本和智能交易系统是在单独的线程中启动。 至于指示器,每个品种会提供单独的线程。 即时报价处理和历史记录同步于指标所在线程中执行。 这意味着终端只为每个智能交易系统分配一个线程。 某些计算可以在指标中执行,其可提供一个额外的线程。 然而,指标中过多的计算会减慢与即时报价数据处理相关的终端操作,这可能会导致针对市场状况的失控。 这种状况能对 EA 性能产生负面影响。不过,有一个解决方案。 MetaTrader 5 开发人员为其提供了利用第三方 DLL 的能力。 在多线程体系结构上创建动态库会自动为函数库中实现的操作提供多线程支持。 在此,EA 操作以及与函数库之间的数据交换依然保留在智能交易系统的主线程之中。第二个选项是利用 OpenCL 技术。 在这种情况下,我们可以用标准方法在支持该技术的处理器和视频卡上规划多线程计算。 对于此选项,程序代码不依赖所使用的设备。 该站点上有许多与 OpenCL 技术有关的出版物。 特别是,该主题在 [第五篇] 和 [第六篇] 文章里已有很好介绍。因此,我决定使用 OpenCL。 首先,运用该技术时,用户不需要额外配置终端,并为第三方 DLL 设置权限。 其次,这样的智能交易系统可通过一个 EX5 文件在终端之间传送。 这允许将计算部分转移到视频卡,因视频卡通常在终端操作期间处于空闲状态。 2. 神经网络中的多线程计算我们已选择了该技术。 现在,我们需要决定将计算部分拆分为线程的过程。 您还记得完全连接感知器算法吗? 信号顺序从输入层转至隐藏层,然后转至输出层。 没必要为每个层分配线程,因为计算必须按顺序执行。 直到收到来自上一层的结果之后,该层才能开始计算。 一层中独立神经元的计算不依赖该层中其他神经元的计算结果。 这意味着我们可为每个神经元分配单独的线程,并发送一整层的所有神经元进行并行计算。

编辑

添加图片注释,不超过 140 字(可选)

编辑添加图片注释,不超过 140 字(可选)深入到一个神经元的运算,我们可以研究把计算输入值与权重系数的乘积并行化的可能性。 不过,结果值的进一步求和,以及计算激活函数的数值被合并到一个线程当中。 我决定利用 vector 函数在单个 OpenCL 内核中实现这些操作。类似的方法也用来拆分反馈线程。 其实现如下所示。3. 利用 OpenCL 实现多线程计算选择了基本方法后,我们就能够继续实现了。 我们从创建内核(可执行的OpenCL函数)开始。 根据以上逻辑,我们将创建 4 个内核。3.1. 前馈内核。与之前文章中讨论的方法类似,我们创建一个前馈推算内核 FeedForward 。不要忘记内核是在每个线程中运行的函数。 调用内核时需设置此类线程的数量。 在内核内部的操作是特定循环内的嵌套操作;循环的迭代次数等于被调用线程的次数。如此,在前馈内核中,我们可以指定计算独立神经元状态的操作,并可从主程序调用内核时以指定神经元数量。内核从参数中接收权重矩阵,输入数据数组和输出数据数组的引用,以及输入数组的元素数量,和激活函数类型。 请注意,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. 反向传播内核。为反向传播误差梯度创建两个内核。 在第一个 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。 更新每个独立权重的过程不依赖于某个神经元之内及来自外部神经元的权重。 这允许发送批量任务,同时并行计算一层中所有神经元的所有权重。 在这种情况下,我们在线程的二维空间中运行一个内核:第一维表示神经元的序列号,第二维表示神经元内的连接数。 以下代码显示的是内核代码的前两行,其中它以二维接收线程 ID。 __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_program3.4. 创建主程序类。创建内核之后,我们返回 MQL5,并开始操控主程序代码。 主程序和内核之间的数据通过一维数组作为缓冲区进行交换(这在[文章第五部分]里的解释)。 为了在主程序端规划此类缓冲区,我们来创建 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 缓冲区,其句柄将被返回。 该句柄被存储在 COpenCL 类的 m_buffers 数组当中。 在 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;在类的受保护模块中再添加三个方法:前馈、隐藏梯度计算,和更新权重矩阵。 virtual bool feedForward(CNeuronBaseOCL *NeuronOCL); virtual bool calcHiddenGradients(CNeuronBaseOCL *NeuronOCL); virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL);在公开部分中,声明类构造函数,和析构函数,神经元初始化方法,和指定激活函数的方法。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; }

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值