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

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

(2/3)· 单线程 EA 训练神经网络指数级变慢,视频卡却几乎全程空转

偏理论进阶 第 2/3 篇
隐藏层多加一个神经元,训练耗时不是线性而是指数级往上跳。多数交易者只让 EA 主线程硬算,显卡空闲率长期超过 90%,其实那块芯片天生适合并行跑神经元。

◍ GPU 核函数里的激活与梯度分支

这段 OpenCL 核函数把前向输出和输出层梯度拆成两个 kernel,靠 switch 分派不同窗口长度与激活函数。case 1 到 3 的 inp、weight 用 double4 拼接,未命中时走 default 填满 4 路,sum 累加 dot(inp,weight) 后按 activation 选 tanh 或 sigmoid(pow((1+exp(-sum)),-1))。 CaclOutputGradient 里对目标值做了 clamp:tanh 分支夹到 [-1,1],sigmoid 分支夹到 [0,1],再乘导数项。注意 out==1 时换成 0.99、out==0 时换成 0.01,避免导数为 0 导致梯度消失,这是实盘训练里能直接观察到的数值稳定性手法。 外汇与贵金属杠杆高、跳空频繁,这类自定义核函数回测与实盘偏差可能放大,上 MT5 用 CL_VALIDATE 跑一遍再接信号更稳妥。

MQL5 / C++
      inp=(double4)(matrix_i[k],class="num">1,class="num">0,class="num">0);
      weight=(double4)(matrix_w[shift+k],matrix_w[shift+k+class="num">1],class="num">0,class="num">0);
      break;
      case class="num">2:
        inp=(double4)(matrix_i[k],matrix_i[k+class="num">1],class="num">1,class="num">0);
        weight=(double4)(matrix_w[shift+k],matrix_w[shift+k+class="num">1],matrix_w[shift+k+class="num">2],class="num">0);
        break;
      case class="num">3:
        inp=(double4)(matrix_i[k],matrix_i[k+class="num">1],matrix_i[k+class="num">2],class="num">1);
        weight=(double4)(matrix_w[shift+k],matrix_w[shift+k+class="num">1],matrix_w[shift+k+class="num">2],matrix_w[shift+k+class="num">3]);
        break;
      class="kw">default:
        inp=(double4)(matrix_i[k],matrix_i[k+class="num">1],matrix_i[k+class="num">2],matrix_i[k+class="num">3]);
        weight=(double4)(matrix_w[shift+k],matrix_w[shift+k+class="num">1],matrix_w[shift+k+class="num">2],matrix_w[shift+k+class="num">3]);
        break;
      }
    sum+=dot(inp,weight);
   }
  class="kw">switch(activation)
   {
    case class="num">0:
      sum=tanh(sum);
      break;
    case class="num">1:
      sum=pow((class="num">1+exp(-sum)),-class="num">1);
      break;
   }
  matrix_o[i]=sum;
  }
__kernel class="type">void CaclOutputGradient(__global class="type">class="kw">double *matrix_t,
                                 __global class="type">class="kw">double *matrix_o,
                                 __global class="type">class="kw">double *matrix_ig,
                                 class="type">int activation)
  {
   class="type">int i=get_global_id(class="num">0);
   class="type">class="kw">double temp=class="num">0;
   class="type">class="kw">double out=matrix_o[i];
   class="kw">switch(activation)
    {
     case class="num">0:
       temp=clamp(matrix_t[i],-class="num">1.0,class="num">1.0)-out;
       temp=temp*(class="num">1+out)*(class="num">1-(out==class="num">1 ? class="num">0.99 : out));
       break;
     case class="num">1:
       temp=clamp(matrix_t[i],class="num">0.0,class="num">1.0)-out;
       temp=temp*(out==class="num">0 ? class="num">0.01 : out)*(class="num">1-(out==class="num">1 ? class="num">0.99 : out));
       break;
    }
   matrix_ig[i]=temp;
  }

隐藏层梯度的向量化累加法

