写在前面
通过将FPGA作为执行结构,Vivado HLS能够优化代码的吞吐量、功耗和延迟。本文参考UG998的第四章,主要介绍Vivado HLS的工作原理及其与传统软件编译器的区别。
运算
运算是指计算结果值所涉及的应用程序的算术和逻辑组件。此定义排除比较语句,因为它们是在条件语句中处理的。
在处理操作时,Vivado HLS与其他编译器的主要区别在于对设计的限制。对于处理器编译器,固定的处理体系结构意味着用户只能通过限制操作依赖性和操纵内存布局来影响性能,从而最大限度地提高缓存性能。
相比之下,Vivado HLS不受固定处理平台的约束,而是基于用户输入构建特定于算法的平台。这使得HLS设计器可以在吞吐量、延迟和功率方面影响应用程序性能。
下图显示了计算结果F[i]时涉及的一组三个操作。
使用处理器,生成的执行配置文件类似于下图。
该应用程序配置文件仅关注中央处理器(CPU)中指令处理的EXE阶段。这是处理器和FPGA之间共享的指令处理的唯一阶段。基于该算法,可以任意顺序或同时计算A[i]和D[i]的值。唯一的算法限制是这两个值必须在F[i]之前计算。
上图显示了使用Vivado HLS中的默认设置将F[i]的计算式子编译到FPGA的结果。生成的执行配置文件类似于处理器的执行配置文件,因为乘法和加法是按顺序进行的。此默认行为的原因是最小化实现用户应用程序所需的构建块的数量。虽然FPGA没有固定的处理体系结构,但每个设备都有其可支持的最大构建块数。因此,可以评估FPGA资源、应用程序性能和每个设备的应用程序数量。
即使使用默认行为,由于为算法创建的自定义内存体系结构,实现的性能也优于处理器执行。在处理器上,阵列A、B、C、D、E和F存储在单个内存空间中,一次只能访问一个。相反,HLS检测这些内存并为每个阵列创建独立的内存库,这导致阵列B和阵列C的读取操作重叠。
在时钟周期1中,对阵列E的读取操作的调度显示了Vivado HLS的自动资源优化之一。对于内存操作,Vivado HLS分析包含数据的库以及计算过程中消耗值的位置。虽然阵列E的读取可能在时钟周期0期间发生,但Vivado HLS会自动将内存操作放置在尽可能靠近数据消耗位置的位置,以减少电路中临时数据存储的量。由于使用值E的乘法器直到时钟周期2才运行,因此将读取访问调度为早于时钟周期1的时间没有任何好处。
Vivado HLS帮助用户控制生成电路大小的另一种方法是提供变量大小的数据类型。与所有编译器类似,Vivado HLS允许用户访问整数、单精度和双精度数据类型。这使得软件能够快速迁移到FPGA上,但可能会掩盖算法效率低下的问题,这是处理器中可用的32位和64位数据路径造成的。
例如,假设上面实现的代码只需要数组B、C和E中的20位值。在原始处理器代码中,这些位大小要求阵列A、D和F能够存储64位值,以避免任何精度损失。Vivado HLS可以按原样编译代码,但这会导致效率低下的64位数据路径,消耗的资源超过算法所需的资源。
使用Vivado HLS任意精度数据类型重写代码的示例。减少实现计算所需的资源数量,还减少了完成操作所需的逻辑级别数量。这反过来又减少了设计的延迟。
硬件设计、流水线或将计算划分为较小的寄存器绑定区域的基本概念是实现目标时钟频率的基本FPGA设计技术。 根据操作的大小,此优化由Vivado HLS自动实现。Vivado HLS将大型操作符划分为多个计算阶段,相应地增加了电路延迟。
条件语句
条件语句是通常作为if、if-else或case语句实现的程序控制流语句。这些编码结构是大多数算法不可分割的一部分,所有编译器(包括HLS)都完全支持它们。编译器之间的唯一区别在于这些类型的语句是如何实现的。
使用处理器编译器,条件语句被转换为分支操作,这些分支操作可能导致也可能不会导致上下文切换。这种不确定性导致处理器执行管道中出现气泡,并直接影响程序性能。
在FPGA中,条件语句对性能的潜在影响与处理器中的不同。Vivado HLS创建由条件语句的每个分支描述的所有电路。因此,条件软件语句的运行时执行涉及两个可能结果之间的选择,而不是上下文切换。
循环
对于循环的处理,在HLS中默认情况下会实现硬件电路的复用,进行迭代,一般情况下,循环操作的总延时为一次迭代操作的时延 × 迭代次数。如果不进行优化设计,一次迭代操作的时延等于运算时钟周期+读时钟周期(一般为1个时钟周期)+写时钟周期(一般为1个时钟周期)设计者可以通过添加相关指令进行对循环操作进行优化。
HLS可以并行化或管道化循环的迭代,以减少计算延迟并提高输入数据速率。用户通过设置循环初始化间隔(II)来控制迭代流水线的级别。循环的II指定连续循环迭代开始时间之间的时钟周期数。
函数
函数是一种编程层次结构,可以包含运算符、循环和其他函数。HLS和处理器编译器中函数的处理与循环的处理类似。
在HLS中,循环和函数之间的主要区别与术语有关。HLS可以并行执行循环和函数。
对于循环,这种转换通常被称为流水线(pipelining) ,因为操作符和循环迭代之间存在明显的层次差异。
对于函数,循环体外部和循环内部的操作处于相同的层次上下文中,如果使用术语流水线,可能会导致混淆。为了避免使用HLS时可能出现的混淆,函数调用执行的并行化被称为数据流优化(dataflow)。
数据流优化指示HLS为给定程序层次结构级别的所有功能创建独立的硬件模块。这些独立的硬件模块能够在数据传输期间并发执行和自同步。
动态内存分配
在HLS中不允许进行使用动态内存分配。因为硬件电路一旦确定就无法更改,所以无法实现动态配置。
如果使用内存分配,可以进行静态分配,确定大小。
指针
HLS编译器支持在编译时能完全分析指针的使用。例如定义一个指针指向数组的首地址。
同时还支持的内存和指针模型是访问外部内存。 HLS 将外部存储器定义为编译器生成的 RTL 范围之外的任何存储器。这意味着该存储器可能位于 FPGA 中的另一个功能或片外存储器的一部分,例如 DDR。
子函数
C语言中调用子函数那么子函数内部的电路是单独综合的,HLS工具不能跨越函数边界进行优化。 例如下图中的顶层函数使用了A、C、D三个函数,而在C和D函数中同时调用了函数B,那么这个函数B将会被分别综合成硬件电路。HLS中取消函数边界的指令为inline,HLS在某些情况下自动应用inline指令,例如子函数只有少量代码代码。可以通过inline指令的off参数来可以强制工具保持函数层次结构。
递归
HLS中不支持递归操作,对于HLS分析综合后的代码电路是固定的,只能执行有限次的操作,所以对于递归的这种无限循环操作是不支持的。
数组
static和const对数组实现有很大的影响。
- const 会综合实现为ROM。
- static 会综合实现为RAM。
对比实现数组时static的影响:
int arr1[3] = {1,1,1};
意味着每次执行该函数时都会设置 arr1,在每次调用的时候都会进行初始化RAM。这也就以为这要花费大量的时间成本进行复位重置数组。
如果使用static,
static int arr1[3] = {1,1,1};
使用static后只会在第一次调用arr1时进行初始化,之后进行调用arr1时,该数组将保持上一次的值,符合RAM使用的思想。
数据包
在C语言中的可以使用结构体对一组数据进行处理,而映射到实际电路中时,实际的电路会将这些端口进行分别映射成单一的端口,这时候可以对结构体进行分组,使得数据在一个时钟周期内全部读入或者写出。
分组结构
- 结构中的第一个元素成为 LSB
- 最后一个结构元素成为 MSB
- 阵列在接口上完全分区
这样的设计在内部映射成为单个端口。使用单总线可能导致简化的控制逻辑、更快和更低的延迟设计。
reference
- UG998