GPU并行计算OpenCL(1)——helloworld

GPU并行计算OpenCL(1)——helloworld

随着现在GPU越来越强大,我们看论文的时候经常听到GPU加速的某某某算法,但是到底如何进行加速呢?CUDA可能大家更加熟悉(奈何电脑是MAC),这里介绍就OpenCL。

OpenCL(Open Computing Langugae)是第一个面向异构系统(此系统中可由CPU,GPU或其它类型的处理器架构组成)的并行编程的开放式标准。它是跨平台的。OpenCL由两部分组成,一是用于编写kernels(在OpenCL设备上运行的函数)的语言,二是用于定义并控制平台的API(函数)。OpenCL提供了基于任务和基于数据两种并行计算机制,它极大地扩展了GPU的应用范围,使之不再局限于图形领域。

我们首先了解一个知识:并发性,并发性指软件系统包含多个操作流时,这些操作流在运行的时候同时向前推进。当我们的硬件有多个处理单元的时候,我们就称为并行性,即硬件支持的并发性就是并行性。

OpenCL基本概念

OpenCL作为面向异构的平台,必须完成以下步骤:

1)发现构成异构系统的组件。

2)探查组件的特征,是软件能适应不同硬件单元的特定特性。

3)创建将在平台上运行的指令块(内核)。

4)建立并管理计算中涉及的内存对象。

5)在系统中正确的组件上按正确的顺序执行内核。

6)收集最终结果。

我们可以吧上述过程转换为4种模型:平台模型,执行模型,内存模型,编程模型。

平台模型

平台模型总包括一个宿主机OpenCL设备。

宿主机:通常为我们的CPU,其作用包括定义kernel、为kernel指定上下文、定义NDRange和队列等。

OpenCL设备:通常称为计算设备,可以是CPU、GPU、DSP或硬件提供以及OpenCL开发商支持的任何其他处理器。

计算设备进一步划分为计算单元,计算单元还可以细分为一个或多个处理单元,设备的计算都在处理单元中完成。

执行模型

OpenCL应用由两个不同部分组成:一个宿主机程序以及一个或者多个内核组成的集合。

宿主机程序在宿主机上运行,内核在OpenCL设备上运行。OpenCL定义了两类内核:

OpenCL内核:用OpenCL C编程语言编写,并应用OpenCL编译器编译的函数,所有OpenCL实现必须支持OpenCL内核。

原生内核:OpenCL之外穿创建的函数,在OpenCL中可以通过一个函数指针来访问。例如,这些函数可以是宿主机中定义的函数或者专门的库导出的函数。

由于编写OpenCL的重点就是执行内核,所以我们首先了解内核如何在OpenCL设备上执行

内核在宿主机上定义。宿主机程序发出一个命令,提交内核在一个OpenCL设备上执行。由宿主机发出这个命令时,OpenCL运行时系统会创建一个整数索引空间。对应这个索引空间中的各个点将分别执行内核的一个实例。我们把执行内核的各个实例称为一个工作项,工作项由它在索引空间中的坐标来标识,这些坐标就是工作项的全局ID。

多个工作项可以组成一个工作组。整个全局索引空间可以分成多个工作组,每个工作组中又有多个工作项。因此工作组的维度大学需要正好设置能整除全局索引空间。因此我们会有一个工作组的索引,工作组内的工作项也能有局部索引。这样我们就可以通过全局索引来索引各个工作项。可以以先索引工作组,然后根据工作组内的局部索引来获得具体的工作项。这里值得注意的是,OpenCL只能确保一个工作组中的工作项并发执行(虽然工作组或内核通常也会并发执行,但是我们设计算法不能依赖此)。

在这里,我们把索引空间称为NDRange。 如下图:

该例子很好的解释了工作项,工作组和索引空间的概念。如该图,我们的全局空间由12*12个工作项组成,我们把每4*4个工作项分为一组工作组,因此我们图中的黑色格子的全局索引为(6,5)。局部索引为(1,1)工作组内的(2,1)工作项。

OpenCL应用的计算工作在OpenCL设备上进行。不过宿主机同样需要完成非常多的工作。内核就是在宿主机上定义的,而且宿主机还为内核建立了上下文。宿主机还定义了NDRange和队列,队列将控制内核如何执行以及何时执行的细节。

上下文

宿主机的第一个任务就是定义上下文。上下文就是一个环境,内核将在该环境下定义和执行。该环境需要以下资源:

设备:宿主机使用的OpenCL设备集合。

内核:在OpenCL设备上运行的OpenCL函数。

程序对象:实现内核的程序源代码和可执行文件。

内存对象:内存中对OpenCL设备可见的一组对象,包含可以由内核实例处理的值。

上下文由宿主机使用OpenCL API函数创建,宿主机程序在其中一个CPU上运行,宿主机程序请求系统发现资源(系统内的CPU和GPU),然后决定OpenCL应用中使用哪些设备。

上下文还包括一个或者多个程序对象,程序对象包含内核的代码。程序对象会在运行时由宿主机程序构建。假设我们的上下文包含OpenCL设备和一个程序对象,将从这个程序对象中取出内核来执行。我们需要考虑内核如何与内存交互。OpenCL引入了内存对象。内存对象在宿主机上明确定义,并在宿主机和OpenCL设备间移动。

