防秒退提醒:如果你不懂 HLS 但是懂 FPGA,也可以读一下 Verilog 层面的优化手段。
前言高级综合(high-level synthesis,简称 HLS)能自动把 C/C++ 之类的高级语言转化成 Verilog/VHDL 之类的底层硬件描述语言(RTL),生成定制硬件在 FPGA 上跑,实现加速。这使得不懂硬件的软件工程师也可以拥有玩转硬件的能力。HLS 的存在已有多年,它的优点,极短的开发和验证时间,使得它在学术界非常活跃;但是它的效率却一直被硬件工程师诟病:再怎么优化的 HLS 的硬件,还是没有手写的 Verilog 香啊!
香是香,可是 HLS 到底输哪了?到底值不值得用?
斯坦福大学教授 Juan J. Alonso 也好奇啊,然后他就挑了一类低运算难加速的应用,流体流动的模拟中的模版计算(Stencil Computations),对两者的性能进行比较。使用 Xilinx Vivado HLS 来设计自动解拉普拉斯方程的代码,然后与手写 RTL 比较,从而评估 HLS 无法直接做到的,但是手写 RTL 可以实现的一些优化。这样可以帮助硬件工程师更加了解 HLS,也可以在自己的工作中找到替代办法。今天我来分享一下第一个问题的答案,然后让读者有能力自己来回答第二个答案。
模版计算是什么?模板计算上是一类处理多维数组的程序,通过使用其最近邻居的值来更新每个数组单元的值。这些相邻的单元由模板确定,模板是定义任何给定单元的邻域的固定模式。例如,在二维矩阵中,除了一个单元的四个正交邻居(即上,下,左和右邻居单元)外,五点模板还包括中心单元。另一方面,九点模版还包括四个对角线邻居。因此,模版计算的基本思想可以扩展到邻居的邻居。因为需要几个相邻的单元来计算单个单元值,并且操作数量不是很重要,所以模板计算的算术强度较低,因此对带宽的要求很高。另一方面,模版计算是固有地并行的,因为每个单元可以同时更新,并且只有直接邻居的访问才能导致较高的数据复用性。
本文选用了五点模板和 Jacobi 迭代方案 来求解拉普拉斯方程( ),这个算法和许多科学计算应用中使用的基于模板的运算类似。该算法在二维统一网格中实施,经过一系列迭代,通过平均每个内部单元的邻居,从输入一个生成输出矩阵。矩阵的边界单元保持不变,并且可以使用简单的副本进行更新。但是,通过将输入和输出矩阵初始化为相同的值,可以消除复制步骤,并且可以跳过边界单元。在每个时间步结束时交换输入和输出矩阵,然后循环继续进行,直到两个矩阵以适当的误差范数收敛。使用双精度浮点变量执行计算,并通过将均方差与用户设置的容差进行比较来确定收敛。
为了利用算法的局部性,文章以多个并行处理元素(processing elements,简称 PE),每个元素处理一个16行和256列的子矩阵。PE 会在一个时间间隔中独立运行,但不会跨间隔独立运行,因为需要在每次迭代结束时执行收敛检查,然后才能进行下一个时间步,并且直到所有PE完成当前的处理才能执行。此外,每个子矩阵边缘的边界单元将必须从相邻的PE中获取其四个邻居之一,并且必须进行同步以确保检索更新后的值。此类单元称为“晕圈单元”(halo cells),它们的高效及时交换是高性能实施中的关键因素。为了在PE之间简单有效地交换光环单元,PE 垂直堆叠以将光晕限制在顶部和底部的PE行中。PE 则被包装在顶层引擎模块中,该模块控制收敛检查的时间间隔。
每个 PE 都由管理输入和输出矩阵的 BRAM 控制器模块,计算每个单元的新值的平均模块以及计算 PE 累积误差的错误模块组成。PE 的基本操作包括在每个时间步中遍历两次输入矩阵。在第一遍中,从输入 BRAM 控制器中检索目标单元的四个邻居,并将其传递到平均模块。一旦计算值准备就绪,它将被从平均模块传递到输出 BRAM 控制器进行存储。在第二遍中,将输入矩阵中的原始值和存储在输出矩阵中的新计算的值传递到错误模块,然后将最终累积的错误转发到引擎模块以进行收敛性检查,基于所有PE计算的总错误。
HDL 流水线设计通过遵循多个优化方向来实现高性能。首先,通过消除内存瓶颈来实现每个周期一个单元更新的理想PE吞吐量。接下来,通过消除时间步和光环管理中的控制开销来减少运行时间。最后,通过有效利用硬件资源,FPGA可以容纳更多的PE,以实现更高的计算能力。下面详细描述每种优化技术:
1. BRAM 分割
在第一遍中,平均模块需要访问四个邻居,而BRAM块仅支持两个端口,从而导致每个单元更新的模块吞吐量为两个周期。但是,通过观察预定义的访问模式,我们注意到平均功能执行的四个访问仅需要两个具有相同奇偶校验的像元。这样,通过将 PE 的 BRAM 存储器划分为两个块,并使用单元的奇偶校验位索引到 BRAM 块中,可以在一个周期内检索所有四个邻居,而不会发生冲突并且开销最小。因此,通过有效地使存储器端口加倍,平均模块的吞吐量提高到每个周期一个单元更新。
2. 单次遍历
为了实现每个周期更新一个小区的 PE 吞吐量,必须融合两次遍历,以使 PE 每个时间间隔仅遍历其矩阵一次。因此,PE 必须在任何给定的周期内最多可以执行5次存储器读取,因为 RTL 设计的流水线性质必须允许平均和误差计算步骤作为不同流水线阶段的一部分同时发生。进一步的 BRAM 分区不能为错误计算所需的第五次访问提供有效的解决方案,因为它将引入复杂的访问管理以避免访问冲突。或者,通过观察当前单元格的右邻居将是下一个周期中的目标单元格来利用数据访问模式,一旦获得下一个单元格的错误,该值将与检索该值相同以计算下一个单元格的错误。通过缓冲每个单元的右邻居直到计算出其下一个单元的输出,我们完全不需要第五个内存访问。由于平均模块的延迟为29个周期,因此使用了一个基于寄存器的小FIFO,其中包含32个条目,以保留正确的邻居,直到相应的输出可用为止。
3. 无缝数据交换
通过利用 PE 执行的锁步性质,PE 可以像访问本地的光环一样访问其他光环,而不必在每次迭代的单独的先前步骤中交换光环。之所以可行,是因为PE边界的单元仅使用四个可用BRAM端口中的三个,因此第四个端口可用于为相邻 PE 提供光晕。
4. 背对背迭代
假设矩阵尚未收敛,则可以通过背靠背运行时间步长来进一步减少总运行时间。因此,与其等待一个时间步完成之后不等待收敛测试结果,不如立即进行下一个时间步,同时进行用于收敛检查的误差计算。如果达到收敛,则中止时间步长,并返回输入矩阵,而不是输出矩阵。
5. 浮点除法优化
可以通过将昂贵的除以4.0运算和较便宜的0.25乘法相乘来减少硬件资源的数量。但是,更大的资源节省将利用 FPGA 在位级别上处理数据并将浮点运算转换为整数运算的能力。通过观察浮点除以4.0可以简单地将浮点数的尾数减少2,该浮点除法被十进制减法运算取代,从而每个PE释放10个DSP slice。
6. 浮点累加优化
利用每个 PE 的小数据量,通过将浮点累加器配置为最低资源使用率,我们将资源利用率降低了87%。这不会影响结果的准确性,因为累加器仅用于收敛测试,在最坏的情况下,累加器会为每个子矩阵的4096个像元引入2^-53的舍入误差,总计 10^-6 的误差。
Verilog 的优化结果
表1列出了 HDL 实施中执行的以上六种优化技术,以及它们对性能的影响。
用 C 写 HLS 下,顶层函数包括一个主循环,该循环循环遍历时间间隔,并在每次迭代中对 PE 函数进行多次调用,然后使用 PE 函数返回值的运行总和检查收敛性。因为 PE 是作为功能实现的,所以子矩阵放置在顶层函数内,而不是每个 PE 内。除了指向光环所在的其他 PE 的子矩阵的指针之外,每个 PE 还会接收到输入和输出子矩阵的指针。
使用这种简单的实现,会出现两个问题。首先,不可能进行简单的指针交换来在两个子矩阵之间交替,因为不允许使用指针到指针,并且必须静态分配指针。其次,即使两个函数都不会更改数组的数据,也不允许两个函数访问同一阵列。当 PE 正在读取其输入子矩阵而另一个 PE 正在读取相同的子矩阵以访问其光环时,就是这种情况。为了解决此问题,创建了专用的光环阵列来复制光环单元,并将这些阵列传递给 PE 函数,而不是相邻的子矩阵。类似于双矩阵方法,每个 PE 具有两个光环数组,一个用于输入,另一个用于输出,并且引入了一个新函数来初始加载光晕。但是,此功能的性能成本可以忽略不计,因为它只能运行一次。尽管光晕数组方法会导致额外的内存使用,但这对于成功的代码生成是不可避免的。对于矩阵交换,PE 调用被复制并使用两种指针可能的参数组合进行硬编码,并且使用条件语句根据迭代的奇偶校验选择合适的调用。
通过解决这些问题,基线 HLS 实现成功生成了 RTL 代码,尽管显示出非常差的性能。这种性能下降的主要原因是 HLS 中没有浮点累加器,而是使用了浮点二进制加法器。使用加法器进行累加的问题在于加法器输入对其输出的自相关性:当前加法器的输出是下一个加法器的输入,因此,每个加法运算必须在开始下一个加法器之前完成。这意味着无法进行流水线操作,并且在加法器具有11个周期的延迟的情况下,处理一个子矩阵至少需要花费其大小的11倍(以时钟周期为单位)才能完成一个时间步长。为了提高性能,Xilinx 建议通过将值循环分配到几个加法器上来累加值,在任何加法器收到其下一个累加输入之前,至少要经过11个周期。每个加法器维护一个部分和,并且一旦子矩阵处理完成,也使用树加法器将这些和相加。
在适用的情况下,HLS 实现中采用了 HDL 版本中使用的优化技术。通过使用实用程序对BRAM 块进行分区以增加内存端口,并且还实现了通过缓冲来重用右邻居,但没有明确使用FIFO。取而代之的是,当 PE 遍历其单元时,右邻居被缓存在变量中,并由下一个单元在下一次迭代中使用。为了便于流水线化,HLS在内部实例化一个FIFO以保持该值,直到下一个单元的输出可用为止。
HDL 版本中使用的资源减少技术不适用于 HLS,因此无法复制。而且也没有将浮点除法转换为整数加法的操作,因为使用高级语言不容易访问位操作,并且违反了使用高级语言的目的。对于浮点累加器优化的方法也不适用,因为使用了加法器。实际上,为了克服加法器用作累加器的延迟,必须过度使用资源来实现部分和。
最后,无法通过连续执行时间间隔来进一步减少运行时间,因为循环流水线需要展开所有内部循环,这是不可行的。至于优化光环管理,设计根据 PE 的位置来对光环分别进行了优化:第一个 PE(使用底部光环),最后一个(使用顶部光环)或中间的 PE(同时使用两个光环) 。因此,光晕管理的延迟开销被最小化,但是光晕的双重缓冲和相关的内存使用无法得到改善。
案例结果比较相较之手写 Verilog,HLS 能实现的优化十分有限:
对于 HLS 实施,浮点累加器的支持不足尤其成问题。加法器和部分和的使用引入了更高的延迟来计算部分和,并且由于包含部分和的数组,每个部分和的加法器以及用于最终求和的树加法器而增加了资源。BRAM 的过度使用则是另一个问题,使用8个块进行部分求和用于存储,而 HLS 工具将每个PE实例化4个块以促进高效实现。
为了实现对右邻居的预取,该工具在内部使用了基于 BRAM 的 FIFO 来缓冲值。每个 PE 的 BRAM 使用量增加了六个或十个块,具体取决于 PE 的位置,无论它有一个还是两个光晕边缘。与不考虑 PE 位置而在 HDL 实现中使用的32个寄存器相比,这种对 BRAM 的过度使用表明该工具在复杂的控制逻辑方面的效率很低。虽然使用 HLS 可以改进数据流或控制流设计,但将两者组合在同一设计中对工具来说可能是挑战,并且可能导致性能下降,尤其是在涉及浮点运算时。
对资源的这种过度使用,使设计在 HLS 实现中仅容纳四个 PE,而在 HDL 版本中容纳八个 PE。尽管此较低的 PE 数量导致总体上减少了资源消耗,但 HLS 的实现因时钟变慢而受苦,这表明底层 RTL 描述不佳。另外,由于 HLS 版本无法对主循环进行流水线处理,因此迭代周期长,因此导致 PE 利用率低。结合更快的时钟,更大的 PE 数量和更高的 PE 利用率, HDL 实施方案在2.1W下每秒执行47000次迭代,而 HLS 版本仅每秒33000次迭代,而功耗仅低0.1W。因此,与 HLS 相比,HDL 版本每W的电池更新次数增加了一倍以上。两种实现的结果与 MATLAB 中开发的参考软件解决方案的结果在位上均相同。
抛开例子,HLS 到底输在了哪?
动态参数:HLS禁止动态分配指针,因为硬件设计通常具有多个存储空间,并且该工具必须知道函数打算访问哪个存储空间,以便可以相应地放置电线连接。动态传递函数参数的一种方法是通过静态定义所有可能的函数调用并对每个参数进行硬编码来引入间接层,然后使用条件语句动态选择正确的调用。
并行执行:为了确保正确执行,HLS 禁止访问同一数组的函数并行运行。由于不支持常量参数,因此它包括只读函数,因为无法对它们进行分类。必须拆分或复制数组,或者必须合并函数以启用并行执行。
内存过度分配:有两种方法可以将大型阵列合成为多个 BRAM 块链。首先是宽度扩展,它可以在多个 BRAM 块之间分配字位,并在每次使用内存的情况下同时激活多个块。第二个是深度扩展,它在每个块中存储完整的字,并使用多路复用器在每次存储访问中激活一个块,但会增加逻辑和延迟。为了实现高性能设计,HLS 似乎更喜欢宽度扩展,这会导致禁止使用 BRAM,尤其是在字非常宽且块未充分利用时。这样,将大型阵列拆分为较小的阵列以充分利用 BRAM 块可以大大减少内存使用量。
资源重用:序列化执行和重用逻辑的最有效方法是通过内联子功能提取公共逻辑。相反,关闭内联会重复资源并启用并行执行,尽管这种方法并不总是成功。确保并行执行的一种肯定但不太方便的方法是复制函数并以不同的方式重命名它们。当然,这种方法使代码难以维护,但是在某些情况下可能是不可避免的。
浮点累加器:由于没有浮点累加支持,实现累加的最有效方法是使用部分和和树加法器。
原文结论因此,可以得出结论,理想的方法是将 HLS 代码用于计算密集型部分与手工制作的用于逻辑控制和存储器管理的 HDL 实现相结合。但是,当内存管理和控制逻辑是应用程序实现过程中最复杂,最耗时的部分时(如本文所说),与纯 HDL 设计相比,混合方法可能不会增加太多价值。此外,在这种方法中,HDL 模块将被视为 HLS 设计中的黑匣子,从而阻碍了该工具重新使用 HDL 模块消耗的资源的努力。
无论哪种方式,HLS 都会带来自己的挑战,因为它将开发人员的注意力和精力从设计和测试转移到考虑该工具的意外行为,推测该工具为什么分配了这么多资源或产生了那么多延迟。因此,建议开发人员相应地设定他们的期望,并在项目规划中花费大量时间进行优化。
笔者看法本文切入点非常新颖巧妙:众所周知,HLS 的优点是设计周期短。从研究角度来说,本文主要是从性能的角度出发,一是展示了今天的 HLS 与手写 RTL 的性能差距被缩短到了什么程度,二是给 HLS 开发者提供进一步缩短差距的建议。
在大多数基于 FPGA 的应用中,特别是深度学习领域,硬件内核大多是规则的,可预判的矩阵运算。这种应用非常适合用今天的 HLS 来写,只要对使用的工具了解,就可以实现较高的性能和质量。当然是最重要的是实现快,发 paper 刺溜刺溜的!而原文论文侧重的是一些非规则的硬件操作,在目前的工具中是不能被完美支持的。
那么接下来的问题是,针对任意算法,如何能加速到最好,这个取决于三点:
算法本身是否进行了优化
使用的工具是否能够支持算法里面需要的硬件优化手段
用户对系统的了解程度和使用工具的能力
第一点:算法的具体优化取决于算法本身,需要具体情况具体分析。
第二点:现有的 HLS 工具各有千秋,我来说说我用过的那些:
Vivado HLS 目前是比较热门的 HLS 的工具,官方资料非常充足,也支持用户对硬件进行高度自主的部署,但是对于非常规运算的代码优化非常有限;
LegUp HLS 是在国内较有名的开源 HLS 工具,相比 Vivado HLS,LegUp 的优点是支持多线程和动态指针,但是在单线程调度方面性能属于 Vivado HLS,而且学术性软件一般对用户自己的能力要求较高;
近年来热门的 Dynamatic HLS 支持动态调度,可以解决大多非常规运算代码的自动优化,但是在常规代码的有优化上不及之前静态调度的 Vivado HLS 和 LegUp HLS;
我自己最新的 DASS HLS 结合了 Vivado HLS 和 Dynamatic HLS,目前是 HLS 加速方面的理论最优解,但是开发时间略短,日常使用还不是非常稳定。
如果大家对这些工具想要进行更深的了解,可以在我往期文章里面阅读更多相关文章。
第三点:在工具足够支持的情况下,用户自己掌握工具的能力也决定了硬件是否得到了绝对优化。一名 HLS 职业玩家能够看着一段 C 代码脑补出对应的硬件结构,从而了解到需要做哪些优化。以上所有的 HLS 工具在目前除了流水线功能以外的其他功能都需要用户手动操作,如果用户不了解一些优化手段,或者说不知道如何在 HLS 里实现,自然无法达到高度的自主权。(有时间的话会分享一下 HLS 里常用的几种优化手段以及在代码上如何实现,欢迎催更)
在这篇论文的研究中,作者做到了第一点和第三点,但是在第二点上非常局限。文章中只采用了 Vivado HLS 作为 HLS 的工具,其实是有一点缺乏普遍性的。比如 LegUp HLS 支持动态指针的操作,而 Vivado HLS 不能支持;Vivado HLS 在有些情况下会复制内存来提高性能, 而 Dynamatic 不存在内存复制的情况,性能也不会输于 Vivado HLS。如果后续有更多的研究,我觉得与多个 HLS 工具的比较会更为客观。
总的来说,在一对一的比较中,文章还是符合基本的规则的。从科研角度来说,最基本的是要求用户在优化方面的智力水平也一样,也就是要用相同的优化手段,如果一个算法进行了流水线化,那么另一个算法也需要流水线化。
那么,如果有人问,难道 HLS 真的是完美的嘛?万一所有的工具都不能支持我想要的优化呢?难道只能全部手写所有 HDL 了嘛?当然不用,HLS 和 HDL 混合使用的骚操作是可行的,但是相对的,设计更为复杂,对用户的水平要求也更高。我并不建议非专业用户去尝试,这种做法违背了 HLS 的初衷,并不是未来的趋势。在目前阶段,大多数应用加速需要的功能都是可以通过现有的 HLS 工具在抽象层手动优化实现的,那些工具只是在自动优化方面做得不够好。
如果大家想要用 HLS 进行算法加速,对 HLS 方面优化了解不深或者代码中有非规则的运算使得工具无法支持对应的优化,欢迎与我私信交流。
往期推荐
什么是 HLS?
【FPGA2020】DASS:一种静动态调度相结合的 HLS 工具
【FPGA2018】Dynamatic 高层次综合工具
盛碗粥,伦敦帝国理工大学电子工程系博士在读。研究旨在用形式化验证的方法做出面积小性能高的硬件。目前的工作主要是优化高层次综合 (high-level synthesis,简称HLS) 的EDA, 其中包括 Xilinx Vitis HLS,LegUp 和 Dynamatic。研究领域包括 FPGA,HLS 优化, 硬件设计,编程语言设计和形式化验证。