OpenCL异构并行计算:原理、机制与优化实践
上QQ阅读APP看书,第一时间看更新

第2章 OpenCL的基本介绍

本章开始,将以OpenCL的历史背景作为出发点,逐步介绍OpenCL有关知识。本章将介绍OpenCL历史、OpenCL平台模型、执行模型、存储器模型、OpenCL与OpenGL的关联、OpenCL与CUDA的区别与联系。通过以上介绍,使得读者对于OpenCL有一个整体认识。

2.1 什么是OpenCL

2008年,苹果公司向Khronos Group提交了一份关于跨平台计算框架的草案,该草案由苹果公司开发,并与AMD、IBM、Intel和NVIDIA公司合作逐步完善。这个跨平台计算框架就是OpenCL(Open Computing Language,开放计算语言)。2008年12月8日,OpenCL 1.0技术规范发布。2010年6月14日,OpenCL 1.1发布。2011年11月19日,OpenCL 1.2发布。2013年11月19日,OpenCL 2.0发布。

OpenCL是一个为异构并行计算平台编写程序的工业标准,此异构计算平台可映射到CPU、GPU、DSP和FPGA等计算设备。OpenCL提供了底层硬件结构的抽象模型,旨在提供一个通用的开放API,既减轻开发人员的编程难度,又让开发人员能够写出高效可移植代码。例如,使用OpenCL,开发人员可以编写在GPU上运行的通用计算程序,而无须将其算法映射到OpenGL或DirectX的3D图形API上。

为了描述OpenCL设计的核心,Khronos Group将OpenCL异构并行计算架构划分为平台模型(platform model)、存储器模型(memory model)、执行模型(execution model)和编程模型(programming model),这些模型既相互独立,又相互联系,组成了OpenCL的有机整体。接下来的章节,将逐步讲解这4个模型。由于编程模型和程序设计的细节密切相关,因此放在第3章详细说明。

2.2 OpenCL平台模型

平台模型是关于OpenCL如何看待硬件的一个抽象描述。OpenCL平台模型由主机及其相连的一个或多个OpenCL设备组成,如图2-1所示。通常主机是指包含X86或ARM处理器的计算平台。OpenCL设备可以是CPU(也可以将主机端的CPU作为OpenCL设备)、GPU、DSP、FPGA或硬件商提供、OpenCL开发商支持的任何其他处理器。每个OpenCL设备有一个或者多个计算单元(Compute Units,CU),而每个计算单元又由一个或多个处理单元(Processing Elements,PE)组成,处理单元是设备上执行数据计算的最小单元。后面谈到OpenCL内存模型和工作组时,就会明白为什么会把OpenCL设备分为处理单元和计算单元。

图2-1 OpenCL平台模型

OpenCL平台模型包含一个主机及一个或多个OpenCL设备,每个OpenCL设备包含一个或多个计算单元,每个计算单元包含一个或多个处理单元。

由于OpenCL的平台模型包含了至少两种处理器,如何连接这两种处理器就和在这两种处理器之间传输信息的性能密切相关,目前OpenCL设备主要通过PCI-e总线和主机相连接。

2.3 OpenCL执行模型

OpenCL程序包含主机端程序和设备端内核(kernel)程序。主机端程序运行在主机处理器上,主机端程序以命令方式将内核程序从主机提交到OpenCL设备,OpenCL设备在处理单元上执行计算。根据这两个不同执行单元定义了OpenCL执行模型。

内核在OpenCL设备上执行,完成OpenCL应用的具体工作。内核通常是一些计算量大、逻辑比较简单的函数,OpenCL设备通过内核将输入数据计算处理后输出到主机。在OpenCL中定义了三类内核:

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

原生内核:OpenCL之外创建的函数,在OpenCL中可以通过一个函数指针来访问。例如,这些函数可以是主机源代码中定义的函数,或者是从一个专门库导出的函数。需要指出的是,执行原生内核是OpenCL的一个可选功能,原生内核的语义依赖于具体OpenCL实现。

内建内核:被绑定到特定设备,并不需要源码编译成程序对象的函数。常见用法是针对公开固定函数硬件或固件,将它们关联到一个特定的OpenCL设备或自定义设备。内建内核是OpenCL扩展功能,内建内核语义依赖于具体OpenCL实现。例如,Intel针对运动搜索,提供了block_motion_estimate_intel内建内核,使用clCreateProgram WithBuiltInKernels()函数来创建程序对象,block_motion_estimate_intel内建内核函数名“block_motion_estimate_intel”作为参数提供给clCreateProgramWithBuiltInKernels函数即可。