我们现在已经了解了OpenCL应用中的上下文。上下文就是OpenCL设备,程序对象,内核以及内核在执行时使用的内存对象。现在我们要看宿主机如何向OpenCL设备发出命令。

命令队列

宿主机与OpenCL设备之间的交互是通过命令完成的,这些命令由宿主机提交给命令队列。这些命令会在命令队列中等待,知道在OpenCL设备上执行。命令队列由宿主机创建,并在定义完上下文之后关联到一个OpenCL设备。。宿主机将命令放入命令队列,然后调度这些命令在关联设备上执行。

我们队列的命令执行有两种模式:

有序执行:按顺序完成命令。

乱序执行:不需等待前一个命令完成。

内存模型

OpenCL定义了两种类型的内存对象:缓冲区对象图像对象。缓冲区对象就是内核可用的一个连续的内存区,我们可以将数据结构映射到这个缓冲区,并通过指针访问缓冲区。图像对象仅限于存储图像,图像存储格式可以进行优化来满足一个特定OpenCL设备的需要。

我们还需要理解控制OpenCL程序中如何使用内存对象的抽象机制。OpenCL内存模型定义了5种不同的内存区域:

宿主机内存:该内存只对宿主机可见。

全局内存:该内存区域允许读写所有工作组中的所有工作项。

常量内存:全局内存的该内存区域在执行一个内核期间保持不变。且为只读。

局部内存:该内存区域对工作组是局部的。

私有内存:为一个工作项私有内存区域。

这些内存区域以及它们与平台和执行模型的关系图为:

其中,工作项在处理单元上运行(PE1-PEM),有其自己的私有内存。工作组在一个计算单元上运行,与该组中的工作项共享一个局部内存区域。OpenCL设备内存利用宿主机来支持全局内存区域。OpenCL设备内存利用宿主机来支持全局内存。

编程模型

OpenCL编程模型定义了一个OpenCL应用如何映射到处理单元,内存区域和宿主机。OpenCL定义了两种不同的编程模型:任务并行数据并行

数据并行编程模型:适合采用数据并行编程模型的问题都与数据结构有关,这些数据结构的元素可以并发更新。就是将一个逻辑指令序列并发地应用到数据结构的元素上。并行算法的结构被设计为一个序列,即对问题领域中数据结构并发更新序列。

任务并行编程模型:OpenCL将任务定义为单个工作项执行的内核,而不用考虑OpenCL应用中其他内核使用的NDRange。,如果程序员所希望的并发性来自任务,就会使用该模型。

HelloWorld:一个OpenCL例子

我们这里看一个简单的HelloWorld实例,该示例让储存在两个数组中的值相加,并把结果保存在另外一个数组中。

我们代码执行由下述概念组成:

  • 选择OpenCL平台并创建上下文。
  • 选择设备并创建一个命令队列。
  • 创建和构建一个程序对象。
  • 创建一个内核对象,并为内核参数创建内存对象。
  • 执行内核并读取结果。
  • 检查OpenCL中的错误。

选择OpenCL平台并创建一个上下文

创建OpenCL程序的第一步是选择一个平台。OpenCL使用可安装客户驱动程序模型,一个系统上可以有多个OpenCL实现并存。例如,在配置有一个NVIDIA GPU和一个AMD CPU的系统上,可以为两者各安装一个OpenCL实现。

我们的helloWorld示例展示了选择OpenCL平台最简单的方法:选择第一个可用的平台。以下代码展示了HelloWorld示例CreateContext函数:

cl_context CreatContext(){
    cl_int errNum;
    cl_uint numPlatforms;
    cl_platform_id firstPlatformID;
    cl_context context=NULL;
    
    /*首先,选择要运行的OpenCL平台。 对于这个例子,我们
      只需选择第一个可用平台。通常,我们也可以
      查询所有可用平台,然后选择最合适的平台。
    */
    errNum=clGetPlatformIDs(1, &firstPlatformID, &numPlatforms);
    if (errNum != CL_SUCCESS || numPlatforms <= 0)
    {
        std::cerr << "Failed to find any OpenCL platforms." << std::endl;
        return NULL;
    }
    
    /*接下来,在平台上创建一个OpenCL上下文。 尝试
      创建基于GPU的上下文,如果失败,请尝试创建
      基于CPU的上下文。
    */
    
    //创建上下文需要的资源 属性 属性值 0结束
    cl_context_properties contextProperties[]={
        CL_CONTEXT_PLATFORM,
        (cl_context_properties)firstPlatformID,
        0
    };
    
    context=clCreateContextFromType(contextProperties, CL_DEVICE_TYPE_GPU, NULL, NULL, &errNum);
    if (errNum != CL_SUCCESS)
    {
        std::cout << "Could not create GPU context, trying CPU..." << std::endl;
        context = clCreateContextFromType(contextProperties, CL_DEVICE_TYPE_CPU,
                                          NULL, NULL, &errNum);
        if (errNum != CL_SUCCESS)
        {
            std::cerr << "Failed to create an OpenCL GPU or CPU context." << std::endl;
            return NULL;
        }
    }

    return context;
}

我们可以看到该函数返回一个cl_context类型的对象,cl_context类型是OpenCL的一个内建类型,用了保存上下文信息。同样,我们的cl_intcl_uint都是内建类型,我们用errnum保存错误信息。numPlatforms保存获取的平台数量。firstPlatformId则用来保存平台索引,其类型cl_platform_id正是内建的平台索引类型。