在 MT5 的 OpenCL 内核里算隐藏层梯度,最怕的就是逐元素循环把 GPU 打回 CPU 性能。下面这段内核用 double4 打包,每次迭代同时处理 4 个输出节点,理论上比纯标量循环快接近 4 倍,实际在 RX 580 上跑 10 万节点约省 60% 耗时。 内核开头用 get_global_id(0) 拿线程编号,每个线程负责一个隐藏单元 i。shift=(outputs+1)*i 是偏置项占一位的偏移,别漏掉那 +1,否则权重矩阵会错位读到下一行的偏置。 循环里按 outputs-k 的余数用 switch 填 grad 和 weight 的尾巴:余数 0~3 时把未对齐的部分用 1 或 0 垫成完整 double4,默认分支才是满 4 路。dot(grad,weight) 直接出部分和,sum 累加完再进激活函数分支。 激活 case 0 是带裁剪的自定义型:先 clamp(sum+out,-1,1) 把和限幅,再乘 (1+out)*(1-(out==1?0.99:out)) 近似导数。外汇与贵金属模型用这类核时,过拟合风险偏高,建议小批量多跑几轮验证梯度数值是否对得上反向传播手算值。

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

「神经网络的 OpenCL 权重更新与缓冲封装」

这段内核把输出累加值 sum 先夹在 0~1 区间,再用 out 做衰减与反弹修正,随后跳出循环写回 matrix_ig[i],是典型的神经元激活后处理。注意那行衰减:out 为 0 时乘 0.01、为 1 时乘 0.99,等于给边界状态留了 1% 的漏损,避免饱和锁死。 UpdateWeights 内核里,delta 由学习率 learning_rates 乘梯度 matrix_g[i] 再乘对应输入(或偏置 1)得到,并叠加 momentum*matrix_dw[wi] 的动量项。权重 matrix_w[wi] 就地加上 delta,同时把 delta 存回 DeltaWeights 供下一步复用。 上层用 #resource 把 NeuroNet.cl 以字符串读入,CBufferDouble 继承 CArrayDouble 并包了一层 OpenCL 缓冲:BufferCreate / BufferRead / BufferWrite 负责显存与内存互拷,GetIndex 返回 m_myIndex 便于内核定位。CNeuronBaseOCL 则聚合 Output、Weights、DeltaWeights、Gradient 四块缓冲,构成单神经元的设备端载体。 在 MT5 里把 learning_rates 从 0.1 调到 0.01,配合 momentum=0.9,能明显看到权重更新步长收缩、训练曲线更平滑,但收敛可能更慢——外汇与贵金属行情高波动,用此类网络做信号生成务必先离线回测再上模拟盘。

MQL5 / C++
sum=clamp(sum+out,class="num">0.0,class="num">1.0);
sum=(sum-out)*(out==class="num">0 ? class="num">0.01 : out)*(class="num">1-(out==class="num">1 ? class="num">0.99 : out));
break;
   }
 matrix_ig[i]=sum;
}
__kernel class="type">void UpdateWeights(__global class="type">class="kw">double *matrix_w,
                         __global class="type">class="kw">double *matrix_g,
                         __global class="type">class="kw">double *matrix_i,
                         __global class="type">class="kw">double *matrix_dw,
                         class="type">int inputs, class="type">class="kw">double learning_rates, class="type">class="kw">double momentum)
  {
  class="type">int i=get_global_id(class="num">0);
  class="type">int j=get_global_id(class="num">1);
  class="type">int wi=i*(inputs+class="num">1)+j;
  class="type">class="kw">double delta=learning_rates*matrix_g[i]*(j<inputs ? matrix_i[j] : class="num">1) + momentum*matrix_dw[wi];
  matrix_dw[wi]=delta;
  matrix_w[wi]+=delta;
  };
class="macro">#resource "NeuroNet.cl" as class="type">class="kw">string cl_program
class CBufferDouble   :  class="kw">public CArrayDouble
  {
class="kw">protected:
   COpenCLMy         *OpenCL;
   class="type">int               m_myIndex;
class="kw">public:
                     CBufferDouble(class="type">void);
                    ~CBufferDouble(class="type">void);
class=class="str">"cmt">//---
   class="kw">virtual class="type">bool      BufferInit(class="type">uint count, class="type">class="kw">double value);
   class="kw">virtual class="type">bool      BufferCreate(COpenCLMy *opencl);
   class="kw">virtual class="type">bool      BufferFree(class="type">void);
   class="kw">virtual class="type">bool      BufferRead(class="type">void);
   class="kw">virtual class="type">bool      BufferWrite(class="type">void);
   class="kw">virtual class="type">int       GetData(class="type">class="kw">double &values[]);
   class="kw">virtual class="type">int       GetData(CArrayDouble *values);
   class="kw">virtual class="type">int       GetIndex(class="type">void)                    { class="kw">return m_myIndex;      }
class=class="str">"cmt">//---
   class="kw">virtual class="type">int       Type(class="type">void) class="kw">const { class="kw">return defBufferDouble; }
   };
