HLS中,C是用来描述硬件的,不是软件编程的,这是基本概念。
下面记录一些常用的C描述技巧。
++++++++++++++++++++++++++++++
移位寄存器的描述。
for(i = N - 1;i > 0;i--){
#pragma HLS unroll
shift_reg[i] = shift_reg[i - 1];
}
shift_reg[0] = x;
对于一维向量的操作描述,使用for循环来描述。
为了更精确的描述左移位操作的先后顺序,不再使用i++的方式,而是使用i–。
最后,补充跳出循环边界后,收尾操作。
为了使HLS能够理解移位的并行特点,正确理解设计意图,添加unroll约束,将for完全展平。
verilog中的拼位操作,实际上是被编译器自动进行了按bit展平处理,虽然代码中没有体现这个展平过程,但是我们心里必须知道,是由这个展平过程的。
C语言里,没有拼位操作符,所以,代码中要么手动展平,要么使用for循环描述一系列只有下标不同的类似操作,并用pragma unroll通知编译器完成展平。
+++++++++++++++++++++++++++++++++++++++++++++++
循环起始间隔(II)是另一个重要的性能度量。 它定义为本次循环到下一次循环开始的时钟周期数。 在本例中, 循环II值为1, 这意味着我们可以在每个周期中启动新的迭代循环。
任何for循环都可以进行流水化优化。
我们通过#pragma HLS pipeline在Vivado HLS 中实现。
在大多数情况下, 循环流水线会减少循环的间隔时间, 但不会影响延迟时间。
请注意, 如果没有适当的数组分割, 展开内部循环可能不会提高性能, 因为并发读取操作的数量受到内存端口数量的限制。
有些代码中的内层循环Vivado HLS是无法完全展开的, 因为循环边界不是常量。
这种循环边界不是常量的情况,是需要尽量避免的。
dataflow 指令和pipeline指令都生成能够流水线执行的电路。 关键的区别在于任务流水的粒度不一样。 pipeline 指令构造了一个在循环级别上有效的流水线化的体系结构, 由指令中的II所决定。
dataflow 指令构造了一种体系结构,这些粗粒度操作不是静态调度的, 是通过流水线中的数据握手来动态地控制的。
dataflow 指令必须要有存储器设置以保证在不同进程之间传递数据。 它使用FIFO实现存储。
++++++++++++++++++++++++++++++++++++++++++++
由于种种原因, 最好使用c++和Vivado HLS模板类
apint<>, ap_uint<>, ap fixed<>, ap_ufixed<>
来表示任意精度数据。
++++++++++++++++++++++++++++++++++++++++++++
当我们选择片上存储器的时候, 需要在嵌入式存储器(例如Block RAM) 或触发器(FF) 之间权衡。
FF的数量通常也限制在大约10万字节左右。
BlockRAM(BRAM) 提供更高的容量, 拥有Mbytes的存储量, 其代价是有限的可访问性。
++++++++++++++++++++++++++++++++++++++++++++
array_reshape和array_partion 都提高了一个时钟周期内可以读取的数组元素个数。
在使用array_reshape 的时候, 所有的元素在变换后的数组中共用同一个地址, 但是array_partition 变换后数组中地址是不相关的。
array_reshape directive 会形成大的存储块, 这样可能更容易高效地映射到FPGA 的资源上。
++++++++++++++++++++++++++++++++++++++++
HLS 中, 通过调用类hls::stream<> 创建FIFO类型的数据结构, 可以很好的进行仿真和综合。
数据通过write() 函数顺序的写入, 通过read() 函数读出。
hls::stream 只能通过引用的方式在函数之间进行传递。
来看一个矩阵块乘的例子。
typedef int DTYPE;
const int SIZE = 8;
const int BLOCK_SIZE = 4;
typedef struct {
DTYPE a[BLOCK_SIZE];
} blockvec;
typedef struct {
DTYPE out[BLOCK_SIZE][BLOCK_SIZE];
} blockmat;
void blockmatmul(
hls::stream<blockvec> &Arows,
hls::stream<blockvec> &Bcols,
blockmat &ABpartial,
int it)
{
#pragma HLS DATAFLOW
int counter = it % (SIZE/BLOCK_SIZE);
static DTYPE A[BLOCK_SIZE][SIZE];
DTYPE AB[BLOCK_SIZE][BLOCK_SIZE] = { 0 };
if(counter == 0){ //only load the A rows when necessary
loadA: for(int i = 0; i < SIZE; i++) {
blockvec tempA = Arows.read();
for(int j = 0; j < BLOCK_SIZE; j++) {
#pragma HLS PIPELINE II=1
A[j][i] = tempA.a[j];
}
}
}
partialsum: for(int k=0; k < SIZE; k++) {
blockvec tempB = Bcols.read();
for(int i = 0; i < BLOCK_SIZE; i++) {
for(int j = 0; j < BLOCK_SIZE; j++) {
AB[i][j] +=A[i][k] * tempB.a[j];
}
}
}
writeoutput: for(int i = 0; i < BLOCK_SIZE; i++) {
for(int j = 0; j < BLOCK_SIZE; j++) {
ABpartial.out[i][j] = AB[i][j];
}
}
}
这个代码中,使用了多个编码技巧。
通过typedef,将一维数组封装到一个struct中,这样,一维数组被理解为一个元素,然后,可以用stream容器来封装一个结构体元素。
通过typedef,将一个二维数组封装到一个struct中,这样,二维数组被理解为一个结构体对象,这样,二维数组就在后面被理解为一个输出对象。
输入参数使用了stream的具象类,这为模块级的流水化提供了保证。
在函数内,语句块被顺序分为了三大块,这为函数内的任务级的流水化提供了保证。任务从上游到下游,分别是输入缓冲,计算处理,临时结果输出。
这里使用了cache,这是一个良好的代码风格,最小化IO访问。static cache(例如A)用static定义,表示这是在多次调用中可以共享的资源。local cache(例如AB)则没有这个关键字。
先来看第一个任务。
使用了条件显隐,精确控制了cache的装载。
stream容器封装的类型是blockvec,所以,每一次read,都是读取的一个blockvec结构体元素,也就是一个一维数组。读取到一个local cache(例如tempA)中缓冲。
对一维数组的遍历,需要使用一个for循环。将tempA中的数据,导入static cache (例如A)中缓冲。
再来看第二个任务。
在最外层for循环中,每次迭代开始,发起一次read,同样的,是从stream容器中读取的一个blockvec结构体元素,放到一个local cache(例如tempB)中缓冲。
内层,需要一个两层嵌套for循环进行逐点处理。所以这里的循环变量,最能使用的是ij,而最外层反而使用的k。经过内层的这个两层嵌套for循环的逐点处理后,二维数组AB中的每个元素都被更新了一次。在最外层for循环的控制下,每次最外层迭代,AB都会被逐点更新一次。
AB是作为中间结果变量使用的,全部的迭代结束后,AB中的中间结果,就是最终结果。
再来看第三个任务。
用一个两层嵌套for循环,进行逐点处理。将中间结果变量AB中寄存的最终结果,逐点拷贝到输出对象中去。
+++++++++++++++++++++++++++++++++++++++++++++
来看一个直方图的例子。
void histogram(int in[INPUT SIZE], int hist[VALUE SIZE])
{
int val;
for(int i = 0; i < INPUT SIZE; i++) {
#pragma HLS PIPELINE
val = in[i];
hist[val] = hist[val] + 1;
}
}
这是原始风格的C描述。
使用了临时变量,也使用了pipeline。但是,代码中仍然存在read after then write。由于内存的重复读写, 系统只能实现II =2的循环。
这是因为在每次迭代循环中我们均需要从hist[]数组中读取数据和写入数据。
改进后的代码如下:
#include ”histogram.h”
void histogram(int in[INPUT SIZE], int hist[VALUE SIZE])
{
#pragma HLS DEPENDENCE variable=hist intra RAW false
int acc = 0;
int i, val;
int old = in[0];
for(i = 0; i < INPUT SIZE; i++) {
#pragma HLS PIPELINE
val = in[i];
if(old == val) {
acc = acc + 1;
}
else {
hist[old] = acc;
acc = hist[val] + 1;
}
old = val;
}
hist[old] = acc;
}
这里使用了几个编码技巧。
首先,通过修改代码,解除了hist数组的intraRAW数据依赖关系,
然后,显式约束dependence,告诉HLS编译器,hist数组不存在intra RAW数据依赖关系。
来看看代码如何修改,可以消除intra RAW的数据依赖性?
第一,使用了临时变量,acc,old,val,作为local cache,
第二,使用了条件控制,避免intraRAW,我们知道,影响II的原因,是因为在同一次迭代中,出现了intraRAW,但是严格意义上,只有对同一个数组元素的intraRAW,才是不可实现的。而对于不同数组元素,是不存在intraRAW的,因为可以通过多端口来访问不同的数组元素。
在本例中,if的语句块,只有对local cache的操作,所以不会出现intraRAW,而else的语句块中,hist[old]和hist[val],也不是同一个元素,所以也不会出现intraRAW。其中的关键,就是用val和old作为两个索引坐标,并将old == val作为语句块的控制条件。
在每次迭代的开始阶段,读取in的元素,寄存到val中,
然后判断val和old是否相同,如果相同,则只操作acc,
如果不相同,则将acc中的计数,写入old索引的数组元素中。然后将val索引的元素的值导入acc,然后将acc加一。
在每次迭代的最后收尾阶段,更新old,将本次输入的val更新到old中,为下一次迭代准备好数据上下文环境。
全部迭代结束后,进行最后的收尾工作,将最后的acc写入最后的old索引的元素。由于最后的收尾是在for循环之外,所以,不会影响for循环的II。
+++++++++++++++++++++++++++++++++++++++++++++++
HLS中, 有多种编码方式可以将代码中的数组接口综合成AXIS流接口,
一种是使用接口约束,
另一种是使用 hls::stream<> 方式显式建立总线流接口。
HLS包含 hls::linebuffer<> 和 hls::window buffer<> 类, 它们可以简化窗口缓冲区和行缓冲区的管理。
但是我们也可以手写一些简约的代码,用来描述行缓冲和窗口。
如果不使用linebuffer,那么window的设置,代码如下:
row_loop: for (int row = 0; row < MAX_HEIGHT; row++) {
col_loop: for (int col = 0; col < MAX_WIDTH; col++) {
#pragma HLS pipeline
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
int wi = row + i − 1;
int wj = col + j − 1;
if (wi < 0 || wi >= MAX_HEIGHT || wj < 0 || wj >= MAX_WIDTH) {
window[i][j].R = 0;
window[i][j].G = 0;
window[i][j].B = 0;
} else
window[i][j] = pixel_in[wi][wj];
}
}
}
if (row == 0 || col == 0 || row == (MAX_HEIGHT − 1) || col == (MAX_WIDTH − 1)) {
pixel_out[row][col].R = 0;
pixel_out[row][col].G = 0;
pixel_out[row][col].B = 0;
} else
pixel_out[row][col] = filter(window);
}
}
}
在外层的两层嵌套for循环中,对图像进行逐点处理。
在循环体内,开始阶段,就是设置window,window是一个二维数组,所以设置window需要用到一个两层嵌套的for循环,进行逐点处理。
由于存在对输入的形参数组pixel_in的反复读取,所以这个代码的II是很高的。
改进的方法,就是使用linebuffer。
rgb_pixel window[3][3];
rgb_pixel line_buffer[2][MAX WIDTH];
#pragma HLS array_partition variable=line_buffer complete dim=1
row_loop: for (int row = 0; row < MAX_HEIGHT; row++) {
col_loop: for (int col = 0; col < MAX_WIDTH; col++) {
#pragma HLS pipeline
for(int i = 0; i < 3; i++) {
window[i][0] = window[i][1];
window[i][1] = window[i][2];
}
window[0][2] = (line_buffer[0][col]);
window[1][2] = (line_buffer[0][col] = line_buffer[1][col]);
window[2][2] = (line_buffer[1][col] = pixel_in[row][col]);
if (row == 0 || col == 0 ||
row == (MAX_HEIGHT − 1) ||
col == (MAX_WIDTH − 1)) {
pixel_out[row][col].R = 0;
pixel_out[row][col].G = 0;
pixel_out[row][col].B = 0;
} else {
pixel_out[row][col] = filter(window);
}
}
}
同样的,在外层的两层嵌套for循环中,对图像进行逐点处理。
在逐点处理的每次迭代的开始阶段,就是设置window,window是一个二维数组,所以设置window需要用到一个两层嵌套的for循环,进行逐点处理。但是这里,我们选择的设计风格是手工展平。
设置window分两个stage,
stage1,move window to eject oldest data,这个操作,是在一个一层for循环中逐行完成的,在每一行中,移动窗口,覆盖掉最旧的数据。
stage2,update window from newest data,这个操作,这里是逐行手工完成的。这里涉及到两个动作,一个是update window,一个是update linebuffer。
需要遵循“先取用再覆盖”的原则,防止数据丢失。
所以,首先用oldest linebuffer的对应column的数据,更新窗口,
然后,再用newer linebuffer的对应column的数据,更新older linebuffer的对应column的数据,并同时更新窗口,
依次向下处理,更新各个linebuffer,并同时更新window。
最后,将本点数据,即newest data 更新到newest linebuffer的对应column,并同时更新窗口。
在大多数情况下, 计算缓冲区窗口只是输入图像的一个区域。 然而, 在输入图像的边界区域, 滤波器
进行计算的范围将超过输入图像的边界。