导读:本文将阐述OpenCL的一些深入的知识,同时结合移动端主流GPU厂商之一的高通Adreno芯片设计,阐述移动端OpenCL编程的优化的一些通用的手段。全文5201字,预计阅读时间14分钟。
一、前言
在《移动端异构运算技术-GPU OpenCL编程(基础篇)》中,介绍了移动端GPU计算现状以及OpenCL编程的基础概念。本文将进一步阐述OpenCL的一些深入的知识,同时结合移动端主流GPU厂商之一的高通Adreno芯片设计,阐述移动端OpenCL编程的优化的一些通用的手段。
备注:高通GPU系列之外,华为麒麟、联发科天机等芯片采用的是ARM公司设计的Mali系列GPU,因为篇幅限制本文不做单独阐述。
二、基础概念
丨OpenCL
OpenCL是由Khronos组织开发和维护的开放的、免费的标准,服务于异构系统中的跨平台并行编程。这种设计可以帮助开发者在利用现代异构系统来发挥出强大的并行计算能力,同时一定程度上也是跨平台的。
丨OpenCL高通
高通是最早完全支持移动端OpenCL运算能力的芯片厂商之一,并且在国内及国际市场上占据了一定的市场份额。
三、OpenCL结构
一个抽象的OpenCL应用,通常包含以下几个部分:
-
CPU Host:作为整个OpenCL应用的管理、调度者,控制整个OpenCL的执行流程。
-
OpenCL Devices:具体的OpenCL硬件设备,比如GPU、DSP、FPGA等等。
-
OpenCL Kernels:承担执行任务的OpenCL内核代码,会被OpenCL Host进行编译,并在对应的硬件进行执行。
丨移动端设备OpenCL
目前的经验来看,Android阵营中的移动端设备,OpenCL通常使用GPU作为硬件加速端。高通的建议是在移动端选择GPU作为OpenCL的加速设备(注:其实多数情况并无选择可能,有且只能获得到一个GPU的device)。
四、OpenCL兼容性
丨程序可移植性
OpenCL提供了不错的程序兼容性,一套OpenCL的代码,在不同的设备上都可以正常运行。当然,少部分基于硬件拓展能力,取决于当前硬件的支持情况。
丨性能可移植性
与程序兼容性不同,OpenCL的性能可移植性通常是比较差的。作为一种高级的计算标准,OpenCL硬件部分的实现是依赖厂商的,每个厂商都会有各自的优点和缺点。因此,针对不同的硬件平台,如高通Adreno 或者Arm Mali,同样的代码的性能表现是不同的。即使是相同的产商,随着硬件的迭代,相应的驱动也会有对应的微调,以充分利用新一代硬件的全部能力。针对不同的设备或者硬件针对性优化是非常必要的。当然这个是一个ROI(投入回报)问题。
丨向后兼容性
OpenCL的设计尽可能的保证向后兼容性。如果要使用已经过时的能力的话,只需要引入特定的头文件即可。值得注意的是:OpenCL的拓展是不完全向后兼容的,这些拓展通常由硬件厂商结合硬件特性来提供,因此应用时需要考虑到不同硬件之间的拓展兼容性。
五、高通 Adreno OpenCL架构
图中为高通Adreno GPU OpenCL(Adreno A5x GPUS)上层架构,OpenCL在执行过程中涉及到几个关键的硬件模块。
丨Shader (or streaming) processor (SP) (着色器、流处理器)
-
Adreno GPU的核心模块,包含众多硬件模块,如算数逻辑单元、加载存储单元、控制流单元、寄存器文件等。
-
运行图形着色器(如顶点着色器、片元着色器、计算着色器等),运行计算负载,如OpenCL内核等。
-
每个SP对应一个或多个OpenCL的运算单元。
-
Adreno GPU可能包含一个或者多个SP,取决于芯片的档次,上图中展示的是单个SP的情况。
-
SP加载和读取Buffer类型或者带有__read_write标记的Image类型数据对象时,可以利用L2缓存。
-
SP加载只读的Image类型的数据对象时,可以利用L1缓存或者纹理处理器。
丨Texture Processer (TP) (纹理处理器)
-
根据内核的调度来进行纹理操作,如纹理的读取、过滤等。
-
TP和L1缓存相结合,减少从L2缓存中读取数据时的缓存丢失几率。
丨Unified L2 Cache (UCHE) (统一L2缓存)
- 响应SP对于Buffer类型的读取和加载,以及L1对于Image类型的数据的加载操作。
六、如何编写高性能OpenCL代码
丨性能兼容性
前文提及了OpenCL的性能兼容性,由于不同硬件的特性并不相同,因此在一块芯片上的调优后的OpenCL代码在另一块芯片上性能可能并非最优的。需要参考对应硬件的文档来进行特异性的优化工作。对于不同的芯片,针对性的优化是必要的。
丨手段总览
OpenCL程序的优化通常可以分为以下三类:
-
程序、算法级别优化
-
API级别优化
-
OpenCL内核优化
程序算法以及API层级的优化手段是较为通用的,此处主要展开OpenCL内核的优化手段。
OpenCL的优化问题本质上一个如何利用内核带宽和计算能力的问题。即合理的利用全局内存、本地内存、寄存器、多级缓存等,以及合理的利用逻辑运算单元、纹理单元等等。
丨程序是否适用OpenCL
开发者需要确定程序是否适合使用OpenCL编写,可以通过以下几个方面来判断:
-
是否存在较大的数据输入
-
程序本身是否是计算密集型
-
程序是否对并行计算亲和
-
程序中的控制流操作相对较少
丨将CPU代码改造为GPU代码时性能Tips
明确了上述的几个关键点之后,开发者可以着手将CPU的代码转化为OpenCL的代码,为了达到一个最优的性能,需要关注以下几个方面:
-
一些情况下,将多个CPU的操作合并到一个OpenCL内核当中可以得到性能收益。这个方式通常适用于减少GPU和主存之间的内存拷贝。
-
一些情况下,将一个复杂的CPU程序拆分成几个简单的OpenCL内核,可以得到更好的程序并行性,进而达到全局性能最优。
-
开发者需要考虑重新设计整体的数据架构,便于减少数据传递的开销。
这些情况要结合实际的情况进行考量,通常也是高性能异构编程本身的难点所在。
丨并行化CPU和GPU的工作流
充分的利用芯片的计算性能,应当合理的规划任务,在GPU执行一些计算工作的同时,CPU也可以同时承担部分工作。通常可以总结为以下几点:
-
使CPU去执行CPU善于执行的部分,比如分支控制逻辑,以及一些串行的操作。
-
尽可能避免GPU进入闲置状态,等待CPU下达进一步任务的情况。
-
CPU和GPU之间的数据传递成本极高,为了减少这部分成本,可以将一些本身适合CPU进行的任务放到GPU进行。
七、性能分析
丨性能Profile
可以结合Profile手段来分析程序性能。由于OpenCL程序分为宿主的CPU的调度逻辑,以及GPU硬件上的执行逻辑。开发者可以分别从CPU调度流程以及GPU执行两个层面去进行性能的Profile。通常CPU Profile是用来衡量整个流程端到端的性能,GPU Profile用来衡量OpenCL内核性能。
CPU Profile
可以采用标准的c++编程方式,例如通过 gettimeofday 之类的api去进行CPU流程间的时间统计。
本文中列出部分示例代码,详细demo可参考OpenCL Profile(https://github.com/xiebaiyuan/opencl_cook/tree/master/profile)。
#include <time.h>
#include <sys/time.h>
void main() {
struct timeval start, end;
// get the start time
gettimeofday(&start, NULL);
// execute function of interest
{
. . .
clFinish(commandQ);
}
// get the end time
gettimeofday(&end, NULL);
// Print the total execution time
double elapsed_time = (end.tv_sec - start.tv_sec) * 1000. + \
(end.tv_usec - start.tv_usec) / 1000.;
printf("cpu all cost %f ms \n", elapsed_time);
GPU Profile
OpenCL提供了对GPU Kernel Profile的API,分别获取OpenCL任务的各个环节的时间节点,便于开发者进行性能优化。
// opencl init codes
...
// cl gpu time profile
cl_event timing_event;
cl_ulong t_queued, t_submit, t_start, t_end;
// add event when clEnqueueNDRangeKernel
int status = clEnqueueNDRangeKernel(runtime.queue, runtime.kernel, 1, nullptr, &ARRAY_SIZE,
nullptr, 0, nullptr, &timing_event);
check_status(status, "clEnqueueNDRangeKernel failed");
clWaitForEvents(1, &timing_event);
clGetEventProfilingInfo(timing_event, CL_PROFILING_COMMAND_QUEUED,
sizeof(cl_ulong), &t_queued, nullptr);
clGetEventProfilingInfo(timing_event, CL_PROFILING_COMMAND_SUBMIT,
sizeof(cl_ulong), &t_submit, nullptr);
clGetEventProfilingInfo(timing_event, CL_PROFILING_COMMAND_START,
sizeof(cl_ulong), &t_start, nullptr);
clGetEventProfilingInfo(timing_event, CL_PROFILING_COMMAND_END,
sizeof(cl_ulong), &t_end, nullptr);
printf("t_queued at %llu \n"
"t_start at %llu \n"
"t_submit at %llu \n"
"t_end at %llu \n"
"kernel execute cost %f ns \n"
"", t_queued, t_start, t_submit, t_end, (t_end - t_start) * 1e-0);
通过上述的api可以得到OpenCL Kernel从进去队列,提交、开始、结束的各个时间点,并且可以计算出Kernel运算时长:
t_queued at 683318895157
t_start at 683318906619
t_submit at 683318897475
t_end at 683318907168
kernel execute cost 549.000000 ns
丨性能瓶颈
识别和定位整个程序的性能瓶颈是非常重要的,没有找到性能的瓶颈,即使其他的环节性能得到优化,也无法使得整个应用性能得到提升。
瓶颈定位
对于OpenCL内核,瓶颈通常是内存瓶颈与计算瓶颈二者之一。
这里提供两个简单的方式,稍微修改代码即可验证:
-
加入额外的计算逻辑,如何没有影响性能,那应当不是计算瓶颈。
-
反之,加入更多的数据加载逻辑,如何没有影响性能,那应当不是数据瓶颈。
解决性能瓶颈
成功的定位到性能瓶颈之后,有一系列的手段可以去针对性的解决:
-
如果是计算瓶颈,可以尝试一些降低计算复杂度的方式、减少计算数的方式,或者使用 OpenCL提供的 fase relax math 或者 native math 等。在精度不高的时候可以使用fp16替代fp32进行计算。
-
如果是内存瓶颈,可以尝试去优化内存的访问策略,如使用向量化的内存加载和存储,利用本地内存或者纹理内存等。在可能的情况下使用更短的数据类型,可以有效的降低内存带宽。
八、总结
本文中以高通Adreno GPU举例,更加深入的阐述了OpenCL的设计思想,同时讲述了OpenCL高性能编程时一些通用的方法论。因为篇幅有限更多细节的内容没有充分展开,对这个方向兴趣感兴趣的小伙伴可以继续关注**「百度Geek说」**公众号。
九、参考文献
[1] OpenCL-Guide
https://github.com/KhronosGroup/OpenCL-Guide/blob/main/chapters/opencl_programming_model.md
[2]OpenCL-Examples
https://github.com/rsnemmen/OpenCL-examples
[3]Mali-GPU
https://zh.wikipedia.org/wiki/Mali_%28GPU%29
[4]Adreno-GPU
https://zh.wikipedia.org/wiki/Adreno
推荐阅读: