本文的目标是帮助对于深度学习硬件加速器设计感兴趣的朋友快速上手基于FPGA的深度学习加速器设计。
准备
以下是阅读本文的基础,请做好下列基础准备后再上手加速器设计:
C语言设计:熟练掌握C语言语法。计算机体系结构知识:参考书《计算机组成与设计》,不需要熟读全书,但要对一些加速器设计相关的基础概念有比较清晰的理解和认识,如流水线、数据并行等。
高层次综合
利用高层次综合工具,开发者只需要编写高级语言的代码完成程序功能,就能将高级语言编写的代码综合成相同功能的RTL级实现(基于Verilog或VHDL)。开发者还可以通过添加一些pragma的方式来指示和调整高层次综合工具生成的硬件模块的架构。整体而言,利用高层次综合工具进行FPGA硬件开发的过程,应该是利用软件语言的表达来描述硬件模块的过程。目前,高层次综合的代码都是基于C/C++/OpenCL的,所以对于没有硬件设计基础的朋友来说,利用高层次综合工具可以大幅度地降低学习难度,缩短开发周期,加快设计迭代速度。
3天入门实例
我们需要使用一个简单的实例来进行入门学习。既然目标是让大家快速上手深度学习加速器的硬件设计,那么我们的实战示例就选择使用目前最火爆、最具代表性的深度学习算法——卷积神经网络(CNN)。
我们选取卷积神经网络前向计算中耗时最长的卷积操作作为我们加速设计的目标。接下来,需完成以下步骤:
1.准备工作(1天)下载XilinxVivadoHLS或XilinxSDx工具链,利用官方Userguide熟悉软件工具的使用,包括:新建工程、配置工程参数、综合流程等。
2.软件实现(1天)实现卷积层的软件版本(C语言版本),并封装成一个顶层函数。综合实现,结合高层次综合工具的report和analyze工具分析理解所生成的硬件架构和预计性能。卷积运算的流程如下图所示:
整个卷积层的输入是CHin张输入特征图,输出是CHout张输出特征图,每张输出特征图的大小为R×C。由于每一个「输入-输出」特征图对都有一个特定的卷积核用于卷积计算,所以总共有CHout×CHin个卷积核,每个卷积核的大小为K×K。在进行卷积计算的过程中,每个卷积核滑过各自的输入特征图,并使用当前滑过的窗口中的输入特征与卷积核内的权重完成卷积计算(对应位置相乘,所有乘积累加),卷积的结果会累加到对应位置的输出特征上。因此,卷积计算的算法流程如下式所示:
相应地,卷积层前向计算的软件版本代码如下所示:
本质上来说,卷积层前向计算的流程就是一个嵌套的6重循环,而在循环的最内层进行的是乘累加运算。在我们的示例中,我们用如下代码放入HLS工具中进行综合分析:
其中,数据类型我们指定为单精度浮点(float),网络层参数如下:
设置硬件周期为10ns,在VivadoHLS.3中综合得到该模块运行延迟和资源开销报告,其中延迟报告为个时钟周期(具体数字可能略有差异)。
3.HLS优化(1天)在实现了卷积层的软件版本后,我们可以尝试对该代码进行硬件并行优化,这里我们用一个简单的加速设计来帮助大家理解HLS的优化方法。从上面的卷积流程分析,我们不难发现:卷积计算过程中,不同通道的输入/输出特征图在参与计算的过程中没有数据依赖关系,因而是可以并行处理的。在我们这个简单的小例子中,我们计划将输入通道(InputChannelLoop)和输出通道(OutputChannelLoop)这两个维度进行并行加速优化。因此,我们想要实现的加速器核心模块示意图如下:
我们使用CHin个并行的乘法器来并行处理不同通道的输入特征与其对应权重的乘法,这些并行乘法的乘积累加到一起,即为一个输出通道的的卷积结果,这里,我们把该模块称为一个处理单元(ProcessingElement,简称PE,即上图中蓝色框部分)。一个PE只负责一个输出通道的卷积计算,我们可以把PE复制多份,形成上图的结构,来并行处理所有输出通道的卷积计算。总结来说,这个加速器调用一次可以并行处理包含CHin个输入特征点和CHout个输出特征点的卷积计算,而其中所有输入特征点都属于不同的输入通道,输出特征点也分属于不同的输出通道。要完成整个卷积层6重循环的计算,我们需要重复多次调用这个加速器。
现在我们就需要使用HLS来将上文设计的加速器描述出来,主要进行的代码改动包括以下三部分:
循环重构:由于我们的加速器是在输入通道和输出通道两个维度进行并行化,完成卷积计算需重复调用加速器多次,因此,我们需要将输入通道循环和输出通道循环放在最内层循环中。由于最内层的乘累加操作满足结合律,卷积的6重循环的顺序可以直接调整,而无需其它改动。数组划分:从上面加速器设计我们可以看出,我们需要并行地访问输入特征(In)、输出特征(Out)和权重(W),而对应的并行度分别为CHin、CHout和CHout×CHin。因此,在FPGA实现的时候,我们需要将这些数据划分到多个RAM块中以满足并行访问的需求。我们可以使用ArrayPartition来完成数组划分,该pragma的具体语法请参考Xilinx官方文档。循环展开:为了描述出我们的加速器在输入通道和输出通道两个维度进行了并行优化,我们需要使用pragmaUNROLL来将这两个循环完全展开,pragmaUNROLL的具体语法请参考Xilinx官方文档。
综合上述改动后,我们的代码如下所示:
在HLS工具中重新综合,我们可以发现延迟降到了个时钟周期(具体数字可能略有差异),有了6.82倍的加速效果。但当我们仔细看HLS的综合报告时可以发现,虽然我们实现了一个并行加速器,可是这个加速器调用并没有流水化起来:
因此,我们可以尝试进一步优化提升性能:使用pragmaPIPELINE将加速器设计流水化起来,该pragma的具体语法请参考Xilinx官方文档。在使用pragmaPIPELINE以后,之前的pragmaUNROLL可以去掉以精简代码,这是由于HLS工具会自动将需要流水化的循环内部的所有子循环展开,这个优化会在HLS工具的Console里显示。因此,我们的代码调整如下:
在HLS工具中重新综合,发现延迟降到了个时钟周期(具体数字可能略有差异),性能进一步提升了22.95%。HLS的综合报告里也显示加速器调用也已经流水化了:
从上面的综合报告我们可以发现一个细节,虽然整个循环已经流水化了,但是InitiationInterval却仍然不是理想情况的1,即:不能做到每个周期都开始一个新的Iteration的计算。那么问题出在哪里呢?还有没有进一步优化的空间呢?
通过分析代码和HLS工具的Warning我们可以发现,问题出在Out这个数组上。在我们的实现中,Out数组在内层循环的一个Iteration中参与了自加运算(+=),即:先被读,后被写。如下图所示,在执行Iteration0的时候(r=c=kr=kc=0),Out[0][0][0]先被读,然后被写;然而在接下来执行计算Iteration1(r=c=kr=0,kc=1)的时候,仍然是Out[0][0][0]先被读,然后被写。因此,如果我们使用pragmaPIPELINE进行性能优化,相邻的这两个Iteration都需要操作Out[0][0][0]这个位置的数据,从而产生写后读(RAW)的数据依赖,即:程序必须等待Iteration0对Out[0][0][0]的写操作完成后,才能开始执行Iteration1。为了保证程序结果的正确性,HLS工具不会将这部分循环完全流水化,进而导致性能的下降。
通过观察我们可以发现:Out数组的在程序中的访问位置,只和r、c这两个循环变量相关,而和kr、kc无关。我们可以利用这一点解决RAW数据依赖的问题。为了能将内层循环的计算完全流水化,我们决定将KernelRow和KernelColumn两重循环移到外层。如下图所示,将循环流水化时,相邻Iteration所访问的Out数组的数据位置是不同的,不存在数据依赖,可以流水执行;而有RAW数据依赖的Iteration将在很远的时间点发生(R×C个Iteration之后),此时对于该位置的写操作早已完成,读操作可以正常进行。这样一来,我们实现了一个完全流水化的硬件架构,提升了加速器的整体处理性能。
按照上述优化调整后的代码如下:
在HLS工具中重新综合,发现延迟降到了仅仅个时钟周期(具体数字可能略有差异),相对于原始无优化版本的加速达到了.06倍!HLS的综合报告里显示加速器调用也已经完全流水化了:
综上,我们就完成了一个高效的卷积运算加速器,而基本上利用HLS工具设计FPGA硬件加速器的入门也就完成了。总结来说,使用HLS设计FPGA加速器的一般化设计流程如下:
熟悉并理解目标算法,通过软件运行目标算法,分析性能瓶颈所在;实现加速目标的软件版本,分析其中可以并行、流水化的部分,并构想可行的硬件架构;通过代码重构,加pragma等方法在HLS工具中描述目标架构,此过程需注意保证改写的代码功能性上与原代码严格保持一致;调整硬件参数配置,最大化利用硬件资源(计算资源如DSP、存储资源如BRAM)来最大化加速器的性能。
30天精通学习
在完成了上面的3天入门实例后,大家可以进一步学习和实践FPGA加速器的设计,这一部分我们推荐大家利用3到4周的时间对相关知识进行详细、系统的学习。高层次综合的相关知识的学习我们推荐学习Xilinx公司推出的《ParallelProgrammingforFPGAs(中文版)》,该教程的下载地址是