我们首先用函数clGetPlatformIDs(1, &firstPlatformId, &numPlatforms)获取第一个可用的平台ID。该函数第一个参数为申请平台的个数,第二个参数为实际申请到平台的ID,第三个参数为实际申请到平台的个数。

我们再看函数clCreateContextFromType,它为我们创建上下文,其形式为:

cl_context clCreateContextFromType ( const cl_context_properties  *properties, 
  cl_device_type  device_type, 
  void  (CL_CALLBACK *pfn_notify) (const char *errinfo, 
  const void  *private_info, 
  size_t  cb, 
  void  *user_data), 
  void  *user_data, 
  cl_int  *errcode_ret)

其中包含了5个参数。

第一个参数为一个cl_context_properties的列表,列表的形式为:第一个为属性,之后跟上属性值,直到遇到0停止。因此我们可以看到:

cl_context_properties contextProperties[] =
    {
        CL_CONTEXT_PLATFORM,
        (cl_context_properties)firstPlatformId,
        0
    };

该列表定义了一个属性为:CL_CONTEXT_PLATFORM。然后跟上的是具体的平台ID,直到0结束。

第二个参数为cl_device_type的设备种类,我们此处的代码传入的为CL_DEVICE_TYPE_GPU,即尝试为GPU创建一个上下文,如果传入CL_DEVICE_TYPE_CPU则是为CPU创建上下文。

第三个参数为一个回调函数,该回调函数是用来获取错误信息的,我们这里传NULL,不去使用该功能。

第四个参数是在调用pfn_notify(回调函数中)时作为user_data参数传递。 我们此处也是传NULL。

第五个参数则是返回报错信息。

我们代码是为GPU创建一个上下文,如果失败了则尝试为CPU创建一个上下文。最后将我们创建好的上下文返回。

选择设备并创建一个命令队列

选择好平台,创建完上下文之后,下一步需要选择一个设备并创建一个命令队列。设备在计算机硬件的底层,如GPU或者CPU。要与设备通信,应用程序必须为他创建一个命令队列。将在设备上完成的操作要在命令队列中排队。我们的创建命令队列函数为

cl_command_queue CreateCommandQueue(cl_context context, cl_device_id *device)
{
    cl_int errNum;
    cl_device_id *devices;
    cl_command_queue commandQueue = NULL;
    size_t deviceBufferSize = -1;

    // 获取设备缓存的尺寸
    errNum = clGetContextInfo(context, CL_CONTEXT_DEVICES, 0, NULL, &deviceBufferSize);
    if (errNum != CL_SUCCESS)
    {
        std::cerr << "Failed call to clGetContextInfo(...,GL_CONTEXT_DEVICES,...)";
        return NULL;
    }

    if (deviceBufferSize <= 0)
    {
        std::cerr << "No devices available.";
        return NULL;
    }

    // 给设备缓存分配空间
    devices = new cl_device_id[deviceBufferSize / sizeof(cl_device_id)];
    errNum = clGetContextInfo(context, CL_CONTEXT_DEVICES, deviceBufferSize, devices, NULL);
    if (errNum != CL_SUCCESS)
    {
        delete [] devices;
        std::cerr << "Failed to get device IDs";
        return NULL;
    }

    // 选择第一个设备和上下文,创建出一个命令队列
    commandQueue = clCreateCommandQueue(context, devices[0], 0, NULL);
    if (commandQueue == NULL)
    {
        delete [] devices;
        std::cerr << "Failed to create commandQueue for device 0";
        return NULL;
    }

    *device = devices[0];
    delete [] devices;
    return commandQueue;
}

可以看到函数返回cl_command_queue类型的一个对象。在OpenCL中,它表示内建的命令队列类型。我们同样用cl_int的errNum保存错误信息,cl_device_id的指针保存设备ID。并创建cl_command_queue的commandQueue,令其为NULL。还定义了设备缓存尺寸deviceBufferSize。我们用clGetContextInfo获取上下文信息。其中clGetContextInfo函数的形式为:

cl_int clGetContextInfo ( cl_context  context , 
  cl_context_info  param_name , 
  size_t  param_value_size , 
  void  *param_value , 
  size_t  *param_value_size_ret )

第一个参数为上下文,第二个参数为需要查询的属性名称,第三个参数为返回查询值的尺寸,第四个参数为一个指向获得查询值的指针,第五个参数为实际返回查询值的尺寸。如果param_value_size和param_value为0和NULL,可查询返回值的大小。

我们的语句errNum = clGetContextInfo(context, CL_CONTEXT_DEVICES, 0, NULL, &deviceBufferSize)表示我们想要获取上下文中的所有的设备信息,只用了deviceBufferSize去保存所有设备缓存需要的大小。

然后我们为设备ID根据deviceBufferSize分配空间,来计算出我们有几个设备。

我们的errNum = clGetContextInfo(context, CL_CONTEXT_DEVICES, deviceBufferSize, devices, NULL)语句再次去获取上下文信息,这次我们获取设备在内存的地址,让devices去指向这块地址。

然后我们用户commandQueue = clCreateCommandQueue(context, devices[0], 0, NULL)语句去用获取的第一个设备和上下文建立命令队列。

我们的clCreateCommandQueue函数形式如下:

