最近实验室租了块xilinx家的AlveoU200加速卡,过去几天被这块板吸引了注意力。刚开始了解,做点什么来试试水呢?一想,可以把曾经学 @蔡宇杰 大佬在pynq-z2上做的那个手写数字识别工程在这块板上复现一下。
数字识别的基础知识在我曾经pynq-z2的总结里讲过了,陈年老文章链接在这:
花火同学:使用PYNQ搭建手写数字识别工程小白级说明(完整版)zhuanlan.zhihu.com接下来就专门来说说加速卡的开发是个什么名堂,以我自己的理解先来比较一下zedboard纯PL开发,pynq使用,以及U200加速卡的区别吧。
1.纯PL开发没啥可说的,写好RTL,烧进去,跑就完事了。
2.pynq的开发跟zedboard的PS开发本质是一回事,只不过pynq是一个先装好了linux的zedboard,同时还有个overlay让这个板子可以支持python。这个在调用的时候,虽然ip核的调用是用python(在zedboard就是用C)来控制的,但ip核之间的调用流程,还有内存的使用方式等细节,我们是控制不了的。
3.U200走的路子跟前面就不同了,它用openCL来写主函数, openCL是专门为异构平台写程序的语言。我们可以实现对ip核调用过程,内存使用等方法的控制。emmm,说实话我简单使用下来的感觉是opencl使用起来并不方便,但起码它提供了方法完成以前做不到的事情2333。
整个事情的流程是这样的:
(1)测试读取目标图像的bmp文件、卷积核以及偏置数据的代码。
(2)使用VITIS的HLS工具将c代码封装成核
(3)用openCL编写host程序,跑仿真,这里面我还分了9步。
3-1:读取main函数的argv[](也就是读取ip核)
3-2:读取图像
3-3:读取卷积核以及偏置参数
3-4: 在host上分配各种ip核处理完后放结果的内存
3-5:用opencl做配置操作
3-6:接下来将device跟host上已经写好的内存位置连接起来
3-7:设置第一次卷积和池化的参数
3-8:将第一次卷积操作压入queue,开始执行
3-9:GUI 操作
(1)读取目标图像的bmp文件、卷积核以及偏置数据
1-1:读bmp
读取目标图像在pynq上面是通过cv2的读取jpeg库函数一步到位完成的。但是U200不支持python,所以这个过程咱们要自己写段简单的C程序完成,为了方便我们将目标函数从jepg格式改成bmp格式。
代码在下面,逻辑很简单,就是bmp整个格式实际有效的数据在文件最后,文件前面有一堆标注格式和文件内容的"废话",我们要做的就是跳过前面的部分,直接把后面的内容读出来。
这里多说一句,这种读文件的操作,写代码的时候还是多花几秒钟把各步的错误信号设置好,说不定就可以为debug省去不少时间。
uint8_t* readbmpfile(const char* filepath) {
FILE* img_pFile;
size_t img_size;
size_t result;
img_pFile = fopen(filepath, "rb");
if (img_pFile == NULL) { fputs("File error", stderr); exit(1); }
// 读bmp大小
fseek(img_pFile, 0, SEEK_END);//把指针放去末尾
img_size = ftell(img_pFile);//读出指针的位置,也就是这个文件的byte数量
rewind(img_pFile);//将指针放回开头
// 开块动态数组
uint8_t* ptr_buffer;
ptr_buffer = new uint8_t[img_size - BMP_OFFSET];
if (ptr_buffer == NULL) { fputs("Memory error", stderr); exit(2); }
//跳1078
fseek(img_pFile, BMP_OFFSET, SEEK_SET);
//将bmp从1078位以后的内容读去动态数组ptr_buffer
result = fread(ptr_buffer, sizeof(uint8_t), img_size - BMP_OFFSET, img_pFile);//result是正确读到的元素数量
if (result != img_size - BMP_OFFSET) { fputs("Reading error", stderr); exit(3); }
cout << result << endl;
fclose(img_pFile);
return ptr_buffer;
}
效果是这样的:
刚读进来是个反的2,简单处理了一下,没啥可说的。
1-2:接下来是要读取卷积函数用到的卷积核以及偏置数据:
首先先来复习一下整个流程用到的数据情况,不做过多解释了,了解流程的一看就懂了,不了解的可以回去翻一下我过去在pynq那篇里的总结。
(2)使用VITIS的HLS工具将c代码封装成核
关于HLS,简单介绍一下三部分内容。
2-1:HLS对循环的处理。
下面这个图就是HLS跑完综合后,对程序里面循环的处理报告,拿下面这个报告中的WR_Loop_Col循环做为例子,画图说明一下其中各个参数的含义。
HLS默认情况下会自动对64次以下的循环做优化,优化的目标是要让循环在1周期内完成,这个目标有时候会太苛刻了,完成不了,那么HLS在跑完综合之后就会在报告里提出issue,issue type是II violation 这里的II 是iteration interval的缩写,并不是"第二类冲突"的意思。
2-2:HLS中进程细节查看以及处理优化冲突
接着上面的点,比如我在自己要做HLS的函数中出现了II violation,咋办呢,可以右键,先到进程调度操作里面看一看。
当然我们也可以在这里界面里退一步,看看全局的调度是什么情况,比如下面这样。
这样就可以清楚地看到,这个循环主要是从gmem(global memory,跟CUDA编程里的global memory类似的概念)读了两个数,这就是图里面两个很长的readreq操作,然后做了一点处理之后,在sum_4这个位置做了fadd操作,也就是浮点加法,我们的问题就出在这个浮点加法上,HLS告诉我们它没有办法把浮点加法压缩到一个周期完成,臣妾做不到啦,最多就压缩到3个周期了(这就是报告图中inteval=3的含义)。
看清楚这个问题之后,我们还能怎么办呢,我们暂时就先摸摸HLS的狗头,微笑地告诉它,3周期就3周期吧,先这么着吧。
具体做法在cpp的那个循环处,就加个directive,手动添加pipeline的directive,同时指定其inteval参数为3。
2-3:HLS对于传入函数的参数会采用什么协议来传数据
默认协议有下面这么几类
详细一点的解释
(3)编写host代码,跑软件仿真
3-1:读取main函数的argv[](也就是读取ip核)
在我的工程里一共有两个参数,argv[0]是地址,argv[1]是两个kernel的二进制文件。
int main(int argc, char* argv[]) {
cout<<"setting address of xclbin"<<endl<<endl;
/**********annotation**********
* ↓ 通过argv把xo文件的xclbin传进来
* ********************/
if (argc != 2) {
std::cout << "Usage: " << argv[0] << " <XCLBIN File>" << std::endl;
return EXIT_FAILURE;
}
std::string binaryFile = argv[1];//传入卷积kernel的地址
//std::string binaryFile_2 = argv[2];//传入池化kernel的地址
cout<<argv[0]<<endl;//地址
cout<<argv[1]<<endl;//第一个xclbin
3-2:读取图像,这个部分在前面visual stuido的测试程序里讲过了
3-3:读取卷积核以及偏置参数,函数实现基本跟前面读取bmp部分是一样的
这里只是把读取出来东西重新放进了我们声明的一个vector里面,这是多此一举吗,并不,因为后面我们要把host上的这个vector里的参数传去给device的内存(也就是加速卡上的内存),这一步直接用vector的系统函数比较方便,所以这里做下这种简单处理。
cout<<"#######read w_conv1#######"<<endl;
size_t size_w_conv1_byte = sizeoffile("../data/W_conv1.bin");
cout<<"size_w_conv1_byte"<<size_w_conv1_byte<<endl;
size_t size_w_conv1_float = size_w_conv1_byte/4;
cout<<"size_w_conv1_float"<<size_w_conv1_float<<endl;
std::vector<float,aligned_allocator<float>> din_w_conv1(size_w_conv1_float);
cout<<"allocation of memory finished"<<endl;
float* ptr_buffer_w_conv1 = readfilterfile_to_float("../data/W_conv1.bin",IN_HEIGHT1,IN_WIDTH1,IN_CH1,OUT_CH1);
//把数据从我们的读取函数取出来,放到vector里面
cout<<"readfilterfile_to_float (W_conv1.bin) finished"<<endl;
for(size_t i = 0 ; i < size_w_conv1_float ; i++){
din_w_conv1[i] = ptr_buffer_w_conv1[i];
}
cout<<"setting din_w_conv1 finished"<<endl;
delete[] ptr_buffer_w_conv1;
for (int i = 0; i < 16; i++) {
for (int j = 0; j < 3; j++) {
for (int k = 0; k < 3; k++) {
cout << setw(5) << (din_w_conv1[i*3*3 + j*3+k]);
}
// cout << endl;
}
}
3-4: 在host上分配各种ip核处理完后放结果的内存,跟前面差不多。
/**********annotation**********
* ↓分配输出第一次卷积后,dout_featureout1的内存,并且初始化为0
* ********************/
size_t size_featureout1 = OUT_CH1* IN_WIDTH1* IN_HEIGHT1;//16*28*28
std::vector<float,aligned_allocator<float>> dout_featureout1(size_featureout1);
for(size_t i = 0 ; i < size_featureout1 ; i++){
dout_featureout1[i] = 0;
}
cout<<"allocation and setting of dou_featureout1"<<endl<<endl;
3-5:重头戏来了,用opencl做配置操作。
cout << "##########step3:OPENCL configuration##############" << endl << endl;
/**********annotation***
* ↓OPENCL的操作:
* one device
* one context
* one queue
* two binary files
* two programs
* two kernels
* ********************/
// Step 0: Device //配置器件
std::vector<cl::Device> devices = get_devices("Xilinx");
devices.resize(1);
cl::Device device = devices[0];
// Step 1: Create Context//配个context
OCL_CHECK(err, cl::Context context(device, NULL, NULL, NULL, &err));
cout<<"create context"<<endl;
// Step 2: Create Command Queue//生产指令列表
//OCL_CHECK(err, cl::CommandQueue q(context, device, CL_QUEUE_PROFILING_ENABLE, &err));
OCL_CHECK(err, cl::CommandQueue q(context, device, 0, &err));
cout<<"create commandqueue"<<endl;
// Step 3: Load Binary File from disk//读取二进制xo文件
unsigned fileBufSize;//fileBufSize在下面的read_binary_file函数里面被赋了文件大小值
char* fileBuf = read_binary_file(binaryFile, fileBufSize);//这里用到了最开始的argv,所以把上面的语句放这应该会更好点
cl::Program::Binaries bins{{fileBuf, fileBufSize}};
cout<<"read conv xclbin"<<endl;
// Step 4: Create the program object from the binary and program the FPGA device with it//个人理解:相当于烧bitstream
OCL_CHECK(err, cl::Program program(context, devices, bins, NULL, &err));
cout<<"program xclbin"<<endl;
// Step 5: Create Kernels//个人理解:相当于给烧了bitstream的PL部分配上PS的寄存器
OCL_CHECK(err, cl::Kernel krnl_Conv(program,"Conv", &err));//kernel1//这里用的名字应该是hls选的那个TOP函数的函数名
cout<<"create kernel:Conv finished"<<endl<<endl;
...其它ip核同理
3-6:接下来将device跟host上已经写好的内存位置连接起来
cout<<"step4:setting parameters for kernel"<<endl;
// ================================================================
// Setup Buffers and run Kernels
// Allocate Global Memory for source_in1 //在device上面分配内存,分配的时候,这里应该.data()只是返回指针,所以这里没有真的在做搬运,而只是标了真正在queue里面要搬运时候的地址
// 自己做个规范,host的内存,用下划线,global的内存,用驼峰命名法
/ 在device上配读img的位置,读第一次卷积核与偏置的位置,读输出doutFeatureOut1和输出doutFeatureOut11的位置
OCL_CHECK(err, cl::Buffer dinImage (context,CL_MEM_USE_HOST_PTR | CL_MEM_READ_ONLY,img_size, din_img.data(), &err));
OCL_CHECK(err, cl::Buffer dinWeightConv1 (context,CL_MEM_USE_HOST_PTR | CL_MEM_READ_ONLY,size_w_conv1_byte, din_w_conv1.data(), &err));
OCL_CHECK(err, cl::Buffer dinBiasConv1 (context,CL_MEM_USE_HOST_PTR | CL_MEM_READ_ONLY,size_b_conv1_byte, din_b_conv1.data(), &err));
OCL_CHECK(err, cl::Buffer doutFeatureOut1 (context,CL_MEM_USE_HOST_PTR | CL_MEM_READ_ONLY,size_featureout1*4, dout_featureout1.data(), &err));
OCL_CHECK(err, cl::Buffer doutFeatureOut11 (context,CL_MEM_USE_HOST_PTR | CL_MEM_READ_ONLY,size_featureout11*4, dout_featureout11.data(), &err));
....读取其它参数同理
3-7:设置第一次卷积和池化的参数
(这里最后4个参数是传递指针进去,前面都是传具体的参数,我直接用一个h文件把所有参数都define好了,查找和复制起来都比较方便,当然更好的方法是以后kernel的c函数就不要传这么多参数了,就简简单单传个类进去就好了)
// 设置第一次卷积和池化的参数
OCL_CHECK(err, err = krnl_Conv.setArg(0, IN_CH1 ));//
OCL_CHECK(err, err = krnl_Conv.setArg(1,IN_HEIGHT1 ));//
OCL_CHECK(err, err = krnl_Conv.setArg(2,IN_WIDTH1 ));//
OCL_CHECK(err, err = krnl_Conv.setArg(3,OUT_CH1 ));//
OCL_CHECK(err, err = krnl_Conv.setArg(4, KERNEL_WIDTH1));//
OCL_CHECK(err, err = krnl_Conv.setArg(5,KERNEL_HEIGHT1 ));//
OCL_CHECK(err, err = krnl_Conv.setArg(6,X_STRIDE1 ));//
OCL_CHECK(err, err = krnl_Conv.setArg(7,Y_STRIDE1 ));//
OCL_CHECK(err, err = krnl_Conv.setArg(8,MODE1 ));//
OCL_CHECK(err, err = krnl_Conv.setArg(9,RELU_EN1 ));//
OCL_CHECK(err, err = krnl_Conv.setArg(10, dinImage));//
OCL_CHECK(err, err = krnl_Conv.setArg(11, dinWeightConv1));//
OCL_CHECK(err, err = krnl_Conv.setArg(12, dinBiasConv1));//
OCL_CHECK(err, err = krnl_Conv.setArg(13, doutFeatureOut1));//
cout<<"setting parameters for first krnl_Conv finished"<<endl;
OCL_CHECK(err, err = krnl_Pool.setArg(0, IN_CH11));//
OCL_CHECK(err, err = krnl_Pool.setArg(1, IN_HEIGHT11));//
OCL_CHECK(err, err = krnl_Pool.setArg(2, IN_WIDTH11));//
OCL_CHECK(err, err = krnl_Pool.setArg(3, KERNEL_WIDTH11));//
OCL_CHECK(err, err = krnl_Pool.setArg(4, KERNEL_HEIGHT11));//
OCL_CHECK(err, err = krnl_Pool.setArg(5, MODE11));//它娘的参数粘帖错了 mmp
OCL_CHECK(err, err = krnl_Pool.setArg(6, doutFeatureOut1));//读入上面的结果doutFeatureOut1
OCL_CHECK(err, err = krnl_Pool.setArg(7, doutFeatureOut11));//输出为doutFeatureOut11
cout << "setting parameters for first krnl_Pool finished" << endl;
...其它核配置方法同理
3-8:将第一次卷积操作压入queue,开始执行。先一图流解释一下
/**********annotation**********
* 准备执行第一次卷积池化
* ****************/
cout<<"开始压指令"<<endl;
//从stackoverflow上看,在这把kernel压入队列之后,压入的操作就定死了,后面就可以去改argument重新压新操作进队列了
cout<<"1.把img,第一次卷积用的filter和bias从host搬去device"<<endl;
OCL_CHECK(err, err = q.enqueueMigrateMemObjects({dinImage, dinWeightConv1,dinBiasConv1},0/* 0 means from host*/)); //把device上的global内容给到kernel里面,话说这里应该是写错了,0标志这里做的是从global传去给kernel里面
cout<<"2.执行krnl_Conv"<<endl;
OCL_CHECK(err, err = q.enqueueTask(krnl_Conv));
cout<<"3.搬输出回host"<<endl;
OCL_CHECK(err, err = q.enqueueMigrateMemObjects({doutFeatureOut1},CL_MIGRATE_MEM_OBJECT_HOST));//把算完的doutFeatureOut1拿出来
//
cout<<"#######再看一遍host上现在到三组数据#######"<<endl;
cout<<"转成float的host上到图像内容"<<endl;
for (int k = 0; k < 100; k++) {cout << setw(5) << (din_img[k]);}cout<<endl;
cout<<"host上的din_w_conv1"<<endl;
for (int k = 0; k < 100; k++) {cout << setw(5) << (din_w_conv1[k]);}cout<<endl;
cout<<"host上的din_b_conv1"<<endl;
for (int k = 0; k < 100; k++) {cout << setw(5) << (din_b_conv1[k]);}cout<<endl;
cout<<"#######第一次conv前host上dout_featureout1前100内容#######"<<endl;
for (int k = 0; k < 100; k++) {cout << setw(5) << (float)(dout_featureout1[k]);}cout<<endl;
cout<<"执行q.finish()"<<endl;
q.finish();
cout<<"#######第一次conv后host上dout_featureout1前100内容#######"<<endl;
for (int k = 0; k < 100; k++) {cout << setw(5) << (dout_featureout1[k]);}cout<<endl;
按照这个方法,把我们要做的2次卷积,2次池化,2次全连接串起来,就可以得到结果了。
多说两句,1.当我们需要让多个核并行执行的时候,我们可以在造queue的时候,声明一个参数来让软件自动把能并行的核做并行处理。2.当几个核之间的数据也是串的,比如核1的结果是核2的输入,我们并不需要让host先存核1的输出,再将这个传给核2,而是可以直接一步到位让核1核2在device里传数据,但这样做需要在函数的hls阶段给参数加hls stream的directive。下次再搞,以后关于优化还有很多要学的,未来学了再输出。
3-9:GUI 操作
把该放进来的文件都放进工程之后,在project界面下,点击Hardware Function处的小闪电,把卷积核池化的函数进行加速。
在Emulation-SW处右键,在run的configuration里面将arguments点选上自动添加二进制文件,就可以了。
下面是最后的运行结果:
四、硬件仿真过程
这步跟软件仿真的主要区别就似乎不再只是读两个核从cpp文件,而是要读hls生成的xo文件。对其它文件的处理也有点小名堂。另外这步要花的时间非常非常长,比如这么个简单的网络,软仿只要几秒钟,但是硬件仿真花了4个多小时...
写累了,后面有空再更吧...