GPU上的神经网络






4.90/5 (69投票s)
本文介绍了使用CUDA实现神经网络。
引言
人工神经网络是一种信息处理方法,其灵感来源于生物神经系统(如大脑)处理信息的方式。它由大量高度互连的处理单元(神经元)组成,这些单元协同工作以解决特定问题。神经网络已被广泛用于“类比”信号分类,包括手写、语音和图像识别。神经网络还可以用于电子游戏,使游戏能够从玩家行为中自适应学习。这种技术已被用于赛车游戏,例如由计算机控制的对手车辆可以学习如何像人类玩家一样驾驶。
由于神经网络需要大量的向量和矩阵运算才能获得结果,因此非常适合在并行编程模型中实现并在图形处理单元(GPU)上运行。我们的目标是利用并释放GPU的强大功能,以提高神经网络解决手写识别问题的性能。
该项目最初是我们的图形架构课程项目。我们在GPU上运行了Mike O'Neill在其精彩文章“用于识别手写数字的神经网络”中所描述的同一个神经网络。
关于神经网络
神经网络由两种基本元素组成:神经元和连接。神经元通过连接相互连接形成网络。这是一个对人脑的简化理论模型。
神经网络通常有多个层;某个层的神经元以某种方式连接到下一层的神经元。它们之间的每个连接都被分配一个权重值。起初,输入数据被馈送到第一层的神经元,通过计算所有连接的第一层神经元的加权和,我们可以得到第二层神经元的神经元值,依此类推。最后,我们可以到达最后一层,即输出层。操作神经网络所涉及的所有计算都是一系列点积。
神经网络的秘密在于权重值。正确的权重值使其完美。然而,一开始我们不知道这些值。因此,我们需要用样本输入来训练我们的网络,并将结果与我们期望的答案进行比较。某些算法可以以错误作为输入并修改网络权重。如果足够耐心,神经网络可以被训练到达到高精度。

我们实现的神经网络是一个称为卷积神经网络的5层网络。这种网络已被证明适合识别手写数字。有关更多理论细节,请查阅Mike的文章和他列出的参考文献。
我们的神经网络的前三层由多个特征图组成。其中每个特征图都是从前一层收缩而来的。我们的输入是一个29*29的数字图像。因此,第一层有29*29=841个神经元。第二层是一个具有6个特征图的卷积层。每个特征图是一个13*13的图像,是从第一层采样的。特征图中的每个像素/神经元是输入层的5*5卷积核。因此,这一层有13*13*6 = 1014个节点/神经元,(5*5+1)*6 = 156个权重,1014*(5*5+1) = 26364个连接到第一层。
第三层也是一个卷积层,但具有50个较小的特征图。每个特征图的大小为5*5,这些特征图中的每个像素都是前一层所有6个特征图的相应区域的5*5卷积核。因此,这一层有5*5*50 = 1250个神经元,(5*5+1)*6*50 = 7800个权重,以及1250*26 = 32500个连接。
第四层是一个具有100个神经元的全连接层。由于它是全连接的,这一层中的每个100个神经元都连接到前一层的所有1250个神经元。因此,它有100个神经元,100*(1250+1) = 125100个权重,以及100x1251 = 125100个连接。
第五层是最终的输出层。这一层也是一个具有10个单元的全连接层。这一层中的每个10个神经元都连接到前一层的所有100个神经元。第五层有10个神经元,10*(100+1) = 1010个权重,以及10x101 = 1010个连接。
正如你所见,尽管结构简单,这个神经网络是一个庞大的数据结构。
之前的GPU实现
Fast Neural Network Library (FANN) 在GPU上有一个非常简单的神经网络实现,使用GLSL。每个神经元被表示为纹理像素的单个颜色通道。这个网络非常具体;神经元的取值范围为0到1,精度只有8位。这个实现利用硬件加速的点积函数来计算神经元。神经元和权重都存储在纹理图中。
这个实现是直接且简单的,但也是有限的。首先,在我们的神经网络中,每个神经元需要32位浮点精度。由于我们的网络有五层,第一层损失的精度会累积并改变最终结果。而且,由于手写识别系统需要足够敏感以检测不同输入之间的细微差别,因此使用仅8位来表示神经元是不可接受的。其次,普通的神经网络将神经元值映射到0到1的范围。然而,在我们的程序中,专门为手写识别设计的神经网络有一个特殊的激活函数,将每个神经元的值映射到-1到1的范围。因此,如果神经元像FANN库那样由单个颜色值表示,我们的神经元将进一步损失精度。最后,FANN方法使用点积来计算神经元,这适用于全连接的神经网络。在我们的实现中,神经网络是部分连接的。在我们神经网络上执行的计算涉及大向量的点积。
我们的实现
由于上述GLSL的所有不便之处,我们最终选择了CUDA。神经网络之所以适合GPU,是因为神经网络的训练和执行是两个独立的过程。一旦正确训练,在使用神经网络时就不需要写访问。因此,没有需要解决的同步问题。此外,同一网络层上的神经元是完全隔离的,因此神经元值计算可以实现高度并行化。
在我们的代码中,第一层的权重存储为数组,这些输入被复制到设备。对于每个网络层,都有一个CUDA函数处理该层的神经元值计算,因为并行化只能在一个层内实现,并且层与层之间的连接是不同的。神经网络的连接在CUDA函数中通过下一层神经元计算的方程隐式定义。我们的代码中没有显式的连接数据结构。这是我们的代码与Mike的CPU版本之间的一个主要区别。