由于OpenCL设备通常没有IO处理能力,因此IO操作通常由主机承担,这意味着程序开始执行时,数据通常都在主机上,故OpenCL设备需要从主机上获得数据,在OpenCL设备计算完成后,又需要将数据从OpenCL设备复制回主机。

对于OpenCL执行模型来说,最重要的是上下文、命令队列和内核三个概念,理解了这三个概念就基本上理解了OpenCL的本质。

2.3.1 上下文

OpenCL程序的计算工作是在OpenCL设备上执行的,不过主机在OpenCL应用中扮演着重要的角色。主机使用OpenCL API来创建和管理上下文,内核在此上下文中执行。上下文定义了内核执行的环境,包含了:

设备:OpenCL平台包含的一个或多个设备;

内核对象:在OpenCL设备上运行的OpenCL内核函数;

程序对象:实现整个内核程序的源代码和目标二进制码;

存储器对象:对主机和OpenCL设备可见的对象,内核执行时操作这些对象的实例。

OpenCL支持的设备有CPU、GPU、DSP、FPGA等。例如,在同一系统中,可能有CPU和GPU,主机程序请求系统发现这些资源,然后确定使用哪些设备。设备的选择取决于具体问题和运行的内核,主机可能选择CPU、一个GPU、CPU+GPU、多个GPU等多个组合方案。一旦确定组合方案,就会在定义的上下文中包含这些OpenCL设备。

上下文还包括了一个或多个程序对象。此处的“程序”非我们平时所理解的运行在某个系统之上的应用软件,最好把它想象成一个动态库,可以从中取出内核使用的函数。程序对象会在运行时由主机程序构建。为什么程序对象会在运行时才编译构建?这看起来有些奇怪。OpenCL是一套跨平台计算框架,对OpenCL开发人员来说,你可能并不知道OpenCL应用最终会在CPU平台、GPU平台还是FPGA平台上运行,只知道目标平台符合OpenCL规范。对于这个问题,解决办法就是主机程序根据上下文中的设备(只有此时才确定最终平台,从而知道如何编译代码来创建内核代码)特性,在运行时从代码中构建程序对象。为了在性能和平台无关之间均衡,OpenCL提供了两种方式从代码中构建对象,一种是从程序源代码中构建,另一种是从源代码中已经编译好的代码上构建。

OpenCL支持很多不同平台,不同平台有各自不同的存储器体系结构,为了处理这种情况,OpenCL引入了存储器对象的概念。存储器对象在主机上明确定义,并在主机与OpenCL设备间交换数据。虽然增加了OpenCL开发人员的负担,但这样却支持了更多的平台。

关于上下文的具体内容将在3.3节中详细阐述。

2.3.2 命令队列

OpenCL没有定义主机代码如何工作的具体细节,只是定义了它通过命令队列与OpenCL设备如何交互。命令队列由主机或运行在设备中的内核(该功能需要支持OpenCL 2.0的设备!)提交给命令队列。命令会在命令队列中等待,直到被调度到OpenCL设备上执行。OpenCL的一个命令队列在上下文中关联到一个OpenCL设备。放入命令队列中的命令分为下列三种类型:

内核入队命令:将一个内核入队关联到同一个OpenCL设备的命令队列中;

存储器入队命令:将在主机与设备内存对象间传输数据,或者将内存对象映射到主机地址空间,或者从主机地址空间取消映射提交给命令队列;

同步命令:对命令队列中需要执行的命令施加执行顺序约束,如只有某个命令执行完成其他命令才能开始执行。

关于在OpenCL中命令队列如何工作,在3.3节进行详细阐述。

命令可以以异步方式执行,在这种方式下主机或运行在设备中的内核向命令队列提交命令,然后继续工作,而不必等待命令完成。如果有必要等待一个命令完成,可以利用命令执行相关的同步机制进行同步。命令同步将在第6章进行详细阐述。

一个命令队列中的命令执行时可以有以下两种模式:

按序(in-order)执行:命令按其排入命令队列中的先后顺序执行,并按顺序完成。

乱序(out-of-order)执行:命令以任意顺序执行,通过显式的同步点或显式事件依赖项来约束顺序。

OpenCL平台都支持按序模式,但乱序模式是可选的。

