0.前言
HLS相对于传统的硬件描述语言而言,有着独特的优势。HLS全称是High Level Synthesis,即高层次综合,基于C/C++的开发流程,可以极大地缩短IP开发周期。总的来说,这是一门人工引导加以优化的编程语言,可以方便地切数组、切流水,提高数据吞吐率与并发度,从而达到时间与空间、速度与面积的trade off。
一、基础元素、流程与指标介绍
1.基础元素到硬件资源的映射
基础元素 | 硬件资源 | 备注 |
---|---|---|
主函数名 | 顶层模块名 | 唯一的,且需要额外声明顶层 |
子函数名 | 子模块调用 | |
顶层参数 | 封装IO | 可定义类型、位宽等 |
数据类型与位宽 | IO口位宽 | 可通过include<ap_fixed.h>自定义数据精度 |
数组 | BRAM、FIFO等 | 数据的读写往往成为速度瓶颈,针对性优化 |
运算操作 | 调用乘法器、与或非门等 | |
循环 | 以状态机控制完成 |
2.HLS设计:C到RTL调试顺序
以矩阵乘法为例,简单介绍HLS设计的关键步骤,顺序如下
(1)C设计
将电路功能用C语言描述,例如完成一个4*4的矩阵乘法
void matrix_mul(ap_int<8> A[4][4],ap_int<8>B[4][4],ap_int<16>C[4][4])
{
for(int i=0;i<4;i++)
{
for(int j=0;j<4;j++)
{
C[i][j]=0;
for(int k=0;k<4;k++)
{
C[i][j]=C[i][j]+A[i][k]*B[k][j];
}
}
}
}
(2)C仿真
C仿真主要用于测试功能是否与预期一致。使用C语言完成testbench,同样地,C语言可以编译为对应的激励,驱动上一步骤完成的电路模块。它作为main函数存在,待测试电路作为例化的子模块调用,对应于C语言中的子函数调用。
int main()
{
ap_int<8> A[4][4];
ap_int<8> B[4][4];
ap_int<16> C[4][4];
//test data in
for(int i=0;i<4;i++)
{
for(int j=0;j<4;j++)
{
A[i][j]=i*4+j;
B[i][j]=A[i][j];
}
}
//instance
matrix_mul(A,B,C);
//print the result
for(int i=0;i<4;i++)
{
for(int j=0;j<4;j++)
{
std::cout<<"C["<<i<<","<<j<<"]="<<C[i][j]<<std::endl;
}
}
return 0;
}
(3)RTL Sybthesis
这一步完成C代码到RTL代码的编译,即综合出对应的电路。查看综合后形成的报告,可以查看电路的延迟时间和资源使用情况。我们进行人工优化主要依照综合报告,针对性地加入优化选项。
(4)C-RTL Cosimulation
这一步完成C代码和RTL代码的联合仿真,作用是保证综合出来的电路功能与C代码描述出来的完全一致。为了方便观测波形,在dump时应该勾选所有的信号端口。
(5)Wave Viwer
顾名思义,这一步用于观测波形。默认情况下会启动Vivado自带的仿真器进行观测。当我们不清楚数据的读写时序时可以将信号抓出来逐个周期观察,并对应地切割数组、切割流水,以达到预期的性能指标。
3.HLS的关键指标:Latency与Througput
(1)Latency主要指顺序执行的组合逻辑电路所需要的延迟
Latency可以往大的延迟方向,往小的延迟约束则不一定能符合要求。例如100M时钟,latency最优化是10ns,可以约束到20ns,但约束为5ns则无法达到要求;
(2)Througput表现为interval,即发送相邻数据的间隔
interval可以做针对性优化,例如展开循环、切割数组、流水线执行等
下图可以说明两者的区别:
二、HLS语法基础
1.不支持的语法
(1)动态内存分配,如malloc(),calloc(),free();new(),delete()
因为硬件的大小是确定的,如果要使用较大的空间,可以声明为数组,映射为存储器。
(2)递归
模块调用是固定的,不能无限次反复调用
2.HLS指针
(1)含义完全明确时可用。
例如 int A[10]; int *pA; pA=A; 即把存储器A[0]地址传给pA
(2)外部存储器的调用
3.data packing
可以将数据打包为结构体,端口控制逻辑,且可以共享控制逻辑。例如调用100次,只需要一个结构体的端口控制逻辑。
4.directives
这是HLS最为独特的部分:人工引导。我们可以针对性地切割数组、展开循环、切流水。其难点是,在哪个地方加入引导,用哪种引导更为合适,从而完成更小的延迟,更小的电路面积。例如对矩阵乘法进行优化,directive选择了pipeline和unroll:
for(int i=0;i<4;i++)
{
for(int j=0;j<4;j++)
{
#pragma HLS LATENCY min=4 max=4
#pragma HLS PIPELINE II=1
C[i][j]=0;
for(int k=0;k<4;k++)
{
//#pragma HLS UNROLL
C[i][j]=C[i][j]+A[i][k]*B[k][j];
}
}
}
三、数组操作
1.数组初始化
普通数组:做变量初始化,每次调用前会有一个初始化写入数据的状态机;
静态数组:相当于ROM,第一次写入的数据固定不变,不需要每次初始化;
备注:在AI中一般直接声明数组即可,不需初始化数值
2.移位寄存器
可变宽度的输出,常用于缓存。属于专用宏资源,需要添加头文件,ap_shift_reg.h
3.数组的优化
(1)partition,按照维度切分,切割为独立的存储器。
(2)reshape,按照维度重组,可以使得读出数据长度增加,减少读取周期。
当报warining时,提示状态机某一参数无法启动,带宽不够,可能需要切分数组。
对带宽的理解:可以执行完整功能的时钟频率。例如实现某功能要读取出ROM中所有数据,而读出数据需要10*10ns,则访问带宽为10M;若其他步骤更慢,则有效带宽由最慢的决定
对数组的访问往往会成为带宽的瓶颈
四、循环操作
1.循环的实现
在verilog中我曾经尝试用case的方法执行for循环内的每一条语句;HLS使用状态机控制。因此每个循环除了自身命令的执行时间外,还会增加两个时钟周期,用于状态机开始与结束的跳转。
以如下的循环加以说明:
for (i=3;i>=0;i--) {
b = a[i] + b;
}
2.循环的展开
(1)unroll。循环形成的电路是由底层命令决定,如下图是一个组合逻辑电路。经过unroll,例如进行4次累加运算,则展开为4个加法器,一个周期可完成。
该优化策略只有在循环次数一定的情况下可用进行。循环N次,完全展开需要调用N个DSP的资源,因此循环次数不能为参数。
(2)flattening.将多重嵌套循环展开成一重循环。
例如二重循环,i=5;j=3,可以通过flattening,将3*5=15次循环展平。
for(i=0;i<5;i++){
for(j=0;j<3;j++){
func1();
}
}
但是,该优化策略在只有一个底层循环执行体时才可用;例如以下情况则显然不适合做flattening:
for(i=0;i<5;i++){
func2();
for(j=0;j<3;j++){
func1();
}
}
3.pipeline
流水线是一种常用的加速方法。用两张图可以直观地表达其特点。
顺序执行:
流水执行: