HLS第三十二课(codingstyle )

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,并同时更新窗口。

在大多数情况下, 计算缓冲区窗口只是输入图像的一个区域。 然而, 在输入图像的边界区域, 滤波器
进行计算的范围将超过输入图像的边界。

猜你喜欢

转载自blog.csdn.net/weixin_42418557/article/details/121096919