数组
可以通过resource关键字来决定数组实现方式
可以通过分块、寄存器等方式
多维数组:
1、数组的实现
数组通常会被综合为memory(RAM,ROM,或者FIFO)。
- Top-level function中的数组会被综合为RTL ports,与外部的memory进行access。这个在数组的接口中会讨论。
- 内部的数组会被综合为RAM,LUTRAM,UltraRAM或者register,取决于optimization settings
与loops一样,数组可以在代码中加入指令性的代码。同样,vivado HLS可以对相应的数组进行相应的RTL优化。
2. 数组的接入与性能
下面的代码显示了在最终的RTL设计之中,数组可能是性能的瓶颈。例如下面的代码中,有三个接入来获取数组mem[N] : mem[i], mem[i-1], mem[i-2]
#include "array_mem_bottleneck.h"
dout_t array_mem_bottleneck(din_t mem[N]) {
dout_t sum=0;
int i;
SUM_LOOP:for(i=2;i<N;++i)
sum += mem[i] + mem[i-1] + mem[i-2];
return sum;
}
在综合中,数组被实现RAM中,如果RAM被定义为单个管脚(single port)的RAM,则不可能对SUM_LOOP进行pipeline。如果对SUM_LOOP进行initiation iterval为1的pipeline,则HLS会出现下面报错:
原因在于单个管脚(single port)的RAM在一个时钟周期之中只能支持一个读,或者一个写操作
- SUM_LOOP Cycle1: read mem[i];
- SUM_LOOP Cycle2: read mem[i-1], sum values;
- SUM_LOOP Cycle3: read mem[i-2], sum values;
运用两个管脚(dual port)的RAM可以被用于这个地方,但是每个时钟周期之中只能运用两个接入。但是代码中运算sum的时候需要用三个接入。把上面代码改为下面这样可以获得更好的性能。通过预读取和手动对数据接入进行pipeline,在每个loop中只需要一次数据读取,这样就确保了获得更好的性能。(每个循环中只读取一次,然后读取的时候把相应的数组存于局部变量。但是我们发现了一个问题,在这个循环之中,上一次循环的结果会影响下一次sum的值。不过我们理解的是pipeline是流水线,并行运算是unroll,所以可以进行pipeline进行加速。)
-
#include "array_mem_perform.h"
-
dout_t array_mem_perform(din_t mem[N]) {
-
din_t tmp0, tmp1, tmp2;
-
dout_t sum=0;
-
int i;
-
tmp0 = mem[0];
-
tmp1 = mem[1];
-
SUM_LOOP:for (i = 2; i < N; i++) {
-
tmp2 = mem[i];
-
sum += tmp2 + tmp1 + tmp0;
-
tmp0 = tmp1;
-
tmp1 = tmp2;
-
}
-
return sum;
-
}
vivado HLS包括了针对数组的优化指令,这些指令包括了数组如何实现和接入。数组会被分为相应的块(block)或者单个的元素。
- 当一个数组被分为多个块时,单个的数组会被分为多个RTL RAM block
- 当一个数组被分为多个元素时,每个元素被在RTL中是一个寄存器(register)
这两种情况下,分组可以使更多的数组元素被接入循环结构的并行,从而改善性能。
3. FIFO接入
一个数组序列可以被应用于FIFO中。这种情况多为运用dataflow优化指令的时候。
接入FIFO需要是一个序列结构,并且是从位置0开始。并且,如果一个数组在多个位置被读入,代码必须严格的按照FIFO的顺序来执行。通常情况下,一个数组有多个分列(fanout)的时候,不能被应用于FIFO上。
4. 数组的接口
vivado HLS将数组作为memory元素。但当数组作为top-level函数接口的时候,vivado HLS会做出如下:
- memory是在芯片之外(off-chip),HLS会将其综合为memory接口
- memroy是latency为1的标准block RAM,当地址确定时,数据会在1个时钟周期之后获得
vivado如何获得这些接口和管脚(ports):
- INTERFACE指令:将其具体化为RAM或者FIFO接口
- RESOURCE指令:将其具体化为单个或者双管脚(single or dual-port) RAM
- RESOURCE指令:能具体化RAM的latency
- array_partitation, array_map, array_reshape指令:将其具体化为相应的结构和IO口的数量
这些数组数据的接入需要RAM或者FIFO,数组的接口接入会影响相应的性能瓶颈,通过相应的指令可以优化这些瓶颈。数组在进行指令优化时必须确定相应的结构。例如运用d[],的时候,HLS就会出现下面的报错:
4.1 数组接口
相应的指令可以具体化哪一种RAM被用于数组,并且确定哪一种RAM端口被创建(sigle-port或者dual-port),如果没有具体的指令,HLS会进行:
- 默认为Single port RAM
- 减少initiation interval或者减少latency时运用dual-port
partitation,map,reshape指令会重新确定数组的接口。数组可以被分成很多个小的数组,每个数组都有其接口。还可以将所有的数组元素分为具体的单个的标量元素。同样的,小的数组可以被合并为大的数组。
-
//Example 3-17: RAM Interface
-
#include "array_RAM.h"
-
void array_RAM (dout_t d_o[4], din_t d_i[4], didx_t idx[4]) {
-
int i;
-
For_Loop: for (i=0;i<4;i++) {
-
d_o[i] = d_i[idx[i]];
-
}
-
}
上面这个代码,相应的RAM会被实现为单个管脚的RAM,因为for循环里面只有一个数组元素的接入。运用双管脚的RAM并没有作用。如果for循环被unroll,则HLS就会运用双管脚的RAM,这样可以改善相应的initiation interval。RAM接口的类型可以被具体的指令确定。数组接口的类型会直接与吞吐量挂钩。
我们可以运用RESOURCE指令对具体的RAM的latency进行规定,这要求HLS运行额外的SRAMs,这种SRAMs时延小于1.
4.2 FIFO接口
HLS允许将数组元素实现在FIFO端口之中。如果FIFO端口被用到,就需要确保数组的接入和读出是成序列的。
如果数组的接入不是按顺序的,RTL simulation就会报错。下面的代码就展示了一个情况,HLS不能确定数组的接入是否为按顺序的,d_i与d_o在综合中会被具体化为FIFO接口
-
#include "array_FIFO.h"
-
void array_FIFO (dout_t d_o[4], din_t d_i[4], didx_t idx[4]) {
-
#pragma HLS INTERFACE ap_fifo port=d_i
-
#pragma HLS INTERFACE ap_fifo port=d_o
-
int i;
-
// Breaks FIFO interface d_o[3] = d_i[2];
-
For_Loop: for (i=0;i<4;i++) {
-
d_o[i] = d_i[idx[i]];
-
}
是否能够作为FIFO接口,取决于idx数组是否为序列的。HLS进行综合时就会有下面的信息:
如果把//break FIFO interface这个指令去掉注释,则HLS就能决定数组的接入是非序列的,然后在FIFO的接口确定的时候就会报错。FIFO必须遵循下面的原则:
- 数组必须在一个循环或者函数中被写或者被读,并且是端到端的连接。
- 数组的读取必须与数组的写入相同的顺序,因为随机接入(random access)是对于FIFO不支持的。数组必须遵循先进先出的准则。
- 用于读和写数组的参数必须在编译的时候就被分析,数组的地址基于时间的变化如果不能被分析出,则HLS不会将其作为FIFO
大多数情况下,数组作为top-level的接口的时候不需要对代码进行更改。代码需要更改的时候可能是数组作为一个数组作为结构体一部分的时候。
5. 数组初始化
在Type qualifiers之中,xilinx推荐将数组实现为memories with the static qualifier。
int coeff[8] = {-2, 8, -4, 10, 14, 10, -4, 8, -2};
上面这个数组的实现过程中,综合后,数组被实现为这些值,单端口RAM被用于实现数组,需要8个时钟周期。对于1024的数组来说,需要1024个时钟周期,并且在这段时间之中,不能对coeff进行操作。
static int coeff[8] = {-2, 8, -4, 10, 14, 10, -4, 8, -2};
运用static指令的时候,数字被初始化为具体的值。并且HLS直接将该数组初始化为RTL的值烧入FPGA的比特流,这避免了初始化数组的多个时钟周期。
6. ROM实现
HLS将数组具体化,用static指令将其综合为memory,用const指令将其综合为ROM。HLS会分析整个设计并且将琦创建为最优的硬件设计。
6.1 c++中const与static的区别
6.1.1 const
定义的常量在超出其作用域之后其空间会被释放。在C++中,const成员变量也不能在类定义处初始化,只能通过构造函数初始化列表进行,并且必须有构造函数。const数据成员 只在某个对象生存期内是常量,而对于整个类而言却是可变的。因为类可以创建多个对象,不同的对象其const数据成员的值可以不同。所以不能在类的声明中初始化const数据成员,因为类的对象没被创建时,编译器不知道const数据成员的值是什么。const数据成员的初始化只能在类的构造函数的初始化列表中进行。要想建立在整个类中都恒定的常量,应该用类中的枚举常量来实现,或者static cosnt。
6.1.2 static
定义的静态常量在函数执行后不会释放其存储空间。在C++中,static静态成员变量不能在类的内部初始化。在类的内部只是声明,定义必须在类定义体的外部,通常在类的实现文件中初始化,如:double Account::Rate=2.25; static关键字只能用于类定义体内部的声明中,定义时不能标示为static.static表示的是静态的。类的静态成员函数、静态成员变量是和类相关的,而不是和类的具体对象相关的。即使没有具体对象,也能调用类的静态成员函数和成员变量。一般类的静态函数几乎就是一个全局函数,只不过它的作用域限于包含它的文件中
6.2 ROM实现
const 关键字会将数组确定为只读格式,然后实现在ROM之中。ROM是一个local,static(non-global)的数组。下面有一些实用的建议:
- 数组越早初始化越好
- 将写操作集成在一起
- 不要将数组(ROM)的初始化插入写过程,就是不要讲non-initialization code插入此过程
- 不要将不同的值存在同一个数组之中
- 数组的值的运算必须是定值的(编译的时候)
如果复杂的初始化被用于初始化ROM,例如运用到math.h中的函数,HLS也会将其RTL初始化为ROM,例如下面:
-
#include "array_ROM_math_init.h"
-
#include <math.h>
-
void init_sin_table(din1_t sin_table[256]){
-
int i;
-
for (i = 0; i < 256; i++) {
-
dint_t real_val = sin(M_PI * (dint_t)(i - 128) / 256.0);
-
sin_table[i] = (din1_t)(32768.0 * real_val);
-
}
-
}
-
dout_t array_ROM_math_init(din1_t inval, din2_t idx){
-
short sin_table[256];
-
init_sin_table(sin_table);
-
return (int)inval * (int)sin_table[idx];
-
}
FOR循环
1.对循环的操作
- Pipelined
- Unrolled
- Partially unrolled
- Merged
- Flattened.
其中对循环结构改变的操作:unrolled,partially unrolled,merged
2.循环上限变动
当循环的上限变动时,HLS难以对循环作出优化。比如下面这个例子中,
Example 3-10
-
#include "ap_cint.h"
-
#define N 32
-
typedef int8 din_t;
-
typedef int13 dout_t;
-
typedef uint5 dsel_t;
-
dout_t code028(din_t A[N], dsel_t width) {
-
dout_t out_accum=0;
-
dsel_t x;
-
LOOP_X:for (x=0;x<width; x++) {
-
out_accum += A[x];
-
}
-
return out_accum;
-
}
循环的次数边界取决于变量width,这个变量是从最高部分输入的,因此这是一个变上界的循环。
2.1 无法确定latency与performance
因为HLS不知道相应的循环上界,所以无法确定时延(运行循环所需要的周期)
2.2 产生报告方法:运用tripcount指令
可以运用tripcount指令,或者将上限定义为c中的宏。
tripcount指令可以定义一个最小或者平均或者最大的循环上限,它表示循环迭代的次数。例如将上例的最大tripcount 定义为32,则运行结果为:
tripcount指令对综合的结果没有影响,只对报告的结果产生影响。
2.3 无法运行的优化
减小 initiation interval的方法:
- Unroll the loop and allow accumulations to occur in parallel
- Partition the array input, or the parallel accumulations are limited,by a single memory port.
如果想要进行上述优化,HLS就会给出下面的报错:
2.4 优化方案,定上界加判断
在循环中加入定值的判断结构,例如上例中,循环上界可以被确定为一个值,loop body就被条件的执行。上面的循环上界因为确定了,所以可以被执行。
-
#define N 32
-
LOOP_X:for (x=0;x<N; x++) {
-
if (x<width) {
-
out_accum += A[x];
-
}
-
}
-
// Example 3-11: Variable Loop Bounds Rewritten
-
#include "ap_cint.h"
-
#define N 32
-
typedef int8 din_t;
-
typedef int13 dout_t;
-
typedef uint5 dsel_t;
-
dout_t loop_max_bounds(din_t A[N], dsel_t width) {
-
dout_t out_accum=0;
-
dsel_t x;
-
LOOP_X:for (x=0;x<N; x++) {
-
if (x<width) {
-
out_accum += A[x];
-
}
-
}
-
return out_accum;
-
}
3. Loop pipeline
对循环进行pipeline时,最优化的基于area和performance的banlance会被执行,针对的是最内层的循环(most inner loop)。这同样会获得最快的运行时间,下面这个代码例子显示了对loop和函数进行的pipeline
-
#include "loop_pipeline.h"
-
dout_t loop_pipeline(din_t A[N]) {
-
int i,j;
-
static dout_t acc;
-
LOOP_I:for(i=0; i < 20; i++){
-
LOOP_J: for(j=0; j < 20; j++){
-
acc += A[i] * j;
-
}
-
}
-
return acc;
-
}
3.1如果对最内层的LOOP_J进行pipeline
硬件上只有一个LOOP_J的copy(单个的乘法器)。vivado HLS会自动的flatten the loops。例如这个程序里面,对单个的loop进行了20*20次迭代。只有一个乘法操作和一个array access会被scheduled。整个loop iteration会被scheduled为single loop-body entity(20*20个loop iteration)
3.2 如果对外层的LOOP_I进行pipeline
(注意:当loop或者function被pipelined的时候,被pipeline结构中的loop必须被unroll)
内层的loop会被unrolled,创建20个乘法器,和20个array access。单个的LOOP_I可以被scheduled as a single entity.
3.3 如果对top-level的函数进行loop_pipeline
所有的循环结构都会被unroll,400 multiplier和400 arryas accessd,会被scheduled
3.4 Data dependencies
data dependencies会prevent parallelism。例如,这个例子中,运用dual-port的RAM用于A[N],则相应设计在一个时钟周期中只能获得两个A[N]的值
3.5 pipeline的总结
pipeline高层的loop会unroll底层的loop,但会获得highest的performance。
- pipeline LOOP_J
latency大概是20*20cycles,但是需要小于100个LUT和寄存器(IO control和FSM are always present)
- pipeline LOOP_I
latency大概为20cycles,需要上百个LUT与register,大约是上面那种方法的20倍。
- pipeline function loop_pipeline
latency大概为10(20 dual-port accesses),但是需要上千个LUT与registers,是第一种方法的400倍
3.6 Imperfect nested loops
当内层的loop被pipelined时,vivado HLS会对nested loops进行flattens的操作,从而减少时延和改善总体的吞吐量。(删去loop 中的对循环参数和相应的checks的操作)
imperfect loop nests或者不能flatten loop的结构,会对结果加入一些额外的clock cycles来进入或者退出loop。尽量的减少nested loops的结构。
4. Loop parallelism
这个指的是不同的loop之间的并行化。vivado HLS会尽可能的将logic operation和function进行并行化处理,但是它并不会对相应的loops进行并行化处理。
-
//Example 3-13: Sequential Loops
-
#include "loop_sequential.h"
-
void loop_sequential(din_t A[N], din_t B[N], dout_t X[N], dout_t Y[N],
-
dsel_t xlimit, dsel_t ylimit) {
-
dout_t X_accum=0;
-
dout_t Y_accum=0;
-
int i,j;
-
SUM_X:for (i=0;i<xlimit; i++) {
-
X_accum += A[i];
-
X[i] = X_accum;
-
}
-
SUM_Y:for (i=0;i<ylimit; i++) {
-
Y_accum += B[i];
-
Y[i] = Y_accum;
-
}
-
}
在这个代码中,循环SUM_X和循环SUM_Y会被sheduled,尽管SUM_Y并不需要等到SUM_X结束再开始,但是它依然会被scheduled after SUM_X。
由于这两个loop的bounds(xlimit与ylimit)是不同的,因此他们不能进行merged。将两个loop放在两个分开的function中,如下面所示,则loops就可以被parallel处理。
循环次数为常数时:
循环次数一个为变量时,不可以合并
两个都是变量时:
-
//Example 3-14: Sequential Loops as Functions
-
#include "loop_functions.h"
-
void sub_func(din_t I[N], dout_t O[N], dsel_t limit) {
-
int i;
-
dout_t accum=0;
-
SUM:for (i=0;i<limit; i++) {
-
accum += I[i];
-
O[i] = accum;
-
}
-
}
-
void loop_functions(din_t A[N], din_t B[N], dout_t X[N], dout_t Y[N],
-
dsel_t xlimit, dsel_t ylimit) {
-
sub_func(A,X,xlimit);
-
sub_func(B,Y,ylimit);
-
}
3-14的时延就比3-13少了将近一半,因为loops可以被并行的执行。但是在3-13中可以进行dataflow的优化,而这里则不行。dataflow的优化只能在top-level的函数和loop中运行。
5. Loop dependencies
loop dependencies即data dependencies,它会让对loop的优化变得困难,特别是pipeline的时候。例如:
在这个循环中,下个循环迭代必须有上个迭代的结果送出。这个会在array中讨论。
6.Unrool loops in c++ classes
在运用c++的classes的时候,应当小心,不要让loop induction variable作为clss中的data member,这样loop就不能unroll。
例如,下面loop induction variable的k就是foo_class的成员。
-
template <typename T0, typename T1, typename T2, typename T3, int N>
-
class foo_class {
-
private:
-
pe_mac<T0, T1, T2> mac;
-
public:
-
T0 areg;
-
T0 breg;
-
T2 mreg;
-
T1 preg;
-
T0 shift[N];
-
int k; // Class Member
-
T0 shift_output;
-
void exec(T1 *pcout, T0 *dataOut, T1 pcin, T3 coeff, T0 data, int col)
-
{
-
Function_label0:;
-
#pragma HLS inline off
-
SRL:for (k = N-1; k >= 0; --k) {
-
#pragma HLS unroll// Loop will fail UNROLL
-
if (k > 0)
-
shift[k] = shift[k-1];
-
else
-
shift[k] = data;
-
}
-
*dataOut = shift_output;
-
shift_output = shift[N-1];
-
}
-
*pcout = mac.exec1(shift[4*col], coeff, pcin);
-
};
如果想让vivado HLS对loop进行UNROLL的pragma指令,则代码需要改为不要把k作为class member成员。
-
template <typename T0, typename T1, typename T2, typename T3, int N>
-
class foo_class {
-
private:
-
pe_mac<T0, T1, T2> mac;
-
public:
-
T0 areg;
-
T0 breg;
-
T2 mreg;
-
T1 preg;
-
T0 shift[N];
-
T0 shift_output;
-
void exec(T1 *pcout, T0 *dataOut, T1 pcin, T3 coeff, T0 data, int col)
-
{
-
Function_label0:;
-
int k; // Local variable
-
#pragma HLS inline off
-
SRL:for (k = N-1; k >= 0; --k) {
-
#pragma HLS unroll// Loop will unroll
-
if (k > 0)
-
shift[k] = shift[k-1];
-
else
-
shift[k] = data;
-
}
-
*dataOut = shift_output;
-
shift_output = shift[N-1];
-
}
-
*pcout = mac.exec1(shift[4*col], coeff, pcin);
-
};