HLS:池化运算模块设计与SDK测试

本文详细介绍了在硬件描述语言(HLS)中优化AXI_HP接口的过程,包括内存分块、循环展开、端口约束等,以提升数据处理效率。同时,探讨了DDR3内存的读写操作,以及如何通过AXI_HP接口实现CPU与硬件加速器之间的数据共享。最后,文章提到了实际测试中遇到的问题和解决方案,以及未来可能的实验方向。
摘要由CSDN通过智能技术生成


一、引言

主要涉及:人工修改C代码来引导HLS工具(为啥改,改哪),AXI_HP接口综合(作为Master),内存申请与共享。


二、初步设计

1、pool.h文件。

#ifndef __POOL_H__
#define __POOL_H__
#include "ap_fixed.h"

// 特征图参数
#define K 8
#define OUT_W 50
// 定义两个宏函数
#define MIN(A,B) ((A<B)?A:B)
#define MAX(A,B) ((A<B)?B:A)
// feature_in特征图输入,feature_out特征图输出,method最大、最小和均匀池化选择
// 特征输入和输出都是四维的,涉及分块的基本理论,即特征的内存排布方式
void pool(ap_int<8> feature_in[1][OUT_W*2][OUT_W*2][K],ap_int<8> feature_out[1][OUT_W][OUT_W][K],ap_uint<2> method);

#endif

特征的内存排布方式:
本次处理后的特征内存排布为四维,即Feature包含了[C/K][H][W][K]四个维度的信息。C/K为通道方向上分块后的数目,H为特征的高度,W为特征的宽带,C为特征通道的数目。
在这里插入图片描述
1°最原始的输入特征是三维的。本身三维的东西也不利于硬件去实现,需要先进行分块。沿着通道的方向进行分块,比如C为16,K为8,这里就沿着特征通道C的方向,将特征通道分成了2块。如果通道方向上C大小无法被K整除,就进行补0操作。
2°分块操作后,有利于硬件在通道方向上的并行度。这个部分听讲后,感觉还是有些不懂。得再理解理解。

2、pool.cpp文件。

#include "pool.h"

void pool(ap_int<8> feature_in[1][OUT_W*2][OUT_W*2][K],ap_int<8> feature_out[1][OUT_W][OUT_W][K],ap_uint<2> method)
{
	// 输出四维,需要三重循环处理
	// 每次只对一个通道方向进行处理,所以通道方向数量c为1
	// 从四个子块中pooling出一个子块,这个子块的数据,先从k=0的地方的四个位置做一个pooling
	// 后面往下不断堆,形成一个子块
	for(int c=0;c<1;c++)
		for(int i=0;i<OUT_W;i++)
			for(int j=0;j<OUT_W;j++)
			{
				for(int k=0;k<K;k++)
				{
					// -128~127每个数,需要加大结果位宽
					ap_int<10> tp;
					tp =	feature_in[c][2*i][2*j][k]+
							feature_in[c][2*i][2*j+1][k]+
							feature_in[c][2*i+1][2*j][k]+
							feature_in[c][2*i+1][2*j+1][k];
					// 将后边两位去掉,相当于除以4
					feature_out[c][i][j][k]=tp.range(9,2);
				}
			}
}

如何理解pool.cpp文件中求解的过程呢?pool操作时,四个特征输入,得到一个特征输出。具体在于特征的索引号需要对应上,假设一个特征输出的索引为(H,W)=(i,j),那么按照函数接口的定义特征输入的索引是(H,W)=(2i,2j)、(2i,2j+1)、(2i+1,2j)、(2i+1,2j+1)。为什么是这样,其实是一个很直观的结论,看这下面一张图就可以发现。
在这里插入图片描述
3、main.c文件。

#include "pool.h"
#include <iostream>

