OpenCL 2.0 :设备队列和工作组内建函数
在上一篇文章中,我们探讨了OpenCL™2.0管道(pipes)。而本文将讲述另一个重要的特性:设备队列(device enqueue)。同时会讲述新的内建工作组函数。
要想最大程度地掌握本文内容,我们建议做好下面的准备工作:
l 参考注释,通读每篇博文的代码片。
l 请点击这里下载AMD OpenCL2.0驱动,下载页中列出了已支持平台的清单。
l 请点击这里下载范例代码
l 请尝试编写并运行自己的OpenCL2.0代码,可以进入OpenCL社区进讨论。
范例代码将运行于不同的AMD平台,例如Radeon HD8000系列。驱动页面会列出完整的可支持产品家族名单。
设备队列(Device Enqueue)
在OpenCL1.2中,一个正在运行的kernel程序是不能调用另一个kernel程序的;这需要把控制权交还给主机,从而有可能对性能造成影响。
OpenCL2.0允许kernel程序在设备端队列中增加工作程序。同时引入了一个新的概念,“clang blocks”,以及新的内建函数来使得父kernel程序来派生调用子kernel程序。此外,新版本中用clCreateCommandQueueWithProperties替代了运行时调用API clCreateCommandQueue,用于创建设备端命令队列。
由于不用把kernel程序执行控制权交还给主机,设备端队列可从多方面为程序带来性能上的提升。固有的递归应用或需要额外处理的应用能获得更多好处。一个经典的例子是树搜索,从根往叶子进行遍历来找出新的节点。
设备端队列也可用于判断父Kernel中的所有工作组在何时能够全部执行完毕。在OpenCL1.2中,如果要实现相同功能,我们必须要等候一个来自kernel程序的完成事件。如果主机需要一个计算结果,程序同样需要在主机上等待。而OpenCL2.0允许父kernel程序执行子kernel程序,可以消除这些等待延时。
以下将用两个两个例子来说明:区域增长和二分法搜索。
workgroup/ subgroup
OpenCL2.0提供了新的内建函数用在区分workgroup和subgroup(一个workgroup含有一个或多个subgroup)。例如,在AMD平台上,一个workgroup映射为一个“wavefront”(具体请参考AMDOpenCL 用户指南,标题为:AMD Accelerated Parallel Programming OpenCLProgramming Guide (rev 2.7)。
OpenCL2.0提供了如下的新内建函数。此外在CL_DEVICE_EXTENSIONS的CL_DEVICE_EXTENSIONS扩展中,为子工作组定义了相似功能的函数。
1. work_group_all与 work_group_any: 对在workgroup中的所有work items进行predicate(论断)检测。“all”结尾版本进行的是对所有predicate的AND(与)运算,结果返回给所有work items;类似地,“any”执行的是OR(或) 运算。也就是说,当所有predicate都返回true时,“all”函数返回true;当有任一work item返回true,“any”函数返回true。
2. work_group_broadcast:该函数会广播一个work item的局部变量,然后广播到其它所有的在workgroup中的work items。
3. work_group_reduce:该函数会对所有work items进行reduction(规约)运算并返回该结果。运算方式可以是add(求和),min(最少值),max(最大值)。例如当对一个数组进行add操作时,结果返回的是数组元素的总和。
4. work_group_inclusive/exclusive_scan: “scan(扫描)”是一种前置运算,会对work item ID进行reduction运算。如果运算中包含当前ID,那么进行的是一次inclusive(内含)扫描;否则,如果覆盖的是除当前work item的其余work items,那么进行的是一次exclusive(除外)扫描。运算方式可以是add(求和),min(最少值),max(最大值)。
了解好上述内容后,接下来会探究如何使用设备端队列和工作组函数。
使用设备端队列—区域增长算法
“Region growing(区域增长)”是一种把图像分割为两个或多个区域的算法。运算开始于每个区域的“seed(源)”像素,然后执行下列操作:
1. 把seed像素放入队列Q1
2. 把Q1中的每个像素加入合适的区域,判定条件是根据像素的LUMA值与区域LUMA均值之间的关系;完成后更新区域LUMA均值
3. 找出所有还没分类的相邻像素(8个方向),把它们放入队列Q2
4. 交换Q1和Q2
5. 重复步骤2—4直到Q2为空
如果使用了第5步所示的设备端队列,一个kernel程序能并行执行步骤2和3。在OpenCL1.2中,我们需要Host端重复调用相同的Kernel函数去实现上述功能。而在2.0中,通过步骤4我们即可在设备端完成Kernel调用。
接下来会简略地描述这些kernel程序。你也可以下载和运行这些代码,对结果进行回顾分析。首先,来看看“分类”kernel程序。每个work item根据像素值和区域像素均值进行像素分类。
- {
- regLuma = regAvgLuma[RGS_REGION_START +i];
- diffLuma = (float)(pixLuma - regLuma);
- if(diffLuma < minLuma;
- {
- minLuma = diffLuma;
- pixReg = RGS_REGION_START +i;
- }
- }
- //classify
- pImage[pi1d].w = pixReg;
添加新像素后,kernel程序会实用原子的和内建的工作组函数来更新每个区域的均值。该程序会更新总加入像素值以及每个区域像素数量,使用以下的工作组函数来进行:
- wgLuma = work_group_reduce_add(luma);
- wgCount = work_group_reduce_add(q);
kernel程序使用原子函数来就不同工作组进行区域信息的adds(相加)和updates(更新)。最后,使用设备队列来添加一个grow_region子kernel程序:
- if(gid == 0)
- {
- nd = ndrange_1D(256);
- int status = enqueue_kernel(get_default_queue(),
- CLK_ENQUEUE_FLAGS_WAIT_KERNEL,
- nd,
- ^{grow_region(pImage,pNodes,pParams);});
- }
grow_region kernel程序进行的是区域增长算法中步骤3的操作。它会对每个区域的像素均值进行计算,交互队列然后排队queue_neigh kernel程序。
- if(gid == 0)
- {
- uint queued = pParams->queued;
- if(queued > 0)
- {
- size_t global_size = queued;
- ndrange_t ndrange = ndrange_1D(global_size);
- int status = enqueue_kernel(get_default_queue();,
- CLK_ENQUEUE_FLAGS_WAIT_KERNEL,
- ndrange,
- ^{queue_neigh(pImage,pNodes,pParams);});
- }
- }
对于每个像素,queue_neigh kernel程序会找出所有的相邻像素,然后对还没分类的像素进行入队:
- if(pw == RGS_REGION_UNCLASSIFIED)
- {
- qn[count].x = ni2d.x;
- qn[count].y = ni2d.y;
- count++;
- pImage[ni1d].w = RGS_REGION_QUEUED;
- qued |= (1 << n);
- }
当加入这些局部qn队列到pNodes(全局队列)后,必须在队列中计算全局偏移值。这里会再次使用工作组函数:
/* find total number of queued pixels in this workgroup */uint tcount = work_group_reduce_add(count);/* find relative offset in global queue for the pixels in this workgroup */uint lcount = work_group_scan_exclusive_add(count);
- </pre><p></p><p>每个工作组会计算开始索引值wgindex,然后使用广播函数来分发该值:</p><p></p><p> <pre name="code" class="plain">/* announce workgroup index in global queue to all workgroups */
- wgindex = work_group_broadcast(wgindex,0);
- uint windex = wgindex + lcount;
最后,kernel程序把相邻像素存入全局队列。
- for(uint i= 0; i < count; ++i)
- {
- pNodes[qi +i].x = qn[i].x;
- pNodes[qi +i].y = qn[i].y;
- }
要检视所有工作组任务完成与否,设备队列特性会入队一个que_classify kernel程序,它会等待父kernel程序完成后进行:
- queue_t default_queue = get_default_queue();
- size_t global_size = 256;
- ndrange_t ndrange = ndrange_1D(global_size);
- void (^fun_blk)(void) = ^{que_classify(pImage,pNodes,pParams);};
- int status = enqueue_kernel(default_queue,
- CLK_ENQUEUE_FLAGS_WAIT_KERNEL,
- ndrange,
- fun_blk);
CLK_ENQUEUE_FLAGS_WAIT_KERNEL标识表示que_classify kernel程序会在所有父kernel程序工作组完成后开始。本设备队列样例会执行一次检查确保所有工作组执行完成后再进行下一步操作。该等待标识的可选的,CLK_ENQUEUE_FLAGS_WAIT_WORK_GROUP会等候已入队的工作组直到完成,CLK_ENQUEUE_FLAGS_NO_WAIT则不会等待父kernel程序。
que_classifykernel程序会排队分类kernel程序:
- void (^fun_blk)(void) = ^{classify(pImage,pNodes,pParams);};
- int status = enqueue_kernel(default_queue,
- CLK_ENQUEUE_FLAGS_WAIT_KERNEL,
- ndrange,
- fun_blk);
分类kernel程序会对在Q2中的所有像素进行分类,然后再次入队grow_region kernel程序。该过程会持续到所有像素都完成分类然后把图像依据seeds(源)点的多少分割为相应数量的区域。
下面的示例代码链接会含有一个不使用设备端队列的版本,可以通过自行设置命令行参数来运行。该代码会执行与设备端队列相同的算法,区别是kernel程序会返回给主机然后按次序入队下个kernel程序。以下表格对这两者进行了性能上的比较:
注:上述数据来自的硬件配置是A10-7850K (3.7GHz) 处理器,4GB RAM,操作系统是Windows 8.1。
相同的程序分别对三个大小不同的图像进行处理。设备队列版的性能明显优于主机队列版,速度要快3倍多。我们相信随着图像尺寸或分类图像数目的增加,性能优势会得到进一步显现。
以下显示的是一幅输入图像和一幅分类后的输出图像。如我们所见的,程序根据LUMA值对图像进行了分割。
输入图像和分类输出图像
(原图出自BioMedCentral,遵循Creative Commons License 4.0 使用条款)
使用设备排队—二分法搜索
二分法搜索会在一个排序后的序列中搜索给定的key值,其过程是先把序列划分为两个对等部分,然后进行递归查找。由于一个标准的GPU会处理多于两个的work items,我们会把序列划分多几部分(全局线程),每个work item会搜索给定的key值。特别地,我们会搜索较多数量的keys。在每一个递归环节,工作数量会随chunk(主干)大小的不同而不同。因此,这个算法对于设备队列来说是一个好的候选程序。
接下来看看搜索kernel程序是如何使用设备队列的。(示例的详细内容请参见本文末。)每个在binarySearch_device_enqueue_multiKeys_child中的work item会根据自身在序列中的部分对key值进行查找;找出一个后,会更新数组key回传值并设置keysHit变量值,以指示另外一个队列可执行。如果全部work item都搜索失败,搜索程序会终止并报告序列中不含有给定的keys值。
最后,kernel程序会使用设备队列再次执行一遍:
- void (^binarySearch_device_enqueue_wrapper_blk)(void) = ^{binarySearch_device_enqueue_multiKeys_child(outputArray,
- sortedArray,
- subdivSize,
- globalLowerIndex,
- keys,
- nKeys,
- parentGlobalids,globalThreads);};
- int err_ret = enqueue_kernel(defQ,CLK_ENQUEUE_FLAGS_WAIT_KERNEL,ndrange1,binarySearch_device_enqueue_wrapper_blk);
同时,它会检查缺失的keys;若不存在任何缺失的keys,搜索会终止下一队列执行:
- /**** Search continues only if at least one key is found in previous search ****/
- int keysHitFlag = atomic_load_explicit(&keysHit,memory_order_seq_cst);
- if(keysHitFlag == 0)
- return;
结语
设备队列是一个强大的特性。特别是重复地把一组kernel程序根据一定条件应用于某数据结构的场合。在动态数据程序并行化运行时—例如在并行数量或问题大小开始时还不确定的情况下在一个巨大空间中进行搜索—设备队列优势会更明显。
以上示例同时也展示了在OpenCL2.0中新引入的workgroup和subgroup函数。这些函数在workgroup级别的运算是高效的,因为能直接映射到硬件指令。
请尝试自行编写程序来测试这些强大的特性,并把性能结果进行反馈。
示例代码和自述文档
示例代码演示了OpenCL2.0的二分法搜索和区域增长特性。请根据以下链接进行访问:
1. 示例代码和自述文档请点击这里进行查阅
2. 请点击这里下载AMD OpenCL2.0驱动
3. 根据指引和自述文档来运行示例
4. 欢迎进入OpenCL开发者论坛进行讨论和反馈