FPGA教学——用硬件思维描述HLS设计
2022-09-02
来源: LaurenGao FPGA技术驿站
ESL (Electronic System Level)设计理念最早可追溯至2001年,其核心思想是通过高层次语言如C/C++或图形设计工具描述或搭建系统行为并对其进行仿真验证。于是,就形成了两个分支。分支一是从高层次语言角度出发,对应产生了如Xilinx Vitis HLS (High Level Synthesis)工具;分支二是从模块化设计角度出发,对应产生了如Mathworks的HDL Coder、Xilinx的Vitis Model Composer等工具。这些工具在其适用的场合可有效加速设计开发的进度,缩短开发周期。
那么如何充分发挥工具的性能得到高质量的结果呢?首先要理解其工作原理。这里我们以Vitis HLS为例加以说明。Vitis HLS要求采用C/C++描述算法和测试平台,其基本流程如下图所示。
整个流程都是围绕C/C++模型展开的,这往往给初学者一个误导:只要是C/C++代码就可以通过Vitis HLS转换为RTL代码。实际上,从原始C/C++代码到最终生成高质量的RTL代码之间存在“鸿沟”:C/C++代码是否可综合(转换为RTL代码)?C/C++代码是否可转换成满足实际工程需求(速度与面积)的RTL代码?前者解决从无到有的问题,后者解决从有到优的问题。从语言特征的角度来看,C/C++与HDL (VHDL/Verilog)有着本质的区别。第一,C/C++是顺序执行的,而HDL是并行执行的。因此,采用C/C++描述算法时,算法的执行顺序可通过语言的描述顺序直观地体现出来。HDL(Hardware Description Language)描述的是硬件电路,一旦上电,所有电路单元并行工作,HDL的并行特性正体现了硬件电路的这一特征。第二,C/C++是静态的,HDL是动态的。所谓静态是指我们在使用C/C++描述算法时,只需关注算法本身,而使用HDL描述算法时,我们要关注的是如何将算法映射为硬件电路,关注每个时钟周期电路应实现的行为。电路在时钟的作用下工作,数据在时钟的作用下流动。第三,C/C++是没有时序性的,而时序是HDL的一个显著特征。无时序可以使设计者将焦点放在算法的描述上,得益于此,设计者可以采用C/C++快速完成算法建模。HDL的时序特性要求设计者尽可能采用流水线的方式使数据在各个处理单元之间流动,同时设计者还要管理好每个处理单元完成操作所需要的时钟周期个数,保证在期望的时钟周期个数之后获得目标结果。时序性还要求设计者在进行电路描述时要考虑到后期的时序收敛,因此设计过程中考虑关键路径的逻辑级数、扇出等因素变得尤其重要,这也导致了传统RTL代码设计流程比较耗时。
尽管C/C++和HDL存在巨大差异,但两者并不是彻底地割裂开来,而是隐含着一些对应关系,这些对应关系对于我们描述HLS设计大有裨益。C/C++是顺序执行的,HDL也存在顺序执行的电路,那就是状态机。因此,对于C/C++中的for循环,从状态机的角度看,可分为空闲状态->进入循环->执行循环体->判断循环是否结束->退出循环这样几个状态。HLS会将for循环映射为相应的状态机。这几个状态中,执行循环体最为耗时,尤其是涉及到大量计算时,往往成为for循环Latency的瓶颈。用HDL描述状态机时,我们要考虑状态转移条件、每个状态持续的时钟周期个数。就for循环而言,进入/退出for循环各需要一个时钟周期,单次for循环所需的时钟周期个数取决于循环体内的操作。整个循环所需的时钟周期个数与循环次数紧密相关,这就要求我们使用for循环时尽可能保证循环边界是固定常数。C/C++最常用的一种数据类型之一是数组,数组其实就是一段存储数据的空间,既然可以存储数据,那么这个空间是有记忆的。FPGA中的记忆元件包括寄存器和RAM。因此,数组最终都可以映射为这两类原件。在HDL中描述RAM时,我们必须指定RAM的深度和宽度,从而使得工具可以在FPGA中分配固定的存储单元。尽管C/C++支持动态可调的数组深度,但应用于HLS时,却是不支持的。换言之,HLS要求C/C++中的数组必须为固定深度和固定宽度,这和HDL的要求是一致的。
除了这些对应关系,Vitis HLS还对原本的C/C++进行了改进,以更友好地匹配硬件需求。C/C++中的各种运算如加/减/乘/除/逻辑运算/关系运算等,设计者在使用这些运算时为了获得更大的动态范围以防止溢出或精度损失,往往采用浮点类型或较为宽泛的int类型。这些运算转换为RTL代码时,都会消耗相应的逻辑资源,包括查找表、触发器、DSP等。显然,较大的数据位宽也会消耗更多的资源。为此,Vitis HLS引入了任意精度类型包括整型和定点类型,数据位宽从1到1024,从而打破了原始C/C++以8为边界的位宽的限制。更为重要的是,该数据类型可以完全匹配原始C/C++所支持的算术操作符。
有了这些知识储备,我们就不难理解Vitis HLS的工作原理了。本质上,Vitis HLS在将C/C++转换为RTL代码时分为三大过程:进度安排(Scheduling)、绑定(Binding)和状态提取。进度安排实际上解决的是什么时候做什么事,进一步而言就是每个时钟周期需要执行的操作。绑定解决的是完成这些事需要什么资源,进一步而言就是这些确定操作需要消耗的硬件资源。状态提前则是从C/C++代码中提取出状态机,控制子函数/子操作的执行顺序。
从宏观来看,对于算法较为复杂(分支条件繁多、存在反馈路径或判断条件复杂)的情形,采用HLS实现是一个很好的选择。一个总体原则是尽可能将反馈路径封装在一个函数之内,确保从顶层函数看到的数据流是单向的,如下图所示,这有利于工具使用DATAFLOW(一种pragma)提高设计的吞吐率。
从微观来看,除了考虑数据位宽这一因素之外,还要考虑数组的访问方式。数组往往映射为RAM,而一个RAM最多提供两个输出端口,这意味着一个时钟周期最多读出两个数据。因此,对于数组的访问,我们尽可能做到减少访问次数提高读取数据的复用率。我们看下面这个案例。函数opt_mem_v1实现的是相邻三个数相加,这三个数来自于同一个数组。每次循环要从指定数组中读取3个数据。映射为RAM时,一个时钟周期内从同一个RAM中读出3个数据是难以实现的。从Schedule视图上也能看到RAM端口的局限性。为此,我们做如下改动,如函数opt_mem_v2所示,先从数组中读出0号地址和1号地址上的数据并将其赋给指定变量,这样每次循环只用从原始数组中读出一个新的数据而其他两个数据可以继承之前的输出结果,从而有效减少了数组的访问次数。
对于for循环,尽可能合并同边界for循环,涉及到if条件的,尽可能将if放在for循环之内。我们看一个案例,如下图所示,在不同边界条件下将读取数据赋值给不同的目标数组。这4个for循环是按顺序执行的,共消耗203个时钟周期(66+50+42+38)。从Schedule视图也能看到4个for循环的执行顺序。
实际上,这4个for循环完全可以合并,如下图所示,这样Latency将从203直接降低到66。
使用HLS对C/C++的要求并不高:不需要设计者掌握C++的高级用法,比如类,但却要求设计者具备基本的硬件知识,明白两者的对应关系,理解HLS的工作原理,这样才能写出适配HLS的高效C/C++代码,再应用合适的pragma,就可以获得高质量的C综合结果。
更多信息可以来这里获取==>>电子技术应用-AET<<