需要注意的是,对于某些应用而言,算法在执行时,可能会有额外的计算,而这种额外的计算不能静态地确定。

与内核有关的计算,在运行时只作为内核实例执行。常规方法上,可以通过主机程序多次启动内核实例来执行,但这样会显著地增加开销或加重应用程序控制流。一个方便有效的方法就是从内核内部嵌套内核命令队列。对于支持OpenCL 2.0的设备,可以在设备上入队内核,不需要主机程序参与,这称为设备端队列,实现了嵌套并行。设备端内核命令队列与主机端内核命令队列类似,运行在设备上的内核(父内核)入队一个内核实例(子内核)到设备端命令队列。这个过程是乱序执行的。这部分内容将在4.16节中详细阐述。

2.3.3 内核在OpenCL设备上执行

主机发出一个命令,提交一个内核到OpenCL设备上执行,OpenCL运行时将会创建一个整数索引空间。索引空间是OpenCL支持的一个N维值的网格,称为NDRange,其中N为1,2或3。三个长度为N的数据确定了NDRange的以下特征:

每个维度索引空间(或全局大小)的范围;

一个偏移指数F表明每个维度的初始索引值(默认为0);

一个工作组(局部大小)每个维度大小。

内核、关联内核参数的参数值和定义索引空间的参数,这三个定义了一个内核实例。对应这个索引空间中的各个点将分别执行内核的一个实例。我们将执行内核的各个实例称为一个工作项(work-item),工作项将由它在索引空间中的坐标来标识,这个坐标就是工作组的全局ID,值从F到F加上该维度元素个数减1。每个工作项使用内核定义的同样的指令序列,尽管指令序列是相同的,但是由于代码中通过全局ID选择的数据不同,因此每个工作项的行为也不同。工作项提供了对索引空间细粒度的分解。

多个工作项组织为工作组(work-group),工作组中工作项的数量由内核入队时的参数决定。工作组横跨了整个全局索引空间,提供了对索引空间粗粒度的分解。同样,每个工作组被指定了一个唯一的ID,值从0开始,到该维度中工作组个数减1。对于分配到一个工作组内的每个工作项,除了有一个全局ID,也赋予了一个局部ID来表示它在所属工作组中的位置,这个局部ID的值从0开始,到工作组内该维度元素个数减1。通过结合工作组ID和工作组中的局部ID可以唯一地定义一个工作项。在4.5节中,将会详细讲解确定ID的几个函数。

例如,定义一个2维索引空间。索引空间的工作项大小为(A X,AY),每个工作组大小为(BX,BY),全局偏移ID(FX,FY)。对于此种情况,索引空间的工作项总个数为AX*AY,每个工作组中工作项个数为BX*BY,则工作组的大小(CX,CY)的值为:

CX=ceil(AX/BX)

CY=ceil(AY/BY)

工作项在工作组内的局部ID为(lx,ly),大小为(0,0)~(BX-1,BY-1)。工作组的ID为(Wx,Wy),大小为(0,0)~(CX-1,CY-1)。对于工作项全局ID(gx,gy)可以结合全局ID(lx,ly)和工作组ID(Wx,Wy)来计算:

gx=Wx*BX+lx+FX

gy=Wy*BY+ly+FY

图2-2提供了一个具体的例子,其中各个小方块分别是一个工作项。对于这个例子,每个维度中使用默认偏移是0。

图2-2 2维NDRange示例

图2-2中阴影方块的全局ID为(gx,gy)=(6,5),工作组ID(Wx,Wy)=(1,1),工作项局部ID(lx,ly)=(2,1)。

2.4 OpenCL存储器模型

在OpenCL执行模型的上下文中,我们简要提及OpenCL存储器对象,并没有详细讲述OpenCL存储器对象的细节,也没有提及OpenCL存储器对象的不同类别等问题。这些问题在本节中都能够得到解决。我们将从存储器区域、存储器对象、共享虚拟存储器三方面分析OpenCL存储器模型。而关于存储器一致性模型,将在5.6节中进行详细介绍。

2.4.1 存储器区域

OpenCL异构平台由主机端和设备端构成,存储器区域包含主机与设备的内存。在OpenCL中具体定义了下面几种不同的存储器区域:

主机内存(host memory):主机直接可用的内存,OpenCL并未定义主机内存的具体行为。通过OpenCL API或者共享虚拟存储器接口,实现存储器对象在主机与设备间的传输。

