一、引言
主要涉及:人工修改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 : 121 (01111001——121)(00101101——45)
the DDR_BASEARDDR rev16 data is : 11641 (0010110101111001)
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进制问题:链接。