int main()
{
	ap_int<8> feature_in[1][OUT_W*2][OUT_W*2][K];
	ap_int<8> feature_out[1][OUT_W][OUT_W][K];

	for(int i=0;i<OUT_W*2;i++)
		for(int j=0;j<OUT_W*2;j++)
			for(int k=0;k<K;k++)
				feature_in[0][i][j][k]=i*OUT_W*2+j;  // 赋初值

	pool(feature_in,feature_out,1);

	for(int i=0;i<OUT_W;i++)
		for(int j=0;j<OUT_W;j++)
			for(int k=0;k<K;k++)
				std::cout<<"out[0]["<<i<<","<<j<<","<<k<<"]="<<feature_out[0][i][j][k]<<std::endl;
}

三、优化操作

1、子块读取方面。
一个周期读取的输入特征子块数目,大大影响了效率,比如单端口RAM,一周期读取一个子块,四周期后才能得到一个结果,如果最后pooling结果为5*5,即25个子块,那么将消耗100个周期以上才能完成操作。使用双端口RAM就能将消耗的时钟降低一半,而若一次读取四个子块数据,则只需要消耗25个时钟周期。想一次读4个字块数据,就得对RAM进行下PARTITION操作,可以把原先的双端口RAM分割成两个。总之,现在有100clk、50clk和25clk的设计目标。这里的设计目标是100clk的,首先,50clk使用的双端口,两个端口同时读出数据,这个方式是不常用的,因为一般双端口有留写入的备用,都作输出目的性不强。其次,25clk的方案RESHAPE和PARTITION的操作其实比较麻烦,需要奇偶行进行对齐等。最后,100clk的方案其实速度已经算快了,因为是基于子块来进行处理的,本身就有8路的通道并行了。

2、端口上约束。
为了不让HLS工具将feature_in和feature_out变成双端口的,可以对两者加入约束:#pragma HLS RESOURCE variable=feature_in core=RAM_1P_BRAM。也可以在Vivado HLS Directive Editor上选择RESOURCE,并把core设为RAM_1P_BRAM。添加完可以跑一个综合和联合仿真后,来看下波形。
补图片
仿真中可以发现,feature_in的数据位宽只有8bit,8个周期才能读出一个子块来。但实际上一次读一个子块,k为0~7,应该是有8个ap_int<8>的数据,也就是一次应该读64bit位宽的数据。需要进行RESHAPE的操作,来一次读出8个数据,即1个子块。这里是对k,也就是第四维来进行RESHAPE操作。
在这里插入图片描述
命令:#pragma HLS ARRAY_RESHAPE variable=feature_in complete dim=4。
在这里插入图片描述

3、for循环优化。
前面对存储器的优化基本解决,但仿真的波形结果中,可以发现,对输出子块0的计算还是8次,这是因为for循环的缘故,本身串行,串行的计算了k=0的结果,k=1的结果…还是慢了8倍。需要对最内层的循环做一个UNROLL操作,即命令:#pragma HLS UNROLL,变成k份并行的做。做循环展开的条件是循环的变量得固定,变量不固定是没法做循环展开的,循环展开的硬件是做死、固定的。
在这里插入图片描述
从波形可以发现,一个子块输出这个操作,还没有PIPELINE,即计算了一个子块后,才对后一个子块进行读等操作。添加命令:命令:#pragma HLS PIPELINE II=4。
在这里插入图片描述
4、将method添加。
添加method选择后,补充C语言的函数功能代码,并再次综合看下是否为100个周期左右,结果并不是100个周期,仅仅只是多了case选择,这都不行?是一件很郁闷的事。

