OpenCL编程指南

您所在的位置:网站首页 opencl动态运行库 OpenCL编程指南

OpenCL编程指南

2023-05-20 08:11| 来源: 网络整理| 查看: 265

OpenCL概念基础

面向异构平台的应用都必须完成以下步骤: 1)发现构成异构系统的组件。 2)探查这些组件的特征,使软件能够适应不同硬件单元的特定特性。 3)创建将在平台上运行的指令块(内核)。 4)建立并管理计算中涉及的内存对象。 5)在系统中正确的组件上按正确的顺序执行内核。 6)收集最终结果。

这些步骤通过OpenCL中的一系列API再加上一个面向内核的编程环境来完成。我们将采用一种“分而治之”的策略解释以上步骤的所有工作。我们把问题分解为以下模型: 1)平台模型 (platform model):异构系统的高层描述。 2)执行模型 (execution model):指令流在异构平台上执行的抽象表示。 3)内存模型 (memory model):OpenCL中的内存区域集合以及一个OpenCL计算期间这些内存区域如何交互。 4)编程模型( programming model):程序员设计算法来实现一个应用时使用的高层抽象。

平台模型

OpenCL平台模型定义了使用OpenCL的异构平台的一个高层表示。这个模型如图1-6所示。 在这里插入图片描述 OpenCL平台总是包括一个宿主机 (host)。宿主机与OpenCL程序外部的环境交互,包括V/О或与程序用户的交互。

宿主机与一个或多个OpenCL设备 (OpenCL device) 连接。设备就是执行指令流 (或内核) 的地方,因此,OpenCL设备通常称为计算设备 (compute device)。设备可以是CPU、GPU、DSP或硬件提供以及OpenCL开发商支持的任何其他处理器。

OpenCL设备进一步划分为计算单元 (compute unit),而计算单元还可以更进一步划分为一个或多个处理单元。设备上的计算都在处理单元中完成。后面谈到工作组和OpenCL内存模型时,就会明白为什么要把OpenCL设备划分为处理单元和计算单元。

执行模型

OpenCL应用由两个不同部分组成:一个宿主机程序 (host program) 以及一个或多个内核 (kernel) 组成的集合。宿主机程序在宿主机上运行。OpenCL并没有定义宿主机程序如何工作的具体细节,只是定义了它与OpenCL中定义的对象如何交互。

内核在OpenCL设备上执行。它们完成OpenCL应用的具体工作。内核通常是一些简单的函数,将输入内存对象转换为输出内存对象。OpenCL定义了两类内核: 1)OpenCL内核:用OpenCL C编程语言编写并用OpenCL编译器编译的函数。所有OpenCL实现都必须支持OpenCL内核。 2)原生内核:OpenCL之外创建的函数,在OpenCL中可以通过一个函数指针来访问。例如,这些函数可以是宿主机源代码中定义的函数,或者是从一个专用库导出的函数。需要说明的是,执行原生内核的能力是OpenCL的一个可选功能,原生内核的语义依赖于具体实现。

OpenCL执行模型定义了内核如何执行。为了详细解释OpenCL执行模型,我们将分部分来讨论。首先我们会解释单个内核如何在OpenCL设备上运行。由于编写OpenCL应用的重点就是执行内核,所以这个概念是理解OpenCL的基础。接下来我们会介绍宿主机如何定义上下文来执行内核以及内核如何排队等待执行。

内核如何在OpenCL设备上执行

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

提交内核执行的命令相应地会创建一个工作项集合,其中各个工作项使用内核定义的同样的指令序列。尽管指令序列是相同的,但是由于代码中的分支语句或者通过全局ID选择的数据可能不同,因此各个工作项的行为可能不同。

工作项组织为工作组 (work- group)。工作组提供了对索引空间更粗粒度的分解,跨越整个全局索引空间。换句话说,工作组在相应维度的大小相同,这个大小可以整除各维度中的全局大小。为工作组指定一个唯一的ID,这个ID与工作项使用的索引空间有相同的维度。另外为工作项指定一个局部ID,这个局部ID在工作组中是唯一的,这样就能由其全局ID或者由其局部ID和工作组ID唯一地标识一个工作项。

