HLS基础:从C语言到RTL的实现

    技术2022-07-11  72

    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 流水线是一种常用的加速方法。用两张图可以直观地表达其特点。 顺序执行: 流水执行:

    Processed: 0.018, SQL: 9