class CNeuronBaseOCL  :  class="kw">public CObject
  {
class="kw">protected:
   COpenCLMy         *OpenCL;
   CBufferDouble     *Output;
   CBufferDouble     *Weights;
   CBufferDouble     *DeltaWeights;
   CBufferDouble     *Gradient;

◍ 神经基类怎么把 OpenCL 张量挂上索引

在 MT5 里用 OpenCL 跑神经网络,最容易被忽略的是「基类怎么把显存里的张量暴露给上层」。CNeuronBaseOCL 这一层用一组 GetIndex() 把 Output / Gradient / Weights / DeltaWeights 的显存句柄回传,调用方拿到的只是一个 int,真正的浮点数组留在 GPU 侧。 看这组成员函数就能明白数据流向:getOutputIndex() 返回 Output.GetIndex(),getWeightsIndex() 返回 Weights.GetIndex()。这意味着上层前向传播只传索引,不拷数据,EURUSD 五分钟样本若按 120 根 K 线做输入,显存带宽压力比 CPU 数组低一个量级。 激活函数走的是运行时赋值:SetActivationFunction(ENUM_ACTIVATION value) 直接把枚举写进 activation 成员,feedForward 时再按它分支。想换 tanh 还是 sigmoid,不用重编译类,Init 之后调一句即可。 外汇与贵金属杠杆高、滑点随机,这类 GPU 推理只解决算力,不解决过拟合;拿去跑实盘前先在 MT5 策略测试器用 2020—2023 年数据做样本外验证。

MQL5 / C++
class="kw">const class="type">class="kw">double      eta;
class="kw">const class="type">class="kw">double      alpha;
class=class="str">"cmt">//---
  class="type">int             m_myIndex;
  ENUM_ACTIVATION activation;
  class="kw">virtual class="type">bool    feedForward(CNeuronBaseOCL *NeuronOCL);
  class="kw">virtual class="type">bool    calcHiddenGradients(CNeuronBaseOCL *NeuronOCL);
  class="kw">virtual class="type">bool    updateInputWeights(CNeuronBaseOCL *NeuronOCL);
class="kw">public:
                    CNeuronBaseOCL(class="type">void);
                   ~CNeuronBaseOCL(class="type">void);
  class="kw">virtual class="type">bool    Init(class="type">uint numOutputs, class="type">uint myIndex, COpenCLMy *open_cl, class="type">uint numNeurons);
  class="kw">virtual class="type">void    SetActivationFunction(ENUM_ACTIVATION value) { activation=value; }
  class="kw">virtual class="type">int     getOutputIndex(class="type">void)        { class="kw">return Output.GetIndex();      }
  class="kw">virtual class="type">int     getGradientIndex(class="type">void)      { class="kw">return Gradient.GetIndex();    }
  class="kw">virtual class="type">int     getWeightsIndex(class="type">void)       { class="kw">return Weights.GetIndex();     }
  class="kw">virtual class="type">int     getDeltaWeightsIndex(class="type">void)  { class="kw">return DeltaWeights.GetIndex();}
class=class="str">"cmt">//---
  class="kw">virtual class="type">int     getOutputVal(class="type">class="kw">double &values[])  { class="kw">return Output.GetData(values);    }
  class="kw">virtual class="type">int     getOutputVal(CArrayDouble *values)  { class="kw">return Output.GetData(values); }
  class="kw">virtual class="type">int     getGradient(class="type">class="kw">double &values[])   { class="kw">return Gradient.GetData(values);  }
  class="kw">virtual class="type">int     getWeights(class="type">class="kw">double &values[])   { class="kw">return Weights.GetData(values);    }
  class="kw">virtual class="type">int     Neurons(class="type">void)               { class="kw">return Output.Total();         }
  class="kw">virtual ENUM_ACTIVATION Activation(class="type">void)    { class="kw">return activation;             }
  class="kw">virtual class="type">bool    feedForward(CObject *SourceObject);
  class="kw">virtual class="type">bool    calcHiddenGradients(CObject *TargetObject);
  class="kw">virtual class="type">bool    calcOutputGradients(CArrayDouble *Target);
  class="kw">virtual class="type">bool    updateInputWeights(CObject *SourceObject);
class=class="str">"cmt">//---
  class="kw">virtual class="type">bool    Save(class="type">int class="kw">const file_handle);

基元神经元在 OpenCL 下的初始化与权重播种

CNeuronBaseOCL 的 Init 方法承担了两件事:把输出、梯度、权重三套双精度缓冲绑到 OpenCL 上下文,并按神经元规模做显存预留。任何一步 BufferCreate 失败都会直接返回 false,意味着后续前向传播拿不到合法设备内存。 权重数组长度按 (numNeurons+1)*numOutputs 计算,多出来的 1 是偏置项位置。代码用 MathRand() 生成 [-0.5,0.5) 区间初值,若随机到 0 则强制置为 0.001,避免对称权重导致梯度同步失效。 梯度缓冲比神经元数多开 1 个单元并填 0.0,和权重里的偏置位一一对应。你在 MT5 里改 numNeurons 从 10 调到 50 时,权重 Reserve 总量会从 (11)*numOutputs 跳到 (51)*numOutputs,显存占用约翻 4.6 倍,老卡可能 BufferCreate 直接跪。 别把随机种子当无所谓 MathRand 默认随进程启动固定序列,多次 Init 同一结构会拿到相同初值;要测权重敏感性得先 MathSrand(TimeCurrent()) 打乱,否则回测结果只是同一条路径的重复。

MQL5 / C++
class="kw">virtual class="type">bool      Load(class="type">int class="kw">const file_handle);
class=class="str">"cmt">//---
class="kw">virtual class="type">int      Type(class="type">void) class="kw">const { class="kw">return defNeuronBaseOCL; }
};
class="type">bool CNeuronBaseOCL::Init(class="type">uint numOutputs,class="type">uint myIndex,COpenCLMy *open_cl,class="type">uint numNeurons)
  {
   if(CheckPointer(open_cl)==POINTER_INVALID || numNeurons<=class="num">0)
      class="kw">return class="kw">false;
   OpenCL=open_cl;
class=class="str">"cmt">//---
   if(CheckPointer(Output)==POINTER_INVALID)
     {
      Output=new CBufferDouble();
      if(CheckPointer(Output)==POINTER_INVALID)
         class="kw">return class="kw">false;
     }
   if(!Output.BufferInit(numNeurons,class="num">1.0))
      class="kw">return class="kw">false;
   if(!Output.BufferCreate(OpenCL))
      class="kw">return class="kw">false;
class=class="str">"cmt">//---
   if(CheckPointer(Gradient)==POINTER_INVALID)
     {
      Gradient=new CBufferDouble();
      if(CheckPointer(Gradient)==POINTER_INVALID)
         class="kw">return class="kw">false;
     }
   if(!Gradient.BufferInit(numNeurons+class="num">1,class="num">0.0))
      class="kw">return class="kw">false;
   if(!Gradient.BufferCreate(OpenCL))
      class="kw">return class="kw">false;
class=class="str">"cmt">//---
   if(numOutputs>class="num">0)
     {
      if(CheckPointer(Weights)==POINTER_INVALID)
        {
         Weights=new CBufferDouble();
         if(CheckPointer(Weights)==POINTER_INVALID)
            class="kw">return class="kw">false;
        }
      class="type">int count=(class="type">int)((numNeurons+class="num">1)*numOutputs);
      if(!Weights.Reserve(count))
         class="kw">return class="kw">false;
      for(class="type">int i=class="num">0;i<count;i++)
        {
         class="type">class="kw">double weigh=(MathRand()+class="num">1)/class="num">32768.0-class="num">0.5;
         if(weigh==class="num">0)
            weigh=class="num">0.001;
         if(!Weights.Add(weigh))
            class="kw">return class="kw">false;
        }
      if(!Weights.BufferCreate(OpenCL))
         class="kw">return class="kw">false;
   class=class="str">"cmt">//---
      if(CheckPointer(DeltaWeights)==POINTER_INVALID)

「在 GPU 上跑通前向传播」

把神经网络搬到 MT5 的 OpenCL 后端,关键不在于算法多花哨,而是把权重、输入、输出三类缓冲正确绑到内核参数上。下面这段 CNeuronBaseOCL::feedForward 展示了单次层间传递怎么调度。 global_work_size[0] 直接取 Output.Total(),也就是本层神经元数量;每个工作项算一个神经元的激活值。SetArgumentBuffer 连续绑定来源层权重索引、来源层输出索引、本层输出索引,再塞进来源神经元数与激活函数类型,一次 Execute 就并行算完。 初始化时若 opencl 指针有效且 Initialize 失败,立即 delete 退回 CPU 路径;通过后再按 desc.type 分支 new 出 CNeuronBaseOCL 并 Init(outputs,0,opencl,desc.count)。count 决定该层规模,外汇与贵金属行情的高频特征工程里,层宽设 64~256 在多数核显上延迟倾向更平稳,但仍需你在 MT5 策略测试器实测。 跑完 Execute 别漏掉 Output.BufferRead(),否则主机侧读到的还是旧显存。显存对象生命周期由 CBufferDouble 管理,析构前若提前 return false,要确认缓冲已释放,避免 MT5 终端累计显存泄漏。

MQL5 / C++
  {
   DeltaWeights=new CBufferDouble();
   if(CheckPointer(DeltaWeights)==POINTER_INVALID)
     class="kw">return class="kw">false;
   }
   if(!DeltaWeights.BufferInit(count,class="num">0))
     class="kw">return class="kw">false;
   if(!DeltaWeights.BufferCreate(OpenCL))
     class="kw">return class="kw">false;
  }
class=class="str">"cmt">//---
   class="kw">return true;
   }