cl_command_queue clCreateCommandQueue( cl_context context, 
  cl_device_id device, 
  cl_command_queue_properties properties, 
  cl_int *errcode_ret)

其中第一个参数contex则为我们的上下文。第二个参数device为我们上下文中存在的设备。第三个参数定义了队列的执行形式,我们这里设置0(忽略)。第四个参数获取错误信息。

最后,我们的函数将返回命令队列,以及获取的设备ID。获取的命令队列用于给程序中要执行的内核排队。

创建和构建一个程序对象

我们下一步是需要从.cl文件加载OpenCL C内核源代码,由它创建一个程序对象。该程序对象用内核源代码加载,然后进行编译,从而在与上下文的关联的设备上执行。值得注意的是,我们需要包含每一个设备的编译代码。我们的HelloWorld的加载内核文件并创建一个程序对象的代码为:

cl_program CreateProgram(cl_context context, cl_device_id device, const char* fileName)
{
    cl_int errNum;
    cl_program program;

    std::ifstream kernelFile(fileName, std::ios::in);
    if (!kernelFile.is_open())
    {
        std::cerr << "Failed to open file for reading: " << fileName << std::endl;
        return NULL;
    }

    std::ostringstream oss;
    oss << kernelFile.rdbuf();//oss输出kernelFile指向的流缓冲

    std::string srcStdStr = oss.str();
    const char *srcStr = srcStdStr.c_str();
    
    //在context上下文上创建程序对象(字符串个数为1)
    program = clCreateProgramWithSource(context, 1,
                                        (const char**)&srcStr,
                                        NULL, NULL);
    if (program == NULL)
    {
        std::cerr << "Failed to create CL program from source." << std::endl;
        return NULL;
    }

    //编译内核源码
    errNum = clBuildProgram(program, 0, NULL, NULL, NULL, NULL);
    if (errNum != CL_SUCCESS)
    {
        // 输出编译错误信息
        char buildLog[16384];
        clGetProgramBuildInfo(program, device, CL_PROGRAM_BUILD_LOG,
                              sizeof(buildLog), buildLog, NULL);

        std::cerr << "Error in kernel: " << std::endl;
        std::cerr << buildLog;
        clReleaseProgram(program);//释放程序对象空间
        return NULL;
    }

    return program;
}

我们的program为cl_program类型的对象,我们该函数首先读取.cl内核代码文件,将其转换为字符串(char的数组形式)。然后利用clCreateProgramWithSource函数创建程序对象。该函数形式如下:

cl_program clCreateProgramWithSource ( cl_context context, 
  cl_uint count, 
  const char **strings, 
  const size_t *lengths, 
  cl_int *errcode_ret)

其第一个参数为一个上下文,第二个参数为字符串的个数,第三个参数为指向字符串数组的指针,第四个参数为长度数组,保存每个字符串的长度,第五个参数为错误信息。最好返回一个cl_program类型的程序对象。

我们的代码program = clCreateProgramWithSource(context, 1,(const char**)&srcStr,NULL, NULL)。表明,我们在山下文context中,利用单个字符串srcStr创建一个程序对象。

构建完程序对象,我们便需要编译内核源码,函数clBuildProgram完成编译工作,该函数形式为:

cl_int clBuildProgram ( cl_program program, 
  cl_uint num_devices, 
  const cl_device_id *device_list, 
  const char *options, 
  void (CL_CALLBACK *pfn_notify)(cl_program program, void *user_data), 
  void *user_data)

第一个参数为一个程序对象,第二个参数为设备的个数,第三个参数为设备ID列表,第四个参数为字符串形式的编译选项,第五个参数为一个回调函数指针,第六个参数为提供回调函数需要的数据。返回报错信息。

我们的代码errNum = clBuildProgram(program, 0, NULL, NULL, NULL, NULL)为程序对象program编译源码,我们为num_devices传值0,device_list传NULL会使源码在所有的设备上进行编译。然后我们还给options和回调函数指针传值NULL,表示我们不对其进行操作。

当我们完成编译时,我们如果编译失败,则调用函数clGetProgramBuildInfo获取编译失败信息,该函数形式:

cl_int clGetProgramBuildInfo ( cl_program  program, 
  cl_device_id  device, 
  cl_program_build_info  param_name, 
  size_t  param_value_size, 
  void  *param_value, 
  size_t  *param_value_size_ret)

其使用方法与clGetContextInfo雷同,就不赘述了。

创建内核和内存对象

要执行OpenCL计算内核,需要在内存中分配内核函数的参数,以便在OpenCL设备上访问。我们的内核(即.cl文件)为以下代码:

__kernel void hello_kernel(__global const float *a,
						__global const float *b,
						__global float *result)
{
    int gid = get_global_id(0);

    result[gid] = a[gid] + b[gid];
}

该代码简单执行数组a和数组b的各个元素相加保存在result之中。我们用__kernel限定符,来指明改函数为核函数。这里要注意的是内核函数返回值必须是void类型,否则编译会出错。

我们现在看一下创建内核和内存对象的代码:

