神经网络变得轻松(第五部分):OpenCL 中的多线程计算·进阶篇
(2/3)· 单线程 EA 训练神经网络指数级变慢,视频卡却几乎全程空转
◍ 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 跑一遍再接信号更稳妥。
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)) 近似导数。外汇与贵金属模型用这类核时,过拟合风险偏高,建议小批量多跑几轮验证梯度数值是否对得上反向传播手算值。
__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,能明显看到权重更新步长收缩、训练曲线更平滑,但收敛可能更慢——外汇与贵金属行情高波动,用此类网络做信号生成务必先离线回测再上模拟盘。
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 年数据做样本外验证。
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()) 打乱,否则回测结果只是同一条路径的重复。
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 终端累计显存泄漏。
{
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))