for(int c=0;c<1;c++)
		for(int i=0;i<OUT_W;i++)
			for(int j=0;j<OUT_W;j++)
			{
				#pragma HLS PIPELINE II=4
				for(int k=0;k<K;k++)
				{
					#pragma HLS UNROLL
					if(method==0)//mean
					{
						// -128~127每个数,需要加大结果位宽
						ap_int<10> tp;
						tp =	feature_in[c][2*i][2*j][k]+
								feature_in[c][2*i][2*j+1][k]+
								feature_in[c][2*i+1][2*j][k]+
								feature_in[c][2*i+1][2*j+1][k];
						// 将后边两位去掉,相当于除以4
						feature_out[c][i][j][k]=tp.range(9,2);
					}
					else if(method==1)//min
							feature_out[c][i][j][k]=MIN(feature_in[c][2*i][2*j][k],
							MIN(feature_in[c][2*i][2*j+1][k],
							MIN(feature_in[c][2*i+1][2*j][k],
							feature_in[c][2*i+1][2*j+1][k])));
					else//max
							feature_out[c][i][j][k]=MAX(feature_in[c][2*i][2*j][k],
							MAX(feature_in[c][2*i][2*j+1][k],
							MAX(feature_in[c][2*i+1][2*j][k],
							feature_in[c][2*i+1][2*j+1][k])));
				}
			}

其实,多种模式不行的原因,是HLS工具并非完全智能,我们没有人工修改C语言的方式,来引导HLS完成正确的综合。HLS认为在不同method选择中的feature_in数据,其实并不是一样的,因此多消耗了很多clk。这里,解决办法是:先将每个子块里某个k对应的数据计算出来,然后再用method方法来进行判断并输出,因为数据的获取都是一致的,可以放在同个地方,这样就能引导HLS来正确的翻译。可以改一些OUT_W参数,来再次确定下功能是否正确。


四、AXI_HP接口综合

之前学矩阵乘法单元的时候,使用了axi_lite的接口,但这里输入数据是1001008,就是8万个数据,之前的方法slave需要被写8万次,配8万次参数,CPU直接计算可能都比配置的时间快。不能再使用axi_lite来不断配置写入了。这里,得使用master axi接口了,不比slave axi,后者用的是axi_lite,属于简化版的,并没有前者完整的快。

使用m_axi接口,这个模块主动的去内存中取数据,数据很快就可以被取出来。计算完,再存放会存储器中去。而CPU也是可以访问内存里处理前后的数据。属于CPU与模块间来共享内存。在Vivado HLS Directive Editor中选择INTERFACE,并把mode设为m_axi,offset设为slave,offset可以理解为要取或写的数据在存储器的哪个位置,offset有off、direct和slave选项,CPU那边定义的数组等变量,位置可能是随意的,使用slave后,CPU可以告诉模块,在哪里来取数据。depth也需设置下,多少无所谓,写个9999999都行。对应的命令:#pragma HLS INTERFACE m_axi depth=9999999 port=feature_in offset=slave。另外,还有函数与method部分的,直接设成s_axi就行了,让CPU去配置。
在这里插入图片描述


五、上板测试

HLS生成的电路,一般最高频率没有纯Verilog手写的高,可以通过该PL时钟来挂接,或者在HLS中保证设计要求前提下,把时钟约束弄严苛一些,再跑个结果。

1、把HP口打开来。
默认Zynq核是没有打开HP口的,需要双击打开一个。自动连接中,AXI的互联有AXI Interconnect和AXI SmartConnect两种,功能一样,选择前者,后者貌似有些Bug。

2、Zynq的硬件平台搭建。
在这里插入图片描述
3、实验环境。一如既往的简陋,拍下来以后回想,maybe还有点印象。
在这里插入图片描述

4、DDR3数据的写入。
1°DDR3的地址查看,可以在vivado工程中的Address Editor中查看,也可以在SDK的xparameters_ps.h文件中查看。xparameters_ps.h的位置在pooling_bsp——ps7_cortexa9_0——include中。xparameters_ps.h文件中包含了A9内核可以直接控制的外设地址的宏定义,在里边也能找到DDR3的地址。这里把其记录下来,后面将会用这个地址,来进行读写操作。

/* Canonical definitions for DDR MEMORY */
#define XPAR_DDR_MEM_BASEADDR		0x00000000U
#define XPAR_DDR_MEM_HIGHADDR		0x3FFFFFFFU

2°DDR3的读写操作函数,在pooling_bsp——ps7_cortexa9_0——include中,有一个xil_io.h的文件,这个头文件中包含了可以完成对某个地址输入输出的IO函数。