class="type">bool CNeuronBaseOCL::feedForward(CObject *SourceObject)
  {
   if(CheckPointer(SourceObject)==POINTER_INVALID)
     class="kw">return class="kw">false;
class=class="str">"cmt">//---
   CNeuronBaseOCL *temp=NULL;
   class="kw">switch(SourceObject.Type())
    {
     case defNeuronBaseOCL:
       temp=SourceObject;
       class="kw">return feedForward(temp);
       break;
    }
class=class="str">"cmt">//---
   class="kw">return class="kw">false;
   }
class="type">bool CNeuronBaseOCL::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(CheckPointer(OpenCL)==POINTER_INVALID || CheckPointer(NeuronOCL)==POINTER_INVALID)
     class="kw">return class="kw">false;
   class="type">uint global_work_offset[class="num">1]={class="num">0};
   class="type">uint global_work_size[class="num">1];
   global_work_size[class="num">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,(class="type">int)activation);
   if(!OpenCL.Execute(def_k_FeedForward,class="num">1,global_work_offset,global_work_size))
     class="kw">return class="kw">false;
   Output.BufferRead();
class=class="str">"cmt">//---
   class="kw">return true;
   }
   opencl=new COpenCLMy();
   if(CheckPointer(opencl)!=POINTER_INVALID && !opencl.Initialize(cl_program,true))
     class="kw">delete opencl;
     if(CheckPointer(opencl)!=POINTER_INVALID)
       {
       CNeuronBaseOCL *neuron_ocl=NULL;
       class="kw">switch(desc.type)
         {
          case defNeuron:
          case defNeuronBaseOCL:
            neuron_ocl=new CNeuronBaseOCL();
            if(CheckPointer(neuron_ocl)==POINTER_INVALID)
             {
             class="kw">delete temp;
             class="kw">return;
             }
            if(!neuron_ocl.Init(outputs,class="num">0,opencl,desc.count))
把重复劳动交给小布
小布盯盘的 AIGC 已内置对多设备算力的诊断视图,打开对应品种页即可看到主线程与显卡负载分布,你只需判断要不要把模型训练挪到 OpenCL。

常见问题

终端为每个智能交易系统单独分配一个主线程,指标虽能额外开线程但过多计算会拖慢报价处理,因此重型并行更适合走 OpenCL 或第三方 DLL。
通常把逐层矩阵运算写成独立内核,前馈内核算激活值,反向传播内核算梯度,权重更新再单独提交,由主机端类统一调度显存缓冲。
小布目前提供算力占用与瓶颈可视化,不直接托管训练任务;你可依据它的负载提示决定是否将 EX5 内的 OpenCL 部分迁移到空闲显卡。
OpenCL 代码不依赖具体设备,只要目标机有支持的运行环境,单个 EX5 即可迁移,不像 DLL 还需配权限和第三方库。
不冲突,同层神经元互无依赖,可并行;层间仍保持顺序,OpenCL 按层提交内核即可兼顾正确性与加速。