高层次综合(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
LUT 或 SICE是构成了 FPGA 的区域。它的数量有限,当它用完时,意味着您的设计太大了!
FPGA中的内存。在 Z-7010 FPGA上,有 120 个,每个都是 2KiB(实际上是 18 kb)。
这与延迟不同!如果函数是流水线的,许多数据项会同时流过它。延迟是一个数据项被推入后弹出的时间,而时间间隔决定了数据可以被推入的速率。
上图中,左边是函数右边是循环,左边的时间间隔(接收新数据之前)是3个时钟周期,右边循环的间隔则是一个时钟周期;对于左边的延迟是这个函数产生结果的时钟周期数,是func_C运行完毕产生的周期数,为5个时钟周期,右边循环的延迟是一次迭代所需的时钟数,是4个时钟周期。
上面的概念非常重要,要不然下面的一些指令作用也看不懂~
这是在实际使用过程中重要的指令列表(不是全部)。
Functions-函数
loops-循环
Various-所有都适合
Arrays-数组
parameters-参数
指令 | 适用范围 | 描述 |
---|---|---|
PIPELINE 流水线指令 | Functions, loops | 简单解释就是使输入更频繁地传递给函数或循环。流水线后的函数或循环可以每 N 个时钟周期处理一次新输入,其中 N 是启动间隔(Initiation Interval)。'II' 默认为 1,是 HLS 应针对的启动间隔(即尝试将新数据项输入管道的速度应该多快)。 |
UNROLL | loops | 创建循环的因子副本,让其并行执行(如果满足数据流依赖性)。但是会浪费资源(以资源换取速度)。尽可能将程序展开以提高速度。 |
ALLOCATION | Various | 限制某事物的实例数。例如,如果只想在另一个函数toplevel中获得函数foo的三个副本,请使用位置toplevel、限制设置为3、实例设置为foo、类型设置为“function”的分配。这也适用于特定的运算。 |
ARRAY_MAP | Arrays | 将多个较小的阵列映射成一个较大的阵列,以牺牲访问时间为代价来节省访问逻辑或 BRAM。'instance' 可以设置为任何未使用的名称。ARRAY_MAP 对同一个实例使用多个 来告诉 HLS 创建一个名为“instance”的新数组,其中包含所有较小的数组。保留“偏移”未设置。请注意,有些人在将三个或更多初始化数组映射到单个 RAM 时遇到了此指令引起的错误。如果在仿真和实现的设计之间遇到行为差异,请尝试删除此指令。 |
ARRAY_PARTITION | Arrays | 将一个大数组拆分为多个较小的数组(与ARRAY_MAP相反)。这对于增加并行访问的可能性很有用。如果“type”是“block”,则源数组将分成block。如果它是“cyclic”,那么元素将被交错到目标数组中。在这两种情况下,“factor因子”都是要创建的较小数组的数量。如果 'type' 是 'complete' 则忽略 'factor' 并且阵列被完全分割成组件寄存器,因此不使用任何 Block RAM。 |
DATAFLOW | Functions | 见下文 |
INLINE | Functions | 该指令不是将函数视为单个硬件单元,而是在每次调用 HLS 时将函数内联。这是以硬件为代价增加了潜在的并行性。如果 'recursive' 为真,则内联函数调用的所有函数也被视为标有 INLINE。 |
INTERFACE | Function,parameters | 告诉 HLS 如何在函数之间传递参数。这在顶层函数中至关重要,因为它定义了设计的引脚排列。在 EMBS 中,我们有一个应该坚持使用的模板(上图)。 |
LATENCY | Functions, loops | HLS 通常会尝试在综合时实现最小延迟。如果使用此指令指定更大的最小延迟,HLS 将“pad out”函数或循环并减慢一切。这有助于资源共享(减少资源),并且对于创建延迟很有用。如果 HLS 无法达到要求的延迟,它将发出警告。 |
LOOP_FLATTEN | loops | 将嵌套循环展平为单个循环。应用于 最里面的 循环。如果成功,将生成更快的硬件代码。 |
LOOP_TRIPCOUNT | loops | 如果循环具有可变的循环边界,HLS 将不知道它需要多少次迭代。这意味着它无法为设计延迟提供明确的值。这允许我们为设计指定循环的最小、平均和最大行程计数(迭代次数)。这只会影响报告,不会影响硬件代码生成。 |
RESOURCE | Various | 这用于指定应使用特定硬件资源来实现源代码元素。指定是否应使用 BRAM 或 LUT 实现ARRAY。见下文详解。 |
可以在 HLS 中使用普通的 C 类型(int、 char等)变量。但是,设计中的常用的寄存器并不完全需要 4、8 或 16 位宽,那么可以使用任意精度类型来准确定义需要多宽的数据类型,而不是接受这种低效率的通用定义。
下面展示了如何使用 C 和 C++ 风格的任意精度类型。我们建议使用 C++,除非有特定的理由不这样做。
包含
uint5 x | 无符号整数,5 位宽 |
---|---|
int19 x | 有符号整数,19 位宽 |
包含
ap_uint<5> x | 无符号整数,5 位宽 |
---|---|
ap_int<19> x | 有符号整数,19 位宽 |
按照上面的设置应该能够正常打印任意精度类型,但是如果在调试过程中得到奇怪的值,请先使用printf调用to_int():
ap_uint<23> myAP;
printf("%d
", myAP.to_int());
在 HLS 中,所有静态和全局变量都被初始化为零(如果给定了初始化值,则初始化为其他值)。这包括 RAM,其中每个元素都被清除为零。然而,这种初始化只发生在 FPGA 首次编程时。任何后续处理器复位都不会触发初始化过程。
如果需要清除设备的内部状态,那么应该包含某种复位协议(根据复位状态处理所需要的程序)。
可以在 HLS 组件中使用两个接口,即 AXI Slave 和 AXI Master。
AXI Slave:ARM 内核使用此接口来启动和停止 HLS 组件。他们还可以使用此接口来读取和写入相对少量的用户定义值。
AXI Master:如果需要更大量的共享数据,HLS 组件可以使用 AXI Master 接口启动事务以从主系统内存读取和写入数据。
可以通过toplevel在 HLS 组件中为函数指定参数并将指令附加到这些参数来定义所需的接口。下面显示了一个只有从接口的组件:
带有AXI Slave的 HLS 组件
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
}
而下面是一个同时具有从接口和主接口的组件:
具有从属和主接口的 HLS 组件
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 内核控制。
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的总线突发读取”来证实这一点。
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。
当更改 HLS 代码时,请执行以下步骤以确保bitfile已更新,方便进行正确地测试。
b、单击刷新 IP 目录
c、在 IP Status面板中,应选择 toplevel IP。单击 Upgrade 选项。
4、在“Generate Output Products”对话框中,单击“Generate”。
5、单击生成比特流。
6、导出硬件到 Vitis。
7、在 Vitis 中重新编程 FPGA 并运行软件。
现在应该明白了为什么测试和仿真如此重要了!
在 HLS 中,可以将指令应用于循环以指示它展开或流水线。考虑以下循环:
myloop: for(int i = 0; i < 3; i++) {
doSomething(X[i]);
}
默认情况下,HLS 将按顺序执行循环的每次迭代。它的执行将如下所示:
如果循环的每次迭代需要 10 个时钟周期,那么循环总共需要 30 个周期才能完成。
如果我们给这个循环 PIPELINE 指令,那么 HLS 将尝试在元素 0 完成之前开始计算元素 1,从而创建一个PIPELINE。这意味着循环的整体执行时间会更短,但代价是更复杂的控制逻辑和更多的寄存器来存储中间数据。循环如下所示:
只有在没有阻止此优化的依赖项时,它才能执行此操作。考虑以下代码:
int lastVal;
for(int i = 0; i < 50; i++) {
lastVal = calculateAValue(lastVal);
}
在此示例中,循环被迫按顺序执行,因为在下一次循环迭代开始时需要在循环体末尾使用计算出的值。PIPELINE 仍然会试图加快速度,但不会大幅加快。
最后,如果我们给循环 UNROLL 指令,那么 HLS 将尝试并行执行循环的迭代。这需要更多的硬件,但速度非常快。在我们的示例中,整个循环只需要 10 个周期。
这要求循环的元素之间没有数据依赖关系。例如,如果 doSomething() 保留一个执行次数的全局计数器,则此依赖项将阻止 UNROLL 指令工作。
请注意,UNROLL默认情况下会尝试展开循环的所有迭代。这可能会导致非常大的设计!为了使事情更合理,可以设置UNROLL的FACTOR参数来告诉工具要创建多少副本。
应用UNROLL后,最好在分析视图中查看它是否实际应用。成功展开的设计在分析视图中将非常“垂直”,表示同一列中的操作同时发生。如果视图仍然非常“水平”且有很多列,那么很可能是数据依赖项阻止了展开。可以尝试通过单击操作来确定是什么阻止了展开。该工具将绘制箭头以显示输入的内容和输出的内容。请记住,BlockRAM 一次只能进行两次访问,因此,如果有一个大型ARRAY,而这些工具是从 BlockRAM 制作的,则展开或流水线操作最多只能创建 2 个副本。可以告诉工具不要使用带有ARRAY_PARTITION指令的块RAM。这可以快得多,但要使用更多的硬件资源。
如果没有使用限制资源的指令(例如 ALLOCATION 指令),HLS 会寻求最小化延迟并提高并发性。但是数据依赖性可以限制这一点。例如,访问数组的函数或循环必须在完成之前完成对数组的所有读/写访问,这就阻止了下一个消耗数据的函数或循环启动。
函数或循环中的操作可能会 在前一个函数或循环完成其所有操作之前开始操作。
HLS指定数据流优化时:
这允许函数或循环并行运行,从而减少延迟并提高 RTL 设计的吞吐量,但以增加硬件资源为代价。尝试一下DATAFLOW ,看看它是否对设计有帮助。
当试图在实验室硬件以外的机器上运行测试时,可能会收到一个错误,抱怨它找不到“crt1.o”。如果是这样,就需要为项目设置自定义链接器标志。
单击顶部菜单中的“Project”,然后单击Project Settings。在此框中,单击左侧的“Simulation”,然后将以下内容粘贴到“Linker Flags”框中:
-B"/usr/lib/x86_64-linux-gnu/"
有时,HLS 综合报告将包含?而不是给出最小和最大延迟的值。这是因为设计中至少有一个循环是数据相关的,即它循环的次数取决于 HLS 无法知道的数据值。
例如,下面的代码:
当综合在综合报告中给出以下内容:
如果我们检查代码,它将来自ram的元素相加,但要相加的元素的确切数量来自用户,作为arg1参数输入。因此,HLS无法提前知道该硬件执行需要多长时间,因为每次运行时它都是可变的。这就是上面我们说的运行时依赖于数据。生成的硬件将正常工作,我们只是无法预测运行需要多长时间。查看循环的细节,HLS仍然可以告诉我们循环的延迟是2,换句话说,它不知道它将迭代多少次,但每次迭代将花费2个时钟周期。
一般来说,应该尽量避免这种情况。如果 HLS 无法预测最坏的情况,那么它会过于“谨慎”,并且它可能会制造比我们需要的更大的硬件。此外,不能展开具有可变循环边界的循环。
一些算法从根本上是依赖于数据的,如果这种情况无法避免,那么可以通过将LOOP_TRIPCOUNT指令添加到循环中来告诉 HLS ,假设循环将进行给定次数的迭代,但这仅用于报告目的。生成的硬件将完全相同,但HLS将在循环迭代该次数的假设下生成延迟数。这意味着延迟数字不“正确”,但这仍然有助于了解其他优化是否具有总体积极效果。
当需要使用小数运算但又不想支付使用浮点的大量硬件成本时,定点类型很有用。Vitis HLS 用户指南(https://www.xilinx.com/support/documentation/sw_manuals/xilinx2020_2/ug1399-vitis-hls.pdf)中详细描述了定点类型,下面是一个简短示例:
定点示例
#include
#include
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
#include
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
void fxp_sqrt(ap_ufixed& result, ap_ufixed& 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 q = 0; // partial sqrt
ap_uint q_star = 0; // diminished partial sqrt
ap_int 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(q) << 2) | 1) << (ROOT_PREC - i));
q_star = q << 1;
q = (q << 1) | 1;
} else {
s = 2 * s + (((ap_int(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(q >> 1);
}
#endif
这是《FPGA高层次综合HLS》系列教程第二篇,后面会按照专题继续更新,文章有什么问题,欢迎大家批评指正~感谢大家支持。
审核编辑 :李倩
全部0条评论
快来发表一下你的评论吧 !