全局存储器(global memory):这个存储器区域允许上下文中任何设备中所有工作组的所有工作项的读写,工作项可以读写存储器对象中的任意元素。全局存储器的读写可能会被缓存,这取决于设备能力。

常量存储器(constant memory):全局存储器中的一块区域,在内核实例执行期间其保存的数据保持不变。对工作项而言这个存储器对象是只读的,主机负责对该存储器对象的分配和初始化。

局部存储器(local memory):这个存储器区域对工作组是局部可见的,它可以用来分配由该工作组中所有工作项共享的变量。

私有存储器(private memory):这个存储器区域是一个工作项的私有区域。一个工作项私有存储器中定义的变量对其他工作项是不可见的。

这些存储器区域以及它们与平台和执行模型的关系如图2-3所示。一般对于存储器与主机端共享的OpenCL设备而言,一个OpenCL设备关联局部存储器和私有存储器;全局存储器和常量存储器由在上下文内的所有设备间共享,OpenCL设备可能包含缓存来支持对这两个存储器的高效访问。

图2-3 OpenCL内存模型

全局存储器和常量存储器可以在一个上下文内的一个或多个设备间共享,一个OpenCL设备关联局部存储器和私有存储器。

2.4.2 存储器对象

全局存储器中的数据内容通过存储器对象来表示。一个存储器对象就是对全局存储器区域的一个引用。在OpenCL中,存储器对象分为三种不同类型:

缓冲(buffer):内核可用的一个连续的存储器区域,编程人员可以将内建数据类型、矢量类型数据或用户自定义的数据结构(当然要符合OpenCL编程规范)映射到这个缓冲区,内核通过指针来访问缓冲区。

图像(image):图像对象用于存储基于标准格式的图像。图像对象是一个不透明的数据结构,使用OpenCL API函数来管理。通常不允许OpenCL内核对单个图像同时进行读和写。然而在OpenCL 2.0中,提供了同步和栅栏操作来放宽这个限制。在第4章中,我们对此会有详细阐述。

管道(pipe):管道存储器是数据项有序的队列。管道有两个端点:一个是写端点,用于插入数据项;另一个是读端点,数据项从读端点被移除。同一时刻,仅有一个内核实例可向一个管道写入数据,同时仅有一个内核实例可从一个管道读出数据。

大多数情况下,主机和OpenCL设备存储器模型是独立的。一旦分配存储器对象,对设备上的内核是有效的。不过在某些情况下,它们是需要交互的。在主机和设备间有三种交互方式://填充、映射和解映射以及拷贝

要显式地读/写/填充数据,主机将命令入队,在主机和全局存储器之间传输数据。实现主机与OpenCL存储器对象间映射和解映射的方法允许主机将一个存储器区域映射到主机可以访问的地址空间。在主机程序安全操作存储器对象之前,主机将这个存储器对象入队到一个映射命令中;主机完成了对这个存储器区域的操作,则入队一个解映射命令,从而使得内核可以安全地读写缓冲。

拷贝命令是将存储器对象在两个缓冲间拷贝,这两个缓冲可驻留在主机或设备上。

上述三个操作命令可以是阻塞或非阻塞操作。关于这部分内容,将在第5章详细阐述。

2.4.3 共享虚拟存储器

通过映射,可以将设备全局存储器区域映射到主机可以访问的地址空间。而除了这种方式,在OpenCL 2.0中,OpenCL通过共享虚拟存储器(Shared Virtual Memory,SVM)机制扩展了全局存储器区域到主机内存区域的方式。在OpenCL 2.0中定义了三种SVM类型:

粗粒度SVM:共享发生在OpenCL缓冲存储器对象区域的粒度;

细粒度SVM:共享发生在OpenCL缓冲存储器对象里独立地以字节加载/存储的粒度;

细粒度系统SVM:共享发生在主机内存内任何地方独立地以字节加载/存储的粒度。

粗粒度内存SVM是OpenCL的核心规范,而对于后两种细粒度实现是可选。

从概念上来说,读者可能不太明白三种SVM到底实现什么功能,这没有关系。只要记住有三种SVM类型,在第5章中会通过具体的例子来讲解SVM的作用和用法。

2.5 OpenCL与OpenGL

如图2-4所示,从GPU诞生之日起,GPU的设计逻辑与CPU的设计逻辑相差很多。GPU从诞生之日起,它的定位是3D图形渲染设备。在设计GPU时从其功能出发,把更多的晶体管用于数据处理。这使得GPU相比CPU有更强的单精度浮点运算能力。人们为了充分利用GPU的性能,使用了很多方法。这其中不得不提OpenGL(Open Graphics Library,开放图形库)。