例如,第二层神经元的每个神经元值是25个第一层神经元和一个偏置的加权和。第二层神经元由6个特征图组成;每个特征图的大小为13*13。我们为每个特征图分配一个blockID
,为特征图上的每个神经元分配一个threadID
。每个特征图由一个块处理,图上的每个像素由一个线程处理。
这是计算第二层网络的CUDA函数
__global__ void executeFirstLayer
(float *Layer1_Neurons_GPU,float *Layer1_Weights_GPU,float *Layer2_Neurons_GPU)
{
int blockID=blockIdx.x;
int pixelX=threadIdx.x;
int pixelY=threadIdx.y;
int kernelTemplate[25] = {
0, 1, 2, 3, 4,
29, 30, 31, 32, 33,
58, 59, 60, 61, 62,
87, 88, 89, 90, 91,
116,117,118,119,120 };
int weightBegin=blockID*26;
int windowX=pixelX*2;
int windowY=pixelY*2;
float result=0;
result+=Layer1_Weights_GPU[weightBegin];
++weightBegin;
for(int i=0;i<25;++i)
{
result+=Layer1_Neurons_GPU
[windowY*29+windowX+kernelTemplate[i]]*Layer1_Weights_GPU[weightBegin+i];
}
result=(1.7159*tanhf(0.66666667*result));
Layer2_Neurons_GPU[13*13*blockID+pixelY*13+pixelX]=result;
}
所有其他层都以相同的方式计算;唯一的区别在于计算神经元的方程。

主程序首先将所有输入数据传输到GPU,然后按顺序调用每个CUDA函数,最后得到答案。

用户界面是使用C#的一个独立程序。用户可以用鼠标在输入板上绘制一个数字,然后程序会生成一个29*29的图像并调用内核神经网络程序。内核将读取输入图像并将其馈送到我们的神经网络。结果也通过文件返回,然后由用户界面读回。
这是截图。绘制一个数字后,我们可以得到最后一个网络层的所有10个神经元值。最大神经元值的索引是最可能的数字。我们根据其可能性以不同深度的红色对候选者进行着色。
右侧,用户界面将打印出前三层的特征图。
请注意,Windows XP下的C#存在分辨率问题。我们在120dpi下测试了我们的程序。96dpi的分辨率设置可能会导致输入图像移位,从而严重影响精度。
我们的GPU实现中不包含训练部分。我们使用Mike的代码来训练所有权重,并将它们缓存到文件中。结果
准确性
我们的神经网络可以达到95%的准确率。我们用来训练网络的数据库称为MNIST,包含来自不同人的60000个手写样本。LeCun博士报告说,这个网络可以在大约25次训练后收敛。这个数字也得到了我们的测试证实。在60000个输入中,我们只实现了大约1400个识别错误样本。
另外请注意,Mike的代码中存在一个bug。这是初始化第二层的更正后的代码。
for ( fm=0; fm<50; ++fm)
{
for ( ii=0; ii<5; ++ii )
{
for ( jj=0; jj<5; ++jj )
{
// iNumWeight = fm * 26; // 26 is the number of weights per feature map
iNumWeight = fm * 156; // 156 is the number of weights per feature map
NNNeuron& n = *( pLayer->m_Neurons[ jj + ii*5 + fm*25 ] );
n.AddConnection( ULONG_MAX, iNumWeight++ ); // bias weight
for ( kk=0; kk<25; ++kk )
{
// note: max val of index == 1013, corresponding to 1014 neurons in prev layer
n.AddConnection( 2*jj + 26*ii + kernelTemplate2[kk], iNumWeight++ );
n.AddConnection( 169 + 2*jj + 26*ii + kernelTemplate2[kk], iNumWeight++ );
n.AddConnection( 338 + 2*jj + 26*ii + kernelTemplate2[kk], iNumWeight++ );
n.AddConnection( 507 + 2*jj + 26*ii + kernelTemplate2[kk], iNumWeight++ );
n.AddConnection( 676 + 2*jj + 26*ii + kernelTemplate2[kk], iNumWeight++ );
n.AddConnection( 845 + 2*jj + 26*ii + kernelTemplate2[kk], iNumWeight++ );
}
}
}
}
有关此bug的详细信息,请参阅此处。
我们的GPU实现基于正确版本,但精度方面没有太大差异。
性能
使用GPU计算神经网络的主要原因是实现鲁棒性。与CPU实现相比,结果是令人鼓舞的。如上表所示,比较了GPU版本、EmuRelease版本和CPU版本在处理单个输入样本时的执行时间。GPU版本比CPU版本快270倍,比EmuRelease版本快516.6倍。为了更精确,我们还考虑了GPU版本的IO时间消耗。正如我们所见,即使考虑了IO时间,我们的方法也快了10倍。在实际使用中,权重值只需要加载到设备一次。
历史
- 2008年3月14日:首次发布