opencl 加速 c语言程序_在AlveoU200加速卡上实现简单手写数字识别

最近实验室租了块xilinx家的AlveoU200加速卡,过去几天被这块板吸引了注意力。刚开始了解,做点什么来试试水呢?一想,可以把曾经学 @蔡宇杰 大佬在pynq-z2上做的那个手写数字识别工程在这块板上复现一下。

数字识别的基础知识在我曾经pynq-z2的总结里讲过了,陈年老文章链接在这:

花火同学:使用PYNQ搭建手写数字识别工程小白级说明(完整版)​zhuanlan.zhihu.com
b475509382b88aca3343ec815ddfdd98.png

接下来就专门来说说加速卡的开发是个什么名堂,以我自己的理解先来比较一下zedboard纯PL开发,pynq使用,以及U200加速卡的区别吧。

4fce372b241c4d4f0383e025d32eca56.png

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;
}

效果是这样的:

4958a2ca74102142944b5669d21eadd3.png

刚读进来是个反的2,简单处理了一下,没啥可说的。

1-2:接下来是要读取卷积函数用到的卷积核以及偏置数据:

首先先来复习一下整个流程用到的数据情况,不做过多解释了,了解流程的一看就懂了,不了解的可以回去翻一下我过去在pynq那篇里的总结。

5dc9a316810928a86ea7fd548a108282.png

(2)使用VITIS的HLS工具将c代码封装成核

关于HLS,简单介绍一下三部分内容。

2-1:HLS对循环的处理。

下面这个图就是HLS跑完综合后,对程序里面循环的处理报告,拿下面这个报告中的WR_Loop_Col循环做为例子,画图说明一下其中各个参数的含义。

9e99389916f84584512c03b8f46475f3.png

HLS默认情况下会自动对64次以下的循环做优化,优化的目标是要让循环在1周期内完成,这个目标有时候会太苛刻了,完成不了,那么HLS在跑完综合之后就会在报告里提出issue,issue type是II violation 这里的II 是iteration interval的缩写,并不是"第二类冲突"的意思。

2-2:HLS中进程细节查看以及处理优化冲突

接着上面的点,比如我在自己要做HLS的函数中出现了II violation,咋办呢,可以右键,先到进程调度操作里面看一看。

0696bf1476dd6bf83260262bd7ea7e76.png

f47b28ec8aff609c8497415270f4799b.png

当然我们也可以在这里界面里退一步,看看全局的调度是什么情况,比如下面这样。

910a9ce6405c3d8fe5276683fbc4c812.png

这样就可以清楚地看到,这个循环主要是从gmem(global memory,跟CUDA编程里的global memory类似的概念)读了两个数,这就是图里面两个很长的readreq操作,然后做了一点处理之后,在sum_4这个位置做了fadd操作,也就是浮点加法,我们的问题就出在这个浮点加法上,HLS告诉我们它没有办法把浮点加法压缩到一个周期完成,臣妾做不到啦,最多就压缩到3个周期了(这就是报告图中inteval=3的含义)。

看清楚这个问题之后,我们还能怎么办呢,我们暂时就先摸摸HLS的狗头,微笑地告诉它,3周期就3周期吧,先这么着吧。

具体做法在cpp的那个循环处,就加个directive,手动添加pipeline的directive,同时指定其inteval参数为3。

7284497bd17152a6aba003ab4fad6d56.png

2-3:HLS对于传入函数的参数会采用什么协议来传数据

默认协议有下面这么几类

ca70c0f26d75f8af1de6a4dba312c8a7.png

详细一点的解释

972e245295faf017f9c280f65fd83ad9.png

(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,开始执行。先一图流解释一下

f3191e48d174003c9e9d71e22f0d1448.png
/**********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处的小闪电,把卷积核池化的函数进行加速。

3c0eef45643311447d74e1b07b96fee0.png

在Emulation-SW处右键,在run的configuration里面将arguments点选上自动添加二进制文件,就可以了。

cc334dbec3d4316123cf4fce96184d9f.png

下面是最后的运行结果:

5fda1f5f473f3e06cffd3f2fba8cabdf.png

四、硬件仿真过程

这步跟软件仿真的主要区别就似乎不再只是读两个核从cpp文件,而是要读hls生成的xo文件。对其它文件的处理也有点小名堂。另外这步要花的时间非常非常长,比如这么个简单的网络,软仿只要几秒钟,但是硬件仿真花了4个多小时...

写累了,后面有空再更吧...

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值