图2-4 CPU与GPU架构区别

OpenGL定义了一个跨编程语言、跨平台的应用程序接口规范,它用于生成二维、三维图像。这个接口由近350个不同的函数调用组成,用来从简单的图像比特绘制到复杂的三维景象。

当我们把绘制的图像传递给OpenGL后,OpenGL还要做很多才能完成3D空间到屏幕的投影。这一系列的过程称为OpenGL渲染流水线。一般的渲染流水线过程如图2-5所示。在顶点装配和片段操作中,使用GPU中的着色器(英文为shader,实际上就是GPU的处理器)来进行相应操作。进行几何处理的处理器叫顶点着色器,它负责对顶点进行坐标转换、投影变换等;进行片段颜色处理的叫片段着色器。

随着GPU技术的发展,GPU的图形渲染流水线从固定功能流水线发展到可编程渲染流水线。可编程渲染流水线含有若干可编程着色器(比如,OpenGL 2.0起支持顶点和片段着色器;OpenGL 3.2起支持了几何着色器;OpenGL 4.0起支持了细分曲面相关的着色器;OpenGL 4.3又引入了计算着色器),这些可编程着色器处理单元可实现用户自定义算法的功能。OpenGL中的GLSL(OpenGL Shading Language,OpenGL着色器语言)就是一种着色器语言。利用GLSL可以实现上述GPGPU(General Purpose GPU,通用计算GPU)的各种着色器程序。但为了掌握GLSL,人们需要去学习太多的计算机图像学知识,这使得在开始时的学习曲线比较陡峭。

图2-5 OpenGL图像流水线

从2007年以后,基于CUDA和OpenCL这些被设计成具有近似于高阶语言的语法特性的新GPGPU语言,降低了人们使用GPGPU的难度,平缓了开始时的学习曲线。使得在GPGPU领域,OpenGL中的GLSL逐渐退出了人们的视线。OpenCL与OpenGL一样,都是基于硬件API的编程。

2.6 OpenCL与CUDA

2007年,NVIDIA向市场推出GPGPU整套解决方案——CUDA。CUDA是集硬件与软件于一体的集成技术。CUDA C编程是在C99的扩展上进行的,这大大降低了开发GPGPU程序的难度,使得开发人员可以方便地开发GPGPU程序。对于CUDA C的代码,只能运行在NVIDIA G80架构以后的GPU上。

OpenCL是2008年才发布的基于硬件API编程的工业标准。OpenCL相比CUDA,支持的平台更多,除了GPU还有CPU、DSP、FPGA等设备。截至本书撰写之时,OpenCL已发布4个正式标准,最新版本为OpenCL 2.1,而NVIDIA的GPU只支持到OpenCL 1.2,对于开发人员来说有点遗憾。OpenCL编程模型设计时,借鉴和参考了CUDA编程模型。从编程语言来看,OpenCL和CUDA语法基本类似,所以对开发人员而言,如果熟悉OpenCL或CUDA中的其中一种,要熟悉另外一种编程语言是很容易的,这也使CUDA与OpenCL程序之间相互移植会比较容易。不过由于OpenCL支持平台更多,所以在主机端OpenCL处理相比CUDA显得有点烦琐。

本书针对OpenCL编程,对CUDA以及其他异构并行编程方式将做简要概述,不详细展开。对CUDA编程有兴趣的读者,可以从NVIDIA官网(http://www.nvidia.com/content/cuda/cuda-downloads.html)下载CUDA有关文档和开发包。

2.7 本章小结

本章介绍了OpenCL的基本概念:平台模型、执行模型、存储器模型。本章并没有介绍编程模型。

在平台模型中,OpenCL将计算平台抽象成主机加OpenCL设备的模式,主机负责将命令入队交给OpenCL设备计算,而OpenCL设备负责计算内核。

在执行模型中,上下文负责管理OpenCL执行所需要的资源,包括设备、命令队列、存储器对象、程序对象等。一个命令队列对象关联到一个OpenCL设备,主机通过命令队列向OpenCL设备提交操作。

在存储器模型中,全局存储器、常量存储器对所有内核实例都可见,而局部存储器只对一个工作组中的所有工作项可见,经常用于工作组内数据共享,私有存储器只对某个工作项可见。