main(){
    ......//前面的过程

    cl_kernel kernel=0;
    cl_mem memObjects[3]={0,0,0};

    // 创建OpenCL内核
    kernel = clCreateKernel(program, "hello_kernel", NULL);
    if (kernel == NULL)
    {
        std::cerr << "Failed to create kernel" << std::endl;
        Cleanup(context, commandQueue, program, kernel, memObjects);
        return 1;
    }

    //用于计算的对象
    float result[ARRAY_SIZE];
    float a[ARRAY_SIZE];
    float b[ARRAY_SIZE];
    for (int i = 0; i < ARRAY_SIZE; i++)
    {
        a[i] = (float)i;
        b[i] = (float)(i * 2);
    }

    
    //创建内存对象
    if (!CreateMemObjects(context, memObjects, a, b))
    {
        Cleanup(context, commandQueue, program, kernel, memObjects);
        return 1;
    }
   
    ......//后面的过程
}

在完成前面的过程后,我们用函数clCreateKernel创建内核,该函数形式为:

cl_kernel clCreateKernel ( cl_program  program, 
  const char *kernel_name, 
  cl_int *errcode_ret)

第一个参数为程序对象。第二个参数为字符串类型的内核名称,该名称会去.cl文件中自动寻找到用__kernel限定符声明的程序中的函数名称,如我们.cl文件中的hello_kernel函数。第三个参数为错误信息。

我们的程序中获取内核代码为kernel = clCreateKernel(program, "hello_kernel", NULL)。如果失败则用cleanup函数清除数据。我们的cleanup函数为:

void Cleanup(cl_context context, cl_command_queue commandQueue,
             cl_program program, cl_kernel kernel, cl_mem memObjects[3])
{
    for (int i = 0; i < 3; i++)
    {
        if (memObjects[i] != 0)
            clReleaseMemObject(memObjects[i]);
    }
    if (commandQueue != 0)
        clReleaseCommandQueue(commandQueue);

    if (kernel != 0)
        clReleaseKernel(kernel);

    if (program != 0)
        clReleaseProgram(program);

    if (context != 0)
        clReleaseContext(context);

}

之后,我们创建内存对象,该函数为CreateMemObjects,我们做如下定义:

bool CreateMemObjects(cl_context context, cl_mem memObjects[3],
                      float *a, float *b)
{
    memObjects[0] = clCreateBuffer(context, CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR,
                                   sizeof(float) * ARRAY_SIZE, a, NULL);
    memObjects[1] = clCreateBuffer(context, CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR,
                                   sizeof(float) * ARRAY_SIZE, b, NULL);
    memObjects[2] = clCreateBuffer(context, CL_MEM_READ_WRITE,
                                   sizeof(float) * ARRAY_SIZE, NULL, NULL);

    if (memObjects[0] == NULL || memObjects[1] == NULL || memObjects[2] == NULL)
    {
        std::cerr << "Error creating memory objects." << std::endl;
        return false;
    }

    return true;
}

我们再该函数中,为我们传进来的memObjects指针(数组)内的内存对象创建缓冲。由于该数组长度为3,我们就为这3个内存对象创建缓存。我们调用的clCreateBuffer函数形式如下:

cl_mem clCreateBuffer ( cl_context context, 
  cl_mem_flags flags, 
  size_t size, 
  void *host_ptr, 
  cl_int *errcode_ret)

第一个参数为上下文。第二个参数为一个位字段,用于指定分配和使用信息,例如应该用于分配缓冲区对象的内存区域以及如何使用它。第三个参数是分配的缓冲的大小(字节)。第四个参数是指向可能已由应用程序分配的缓冲区数据的指针(如我们的a,b)。第五个参数为错误信息。

我们的代码:

    memObjects[0] = clCreateBuffer(context, CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR,
                                   sizeof(float) * ARRAY_SIZE, a, NULL);
    memObjects[1] = clCreateBuffer(context, CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR,
                                   sizeof(float) * ARRAY_SIZE, b, NULL);
    memObjects[2] = clCreateBuffer(context, CL_MEM_READ_WRITE,
                                   sizeof(float) * ARRAY_SIZE, NULL, NULL);

表明了我们对于输入数组a和b,我们缓冲区使用CL_MEM_READ_ONLY(只读),CL_MEM_COPY_HOST_PTR(从宿主机复制到设备内存),并且我们的内存对象的空间用a和b指针指向的空间数据填充。而我们的result数组预先分配了内存,但是用null填充,并且设置CL_MEM_READ_WRITE(可读写)。

执行内核

我们创建了内核和内存对象,接下来需要将要执行的内核排队,排队完成便可以执行内核读取结果,我们执行这些过程的代码如下:

......//之前的代码

//设置内核参数
    errNum=clSetKernelArg(kernel, 0, sizeof(cl_mem), &memObjects[0]);
    errNum|=clSetKernelArg(kernel, 1, sizeof(cl_mem), &memObjects[1]);
    errNum|=clSetKernelArg(kernel, 2, sizeof(cl_mem), &memObjects[2]);
    if (errNum != CL_SUCCESS)
    {
        std::cerr << "Error setting kernel arguments." << std::endl;
        Cleanup(context, commandQueue, program, kernel, memObjects);
        return 1;
    }

    size_t globalWorkSize[1] = { ARRAY_SIZE };
    size_t localWorkSize[1] = { 1 };
    
    //为将在设备上执行的内核排队
    errNum=clEnqueueNDRangeKernel(commandQueue, kernel, 1, NULL, globalWorkSize, localWorkSize, 0, NULL, NULL);
    if (errNum != CL_SUCCESS)
    {
        std::cerr << "Error queuing kernel for execution." << std::endl;
        Cleanup(context, commandQueue, program, kernel, memObjects);
        return 1;
    }
    
    //执行内核并读出数据
    errNum = clEnqueueReadBuffer(commandQueue, memObjects[2], CL_TRUE,
                                 0, ARRAY_SIZE * sizeof(float), result,
                                 0, NULL, NULL);
    if (errNum != CL_SUCCESS)
    {
        std::cerr << "Error reading result buffer." << std::endl;
        Cleanup(context, commandQueue, program, kernel, memObjects);
        return 1;
    }

    // Output the result buffer
    for (int i = 0; i < ARRAY_SIZE; i++)
    {
        std::cout << result[i] << " ";
    }
    std::cout << std::endl;
    std::cout << "Executed program succesfully." << std::endl;
    Cleanup(context, commandQueue, program, kernel, memObjects);