给定工作组中的工作项会在一个计算单元的处理单元上并发执行。这是理解OpenCL并发性的关键。具体实现可能串行化内核的执行,甚至可能在一个内核调用中串行化工作组的执行。OpenCL只能确保一个工作组中的工作项并发执行 (和共享设备上的处理器资源)。因此,不要认为工作组或内核调用会并发执行。尽管实际上它们通常确实会并发执行,但是算法设计人员不能依赖这一点。

索引空间是一个N维的值网格,因此也称为NDRange。目前,这个N维索引空间中的N可以是1、2或3。在一个OpenCL程序中,NDRange由一个长度为N的整数数组定义,N指定索引空间各维度的大小。各个工作项的全局和局部ID都是一个N维元组。在最简单的情况下,全局ID中各个值的取值范围从О开始,到该维度元素个数减1。

与为工作项指定ID类似,仍采用这种方法为工作组指定ID。有一个长度为N的数组定义各个维度中工作组的个数。工作项指定到一个工作组,并给定一个局部ID,这个局部ID中各个值的取值范围也是从0开始,到该维度中工作组个数减1。因此,通过结合工作组ID和工作组中的局部ID就可以唯一地定义一个工作项。

下面进一步分析根据这个模型建立的不同索引,并研究它们之间如何关联。可以考虑一个2维的NDRange。我们使用小写字母g表示给定下标x或y时各维度中一个工作项的全局ID。大写字母G指示索引空间各维度的大小。因此,各工作项在全局NDRange索引空间中有一个坐标 (gx,gy),全局索引空间的大小为(Gx,Gy),工作项坐标取值范围为[0… (Gx, -1), 0… (Gy, -1)]。

OpenCL要求各个维度中工作组的数目能够整除NDRange索引空间各个维度的大小。这样可以保证所有工作组都是满的,而且大小相同。各个方向 (在这个2维的例子中,就是x和y方向) 的工作组大小要用来为各个工作项定义一个局部索引空间。我们把一个工作组内的索引空间称为局部索引空间 (local index space)。按照前面使用大小写字母的约定,局部索引空间中各个维度 (x和y) 的大小用大写字母L表示,工作组中的局部ID使用小写字母l表示。

因此,大小为Gx×Gy的NDRange索引空间将划分为Wx×Wy空间上的工作组,其索引为(wx,wy)。各个工作组的大小为Lx×Ly,这里可以得到: L x L_x Lx​= G x / W x G_x/W_x Gx​/Wx​ L y L_y Ly​= G y / W y G_y/W_y Gy​/Wy​ 可以根据工作项的全局ID(gx,gy)来定义一个工作项,或者结合局部ID(lx,1y)和工作组ID(wx,wy)来定义: g x g_x gx​= w x ∗ L x + l x w_x*L_x+l_x wx​∗Lx​+lx​ g y g_y gy​= w y ∗ L y + l y w_y*L_y+l_y wy​∗Ly​+ly​ 或者,还可以由gx和gy后退一步,如下恢复局部ID和工作组ID: w x w_x wx​= g x / L x g_x/L_x gx​/Lx​ w y w_y wy​= g y / L y g_y/L_y gy​/Ly​ l x l_x lx​= g x g_x gx​% L x L_x Lx​ l y l_y ly​= g y g_y gy​% L y L_y Ly​ 在这些公式中,使用了整除(截断除)和取模或“取余”( %)操作。

在所有这些公式中,假设索引空间的各个维度都从0开始。不过,索引通常会选择为与原始问题自然匹配。因此,在OpenCL 1.1中增加了一个选项,可以为全局索引空间的起始点定义一个偏移量。需要为各个维度(这个例子中就是x和y)定义偏移量,因为它修改了全局索引,所以使用一个小写字母o表示偏移量。对于非0偏移量(ox,oy),连接全局和局部索引的最后公式为: g x g_x gx​= w x ∗ L x + l x + o x w_x*L_x+l_x+o_x wx​∗Lx​+lx​+ox​ g y g_y gy​= w y ∗ L y + l y + o y w_y*L_y+l_y+o_y wy​∗Ly​+ly​+oy​ 在图1-7中,我们提供了一个具体的例子,其中各个小方块分别是一个工作项。对于这个例子,各个维度中使用默认偏移量0。请仔细研究这个图,确保能够理解有阴影的方块 (全局索引为(6,5)) 落在ID(1,1)的工作组中,局部索引为(2,1)。 在这里插入图片描述 如果这些索引处理让人很困惑,不用担心。在很多情况下,OpenCL程序员只需处理全局索引空间。经过一段时间后,随着你做了更多的OpenCL工作,就会对处理不同类型的索引积累更多的经验,这些处理对你来说也会变得很自然了。

OpenCL执行模型相当灵活。这个模型支持大量不同类型的编程模型。不过,在设计OpenCL中,只明确考虑了两个模型:数据并行和任务并行。后面还会讨论这些模型和它们对OpenCL的影响。不过,首先需要结束对OpenCL执行模型的讨论。

上下文

OpenCL应用的计算工作在OpenCL设备上进行。不过,宿主机在OpenCL应用中扮演着非常重要的角色。内核就是在宿主机上定义的,而且宿主机为内核建立了上下文。宿主机定义了NDRange和队列,队列将控制内核如何执行以及何时执行的细节。所有这些重要的函数都包含在OpenCL定义的API 中。

宿主机的第一个任务是为OpenCL应用定义上下文。顾名思义,上下文定义了一个环境,内核就在这个环境中定义和执行。更准确地说,由以下资源定义上下文: 1)设备 (device):宿主机使用的OpenCL设备集合。 2)内核 (kernel):在OpenCL设备上运行的OpenCL函数。 3)程序对象 (program object):实现内核的程序源代码和可执行文件。 4)内存对象 (memory object):内存中对OpenCL设备可见的一组对象,包含可以由内核实例处理的值。

上下文由宿主机使用OpenCL API函数创建和管理。例如,考虑图1-3的异构平台。这个系统有两个多核CPU和一个GPU。宿主机程序在其中一个CPU上运行。宿主机程序请求系统发现这些资源,然后决定OpenCL应用中使用哪些设备。取决于具体问题和要运行的内核,宿主机可能选择GPU、另一个CPU、同一个CPU上的其他核,或者是这些方案的组合。一旦决定了,这个选择就会定义当前上下文中的OpenCL设备。

上下文中还包括一个或多个程序对象 (program object),程序对象包含内核的代码。程序对象这个名字的选择容易让人混淆。最好把它想象成一个动态库,可以从中取出内核使用的函数。程序对象会在运行时由宿主机程序构建。对于不是从事图形领域的程序员来说,这看起来可能有些奇怪。可以考虑一下OpenCL程序员面对的挑战。他编写了OpenCL应用程序并把这个应用程序交给最终用户,但是这些用户可能选择在其他地方运行这个应用程序。程序员根本无法控制最终用户在哪里运行应用程序(可能是GPU、CPU或者其他芯片)。OpenCL程序员所知道的只是目标平台符合OpenCL规范。

对于这个问题,解决方法就是在运行时从源代码构建程序对象。宿主机程序定义上下文中的设备。只有那时才有可能知道如何编译程序源代码来创建内核代码。对于源代码本身,OpenCL在形式上相当灵活。在很多情况下,这会是一个常规的字符串,可以在宿主机程序中静态定义,或者在运行时从一个文件加载,也可能在宿主机程序中动态生成。

现在我们的上下文中包含OpenCL设备和一个程序对象,将从这个程序对象中取出内核来执行。接下来我们考虑内核如何与内存交互。OpenCL使用的详细内存模型将在后面介绍。为了便于讨论上下文,我们需要从高层次了解OpenCL 内存如何工作。这里的核心问题是,一个异构平台上通常要管理多个地址空间。在CPU平台上,宿主机可能有我们熟悉的地址空间,不过设备可能有各种不同的内存体系结构。为了处理这种情况,OpenCL引入了内存对象的思想。内存对象在宿主机上明确定义,并在宿主机和OpenCL设备之间移动。对于程序员来说,这确实增加了负担,但是这样允许我们支持更多的平台。

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

命令队列

宿主机与OpenCL设备之间的交互是通过命令完成的,这些命令由宿主机提交给命令队列 (command-queue)。这些命令会在命令队列中等待,直到在OpenCL设备上执行。命令队列由宿主机创建,并在定义上下文之后关联到一个OpenCL设备。宿主机将命令放入命令队列,然后调度这些命令在关联设备上执行。OpenCL支持3种类型的命令: 1)内核执行命令(kernel execution command):在OpenCL设备的处理单元上执行内核。 2)内存命令(memory command):在宿主机和不同内存对象之间传递数据,在内存对象之间移动数据,或者将内存对象映射到宿主机地址空间,或者从宿主机地址空间解映射。 3)同步命令(synchronization command):对命令执行的顺序施加约束。

在一个典型的宿主机程序中,程序员不仅定义上下文和命令队列,定义内存和程序对象,还会构建宿主机上所需要的数据结构来支持应用。然后把重点转向命令队列。内存对象从宿主机移到设备上,内核参数关联到内存对象,然后提交到命令队列执行。内核完成工作时,计算中生成的内存对象可能会再复制到宿主机。

将多个内核提交到队列时,它们可能需要交互。例如,一组内核可能生成内存对象,而另一组内核需要这些内存对象来完成处理。在这种情况下,可以使用同步命令来强制第一组内核先完成,在此之前另一组内核不能开始。

关于OpenCL中命令如何工作,还有很多小细节。这些细节问题我们将在本书后面说明。现在的目标只是了解命令队列,对OpenCL命令有一个高层次的认识。

到目前为止,对于命令以什么顺序执行或者命令的执行与宿主机程序的执行有什么关系,我们很少谈到。命令总是与宿主机程序异步执行。宿主机程序向命令队列提交命令,然后继续工作,而不必等待命令完成。如果有必要让宿主机等待一个命令,可以利用一个同步命令显式地建立这个约束。

一个队列中的命令执行时可以有以下两种模式: 1)有序执行(in-order execution):命令按其在命令队列中出现的顺序发出,并按顺序完成。换句话说,队列中前一个命令完成之后,下一个命令才会开始。这会将队列中命令的执行顺序串行化。 2)乱序执行(out-of-order execution):命令按顺序发出,但是下一个命令执行之前不会等待前一个命令完成。程序员要通过显式的同步机制来强制顺序约束。

待前一个命令完成。程序员要通过显式的同步机制来强制顺序约束。

所有OpenCL平台都支持有序模式,而乱序模式是可选的。为什么想要使用乱序模式呢?请考虑图1-5,其中引入了负载平衡的概念。对于一个应用来说,在所有内核完成之前它是无法完成的。因此,要得到一个尽可能降低运行时开销的高效程序,肯定希望所有计算单元都得到充分利用,另外运行的时间大致相同。为了做到这一点,通常的做法是仔细考虑向队列提交命令的顺序,使有序执行能够达到很好的负载平衡。不过,如果有一组执行时间不同的命令,要实现负载平衡,使得所有计算单元都得到充分利用并同时完成,这可能很困难。乱序队列可以为你考虑这个问题。命令可以按任意顺序执行,所以如果一个计算单元很早就完成了它的工作,就可以立即从命令队列获取一个新命令,开始执行一个新内核。这称为自动负载平衡(automatic loadbalancing),这也是由命令队列驱动的并行算法设计中常用的一种技术。

如果一个应用程序中有多个执行流,就有可能出现灾难。有可能还没有写入数据就意外地要使用这个数据,或者内核执行的顺序可能导致错误的答案。程序员需要某种方法来管理命令的约束。我们已经提到过,可以用一个同步命令告诉一组内核等待,直到之前的一组命令完成。这通常很有效,但是在有些情况下还需要更复杂的同步协议。

为了支持定制的同步协议,提交到命令队列的命令会生成事件对象。可以告诉一个命令等待,直到事件对象上的某些条件成立时才执行。这些事件还可以用来协调宿主机和OpenCL 设备之间的执行。后面还会更多地讨论事件。

最后,还可以通过一个上下文为OpenCL 设备关联多个队列。两个队列并发、独立地运行,OpenCL中对它们之间如何同步没有明确的机制。

内存模型

执行模型指出了内核如何执行,它们与宿主机如何交互,以及它们与其他内核如何交互。为了描述这个模型和关联的命令队列,我们曾简单地提到内存对象,不过还没有定义这些对象的细节,也没有指出内存对象的类型或安全地使用内存对象的原则。这些问题都涵盖在OpenCL内存模型中。

OpenCL定义了两种类型的内存对象:缓冲区对象(buffer object)和图像对象(imageobject)。顾名思义,缓冲区对象就是内核可用的一个连续的内存区。程序员可以将数据结构映射到这个缓冲区,并通过指针访问缓冲区。这就为定义程序员所需要的任何数据结构提供了灵活性 (当然要受OpenCL内核编程语言的限制)。

另一方面,图像对象仅限于存储图像。图像存储格式可以进行优化来满足一个特定OpenCL设备的需要。因此,OpenCL要提供实现的自由,允许定制图像格式,这很重要。因此,图像内存对象是一个不透明的对象。OpenCL框架提供了很多函数来管理图像,但是除了这些特定的函数外,图像对象的内容对内核程序是隐藏的。

OpenCL还允许程序员将一个内存对象的子区域指定为不同的内存对象 (OpenCL 1.1规范中新增加的)。这使得一个大的内存对象的子区域也成为OpenCL中的首类对象,可以通过命令队列管理和协调。

了解内存对象本身只是第一步。我们还需要理解控制OpenCL程序中如何使用内存对象的抽象机制。OpenCL内存模型定义了5种不同的内存区域: 1)宿主机内存 (host memory):这个内存区域只对宿主机可见。与有关宿主机的大多数细节问题一样,OpenCL只定义了宿主机内存与OpenCL对象和构造如何交互。 2)全局内存 (global memory):这个存储区域允许读、写所有工作组中的所有工作项。工作项可以读、写全局内存中一个内存对象的任何元素。读、写全局内存可能会缓存,这取决于设备的容量。 3)常量内存(constant memory):全局内存的这个内存区域在执行一个内核期间保持不变。宿主机分配并初始化放在常量内存中的内存对象。这些对象对于工作项是只读的。 4)局部内存 (local memory):这个内存区域对工作组是局部的。这个内存区域可以用来分配由该工作组中所有工作项共享的变量。它可以实现为OpenCL设备上的专用内存区域。或者,局部内存区域也可以映射到全局内存的区段( section ) 。 5)私有内存 (private memory):这个内存区域是一个工作项私有的区域。一个工作项私有内存中定义的变量对其他工作项不可见。

这些内存区域以及它们与平台和执行模型的关系见图1-8。工作项在处理单元上运行,有其自己的私有内存。工作组在一个计算单元上运行,与该组中的工作项共享一个局部内存区域。OpenCL设备内存利用宿主机来支持全局内存。 在这里插入图片描述 在大多数情况下,宿主机和OpenCL设备内存模型是独立的。有必要假设宿主机在OpenCL之外定义。不过,在有些情况下,它们确实需要交互。这种交互有两种方式:显式地复制数据,或者映射和解映射内存对象的内存区域。

要显式地复制数据,宿主机将命令入队,在内存对象和宿主机内存之间传输数据。这些内存传输命令可以是阻塞的,也可能是非阻塞的。一旦宿主机上关联的内存资源可以安全地重用,要求阻塞内存传输的OpenCL函数就会返回。对于非阻塞内存传输,一旦命令入队,OpenCL函数就会立即返回,不论宿主机内存是否可以安全使用。

