今天来搞个关于vivado_hls的蛮有意思的事。
我们知道用vivado的hls工具将C++代码实现成电路时,可以加"展开"啊、"流水线"啊、"内联"啊、"串联变并联"啊等等不同效果的directive(实现方式)。同一段代码能选择的directive很多,有时候我们不好直接判断一段代码选什么directive才会达到最好的效果,那就只能一个个directive地加,然后比较效果。
可是这个过程是很麻烦的,而且容易比着比着就把自己搞晕头了,“我刚刚顺手删掉的directive是什么?”“前面这段代码修改后我比较过了没有?”“我是谁,我在哪?”233
为了从繁琐的操作中将自己解放出来,我们可以用tcl来完成加directive的操作。
(一)基本操作
首先,用tcl跑hls的方法是通过vivado hls的command prompt实现的:
在这个界面下,我们只要cd到存放工程的路径,然后记住下面这俩行代码就可以了
8月26更新:最近把软件更新到了xilinx家新推出的的vitis,只要把指令里的vivado_hls改成vitis_hls就可以了。
1.跑事先存在这个目录里的tcl脚本
vivado_hls -f s.tcl
2.在跑完脚本后打开目前目录里存放的工程
vivado_hls -p 1
(二)tcl脚本本身
open_project 1
set_top foo
add_files foo.cpp
add_files -tb main.cpp
set all_solution [list no_directive pipeline unroll unroll_2 unroll_4 unroll_5 unroll_10 ]
set all_directive [list no_directive pipeline unroll unroll_2 unroll_4 unroll_5 unroll_10 ]
foreach solution $all_solution directive $all_directive {
open_solution -reset $solution
set_part {xc7z020clg400-1}
create_clock -period 10 -name default
source "$directive.tcl"
csim_design
csynth_design
cosim_design
export_design -format ip_catalog
}
exit
这其实就是一个跑hls的流程了。
里面用了foreach循环来把整个新建solution的流程套起来,在每个循环里面用source来调用同一个文件夹下事先写好的装directive的tcl文件。
(三)C++代码以及directive
.h文件
#ifndef FOO_H
#define FOO_H
#include <ap_int.h>
using namespace std;
#define M 9
#define N 10
#define XW 8
#define BW 16
typedef ap_int<XW> dx_t;
typedef ap_int<BW> db_t;
typedef ap_int<BW+1> do_t;
void foo (dx_t xin[N],db_t a,db_t b,db_t c,do_t yo[2*N],do_t y1[N],do_t y2[N],do_t sum[N],do_t mem[N]);
do_t inline_function(dx_t p,db_t b);
#endif
.cpp文件
#include "foo.h"
void foo (dx_t xin[N],db_t a,db_t b,db_t c,do_t yo[2*N],do_t y1[N],do_t y2[N],do_t mem[N],do_t sum[N])
{
int i = 0;
int j = 0;
//part1 for comparison between pipeline / unroll
part1:for(i = 0; i < 2*N; i++)
{
if(i<N+1)
{
yo[i] = a *xin[i]+b+c;
}
else
{
yo[i] = a*b+c;
}
}
part2:{
//part2 for effect of "merge loop" / flatten / inline
part2_1_outer:for(i = 0; i < N; i++)
{
part2_1_inner:for(j = 0; j < N; j++)
{
y1[j]=xin[j]+a;
y1[j]=xin[j]-a;
}
}
part2_2_outer:for(i = 0; i < N; i++)
{
part2_2_inner:for(j = 0;j<N;j++)
{
y2[j]=xin[j]+b;
y2[j] = inline_function(xin[j],b);
}
}
}
part3:
//part3 for comparison of 1-port RAM / 2-port RAM / array partition
{
part3_1:for(i = 1;i<N;i++)
{
sum[i-1] = mem[i] + mem[i-1];
}
}
}
do_t inline_function(dx_t p,db_t b)
{
return p-b;
}
tb文件
#include "foo.h"
using namespace std;
int main()
{
dx_t xin[N] = {1,2,3,4,5,6,7,8,9,10};
do_t yo[2*N] ={1,2,3,4,5,6,7,8,9,10,1,2,3,4,5,6,7,8,9,10};
do_t y1[N] ={1,2,3,4,5,6,7,8,9,10};
do_t y2[N] ={1,2,3,4,5,6,7,8,9,10};
do_t mem[N] ={1,2,3,4,5,6,7,8,9,10};
do_t sum[N] ={1,2,3,4,5,6,7,8,9,10};
foo(xin,1,2,3,yo,y1,y2,mem,sum);
return 0;
}
(1)对于part1,是一个很简单的循环,有两种常见的优化方法,一种是流水线,一种是unroll展开,所谓展开也就是把里面的电路做复制,可以用-factor来规定要复制几份。
在command prompt中跑完脚本之后,就可以打开工程来对比对part1所使用的各种directive方法的效果:
8月26日更新:
从下图可以看到,使用了更多资源的板卡之后,整体效果是HLS工具会去调用更多资源,实现更快的速度,具体看下图
我建工程时选的是Alveo U200,这是目前另外一个项目刚准备开始玩的加速卡,从两幅图的对比可以看到,两次HLS优化的效果呈现了很好的数字关系,周期是1/4,总延时是1/2,资源量是2倍关系。我感觉这么巧的数字背后是有点名堂的,应该可以通过板子的某些参数先验地算出来。如果搞明白这背后的逻辑,那么以后做加速工程的时候就可以更有针对性地选板子和在事前预估效果。
另外我听说xilinx在vitis上公开了它HLS工具中用到的llvm中间IR文件,并且提供了方法让我们可以用比添加约束更直接的方法来干预HLS从IR文件到最后verilog的过程。这是个很有意思的事情,以前在llvm上写pass的那一套可能有用武之地了,果然学什么都不是白学的啊233。
(2)对于part2,这是由两个有并行可能的两个循环,如果什么优化都不加,那么综合出来的电路不会让它们并行,而是会顺序处理它们,让它们并行的指令是loop_merge,不过要注意只有在循环的边界是常量的时候才可以用这种方法。
两个outerloop里面都嵌了一个循环,对于嵌套的循环,可以用flatten的方法让它们的嵌套打通,比如我写的这个例子,一个10次的循环里又嵌套了一个10次的循环,加上flatten的directive之后,电路会把它直接实现成一个100次的循环,可以节省一点在里外循环跳转的时间。
第二个outerloop里面引用了里外一个函数,对这种情况vivado会自动将引用的函数内联进去,也就是inline,所以inline这个directive并不需要我们额外加上去。
效果:
(3)对于part3,这里对这个部分的输入变量做了一些directive的比较,我们的输入是mem数组,对于数组常见的处理方法一个是改变数组存储的单元,比如我们的头两个directive分别是把存储单元设成单端RAM和双端RAM,那想也知道肯定是用双端RAM读取数组的速度会快一倍。另外我们还可以把存储单元重新排列,比如可以把这10个数据分散到5个BRAM里面,BRAM是xilinx搞的一种比较小巧的RAM,分散的好处是当我们对里面的for循环用unroll来复制了电路的时候,几份复制好的电路可以从不同的BRAM里面取数据,这样就实现了读取数据时候的并行,不然即便电路被复制了,它们还得从一个RAM里面取数据,那样速度就会被这个RAM 的速度所限制。
跑脚本需要花很长的时间,跑脚本都要花这么长的时间,可想而知如果不用tcl来做,这个事情要花费的时间会非常长。跑完后,在terminal里面输入运行GUI界面的语句,就可以打开刚刚跑完的工程。
效果: