作者: 碎碎思,文章来源:<span id="profileBt"><a href="https://mp.weixin.qq.com/s/aTpuQP38RSl-XlnMzmmVYA"> OpenFPGA微信公众号</a></span>
<strong>FPGA高层次综合HLS(二)-Vitis HLS知识库</strong>
高层次综合(High-level Synthesis)简称HLS,指的是将高层次语言描述的逻辑结构,自动转换成低抽象级语言描述的电路模型的过程。
对于AMD Xilinx而言,Vivado 2019.1之前(包括),HLS工具叫Vivado HLS,之后为了统一将HLS集成到Vitis里了,集成之后增加了一些功能,同时将这部分开源出来了。Vitis HLS是Vitis AI重要组成部分,所以我们将重点介绍Vitis HLS。
官方指南:https://docs.xilinx.com/r/_lSn47LKK31fyYQ_PRDoIQ/root
<strong>重要术语</strong>
<strong>LUT 或 SICE</strong>
LUT 或 SICE是构成了 FPGA 的区域。它的数量有限,当它用完时,意味着您的设计太大了!
<strong>BRAM 或 Block RAM</strong>
FPGA中的内存。在 Z-7010 FPGA上,有 120 个,每个都是 2KiB(实际上是 18 kb)。
<strong>Latency延迟</strong>
<li>设计产生结果所需的时钟周期数。</li>
<li>循环的延迟是一次迭代所需的时钟周期数。</li>
<strong>Initiation Interval (or II, or Interval间隔)</strong>
<li>在接受新数据之前必须执行的时钟周期数。</li>
这与延迟不同!如果函数是流水线的,许多数据项会同时流过它。延迟是一个数据项被推入后弹出的时间,而时间间隔决定了数据可以被推入的速率。
<li>循环的间隔是可以开始循环迭代的最大速率,以时钟周期为单位。</li>
<center><img src="http://xilinx.eetrend.com/files/2022-09/%E5%8D%9A%E5%AE%A2/100563805-26…; alt=""></center>
上图中,左边是函数右边是循环,左边的时间间隔(接收新数据之前)是3个时钟周期,右边循环的间隔则是一个时钟周期;对于左边的延迟是这个函数产生结果的时钟周期数,是func_C运行完毕产生的周期数,为5个时钟周期,右边循环的延迟是一次迭代所需的时钟数,是4个时钟周期。
上面的概念非常重要,要不然下面的一些指令作用也看不懂~
<strong>重要的指令</strong>
这是在实际使用过程中重要的指令列表(不是全部)。
<li>Functions-函数</li>
<li>loops-循环</li>
<li>Various-所有都适合</li>
<li>Arrays-数组</li>
<li>parameters-参数</li>
<table border="1">
<thead>
<tr>
<th bgcolor="#CCCCFF">指令</th>
<th bgcolor="#CCCCFF">适用范围</th>
<th bgcolor="#CCCCFF">描述</th>
</tr>
</thead>
<tbody>
<tr>
<td>PIPELINE 流水线指令</td>
<td>Functions, loops</td>
<td>简单解释就是使输入更频繁地传递给函数或循环。流水线后的函数或循环可以每 N 个时钟周期处理一次新输入,其中 N 是启动间隔(Initiation Interval)。'II' 默认为 1,是 HLS 应针对的启动间隔(即尝试将新数据项输入管道的速度应该多快)。</td>
</tr>
<tr>
<td>UNROLL</td>
<td>loops</td>
<td>创建循环的因子副本,让其并行执行(如果满足数据流依赖性)。但是会浪费资源(以资源换取速度)。尽可能将程序展开以提高速度。</td>
</tr>
<tr>
<td>ALLOCATION</td>
<td>Various</td>
<td>限制某事物的实例数。例如,如果只想在另一个函数toplevel中获得函数foo的三个副本,请使用位置toplevel、限制设置为3、实例设置为foo、类型设置为“function”的分配。这也适用于特定的运算。</td>
</tr>
<tr>
<td>ARRAY_MAP</td>
<td>Arrays</td>
<td>将多个较小的阵列映射成一个较大的阵列,以牺牲访问时间为代价来节省访问逻辑或 BRAM。'instance' 可以设置为任何未使用的名称。ARRAY_MAP 对同一个实例使用多个 来告诉 HLS 创建一个名为“instance”的新数组,其中包含所有较小的数组。保留“偏移”未设置。请注意,有些人在将三个或更多初始化数组映射到单个 RAM 时遇到了此指令引起的错误。如果在仿真和实现的设计之间遇到行为差异,请尝试删除此指令。</td>
</tr>
<tr>
<td>ARRAY_PARTITION</td>
<td>Arrays</td>
<td>将一个大数组拆分为多个较小的数组(与ARRAY_MAP相反)。这对于增加并行访问的可能性很有用。如果“type”是“block”,则源数组将分成block。如果它是“cyclic”,那么元素将被交错到目标数组中。在这两种情况下,“factor因子”都是要创建的较小数组的数量。如果 'type' 是 'complete' 则忽略 'factor' 并且阵列被完全分割成组件寄存器,因此不使用任何 Block RAM。</td>
</tr>
<tr>
<td>DATAFLOW</td>
<td>Functions</td>
<td>见下文</td>
</tr>
<tr>
<td>INLINE</td>
<td>Functions</td>
<td>该指令不是将函数视为单个硬件单元,而是在每次调用 HLS 时将函数内联。这是以硬件为代价增加了潜在的并行性。如果 'recursive' 为真,则内联函数调用的所有函数也被视为标有 INLINE。</td>
</tr>
<tr>
<td>INTERFACE</td>
<td>Function,parameters</td>
<td>告诉 HLS 如何在函数之间传递参数。这在顶层函数中至关重要,因为它定义了设计的引脚排列。在 EMBS 中,我们有一个应该坚持使用的模板(上图)。</td>
</tr>
<tr>
<td>LATENCY</td>
<td>Functions, loops</td>
<td>HLS 通常会尝试在综合时实现最小延迟。如果使用此指令指定更大的最小延迟,HLS 将“pad out”函数或循环并减慢一切。这有助于资源共享(减少资源),并且对于创建延迟很有用。如果 HLS 无法达到要求的延迟,它将发出警告。</td>
</tr>
<tr>
<td>LOOP_FLATTEN</td>
<td>loops</td>
<td>将嵌套循环展平为单个循环。应用于 <strong>最里面的</strong> 循环。如果成功,将生成更快的硬件代码。</td>
</tr>
<tr>
<td>LOOP_TRIPCOUNT</td>
<td>loops</td>
<td>如果循环具有可变的循环边界,HLS 将不知道它需要多少次迭代。这意味着它无法为设计延迟提供明确的值。这允许我们为设计指定循环的最小、平均和最大行程计数(迭代次数)。这只会影响报告,不会影响硬件代码生成。</td>
</tr>
<tr>
<td>RESOURCE</td>
<td>Various</td>
<td>这用于指定应使用特定硬件资源来实现源代码元素。指定是否应使用 BRAM 或 LUT 实现ARRAY。见下文详解。</td>
</tr>
</tbody>
</table>
<strong>任意精度类型</strong>
可以在 HLS 中使用普通的 C 类型(int、 char等)变量。但是,设计中的常用的寄存器并不完全需要 4、8 或 16 位宽,那么可以使用任意精度类型来准确定义需要多宽的数据类型,而不是接受这种低效率的通用定义。
下面展示了如何使用 C 和 C++ 风格的任意精度类型。我们建议使用 C++,除非有特定的理由不这样做。
在 C 中:
包含 <ap_cint.h> 头文件。然后,可以声明具有如下类型的变量:
<table border="1">
<tr>
<th>uint5 x</th>
<th>无符号整数,5 位宽</th>
</tr>
<tbody>
<tr>
<td>int19 x</td>
<td>有符号整数,19 位宽</td>
</tr>
</tbody>
</table>
在 C++ 中:
包含 <ap_int.h> 头文件。然后,可以声明具有如下类型的变量:
<table border="1">
<thead>
<tr>
<th>ap_uint<5> x</th>
<th>无符号整数,5 位宽</th>
</tr>
</thead>
<tbody>
<tr>
<td>ap_int<19> x</td>
<td>有符号整数,19 位宽</td>
</tr>
</tbody>
</table>
按照上面的设置应该能够正常打印任意精度类型,但是如果在调试过程中得到奇怪的值,请先使用printf调用to_int():
ap_uint<23> myAP;
printf("%d\n", myAP.to_int());
复位行为
在 HLS 中,所有静态和全局变量都被初始化为零(如果给定了初始化值,则初始化为其他值)。这包括 RAM,其中每个元素都被清除为零。然而,这种初始化只发生在 FPGA 首次编程时。任何后续处理器复位都不会触发初始化过程。
如果需要清除设备的内部状态,那么应该包含某种复位协议(根据复位状态处理所需要的程序)。
<strong>AXI 从接口和 AXI 主接口</strong>
可以在 HLS 组件中使用两个接口,即 AXI Slave 和 AXI Master。
<li>AXI Slave:ARM 内核使用此接口来启动和停止 HLS 组件。他们还可以使用此接口来读取和写入相对少量的用户定义值。</li>
<li>AXI Master:如果需要更大量的共享数据,HLS 组件可以使用 AXI Master 接口启动事务以从主系统内存读取和写入数据。</li>
可以通过toplevel在 HLS 组件中为函数指定参数并将指令附加到这些参数来定义所需的接口。下面显示了一个只有从接口的组件:
<strong>带有AXI Slave的 HLS 组件</strong>
uint32 toplevel(uint32 *arg1, uint32 *arg2, uint32 *arg3, uint32 *arg4) {
#pragma HLS INTERFACE s_axilite port=arg1 bundle=AXILiteS register
#pragma HLS INTERFACE s_axilite port=arg2 bundle=AXILiteS register
#pragma HLS INTERFACE s_axilite port=arg3 bundle=AXILiteS register
#pragma HLS INTERFACE s_axilite port=arg4 bundle=AXILiteS register
#pragma HLS INTERFACE s_axilite port=return bundle=AXILiteS register
}
而下面是一个同时具有从接口和主接口的组件:
<strong>具有从属和主接口的 HLS 组件</strong>
uint32 toplevel(uint32 *ram, uint32 *arg1, uint32 *arg2, uint32 *arg3, uint32 *arg4) {
#pragma HLS INTERFACE m_axi port=ram offset=slave bundle=MAXI
#pragma HLS INTERFACE s_axilite port=arg1 bundle=AXILiteS register
#pragma HLS INTERFACE s_axilite port=arg2 bundle=AXILiteS register
#pragma HLS INTERFACE s_axilite port=arg3 bundle=AXILiteS register
#pragma HLS INTERFACE s_axilite port=arg4 bundle=AXILiteS register
#pragma HLS INTERFACE s_axilite port=return bundle=AXILiteS register
}
请注意,可以为从接口添加和删除参数,并更改它们的数据类型,只需记住也要更新关联#pragmaS。HLS 将相应地更新组件的驱动程序。
PS:主数据类型:由于 AXI 主接口会连接到 32 位宽的 RAM,因此在指定 AXI 主接口时应始终使用 32 位数据类型。
一旦决定了的接口,应该能够依靠 Vivado 自动化连线来连接一切。
请注意,返回端口的 pragma 很重要!
#pragma HLS INTERFACE s_axilite port=return bundle=AXILiteS register
//端口=返回 包=AXILiteS 寄存器
即使不使用函数的返回值,此 pragma 也会告诉 HLS 将 start、stop、done 和 reset 信号捆绑到 AXI Slave 接口中的控制寄存器中。因此,这将生成相应的驱动程序函数来启动和停止生成的 IP 内核。如果不包含此 pragma,则 HLS 将为这些信号生成简单的连线,并且 IP 内核将无法直接被 ARM 内核控制。
<strong>多种类型的 AXI Master</strong>
Vitis HLS在从同一主AXI端口复制值并将其解释为不同类型时非常挑剔。
例如,以下 memcpy 可能会导致“Stored value type does not match pointer operand type! (存储值类型与指针操作数类型不匹配!)” ,尝试将 RAM 视为uint32 和float类型时,综合过程中将会产生 LLVM 错误:
void toplevel(uint32 *ram) {
#pragma HLS INTERFACE m_axi port=ram offset=slave bundle=MAXI
uint32 u_values[10];
float f_values[10];
memcpy(u_values, ram, 40);
memcpy(f_values, ram+10, 40);
}
为了正确强制从 RAM 中复制数据的类型信息,可以使用union,如下所示:
typedef union {
uint32 u;
float f;
} ram_t;
void toplevel(ram_t *ram) {
#pragma HLS INTERFACE m_axi port=ram offset=slave bundle=MAXI
uint32 u_values[10];
float f_values[10];
for (int i = 0; i < 10; i++) {
ram_t data = ram[i];
u_values[i] = data.u;
}
for (int i = 0; i < 10; i++) {
ram_t data = ram[i+10];
f_values[i] = data.f;
}
}
此外,只要循环边界从零开始(并且是固定的),HLS应该足够聪明,将其视为类似于memcpy的突发传输-在综合过程中查找“推断MAXI端口上长度为X的总线突发读取”来证实这一点。
<strong>强制和阻止使用 Block RAM</strong>
HLS 会自动将大部分ARRAY转换为 BRAM。这通常很有用,因为寄存器ARRAY在 LUT(FPGA 空间)方面非常昂贵。但是,FPGA 的 BRAM 数量有限。BRAM 也只有 2 个访问端口。这意味着在任何时候最多有两个并行进程可以访问 RAM。这可能会限制设计的并行性潜力。
如果HLS使用的是不希望使用的BRAM,则将类型设置为COMPLETE且维度设置为1的指令array_PARTITION应用于数组。这将迫使它从寄存器中生成数组。这会占用大量的FPGA空间(LUT),所以要节约!
要强制 HLS 使用 BRAM,请将指令BIND_STORAGE集应用到 RAM_2P。(添加时按下帮助按钮可查看所有各种选项的说明)。
该 ARRAY_MAP 指令(见上文)可以通过自动将多个较小的数组放入一个较大的数组来帮助节省 Block RAM。
<strong>当更改 HLS 时</strong>
当更改 HLS 代码时,请执行以下步骤以确保bitfile已更新,方便进行正确地测试。
1、重新运行综合。
2、重新导出 IP 核。
3、在 Vivado 中,它应该已经注意到了变化,并且会出现一条消息说“IP Catalog is out-of-date”。a、如果没有,请单击 IP Status,然后单击重新运行报告
b、单击刷新 IP 目录
c、在 IP Status面板中,应选择 toplevel IP。单击 Upgrade 选项。
4、在“Generate Output Products”对话框中,单击“Generate”。
5、单击生成比特流。
6、导出硬件到 Vitis。
7、在 Vitis 中重新编程 FPGA 并运行软件。
现在应该明白了为什么测试和仿真如此重要了!
<strong>循环优化</strong>
在 HLS 中,可以将指令应用于循环以指示它展开或流水线。考虑以下循环:
myloop: for(int i = 0; i < 3; i++) {
doSomething(X[i]);
}
默认情况下,HLS 将按顺序执行循环的每次迭代。它的执行将如下所示:
<center><img src="http://xilinx.eetrend.com/files/2022-09/%E5%8D%9A%E5%AE%A2/100563805-26…; alt=""></center>
如果循环的每次迭代需要 10 个时钟周期,那么循环总共需要 30 个周期才能完成。
如果我们给这个循环 PIPELINE 指令,那么 HLS 将尝试在元素 0 完成之前开始计算元素 1,从而创建一个PIPELINE。这意味着循环的整体执行时间会更短,但代价是更复杂的控制逻辑和更多的寄存器来存储中间数据。循环如下所示:
<center><img src="http://xilinx.eetrend.com/files/2022-09/%E5%8D%9A%E5%AE%A2/100563805-26…; alt=""></center>
只有在没有阻止此优化的依赖项时,它才能执行此操作。考虑以下代码:
int lastVal;
for(int i = 0; i < 50; i++) {
lastVal = calculateAValue(lastVal);
}
在此示例中,循环被迫按顺序执行,因为在下一次循环迭代开始时需要在循环体末尾使用计算出的值。PIPELINE 仍然会试图加快速度,但不会大幅加快。
最后,如果我们给循环 UNROLL 指令,那么 HLS 将尝试并行执行循环的迭代。这需要更多的硬件,但速度非常快。在我们的示例中,整个循环只需要 10 个周期。
<center><img src="http://xilinx.eetrend.com/files/2022-09/%E5%8D%9A%E5%AE%A2/100563805-26…; alt=""></center>
这要求循环的元素之间没有数据依赖关系。例如,如果 doSomething() 保留一个执行次数的全局计数器,则此依赖项将阻止 UNROLL 指令工作。
请注意,UNROLL默认情况下会尝试展开循环的所有迭代。这可能会导致非常大的设计!为了使事情更合理,可以设置UNROLL的FACTOR参数来告诉工具要创建多少副本。
应用UNROLL后,最好在分析视图中查看它是否实际应用。成功展开的设计在分析视图中将非常“垂直”,表示同一列中的操作同时发生。如果视图仍然非常“水平”且有很多列,那么很可能是数据依赖项阻止了展开。可以尝试通过单击操作来确定是什么阻止了展开。该工具将绘制箭头以显示输入的内容和输出的内容。请记住,BlockRAM 一次只能进行两次访问,因此,如果有一个大型ARRAY,而这些工具是从 BlockRAM 制作的,则展开或流水线操作最多只能创建 2 个副本。可以告诉工具不要使用带有ARRAY_PARTITION指令的块RAM。这可以快得多,但要使用更多的硬件资源。
<strong>数据流优化</strong>
如果没有使用限制资源的指令(例如 ALLOCATION 指令),HLS 会寻求最小化延迟并提高并发性。但是数据依赖性可以限制这一点。例如,访问数组的函数或循环必须在完成之前完成对数组的所有读/写访问,这就阻止了下一个消耗数据的函数或循环启动。
函数或循环中的操作可能会 在前一个函数或循环完成其所有操作之前开始操作。
HLS指定数据流优化时:
<li>分析顺序函数或循环之间的数据流。</li>
这允许函数或循环并行运行,从而减少延迟并提高 RTL 设计的吞吐量,但以增加硬件资源为代价。尝试一下DATAFLOW ,看看它是否对设计有帮助。
找不到 'crt1.o' 错误
当试图在实验室硬件以外的机器上运行测试时,可能会收到一个错误,抱怨它找不到“crt1.o”。如果是这样,就需要为项目设置自定义链接器标志。
单击顶部菜单中的“Project”,然后单击Project Settings。在此框中,单击左侧的“Simulation”,然后将以下内容粘贴到“Linker Flags”框中:
-B"/usr/lib/x86_64-linux-gnu/"
我的循环有???latency估计!
有时,HLS 综合报告将包含?而不是给出最小和最大延迟的值。这是因为设计中至少有一个循环是数据相关的,即它循环的次数取决于 HLS 无法知道的数据值。
例如,下面的代码:
<center><img src="http://xilinx.eetrend.com/files/2022-09/%E5%8D%9A%E5%AE%A2/100563805-26…; alt=""></center>
当综合在综合报告中给出以下内容:
<center><img src="http://xilinx.eetrend.com/files/2022-09/%E5%8D%9A%E5%AE%A2/100563805-26…; alt=""></center>
如果我们检查代码,它将来自ram的元素相加,但要相加的元素的确切数量来自用户,作为arg1参数输入。因此,HLS无法提前知道该硬件执行需要多长时间,因为每次运行时它都是可变的。这就是上面我们说的运行时依赖于数据。生成的硬件将正常工作,我们只是无法预测运行需要多长时间。查看循环的细节,HLS仍然可以告诉我们循环的延迟是2,换句话说,它不知道它将迭代多少次,但每次迭代将花费2个时钟周期。
一般来说,应该尽量避免这种情况。如果 HLS 无法预测最坏的情况,那么它会过于“谨慎”,并且它可能会制造比我们需要的更大的硬件。此外,不能展开具有可变循环边界的循环。
一些算法从根本上是依赖于数据的,如果这种情况无法避免,那么可以通过将LOOP_TRIPCOUNT指令添加到循环中来告诉 HLS ,假设循环将进行给定次数的迭代,但这仅用于报告目的。生成的硬件将完全相同,但HLS将在循环迭代该次数的假设下生成延迟数。这意味着延迟数字不“正确”,但这仍然有助于了解其他优化是否具有总体积极效果。
<strong>定点类型</strong>
当需要使用小数运算但又不想支付使用浮点的大量硬件成本时,定点类型很有用。Vitis HLS 用户指南(https://www.xilinx.com/support/documentation/sw_manuals/xilinx2020_2/ug…)中详细描述了定点类型,下面是一个简短示例:
定点示例
#include <iostream>
#include <ap_fixed.h>
ap_fixed<15, 5> a = 3.45;
ap_fixed<15, 5> b = 9.645;
ap_fixed<20, 6> c = a / b * 2;
std::cout << c;
//Prints 0.7148. The accurate answer is 0.7154. More bits can be allocated to the types if more accuracy is required.
C标准数学函数(在math.h中)仅针对浮点实现,但Xilinx在hls_math.h中提供了某些函数的定点实现。在hls::命名空间下;例如:hls::sqrt()、hls::cos()和hls::sin()。
此外,以下赛灵思示例代码显示了另一种定点平方根实现,在某些情况下可能更有效。
fxp_sqrt.h
#ifndef __FXP_SQRT_H__
#define __FXP_SQRT_H__
#include <cassert>
#include <ap_fixed.h>
using namespace std;
/*
* Provides a fixed point implementation of sqrt()
* Must be called with unsigned fixed point numbers so convert before calling, follows:
* ap_ufixed<32, 20> in = input_number;
* ap_ufixed<32, 20> out;
* fxp_sqrt(out, in);
*/
template <int W2, int IW2, int W1, int IW1>
void fxp_sqrt(ap_ufixed<W2,IW2>& result, ap_ufixed<W1,IW1>& in_val)
{
enum { QW = (IW1+1)/2 + (W2-IW2) + 1 }; // derive max root width
enum { SCALE = (W2 - W1) - (IW2 - (IW1+1)/2) }; // scale (shift) to adj initial remainer value
enum { ROOT_PREC = QW - (IW1 % 2) };
assert((IW1+1)/2 <= IW2); // Check that output format can accommodate full result
ap_uint<QW> q = 0; // partial sqrt
ap_uint<QW> q_star = 0; // diminished partial sqrt
ap_int<QW+2> s; // scaled remainder initialized to extracted input bits
if (SCALE >= 0)
s = in_val.range(W1-1,0) << (SCALE);
else
s = ((in_val.range(W1-1,0) >> (0 - (SCALE + 1))) + 1) >> 1;
// Non-restoring square-root algorithm
for (int i = 0; i <= ROOT_PREC; i++) {
if (s >= 0) {
s = 2 * s - (((ap_int<QW+2>(q) << 2) | 1) << (ROOT_PREC - i));
q_star = q << 1;
q = (q << 1) | 1;
} else {
s = 2 * s + (((ap_int<QW+2>(q_star) << 2) | 3) << (ROOT_PREC - i));
q = (q_star << 1) | 1;
q_star <<= 1;
}
}
// Round result by "extra iteration" method
if (s > 0)
q = q + 1;
// Truncate excess bit and assign to output format
result.range(W2-1,0) = ap_uint<W2>(q >> 1);
}
#endif
<strong>总结</strong>
这是《FPGA高层次综合HLS》系列教程第二篇,后面会按照专题继续更新,文章有什么问题,欢迎大家批评指正~感谢大家支持