//从某个地址读数据
static INLINE u8 Xil_In8(UINTPTR Addr);
static INLINE u16 Xil_In16(UINTPTR Addr);
static INLINE u32 Xil_In32(UINTPTR Addr);
//从某个地址写数据
static INLINE void Xil_Out8(UINTPTR Addr, u8 Value);
static INLINE void Xil_Out16(UINTPTR Addr, u16 Value);
static INLINE void Xil_Out32(UINTPTR Addr, u32 Value);

用C编程,完成对DDR3的读写,其中DDR_BASEARDDR做了一个地址上的映射,让写入的数据不从0x00000000开始。

// ddr的读写测试demo
#include <stdio.h>
#include "platform.h"
#include "xil_printf.h"
#include "xparameters.h"
#include "xparameters_ps.h"
#include "xil_io.h"

#define DDR_BASEARDDR      XPAR_DDR_MEM_BASEADDR + 0x10000000

int main()
{
    init_platform();

    int  i;
    int  rev;

    print("Hello World\n\r");

    for (i=0; i<32; i++){
    	Xil_Out32(DDR_BASEARDDR+i*4,i);
	}

    for (i=0; i<32; i++)
	{
    	rev = Xil_In32(DDR_BASEARDDR+i*4);
    	xil_printf( "the address at  %x data is : %d \n\r" , DDR_BASEARDDR+i*4, rev);
	}

    cleanup_platform();
    return 0;
}

测试实验的输出结果,输出正确。
在这里插入图片描述
为了验证一些猜想,做了如下几个测试。
一是先用Xil_Out32函数,在DDR_BASEARDDR地址处写入70000,代表了0001 0001 0001 0111 0000,然后用一个Xil_In8函数,来接收DDR_BASEARDDR上8bit的数据并打印出来,得到的结果是112,也就是0111 0000,验证了一个猜想,就是
Xil_In8之类的读取函数,其实都是从某个地址的LSB开始读的,具体读取的宽度由函数决定。

    Xil_Out32(DDR_BASEARDDR,70000);
    rev = Xil_In8(DDR_BASEARDDR);
    xil_printf( "the address at  %x data is : %d \n\r" , DDR_BASEARDDR, rev);
    // 输出打印结果112

二是将ARM平台数据变量类型占用的空间打印出来,做这个是因为后面可能用到64位的数据,需要对各种类型的占用空间非常明确。另外,网上很多说int是2个字节,但不同平台还是有些差别的,比如这里。

    xil_printf("the char byte is %d \n\r" , sizeof(char));
    xil_printf("the short byte is %d \n\r" , sizeof(short));
    xil_printf("the int byte is %d \n\r" , sizeof(int));
    xil_printf("the long byte is %d \n\r" , sizeof(long));
    xil_printf("the float byte is %d \n\r" , sizeof(float));
    xil_printf("the double byte is %d \n\r" , sizeof(double));

在这里插入图片描述
三是测试了下越DDR位宽读取会怎么样,果然出错了。DDR配置为16bit后,读取时为8bit或16bit就不会出错。后续按照每个数据8bit,读取也按照8bit来读。

    Xil_Out8(DDR_BASEARDDR  , 121);
    Xil_Out8(DDR_BASEARDDR+1, 45);
    Xil_Out8(DDR_BASEARDDR+2, 12);
    Xil_Out8(DDR_BASEARDDR+3, 178);
    Xil_Out8(DDR_BASEARDDR+4, 65);
    Xil_Out8(DDR_BASEARDDR+5, 77);
    Xil_Out8(DDR_BASEARDDR+6, 111);
    Xil_Out8(DDR_BASEARDDR+7, 245);

    rev8 = Xil_In8(DDR_BASEARDDR);
    rev16 = Xil_In16(DDR_BASEARDDR);
    rev32 = Xil_In32(DDR_BASEARDDR);
    rev64 = Xil_In64(DDR_BASEARDDR);

    xil_printf( "the DDR_BASEARDDR rev8 data is : %d \n\r" , rev8);
    xil_printf( "the DDR_BASEARDDR rev16 data is : %d \n\r" , rev16);
    xil_printf( "the DDR_BASEARDDR rev32 data is : %d \n\r" , rev32);
    xil_printf( "the DDR_BASEARDDR rev64 data is : %d \n\r" , rev64);
    //实验结果
    the DDR_BASEARDDR rev8 data is : 12101111001——121)(00101101——45)
	the DDR_BASEARDDR rev16 data is : 116410010110101111001)
	the DDR_BASEARDDR rev32 data is : -1307824775 (超过DDR位宽16bit时,是不正常的)
	the DDR_BASEARDDR rev64 data is : -1307824775 