实现宿主机与OpenCL内存对象之间交互的映射/解映射方法允许宿主机将一个区域从内存对象映射到它自己的地址空间。内存映射命令 (像其他OpenCL命令一样在命令队列中排队) 可以是阻塞的,也可以是非阻塞的。一旦从内存对象映射了一个区域,宿主机就可以读、写这个区域。宿主机完成这个映射区域的访问 (读、写) 时,宿主机可以解除这个区域的映射。

不过,涉及并发执行时,内存模型需要仔细定义内存对象如何与内核和宿主机及时交互。这是内存一致性 (memory consistency)问题。只是指出内存值去哪里还不够,还必须定义这些值在平台上何时可见。

再次说明,OpenCL并没有在宿主机上规定内存一致性模型。先从离宿主机最远的内存 (私有内存区域) 开始考虑,逐步转向宿主机。私有内存对宿主机是不可见的。它只对相应的工作项可见。这个内存采用顺序编程中很熟悉的加载/存储内存模型。换句话说,对私有内存的加载和存储不能重新排序,即除了程序文本中定义的顺序外,不能以其他顺序出现。

对于局部内存,可以保证一个工作组中一组工作项能够看到的值在工作组同步点是一致的。例如,一个工作组栅栏(work-group barrier)要求在栅栏之前定义的所有加载和存储必须先完成,工作组中这个栅栏之后的工作项才能继续。换句话说,栅栏标记了–组工作项执行中的某一点,在这一点可以保证内存是一致的,在继续执行之前处于已知状态。

由于局部内存只是在一个工作组内共享,所以这对于定义局部内存区域的内存一致性就足够了。对于一个工作组中的工作项,在工作组栅栏,全局内存也是一致的。不过,尽管这个内存在工作组间共享,但无法强制执行一个内核的不同工作组之间全局内存的一致性。

对于内存对象,OpenCL定义了一个宽松一致性模型。换句话说,单个工作项在内存中看到的值不能保证任何时刻在整个工作项集中都保持一致。在给定的时刻,对于不同的工作项,对OpenCL内存对象的加载和存储可能以不同的顺序出现。这称为宽松一致性(relaxed consisten-cy)模型,因为它没有我们期望的加载/存储模型那么严格,即并发执行要与串行执行的顺序完全匹配。

最后一步是定义相对于命令队列中命令的内存对象一致性。在这种情况下,我们使用了释放一致性的一个修改版本。与一个内核相关联的所有工作项完成时,这个内核释放的内存对象的相应加载和存储完成后,内核命令才能标志为完成。对于有序队列,这足以定义内核间的内存一致性。对于乱序队列,则有两个选择(称为同步点)。第一个选择是在特定的同步点(如命令队列栅栏)强制一致性。第二个选择是通过事件机制(将在后面介绍)显式地管理一致性。这些选择同样用于强制宿主机和OpenCL设备之间的一致性,也就是说,内存仅在命令队列中的同步点是一致的。

编程模型

OpenCL执行模型定义了一个OpenCL应用如何映射到处理单元、内存区域和宿主机。这是一个“以硬件为中心”的模型。现在我们换个角度,介绍如何使用编程模型将并行算法映射到OpenCL。编程模型实际上就是程序员如何考虑他们的算法。因此,这些模型本质上比执行模型更为灵活。

OpenCL定义了两种不同的编程模型:任务并行和数据并行。可以看到,还可以考虑-种混合模型:包含数据并行的任务。程序员很有创造性,可以预期,过一段时间可能还会创建映射到OpenCL基本执行模型的另外的编程模型。

数据并行编程模型

前面内容描述了数据并行编程模型的基本思想(见图1-4)。适合采用数据并行编程模型的问题都与数据结构有关,这些数据结构的元素可以并发更新。基本上,就是将一个逻辑指令序列并发地应用到数据结构的元素上。并行算法的结构被设计为一个序列,即对问题领域中数据结构并发更新的序列。