我们首先执行了三次clSetKernelArg函数,该函数形式为:

cl_int clSetKernelArg ( cl_kernel kernel, 
  cl_uint arg_index, 
  size_t arg_size, 
  const void *arg_value)

第一个参数为接收一个内核。第二个参数为内核形参的索引,其中a,b和result的索引分别为0,1,2。第三个参数指定参数值的大小, 如果参数是内存对象,则大小是缓冲区或图像对象类型的大小。第四个参数为指向数据的指针,该数据应用作arg_index指定的参数的参数值。返回错误信息。

我们代码:

    errNum=clSetKernelArg(kernel, 0, sizeof(cl_mem), &memObjects[0]);
    errNum|=clSetKernelArg(kernel, 1, sizeof(cl_mem), &memObjects[1]);
    errNum|=clSetKernelArg(kernel, 2, sizeof(cl_mem), &memObjects[2]);

利用位运算符或获取所有设置参数时返回的错误,分别用memObjects数组的三个内存对象作为参数传入内核函数hello_kernel作为参数。

然后我们的代码errNum=clEnqueueNDRangeKernel(commandQueue, kernel, 1, nullptr, globalWorkSize, localWorkSize, 0, nullptr, nullptr)为将要执行的内核排队。函数clEnqueueNDRangeKernel的形式为:

cl_int clEnqueueNDRangeKernel ( cl_command_queue command_queue, 
  cl_kernel kernel, 
  cl_uint work_dim, 
  const size_t *global_work_offset, 
  const size_t *global_work_size, 
  const size_t *local_work_size, 
  cl_uint num_events_in_wait_list, 
  const cl_event *event_wait_list, 
  cl_event *event)

第一个参数为我们的命令队列。第二个参数为内核。第三个参数是工作项的维数。第四个参数是工作项的全局偏移量。第五个参数是全局工作项的总数。第六个参数为工作组的大小。第七,第八和第九个参数都是事件参数,用于设置事件。

我们的代码errNum=clEnqueueNDRangeKernel(commandQueue, kernel, 1, NULL, globalWorkSize, localWorkSize, 0, NULL, NULL)表示了我们工作项维数为1,无全局偏移,全局工作项总数为数据的大小,工作组大小为1,事件参数为NULL。通过该语句我们完成了利用命令队列将内核排队。排队完成并不会马上执行。

我们利用函数clEnqueueReadBuffer来从内核读回结果,该函数有形式:

cl_int clEnqueueReadBuffer ( cl_command_queue command_queue, 
  cl_mem buffer, 
  cl_bool blocking_read, 
  size_t offset, 
  size_t size, 
  void *ptr, 
  cl_uint num_events_in_wait_list, 
  const cl_event *event_wait_list, 
  cl_event *event)

第一个参数为将在其中排队读取命令的命令队列。

第二个参数为一个有效的缓冲区对象(内存对象)。

第三个参数为是否阻塞,如果blocking_read为CL_TRUE,即读取命令正在阻塞,则在已读取缓冲区数据并将其复制到ptr指向的内存中之前,clEnqueueReadBuffer不会返回。如果blocking_read为CL_FALSE,即读取命令为非阻塞,则clEnqueueReadBuffer将非阻塞读取命令排队并返回。在读取命令完成之前,不能使用ptr指向的缓冲区的内容。

第四个参数为要读取的缓冲区对象中的字节偏移量。

第五个参数为读取的数据大小(以字节为单位)。

第六个参数为指向主机存储器中要读取数据的缓冲区的指针。

第七,第八和第九个参数都是事件参数,用于设置事件。

我们发现OpenCL在执行的过程,把我们需要输入的数据复制到我们创建的内存对象内,然后利用内存对象返回OpenCL计算完的数据到我们创建的数组中。

到这里我们的全部代码为:

#include <iostream>
#include <OpenCL/OpenCL.h>
#include <fstream>
#include <sstream>
#define ARRAY_SIZE 10000

 /*
  1 选择平台创建OpenCL上下文
  2 选择设备创建命令队列
  3 加载内核文件(hello_world.cl)并构建到程序对象中
  4 为内核函数创建hello_kernel()创建一个内核对象
  5 为内核参数创建内存对象(result,a,b)
  6 将待执行的内核排队
  7 将内核结果读回结果缓冲区
  */