在这里插入图片描述

3°为了简化测试数据量,池化前的输入特征为[1][10][10][8],池化后的输出特征为[1][5][5][8]。也就是说,一次输入和输出的数据位宽都为8bit*8=64bit,只不过子块的数目变为原来的1/4。将(2i,2j)处的8个8bit的数据合并,一次传入pool模块中,后续也是如此反复。按照8bit读写DDR里的数据。输入1通道的数据量如下图。k为8,有8个通道,即800个数据量。
在这里插入图片描述
4°在通道方向上,需要把相同坐标的8个数据组成一个64bit的数据,传入pool模块中,总共传100次。
在这里插入图片描述
5、最后的代码。


#include <stdio.h>
#include <stdlib.h>
#include "platform.h"
#include "xil_printf.h"
#include "xparameters.h"
#include "xparameters_ps.h"
#include "xil_io.h"
#include "xpool.h"
#include "xpool_hw.h"

#define DDR_BASEARDDR      XPAR_DDR_MEM_BASEADDR + 0x10000000

int main()
{
    init_platform();

    int  i,j,k,v;
    int  rev;
    int  data,state;
    char input_buffer[1][10][10][8];
    char output_buffer[1][5][5][8];

    XPool xpool;

    print("Hello World\n\r");

    // 关闭cache,保证内存缓存读写的一致性
    Xil_DCacheDisable();

    // 判断初始化pool模块是否成功
	state = XPool_Initialize(&xpool, XPAR_POOL_0_DEVICE_ID);
	if(state != XST_SUCCESS)
	{
		print("XPool_Initialize fail!!\n\r");
		return XST_FAILURE;
	}

    // 初始化buffer数据
    for(i=0; i<1; i++)
    	for(j=0; j<10; j++)
    		for(k=0; k<10; k++)
    			for(v=0; v<8; v++)
    				input_buffer[i][j][k][v]=(j*10+k)%100;
/*
    for(i=0; i<1; i++)
    			for(j=0; j<10; j++)
    				for(k=0; k<10; k++)
    					for(v=0; v<8; v++)
    						xil_printf( "the input_buffer is [%d][%d][%d][%d] : %d \n\r" , i,j,k,v, input_buffer[i][j][k][v]);
*/
    for(i=0; i<1; i++)
		for(j=0; j<5; j++)
			for(k=0; k<5; k++)
				for(v=0; v<8; v++)
					output_buffer[i][j][k][v]=0;

    // 设置method模式,为均值池化
    XPool_Set_method_V(&xpool, 0);
    XPool_Set_feature_in_V(&xpool, (u32)input_buffer);
    XPool_Set_feature_out_V(&xpool, (u32)output_buffer);

    // 启动电路,将ap_start置为1,开始计算。
    XPool_EnableAutoRestart(&xpool);
    XPool_Start(&xpool);
    print("Test Start!!!\n\r");

    // 数据输出
	// 判断计算完成的条件是ap_done为1,当不是1时说明还尚未完成,就一直读取判断,直至算完。
	data  = XPool_IsDone(&xpool);
	while(data != 1)
	{
		data  = XPool_IsDone(&xpool);

	}

	for(i=0; i<1; i++)
			for(j=0; j<5; j++)
				for(k=0; k<5; k++)
					for(v=0; v<8; v++)
						xil_printf( "the output_buffer is [%d][%d][%d][%d] : %d \n\r" , i,j,k,v, output_buffer[i][j][k][v]);

    cleanup_platform();
    return 0;
}