这个编程模型很自然地切合了OpenCL的执行模型。关键是执行一个内核时定义的NDRange。算法设计者要保证问题中的数据结构与NDRange索引空间一致,将它们映射到OpenCL内存对象。内核定义了OpenCL计算中作为工作项并发应用的指令序列。

在更复杂的数据并行问题中,一个工作组中的工作项需要共享数据。这要通过存储在局部内存区域中的数据来支持。只要工作项之间引入了依赖性,就必须特别当心,不论工作项以什么顺序完成,都要生成相同的结果。换句话说,工作项的执行需要同步。一个工作组中的工作项可能参与一个工作组栅栏。前面已经指出,一个工作组中的所有工作项必须先执行这个栅栏,之后才允许跨过这个栅栏继续执行。需要说明的是,要么工作组中执行内核的所有工作项都遇到工作组栅栏,要么所有工作项都不会遇到这个栅栏。

对于执行一个内核时不同工作组的工作项之间如何同步,OpenCL 1.1并未提供任何机制。这是程序员设计并行算法时一定要记住的一个重要限制。

工作项可能需要共享信息,作为这样一个例子,下面考虑一组工作项参与某种归约。归约是指一个数据元素集合通过某种结合运算归约为一个元素。最常见的例子就是求和或者查找一组数据元素中的极值(最大值或最小值)。在归约中,工作项要完成一个计算来生成将要归约的数据元素。这必须在所有工作项上完成,之后才能对所有工作项完成累积,生成工作项的一个子集(通常是一个大小为1的子集)。

OpenCL提供了层次结构的数据并行性:工作组中工作项的数据并行再加上工作组层次的数据并行。OpenCL规范讨论了这种数据并行形式的两个变种。在显式模式 (explicit model) 中,程序员负责显式地定义工作组的大小。利用第二个模型,即隐式模型 (implicit model),程序员只需定义NDRange空间,由系统选择工作组。

如果内核不包含任何分支语句,那么各个工作项会执行相同的操作,但是会处理其全局ID选择的数据项的一个子集。这种情况定义了数据并行模型的一个重要子集,称为单指令多数据 (Single Instruction Multiple Data,SIMD)。另外,内核中的分支语句可能让各个工作项执行完全不同的操作。尽管各个工作项使用相同的“程序”(也就是,内核),但它完成的具体工作可能完全不同。这通常称为单程序多数据 (Single Program Multiple Data,SPMD) 模型。

OpenCL同时支持SIMD和SPMD模型。在指令内存带宽有限的平台上,或者如果处理单元映射到一个矢量单元,SIMD模型会更为高效。因此,程序员很有必要了解这两种模型,而且要知道什么情况下使用哪一种模型。

有一种情况下OpenCL程序是严格的SIMD,即矢量指令。利用这些指令,可以向与一个处理单元相关联的矢量单元显式地发出指令。例如,下面的指令来自一个数值积分程序(积分函数为4.0/(1+x2))。在这个程序中,会展开8层积分循环,使用目标平台的原生矢量指令立即计算积分中的8个步骤。

float8 x, psum_vec; float8 ramp = (float8)(0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5); float8 four = (float8)(4.0); float8 one = (float8)(1.0); float step_number; //step number from loop index float step_size; //Input integration step size ...and later inside a loop body.... x = ((float8)step_number + ramp)*step_size; psum_vec += four/(one + x*x);

由于市场上提供了大量矢量指令集,因此OpenCL为显式的矢量指令提供一个可移植的记法,这是OpenCL的一个很方便的特性。

总之,数据并行很自然地切合了OpenCL执行模型。这个模型是层次结构,因为数据并行计算(工作项)可能包括矢量指令(SIMD),作为更大规模的块级数据并行(工作组)的一部分。所有这些工作结合起来为表述数据并行算法创建了一个很好的环境。

任务并行编程模型

OpenCL执行模型被设计为以数据并行作为主要目标。不过这个模型还支持大量任务并行算法。