cl_context CreatContext();
cl_command_queue CreateCommandQueue(cl_context context, cl_device_id *device);
cl_program CreateProgram(cl_context context, cl_device_id device, const char* fileName);
bool CreateMemObjects(cl_context context, cl_mem memObjects[3], float *a, float *b);
void Cleanup(cl_context context, cl_command_queue commandQueue,cl_program program, cl_kernel kernel, cl_mem memObjects[3]);

int main(int argc, const char * argv[]) {
    cl_context context=0;
    cl_command_queue commandQueue=0;
    cl_program program=0;
    cl_device_id device=0;
    cl_kernel kernel=0;
    cl_mem memObjects[3]={0,0,0};
    cl_int errNum;
    
    //创建上下文
    context=CreatContext();
    if (context == NULL)
    {
        std::cerr << "Failed to create OpenCL context." << std::endl;
        return 1;
    }

    //创建命令队列
    commandQueue=CreateCommandQueue(context, &device);
    if (commandQueue == NULL)
    {
        Cleanup(context, commandQueue, program, kernel, memObjects);
        return 1;
    }
    
    //创建程序对象
    program=CreateProgram(context, device, "hello_world.cl");
    if (program == NULL)
    {
        Cleanup(context, commandQueue, program, kernel, memObjects);
        return 1;
    }
    
    //创建OpenCL核
    kernel=clCreateKernel(program, "hello_kernel", nullptr);
    if (kernel == NULL)
    {
        std::cerr << "Failed to create kernel" << std::endl;
        Cleanup(context, commandQueue, program, kernel, memObjects);
        return 1;
    }
    
    float result[ARRAY_SIZE];
    float a[ARRAY_SIZE];
    float b[ARRAY_SIZE];
    for (int i = 0; i < ARRAY_SIZE; i++)
    {
        a[i] = (float)i;
        b[i] = (float)(2*i);
    }
    
    //创建内存对象
    if (!CreateMemObjects(context, memObjects, a, b))
    {
        Cleanup(context, commandQueue, program, kernel, memObjects);
        return 1;
    }
    
    //设置内核参数
    errNum=clSetKernelArg(kernel, 0, sizeof(cl_mem), &memObjects[0]);
    errNum|=clSetKernelArg(kernel, 1, sizeof(cl_mem), &memObjects[1]);
    errNum|=clSetKernelArg(kernel, 2, sizeof(cl_mem), &memObjects[2]);
    if (errNum != CL_SUCCESS)
    {
        std::cerr << "Error setting kernel arguments." << std::endl;
        Cleanup(context, commandQueue, program, kernel, memObjects);
        return 1;
    }

    size_t globalWorkSize[1] = { ARRAY_SIZE };
    size_t localWorkSize[1] = { 1 };
    
    //为将在设备上执行的内核排队
    errNum=clEnqueueNDRangeKernel(commandQueue, kernel, 1, nullptr, globalWorkSize, localWorkSize, 0, nullptr, nullptr);
    if (errNum != CL_SUCCESS)
    {
        std::cerr << "Error queuing kernel for execution." << std::endl;
        Cleanup(context, commandQueue, program, kernel, memObjects);
        return 1;
    }
    
    //执行内核并读出数据
    errNum = clEnqueueReadBuffer(commandQueue, memObjects[2], CL_TRUE,
                                 0, ARRAY_SIZE * sizeof(float), result,
                                 0, NULL, NULL);
    if (errNum != CL_SUCCESS)
    {
        std::cerr << "Error reading result buffer." << std::endl;
        Cleanup(context, commandQueue, program, kernel, memObjects);
        return 1;
    }

    // Output the result buffer
    for (int i = 0; i < ARRAY_SIZE; i++)
    {
        std::cout << result[i] << " ";
    }
    std::cout << std::endl;
    std::cout << "Executed program succesfully." << std::endl;
    Cleanup(context, commandQueue, program, kernel, memObjects);
    
    return 0;
}

cl_context CreatContext(){
    cl_int errNum;
    cl_uint numPlatforms;
    cl_platform_id firstPlatformID;
    cl_context context=nullptr;
    
    /*首先,选择要运行的OpenCL平台。 对于这个例子,我们
      只需选择第一个可用平台。通常,我们也可以
      查询所有可用平台,然后选择最合适的平台。
    */
    errNum=clGetPlatformIDs(1, &firstPlatformID, &numPlatforms);
    if (errNum != CL_SUCCESS || numPlatforms <= 0)
    {
        std::cerr << "Failed to find any OpenCL platforms." << std::endl;
        return NULL;
    }
    
    /*接下来,在平台上创建一个OpenCL上下文。 尝试
      创建基于GPU的上下文,如果失败,请尝试创建
      基于CPU的上下文。
    */
    
    //创建上下文需要的资源 属性 属性值 0结束
    cl_context_properties contextProperties[]={
        CL_CONTEXT_PLATFORM,
        (cl_context_properties)firstPlatformID,
        0
    };
    
    context=clCreateContextFromType(contextProperties, CL_DEVICE_TYPE_GPU, nullptr, nullptr, &errNum);
    if (errNum != CL_SUCCESS)
    {
        std::cout << "Could not create GPU context, trying CPU..." << std::endl;
        context = clCreateContextFromType(contextProperties, CL_DEVICE_TYPE_CPU,
                                          NULL, NULL, &errNum);
        if (errNum != CL_SUCCESS)
        {
            std::cerr << "Failed to create an OpenCL GPU or CPU context." << std::endl;
            return NULL;
        }
    }

    return context;
}