6、测试结果。
至于数据为啥是这样的,可以根据feature_in来推导就行了,比较简单。
在这里插入图片描述


六、补充部分

1、完整pool.cpp的代码。

#include "pool.h"

void pool(ap_int<8> feature_in[1][OUT_W*2][OUT_W*2][K],ap_int<8> feature_out[1][OUT_W][OUT_W][K],ap_uint<2> method)
{
	#pragma HLS INTERFACE s_axilite port=return
	#pragma HLS INTERFACE m_axi depth=9999999 port=feature_in offset=slave
	#pragma HLS INTERFACE m_axi depth=9999999 port=feature_out offset=slave
	#pragma HLS ARRAY_RESHAPE variable=feature_in complete dim=4
	#pragma HLS ARRAY_RESHAPE variable=feature_out complete dim=4
	#pragma HLS INTERFACE s_axilite port=method

	for(int c=0;c<1;c++)
		for(int i=0;i<OUT_W;i++)
			for(int j=0;j<OUT_W;j++)
			{
				#pragma HLS PIPELINE
				for(int k=0;k<K;k++)
				{
					#pragma HLS UNROLL
					ap_int<8> dat1=feature_in[c][2*i][2*j][k];
					ap_int<8> dat2=feature_in[c][2*i][2*j+1][k];
					ap_int<8> dat3=feature_in[c][2*i+1][2*j][k];
					ap_int<8> dat4=feature_in[c][2*i+1][2*j+1][k];
					ap_int<8> result;

					if(method==0)//mean
					{
						ap_int<10> tp;
						tp=dat1+dat2+dat3+dat4;
						result=tp.range(9,2);
					}
					else
						if(method==1)//min
							result=MIN(dat1,MIN(dat2,MIN(dat3,dat4)));
						else//max
							result=MAX(dat1,MAX(dat2,MAX(dat3,dat4)));
					feature_out[c][i][j][k]=result;
				}
			}
}

2、HLS综合后性能。50MHz。
在这里插入图片描述
3、部分接口。
在这里插入图片描述
4、综合后仿真结果。
在这里插入图片描述
5、测试中犯的几个错误。
多维数组索引问题:数组引用时的下标,不能超过或等于定义时的下标号。以前经常写C,就不会忘,但隔了有一段时间没写C代码,就出现了这种低级错误。最气的是这东西,编译器还不报错。感谢队友的帮助与不杀之恩。
在这里插入图片描述
Cache缓存一致性问题:Cache是为了CPU处理数据更高效而设计的缓存,但pool模块通过HP接口访问的是DDR内存,不是Cache,这样若更改了DDR数据,CPU还是从Cache读之前的数据,就会出现验证失败的现象。要解决一致性问题,把Cache给关闭了就行。不关闭,就出现下面这种结果了。
在这里插入图片描述
关于代码设计时的误区:虽然在pool模块中使用了HP接口,但实际上,method、start、done、feature_in_address和feature_out_address这类信号,还是通过slave_axi完成的,而我们也不需要对HP那边的接口进行操作,而只需要完成模块本身输入端的控制就像,而输入端的控制也就是CPU通过s_axi来完成对很多寄存器的配置,SDK中用函数实现。


七、时间与参考

一些关键时间点的记录:

2021年04月10日:pooling设计的一些知识。
2021年04月11日:HLS开发pooling模块学习,同时测试,验证了部分猜想。
2021年04月12日:完成模块的测试,没有太大的问题。
后续可能做的工作:如果有Pynq板卡,计划将本实验作为Pynq学习实验。

参考性的资料:

Zynq_PS如何控制DDR3内存读写:链接
C语言print进制问题:链接


评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ATian+

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值