OpenCL编程指南
1. 简介
OpenCL(Open Computing Language,开放计算语言)是由 Khronos Group 维护的开放、跨平台并行计算框架。 它为开发者提供统一的编程接口,使应用能够在不同硬件平台上运行(如 CPU、GPU、DSP 及其他处理器)。 通过这种方式,开发者可以同时提升 代码可移植性 和 运行性能。
OpenCL 主要包含以下两个部分:
- 内核编程语言:基于 C99 的语言,用于编写在 OpenCL 设备上运行的函数(Kernels)
- 平台API:用于定义和控制计算平台的接口
如图所示,OpenCL框架包含两个关键API层:
-
平台层(Platform Layer)API 运行在主机(Host)CPU上 主要功能:
- 查询和使能系统中可用的并行处理器或计算设备
- 应用程序就能在不同系统中移植和运行,支持多种硬件组合
-
运行时(Runtime)API 核心功能:
- 为选定设备编译内核程序
- 管理内核在处理器上的并行执行
- 收集和处理计算结果
2. OpenCL 程序的 执行
-
内核 (Kernel):设备端执行的基本单元(类似 C 函数),支持两种并行模式:
- 数据并行
- 任务并行
-
程序对象:包含多个内核和函数的集合(类似具有运行时链接的动态库)
-
命令队列:主机向设备提交命令的通道,支持特性:
- 顺序/乱序执行模式
- 支持多队列
下图展示了执行OpenCL Kernel的流程:
执行 OpenCL 程序的完整步骤如下:
-
查询可用的 OpenCL 平台和设备
-
为一个或多个平台中的 OpenCL 设备创建上下文
-
为上下文中的 OpenCL 设备创建并构建程序
-
从程序中选择要执行的内核
-
为内核创建内存对象以进行操作
-
创建命令队列以在 OpenCL 设备上执行命令
-
获取执行结果并清理环境
更详细的介绍可以参考:
主要 API
OpenCL 平台
选择 OpenCL 平台是 OpenCL 的第一步,clGetPlatformIDs()
这个 API 就是查找制定系统上的可用 OpenCL 平台的集合。
cl_int clGetPlatformIDs(cl_uint num_entries, cl_platform_id *platforms, cl_uint *num_platforms)
-
num_entries
:表示 OpenCL 平台的索引值。设置为 0,且platforms
为 NULL 时用于查询可用的平台数 -
platforms
:表示平台的指针 -
num_platforms
:表示 OpenCL 平台的数量,一般作为返回值
这个 API 一般会调用两次,用来查询和获取到对应的平台信息,使用方式如下:
cl_int err = 0; // 错误代码
cl_uint num_platform = 0; // 平台数量
cl_platform_id *platform = NULL; // 平台 ID 指针
err = clGetPlatformIDs(0, NULL, &num_platform); // 获取平台数量,第一个参数为要获取的平台数量,第二个参数为平台 ID 数组,第三个参数为返回的平台数量
if (err!= CL_SUCCESS) { // 检查错误
fprintf(stderr, "Failed to create context: %d\n", err); // 输出错误信息
exit(-1); // 退出程序
}
platform = (cl_platform_id*)malloc(sizeof(cl_platform_id) * num_platform); // 分配内存以存储平台 ID
err = clGetPlatformIds(num_platform, platform, NULL); // 获取平台 ID
OpenCL 设备
当平台确定好之后,下一步就是查询平台上可用的设备:
/**
* 获取设备 ID 的函数
* @return cl_int 错误代码
*/
cl_int clGetDeviceIDs(
cl_platform_id platform, //平台 ID
cl_device_type device_type, //设备类型
cl_uint num_entries, //要获取的设备 ID 数量
cl_device_id *devices, //存储设备 ID 的数组
cl_uint *num_devices //实际获取到的设备 ID 数量
);
// 使用:
cl_int err = 0; // 用于存储错误代码
cl_uint num_devices = 0; // 用于存储设备数量
cl_device_id *devices = NULL; // 用于存储设备 ID 的指针
err = clGetDeviceIDs(platform, CL_DEVICE_TYPE_GPU, 0, NULL, &num_devices); // 获取 GPU 设备数量,platform 是平台,CL_DEVICE_TYPE_GPU 表示获取 GPU 设备,0 表示不指定特定设备,NULL 表示不返回设备 ID 列表,&num_devices 用于存储设备数量
if (err!= CL_SUCCESS) // 检查是否有错误
exit(-1); // 如果有错误,退出程序
devices = (cl_device_id*)malloc(sizeof(cl_device_id) * num_devices); // 为设备 ID 分配内存
err = clGetDeviceIDs(platform, CL_DEVICE_TYPE_GPU, num_devices, devices, NULL); // 获取设备 ID 列表
cl_device_type
参数描述如下:
-
CL_DEVICE_TYPE_CPU
:将 CPU 作为 OpenCL 设备 -
CL_DEVICE_TYPE_GPU
:GPU 设备 -
CL_DEVICE_TYPE_ACCELERATOR
:加速卡设备, 如 FPGA 设备(属于加速卡类型的 OpenCL 设备) -
CL_DEVICE_TYPE_DEFAULT
:与平台关联的默认 OpenCL 设备 -
CL_DEVICE_TYPE_ALL
:平台支持的所有 OpenCL 设备
OpenCL 上下文
OpenCL 中上下文为了内核的正确执行,进行协调和内存管理。上下文对象可以通过 clCreateContext()
进行创建。
// 创建 OpenCL 上下文
cl_context clCreateContext(
const cl_context_properties *properties, // 上下文属性列表
cl_uint num_devices, // 设备数量
const cl_device_id *devices, // 设备 ID 数组
void (CL_CALL_BACK *pfn_notify)(const char *errinfo, const void *private_info, size_t cb, void *user_data), // 与user_data共同做一个错误通知回调函数,报告上下文生命周期中出现的错误信息
void *user_data, // 用户提供的数据,将传递给错误通知回调函数
cl_int *errcode_ret // 用于返回错误代码的指针
);
OpenCL 提供了另一个 API 也能用来创建上下文:通过 clCreateContextFromType()
可以使用所有的设备类型(CPU、GPU 和 ALL)创建上下文。
OpenCL 命令队列
对上下文的 程序对象、内存对象、内核对象 进行操作时需要借助命令队列。 命令是主机(Host)发送给设备(Devices)的消息,通知设备执行操作。每个命令队列只能管理一个设备。
OpenCL 的 clCreateCommandQueueWithProperties()
就是用来创建命令队列,且将命令队列与一个 device 进行关联,其用法如下:
// 创建具有特定属性的命令队列
cl_command_queue clCreateCommandQueueWithProperties(
cl_context context, // 上下文对象,用于关联命令队列和设备
cl_device_id device, // 要关联的设备
cl_command_queue_properties properties, // 命令队列的属性,乱序执行或性能分析使能,默认为顺序执行
cl_int *errcode_ret // 用于返回错误代码的指针
);
OpenCL 程序对象与内核对象
程序对象和内核对象是 OpenCL 最重要的部分。程序对象就是内核的一个容器,一个程序对象可以包含多个内核对象,内核对象由程序对象创建和管理。
一个 OpenCL 程序对象通常包括:
- 一个或多个用 OpenCL C 编写的内核函数;
- 所调用的辅助函数;
- 常量数据。
例如,在一个代数计算场景中,同一个程序对象可以同时包含以下三个内核:
- 向量相加的内核
- 矩阵相乘的内核
- 矩阵转置的内核
使用源码创建内核的步骤如下:
-
准备源码: 将 OpenCL C 源码存放在一个字符数组中。若源码以文件形式存于硬盘,需要先读取文件内容并加载到内存中的字符数组中。
-
创建程序对象: 调用
clCreateProgramWithSource()
,通过源码创建一个cl_program
类型的程序对象。 -
编译程序对象: 所创建的程序对象需要进行编译,编译后的内核方能在一个或多个 OpenCL 设备上运行。调用
clBuildProgram()
完成对内核的编译,若编译存在问题,该 API 会输出错误信息。 -
创建内核对象: 最后,创建
cl_kernel
类型的内核对象。调用clCreateKernel()
,并指定对应的程序对象和内核函数名,以创建内核对象。
内核对象本质上是一个函数。它具有参数和返回值,需要通过内存对象进行传 入和传出。该函数可以在 OpenCL 设备上运行。
向量相加的内核源码示例:
// Perform an element-wise addition of A and B and store in C.
// N work-items will be created to execute this kernel.
__kernel
void vecadd(__global int *C, __global int *A, __global int *B){
int tid = get_global_id(0); // OpenCL intrinsic函数
c[tid] = A[tid] + B[tid];
}
创建一个程序对象:
cl_program clCreateProgramWithSource(
cl_context context, // 上下文对象
cl_uint count, // 源代码字符串的数量
const char **strings, // 源代码字符串数组
const size_t *lengths, // 每个源代码字符串的长度数组
cl_int *errcode_ret // 错误代码返回值
)
编译程序对象:
@return 编译的程序对象 */
cl_int clBuildProgram(
cl_program program, //要创建内核的程序对象
cl_uint num_devices, //设备数量
const cl_device_id *device_list, //设备列表
const char*options, //构建选项
void(*pfn_notify)(cl_program, void*user_data), //回调函数
void*user_data //用户数据
)
创建一个内核对象:
@return 创建的内核对象 */
cl_kernel
clCreateKernel(
cl_program program, //要创建内核的程序对象
const char *kernel_name, //内核的名称,即为内核函数名称
cl_int *errcode_ret //用于返回错误代码的指针
)
OpenCL 内存对象
OpenCL 内核通常需要对输入和输出数据进行分类(例如,数组或多维矩阵)。 程序执行前,需要保证输入数据可在设备端访问。为了将数据转移到设备端:
- 在设备端开辟足够的空间;
- 将空间封装成内存对象,以便内核访问。
OpenCL 定义了三种内存类型:数组、图像 和 管道。
Buffer 创建
Buffer 类型(数组)中的数据在内存上是连续的,这种类型可以在设备端以指针的方式使用。clCreateBuffer()
可以为这种类型的数据分配内存,并返回一个内存对象。
cl_mem clCreateBuffer(
cl_context context, // 上下文对象,用于关联设备和命令队列
cl_mem_flags flags, // 内存对象的标志,例如 CL_MEM_READ_WRITE 表示可读可写
size_t size, // 内存对象的大小
void* host_ptr, // 主机指针,如果为 NULL,则在设备上分配内存
cl_int *errcode_ret) // 错误代码返回指针,如果为 NULL,则不返回错误代码
内核参数设置
与 C 函数不同,OpenCL 内核的参数不能直接写在参数列表中。 执行内核时,参数需要通过入队函数进行传递。
注意: 内核语法基于 C,且参数具有持续性。这意味着如果只修改参数内容(而不是重新绑定新的内存对象),就无需再次调用设置函数。
在 OpenCL 中,可使用 clSetKernelArg()
来设置内核的参数。
cl_int clSetKernelArg(
cl_kernel kernel, // 要设置参数的内核对象
cl_uint arg_index, // 内核参数的索引,从0开始
size_t arg_size, // 参数所占内存的大小
const void* arg_value) // 参数值的地址