OpenCL将任务定义为单个工作项执行的内核,而不考虑OpenCL应用中其他内核使用的NDRange。如果程序员所希望的并发性来自于任务,就会使用这个模型。例如,并发性可能只是通过矢量类型上的矢量操作来表述。或者任务可能使用原生内核接口定义的一个内核,并行性使用OpenCL之外的一个编程环境来表述。

任务并行的另一个版本是内核作为任务提交,利用一个乱序队列同时执行这些任务。例如,考虑图1-5所示的独立任务的集合。在一个四核CPU上,一个核可能是宿主机,另外3个核配置为一个OpenCL 设备中的计算单元。OpenCL应用可以将所有6个任务入队,由计算单元动态地调度工作。任务数远大于计算单元数时,这个策略将是一个很有效的方法,可以得到很好的负载平衡。不过,这种类型的任务并行不一定适用所有OpenCL平台,因为命令队列的乱序模式是OpenCL 1.1中的一个可选特性。

任务并行的第3个版本是任务使用OpenCL的事件模型连接到一个任务图。提交到事件队列的命令有可能生成事件。后续的命令在执行之前可能要等待这些事件。与支持乱序执行模型的命令队列结合使用时,就允许OpenCL程序员在OpenCL中定义静态任务图,图中的节点表示任务,边为节点之间的依赖关系(由事件管理)。

并行算法限制

OpenCL框架为数据并行和任务并行编程模型定义了一个坚实的基础。很多并行算法都能映射到这些模型上,不过也存在限制。由于OpenCL支持大量不同类型的设备,所以对于OpenCL执行模型存在一些限制。换句话说,OpenCL优秀的可移植性是以算法中所能支持的通用性为代价的。

问题的关键要归结于执行模型中所做的假设。提交一个命令来执行一个内核时,我们可以只假设工作组中的工作项并发执行。具体实现可以按任何顺序运行不同的工作组——包括串行(也就是一个接一个地运行)。对于内核执行也是如此。即使启用乱序队列模式,符合规范的实现仍有可能串行地执行内核。

OpenCL表述并行性的这些约束进一步限制了工作组之间和内核之间共享数据的方式。需要了解两种情况。首先,考虑与一个内核执行关联的工作组集合。符合规范的OpenCL实现可以采用它选择的任何方式对这些工作组排序。因此,我们构造算法时不能依赖于执行一个内核的相关工作组间共享数据的具体细节,这是不安全的。

其次,考虑多个内核的执行顺序。它们按其入队的顺序提交执行,但是可以串行执行(有序命令队列模式)或并发执行(乱序命令队列模式)。不过,即使采用乱序队列,实现也完全可以采用串行顺序执行内核。因此,前面的内核如果等待来自后面内核的事件,就可能死锁。另外,与一个算法关联的任务图只能有单向边,从命令队列中先入队的节点指向命令队列中后入队的内核。

这些都是很重要的限制。这说明存在一些无法用OpenCL表述的并行设计模式。不过,过一段时间,随着硬件的发展,特别是随着GPU继续增加特性来支持更通用的计算,我们会在OpenCL的将来版本中修正这些限制。对现在来说,别无他法,只能接受这些限制。

其他编程模型

程序员完全可以结合OpenCL的编程模型来创建各种复合编程模型。我们已经提到过一种情况,即一个数据并行算法中的工作项通过矢量指令包含SIMD并行。

不过,随着OpenCL实现的成熟,以及命令队列乱序模式越来越常见,可以想象还可能出现其他静态任务图,各个节点为数据并行算法(多个工作项),其中包含SIMD矢量指令。

OpenCL通过一个可移植的平台模型和一个强大的执行模型公布硬件。这些工作结合起来定义了一个灵活的硬件抽象层。计算机科学家可以在OpenCL硬件抽象层上增加其他编程模型层。OpenCL还很年轻,对于OpenCL规范之外的编程模型在OpenCL平台上如何运行,我们还无法给出任何具体的例子。不过,拭目以待吧,关注相关文献。出现这种情况只是早晚的事情。



【本文地址】


今日新闻


推荐新闻


    CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3