cl_command_queue CreateCommandQueue(cl_context context, cl_device_id *device)
{
    cl_int errNum;
    cl_device_id *devices;
    cl_command_queue commandQueue = NULL;
    size_t deviceBufferSize = -1;

    // 获取设备缓存的尺寸
    errNum = clGetContextInfo(context, CL_CONTEXT_DEVICES, 0, NULL, &deviceBufferSize);
    if (errNum != CL_SUCCESS)
    {
        std::cerr << "Failed call to clGetContextInfo(...,GL_CONTEXT_DEVICES,...)";
        return NULL;
    }

    if (deviceBufferSize <= 0)
    {
        std::cerr << "No devices available.";
        return NULL;
    }

    // 给设备缓存分配空间
    devices = new cl_device_id[deviceBufferSize / sizeof(cl_device_id)];
    errNum = clGetContextInfo(context, CL_CONTEXT_DEVICES, deviceBufferSize, devices, NULL);
    if (errNum != CL_SUCCESS)
    {
        delete [] devices;
        std::cerr << "Failed to get device IDs";
        return NULL;
    }

    // 选择第一个设备和上下文,创建出一个命令队列
    commandQueue = clCreateCommandQueue(context, devices[0], 0, NULL);
    if (commandQueue == NULL)
    {
        delete [] devices;
        std::cerr << "Failed to create commandQueue for device 0";
        return NULL;
    }

    *device = devices[0];
    delete [] devices;
    return commandQueue;
}

cl_program CreateProgram(cl_context context, cl_device_id device, const char* fileName)
{
    cl_int errNum;
    cl_program program;

    std::ifstream kernelFile(fileName, std::ios::in);
    if (!kernelFile.is_open())
    {
        std::cerr << "Failed to open file for reading: " << fileName << std::endl;
        return NULL;
    }

    std::ostringstream oss;
    oss << kernelFile.rdbuf();//oss输出kernelFile指向的流缓冲

    std::string srcStdStr = oss.str();
    const char *srcStr = srcStdStr.c_str();
    
    //在context上下文上创建程序对象(字符串个数为1)
    program = clCreateProgramWithSource(context, 1,
                                        (const char**)&srcStr,
                                        NULL, NULL);
    if (program == NULL)
    {
        std::cerr << "Failed to create CL program from source." << std::endl;
        return NULL;
    }

    //编译内核源码
    errNum = clBuildProgram(program, 0, NULL, NULL, NULL, NULL);
    if (errNum != CL_SUCCESS)
    {
        // 输出编译错误信息
        char buildLog[16384];
        clGetProgramBuildInfo(program, device, CL_PROGRAM_BUILD_LOG,
                              sizeof(buildLog), buildLog, NULL);

        std::cerr << "Error in kernel: " << std::endl;
        std::cerr << buildLog;
        clReleaseProgram(program);//释放程序对象空间
        return NULL;
    }

    return program;
}

//  创建内存对象
bool CreateMemObjects(cl_context context, cl_mem memObjects[3],
                      float *a, float *b)
{
    memObjects[0] = clCreateBuffer(context, CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR,
                                   sizeof(float) * ARRAY_SIZE, a, NULL);
    memObjects[1] = clCreateBuffer(context, CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR,
                                   sizeof(float) * ARRAY_SIZE, b, NULL);
    memObjects[2] = clCreateBuffer(context, CL_MEM_READ_WRITE,
                                   sizeof(float) * ARRAY_SIZE, NULL, NULL);

    if (memObjects[0] == NULL || memObjects[1] == NULL || memObjects[2] == NULL)
    {
        std::cerr << "Error creating memory objects." << std::endl;
        return false;
    }

    return true;
}

//释放OpenCL资源
void Cleanup(cl_context context, cl_command_queue commandQueue,
             cl_program program, cl_kernel kernel, cl_mem memObjects[3])
{
    for (int i = 0; i < 3; i++)
    {
        if (memObjects[i] != 0)
            clReleaseMemObject(memObjects[i]);
    }
    if (commandQueue != 0)
        clReleaseCommandQueue(commandQueue);

    if (kernel != 0)
        clReleaseKernel(kernel);

    if (program != 0)
        clReleaseProgram(program);

    if (context != 0)
        clReleaseContext(context);

}

我们的.cl文件代码为:


__kernel void hello_kernel(__global const float *a,
                           __global const float *b,
                           __global float *result){
    int gid=get_global_id(0);
    result[gid]=a[gid]+b[gid];
}

其中get_global_id(0)指从第一个维度(我们输入为dim-1,即0)按顺序获得工作项id。

我们程序运行就能执行a[10000]和b[10000]相加,并将结果保存至result[10000]中了。

检查OpenCL中的错误

OpenCL中的函数在返回错误信息时,有的是返回一个cl_int的错误码,有的是用一个指针返回错误信息。

其实这是OpenCL的两种报错规则:

  • 返回cl_xxx对象的OpenCL函数最后一个参数是一个指针,指向返回的错误码。
  • 不返回对象的OpenCL函数会返回一个错误码。

OpenCL的错误码如下表:

发布了22 篇原创文章 · 获赞 22 · 访问量 2580

猜你喜欢

转载自blog.csdn.net/qq_39300235/article/details/103699603