OpenCL-并行程序开发秘籍-全-

OpenCL 并行程序开发秘籍(全)

原文:zh.annas-archive.org/md5/40aefa8cf27e23018cc9b75965224629

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎来到 OpenCL 并行编程开发食谱集!哇,这个名字确实有点长。这本书是由一位开发者(就是我)为开发者(希望也是你)编写的。这本书对一些人来说可能很熟悉,而对另一些人来说则很独特。它是我在 OpenCL 方面的经验的结果,但更重要的是在编程异构计算环境方面的经验。我想整理我所学到的知识,并与你,读者分享,并决定采用一种方法,即将每个问题分类到一个食谱中。这些食谱旨在简洁,但诚然有些比其他的长。这样做的原因是因为我选择的问题,它们以本书的章节形式出现,描述了你可以如何将这些技术应用到你的当前或未来工作中。希望它能成为你桌上众多参考资料的一部分。我确实希望理解这些问题的解决方案能像帮助我一样帮助你。

这本书是为那些希望不仅知道如何并行编程,而且希望学会并行思维的软件开发者而编写的。在我看来,后者比前者更重要,但单独任何一方都无法解决问题。本书通过代码强化每个概念,并在我们利用更多食谱时对其进行扩展。

本书结构旨在通过让你熟悉 OpenCL 的核心概念,让你轻松地进入 OpenCL,然后我们将通过将新获得的知识应用到你在工作中遇到的各个食谱和一般并行计算问题中进行深入研究。

为了最大限度地利用这本书,强烈建议你是软件开发者或嵌入式软件开发者,并对并行软件开发感兴趣,但不知道从哪里或如何开始。理想情况下,你应该了解一些 C 或 C++(你可以学习 C,因为它相对简单),并且熟悉使用跨平台构建系统,例如 Linux 环境中的 CMake。CMake 的好处是它允许你为那些习惯使用微软的 Visual Studio、苹果的 XCode 或其他集成开发环境的人生成构建环境。我必须承认,本书中的示例没有使用这些工具。

本书涵盖的内容

第一章,使用 OpenCL,通过建立 OpenCL 的目的和动机为读者设定了场景。核心概念通过涵盖设备和它们之间交互的内在特性以及实际工作代码的食谱来概述。读者将了解上下文和设备以及如何创建在这些设备上运行的代码。

第二章, 理解 OpenCL 数据传输和分区,讨论了 OpenCL 中的缓冲区对象以及如何在它们之间分区数据的策略。随后,读者将了解工作项是什么以及如何通过利用 OpenCL 抽象来实现数据分区。

第三章, 理解 OpenCL 数据类型,解释了 OpenCL 提供的两种通用数据类型,即标量和向量数据类型,它们如何用于解决不同的问题,以及 OpenCL 如何抽象处理器的原生向量架构。读者将了解他们如何通过 OpenCL 实现可编程向量化。

第四章, 理解 OpenCL 函数,讨论了 OpenCL 在解决日常问题(例如几何、排列和三角学)时提供的各种功能。它还解释了如何通过使用它们的向量化对应物来加速这些功能。

第五章, 开发直方图 OpenCL 程序,见证了典型 OpenCL 开发的生命周期。它还讨论了依赖于对所讨论算法的了解的数据分区策略。读者将无意中意识到并非所有算法或问题都需要相同的方法。

第六章, 开发 Sobel 边缘检测滤波器,将指导您如何使用 Sobel 方法构建边缘检测滤波器。它将介绍一些数学形式,包括一维和二维的卷积理论及其相应的代码。最后,我们将介绍 OpenCL 中的性能分析是如何工作的以及它在本食谱中的应用。

第七章, 使用 OpenCL 开发矩阵乘法,通过研究其并行形式并应用从顺序到并行的转换来讨论并行化矩阵乘法。接下来,它将讨论如何通过增加计算吞吐量和预热缓存来优化矩阵乘法。

第八章, 使用 OpenCL 开发稀疏矩阵-向量乘法,讨论了这种计算的背景以及解决它的传统方法,即通过足够的数学运算使用共轭梯度。一旦这种直觉得到发展,读者将了解各种稀疏矩阵的存储格式如何影响并行计算,然后读者可以检查 ELLPACK、ELLPACK-R、COO 和 CSR。

第九章, 使用 OpenCL 开发 Bitonic 排序,将向读者介绍排序算法的世界,并专注于并行排序网络,也称为 Bitonic 排序。本章通过展示理论及其顺序实现,从转换中提取并行性,然后开发最终的并行版本,来介绍这些配方,就像我们在所有其他章节中所做的那样。

第十章, 使用 OpenCL 开发基数排序,将介绍一个非比较排序算法的经典示例,例如 QuickSort,它更适合 GPU 架构。读者还将被介绍到另一种称为归约的核心并行编程技术,我们开发了归约如何帮助基数排序表现更好的直觉。基数排序配方还展示了多个内核编程,并突出了其优点和缺点。

你需要这本书的以下内容

您需要在一个 Linux 环境中感到舒适,因为示例是在 Ubuntu 12.10 64 位操作系统上测试的。以下是一些要求:

  • GNU GCC C/C++ 编译器版本 4.6.1(至少)

  • AMD、Intel 和 NVIDIA 的 OpenCL 1.2 SDK

  • AMD APP SDK 版本 2.8 与 AMD Catalyst Linux 显示驱动程序版本 13.4

  • Intel OpenCL SDK 2012

  • CMake 版本 2.8(至少)

  • Clang 版本 3.1(至少)

  • 如果你在 Windows 上工作,则需要 Microsoft Visual C++ 2010

  • Boost 库版本 1.53

  • VexCL(由 Denis Demidov 编写)

  • AMD 的 CodeXL Profiler(可选)

  • 至少需要八小时的睡眠

  • 一个开放和接受的心态

  • 一杯新鲜煮的咖啡或任何能起作用的东西

这本书是为谁而写的

这本书是为那些经常想知道除了玩电脑游戏之外,如何使用他们新购买的 CPU 或 GPU 的软件开发者而编写的。话虽如此,这本书并不是关于那些只能在你的家用工作站上运行的玩具算法。这本书非常适合那些对 C/C++ 有一定了解并希望学习如何编写在 OpenCL 异构计算环境中执行的并行程序的开发者。

约定

在这本书中,你会发现许多不同风格的文本,用于区分不同类型的信息。以下是一些这些风格的示例及其含义的解释。

文本中的代码单词如下显示:“我们可以通过使用 #include 指令来包含其他上下文。”

代码块如下设置:

[default]
cl_uint sortOrder = 0; // descending order else 1 for ascending order
        cl_uint stages = 0;
        for(unsigned int i = LENGTH; i > 1; i >>= 1)
            ++stages;
        clSetKernelArg(kernel, 0, sizeof(cl_mem),(void*)&device_A_in);
        clSetKernelArg(kernel, 3, sizeof(cl_uint),(void*)&sortOrder);
#ifdef USE_SHARED_MEM
        clSetKernelArg(kernel, 4, (GROUP_SIZE << 1) *sizeof(cl_uint),NULL);
#elif def USE_SHARED_MEM_2

当我们希望将您的注意力引到代码块的一个特定部分时,相关的行或项目将以粗体显示:

[default]
cl_uint sortOrder = 0; // descending order else 1 for ascending order
        cl_uint stages = 0;
        for(unsigned int i = LENGTH; i > 1; i >>= 1)
            ++stages;
        clSetKernelArg(kernel, 0, sizeof(cl_mem),(void*)&device_A_in);
        clSetKernelArg(kernel, 3, sizeof(cl_uint),(void*)&sortOrder);
#ifdef USE_SHARED_MEM
        clSetKernelArg(kernel, 4, (GROUP_SIZE << 1) *sizeof(cl_uint),NULL);
#elif def USE_SHARED_MEM_2

任何命令行输入或输出都应如下编写:

# gcc –Wall test.c –o test

新术语和重要词汇以粗体显示。你在屏幕上看到的单词,例如在菜单或对话框中,在文本中如下显示:“点击下一个按钮将您带到下一屏幕”。

注意

警告或重要注意事项以这种框的形式出现。

小贴士

技巧和窍门以这种方式出现。

读者反馈

我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢什么或可能不喜欢什么。读者反馈对我们开发您真正能从中获得最大收益的标题非常重要。

要向我们发送一般反馈,只需发送一封电子邮件到<feedback@packtpub.com>,并在邮件主题中提及书名。

如果您在某个主题上具有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在您是 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。

下载示例代码

您可以从www.packtpub.com的账户下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

错误清单

尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然会发生。如果您在我们的某本书中找到一个错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进此书的后续版本。如果您发现任何错误清单,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击错误清单提交表单链接,并输入您的错误清单详情来报告它们。一旦您的错误清单得到验证,您的提交将被接受,错误清单将被上传到我们的网站,或添加到该标题的错误清单部分。任何现有的错误清单都可以通过从www.packtpub.com/support中选择您的标题来查看。

盗版

互联网上版权材料的盗版是一个持续存在的问题,所有媒体都存在。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以追究补救措施。

请通过<copyright@packtpub.com>与我们联系,并提供涉嫌盗版材料的链接。

我们感谢您在保护我们作者方面的帮助,以及我们为您提供有价值内容的能力。

问题

如果您在本书的任何方面遇到问题,可以通过<questions@packtpub.com>与我们联系,我们将尽力解决。

第一章. 使用 OpenCL

在本章中,我们将涵盖以下食谱:

  • 查询 OpenCL 平台

  • 查询您平台上的 OpenCL 设备

  • 查询 OpenCL 设备扩展

  • 查询 OpenCL 上下文

  • 查询 OpenCL 程序

  • 创建 OpenCL 内核

  • 创建命令队列并将 OpenCL 内核入队

简介

让我们回顾一下计算的历史,并从它旨在统一异构设备的软件编程模型的角度来看,为什么 OpenCL 之所以重要。OpenCL 的目标是开发一个免版税标准,用于跨平台的现代处理器的并行编程,这些处理器在个人电脑、服务器和手持/嵌入式设备中都很常见。这项努力由“Khronos Group”发起,并得到了英特尔、ARM、AMD、NVIDIA、QUALCOMM、苹果和其他许多公司的参与。OpenCL 允许软件一次编写,然后在其支持的设备上执行。这种方式类似于 Java,这有好处,因为现在这些设备上的软件开发有了统一的方法,OpenCL 通过暴露各种数据结构来实现这一点,这些结构通过 应用程序可编程接口APIs)与硬件交互。今天,OpenCL 支持包括 x86s、ARM 和 PowerPC 的 CPU,以及 AMD、Intel 和 NVIDIA 的 GPU。

开发者肯定能理解我们需要开发跨平台兼容的软件这一事实,因为它允许开发者在他们感到舒适的任何平台上开发应用程序,更不用说它提供了一个连贯的模型,我们可以将我们的想法表达成可以在支持此标准的任何设备上执行的程序。然而,跨平台兼容性也意味着异构环境的存在,并且相当长一段时间以来,开发者不得不学习和应对为这些设备编写软件时出现的问题,这些问题从执行模型到内存系统都有涉及。从在那些异构设备上开发软件中通常出现的另一个任务是,开发者还被期望从它们中表达和提取并行性。在 OpenCL 出现之前,我们知道为了处理在它们执行的设备上表达并行性的方面(例如,Fortran、OpenMP、MPI、VHDL、Verilog、Cilk、Intel TBB、统一并行 C、Java 等),已经发明了各种编程语言及其哲学。但这些工具是为同质环境设计的,尽管开发者可能认为这对他们有利,因为它为他们简历增添了相当多的专业知识。退一步再看,我们发现没有统一的方法来在异构环境中表达并行性。我们不必提及开发者在这些技术中需要多少时间才能变得高效,因为并行分解通常是一个复杂的过程,因为它在很大程度上依赖于硬件。更糟糕的是,许多开发者只需要处理同质计算环境,但过去几年,对异构计算环境的需求增长了。

对异构设备的需求部分是由于对高性能和高度反应性系统的需求,而“功耗墙”的存在使得提高更多性能的一种可能方式是在其中添加专门的处理器单元,以期从中提取每一盎司的并行性,因为这是达到能效的唯一途径。这种向混合计算转变的主要动机可以追溯到名为Optimizing power using Transformations的研究,该研究由Anantha P. Chandrakasan领导。它得出了一个基本结论,即多核芯片(其运行频率略低于当代 CPU)实际上更节能。没有统一开发方法(例如 OpenCL)的异构计算的问题在于,开发者需要掌握几种类型的 ISA,以及与之相关的各种并行级别及其内存系统。NVIDIA 开发的 CUDA GPGPU 计算工具包值得提及,不仅因为它与 OpenCL 有显著的相似性,而且还因为该工具包在学术界和工业界都有广泛的应用。不幸的是,CUDA 只能驱动 NVIDIA 的 GPU。

从异构环境中提取并行性的能力非常重要,因为计算应该是并行的,否则就会违背 OpenCL 的整个目的。幸运的是,主要的处理器公司都是 Khronos 集团领导的联盟的一部分,并且通过这些组织积极实现这一标准。不幸的是,故事还没有结束,但好事是,我们,开发者,意识到有必要理解并行性以及它在同构和异构环境中的工作方式。OpenCL 的设计初衷就是为了在异构环境中表达并行性。

很长一段时间以来,开发者们很大程度上忽视了他们的软件需要利用他们可用的多核机器的事实,并且继续在单线程环境中开发他们的软件,但这种情况正在改变(如前所述)。在多核世界中,开发者需要处理并发性的概念,而并发的优势在于,当有效使用时,它通过为其他进程提供进展而使一些进程停滞,从而最大化资源利用率。

当软件与多个处理元素同时执行,使得线程可以同时运行时,我们称之为并行计算。开发者面临的挑战是发现这种并发性并实现它。在 OpenCL 中,我们专注于两种并行编程模型:任务并行和数据并行。

任务并行意味着开发者可以创建和操作并发任务。当开发者为 OpenCL 开发解决方案时,他们需要将问题分解成不同的任务,其中一些任务可以并发运行,并且正是这些任务被映射到并行环境的处理元素PEs)上以执行。另一方面,有些任务不能并发运行,甚至可能是相互依赖的。另一个复杂性是数据可以在任务之间共享。

当尝试实现数据并行时,开发者需要调整他们对数据的思考方式以及它们如何可以并发读取和更新。在并行计算中常见的一个问题是计算给定任意值数组的所有元素的总和,而存储中间求和值和一种可能的实现方式如图所示,其中应用的运算符,即简介,是任何二元结合运算符。从概念上讲,开发者可以使用一个任务来执行输入中两个元素的加法以得到求和值。

简介

开发者是否选择体现任务/数据并行取决于问题,一个任务并行有意义的例子是通过遍历图。无论开发者更倾向于哪种模型,当他们开始通过 OpenCL 将程序映射到硬件时,都会遇到自己的一套问题。在 OpenCL 出现之前,开发者需要开发一个将在所需设备上执行的模块,并与驱动程序进行通信和 I/O。一个例子是图形渲染程序,其中 CPU 初始化数据并设置一切,然后在将渲染任务卸载到 GPU 之前。OpenCL 被设计用来利用所有检测到的设备,从而最大化资源利用率,因此在这方面它与“传统”的软件开发方式不同。

既然我们已经对 OpenCL 有了良好的理解,我们应该花些时间了解开发者如何学习它。而且不用担心,因为你在开始每一个项目时,OpenCL 都会要求你理解以下内容:

  • 发现你正在为开发的异构系统的组成

  • 通过探测来了解那些设备的属性

  • 使用任务并行或数据并行中的任何一个或所有来启动并行程序分解,通过将它们表达为在平台上运行的指令,也称为内核

  • 设置计算所需的数据结构

  • 操作计算所需的内存对象

  • 在适当的设备上按所需顺序执行内核

  • 汇总结果并验证其正确性

接下来,我们需要通过更深入地研究 OpenCL 的各个组成部分来巩固前面的观点。以下组件共同构成了 OpenCL 架构:

  • 平台模型:平台实际上是一个连接到一个或多个 OpenCL 设备的宿主。每个设备可能包含多个计算单元CUs),这些单元可以分解为一个或多个处理元素,计算将在处理元素上运行。

  • 执行模型:OpenCL 程序的执行是这样的,宿主程序将在宿主上执行,并且是宿主程序将内核发送到该平台上的一个或多个 OpenCL 设备上执行。

    当内核提交执行时,定义了一个索引空间,以便为该空间中的每个点实例化一个工作项。工作项将通过其全局 ID 来识别,并执行内核中表达的同一段代码。工作项被分组到工作组中,每个工作组都有一个 ID,通常称为其工作组 ID,并且是工作组的工作项在单个 CU 的 PE 上并发执行。

    我们之前提到的索引空间被称为 NDRange,它描述了一个 N 维空间,其中 N 的范围可以从一到三。当工作项分组到工作组中时,每个工作项都有一个全局 ID 和一个局部 ID,这是与其他工作项不同的,并且是从 NDRange 派生出来的。工作组 ID 也是如此。让我们用一个简单的例子来说明它们是如何工作的。

    给定两个各有 1024 个元素的数组 A 和 B,我们想要执行向量乘法,也称为点积,其中 A 的每个元素将与 B 中相应的元素相乘。内核代码看起来可能如下所示:

    __kernel void vector_multiplication(__global int* a, 
                                        __global int* b,
                                        __global int* c) {
    int threadId = get_global_id(0); // OpenCL function
    c[i] = a[i] * b[i];
    }
    

    在这个场景中,让我们假设我们有 1024 个处理元素,我们将分配一个工作项来执行精确的一次乘法,在这种情况下,我们的工作组 ID 将是零(因为只有一个组)并且工作项 ID 的范围从{0 … 1023}。回想一下我们之前讨论的内容,是工作组的工作项可以在 PE 上执行。因此,回顾一下,这不是利用设备的好方法。

    在这个相同的场景中,让我们放弃之前的假设,采用这个:我们仍然有 1024 个元素,但我们把四个工作项分成一组,因此我们将有 256 个工作组,每个工作组的 ID 范围从{0 … 255},但需要注意的是,工作项的全局 ID 仍然会从{0 … 1023}的范围,仅仅因为我们没有增加要处理元素的数量。这种将工作项分组到工作组的方式是为了在这些设备中实现可伸缩性,因为它通过确保所有 PE 都有工作可做来提高执行效率。

    NDRange 可以从概念上映射到一个 N 维网格,以下图表说明了 2DRange 的工作原理,其中 WG-X 表示特定工作组的行长度,WG-Y 表示工作组的列长度,以及工作项是如何分组,包括它们在工作组中的相应 ID。

    简介

    在内核在设备(们)上执行之前,主程序扮演着重要的角色,那就是与底层设备建立上下文并安排任务的执行顺序。主程序通过建立以下内容的实体来创建上下文(如果需要的话):

    • 主程序将要使用的所有设备

    • OpenCL 内核,即将在这些设备上运行的函数及其抽象

    • 封装了 OpenCL 内核将要使用/共享的数据的内存对象。

    • 一旦实现这一点,主机需要创建一个名为命令队列的数据结构,该数据结构将由主机用于协调设备上内核的执行,并将命令发布到该队列并调度到设备上。命令队列可以接受:内核执行命令、内存传输命令和同步命令。此外,命令队列可以按顺序执行命令,即按照它们被给出的顺序执行,或者可以乱序执行。如果问题被分解为独立的任务,则可以创建多个针对不同设备的命令队列,并将这些任务调度到它们上面,然后 OpenCL 将并发运行它们。

  • 内存模型:到目前为止,我们已经了解了执行模型,现在是时候介绍 OpenCL 规定的内存模型了。回想一下,当内核执行时,实际上是工作项在执行其内核代码的实例。因此,工作项需要从内存中读取和写入数据,每个工作项都可以访问四种类型的内存:全局、常量、局部和私有。这些内存的大小和可访问性各不相同,其中全局内存具有最大的大小,并且对工作项来说最易访问,而私有内存可能是最受限的,因为它对工作项是私有的。常量内存是一个只读内存,其中存储了不可变对象,并且可以与所有工作项共享。局部内存仅对在同一个工作组中执行的所有工作项可用,并且由每个计算单元持有,即 CU 特定的。

    在主机上运行的应用程序使用 OpenCL API 在全局内存中创建内存对象,并将内存命令入队到命令队列以操作它们。主机的责任是确保当内核开始执行时,数据对设备可用,它通过复制数据或映射/取消映射内存对象的部分区域来实现这一点。在典型的从主机内存到设备内存的数据传输过程中,OpenCL 命令被发布到队列,这些队列可能是阻塞的或非阻塞的。阻塞和非阻塞内存传输的主要区别在于,在前者中,函数调用只在队列后返回一次(在入队后被认为是安全的),而在后者中,调用在命令入队后立即返回。

    OpenCL 中的内存映射允许内存空间的一部分可用于计算,这个区域可以是阻塞的或非阻塞的,开发者可以将这个空间视为可读的、可写的或两者兼具。

    从此以后,我们将专注于通过编写小的 OpenCL 程序来掌握 OpenCL 的基础知识,以更深入地了解如何使用 OpenCL 的平台和执行模型。

OpenCL 规范版本 1.2 是一个开放、免版税的标准,适用于从移动设备到传统 CPU,以及最近通过 API 和当时的标准支持的各种设备的通用编程。

  • 基于数据和任务并行编程模型

  • 实现了 ISO C99 的一个子集,并针对并行性添加了一些限制,例如不支持递归、可变参数函数和宏

  • 数学运算符合 IEEE 754 规范

  • 通过建立配置配置文件,可以将程序移植到手持设备和嵌入式设备

  • 与 OpenGL、OpenGL ES 和其他图形 API 的互操作性

在整本书中,我们将向您展示如何精通 OpenCL 编程。

随着您阅读本书,您将发现不仅如何使用 API 对 OpenCL 设备执行各种操作,而且您还将学习如何建模问题,并将其从串行程序转换为并行程序。更常见的是,您将学习的技巧可以转移到其他编程工具集中。

在工具集里,我使用过 OpenCL(TM)、CUDA(TM)、OpenMP(TM)、MPI(TM)、Intel 线程构建块(TM)、Cilk(TM)、CilkPlus^(TM),这允许开发者在一个统一的环境中表达并行性,并将学习工具到应用知识的过程分为四个部分。这四个阶段相当常见,我发现按照这个过程记忆它们非常有帮助。我希望你们也能从中受益。

  • 寻找并发性:程序员在问题域中工作,以识别可用的并发性并将其暴露出来用于算法设计

  • 算法结构:程序员使用高级结构来组织并行算法。

  • 支持结构:这指的是并行程序的组织方式以及用于管理共享数据的技术。

  • 实现机制:最后一步是查看用于实现并行程序的具体软件结构。

不要担心这些概念,随着我们通过本书的进展,它们将被解释。

我们接下来要检查的几个菜谱都与理解 OpenCL API 的使用有关,通过集中精力理解架构的平台模型。

查询 OpenCL 平台

在你开始编码之前,请确保你已经为你要开发的平台安装了适当的 OpenCL 开发工具包。在这个菜谱中,我们将演示如何使用 OpenCL 查询其平台以检索它检测到的兼容设备及其各种属性的信息。

准备工作

在这个第一个 OpenCL 应用程序中,你将能够查询你的计算机上安装的 OpenCL 平台类型。在你的计算机设置中,你可能有一个配置,其中同时安装了 NVIDIA 和 AMD 显卡,在这种情况下,你可能已经安装了 AMD APP SDK 和 NVIDIA 的 OpenCL 工具包。因此,你会安装这两个平台。

以下代码列表是从Ch1/platform_details/platform_details.c中提取的。

如何做…

注意包含的注释,因为它们将帮助你理解每个单独的函数:

#include <stdio.h>
#include <stdlib.h>

#ifdef APPLE
#include <OpenCL/cl.h>
#else
#include <CL/cl.h>
#endif

void displayPlatformInfo(cl_platform_id id,
                         cl_platform_info param_name,
                         const char* paramNameAsStr) {
    cl_int error = 0;
    size_t paramSize = 0;

    error = clGetPlatformInfo( id, param_name, 0, NULL,
                               &paramSize );
    char* moreInfo = (char*)alloca( sizeof(char) * paramSize);
    error = clGetPlatformInfo( id, param_name, paramSize,
                               moreInfo, NULL );
    if (error != CL_SUCCESS ) {
        perror("Unable to find any OpenCL platform
                information");
        return;
    }
    printf("%s: %s\n", paramNameAsStr, moreInfo);
}

int main() {
   /* OpenCL 1.2 data structures */
   cl_platform_id* platforms;
   /* OpenCL 1.1 scalar data types */
   cl_uint numOfPlatforms;
   cl_int  error;

   /* 
      Get the number of platforms 
      Remember that for each vendor's SDK installed on the
      Computer, the number of available platform also
      increased. 
    */
   error = clGetPlatformIDs(0, NULL, &numOfPlatforms);
   if(error < 0) {      
      perror("Unable to find any OpenCL platforms");
      exit(1);
   }
   // Allocate memory for the number of installed platforms.
   // alloca(...) occupies some stack space but is
   // automatically freed on return
   platforms = (cl_platform_id*) alloca(sizeof(cl_platform_id)
               * numOfPlatforms);
   printf("Number of OpenCL platforms found: %d\n",
           numOfPlatforms);

   // We invoke the API 'clPlatformInfo' twice for each
   // parameter we're trying to extract
   // and we use the return value to create temporary data
   // structures (on the stack) to store
   // the returned information on the second invocation.
   for(cl_uint i = 0; i < numOfPlatforms; ++i) {
        displayPlatformInfo( platforms[i], 
                             CL_PLATFORM_PROFILE,
                             "CL_PLATFORM_PROFILE" );

        displayPlatformInfo( platforms[i], 
                             CL_PLATFORM_VERSION,
                             "CL_PLATFORM_VERSION" );

        displayPlatformInfo( platforms[i], 
                             CL_PLATFORM_NAME,   
                             "CL_PLATFORM_NAME" );

        displayPlatformInfo( platforms[i], 
                             CL_PLATFORM_VENDOR, 
                             "CL_PLATFORM_VENDOR" );

        displayPlatformInfo( platforms[i], 
                             CL_PLATFORM_EXTENSIONS,
                             "CL_PLATFORM_EXTENSIONS" );
   }
   return 0;
}

要在 UNIX 平台上编译它,你会运行一个类似于以下编译命令:

gcc –std=c99 –Wall –DUNIX –g –DDEBUG –DAPPLE –arch i386 –o platform_details platform_details.c –framework OpenCL

当这种情况发生时,你将有一个名为platform_details的二进制可执行文件。

要运行程序,只需执行platform_details程序,一个示例输出将是 OSX:

Number of OpenCL platforms found: 1
CL_PLATFORM_PROFILE: FULL_PROFILE
CL_PLATFORM_VERSION: OpenCL 1.0 (Dec 23 2010 17:30:26)
CL_PLATFORM_NAME: Apple
CL_PLATFORM_VENDOR: Apple
CL_PLATFORM_EXTENSIONS:

它是如何工作的…

当你刚开始学习编程 OpenCL 时,这可能是一项艰巨的任务,但随着我们的进展,它确实会变得更好。所以,让我们解析我们刚刚看到的源代码。文件是一个 C 源文件,你会注意到它被组织得如此之好,以至于系统头文件几乎总是放在顶部附近:

| #include <stdlib.h>
| #include <stdio.h> 

接下来是 C 程序员所说的平台相关代码:

| #ifdef APPLE
| #include <OpenCL/cl.h>
| #else
| #include <CL/cl.h>
| #endif

OpenCL 头文件对于程序编译是必需的,因为它们包含方法签名。现在,我们将尝试理解其余代码的功能。在 OpenCL 中,一个代码约定是数据类型以cl_为前缀,你可以找到每个平台、设备和上下文的数据类型,分别以cl_platform_XXcl_device_XXcl_context_XX表示,API 以类似的方式以cl为前缀,其中一个这样的 API 是clGetPlatformInfo

在 OpenCL 中,API 并不假设你知道当你编写 OpenCL 代码时确切有多少资源(例如平台、设备和上下文)存在或需要。为了编写可移植的代码,语言的开发者想出了一个巧妙的方法来呈现 API,这样你可以使用相同的 API 提出一个一般性问题,并根据该问题的结果,通过相同的 API 请求更多信息。让我用一个例子来说明。

在代码中,你会注意到clGetPlatformInfo()被调用了两次。第一次调用是为了查询机器上安装的平台数量。根据查询结果,我们再次调用了clGetPlatformInfo,但这次我们传递了上下文相关的信息,例如,获取供应商的名称。当你用 OpenCL 编程时,你会发现这种模式经常出现,我可以想到的缺点是它有时会使 API 变得相当晦涩难懂,但它的好处是它防止了 API 在语言中的过度扩散。

老实说,这在整个 OpenCL 编程生态系统中相当简单,但后续章节将展示如何将顺序代码转换为 OpenCL 中的并行代码。

接下来,让我们在代码的基础上构建,并查询 OpenCL 以获取连接到平台上的设备。

查询平台上的 OpenCL 设备

我们现在将查询安装在你平台上的 OpenCL 设备。

准备中

如何做…部分讨论的代码列表展示了Ch1/device_details/device_details.c中的代码的简略部分。这段代码演示了你可以如何通过clGetDeviceIDs获取你平台上安装的设备类型。你将使用这些信息通过传递给clGetDeviceInfo来检索有关设备的详细信息。

如何做…

对于这个菜谱,你需要完全参考相应的章节代码。注意包含的注释,因为它们将帮助你理解每个单独的函数。我们已包括带有突出注释的菜谱的主要部分:

/* C-function prototype */
void displayDeviceDetails(cl_device_id id, cl_device_info param_name, const char* paramNameAsStr) ; 

…
void displayDeviceInfo(cl_platform_id id, 
                       cl_device_type dev_type) {
    /* OpenCL 1.1 device types */

    cl_int error = 0;
    cl_uint numOfDevices = 0;

    /* Determine how many devices are connected to your
       platform */
    error = clGetDeviceIDs(id, dev_type, 0, NULL,
                           &numOfDevices);
    if (error != CL_SUCCESS ) { 
        perror("Unable to obtain any OpenCL compliant device
                info");
        exit(1);
    }

    cl_device_id* devices = (cl_device_id*)
                  alloca(sizeof(cl_device_id) * numOfDevices);

    /* Load the information about your devices into the 
       variable 'devices'
    */
    error = clGetDeviceIDs(id, dev_type, numOfDevices, devices,
                           NULL);
    if (error != CL_SUCCESS ) { 
        perror("Unable to obtain any OpenCL compliant device
                info");
        exit(1);
    }

    printf("Number of detected OpenCL devices:
            %d\n",numOfDevices);

    /* 
      We attempt to retrieve some information about the
      devices. 
    */

    for(int i = 0; i < numOfDevices; ++ i ) {
        displayDeviceDetails( devices[i], CL_DEVICE_TYPE, "CL_DEVICE_TYPE" );
        displayDeviceDetails( devices[i], CL_DEVICE_VENDOR_ID, "CL_DEVICE_VENDOR_ID" );
        displayDeviceDetails( devices[i], CL_DEVICE_MAX_COMPUTE_UNITS, "CL_DEVICE_MAX_COMPUTE_UNITS" );
        displayDeviceDetails( devices[i], CL_DEVICE_MAX_WORK_ITEM_DIMENSIONS, "CL_DEVICE_MAX_WORK_ITEM_DIMENSIONS" );
        displayDeviceDetails( devices[i], CL_DEVICE_MAX_WORK_ITEM_SIZES, "CL_DEVICE_MAX_WORK_ITEM_SIZES" );
        displayDeviceDetails( devices[i], CL_DEVICE_MAX_WORK_GROUP_SIZE, "CL_DEVICE_MAX_WORK_GROUP_SIZE" );
    }
}

void displayDeviceDetails(cl_device_id id,
                          cl_device_info param_name, 
                          const char* paramNameAsStr) {
  cl_int error = 0;
  size_t paramSize = 0;

  error = clGetDeviceInfo( id, param_name, 0, NULL, &paramSize );
  if (error != CL_SUCCESS ) {
    perror("Unable to obtain device info for param\n");
    return;
  }

  /* 
    The cl_device_info are preprocessor directives defined in cl.h
  */

  switch (param_name) {
    case CL_DEVICE_TYPE: {
            cl_device_type* devType = (cl_device_type*)
                        alloca(sizeof(cl_device_type) * paramSize);
            error = clGetDeviceInfo( id, param_name, paramSize, devType, NULL );

            if (error != CL_SUCCESS ) {
                perror("Unable to obtain device info for param\n");
                return;
            }

            switch (*devType) {
              case CL_DEVICE_TYPE_CPU : 
                   printf("CPU detected\n");break;
              case CL_DEVICE_TYPE_GPU : 
                   printf("GPU detected\n");break;
              case CL_DEVICE_TYPE_DEFAULT : 
                   printf("default detected\n");break;
            }
            }break;

     // omitted code – refer to source "device_details.c"
   } //end of switch
}

在 UNIX 平台上,你可以在终端运行以下命令来编译device_details.c

gcc -std=c99 -Wall -DUNIX -g -DDEBUG -DAPPLE -arch i386 -o device_details device_details.c   -framework OpenCL

并且一个名为device_details的二进制可执行文件应该被本地存储在你的机器上。

当你在机器上执行依赖于你机器设置的二进制可执行文件时,你会看到不同的结果。但在我这里的 OSX 平台上,当在配备英特尔酷睿 i5 处理器和 NVIDIA 移动 GPU GT330m(扩展被突出显示)的机器上执行时,这是输出结果:

Number of OpenCL platforms found: 1
CL_PLATFORM_PROFILE: FULL_PROFILE
CL_PLATFORM_VERSION: OpenCL 1.0 (Dec 23 2010 17:30:26)
CL_PLATFORM_NAME: Apple
CL_PLATFORM_VENDOR: Apple
CL_PLATFORM_EXTENSIONS: 
Number of detected OpenCL devices: 2
GPU detected
  VENDOR ID: 0x1022600
  Maximum number of parallel compute units: 6
  Maximum dimensions for global/local work-item IDs: 3
  Maximum number of work-items in each dimension: 512
  Maximum number of work-items in a work-group: 512
CPU detected
  VENDOR ID: 0x1020400
  Maximum number of parallel compute units: 4
  Maximum dimensions for global/local work-item IDs: 3
  Maximum number of work-items in each dimension: 1
  Maximum number of work-items in a work-group: 1

如果信息现在看起来没有意义,请不要过于担心,后续章节将揭示一切。

它是如何工作的…

利用我们在上一节中完成的工作,我们现在已经通过 clGetPlatformInfo 使用了平台,该平台被检测到以查询连接的设备。这次,我们使用了新的 API 函数 clGetDeviceIDsclGetDeviceInfo。前者试图揭示连接到给定平台的所有设备的基本信息,我们使用 clGetDeviceInfo 来遍历结果以了解它们的更多功能。当您在构建算法时,这些信息非常有价值,并且不确定它将在哪个设备上运行。考虑到 OpenCL 支持各种处理器,这是一种编写可移植代码的好方法。

实际上,您可以从您的设备中获取更多信息,我强烈建议您参考 www.khronos.org/registry/cl/sdk/2.0/docs/man/xhtml/ 并查看 clGetDeviceInfo 的主页。

现在我们已经了解了如何查询平台和连接的设备,我们应该看看如何查询 OpenCL 扩展。扩展允许供应商定义与 OpenCL 兼容设备一起提供的附加功能,这反过来又允许您,作为程序员,利用它们。

查询 OpenCL 设备扩展

OpenCL 中的扩展允许程序员利用设备供应商提供的额外功能,因此它们是可选的。然而,有一些扩展被 OpenCL 识别,并且据说被主要供应商支持。

这里是 OpenCL 1.2 中批准和支持的扩展的部分列表。如果您想发现 OpenCL 采用者公开的整个扩展列表(其中一些在表中给出),请通过此链接查看 PDF 文档:www.khronos.org/registry/cl/specs/opencl-1.2-extensions.pdf

扩展名称 描述
cl_khr_fp64 此表达式提供双精度浮点数
cl_khr_int64_base_atomics 此表达式提供 64 位整数基本原子操作,提供加法、减法、交换、增量/减量以及 CAS 的原子操作
cl_khr_int64_extended_atomics 此表达式提供 64 位整数扩展原子操作,提供查找最小值、最大值以及布尔操作(如与、或、异或)的原子操作
cl_khr_3d_image_writes 此表达式写入 3D 图像对象
cl_khr_fp16 此表达式提供半精度浮点数
cl_khr_global_int32_base_atomics 此表达式提供 32 位操作数的原子操作
cl_khr_global_int32_extended_atomics 此表达式为 32 位操作数提供更多原子功能
cl_khr_local_int32_base_atomics 此表达式提供共享内存空间中 32 位操作数的原子操作
cl_khr_local_int32_extended_atomics 这个表达式为共享内存空间中的 32 位操作数提供了更多的原子功能
cl_khr_byte_addressable_store 这个表达式允许对小于 32 位字节的内存进行写入
cl_APPLE_gl_sharing 这个表达式提供了 MacOSX OpenGL 共享,并允许应用程序将 OpenGL 缓冲区、纹理和渲染缓冲区对象用作 OpenCL 内存对象
cl_khr_gl_sharing 这个表达式提供了 OpenGL 共享
cl_khr_gl_event 这个表达式从 GL 同步对象中检索 CL 事件对象
cl_khr_d3d10_sharing 这个表达式与 Direct3D 10 共享内存对象

接下来,让我们找出如何通过利用我们之前所做的工作来确定你的平台支持哪些扩展以及它们是否可用。

准备工作

下面的列表仅显示了Ch1/device_extensions/device_extensions.c中找到的有趣代码部分。各种符合 OpenCL 标准的设备将具有不同的功能,在你开发应用程序的过程中,你肯定想在使用它们之前确保某些扩展是可用的。本配方中“如何做…”部分讨论的代码展示了如何检索这些扩展。

如何做…

我们已经包含了主要的查询函数,它允许你实现这个特定的配方:

void displayDeviceDetails(cl_device_id id,
                          cl_device_info param_name, 
                          const char* paramNameAsStr) {

  cl_int error = 0;
  size_t paramSize = 0;

  error = clGetDeviceInfo( id, param_name, 0, NULL, &paramSize );
  if (error != CL_SUCCESS ) {
    perror("Unable to obtain device info for param\n");
    return;
  }
  /* the cl_device_info are preprocessor directives defined in cl.h
  */
  switch (param_name) {
    // code omitted – refer to "device_extensions.c"
    case CL_DEVICE_EXTENSIONS : {
  size_t* ret = (size_t*) alloc(sizeof(size_t) * paramSize);
           error = clGetDeviceInfo( id, param_name, paramSize, ret, NULL );
           char* extension_info = (char*)malloc(sizeof(char) * (*ret));
           error = clGetDeviceInfo( id, CL_DEVICE_EXTENSIONS, sizeof(extension_info), extension_info, NULL);
           printf("\tSupported extensions: %s\n",
                  extension_info);
           }break;
  } //end of switch
}

要编译代码,就像你之前在终端上运行类似命令一样:

gcc -std=c99 -Wall -DUNIX -g -DDEBUG -DAPPLE -arch i386 -o device_extensions device_extensions.c   -framework OpenCL

在 UNIX 平台上,当在英特尔酷睿 i5 处理器上使用 NVIDIA 移动 GPU GT330m 执行时,我们得到了以下结果(扩展被突出显示):

Number of OpenCL platforms found: 1
CL_PLATFORM_PROFILE: FULL_PROFILE
CL_PLATFORM_VERSION: OpenCL 1.0 (Dec 23 2010 17:30:26)
CL_PLATFORM_NAME: Apple
CL_PLATFORM_VENDOR: Apple
CL_PLATFORM_EXTENSIONS: 
Number of detected OpenCL devices: 2
GPU detected
  VENDOR ID: 0x1022600
  Maximum number of parallel compute units: 6
  Maximum dimensions for global/local work-item IDs: 3
  Maximum number of work-items in each dimension: ( 512 512 64  )
  Maximum number of work-items in a work-group: 512
  Supported extensions: cl_khr_byte_addressable_store cl_khr_global_int32_base_atomics cl_khr_global_int32_extended_atomics cl_APPLE_gl_sharing cl_APPLE_SetMemObjectDestructor cl_APPLE_ContextLoggingFunctions cl_khr_local_int32_base_atomics cl_khr_local_int32_extended_atomics 
CPU detected
  VENDOR ID: 0x1020400
  Maximum number of parallel compute units: 4
  Maximum dimensions for global/local work-item IDs: 3
  Maximum number of work-items in each dimension: ( 1 1 1  )
  Maximum number of work-items in a work-group: 1
  Supported extensions: cl_khr_fp64 cl_khr_global_int32_base_atomics cl_khr_global_int32_extended_atomics cl_khr_local_int32_base_atomics cl_khr_local_int32_extended_atomics cl_khr_byte_addressable_store cl_APPLE_gl_sharing cl_APPLE_SetMemObjectDestructor cl_APPLE_ContextLoggingFunctions

它是如何工作的…

当我们检查我们刚刚所做的工作时,我们只是利用现有的代码,并在需要的地方添加了所需的功能,即通过添加代码来处理CL_DEVICE_EXTENSIONS被传递的情况。我们在栈上创建了一个固定大小的数组,并将其传递给clGetDeviceInfo,在那里 API 最终将信息存储到数组中。提取信息就像打印出数组一样简单。对于高级使用,你可能希望将那些信息存储到全局表结构中,以便应用程序的其他部分可以使用它。

要了解这些扩展的含义以及如何利用它们,我建议你参考 Khronos 注册的 OpenCL:www.khronos.org/registry/cl/

我们不会过多地停留在迄今为止我们所看到的每个扩展上。让我们继续了解 OpenCL 上下文。

查询 OpenCL 上下文

OpenCL 上下文是通过一个或多个设备创建的。上下文被 OpenCL 运行时用于管理对象,例如命令队列(允许你向设备发送命令的对象)、内存、程序和内核对象,以及执行上下文中指定的一个或多个设备上的内核。

更详细地说,OpenCL 上下文可以通过通过 clCreateContext 将可用的设备集合关联到平台,或者通过通过 clCreateContextFromType 将其与特定类型的设备关联,例如 CPU、GPU 等,来创建。然而,无论哪种方式,你都不能创建与多个平台关联的上下文。让我们以 简介 部分中的向量乘法为例来演示这些概念。向量乘法或点积问题可以使用:笔和纸、CPU、GPU 或 GPU + CPU 来解决。显然,当我们有超过 20 个元素时,第一种方法并不太适用,而使用 OpenCL 你有更多的选择。你需要决定的第一件事是它应该在哪个平台上运行,在 OpenCL 中这意味着决定是否使用 AMD、NVIDIA、Intel 等等。接下来要决定的是是否要在该平台列出的所有设备上运行点积,或者只运行其中的一部分。

因此,让我们假设平台报告了一个英特尔酷睿 i7 和 3 个 AMD GPU,并且开发者可以使用 clCreateContextFromType 来限制执行到 CPU 或 GPU,但是当你使用 clCreateContext 时,你可以列出所有四个要执行的设备,从理论上讲(然而,在实践中,很难有效地使用所有 CPU 和 GPU,因为 GPU 可以推送比 CPU 更多的线程来执行)。以下图表说明了开发者创建上下文时可供选择的选项,假设主机环境安装了英特尔和 AMD 的 OpenCL 平台软件。当考虑到包括 HD 图形协处理器在内的 Ivy Bridge 英特尔处理器时,配置会变得更有趣,该协处理器允许上下文同时识别 CPU 和 GPU。

查询 OpenCL 上下文

上下文还有一个有趣的属性,即它保留了一个引用计数,这样第三方库就可以引用它并因此利用设备。例如,如果你的设备上提供了 cl_khr_d3d10_sharing 扩展,你实际上可以在 OpenCL 和 Direct3D 10 之间进行互操作,并将 Direct3D 10 资源类似于内存对象作为你可以从中读取或写入的 OpenCL 内存对象。然而,我们不会在这个书中用这个扩展来演示功能,而是将其留给读者去进一步探索。

准备工作

如何做… 部分给出的代码列表是从 Ch1/context_query/context_details.c 中提取的,它说明了如何创建和释放 OpenCL 上下文。

如何做…

要查询 OpenCL 上下文,你需要在你的代码中包含一个类似于以下的功能。你应该参考与此菜谱相关的完整代码列表:

void createAndReleaseContext(cl_platform_id id, 
                             cl_device_type dev_type) {
    /* OpenCL 1.1 scalar types */
    cl_int error = 0;
    cl_uint numOfDevices = 0;

 /* Determine how many devices are connected to your platform */
    error = clGetDeviceIDs(id, dev_type, 0, NULL, &numOfDevices);
    if (error != CL_SUCCESS ) { 
        perror("Unable to obtain any OpenCL compliant device info");
        exit(1);
    }
    cl_device_id* devices = (cl_device_id*)
                     alloca(sizeof(cl_device_id) * numOfDevices);

    /* 
     Load the information about your devices into the variable
     'devices'
    */

    error = clGetDeviceIDs(id, dev_type, numOfDevices, devices, NULL);
    if (error != CL_SUCCESS ) { 
        perror("Unable to obtain any OpenCL compliant device info");
        exit(1);
    }

    printf("Number of detected OpenCL devices: %d\n",
            numOfDevices);

    /* 
       We attempt to create contexts for each device we find,
       report it and release the context. Once a context is
       created, its context is implicitly
       retained and so you don't have to invoke
      'clRetainContext'
     */

    for(int i = 0; i < numOfDevices; ++ i ) {
        cl_context context = clCreateContext(NULL, 1,
                                             &devices[i],
                                             NULL, NULL,
                                             &error); 
        cl_uint ref_cnt = 0;
        if (error != CL_SUCCESS) {
            perror("Can't create a context");
            exit(1);
        }

        error = clGetContextInfo(context,
                                 CL_CONTEXT_REFERENCE_COUNT,
                                 sizeof(ref_cnt), &ref_cnt,
                                 NULL);

        if (error != CL_SUCCESS) {
            perror("Can't obtain context information");
            exit(1);
        }
        printf("Reference count of device is %d\n", ref_cnt);
        // Release the context
        clReleaseContext(context);
    }
}

在 UNIX 平台上,你可以通过输入以下命令来编译和构建程序

gcc -std=c99 -Wall -DUNIX -g -DDEBUG -DAPPLE -arch i386 -o context_details context_details.c   -framework OpenCL

在测试机器上,我们有两个符合 OpenCL 规范的设备。第一个是英特尔酷睿 i5 CPU,第二个是 NVIDIA 移动 GT330m GPU。以下是其输出:

Number of OpenCL platforms found: 1
Number of detected OpenCL devices: 2
Reference count of device is 1
Reference count of device is 1

它是如何工作的…

如果你一直在跟随这本书学习,你应该意识到我们并没有做任何特别的事情,只是利用了之前的练习,发现了已安装的平台类型,并据此发现了设备和最终使用这些信息来创建相关上下文。最后,有了这些相关上下文,我们就可以查询它们。你会注意到,在两种情况下上下文的引用计数都是一,这表明当前有一个内存对象正在引用它,而我们传递的 CL_CONTEXT_REFERENCE_COUNT 也反映了这一点。这个计数器只有在你想检测应用程序是否经历上下文泄露(实际上意味着内存泄露)时才有用。对于 CPU 或 GPU 这样的 OpenCL 设备,这个问题可能并不严重。但对于移动处理器来说,它可能会造成相当严重的问题,因为内存泄露通常浪费资源,并最终耗尽电池寿命。

实际上,你还可以通过传递各种 cl_context_info 类型来使用 clGetContextInfo 查询更多细节。以下是一个列表:

cl_context_info 返回类型 param_name 中返回的信息
CL_CONTEXT_REFERENCE_COUNT cl_uint 这个变量返回上下文的引用计数
CL_CONTEXT_NUM_DEVICES cl_uint 这个变量返回上下文中的设备数量
CL_CONTEXT_DEVICES cl_device_id[] 这个变量返回上下文中的设备列表
CL_CONTEXT_PROPERTIES cl_context_properties 这个变量返回在 clCreateContextclCreateContextFromType 中指定的属性参数

现在我们已经了解了查询平台、设备、扩展和上下文的基本知识,我认为是时候看看 OpenCL 内核以及如何编程它们了。

查询 OpenCL 程序

在 OpenCL 中,内核指的是程序中声明的一个函数。OpenCL 程序由一组内核组成,这些内核是在代码中使用 __kernel 限定符声明的函数。这样的程序封装了一个上下文、程序源或二进制文件以及附加的内核数量。以下几节将解释如何构建 OpenCL 程序,并最终在设备上加载内核以执行。

准备工作

为了运行 OpenCL 内核,你需要有一个程序(源代码或二进制文件)。目前,有两种方式来构建程序:一种是从源文件构建,另一种是通过 clCreateProgramWithSourceclCreateProgramWithBinary 分别从二进制对象构建(名字很聪明)。这两个 API 在成功时返回一个由 OpenCL 类型 cl_program 表示的程序对象。让我们检查方法签名以更好地理解它:

cl_program clCreateProgramWithSource(cl_context context,
                                     cl_uint count,
                                     const char** strings,
                                     const size_t* lengths,
                                     cl_int* errcode_ret)

如果你仔细阅读签名,你会注意到在从源代码构建我们的程序之前,需要先创建 OpenCL 上下文。接下来,stringslengths参数持有各种(内核)文件名及其相应的文件长度,最后一个参数errcode_ret反映了在构建程序时是否存在错误:

cl_program clCreateProgramWithBinary(cl_context context,
                                     cl_uint num_devices,
                                     const cl_device_id* device_list,
                                     const size_t* lengths,
                                     const unsigned char** binaries,
                                     cl_int* binary_status,
                                     cl_int* errcode_ret)

检查签名,你很快就会意识到binarieslengths参数持有程序二进制及其相应长度的指针。所有二进制文件都是通过device_list参数表示的设备加载的,加载到设备上的程序是否成功,反映在binary_status参数中。当二进制是唯一可以暴露给客户或甚至在系统集成测试期间可以使用的工件时,开发者会发现这种程序创建方式很有用。

为了使开发者能够通过使用clCreateProgramWithBinary从离线二进制文件中创建有效的 OpenCL 程序,他首先需要使用平台的编译器生成离线二进制文件,但遗憾的是这个过程是供应商特定的。如果你使用 AMD APP SDK,那么你需要启用cl_amd_offline_devices AMD 扩展,并在创建上下文时传递CL_CONTEXT_OFFLINE_DEVICES_AMD属性。如果你为 Intel 或 Apple OpenCL 平台开发,我们建议你咨询他们网站上的文档。

接下来,我们需要通过调用clBuildProgram并传递从clCreateProgramFromSource创建的cl_program对象来构建程序。在程序创建过程中,开发者可以向它提供额外的编译器选项(就像你在编译 C/C++程序时所做的)。让我们看看在如何做…部分给出的代码示例,该示例摘自Ch1/build_opencl_program/build_opencl_program.c,OpenCL 内核文件列在Ch1/build_opencl_program/{simple.cl, simple_2.cl}中。

如何做…

要查询 OpenCL 程序,你需要在你的代码中包含一个类似于以下的功能。你应该参考随此食谱一起提供的完整代码列表:

int main(int argc, char** argv) {
   // code omitted – refer to "build_opencl_program.c"
   ...
   // Search for a CPU/GPU device through the installed
   // platform. Build a OpenCL program and do not run it.
   for(cl_uint i = 0; i < numOfPlatforms; i++ ) {
       // Get the GPU device
       error = clGetDeviceIDs(platforms[i], 
                              CL_DEVICE_TYPE_GPU, 1,
                              &device, NULL);
       if(error != CL_SUCCESS) {
          // Otherwise, get the CPU
          error = clGetDeviceIDs(platforms[i],
                                 CL_DEVICE_TYPE_CPU,
                                 1, &device, NULL);
       }
        if(error != CL_SUCCESS) {
            perror("Can't locate any OpenCL compliant device");
            exit(1);
        }
        /* Create a context */
        context = clCreateContext(NULL, 1, &device, NULL, NULL,
                                  &error);
        if(error != CL_SUCCESS) {
            perror("Can't create a valid OpenCL context");
            exit(1);
        }

        /* Load the two source files into temporary 
           datastores */
        const char *file_names[] = {"simple.cl",
                                    "simple_2.cl"};
        const int NUMBER_OF_FILES = 2;
        char* buffer[NUMBER_OF_FILES];
        size_t sizes[NUMBER_OF_FILES];
        loadProgramSource(file_names, NUMBER_OF_FILES, buffer,
                          sizes);

        /* Create the OpenCL program object */
        program = clCreateProgramWithSource(context,
                                            NUMBER_OF_FILES,
                                            (const
                                             char**)buffer,
                                            sizes, &error);      
      if(error != CL_SUCCESS) {
        perror("Can't create the OpenCL program object");
        exit(1);   
      }

        /* 
         Build OpenCL program object and dump the error
         message, if any
        */
        char *program_log;
        const char options[] = "-cl-finite-math-only \
                                -cl-no-signed-zeros";  
        size_t log_size;
        error = clBuildProgram(program, 1, &device, options,
                               NULL,NULL);		
        // Uncomment the line below, comment the line above;
        // build the program to use build options dynamically
        // error = clBuildProgram(program, 1, &device, argv[1],
        // NULL, NULL);    

      if(error != CL_SUCCESS) {
        // If there's an error whilst building the program,
            // dump the log
        clGetProgramBuildInfo(program, device,
                                  CL_PROGRAM_BUILD_LOG, 0,
                                  NULL,
                                  &log_size);
        program_log = (char*) malloc(log_size+1);
        program_log[log_size] = '\0';
        clGetProgramBuildInfo(program, device,
                                  CL_PROGRAM_BUILD_LOG, 
                                  log_size+1, program_log,
                                  NULL);
        printf("\n=== ERROR ===\n\n%s\n=============\n",
                   program_log);
        free(program_log);
        exit(1);
      }

        /* Clean up */
        for(i=0; i< NUMBER_OF_FILES; i++) { free(buffer[i]); }
        clReleaseProgram(program);
        clReleaseContext(context);
   }
}

与你之前所做的一样,编译命令不会离得太远:

gcc -std=c99 -Wall -DUNIX -g -DDEBUG -DAPPLE -arch i386 -o build_opencl_program build_opencl_program.c   -framework OpenCL

你会在文件系统中找到名为build_opencl_program的可执行文件。

根据你如何编译程序,有两种运行程序的方式。如果你重新检查之前显示的代码片段,你会注意到编译器选项是在源代码中定义的,因此它是静态的,但还有另一种动态方式可以在编译期间传递编译器选项,以下就是这两种简单的方法:

如果你选择了定义构建选项为静态的选项,即如果你有如下几行:

const char options[] = "-cl-nosigned-zeros –cl-finite-math-only";
error = clBuildProgram(program, 1, &device, options, NULL, NULL);

OpenCL 将简单地根据你提供的构建选项构建程序。这对于随应用程序一起提供的应用程序在不同客户设置上运行时具有一致的结果是非常合适的。

要运行程序,只需单击build_opencl_program可执行文件。

然而,如果你选择了允许用户传入他们选择的选项的其他选项(很大程度上取决于你的算法设计),也就是说,如果你有类似这样的东西:

error = clBuildProgram(program, 1, &device, argv[1], NULL, NULL);

代替选项,我们有字符串指针数组,传统上用于通过命令行(对 C 程序员来说方便地称为argv)传递程序参数,然后你将允许用户传入多个构建选项。

要运行程序,你会输入一个类似于以下的命令,其中引用了(用引号括起来)你希望通过–D传递给程序的多个选项:

./build_opencl_program -D"-cl-finite-math-only -cl-no-signed-zeros"

它是如何工作的…

本节中的代码示例比我们迄今为止所做的工作要复杂一些。我们所做的是构建一个 OpenCL 程序,包含两个文件:simple.clsimple_2.cl,这两个文件通过这个(较早的)代码片段包含两个简单的 OpenCL 内核。

const char *file_names[] = {"simple.cl",
                            "simple_2.cl"};

我们展示了如何创建必要的两个变量buffersizes来存储两个文件的内容及其程序长度。

接下来,我们展示了如何使用clCreateProgramWithSource返回的cl_program对象构建 OpenCL 程序,并使用预定义或用户定义的构建选项。我们还学习了如何使用clGetProgramInfo查询程序对象的构建结果。此外,主机应用程序还具有从该过程中转储任何构建错误的能力。

最后,我们以创建顺序的相反顺序释放了与程序和上下文相关的数据结构。在 OpenCL 1.2 中,你可以以另一种方式构建 OpenCL 程序对象,但你需要使用这两个新的 API:clCompileProgramclLinkProgram。它们的背后原理是便于分离、编译和链接。

构建选项值得在这里进一步提及,因为通常有四组选项可供 OpenCL 程序员使用。以下信息供参考。

当你希望构建 OpenCL 程序时,通常有三个选项组可用:用于控制数学行为、优化和杂项的选项。

以下表格展示了可用的数学选项:

-cl-single-precision-constant 此选项将双精度浮点数视为单精度常数。
-cl-denorms-are-zero 此选项控制单精度和双精度未规格化数字的处理方式。编译器可以选择将这些数字清除为零。请参阅www.khronos.org/registry/cl/sdk/1.1/docs/man/xhtml/
-cl-fp32-correctly-rounded-divide-sqrt 此选项可以传递给 clBuildProgramclCompileProgram,允许应用程序指定程序源中使用的单精度浮点除法(x / y 和 1 / x)以及 sqrt 是正确舍入的。

以下表格突出了可用的优化选项:

-cl-opt-disable 此选项禁用所有优化。优化默认启用
-cl-mad-enable 此选项允许使用降低精度的计算 a * b + c
-cl-unsafe-math-optimizations 此选项结合了 –cl-mad-enable–cl-no-signed-zeros 选项
-cl-no-signed-zeros 此选项允许浮点运算忽略零的符号,因为根据 IEEE 754,+0.0 和 -0.0 之间存在差异
-cl-finite-math-only 此选项允许优化假设没有浮点参数取 NaN 或无穷大值
-cl-fast-relaxed-math 此选项结合了 –cl-unsafe-math-优化和 –cl-finite-math-only 选项

以下表格突出了可用的各种选项:

-w 此选项阻止所有警告消息
-Werror 此选项将所有警告消息转换为错误
-cl-std=VERSION 此选项根据 OpenCL 编译器的版本(VERSION={CL1.1})构建程序

让我们来看一个更大的例子,其中我们创建和查询 OpenCL 内核,并最终将它们放置在设备上的命令队列中。

创建 OpenCL 内核

到目前为止,我们已经从源文件中创建了一个程序。这些源文件实际上是 OpenCL 内核代码。以下是如何查看它们的示例:

__kernel void simpleAdd(__global float *a,
                        __global float *b,
                        __global float *c) {

  int gid = get_global_id(0);
   c[gid] = a[gid] + b[gid];
}

内核通过 __kernel 修饰符识别,类似于 C 函数。__global 修饰符指的是变量所在的内存空间。我们将在后面的章节中对此进行更多讨论。

尽管我们已创建了程序对象,如前所述,但此程序无法在设备上执行。回想一下,一个程序可以引用多个内核,我们需要保留这些内核,因为是在设备上调度执行的是内核而不是程序对象。OpenCL 通过 clCreateKernelclCreateKernelsInProgram 函数为我们提供了提取这些内核的功能。让我们仔细看看它们:

cl_kernel clCreateKernel(cl_program program,
                         const char* kernel_name,
                         cl_int* errcode_ret) 

通过查看此代码,您会注意到,为了创建内核,我们首先需要创建程序对象,内核函数的名称以及捕获返回状态。此 API 返回 cl_kernel,表示内核对象在成功时。此 API 为程序员提供了一个选项,即不必将程序中的每个内核函数转换为实际可用于执行的 OpenCL 内核对象。

但如果您希望将程序中的所有内核函数简单地转换为内核对象,那么 clCreateKernelsInProgram 是要使用的 API:

cl_int clCreateKernelsInProgram(cl_program program,
                                cl_uint num_kernels,
                                cl_kernel* kernels,
                                cl_uint* num_kernels_ret)

您使用此 API 请求 OpenCL 创建并加载内核到kernels参数中,并通过num_kernels参数向 OpenCL 编译器提示您期望的内核数量。

准备工作

完整的代码可以在ch1/kernel_query/kernel_query.c中找到。为了让我们专注于关键的 API,本食谱中讨论的代码片段展示了简化的代码。此代码需要一个或多个 OpenCL 源文件,即*.cl文件,一旦将它们放在一起,您需要更改程序的变量file_namesNUMBER_OF_FILES以反映相应的文件。

如何做…

要查询 OpenCL 内核,您需要在代码中包含一个类似于以下的功能。您应该参考与此食谱一起提供的完整代码列表:

        /* 
         Query the program as to how many kernels were detected 
         */
        cl_uint numOfKernels;
        error = clCreateKernelsInProgram(program, 0, NULL,
                                         &numOfKernels);
        if (error != CL_SUCCESS) {
            perror("Unable to retrieve kernel count from
                    program");
            exit(1);
        }
        cl_kernel* kernels = (cl_kernel*)
                             alloca(sizeof(cl_kernel) *
                                                 numOfKernels);
        error = clCreateKernelsInProgram(program, numOfKernels,
                                         kernels, NULL);

        for(cl_uint i = 0; i < numOfKernels; i++) {
            char kernelName[32];
            cl_uint argCnt;
            clGetKernelInfo(kernels[i],
                            CL_KERNEL_FUNCTION_NAME,
                            sizeof(kernelName), 
                            kernelName, NULL);
            clGetKernelInfo(kernels[i], CL_KERNEL_NUM_ARGS,
                            sizeof(argCnt), &argCnt, NULL);
            printf("Kernel name: %s with arity: %d\n",
                    kernelName,
                    argCnt);
        }
        /* Release the kernels */
        for(cl_uint i = 0; I < numOfKernels; i++) 
            clReleaseKernel(kernels[i]);

编译与上一节中展示的build_opencl_program.c非常相似,所以我们跳过这一步。当此应用程序使用两个 OpenCL 源文件运行时,我们将得到以下输出:

Number of OpenCL platforms found: 1
Kernel name: simpleAdd with arity: 3
Kernel name: simpleAdd_2 with arity: 3

两个源文件各自定义了一个简单的内核函数,该函数将两个参数相加并将结果存储到第三个参数中;因此,函数的arity3

工作原理…

代码调用clCreateKernelsInProgram两次。如果您还记得,这种模式在许多 OpenCL API 中都会出现,其中第一次调用会查询平台以获取某些详细信息,在这种情况下是程序中检测到的内核数量。后续调用会要求 OpenCL 将内核对象存入由kernels引用的存储中。

最后,我们调用clGetKernelInfo,向其传递检索到的内核对象,并通过CL_KERNEL_FUNCTION_NAMECL_KERNEL_NUM_ARGS变量打印出有关内核函数的一些信息,例如内核函数的名称和函数的arity

可以从内核对象查询的详细列表反映在下表中:

cl_kernel_info 返回类型 在 param_value 中返回的信息
CL_KERNEL_FUNCTION_NAME char[] 此变量返回内核函数的名称
CL_KERNEL_NUM_ARGS cl_uint 此变量返回内核的参数数量
CL_KERNEL_REFERENCE_COUNT cl_uint 此变量返回内核引用计数
CL_KERNEL_CONTEXT cl_context 此变量返回与此内核关联的上下文
CL_KERNEL_PROGRAM cl_program 此变量返回将绑定到内核对象的程序对象

现在我们已经弄清楚如何创建内核对象,我们应该看看如何创建命令队列并开始将我们的内核对象和数据入队以执行。

创建命令队列和入队 OpenCL 内核

本节将向您展示如何在设备上入队 OpenCL 内核对象。在我们这样做之前,让我们回顾一下,我们可以创建内核而不指定 OpenCL 设备,并且内核可以通过命令队列在设备上执行。

到目前为止,我们可能应该花一些时间来谈谈按顺序执行以及它们如何与乱序执行进行比较,尽管这个主题很复杂,但也很吸引人。当程序要被执行时,处理器可以选择按顺序或乱序处理程序中的指令;这两种方案之间的一个关键区别是,按顺序执行会产生一个静态的执行顺序,而乱序执行允许指令动态地调度。乱序执行通常涉及重新排序指令,以便充分利用处理器中的所有计算单元,并驱动目标以最小化计算停滞。

然而,内核并不是唯一可以入队到命令队列的对象。内核需要数据以便执行其操作,并且数据需要传输到设备上进行消费,这些数据可以是 OpenCL 缓冲区/子缓冲区或图像对象。封装数据的内存对象需要被传输到设备上,并且你必须向命令队列发出内存命令以实现这一点;在许多用例中,在计算之前用数据填充设备是很常见的。

以下图示突出了这种用例,其中内核被安排按顺序执行,假设内核需要显式复制数据或内存映射数据,并在计算完成后,数据从设备内存复制到主机内存。

创建命令队列和入队 OpenCL 内核

还可以创建多个命令队列,并使用命令入队,它们存在的原因是您希望解决的问题可能涉及主机中的某些,如果不是所有异构设备。它们可以代表独立计算流,其中没有数据共享,或者依赖计算流,其中每个后续任务都依赖于前一个任务(通常,数据是共享的)。请注意,如果没有数据共享,这些命令队列将在设备上无同步地执行。如果数据共享,则程序员需要确保通过 OpenCL 规范提供的同步命令来同步数据。

作为独立计算流的示例,以下图示假设已经确定了三个独立任务,并且它们需要在设备上执行。可以形成三个命令队列(仅按顺序执行),每个队列中入队了任务,并可以形成一个流水线,这样设备在执行内核代码的同时进行 I/O 操作,从而在不让设备空闲等待数据的情况下提高利用率。

创建命令队列和入队 OpenCL 内核

小贴士

请注意,尽管默认情况下,入队到命令队列中的命令按顺序执行,但你可以在创建命令队列时通过传递 CL_QUEUE_OUT_OF_ORDER_EXEC_MODE_ENABLE 标志来启用非顺序执行。

下面的图表显示了非顺序执行的示例,并假设我们的问题被分解为三个相互依赖的内核,每个内核将消耗和处理数据,然后将其传递到下一阶段。进一步假设内核的执行是非顺序的。接下来会发生的事情可能是混乱,这也可能是为什么这个选项不是默认设置的原因。

创建命令队列和入队 OpenCL 内核

然而,读者应该注意来自 AMD 和 Intel 的 CPU。

当你开始处理内核时,你可能会发现某些内核似乎比其他内核的性能更好。你可以在微调内核时通过在创建命令队列时传递 CL_QUEUE_PROFILING_ENABLE 标志来分析内核。

准备工作

在不重复太多之前代码的基础上,以下是源自 ch1/kernel_queue/kernel_queue.c 的相关代码。此代码列表需要具有不同内核函数名称(不允许函数重载)的有效 OpenCL 内核文件,以及有效的函数参数。在 ch1/kernel_queue/hello_world.cl 中,你可以看到一个这样的函数或内核的示例。

__kernel void hello(__global char* data) {              
} 

如何操作…

你应该参考与此配方一起的完整代码列表:

cl_kernel* kernels = (cl_kernel*) alloca(sizeof(cl_kernel) *
                                         numOfKernels);
error = clCreateKernelsInProgram(program, numOfKernels,
                                 kernels, NULL);
for(cl_uint i = 0; i < numOfKernels; i++) {
    char kernelName[32];
    cl_uint argCnt;
    clGetKernelInfo(kernels[i], CL_KERNEL_FUNCTION_NAME,
                    sizeof(kernelName), kernelName, NULL);
    clGetKernelInfo(kernels[i], CL_KERNEL_NUM_ARGS,
                    sizeof(argCnt),
                    &argCnt, NULL);
    printf("Kernel name: %s with arity: %d\n", kernelName,
            argCnt);
    printf("About to create command queue and enqueue this
            kernel...\n");

    /* Create a command queue */
    cl_command_queue cQ = clCreateCommandQueue(context, 
                                               device,
                                               0,
                                               &error);
    if (error != CL_SUCCESS) { 
        perror("Unable to create command-queue");
        exit(1);
    }
    /* Create a OpenCL buffer object */
   cl_mem strObj = clCreateBuffer(context,CL_MEM_READ_ONLY |
                                          CL_MEM_COPY_HOST_PTR,
                                          sizeof(char) * 11,
                                          "dummy value", NULL);

   /* 
     Let OpenCL know that the kernel is suppose to receive an
     Argument
   */
   error = clSetKernelArg(kernels[i], 
                          0, 
                          sizeof(cl_mem), 
                          &strObj);
   if (error != CL_SUCCESS) { 
       perror("Unable to create buffer object");
       exit(1);
   }
   /* Enqueue the kernel to the command queue */
   error = clEnqueueTask(cQ, kernels[i], 0, NULL, NULL);

   if (error != CL_SUCCESS) { 
       perror("Unable to enqueue task to command-queue");
       exit(1);
   }
   printf("Task has been enqueued successfully!\n");
   /* Release the command queue */
   clReleaseCommandQueue(cQ);
}
/* Clean up */
for(cl_uint i = 0; i < numOfKernels; i++) {
    clReleaseKernel(kernels[i]);
}

如前所述,编译步骤与 kernel_query.c 中的类似,可以使用如下命令:

gcc -std=c99 -Wall -DUNIX -g -DDEBUG -DAPPLE -arch i386 -o kernel_queue kernel_queue.c   -framework OpenCL

下面是我在我机器上执行应用程序时的示例输出:

Number of OpenCL platforms found: 1
Kernel name: hello with arity: 1
About to create command queue and enqueue this kernel...
Task has been enqueued successfully!

从输出中,你可以看出任务已成功入队到命令队列中!

它是如何工作的…

在上一节中,我们成功查询了 OpenCL 内核对象的信息之后,我们利用那段代码通过 clCreateCommandQueue 创建命令队列,通过 clEnqueueTask 将内核入队到队列中,但在设置内核所需数据之前,需要通过 clSetKernelArgclCreateBuffer。你现在可以忽略这两个 API,直到我们在后面的章节中解释它们。

第二章:理解 OpenCL 数据传输和分区

在本章中,我们将介绍以下食谱:

  • 创建 OpenCL 缓冲区对象

  • 获取关于 OpenCL 缓冲区对象的信息

  • 创建 OpenCL 子缓冲区对象

  • 获取关于 OpenCL 子缓冲区对象的信息

  • 理解事件和事件同步

  • 在内存对象之间复制数据

  • 使用工作项来分区数据

简介

在本章中,我们将探讨如何调用 OpenCL 的数据传输 API,查询内存对象,以及 GPU 和 CPU 之间的数据/工作分区。

小贴士

注意,并非所有 OpenCL SDK 都支持在 GPU 和 CPU 上同时编译和执行。AMD 的 OpenCL 实现支持其自己的 AMD 和 Intel CPU 以及 GPU;NVIDIA 支持其 GPU,而 Intel 支持其自己的 Intel Core CPU 和 Intel HD Graphics。请咨询供应商以了解支持的设备。

开放计算语言OpenCL)的开发中,你不可避免地需要处理数据,而该标准不允许你直接操作内存对象,就像你在 C 或 C++编程时做的那样,因为在异构环境中,主机中的数据内存最终会被传输到设备上进行处理,之前你会使用各种库或语言的编程结构来访问它们,这也是 OpenCL 出现的原因之一;因此,为了统一这些方法,标准添加了抽象来保护开发者免受这些问题的困扰。

在数据类型方面,除了单维数据缓冲区之外,还有一些你需要注意的类型。OpenCL 缓冲区对象可以用来加载和存储二维/三维数据。OpenCL 中的下一个数据类型是image对象;这些对象用于存储二维或三维图像(本书不会过多涉及image对象的使用)。

OpenCL 1.1 的新数据传输功能包括以下内容:

  • 使用子缓冲区对象将缓冲区的区域分布到多个 OpenCL 设备上

  • 3 分量向量数据类型

  • 使用全局工作偏移,它使内核能够在 NDRange 的不同部分上操作——全局工作偏移指的是输入数据中的数据点,工作项可以从那里开始处理

  • 读取、写入或复制缓冲区对象的 1D、2D 或 3D 矩形区域

创建 OpenCL 缓冲区对象

在上一章中,我们了解了创建或包装我们的主机内存对象到 OpenCL 可以操作的一种抽象的需要,在本食谱中,我们将探讨如何创建规范中定义的特定类型的内存对象,这种对象通常用于通用计算——缓冲区对象。开发者可以选择创建一个一维、二维或三维的内存对象,以最适合计算模型。

在 OpenCL 中创建缓冲区对象很简单,类似于您使用 C 的内存分配例程(如mallocalloca)的方式。但是,相似之处到此为止,因为 OpenCL 不能直接操作由这些例程创建的内存结构。您可以做的只是创建一个存在于设备上的内存结构,该结构可以映射到主机上的内存,并通过向命令队列(您记得这是通往设备的通道)发出内存传输命令来将数据传输到设备。您需要决定的是对象的类型,以及您希望设备计算多少这样的对象。

在这个例子中,我们将学习如何根据用户定义的结构创建缓冲区对象,也称为 C/C++语言中的structs。在那之前,让我们了解 API:

cl_mem clCreateBuffer(cl_context context,
                      cl_mem_flags flags,
                      size_t size,
                      void* host_ptr,
                      cl_int* errcode_ret)

您可以通过指定它应该附加到的上下文来创建一个缓冲区(记住上下文可以通过多个设备创建),指定数据的大小,以及如何使用sizehost_ptr分别引用它,通过flags指定内存的分配方式和内存类型是只读、只读、读写或只写;最后在errcode_ret中捕获结果错误代码。请注意,clCreateBuffer不会将命令排队以从主机到设备内存进行内存传输。

准备工作

下面是Ch2/user_buffer/user_buffer.c代码的一部分,您将看到如何使用clCreateBuffer API 为用户定义的结构分配内存。在这个例子中,我们试图解决的问题是将一百万个用户定义的结构发送到设备进行计算。内核封装的计算非常简单——计算每个用户结构的所有元素的总和。细心的读者会注意到我们本可以用 OpenCL 的向量数据类型int4来演示这个数据结构;我们没有那样做的原因有两个:一是这是一个应用领域建模的例子,二是由于在当前的一小段文字中,我们想要展示如何使用数据类型对齐构造,现在不要担心数据类型,因为我们在下一章将深入探讨各种数据类型。继续前进,用户定义的结构如下:

typedef struct UserData {
 int x;
 int y;
 int z;
 int w;
} UserData;

您需要做的是在主机应用程序中使用标准的 C/C++动态/静态内存分配技术,如newmallocalloca来创建一个缓冲区。接下来,您需要初始化该数据缓冲区,最后您必须调用clCreateBuffer,并确保在调用clSetKernelArg之前完成;记住我们提到内核在执行设备上的内核代码之前会被调度到设备上,它需要数据和值来工作,您可以通过调用clSetKernelArg来实现这一点,通常在创建缓冲区对象时这样做。

API clSetKernelArg看起来像以下代码,并且理解它是如何工作的将非常重要:

cl_int clSetKernelArg(cl_kernel kernel,
                      cl_uint arg_index,
                      size_T arg_size,
                      const void *arg_value);

内核可以没有参数,或者至少有一个,可能还有更多参数,配置它们很简单。以下代码片段应该能完成这个故事:

// in the kernel code
_kernel void somefunction(__global int* arg1, __global int* arg2) {…} 
// in the host code
int main(int argc, char**argv) {
// code omitted
cl_kernel kernel; 
// kernel is initialized to point to "somefunction" in the kernel file
clSetKernelArg(kernel, 0, sizeof(cl_mem), (void*) &memoryobjectA);
clSetKernelArg(kernel, 1, sizeof(cl_mem), (void*) &memoryobjectB);

因此,内核参数以编程方式配置,理解如果内核函数有n个参数,则arg_index的范围将从0n - 1

如何做到这一点…

我们从Ch2/user_buffer/user_buffer.c中包含了此菜谱的主要部分,并带有高亮注释:

/* Defined earlier */
#define DATA_SIZE 1048576
UserData* ud_in = (UserData*) malloc(sizeof(UserData) *
                                     DATA_SIZE); // input to device
/* initialization of 'ud_in' is omitted. See code for details.*/
/* Create a OpenCL buffer object */
cl_mem UDObj = clCreateBuffer(context, 
                              CL_MEM_READ_ONLY |
                              CL_MEM_COPY_HOST_PTR, 
                              sizeof(UserData) * DATA_SIZE,
                              ud_in, &error);
if (error != CL_SUCCESS) {
  perror("Unable to create buffer object");
  exit(1)
}

在 OSX 上,你可以在终端运行以下命令来编译程序:

gcc -std=c99 -Wall -DUNIX -g -DDEBUG -DAPPLE -arch i386 -o user_buffer user_buffer.c   -framework OpenCL

在带有 Intel OpenCL SDK 的 Ubuntu Linux 12.04 上,命令将如下所示:

gcc -std=c99 -Wall -DUNIX -g -DDEBUG -m64 -o user_buffer user_buffer.c -I . -I /usr/include -L/usr/lib64/OpenCL/vendors/intel -lintelocl -ltbb -ltbbmalloc -lcl_logger -ltask_executor

在带有 AMD APP SDK v2.8 的 Ubuntu Linux 12.04 上,命令将如下所示:

gcc -std=c99 -Wall -DUNIX -g -DDEBUG –m64 -o user_buffer user_buffer.c   -I. –I/opt/AMDAPP/include –L/opt/AMDAPP/lib/x86_64 –lOpenCL

无论平台如何,都会在本地存储一个二进制可执行文件user_buffer

注意

在两个平台上运行应用程序,我们会得到以下结果:

Number of OpenCL platforms found: 1
Kernel name: hello with arity: 1
About to create command queue and enqueue this kernel...
Task has been enqueued successfully!
Check passed!

它是如何工作的…

应用程序在主机上创建了百万个UserData对象。参考以下代码片段:

/*
  Prepare an array of UserData via dynamic memory allocation
*/
UserData* ud_in = (UserData*) malloc( sizeof(UserData) * DATA_SIZE); // input to device
UserData* ud_out = (UserData*) malloc( sizeof(UserData) * DATA_SIZE); // output from device
  for( int i = 0; i < DATA_SIZE; ++i) {
    (ud_in + i)->x = i;
    (ud_in + i)->y = i;
    (ud_in + i)->z = i;
    (ud_in + i)->w = 3 * i;
  }

应用程序在程序和内核对象初始化后将其发送到设备进行计算,并将最近创建的UDObj内存对象分配给内核作为其参数。参考以下代码片段:

error = clSetKernelArg(kernels[i], 0, sizeof(cl_mem), &UDObj);
  if (error != CL_SUCCESS) {
    perror("Unable to create buffer object");
      exit(1);
  }

接下来,我们向命令队列cQ发出内核执行命令,代码将在设备上运行,以下代码片段演示了内核的入队:

  /* Enqueue the kernel to the command queue */
  error = clEnqueueTask(cQ, kernels[i], 0, NULL, NULL);
    if (error != CL_SUCCESS) {
      perror("Unable to enqueue task to command-queue");
      exit(1);
    }

之后,将设备内存中的数据读回,我们表示我们希望读取数据直到设备完成其执行,通过传递CL_TRUE来指示阻塞读取,否则可能会导致读取部分数据;最后,数据通过以下代码片段进行验证:

/* Enqueue the read-back from device to host */
            error = clEnqueueReadBuffer(cQ, UDObj,
                                         CL_TRUE, // blocking read
                                         0, // write from the start
                                         sizeof(UserData) * DATA_SIZE, // how much to copy
                                         ud_out, 0, NULL, NULL);
    if ( valuesOK(ud_in, ud_out) ) {
      printf("Check passed!\n");
    } else printf("Check failed!\n");

让我们进一步探索如何使用clCreateBuffer

在这种情况下,当向设备提供输入并且你想要确保没有其他东西正在写入数据存储时,你希望在设备上分配只读内存。因此,传递了标志CL_MEM_READ_ONLY,但如果你的输入数据旨在可读和可写,那么你需要使用CL_MEM_READ_WRITE来指示它。注意,我们实际上通过ud_in在主机上创建了一个数据存储,并且我们希望我们的 OpenCL 内存对象与ud_inC语句的大小相同;最后,我们希望 OpenCL 知道新的内存对象将从ud_in复制其值,我们也提供了标志CL_MEM_COPY_HOST_PTR,并且我们使用标准美国键盘上表示为管道符号的位运算符|来合并这两个标志。

从概念上讲,你可以将其视为一个结构体的一维数组,或者更一般地说,是一个结构体数组。

UserData UserData UserData …………………………………………… UserData

提示

将相同的应用数据类型声明提供给 OpenCL 内核文件(*.cl)以及主机应用程序文件(*.c, *.h, *.cpp, *.hpp);否则,OpenCL 运行时会发出错误,以反映它正在寻找的结构不存在,并且复制是必要的,因为 OpenCL 禁止 C 头文件包含机制。

让我们花些时间来理解我们在本例中使用的 C struct。我们刚刚使用的 C 结构 UserData 是一个应用数据类型的例子。OpenCL 对缓冲区和图像之外的 OpenCL 数据类型的对齐没有要求;因此,OpenCL 开发者需要确保数据得到适当的对齐。幸运的是,OpenCL 提供了属性限定符,这样我们就可以注释我们的类型、函数和变量,以适应算法和 CPU/GPU 架构,主要动机是提高内存带宽。对齐必须是 2 的幂,并且至少是结构或联合中所有成员对齐的最小公倍数的完美倍数。

注意

请参阅 OpenCL 1.2 规范中的第 6.11.1 节“指定类型的属性”

让我们看看当涉及到对 enumstructunion 等数据类型进行对齐时,开发者可以有哪些选择。

数据对齐是各种计算机系统限制原始数据类型允许地址的直接结果,要求某些类型对象的地址必须是某个值 K(通常是 2、4 或 8)的倍数,这实际上简化了处理器和内存系统之间硬件的设计。例如,如果处理器总是从地址必须是 8 的倍数的内存中获取 8 字节,那么值可以在单一内存操作中读取或写入,否则,处理器需要执行两个或更多的内存访问。

通过确保每个数据类型中的每个对象都按照满足其对齐限制的方式组织和分配来强制执行对齐。

让我们用一个例子来说明这一点。以下是为应用数据类型如 UserData 定义对齐的通用方式。在检查代码时,您会注意到如果没有 aligned 属性,这个数据结构将在假设 int 是 4 字节且 char 是 1 字节(在 32 位/64 位系统架构上)的情况下,分配在 17 字节边界上。一旦包含了这个属性,对齐如下:

| __attribute__((aligned))

现在对齐由 OpenCL 编译器确定为 32 字节对齐,而不是 17 字节,即求所有结构成员的大小之和,规范指定对齐大小为最大的 2 的幂,因此它是 25,因为 24 多了 1 字节;然而,如果您将之前的对齐更改为以下对齐:

| __attribute__((aligned (8)))

然后,对齐至少为 8 字节,如下面的代码所示:

typedef struct __attribute__((aligned)) UserData {
    int x;
    int y;
    int z;
    int w;
    char c;
} UserData;

同样,你也可以更明确地写成如下形式:

typedef struct __attribute__((aligned(32)) UserData {…}

通常,设计数据以内存对齐的金科玉律仍然是必要的实践;我牢记的一个经验法则是,对于 128 位访问,16 字节对齐,对于 256 位访问,32 字节对齐。

在故事的另一面,你可能发现自己希望对齐程度不是那么大,使用 OpenCL 你可以通过使用 packed 属性来表示这一点,如下面的代码所示,假设 LargeUserData 是一个假想的大型数据结构:

typedef struct __attribute__((packed)) LargeUserData {…}

当你将此属性应用于 structunion 时,你实际上是将属性应用于数据的每个成员;应用于 enum 意味着 OpenCL 编译器将选择在该架构上找到的最小整型。你可以参考 Ch2/user_buffer_alignment/user_buffer_align.c 来查看所做的工作以及如何通过 AMD APP SDK 在 readme.txt 文件中通过性能分析来分析应用程序。

检索 OpenCL 缓冲区对象的信息

要检索有关缓冲区或子缓冲区对象的信息,你需要使用 API clGetMemObjectInfo 以及其签名,如下面的代码所示:

cl_int clGetMemObjectInfo(cl_mem memobj,
                          cl_mem_info param_name,
                          size_t param_value_size,
                          void* param_value,
                          size_t* param_value_size_ret)

要查询内存对象,只需将对象传递给 memobj,指定你想要在 param_name 中获取的信息类型,通知 OpenCL 返回信息的尺寸在 param_value_size 中,以及在哪里存储它(param_value);最后一个参数 param_value_size_ret 大部分是可选的,但它返回 param_value 中值的尺寸。

准备工作

下面是从 Ch2/buffer_query/buffer_query.c 代码中摘录的一段,展示了如何提取内存对象的信息,UDObj 被封装到一个用户定义的函数 displayBufferDetails 中,因为代码可能很长,这取决于你希望从内存对象中提取多少属性,你会在创建缓冲区对象之后或如果你已经得到了内存对象的句柄时调用此函数。以下代码演示了如何通过将 OpenCL 内存检索 API 抽象到函数 displayBufferDetails 中来显示内存对象的信息:

cl_mem UDObj = clCreateBuffer(context, … sizeof(UserData) *                
                              DATA_SIZE, ud_in, &error);
/* Extract some info about the buffer object we created */
displayBufferDetails(UDObj);

如何做到这一点…

我们已经包括了此菜谱的主要部分,如下面的代码所示:

void displayBufferDetails(cl_mem memobj) {
  cl_mem_object_type objT;
  cl_mem_flags flags;
  size_t memSize;
  clGetMemObjectInfo(memobj, CL_MEM_TYPE,
                     sizeof(cl_mem_object_type), &objT, 0);
  clGetMemObjectInfo(memobj, CL_MEM_FLAGS, sizeof(cl_mem_flags),
                     &flags, 0);
  clGetMemObjectInfo(memobj, CL_MEM_SIZE, sizeof(size_t),
                     &memSize, 0);
  char* str = '\0';
  switch (objT) {
    case CL_MEM_OBJECT_BUFFER: str = "Buffer or Sub
                                      buffer";break;
    case CL_MEM_OBJECT_IMAGE2D: str = "2D Image Object";break;
    case CL_MEM_OBJECT_IMAGE3D: str = "3D Image Object";break;
  }
  char flagStr[128] = {'\0'};
  if(flags & CL_MEM_READ_WRITE) strcat(flagStr, "Read-Write|");
  if(flags & CL_MEM_WRITE_ONLY) strcat(flagStr, "Write Only|");
  if(flags & CL_MEM_READ_ONLY)  strcat(flagStr, "Read Only|");
  if(flags & CL_MEM_COPY_HOST_PTR) strcat(flagStr, "Copy from
                                                    Host|");
  if(flags & CL_MEM_USE_HOST_PTR)  strcat(flagStr, "Use from
                                                    Host|");
  if(flags & CL_MEM_ALLOC_HOST_PTR) strcat(flagStr, "Alloc from
                                                     Host|");
  printf("\tOpenCL Buffer's details =>\n\t size: %lu MB,\n\t object type is: %s,\n\t flags:0x%lx (%s) \n", memSize >> 20, str, flags, flagStr);
}

在 OSX 上,你将通过在终端运行以下命令来编译程序:

gcc -std=c99 -Wall -DUNIX -g -DDEBUG -DAPPLE -arch i386 -o buffer_query buffer_query.c   -framework OpenCL

在 Ubuntu Linux 12.04 上使用 Intel OpenCL SDK,命令将如下所示:

gcc -std=c99 -Wall -DUNIX -g -DDEBUG -m64 -o buffer_query buffer_query.c -I . -I /usr/include -L/usr/lib64/OpenCL/vendors/intel -lintelocl -ltbb -ltbbmalloc -lcl_logger -ltask_executor

在 Ubuntu Linux 12.04 上使用 AMD APP SDK v2.8,命令将如下所示:

gcc -std=c99 -Wall -DUNIX -g -DDEBUG –m64 -o buffer_query buffer_query.c   -I. –I/opt/AMDAPP/include –L/opt/AMDAPP/lib/x86_64 –lOpenCL

不论是哪个平台,一个名为 buffer_query 的二进制可执行文件将被本地存储。

在 OSX 10.6 和 Ubuntu 12.04 上使用 AMD APP SDK v2.7 运行程序将呈现以下结果:

Number of OpenCL platforms found: 1
Kernel name: hello with arity: 1
About to create command queue and enqueue this kernel...
  OpenCL Buffer's details =>
    size: 128 MB,
    object type is: Buffer or Sub-buffer,
    flags:0x21 (Read-Write|Copy from Host)
Task has been enqueued successfully!
Check passed!

它是如何工作的…

主应用程序首先创建它将发送到设备的缓冲区,然后应用程序查询有关缓冲区的信息。可以查询的完整属性列表如下表所示:

cl_mem_info 返回类型 在 param_value 中返回的信息。
CL_MEM_TYPE cl_mem_object_type 如果memobj是通过clCreateBufferclCreateSubBuffer创建的,则返回CL_MEM_OBJECT_BUFFER
Cl_MEM_FLAGS cl_mem_flags 返回创建memobj时指定的标志参数。使用clCreateBufferclCreateSubBufferclCreateImage2DclCreateImage3D创建memobj时返回。
CL_MEM_SIZE size_t 返回与memobj关联的数据的实际大小(以字节为单位)。
CL_MEM_HOST_PTR void* 如果memobj是通过clCreateBufferclCreateImage2dclCreateImage3D创建的,则返回创建memobj时指定的host_ptr参数。如果memobj是通过clCreateSubBuffer创建的,则返回创建memobj时指定的host_ptr加上origin。参见clCreateBuffer了解host_ptr是什么。
CL_MEM_MAP_COUNT cl_uint 映射计数。
CL_MEM_REFERENCE_COUNT cl_uint 返回memobj的引用计数。
CL_MEM_CONTEXT cl_context 返回创建内存时指定的上下文。如果memobj是通过clCreateSubBuffer创建的,则返回与内存对象关联的上下文,该上下文是作为clCreateSubBufferbuffer参数指定的。
CL_MEM_ASSOCIATED_MEMOBJECT cl_mem 返回创建memobj的内存对象。在clCreateSubBuffer中,它返回buffer参数;否则返回 NULL。
CL_MEM_OFFSET size_t 适用于通过clCreateSubBuffer创建的memobj。它返回偏移量或 0。

创建 OpenCL 子缓冲区对象

子缓冲区是非常有用的数据类型,随着你继续在本章中探索 OpenCL,你会注意到这种数据类型可以用来划分数据并将它们分布到你的平台上的 OpenCL 设备上。

注意

在撰写本文时,OSX 10.6 中提供的 OpenCL 的子缓冲区支持尚未启用,因为官方版本是 OpenCL 1.0。然而,如果你有 OSX 10.7,那么你将能够无任何问题地运行此代码。

让我们看看方法签名并检查它:

cl_mem clCreateSubBuffer(cl_mem buffer,
                         cl_mem_flags flags,
                         cl_buffer_create_type bufferType,
                         const void* buffer_create_info,
                         cl_int* errcode_ret)

参数buffer指的是通过clCreateBuffer创建的缓冲区,flags参数指的是你希望此选项具有的选项,如果为零,则默认选项为CL_MEM_READ_WRITE;此标志可以采用前表中任何值。参数bufferType是一个数据结构:

typedef struct _cl_buffer_region {
  size_t origin;
  size_t size;
} cl_buffer_region;

因此,你通过origin参数指示创建区域的起始位置,并通过size参数指示它的大小。

准备工作

在本食谱的“如何做...”部分,有来自Ch2/sub_buffers/sub_buffer.c的摘录,其中我们创建了两个子缓冲区对象,每个对象包含一半的数据;这两个子缓冲区将发送到我的设置中的每个 OpenCL 设备上,它们将被计算并检查结果。从概念上讲,以下是代码所做的工作:

准备中

如何操作...

我们已经将此菜谱的主要部分包含在以下代码中:

/ 在所有设备之间均匀分割数据并创建子缓冲区 /

  cl_buffer_region region;
  region.size   = (sizeof(UserData)*DATA_SIZE) / numOfDevices; 
  region.origin = offset * region.size;
  cl_mem subUDObj = clCreateSubBuffer(UDObj,
                                CL_MEM_READ_WRITE, // read-write
                                CL_BUFFER_CREATE_TYPE_REGION,
                                &region, &error);
  if (error != CL_SUCCESS) { 
    perror("Unable to create sub-buffer object");
    exit(1);
  }

/ 让 OpenCL 知道内核应该接收一个参数 /

error = clSetKernelArg(kernels[j], 0, sizeof(cl_mem), &subUDObj);
// Error handling code omitted

如前所述,此应用程序在 OSX 10.6 上无法工作,因此要使用 AMD APP SDK 编译它,你需要输入以下命令:

gcc –std=c99 –Wall –DUNIX –g –DDEBUG –m64 –o sub_buffer sub_buffer.c –I. –I/opt/AMDAPP/include –L/opt/AMDAPP/lib/x86_64 –lOpenCL

对于 Intel OpenCL SDK,你需要输入以下命令:

gcc –std=c99 –Wall –DUNIX –g –DDEBUG –m64 –o sub_buffer sub_buffer.c –I. –I/usr/include 
–L/usr/lib64/OpenCL/vendors/intel
-lintelocl
-ltbb
-ltbbmalloc
-lcl_logger
-ltask_executor

对于 Ubuntu Linux 12.04 上的 NVIDIA,你需要输入以下命令:

gcc –std=c99 –Wall –DUNIX –g –DDEBUG –m64 –o sub_buffer sub_buffer.c –I. –I/usr/local/cuda/include –lOpenCL

无论平台如何,二进制可执行文件sub_buffer都会被本地存储。

在我设置的 Ubuntu Linux 12.04 环境中,有一个 NVIDIA GTX460 显卡,安装了 NVIDIA 和 Intel 的 OpenCL 工具包,以下是我的输出结果:

Number of OpenCL platforms found: 2
Number of detected OpenCL devices: 1
Kernel name: hello with arity: 1
About to create command queue and enqueue this kernel...
Task has been enqueued successfully!
Check passed!

在另一个 Ubuntu Linux 12.04 环境中,有一个 ATI 6870x2 显卡和 AMD APP SDK 安装的设置中,输出的差异仅在于平台数量为 1,数据在 CPU 和 GPU 之间分割:

Number of OpenCL platforms found: 1
Number of detected OpenCL devices: 2
Kernel name: hello with arity: 1
About to create command queue and enqueue this kernel...
Task has been enqueued successfully!
Check passed!
Kernel name: hello with arity: 1
About to create command queue and enqueue this kernel...
Task has been enqueued successfully!
Check passed!

它是如何工作的...

应用程序基本上会发现所有符合 OpenCL 规范的设备,并跟踪其发现过程。接下来,应用程序使用先前信息在将数据入队执行之前将数据分配到各个设备上,以下代码片段展示了以下内容:

cl_buffer_region region;
region.size   = (sizeof(UserData)*DATA_SIZE) / numOfDevices;
region.origin = offset * region.size;
cl_mem subUDObj = clCreateSubBuffer(UDObj,
                                    CL_MEM_READ_WRITE, // read-write
                                    CL_BUFFER_CREATE_TYPE_REGION,
                                    &region, &error);

最后,在从设备内存将数据读回到主机内存后,会检查数据的有效性,如下代码片段所示:

error = clEnqueueReadBuffer(cQ, 
                            subUDObj,
                            CL_TRUE, // blocking read
                            region.origin, // write from the last offset
                            region.size, // how much to copy
                            ud_out, 0, NULL, NULL);
                /* Check the returned data */
                if ( valuesOK(ud_in, ud_out, DATA_SIZE/numOfDevices){
                  printf("Check passed!\n");
               } else printf("Check failed!\n");

你刚才看到的是一种数据分区技术,也称为一维数据块上的分布式数组模式。

注意

根据分布式数组模式,已经开发出三种一般技术,它们是一维和二维数据块,最后是块循环模式。

根据你是否安装了一个或多个供应商的 OpenCL 工具包,OpenCL 会报告适当的平台,并且 OpenCL 可安装客户端驱动程序ICD)允许多个 OpenCL 实现共存于同一台物理机器上。有关 ICD 的更多信息,请参阅 URL www.khronos.org/registry/cl/extensions/khr/cl_khr_icd.txt。这解释了为什么你的程序可能为每个安装的平台显示不同的数字。ICD 实际上标识了在您设置的机器上提供 OpenCL 实现的供应商,其主要功能是向主机代码暴露平台,以便开发者可以选择运行相关算法。ICD 包含两条信息——(a)供应商 OpenCL 实现在文件系统上安装的库中的入口点,(b)用于识别该供应商提供的 OpenCL 扩展后缀的字符串。

获取 OpenCL 子缓冲区对象的信息

获取 OpenCL 子缓冲区信息的检索与之前菜谱中描述的非常相似,涉及到调用clGetMemObjInfo。让我们来看看它。

小贴士

OSX 注意事项——你需要至少 OpenCL 1.1 的实现来看到这个构建和运行;由于 OSX 10.6 不支持这个版本,你将需要获取 OSX 10.7 来运行这段代码。

准备工作

Ch2/sub_buffer_query/subbuffer_query.c中,你可以找到以下代码的摘录,展示了我们如何将子缓冲区内存对象传递给我们的定义函数displayBufferDetails

cl_buffer_region region;
region.size = sizeof(UserData)*DATA_SIZE;
region.origin = 0;
cl_mem subUDObj = clCreateSubBuffer(UDObj,
                                    CL_MEM_READ_WRITE, // read-write
                                    CL_BUFFER_CREATE_TYPE_REGION,
                                    &region, &error);
displayBufferDetails(subUDObj);

小贴士

在我的实验过程中,我发现与 AMD 的 APP SDK v2.7 相比,NVIDIA CUDA 5 OpenCL 工具包在评估传递给clCreateSubBuffer的参数标志中的属性时更为严格。请注意,当你阅读这本书的时候,这个错误可能已经被修复了。作为一个具体的例子,以下代码在 NVIDIA 上运行时会抛出错误,而在 AMD 上则不会,当你编写以下内容时:

clCreateSubBuffer(buffer,CL_MEM_READ_WRITE|CL_MEM_COPY_HOST_PTR,…)以反映CL_MEM_COPY_HOST_PTR没有意义的事实。

如何去做…

我们已经包括了本菜谱的主要部分,如下面的代码所示:

void displayBufferDetails(cl_mem memobj) {
  cl_mem_object_type objT;
  cl_mem_flags flags;
  size_t memSize;
  size_t memOffset;
  cl_mem mainBuffCtx;
  clGetMemObjectInfo(memobj, CL_MEM_TYPE,
                     sizeof(cl_mem_object_type), &objT, 0);
  clGetMemObjectInfo(memobj, CL_MEM_FLAGS, sizeof(cl_mem_flags),
                     &flags, 0);
  clGetMemObjectInfo(memobj, CL_MEM_SIZE, sizeof(size_t),
                     &memSize, 0);
  clGetMemObjectInfo(memobj, CL_MEM_OFFSET, sizeof(size_t),
                     &memOffset, 0); // 'CL_MEM_OFF_SET' new in OpenCL 1.2
  clGetMemObjectInfo(memobj, CL_MEM_ASSOCIATED_MEMOBJECT,
                     sizeof(size_t),
                     &memOffset, 0);
  char* str = '\0';
  if (mainBuffCtx) { // implies that 'memobj' is a sub-buffer
    switch (objT) {
      case CL_MEM_OBJECT_BUFFER: str = "Sub-buffer";break;
      case CL_MEM_OBJECT_IMAGE2D: str = "2D Image Object";break;
      case CL_MEM_OBJECT_IMAGE3D: str = "3D Image Object";break;
    }
  } else {
switch (objT) {
  case CL_MEM_OBJECT_BUFFER: str = "Buffer";break;
  case CL_MEM_OBJECT_IMAGE2D: str = "2D Image Object";break;
  case CL_MEM_OBJECT_IMAGE3D: str = "3D Image Object";break;
  } 
}
  char flagStr[128] = {'\0'};
  if(flags & CL_MEM_READ_WRITE) strcat(flagStr, "Read-Write|");
  if(flags & CL_MEM_WRITE_ONLY) strcat(flagStr, "Write Only|");
  if(flags & CL_MEM_READ_ONLY)  strcat(flagStr, "Read Only|");
  if(flags & CL_MEM_COPY_HOST_PTR) strcat(flagStr, "Copy from
                                                    Host|");
  if(flags & CL_MEM_USE_HOST_PTR)  strcat(flagStr, "Use from
                                                    Host|");
  if(flags & CL_MEM_ALLOC_HOST_PTR) strcat(flagStr, "Alloc from
                                                     Host|");
  printf("\tOpenCL Buffer's details =>\n\t size: %lu MB,\n\t object type is: %s,\n\t flags:0x%lx (%s) \n", memSize >> 20, str, flags, flagStr);
}

在 Ubuntu Linux 12.04 和 AMD 的 APP SDK v2.8 上,以下命令就足够了:

gcc –std=c99 –Wall –DUNIX –g –DDEBUG –m64 –o subbuffer _query subbuffer_query.c –I. –I/opt/AMDAPP/include –L/opt/AMDAPP/lib/x86_64 –lOpenCL

对于 Intel OpenCL SDK,你需要输入以下命令:

gcc –std=c99 –Wall –DUNIX –g –DDEBUG –m64 –o subbuffer_query subbuffer_query.c –I. –I/usr/include 
–L/usr/lib64/OpenCL/vendors/intel
-lintelocl
-ltbb
-ltbbmalloc
-lcl_logger
-ltask_executor

对于 Ubuntu Linux 12.04 上的 NVIDIA,你需要输入以下命令:

gcc –std=c99 –Wall –DUNIX –g –DDEBUG –m64 –o subbuffer_query subbuffer_query.c –I. –I/usr/local/cuda/include –lOpenCL

不论是哪个平台,一个二进制可执行文件subbuffer_query都会被本地存储。

当你运行程序时,你应该得到以下类似的输出:

Number of OpenCL platforms found: 2
Kernel name: hello with arity: 1
About to create command queue and enqueue this kernel...
    OpenCL Buffer's details =>
    size: 128 MB,
   object type is: Buffer,
    flags:0x21 (Read-Write|Copy from Host|) 
    OpenCL Buffer's details =>
    size: 128 MB,
    object type is: Sub-buffer,
    flags:0x1 (Read-Write|) 
Task has been enqueued successfully!
Check passed!

它是如何工作的…

应用程序能够解析它是否是一个 OpenCL 子缓冲区对象,这要归功于 OpenCL 1.2 中引入的两个标志。它们是CL_MEM_OFFSETCL_MEM_ASSOCIATED_MEMOBJECT;使用任一标志都可以揭示它是否是一个子缓冲区,但要注意的是,对于子缓冲区,CL_MEM_OFFSET可以是零,因为这表示 OpenCL 从哪里开始提取数据;一个更好、更推荐的选择是使用CL_MEM_ASSOCIATED_MEMOBJECT,因为它的存在意味着参数memobj是一个子缓冲区。参见之前的菜谱,获取 OpenCL 缓冲区对象信息

理解事件和事件同步

之前的菜谱展示了如何创建封装要从一个主机内存传输到设备内存的数据的内存对象,并讨论了如何通过子缓冲区将输入数据分配到各个设备。

在这个菜谱中,我们将探讨开发者如何利用 OpenCL 的事件系统来控制内核命令以及内存命令的执行。这对开发者来说是有益的,因为它提供了多种控制异构环境中执行流程的方法。

当开发者希望被通知某个事件发生,并可以选择对那个事件之后进行处理的操作时,事件通常是一种被动的机制;与轮询(polling)相对,轮询是一种更主动的机制,因为应用程序会主动查询当前状态,并在满足特定条件时决定下一步操作。

OpenCL 中的事件分为以下两类:

  • 主机监控事件

  • 命令事件

在这两种事件类型中,开发者需要显式创建事件,并通过等待列表(waitlists)将它们与对象关联起来;等待列表实际上只是一个容器,其中包含命令必须等待完成的事件,也就是说,在执行下一个命令之前,事件的状态必须是CL_COMPLETECL_SUCCESS。这两种事件类型(正如我们很快将看到的)之间的区别在于队列中下一个后续命令的执行方式,主机事件由开发者更新,当完成更新时,程序源会指示这一点;另一方面,等待列表上的命令事件由 OpenCL 运行时更新。考虑到在等待列表中保留的事件必须在执行下一个命令之前达到某种状态,这意味着等待列表实际上是同步点,因为没有清空该列表就无法取得进展。

让我们先来检查主机事件。到目前为止,我们已经了解到需要将命令放入命令队列,以便它们可以被调度执行,而主机监控事件允许开发者监控已入队的命令状态,并且我们可以选择性地将回调函数附加到事件上,以便当它返回我们期望的状态时,回调函数将执行。这是通过 API clCreateUserEventclSetUserEventStatusclReleaseEventclSetEventCallback实现的。在如何操作部分的一个示例将说明如何实现这一点。

准备工作

假设一个内核希望处理两个名为objAobjB的 1D 内存对象,并将结果写入objC(对于这个例子,我们可以忽略objC的输出)。我们希望从objB复制输入数据只在我们指示主机程序时进行。

如何操作...

完整的源代码在Ch2/events/{events.c,sample_kernel.cl}中演示,我们必须首先创建必要的数据结构,就像之前一样;接下来,我们将创建事件对象,如下所示:

event1 = clCreateUserEvent(context, &ret);

在此事件对象中,我们接下来可以为事件分配一个回调函数,并指示当事件的状态变为CL_COMPLETE时,回调函数将像以下代码那样执行:

void CL_CALLBACK postProcess(cl_event event, cl_int status, void *data) {
  printf("%s\n", (char*)data);
}
clSetEventCallback(event1, CL_COMPLETE, &postProcess, "Looks like its done.");

然后,主机程序将继续为objAobjB执行内存传输,但不会继续处理命令队列上排队的任何更多 OpenCL 命令,直到event1的状态设置为CL_COMPLETE

ret = clEnqueueWriteBuffer(command_queue, objA, CL_TRUE, 0, 4*4*sizeof(float), A, 0, NULL, NULL );
  printf("A has been written\n");
  /* The next command will wait for event1 according to its status*/
  ret = clEnqueueWriteBuffer(command_queue, objB, CL_TRUE, 0, 4*4*sizeof(float), B, 1, &event1, NULL);
  printf("B has been written\n");
clSetUserEventStatus(event1, CL_COMPLETE);
//….code omitted
clReleaseEvent(event1);

我们将要介绍的另一个 API 是clWaitForEvents,其签名如下:

Cl_int clWaitForEvents(cl_uint num_events, const cl_event* event_list);

这通常用于使主线程暂停,直到事件列表中的所有命令都完成(下一个代码片段将演示如何实现)。

我们接下来要讨论的主题是命令事件,通常在你想被通知与命令相关联的某些事件时使用。一个典型的用例如下,你有一个命令队列,你希望被通知内存传输命令(如 clEnqueueWriteBuffer)的状态,并根据该状态采取特定行动:

cl_event event1;
// create memory objects and other stuff
ret = clEnqueueWriteBuffer(queue, object, CL_TRUE, 0, 1048576, hostPtrA, 1, &event1, NULL);
clWaitForEvents(&event1); // stalls the host thread until 'event1' has a status of CL_COMPLETE.

你可以很容易地推断出你有一个大型异构计算环境,有大量的 CPU 和 GPU,显然你希望最大化你的计算能力,OpenCL 中的事件机制允许开发者设计如何排序这些计算并协调这些计算。然而,作为最佳实践,你可能希望清理与命令关联的事件对象,但你需要发现你正在观察的事件的状态,否则你可能会提前释放事件,以下是如何通过轮询 API clGetEventInfo 并传入你正在观察的事件来完成这一操作;以下代码演示了这个想法:

int
waitAndReleaseEvent(cl_event* event) {
  cl_int eventStatus = CL_QUEUED;
  while(eventStatus != CL_COMPLETE) {
    clGetEventInfo(*event, 
                   CL_EVENT_COMMAND_EXECUTION_STATUS,
                   sizeof(cl_int), 
                   &eventStatus, NULL);
  }
  clReleaseEvent(*event);
  return 0;
}

还有更多…

有两种情况值得提及,它们涉及到(a)你希望为事件组接收通知(假设它们与内存对象相关联)和(b)你希望暂停管道中任何命令的执行,即命令队列,直到你正在观察的这一组事件完成。API clEnqueueMarkerWithWaitList 用于前者,而 clEnqueueBarrierWithWaitList 适用于后者。你被鼓励在 OpenCL 1.2 规范中探索它们。

注意

如果你仍在使用 OpenCL 1.1,你可以使用 clEnqueueMarkerclEnqueueBarrier(它们是 clEnqueueMarkerWithWaitListclEnqueueBarrierWithWaitList 的旧版本),但请注意,它们在 OpenCL 1.2 中都被弃用了。

在内存对象之间复制数据

你会很快意识到 OpenCL 中的事件机制在控制算法的各个部分时是多么有用,它可以在常见的内核和内存命令中找到。本食谱将从创建内存对象开始,重点关注这些内存对象如何从主机内存传输到设备内存,反之亦然,我们将专注于数据传输 API clEnqueueReadBufferclEnqueueWriteBuffer,这是用于一维数据块,以及 clEnqueueReadBufferRectclEnqueueWriteBufferRect 用于二维数据块;我们还将查看 clEnqueueCopyBuffer 用于设备内存中内存对象之间的数据传输。首先,我们来看一下在内存对象之间复制数据。

有时会需要在不同内存对象之间复制数据,OpenCL 为我们提供了一个方便的方法,通过 clEnqueueCopyBuffer 来实现。这只能发生在两个不同的内存对象之间(例如,一个是普通缓冲区,另一个是子缓冲区)或者两个相似的对象之间(例如,两者都是子缓冲区或普通缓冲区),并且复制的区域不能重叠。以下是方法签名:

cl_int clEnqueueCopyBuffer(cl_command_queue command_queue,
                           cl_mem src_buffer,
                           cl_mem dst_buffer,
                           size_t src_offset,
                           size_t dst_offset,
                           size_t cb,
                           cl_uint num_events_in_wait_list,
                           const cl_event* event_wait_list,
                           cl_event* event)

在内存对象之间复制数据的函数列表如下:

  • clEnqueueCopyBuffer

  • clEnqueueCopyImage

  • clEnqueueCopyBufferToImage

  • clEnqueueCopyImageToBuffer

  • clEnqueueCopyBufferRect

要复制一个缓冲区,你需要通过 src_bufferdst_buffer 指示源和目标 cl_mem 对象,通过 src_offsetdst_offset 分别指示 src_bufferdst_buffer 的偏移量以及要复制的数据大小通过 cb。如果你希望在某些操作之后进行数据复制,你需要指示这些操作的数量以及代表每个操作的 cl_event 对象的有效数组,分别通过 num_events_in_wait_listevent_wait_list

小贴士

注意,你可以通过传递事件对象到 event 参数来查询设备复制状态,当你的数据数组很大时。另一种方法是排队一个 clEnqueueBarrier 命令。

准备就绪

以下代码是 Ch2/copy_buffer/copy_buffer.c 的摘录,它说明了如何将 clEnqueueCopyBuffer 命令排队到命令队列中,并且内核使用这个数据副本进行计算。这个过程在机器上检测到的所有 OpenCL 设备之间迭代。以下图解说明了原始数据块(前一个图)是如何复制到另一个 cl_mem 对象(下一个图)并传递给 OpenCL 设备进行计算的。

准备就绪

如何操作...

我们已经包括了这个菜谱的主要部分,并带有高亮注释:

cl_mem UDObj = clCreateBuffer(context,
                              CL_MEM_READ_WRITE |
                              CL_MEM_COPY_HOST_PTR,
                              sizeof(UserData) * DATA_SIZE, 
                              ud_in, &error);
… // code omitted. See the source.
/* Create a buffer from the main buffer 'UDObj' */
cl_mem copyOfUDObj = clCreateBuffer(context, CL_MEM_READ_WRITE,	                                               
                                    sizeof(UserData) * DATA_SIZE,
                                    0, &error)
if (error != CL_SUCCESS) { 
  perror("Unable to create sub-buffer object");
  exit(1);
}
/* Let OpenCL know that the kernel is suppose to receive an argument */
error = clSetKernelArg(kernels[j], 
                       0,
                       sizeof(cl_mem),
                       &copyOfUDObj);
if (error != CL_SUCCESS) { 
  perror("Unable to set buffer object in kernel");
  exit(1);
}
// code omitted. See the source.
/* Enqueue the copy-write from device to device */
error = clEnqueueCopyBuffer(cQ,
                            UDObj,
                            copyOfUDObj,              
                            0,            // copy from which offset
                            0,            // copy to which offset
                            sizeof(UserData)*DATA_SIZE,
                            0, NULL, NULL);
printf("Data will be copied!\n");
// Code for enqueueing kernels is omitted.
/* Enqueue the read-back from device to host */
error = clEnqueueReadBuffer(cQ, 
                            copyOfUDObj, 
                            CL_TRUE,  // blocking read
                            0,        // read from the start
                            sizeof(UserData)*DATA_SIZE,
                            ud_out, 0, NULL, NULL);

在 OSX 上,你可以运行以下命令:

gcc -std=c99 -Wall -DUNIX -g  -DAPPLE -arch i386 -o copy_buffer copy_buffer.c   -framework OpenCL

在安装了 Intel OpenCL SDK 的 Ubuntu Linux 12.04 上,你可以运行以下命令:

gcc -std=c99 -Wall -DUNIX -g -DDEBUG -m64 -o copy_buffer copy_buffer.c -I . -I /usr/include -L/usr/lib64/OpenCL/vendors/intel -lintelocl -ltbb -ltbbmalloc -lcl_logger -ltask_executor

在安装了 NVIDIA CUDA 5 的 Ubuntu Linux 12.04 上,你可以运行以下命令:

gcc -std=c99 -Wall -DUNIX -g -DDEBUG -m64 -o copy_buffer copy_buffer.c -I. -I/usr/local/cuda/include  -lOpenCL

一个名为 copy_buffer 的二进制可执行文件将被放置在目录中。

根据你的机器上安装了多少个 OpenCL SDK,你的输出可能会有所不同,但在我自己的 OSX 上,以下是这样的输出:

Number of OpenCL platforms found: 1
Number of detected OpenCL devices: 2
Kernel name: hello with arity: 1
About to create command queue and enqueue this kernel...
Task has been enqueued successfully!
Data will be copied!
Check passed!
Kernel name: hello with arity: 1
About to create command queue and enqueue this kernel...
Task has been enqueued successfully!
Data will be copied!
Check passed!

它是如何工作的...

应用程序需要计算复制的缓冲区,你可以通过以下语句定义 clSetKernelArg 来判断这一点:

error = clSetKernelArg(kernels[j], 0, sizeof(cl_mem), &copyOfUDObj);

接下来,我们可以通过 clEnqueueCopyBuffer 执行一个复制操作,该操作发生在设备的内存中,并通过 clEnqueueReadBuffer 最终检索计算出的值。

小贴士

创建的命令队列将默认为顺序执行,而不是乱序执行,因此设备将按照队列的顺序执行命令。

现在,我们将讨论一维和二维数据传输 API,如clEnqueueReadBufferclEnqueueWriteBufferclEnqueueWriteBufferRectclEnqueueReadBufferRect,我们现在这样做是因为你已经看到,到目前为止,我们的大部分示例都是通过clCreateBuffer与主机中的内存结构关联来演示内存对象的创建,尽管这可能适用于某些情况,你可能希望有更多控制的 API,当设备内存中的内存对象需要写入或读取时。这些 API 给予开发者的控制,是因为它们被排入命令队列,并带有开发者可能创建的任何事件;这为在异构环境中构建 I/O 提供了良好的策略和灵活性。

注意

注意,有类似的 API 用于从主机到设备内存读取和写入二维或三维图像。它们的名称是clEnqueueReadImageclEnqueueWriteImageclEnqueueReadImageRectclEnqueueWriteImageRect。有关更多详细信息,请参阅 OpenCL 1.2 规范。

这些 API 允许我们向设备指示我们希望数据传输何时发生,这与clEnqueueCopyBuffer非常相似。让我们看看它们的方法签名:

cl_int clEnqueueReadBuffer(cl_command_queue command_queue,
                        cl_mem buffer,
                           cl_bool blocking_read, 
                           size_t offset,
                           size_t cb, 
                           void *ptr,
                           cl_uint num_events_in_wait_list,
                           const cl_event *event_wait_list,
                           cl_event *event)
cl_int clEnqueueWriteBuffer(cl_command_queue command_queue,
                        cl_mem buffer,
                           cl_bool blocking_write, 
                           size_t offset,
                           size_t cb, 
                           const void *ptr,
                           cl_uint num_events_in_wait_list,
                           const cl_event *event_wait_list,
                           cl_event *event)

这两个函数彼此非常相似,它们基本上说明如果你想要读写内存缓冲区,即cl_mem对象,你需要通过command_queue指定哪个命令队列,通过buffer指定哪个缓冲区,是否为阻塞读写通过blocking_read/blocking_write,通过offsetcb指定从哪里读取/写入什么大小的数据,通过ptr指定从哪里读取数据或写入数据到,是否在某个事件之后执行这个读写命令通过num_events_in_wait_listevent_wait-list。函数的最后一个参数是event,它允许查询读取或写入操作,这在clEnqueueCopyBuffer中有描述。

clEnqueuReadBuffer中的阻塞读取意味着命令不会退出,直到主机指针被设备内存缓冲区填充;同样,在clEnqueueWriteBuffer中的阻塞写入意味着命令不会退出,直到整个设备内存缓冲区被主机指针写入。

要了解这些调用是如何使用的,你可以参考早期示例中的代码,在配方理解事件和事件同步中,为了方便起见,以下是在Ch2/events/events.c中的相关代码:

ret = clEnqueueWriteBuffer(command_queue, objA, CL_TRUE, 0, 4*4*sizeof(float), A, 0, NULL, NULL );
ret = clEnqueueWriteBuffer(command_queue, objB, CL_TRUE, 0, 4*4*sizeof(float), B, 1, &event1, NULL);

能够模拟一维内存对象的能力非常棒,但 OpenCL 更进一步,通过促进二维内存对象内存传输。

这里是一个从设备内存读取二维数据块到主机内存输出缓冲区的示例;摘自Ch2/simple_2d_readwrite/simple_2d_readwrite.c。代码展示了如何使用buffer_originhost_originregion,正如 API 中所示。应用程序将从表示一维输入数据的UDObj cl_mem对象中读取,将其作为 2 x 2 矩阵写入由outputPtr表示的主机内存数据块。应用程序将从设备读取数据到主机内存,并检查数据是否合理。

cl_int hostBuffer[NUM_BUFFER_ELEMENTS] = {0, 1, 2, 3, 4, 5, 6, 7,
                                          8,9,10,11,12,13,14,15};
cl_int outputPtr[16] = {-1, -1, -1, -1,-1, -1, -1, -1,-1, -1, -1, 
                        -1,-1, -1, -1, -1};
for(int idx = 0; idx < 4; ++ idx) {	
    size_t buffer_origin[3] = {idx*2*sizeof(int), idx, 0}; 
    size_t host_origin[3] = {idx*2*sizeof(int), idx, 0}; 
    size_t region[3] = {2*sizeof(int), 2, 1};
error = clEnqueueReadBufferRect (cQ,
                                 UDObj,
                                 CL_TRUE,
                                 buffer_origin,
                                 host_origin,
                                 region,
                                 0, //buffer_row_pitch,
                                 0, //buffer_slice_pitch,
                                 0, //host_row_pitch,
                                 0, //host_slice_pitch,
                                 outputPtr,
                                 0, NULL, NULL);
}//end of for-loop

在这个例子中,我们使用了C语言中的for循环和标准数组索引技术来模拟如何遍历二维数组并引用元素,以便我们逐步复制输入。我们不会过多地深入这个话题,因为构建和运行它与之前非常相似,你应该探索目录以了解如何通过 Makefile 构建和运行程序。

使用工作项来分区数据

在上一章中,我们介绍了如何在多个工作项之间对一维数组进行分区(如果你现在记不起来的话,应该翻回来看),以及每个工作项将如何获得一个索引,内核可以使用这个索引在内核代码vector_multiplication中进行计算。在本食谱中,我们将在此基础上,更详细地探索二维数据分区。

到现在为止,你应该意识到 OpenCL 的一个基石是将数据通过内核传输到设备/进行处理,你已经看到了数据如何通过内核在不同设备之间进行分区。在前者中,你看到了我们如何使用分布式数组模式将数据分区到设备中;这指的是粗粒度数据并行性。后者指的是 OpenCL 提供的粗粒度任务并行性,它之所以是粗粒度的,是因为 OpenCL 既能实现数据并行性,也能实现任务并行性。

你到目前为止看到的代码大部分都使用了clEnqueueTask来根据一维数据块执行内核,并且为了让内核处理二维或三维数据,我们需要理解clEnqueueNDRangeKernel;以及如何在二维或三维空间中概念性地布局数据。

小贴士

在设备内存中可视化二维或三维数据布局为基于行而不是基于列是有帮助的。

clEnqueueNDRangeKernel中的NDRange指的是一种数据索引方案,它应该跨越一个 N 维值的范围,因此得名。目前,在这个 N 维索引空间中,N可以是 1、2 或 3。接下来,我们可以将每个维度分成大小为 2、3、4 或更大的块,直到达到参数CL_DEVICE_MAX_WORK_ITEM_DIMENSIONS允许的最大值。有关如何获取值的说明,请参阅Ch1/device_details/device_details.c。这将决定我们可以并行运行多少个处理组,在 OpenCL 中它们被称为工作组。工作组将有一些可用的处理单元,这些单元被称为工作项,尽管我喜欢把它们看作是可执行的线程。

让我们通过一个例子来理解,使用 12 行 12 列的二维数据大小,即一个 12 x 12 的矩阵。让我们看一下以下图表,以了解工作组和工作项之间的关系:

使用工作项划分数据

在这个例子中,我决定将二维空间划分为九个工作组,其中每个工作组是一个 4 x 4 的矩阵。接下来,为了决定每个工作组中应该有多少个工作项,你有两个选择:a) 将一个工作项分配给处理 4 x 4 矩阵中的每个单元格,b) 将一个工作项分配给处理 4 x 4 矩阵中的 n 个单元格;在第二种情况下,它类似于向量处理,其中 n 个值一起加载以供工作项处理。让我们假设我们已经决定选择选项 a

提示

我们将在第三章中查看各种数据类型,理解 OpenCL 数据类型

现在,让我们详细看看 API clEnqueueNDRangeKernel及其以下方法签名,并了解如何通过我们的例子输入这些值:

cl_int
clEnqueueNDRangeKernel(cl_command_queue command_queue,
                       cl_kernel kernel,
                       cl_uint work_dim,
                       const size_t *global_work_offset,
                       const size_t *global_work_size,
                       const size_t *local_work_size,
                       cl_uint num_events_in_wait_list,
                       const cl_event *event_wait_list,
                       cl_event *event)

让我们看看clEnqueueNDRangeKernel中的那些变量是用来做什么的;command_queue指的是特定的队列,如kernel,用于执行。接下来,你需要通过work_dim指示你的输入数据有多少维;接下来的两个变量global_work_sizelocal_work_size将指示有多少个工作组以及每个工作组中可以执行多少个工作项/工作线程。回想一下,内核在设备上被调度,但工作组会分配设备的计算单元,工作项在计算单元的处理器上执行。接下来,如果你需要内核的启动等待算法中的几个事件,你可以通过num_events_in_wait_listevent_wait_list来指示它们,最后,如果你希望将事件关联到这个内核的状态,你可以通过这个 API 中的event传递一个事件类型。

到现在为止,方法签名不应该让你感到那么令人生畏。给定一个 12 x 12 的矩阵,划分为九个工作组,其中每个工作组是一个 4 x 4 的矩阵,每个工作项将处理一个数据单元格,我们将像以下代码片段那样编写代码:

 cl_uint work_dim = 2; // 2-D data
 size_t global_work_offset[2] = {0,0}; // kernel evals from (0,0)
 size_t global_work_size[2] = {12,12};
 size_t local_work_size[2]  = {4,4};
 clEnqueueNDRangeKernel(command_q, kernel, work_dim,
 global_work_offset,global_work_size, local_work_size, 0,
 NULL,NULL);

为了确保你的计算正确,你可以使用以下简单的公式:

小贴士

工作组数量 = (global_work_size[0]global_work_size[n-1]) / (local_work_size[0]local_work_size[n-1])

接下来,我们将看看如何使这项任务并行和数据并行能够由 CPU 和 GPU 处理,其中每个设备将从输入缓冲区复制一个一维数据数组,并将其视为二维矩阵进行并行计算,最后将结果输出到一个一维矩阵中。

准备工作

Ch2/work_partition/work_partition.c 中,我们看到了一个摘录,其中我们需要使用二维数据格式从输入缓冲区复制一百万个元素到输出缓冲区。我们继续将数据划分为一个 1024 x 1024 的矩阵,其中每个工作项处理一个单元格,我们创建大小为 64 x 2 矩阵的工作组。

小贴士

注意——在我的实验中,这个程序在运行 OSX 10.6 Intel Core i5 和 OpenCL 1.0 时崩溃了,因为在每个维度上工作组只能有一个大小。我们将在第三章 理解 OpenCL 数据类型 中探讨如何使我们的程序更具可移植性。

内核函数 copy2Dfloat4 是一个典型的在设备上执行的功能,我们希望表达从一点到另一点传输元素向量的想法,一旦完成,应用程序将进行数据完整性检查,这将使程序通过或失败;请参阅 Ch2/work_partition/work_partition.cl

如何操作...

我们在以下代码中包含了这个菜谱的主要部分,并突出显示了注释:

// --------- file: work_partition.cl --------------
#define WIDTH 1024
#define DATA_TYPE float4
/*
  The following macros are convenience 'functions'
  for striding across a 2-D array of coordinates (x,y)
  by a factor which happens to be the width of the block
  i.e. WIDTH
*/
#define A(x,y) A[(x)* WIDTH + (y)]
#define C(x,y) C[(x)* WIDTH + (y)]
__kernel void copy2Dfloat4(__global DATA_TYPE *A, __global DATA_TYPE *C)
{
    int x = get_global_id(0);
    int y = get_global_id(1);
    // its like a vector load/store of 4 elements
    C(x,y) = A(x,y);
}
// --------- file: work_partition.c ---------
cl_float* h_in = (float*) malloc( sizeof(cl_float4) * DATA_SIZE); // input to device
cl_float* h_out = (float*) malloc( sizeof(cl_float4) * DATA_SIZE); // output from device
  for( int i = 0; i < DATA_SIZE; ++i) {
    h_in[i] = (float)i;
  }
// code omitted
cl_mem memInObj = clCreateBuffer(context, CL_MEM_READ_WRITE | CL_MEM_COPY_HOST_PTR, sizeof(cl_float4) * (DATA_SIZE), h_in, &error);
cl_mem memOutObj = clCreateBuffer(context, 
                                  CL_MEM_WRITE_ONLY ,
                                  sizeof(cl_float4) * (DATA_SIZE),
                                  NULL, &error);
if(error != CL_SUCCESS) {
  perror("Can't create an output buffer object");
  exit(1);
}
/* Let OpenCL know that the kernel is suppose to receive two arguments */
error = clSetKernelArg(kernels[j], 0, sizeof(cl_mem), &memInObj);
if (error != CL_SUCCESS) {
  perror("Unable to set buffer object in kernel");
  exit(1);
}
error = clSetKernelArg(kernels[j], 1, sizeof(cl_mem), &memOutObj);
if (error != CL_SUCCESS) {
  perror("Unable to set buffer object in kernel");
  exit(1);
}
/* Enqueue the kernel to the command queue */
size_t globalThreads[2];
globalThreads[0]=1024;
globalThreads[1]=1024;
size_t localThreads[2];
localThreads[0] = 64;
localThreads[1] = 2;
cl_event evt;
error = clEnqueueNDRangeKernel(cQ, 
                               kernels[j],
                               2,
                               0,
                               globalThreads,
                               localThreads,
                               0, 
                               NULL, &evt);
clWaitForEvents(1, &evt);
if (error != CL_SUCCESS) {
  perror("Unable to enqueue task to command-queue");
  exit(1);
}
clReleaseEvent(evt);

在 OSX 上,你可以运行以下命令:

gcc -std=c99 -Wall -DUNIX -g  -DAPPLE -arch i386 -o work_partition work_partition.c   -framework OpenCL

在安装了 Intel OpenCL SDK 的 Ubuntu Linux 12.04 上,你可以运行以下命令:

gcc -std=c99 -Wall -DUNIX -g -DDEBUG -m64 -o work_partition work_partition.c -I . -I /usr/include -L/usr/lib64/OpenCL/vendors/intel -lintelocl -ltbb -ltbbmalloc -lcl_logger -ltask_executor

在安装了 NVIDIA CUDA 5 的 Ubuntu Linux 12.04 上,你可以运行以下命令:

gcc -std=c99 -Wall -DUNIX -g -DDEBUG -m64 -o work_partition work_partition.c -I. -I/usr/local/cuda/include  -lOpenCL

一个名为 work_partition 的二进制可执行文件将被存放在目录中。

在安装了 AMD APP SDK v2.8 和 NVIDIA CUDA 5 的 Ubuntu Linux 12.04 上,我得到了以下输出。如果你使用 Intel® OpenCL SDK 运行了程序,那么你将不会看到与离散图形芯片相关的输出。在这个例子中,我们展示了粗粒度和细粒度的数据以及任务并行:

Number of OpenCL platforms found: 2
Number of detected OpenCL devices: 1
Running GPU 
    => Kernel name: copy2Dfloat4 with arity: 2
    => About to create command queue and enqueue this kernel...
    => Task has been enqueued successfully!
Check passed!
Number of detected OpenCL devices: 1
Running on CPU ........
    => Kernel name: copy2Dfloat4 with arity: 2
    => About to create command queue and enqueue this kernel...
    => Task has been enqueued successfully!
Check passed!

它是如何工作的...

主应用程序分配了两个缓冲区,可以存储一百万个cl_float4类型的数据元素,这是一种 OpenCL 的vector数据类型。接下来,我们通过clBuildProgramWithSource(参考Ch2/work_partition/work_partition.c)构建程序,并从内核文件(*.cl)中提取所有内核。每个检测到的设备都会提取一个一维输入缓冲区,将其转换为二维矩阵,并将数据分配到其并行计算单元中,其中每个工作组将计算以下内容:

  • 通过get_global_id(0)获取行的索引;这可以被视为 x 轴上的线程 ID

  • 通过get_global_id(1)获取列的索引;这可以被视为 y 轴上的线程 ID

  • 与行和列索引一起,执行 4 个元素的内存加载,并通过C(x,y) = A(x,y)将其存储。

OpenCL 运行时会将数据分配到工作组中,同时还包括工作项和工作组的 ID;因此,不会出现线程 ID 重复的情况,从而避免对计算造成混乱(OpenCL 供应商有责任确保这种情况不会发生)。OpenCL 知道如何做到这一点,因为输入数据的维度、工作组的数量以及执行的工作项的数量都是通过clEnqueueNDRangeKernel API 中的work_dimglobal_work_sizelocal_work_size参数传递的。

以下是一个示例来澄清这一点:假设假设的输入数据具有二维,global_work_size为 8192,local_work_size为 16*16,那么我们将有 32 个工作组;为了能够引用二维数据块中的任何元素,你需要编写一些类似的代码来生成全局线程 ID(这不是唯一的方法,但通常是首选方法):

int x = get_local_id(0);//x would range from 0 to 15
int y = get_local_id(1);//y would range from 0 to 15
int blockIdX = get_group_id(0);
int blockIdY = get_group_id(1);
int blockSizeX = get_local_size(0); // would return 16
int blockSizeY = get_local_size(1); // would return 16
uint globalThreadId = (blockIdx * blockSizeX + x) + 
                      (blockIdY * blockSizeY + y);

由于调用了clWaitForEvents(我们将在下一章中讨论这一点),OpenCL 内核最终将完成其计算,然后通过clEnqueueReadBuffer将设备内存中的数据存储到输出缓冲区,并对数据进行合理性检查。

第三章:理解 OpenCL 数据类型

在本章中,我们将介绍以下内容:

  • 初始化 OpenCL 标量数据类型

  • 初始化 OpenCL 向量数据类型

  • 使用 OpenCL 标量类型

  • 理解 OpenCL 向量类型

  • 向量和标量地址空间

  • 配置你的 OpenCL 项目以启用双精度数据类型

简介

OpenCL 支持从 C 编程语言派生出的广泛的数据类型。它们被广泛分为两组,称为标量和向量。标量基本上是基本值,而向量是一组基本值,向量好的一点是许多 OpenCL SDK 供应商已经提供了自动化向量化,这使得值可以被加载到宽的,即 128 位、256 位或 512 位寄存器中进行消费。

OpenCL 标量整型数据类型包括boolcharshortintlongucharushortuintulong的符号和无符号类型;对于浮点值,有floathalfdouble。要在你的主机程序中表示这些类型,你只需在每个类型前加上字母cl_,OpenCL 编译器就会理解。

OpenCL 向量数据类型由多个标量整型和浮点数据类型组成,它们是char<N>short<N>int<N>long<N>uchar<N>ushort<N>uint<N>ulong<N>float<N>,其中<N>代表 2、3、4、8 或 16 的值。同样,你将在主机程序中通过在每个数据类型前加上字母cl_来表示这些类型。

在这两种情况下,如果你更喜欢无符号类型的显式形式,那么你可以将数据类型中的字母u替换为关键字unsigned

初始化 OpenCL 标量数据类型

在这个菜谱中,我们将演示初始化标量类型的各种方法,如果你已经使用 C 编程语言进行编程,那么大多数技术都会非常有意义。

准备工作

除了在 OpenCL 中定义的常规数据类型外,标准还添加了一些我们在上一节中提到之外的数据类型,以下表格展示了它们:

类型 描述
half 它是一个 16 位浮点数。half数据类型必须符合 IEEE 754-2008 half 精度存储格式。
bool 它是一个条件数据类型,其值评估为真或假。true 扩展为整数 1,而 false 扩展为 0。
size_t 它是 sizeof 运算符的结果的无符号整型。这可以是一个 32 位或 64 位无符号整型。
ptrdiff_t 它是一个 32 位或 64 位有符号整数,通常用于表示两个点相减的结果
intptr_t 它是一个 32 位或 64 位的有符号整数,具有任何有效指针都可以转换为该类型的属性,然后可以转换回指向 void,并且结果将与原始指针比较相等。
uintptr_t 它是一个 32 位或 64 位的无符号整数,具有与intptr_t相同的属性。

OpenCL 允许在源代码中使用以下数据类型进行互换:

OpenCL 中的类型 应用程序中的类型
bool n/a
char cl_char
unsigned char , uchar cl_uchar
short cl_short
unsigned short , ushort cl_ushort
int cl_int
unsigned int , uint cl_uint
long cl_long
unsigned long , ulong cl_ulong
float cl_float
double cl_double
half cl_half
size_t n/a
ptrdiff_t n/a
intptr_t n/a
uintptr_t n/a
void void

因此,以下是一些示例,说明您如何在内核和主机源代码中声明和定义标量数据类型:

float f = 1.0f;                  // In the OpenCL kernel
char c = 'a';                    // In the OpenCL kernel
const char* cs = "hello world\n";
cl_char c1 = 'b';                // in the host program
cl_float f1 = 1.0f;              // in the host program
const cl_char* css = "hello world\n";

在上一章理解 OpenCL 数据传输和分区中,我们花了一些时间讨论数据类型以及对齐是如何工作的,或者说,数据对齐如何影响性能。标量数据类型始终对齐到数据类型的大小(以字节为单位)。大小不是 2 的幂的内置数据类型必须对齐到下一个更大的 2 的幂。也就是说,char变量将对齐到 1 字节边界,float变量将对齐到 4 字节边界。

如何做到这一点...

如果您的应用程序需要用户定义的数据类型,那么您需要将这些类型放置在__attribute__((aligned))中;有关更多详细信息,请参阅第二章,理解 OpenCL 数据传输和分区

在 OpenCL 中,有几个运算符可以将操作数的值从一种类型转换为另一种类型,这通常被称为隐式转换;另一种方式是在操作数或二元运算的结果上应用类型转换操作。除了voidhalf数据类型外,支持标量内置类型之间的隐式转换。这可以通过以下代码说明:

cl_int x = 9;
cl_float y = x; // y will get the value 9.0

或者

int x = 9;
float y = x;  // y will get the value 9.0

您可以在应用程序代码中使用这两种形式。在 OpenCL 中,您也可以将一种数据类型强制转换为另一种数据类型,就像在 C 编程语言中做的那样。请参考以下示例:

float f = 1.0f;
int i = (int) f; // i would receive the value of 1

您还可以使用以下代码在 OpenCL 中将标量数据类型强制转换为向量数据类型:

float f = 1.0f;
float4 vf = (float4)f; // vf is a vector with elements (1.0, 1.0, 1.0, 1.0)
uchar4 vtrue = (uchar4)true; // vtrue is a vector with elements(true, true, true, true)
             // which is actually (0xff, 0xff, 0xff, 0xff)

初始化 OpenCL 向量数据类型

向量对于 OpenCL 程序员来说非常强大,因为它允许硬件批量加载/存储数据到/从内存;这类计算通常利用算法的空间和时间局部性属性。在本食谱中,我们将熟悉创建各种类型的向量。

准备工作

你可以通过两种主要方式初始化一个向量,如下所示:

  • 向量字面量

  • 向量组合

创建一个向量字面量简单地说就是你可以构造你想要的任何类型的向量,如下面的代码所示:

float a = 1.0f;
float b = 2.0f;
float c = 3.0f;
float d = 4.0f;
float4 vf = (float4) (a, b, c, d);
//vf will store (1.0f, 2.0f, 3.0f, 4.0f)

初始化向量的另一种方式是通过标量值,如下面的代码所示:

uint4 ui4 = (uint4)(9); // ui4 will store (9, 9, 9, 9)

你也可以以下这种方式创建向量:

float4 f = (float4) ((float2) (1.1f, 2.2f), 
                     (float2) (3.3f, 4.4f));
float4 f2 = (float4) (1.1f, (float2) (2.2f, 3.3f), 4.4f);

左侧和右侧的数据类型必须相同,否则 OpenCL 编译器将发出警告。

如何做到这一点...

向量还有一个显著特性,那就是你可以通过索引访问单个分量,也就是说,如果你想访问 float4 向量 v 的每个分量,那么你会通过 v.xv.yv.zv.w 分别进行,对于更大型的 8 或 16 元素向量,我们会通过 v.s0v.s1v.s7,以及 v.s0v.s1v.sav.sf 分别访问这些单个元素。因此,char2uchar2short2ushort2int2uint2long2ulong2float2 类型的向量可以访问它们的 .xy 元素。

以下是通过组合创建向量的另一种方式:

float4 c;
c.xyzw = (float4) (1.0f, 2.0f, 3.0f, 4.0f);
float4 d;
d.x = c.x;
d.y = c.y;
d.z = c.z;
d.w = c.w; // d stores (1.0f, 2.0f, 3.0f, 4.0f)

它是如何工作的...

在类似的情况下,你也可以使用数值索引来引用向量中的分量,并依次创建向量。以下表格显示了各种向量数据类型的索引列表:

向量分量 可以使用的数值索引
2-分量 0, 1
3-分量 0, 1, 2
4-分量 0, 1, 2, 3
8-分量 0, 1, 2, 3, 4, 5, 6, 7
16-分量 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, a, A, b, B, c, C, d, D, e, E, f, F

要使用这些数值索引,你必须以字母 sS 开头,以下是一些如何创建向量的快速示例:

float4 pos = (float4)(1.0f, 2.0f, 3.0f, 4.0f); 
float4 swiz= pos.wzyx; // swiz = (4.0f, 3.0f, 2.0f, 1.0f) 
float4 dup = pos.xxyy; // dup = (1.0f, 1.0f, 2.0f, 2.0f)
float4 f, a, b;
f.xyzw = a.s0123 + b.s0123;

还有更多...

最后,向量数据类型可以使用 .lo(或 .even)和 .hi(或 .odd)后缀来组合新的向量类型,或者将较小的向量类型组合成较大的向量类型。可以使用多级的 .lo(或 .even)和 .hi(或 .odd)后缀,直到它们指代一个标量项。.lo.hi 后缀指的是向量的下半部分,而向量的 .even.odd 后缀指的是向量的偶数和奇数元素。以下是通过组合创建向量的示例:

float4 vf = (float4) (1.0f, 2.0f, 3.0f, 4.0f);
float2 low = vf.lo; // returns vf.xy
float2 high = vf.hi; // returns vf.zw
float4 vf4 = (float4) (low, high);// returns (1.0f, 2.0f, 3.0f, 4.0f)

向量不允许隐式转换,因此你不能执行以下操作:

float4 vf4, wf4;
int4 if4;
wf4 = vf4; // illegal
if4 = wf4; // illegal

向量类型之间的显式转换也是不允许的,实际上,唯一可以显式转换为向量类型的情况是在使用标量初始化向量时:

float f = 4.4f;
float4 va = (float4) (f); // va stores ( 4.4f, 4.4f, 4.4f, 4.4f)

如果你通过后缀 .lo(或 .even)、.hi(或 .odd)提取 3 分量向量类型的分量,那么 3 分量向量类型会表现得像 4 分量向量类型,除了 w 分量是未定义的。

使用 OpenCL 矢量类型

标量数据类型与你在用 C 语言编程时预期的相当相似。然而,有两个主题值得更多关注,我们将在本食谱中涉及;我们将查看half数据类型,并检查 OpenCL 设备可能如何排序他们的数据。

准备工作

许多符合 OpenCL 规范的设备实际上都是小端架构,开发者需要确保他们的内核在大小端设备上都被测试过,以确保与当前和未来设备的源代码兼容性。让我们用一个简单的例子来说明字节序。

如何做…

考虑一个变量x,它持有值0x01234567,且x的地址从0x100开始。在计算机体系结构术语中,值0x01最高有效字节MSB),而0x67最低有效字节LSB)。大端存储方案首先存储 MSB 直到遇到 LSB,而小端存储方案首先存储 LSB 直到遇到 MSB。

大端

地址 0x100 0x101 0x102 0x103
0x01 0x23 0x45 0x67

小端

地址 0x100 0x101 0x102 0x103
0x67 0x45 0x23 0x01

注意

查看列在Ch3/byte_ordering/show_bytes.c中的完整代码,按照顺序运行cmakemake命令来编译代码;这将生成一个名为ShowBytes的二进制文件,然后运行该程序以查看其输出。此代码将打印出一系列输出,并且根据架构的字节序,你会注意到不同的字节序。

参考以下代码:

#include <stdio.h>
typedef unsigned char* byte_pointer;
void show_bytes(byte_pointer start, int len) {
  int i;
  for(i = 0; i < len; i++)
    printf(" %.2x", start[i]);
  printf("\n");
}
void show_int(int x) {
  show_bytes((byte_pointer) &x, sizeof(int));
}
void show_float(float x) {
  show_bytes((byte_pointer) &x, sizeof(float));
}
void show_pointer(void* x) {
  show_bytes((byte_pointer) &x, sizeof(void*));
}
void test_show_bytes(int val ) {
  int ival = val;
  float fval = (float) ival;
  int* pval = &ival;
  show_int(ival);
  show_float(fval);
  show_pointer(pval);
}

由于你已经理解了字节序如何影响数据(标量)的读取和写入方式;让我们看看字节序如何影响 OpenCL 中的向量数据类型。在向量数据类型中,每个值内字节的顺序以及值之间的顺序都被反转。以一个包含值0x000102030405060708090A0B0C0D0E0Fuint4向量为例,地址为0x100,以下表格显示了小端存储方案的外观:

0x100 0x104 0x108 0x1b0
0x0F0E0D0C 0x0B0A0908 0x07060504 0x3020100

如果你正在处理数据压缩和计算机图像算法,了解这一事实非常重要,因为这两类算法在字节级操作方面有大量的操作,你不想被这些问题困扰。

它是如何工作的…

被方便地称为halfhalf-precision数据类型实际上具有常规float类型一半的存储和精度。half类型符合 IEEE754-2008 标准,并由 NVIDIA 和工业光魔公司首次引入。你可以用这种类型做的唯一一件事是声明一个指向包含half值的缓冲区的指针;这些值必须是有限数、正常数、非规范化数、无穷大和 NaN。

您可以选择使用向量加载和存储函数,例如 vload_halfvload_halfnvstore_half 等。然而,请记住,加载/存储操作将创建一个中间的浮点值。

注意

load 函数从内存中读取 half 值并将其转换为常规的 float 值。store 函数接受一个 float 作为输入,将其转换为 half 值并将其存储到内存中。

要确定您的设备是否支持此功能,您可以在 Ch2/device_extension/device_extensions 目录中运行程序,输出应包含 cl_khr_fp16;或者,您可以通过传递参数 CL_DEVICE_EXTENSIONSclGetDeviceInfo 来查询设备。以下是从 Ch2/device_extensions/device_extensions.c 的代码片段:

/* --- file: device_extensions.c --- */
displayDeviceDetails( devices[i], CL_DEVICE_EXTENSIONS, "CL_DEVICE_EXTENSIONS");
void displayDeviceDetails(cl_device_id id,
                          cl_device_info param_name,
                          const char* paramNameAsStr) {
  cl_int error = 0;
  size_t paramSize = 0;
  error = clGetDeviceInfo( id, param_name, 0, NULL, &paramSize );
  if (error != CL_SUCCESS ) {
    perror("Unable to obtain device info for param\n");
    return;
  }
  /* the cl_device_info are preprocessor directives defined in cl.h */
  switch (param_name) {
// code omitted
    case CL_DEVICE_EXTENSIONS : {
// beware of buffer overflow; alternatively use the OpenCL C++ //bindings
      char* extension_info[4096];
      error = clGetDeviceInfo( id, CL_DEVICE_EXTENSIONS, sizeof(extension_info), extension_info, NULL);
      printf("\tSupported extensions: %s\n", extension_info);
    }break;
  } //end of switch

理解 OpenCL 向量类型

当您开始处理您的 OpenCL 项目时,您不可避免地会使用标量和向量数据类型来模拟算法。标量的工作方式类似于您在大多数编程语言中遇到的任何变量声明/定义,您应该将向量视为一个宽容器,可以并行地提供该容器中的所有项目,而区分标量和向量的唯一一点是,当对一个标量应用操作时,它只影响单个值,而当对向量应用相同的操作时,它并行地影响其中的所有项目。

在现代处理器中,存在一个专门的硬件单元,每个周期可以处理更多数据,它们通常被称为 单指令多数据SIMD)或称为 流式 SIMD 扩展SSE),这是英特尔对 SIMD 的实现。SIMD 指令提供的优势是它们允许在一个大寄存器中在一个周期内操作多个值;通常有很多这样的单元,从而提高程序的性能。我们应该清楚,SIMD 描述的是一个允许并行性发生的机制,这是从弗林分类法中获得的,而 SSE 描述了两个 CPU 处理器制造商,即英特尔和 AMD 如何实现 SIMD。

在揭示 OpenCL 内核如何在 GPU 上工作之前,故事的第一部分是告诉您 OpenCL 内核在 CPU 上是如何运行的,目前我们将注意力放在英特尔 CPU 架构上。在这些架构上,OpenCL 看到一个具有多个计算单元的单个设备,如果您猜测每个核心都是一个计算单元,那么您是对的,因此,除非您使用 OpenCL 1.2 中新引入的设备分裂扩展,否则您的内核将在所有计算单元上运行。

注意

OpenCL 1.2 中引入的设备分裂(cl_khr_device_fission)目前由英特尔、AMD 和 IBM Cell Broadband 的多核 CPU 支持。GPU 目前不支持。

故事的下一段是描述 OpenCL 内核如何在 AMD 制造的 GPU 上运行,我们关注的是本书中使用的基于 AMD 南方群岛架构的 AMD GPU,该架构包括他们的 Radeon HD 7900、7800 和 7700 GPU;顺便提一下,你可能希望查阅 NVIDIA 的网站以获取有关其 GPU 的更多产品细节,网址为www.nvidia.com

内核基本上执行基于标量或向量的指令,工作负载以 64 个工作项的块分配给计算单元,这被称为波前。波前有一个单独的程序计数器,被视为一个小的工作单元,这意味着它们以同步的方式执行。

当你的应用程序将工作负载传递给 GPU 时,它必须首先编译内核并将其加载到内存中。它还必须绑定源数据和结果数据的缓冲区,最后它将决定如何在 GPU 上执行给定的工作负载。当工作负载要执行时,GPU 将输入域划分为 64 线程的块,称为波前(wavefronts),并将它们调度到计算单元(compute unit,简称CU)。接下来,内核被检索到指令缓存中,计算单元开始向执行单元调度指令;每个计算单元可以并行处理多个波前,同时处理向量算术逻辑单元(ALU)计算以及内存访问。波前将继续执行,直到内核的末尾,此时波前将被终止,新的波前可以接替其在 GPU 上的位置。

考虑到波前对内存的访问是并行的,你可能会期望出现某种延迟,处理器在处理这种情况时相当聪明,它所做的就是并行执行许多波前,并且它的工作方式是,如果一个波前正在等待从内存中获取结果,其他波前可以发出内存请求,并且如果它们是独立的计算,它们可以在挂起的内存请求与并行执行 ALU 操作之间进行并行处理。可以从程序中提取的并行程度增加的因素各不相同,但其中之一就是可用于并行计算的硬件单元的实际数量,在 OpenCL 术语中,它被称为 CU,在 CPU 和 GPU 中,它们基本上是处理器。

计算单元是并行计算的基础,在南方群岛架构中,该架构支持其他产品,计算单元的数量会有所不同,每个计算单元基本上包含以下内容:

  • 标量算术逻辑单元(Scalar ALU)和标量通用寄存器(General-Purpose Registers,简称GPRs)也称为SGPRs

  • 四个 SIMD(单指令多数据),每个由一个向量算术逻辑单元(vector ALU)和向量通用寄存器(vector GPRs,简称 VGPRs)组成

  • 本地内存

  • 通过一级缓存对向量内存进行读写访问

  • 指令缓存,由四个计算单元共享,即计算单元

  • 常量缓存,由四个计算单元(compute units,简称 CUs)共享,即计算单元

现在我们将专注于 GPU 上的向量操作,这包括 ALU 和内存操作。每个四路 SIMD 包含一个在四个周期内对波前进行操作的向量-ALU;每个 SIMD 还可以托管十个正在飞行的波前,即一个 CU 可以并行执行四十个波前。在本书中使用的基于南方群岛架构的 AMD GPU(AMD HD 7870)上,我们有 20 个计算单元,我们知道每个 CU 包含四个 SIMD,每个 SIMD 执行一个波前意味着在任何时候我们都可以有 20 x 4 x 10 x 64 = 51,200 个工作项,如果您想象每个工作项都处于执行向量操作的阶段,那么 GPU 提供的并行性将远远大于 CPU;我们指的是具有 60 个核心的 Intel Xeon Phi,每个核心托管 4 个工作项,这提供了 60 x 4 = 240 个工作项;请注意,我们并不是说 GPU 优于 CPU,因为每个设备都有其特定的领域,但我们展示这些数字是为了说明一个简单的事实,即 GPU 的吞吐量高于 CPU。

说了这么多,我们很快将看到一个例子,但首先请记住,向量操作是按组件进行的,并且可以通过数字索引访问向量,每个索引可以组合成更大的索引组以执行内存的存储/加载操作。请参考以下代码:

float4 v, u;
float f;
v = u + f;
// equivalent to 
// v.x = u.x + f
// v.y = u.y + f
// v.z = u.z + f
// v.w = u.w + f
float4 a, b, c;
c = a + b
// equivalent to 
// c.x = a.x + b.x
// c.y = a.y + b.y
// c.z = a.z + b.z
// c.w = a.w + b.w

向量可以通过组件方式聚合以执行操作,而不需要代码冗余,这种方式实际上有助于程序员在日常工作中提高工作效率。接下来,我们可以深入了解向量类型是如何转换为利用您的硬件的。

准备工作

我们将要描述的演示有两个部分。首先,我们将使用 Windows 上的 Intel OpenCL 编译器来演示内核代码的隐式向量化;其次,我们将演示如何在您的代码中启用原生向量类型表示法,以表达使用 Linux 上的 AMD APP SDK v2.7 或 v2.8 生成向量化代码的愿望。

我们结合这两种方法,旨在解决将大输入数组从设备内存的一部分传输到另一部分的问题,最终我们提取并比较它们以检查是否相等。与之前一样,我们会在主机代码中准备传输的数据结构,并编写一个合适的 OpenCL 内核来实际传输内存内容。源代码可以在Ch3/vectorization中找到,我们使用 AMD APP SDK 构建程序。

注意

对于对 AMD CPU 和 GPU 平台的 OpenCL 代码生成感兴趣的读者,应咨询AMD CodeXL产品,因为 AMD APP 内核分析器已经停用。当您研究中间语言输出时,可能还需要查阅 AMD 中间语言手册。

隐式向量化是所有符合 OpenCL 编译器实现要求的必备功能,我们选择使用 Intel OpenCL 编译器来演示此功能的原因是生成的 SIMD 指令更有可能被读者识别,而不是其他编译器实现(如 AMD 或 NVIDIA 的)生成的中间代码。我们提供的内核代码可以在Ch3/vectorization/vectorization.cl中找到,如下所示:

__kernel void copyNPaste(__global float* in, __global float8* out) {
  size_t id = get_global_id(0);
  size_t index = id*sizeof(float8);
  float8 t = vload8(index, in);
  out[index].s0 = t.s0;
  out[index].s1 = t.s1;
  out[index].s2 = t.s2;
  out[index].s3 = t.s3;
  out[index].s4 = t.s4;
  out[index].s5 = t.s5;
  out[index].s6 = t.s6;
  out[index].s7 = t.s7;
}

此内核的主要操作是将内容从一个地方传输到另一个地方,它是通过使用两个包含八个浮点数的向量并行传输来实现的,你将注意到我们使用向量分量符号来明确表示这些内存传输。

在接下来的演示中,我们将从内核代码回到主机代码,假设开发者希望以更明确的方式控制代码生成;这可以通过原生向量类型符号来实现。

我们要求读者参考更多内容…部分以获取详细信息,但这里的演示基于开发者希望在设备内存传输完成后手动调整处理数据验证的程序的假设,这个函数可以在Ch3/vectorization/vectorization.c中找到,命名为valuesOK,以下是如何实现的代码:

#ifdef __CL_FLOAT4__
int valuesOK(cl_float8* to, cl_float8* from, size_t length) {
#ifdef DEBUG
  printf("Checking data of size: %lu\n", length);
#endif
  for(int i = 0; i < length; ++i) {
#ifdef __SSE__
    __cl_float4 __toFirstValue = to->v4[0];
    __cl_float4 __toSecondValue = to->v4[1];
    __cl_float4 __fromFirstValue = from->v4[0];
    __cl_float4 __fromSecondValue = from->v4[1];
    __m128i vcmp = (__m128i) _mm_cmpneq_ps(__toFirstValue, __fromFirstValue);
    uint16_t test = _mm_movemask_epi8(vcmp);
    __m128i vcmp_2 = (__m128i) _mm_cmpneq_ps(__toSecondValue, __fromSecondValue);
    uint16_t test_2 = _mm_movemask_epi8(vcmp_2);
    if( (test|test_2) != 0 ) return 0; // indicative that the result failed
#else
    #error "SSE not supported, which is required for example code to work!"
#endif
  }
return 1;
}
#endif

如何操作...

通过 Intel OpenCL 编译器实现隐式向量化相对简单,在这个简单的例子中,我们选择在 Windows 操作系统上安装它。你可以从software.intel.com/en-us/vcsource/tools/opencl下载编译器。

要见证如何通过此编译器实现隐式向量化,你需要将内核代码(前面的代码)复制粘贴到 GUI 的编辑面板中,并开始编译。一旦编译完成,你可以通过点击 GUI 上的ASMLLVM按钮来查看生成的代码。以下是一个示例截图:

如何操作…

下一步是手动调整我们的数据验证代码valuesOK以展示向量化。这个例子只是为了说明如何完成类似的事情,你不需要做任何事情,只需在Ch3/vectorization目录中调用make,一个可执行的向量化程序将被放入文件系统中,我们将在下一部分对其进行剖析。

注意

如果你在 Mac OSX 10.7 上运行 OpenCL 1.1,那么将标志–cl-auto-vectorizer-enable传递给clBuildProgram作为构建选项,将使 CPU 上将要执行的内核进行向量化。SIMD 指令将与你在本食谱中看到的大致相同。

以这种方式手动调整你的代码基本上是关闭了隐式向量化,你将需要根据你的场景判断这种努力是否值得,与问题的复杂性相比。要查看生成的 SIMD 代码,最好的做法是将程序放在调试器下,在 Linux 上,最好的调试器将是 GNU GDB。你基本上是将程序加载到调试器中,并发出命令 disassemble /m valuesOK 以验证是否确实生成了 SIMD 指令。以下是一个示例 gdb 会话,其中反汇编与源代码交织在一起:

$ gdb ./Vectorization 
GNU gdb (GDB) 7.5-ubuntu
Copyright (C) 2012 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later sa<http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law. Type "show copying" and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>...
Reading symbols from /home/tayboonl/PACKT_OpenCL_Book/src/Ch3/vectorization/Vectorization...done.
(gdb) disassemble /m valuesOK 
Dump of assembler code for function valuesOK:
warning: Source file is more recent than executable.
31 int valuesOK(cl_float8* to, cl_float8* from, size_t length) {
  0x000000000040117c <+0>: push  %rbp
  0x000000000040117d <+1>: mov  %rsp,%rbp
  0x0000000000401180 <+4>: sub  $0xf0,%rsp
  0x0000000000401187 <+11>: mov  %rdi,-0xd8(%rbp)
  0x000000000040118e <+18>: mov  %rsi,-0xe0(%rbp)
  0x0000000000401195 <+25>: mov  %rdx,-0xe8(%rbp)
32 #ifdef DEBUGf
33 printf("Checking data of size: %lu\n", length);
  0x000000000040119c <+32>: mov  -0xe8(%rbp),%rax
  0x00000000004011a3 <+39>: mov  %rax,%rsi
  0x00000000004011a6 <+42>: mov  $0x4020a8,%edi
  0x00000000004011ab <+47>: mov  $0x0,%eax
  0x00000000004011b0 <+52>: callq  0x400f20 <printf@plt>
34 #endif
35 for(int i = 0; i < length; ++i) {
  0x00000000004011b5 <+57>: movl  $0x0,-0xc4(%rbp)
  0x00000000004011bf <+67>: jmpq  0x4012a9 <valuesOK+301>
  0x00000000004012a2 <+294>: addl  $0x1,-0xc4(%rbp)
  0x00000000004012a9 <+301>: mov  -0xc4(%rbp),%eax
  0x00000000004012af <+307>: cltq  
  0x00000000004012b1 <+309>: cmp  -0xe8(%rbp),%rax
  0x00000000004012b8 <+316>: jb  0x4011c4 <valuesOK+72>
36 #ifdef __SSE__
37    __cl_float4 __toFirstValue = to->v4[0];
  0x00000000004011c4 <+72>: mov  -0xd8(%rbp),%rax
  0x00000000004011cb <+79>: movaps (%rax),%xmm0
  0x00000000004011ce <+82>: movaps %xmm0,-0xc0(%rbp)
38    __cl_float4 __toSecondValue = to->v4[1];
  0x00000000004011d5 <+89>: mov  -0xd8(%rbp),%rax
  0x00000000004011dc <+96>: movaps 0x10(%rax),%xmm0
  0x00000000004011e0 <+100>: movaps %xmm0,-0xb0(%rbp)

39    __cl_float4 __fromFirstValue = from->v4[0];
  0x00000000004011e7 <+107>: mov  -0xe0(%rbp),%rax
  0x00000000004011ee <+114>: movaps (%rax),%xmm0
  x00000000004011f1 <+117>: movaps %xmm0,-0xa0(%rbp)
40    __cl_float4 __fromSecondValue = from->v4[1];
  0x00000000004011f8 <+124>: mov  -0xe0(%rbp),%rax
  0x00000000004011ff <+131>: movaps 0x10(%rax),%xmm0
  0x0000000000401203 <+135>: movaps %xmm0,-0x90(%rbp)
  0x000000000040120a <+142>: movaps -0xc0(%rbp),%xmm0
  0x0000000000401211 <+149>: movaps %xmm0,-0x60(%rbp)
---Type <return> to continue, or q <return> to quit---
  0x0000000000401215 <+153>: movaps -0xa0(%rbp),%xmm0
  0x000000000040121c <+160>: movaps %xmm0,-0x50(%rbp)
41    __m128i vcmp = (__m128i) _mm_cmpneq_ps(__toFirstValue, __fromFirstValue);
  0x0000000000401229 <+173>: movdqa %xmm0,-0x80(%rbp)
  0x000000000040122e <+178>: movdqa -0x80(%rbp),%xmm0
  0x0000000000401233 <+183>: movdqa %xmm0,-0x40(%rbp)
42    uint16_t test = _mm_movemask_epi8(vcmp);
  0x0000000000401241 <+197>: mov  %ax,-0xc8(%rbp)
  0x0000000000401248 <+204>: movaps -0xb0(%rbp),%xmm0
  0x000000000040124f <+211>: movaps %xmm0,-0x30(%rbp)
  0x0000000000401253 <+215>: movaps -0x90(%rbp),%xmm0
  0x000000000040125a <+222>: movaps %xmm0,-0x20(%rbp)
43    __m128i vcmp_2 = (__m128i) _mm_cmpneq_ps(__toSecondValue, __fromSecondValue);
  0x0000000000401267 <+235>: movdqa %xmm0,-0x70(%rbp)
  0x000000000040126c <+240>: movdqa -0x70(%rbp),%xmm0
  0x0000000000401271 <+245>: movdqa %xmm0,-0x10(%rbp)
44    uint16_t test_2 = _mm_movemask_epi8(vcmp_2);
  0x000000000040127f <+259>: mov  %ax,-0xc6(%rbp)
45    if( (test|test_2) != 0 ) return 0; // indicative that the result failed
  0x0000000000401286 <+266>: movzwl -0xc6(%rbp),%eax
  0x000000000040128d <+273>: movzwl -0xc8(%rbp),%edx
  0x0000000000401294 <+280>: or  %edx,%eax
  0x0000000000401296 <+282>: test  %ax,%ax
  0x0000000000401299 <+285>: je  0x4012a2 <valuesOK+294>
  0x000000000040129b <+287>: mov  $0x0,%eax
  0x00000000004012a0 <+292>: jmp  0x4012c3 <valuesOK+327>
46 #else
47    #error "SSE not supported, which is required for example code to work!"  
48 #endif
49  }
50 return 1;
  0x00000000004012be <+322>: mov  $0x1,%eax
51  }
  0x00000000004012c3 <+327>: leaveq
  0x00000000004012c4 <+328>: retq
End of assembler dump
(gdb)

它是如何工作的…

隐式向量化是写入实现提供的编译器中的复杂软件的一部分,并且肯定与硬件相关,通常由处理器制造商的专有 中间语言IL)表示,但遗憾的是,文档并不完善,所以我们更愿意关注更详细地了解原生向量类型表示法的工作方式。

注意

然而,感兴趣的读者被邀请去探索由 AMD 和 NVIDIA 开发的 IL,分别被称为 AMD IL 和 NVIDIA 的 PTX。

这种手动调整的方法允许开发者引用他们正在工作的平台内置的向量化数据类型,而不是依赖于 OpenCL 编译器自动向量化代码,并且可能会带来性能上的好处。到目前为止,在 OpenCL 中实现这一点的做法是将这些差异抽象成文件 cl_platform.h 中的平台相关宏。让我们来看看在我们的示例中这是如何工作的。

我们之前看到的示例是在 Ubuntu Linux 12.04 操作系统上,使用英特尔酷睿 i7 CPU 和 AMD Radeon HD 7870 GPU 进行测试的,但鉴于我们的示例专注于主机代码的显式向量化,这意味着我们需要根据英特尔指令集知道 SIMD 向量的宽度。我们知道这是 128 位,这意味着如下:

float4 a,b;
float4 c = a + b;

上述代码被转换成以下 C 代码片段:

__m128 a, b;
__m128 c = __mm_add_ps(a, b);

函数 __mm_add_ps 是 SIMD 函数,通过逐个添加它们的单精度浮点值来添加两个向量,最初看起来像是一种语法糖,但实际上这是 OpenCL 提供跨平台兼容性的许多方法之一,并且消除了为各种处理器架构提供定制化向量化代码的痛苦,因此以这种方式,一个门面实际上是一件好事。

回到我们试图解决的问题,即对输入和输出数组进行数据验证的过程进行向量化。在我们的例子中,我们选择了可以包含 8 个浮点数的数组或向量,我们想要做的是检查它们并比较它们是否相等。使用 OpenCL 的本地向量类型表示法,我们知道 8 元素向量可以分解成 4 元素向量,因为 OpenCL 规定,如果一个平台可以支持本地向量类型,那么该宏在cl_platform.h文件中以__CL_<TYPEN>命名,其中<TYPEN>可以是UCHAR16CHAR16INT4FLOAT4,即向量化的原始数据类型。一般来说,你可以使用.v<N>子向量表示法来访问本地组件,其中<N>是子向量中的元素数量。

使用这个新发现的信息,我们可以分析之前看到的程序,其中原始主机内存的内容由cl_float8 *表示,而主机到设备复制的内存内容由cl_float8*持有:

int valuesOK(cl_float8* to, cl_float8* from, size_t length) {
// code omitted
for(int i = 0; i < length; ++i) {

我们需要遍历输入和输出数组中的向量,并按照以下方式从主机指针中提取第一个和第二个 4 元素向量:

    __cl_float4 __hostFirstValue = to->v4[0];
    __cl_float4 __hostSecondValue = to->v4[1];

然后我们按照以下方式从设备指针中提取第一个和第二个 4 元素向量:

    __cl_float4 __deviceFirstValue = from->v4[0];
    __cl_float4 __deviceSecondValue = from->v4[1];

现在,我们使用 SSE API __mm_cmp_neq_ps比较每一半,并将每个测试的结果保存在变量 test 和 test2 中,如下面的代码所示:

    __m128i vcmp = (__m128i) _mm_cmpneq_ps(__hostFirstValue, __deviceFirstValue);
    uint16_t test = _mm_movemask_epi8(vcmp);
    __m128i vcmp_2 = (__m128i) _mm_cmpneq_ps(__hostSecondValue, __deviceSecondValue);
    uint16_t test_2 = _mm_movemask_epi8(vcmp_2);

最后,我们按照以下方式比较这些结果:

    if( (test|test_2) != 0 ) return 0; // indicative that the result failed
#else

还有更多...

我们还想要告诉你的向量化故事的一部分是,作为开发人员,你有权通过向内核代码提供显式的编译器提示来控制自动向量化。如果你想要手动调整代码的向量化,这可能很有用。

我们所提到的编译器提示是vec_type_hint(<type>),其中<type>是我们之前提到的任何内置标量或向量数据类型。属性vec_type_hint(<type>)表示内核的计算宽度,如果没有指定,则假定内核应用了vec_type_hint(int)限定符,即 4 字节宽。以下代码片段说明了内核的计算宽度如何从 16 字节变为 8 字节,最后变为 4 字节,这恰好是默认值:

// autovectoize assuming float4 as computation width
__kernel __attribute__((vec_type_hint(float4)))
void computeThis(__global float4*p ) {…}
// autovectorize assuming double as computation width
__kernel __attribute__((vec_type_hint(double)))
void computeThis(__global float4*p ) {…}
// autovectorize assuming int (default) as computation width
__kernel __attribute__((vec_type_hint(int)))
void computeThis(__global float4*p ) {…}

对于您,开发者来说,要能够使用这个功能,您需要知道您平台中向量单元的宽度,这可能是在 CPU 或 GPU 上运行的。在下一张图中,我们展示了两种场景,我们假设两个__kernel函数分别使用__attribute_((vec_type_hint(float4)))__attribute_((vec_type_hint(char4)))声明。此外,我们还假设内核正在 256 位宽的寄存器上运行,以及自动向量化器如何选择运行一个或多个工作项以最大化寄存器的使用;这当然取决于编译器的实现。以下图展示了 OpenCL 编译器可能如何生成工作项以消耗宽寄存器中的数据:

还有更多…

在原生向量类型表示法中进行显式向量化的原生向量类型方法中,我们提到原生向量类型通过cl_platform.h中的__CL_<TYPEN>__预处理符号(也称为 C 宏)在cl_platform.h中标识,但我们还没有告诉您我们如何在代码示例中使用 SSE 指令。现在让我们找出原因,我们需要参考 OpenCL 1.2 标准定义的cl_platform.h(您可以从www.khronos.org/registry/cl/api/1.2/cl_platform.h下载)。

代码示例在 Ubuntu Linux 12.04 64 位操作系统上进行了测试,该系统配备英特尔酷睿 i7 CPU 和 AMD Radeon HD 7870 GPU,我们应该忽略 GPU 的存在,因为它除了通知您机器配置外没有其他相关性。

这个设置告诉我们,我们有一个支持 SSE 的指令集,并且按照 UNIX 和 GCC 社区的一般惯例,我们应该寻找__SSE__预处理符号,我们确实是这样做的,如下所示:

#if defined(__SSE__)
#if defined(__MINGW64__)
#include <intrin.h>
#else
#include <xmmintrin.h> 
#endif
#if defined(__GNUC__)
typedef float __cl_float4 __attribute__((vector_size(16)));
#else
typedef __m128 __cl_float4;// statement 1
#endif
#define __CL_FLOAT4__ 1// statement 2
#endif

从前面的代码片段中,我们知道我们应该关注语句 1,因为它为我们提供了 SIMD 向量的指示性宽度,我们还知道按照惯例__m128表示其向量的宽度为 128 位;其他值包括 64 位和 256 位。我们还应该注意将显式向量化包含在预处理器的保护中,这是一个最佳实践,即#ifdef __CL_FLOAT4__。利用这种理解,我们可以继续寻找允许我们操作所需宽度数据值的适当 SSE API。感兴趣的读者可以查阅英特尔开发者手册和 AMD 开发者手册,并探索这些 ISA 如何比较以及最重要的是它们在哪里不同。

向量和标量地址空间

现在我们已经了解了如何在 OpenCL 中使用标量和向量,是时候检查 OpenCL 定义的四个地址空间了:__global__local__constant__private,在这些地址空间中,向量和标量可以存在。这些空间映射到内存单元,因此受设备实际资源的限制,并定义了工作项如何访问内存。

准备工作

以下是对各种内存域的概念图:

准备就绪

前一个图的下半部分中找到的全局内存常量内存对应于__global__constant域。与 OpenCL 中每个计算单元(执行内核代码)关联的局部内存将有一个由块中所有工作项共享的内存空间,这对应于__local内存空间,而每个处理元素将拥有自己的命名空间来存储数据,它由__private内存空间表示。请注意,工作项无法以任何方式访问另一个工作项的(__private)内存空间,无论它们是否在同一个工作组中,同样适用于共享内存,即__local内存,因为没有任何两个工作组可以检查对方的内存。

设备中的每个计算单元都有一定数量的处理元素,这些处理元素执行工作项,并且计算单元作为一个整体会根据计算需要访问局部、常量或全局内存空间。每个处理元素(工作组或工作项)在其私有内存空间中存储自己的私有变量。

如何做到这一点...

__global地址空间名称用于引用从全局内存池分配的内存对象。为了确定设备上实际可用的资源量,你需要传递参数CL_DEVICE_GLOBAL_MEM_SIZEclGetDeviceInfo。以下代码片段来自Ch2/device_details/device_details.c

displayDeviceDetails( devices[i], CL_DEVICE_GLOBAL_MEM_SIZE, "CL_DEVICE_GLOBAL_MEM_SIZE");
void displayDeviceDetails(cl_device_id id,
                          cl_device_info param_name,
                          const char* paramNameAsStr) {
  cl_int error = 0;
  size_t paramSize = 0;
  error = clGetDeviceInfo( id, param_name, 0, NULL, &paramSize );
  if (error != CL_SUCCESS ) {
    perror("Unable to obtain device info for param\n");
    return;
  }
  /* the cl_device_info are preprocessor directives defined in cl.h */
  switch (param_name) { 
    case CL_DEVICE_GLOBAL_MEM_SIZE:
    case CL_DEVICE_MAX_MEM_ALLOC_SIZE: {
      cl_ulong* size = (cl_ulong*) alloca(sizeof(cl_ulong) * paramSize);
      error = clGetDeviceInfo( id, param_name, paramSize, size, NULL );
      if (error != CL_SUCCESS ) {
        perror("Unable to obtain device name/vendor info for param\n");
        return;
      }

__local地址空间名称用于描述需要在局部内存中分配并共享工作组中所有工作项的变量。你可以通过传递参数CL_DEVICE_MAX_LOCAL_MEM_SIZEclGetDeviceInfo来确定这个空间的最大大小。

__constant地址空间名称用于描述需要作为只读分配到全局内存中的不可变变量,并且在内核执行期间可以被所有工作项读取。你可以通过传递参数CL_DEVICE_MAX_CONSTANT_BUFFER_SIZEclGetDeviceInfo来确定这个空间的最大大小。这个地址空间在存在特定值且内核函数需要时非常有用,这个值不会改变。

__private 地址空间用于描述仅对特定工作项私有的对象;因此,如果它们被标记为 __private,则工作项无法检查彼此的变量。默认情况下,内核函数内部未声明任何地址空间限定符(如:__global__local__constant)的变量被标记为 __private;这包括所有非内核函数和函数参数中的所有变量。以下来自 Ch3/vectorization/vectorization.cl 的内核代码将说明全局和私有内存空间,其中变量 idindext 位于私有内存空间,因此对其他工作项不可见,因此不受干扰,而变量 inout 位于全局内存空间,对所有工作项可见:

__kernel void copyNPaste(__global float* in, __global float8* out) {
    size_t id = get_global_id(0);
    size_t index = id*sizeof(float8);
    float8 t = vload8(index, in);
    out[index].s0 = t.s0;
  //code omitted
  out[index].s7 = t.s7;
}

它是如何工作的…

以下图示说明了 OpenCL 编程模型:

如何工作…

让我们使用前面的图示来了解您的内核在 OpenCL 中的工作方式。想象您有一个名为 doCompute 的内核,它接受几个参数,这些参数引用全局、常量、局部或私有内存空间。工作和数据在由 W[0…4] 表示的计算单元之间的内核中划分;它们将代表工作组(工作项集合)或工作项。

通常,在 OpenCL 中的计算通常涉及单个工作项通过全局、私有或常量空间独立执行计算,或者收集这些工作项以形成一个工作组,这样它们就可以通过利用局部内存空间更有效地加载数据和存储数据,因为该空间允许工作组中所有工作项之间共享数据,从而防止从设备内存进行多次内存加载。

配置您的 OpenCL 项目以启用双精度数据类型

今天,来自 Intel、AMD 和 ARM 的现代处理器都拥有符合 IEEE 754 标准的浮点单元(FPUs);然而,ARM 除了支持单精度和双精度数字外,还支持硬件和软件对半精度数字的支持。因此,这意味着您的 OpenCL 程序实际上可以在基于 ARM 的处理器上利用半精度,这引发了一个问题:如何确定设备具有哪种类型的浮点支持。

这个问题的答案是,通过 clGetDeviceInfo API 查询设备,并传递以下任何参数:CL_DEVICE_SINGLE_FP_CONFIGCL_DEVICE_DOUBLE_FP_CONFIGCL_DEVICE_HALF_FP_CONFIG,这些参数标识设备是否支持单精度、双精度或半精度数字操作。

小贴士

CL_DEVICE_HALF_FP_CONFIGCL_DEVICE_DOUBLE_FP_CONFIG 在 OpenCL 1.0 的 Mac OSX 10.6 上不受支持。

API 调用结果返回一个 cl_device_fp_config 类型的对象。

小贴士

在撰写本文时,CL_FP_SOFT_FLOAT 在 Mac OSX 10.6 上不可用,但在 AMD APP SDK v2.7 和 Intel OpenCL SDK 中可用。

在双精度浮点值的情况下,OpenCL 设备扩展 cl_khr_fp64 必须存在,你才能在内核中使用 double 数据类型。截至 OpenCL 1.2,开发者不再需要查询设备的扩展来验证双精度浮点数支持的存在,我们将在本食谱的后续部分解释在这种情况下你需要做什么。

小贴士

截至 OpenCL 1.1,工作委员会没有强制要求通过 OpenCL 1.1 设备扩展 cl_khr_fp64 支持双精度数据类型。如果你使用 AMD 设备,你应该知道 AMD 提供了一个实现 cl_khr_fp64 子集的扩展,称为 cl_amd_fp64

让我们用一个简单的例子来理解这一点。

准备工作

在即将到来的例子中,例子的目标是说明使用 double 数据类型来保存两个 float 相加的中间结果,然后我们将这个 double 发送到结果数组中作为 float 存储起来。请注意,如果启用了扩展 cl_khr_fp64cl_amd_fp64(对于 AMD 设备),则不能在内核代码中使用 double 类型。

涉及的两个测试机器在英特尔酷睿 i7 处理器和 NVIDIA GPU 上支持 cl_khr_fp64,但 ATI 6870x2 GPU 不支持 cl_khr_fp64cl_amd_fp64

如何做到这一点...

以下是从 Ch3/double_support/double_support.cl 文件中摘录的代码片段,它说明了内核代码:

#ifdef fp64
#pragma OPENCL EXTENSION cl_khr_fp64 : enable
#endif
__kernel void add3(__global float* a, __global float* b, __global float* out) {
  int id = get_global_id(0);
#ifdef fp64
  double d = (double)a[id] + (double)b[id];
  out[id] = d;
#else
  out[id] = a[id] + b[id];
#endif
}

接下来,是从 Ch3/double_support/double_support.c 文件中摘录的代码片段,其中展示了如何将内核参数设置到 add3 函数中:

// memobj1 & memobj2 refers to float arrays for consumption
// outObj refers to the output float array
error = clSetKernelArg(kernels[j], 0, sizeof(cl_mem), &memobj1);
error = clSetKernelArg(kernels[j], 1, sizeof(cl_mem), &memobj2);
error = clSetKernelArg(kernels[j], 2, sizeof(cl_mem), &outObj);
if (error != CL_SUCCESS) { 
  perror("Unable to set buffer object in kernel arguments");
  exit(1);
}
/* Enqueue the kernel to the command queue */
size_t local[1] = {1};
size_t global[1] = {64};
error = clEnqueueNDRangeKernel(cQ, kernels[j], 1, NULL, global, local, 0, NULL, NULL);
if (error != CL_SUCCESS) {
  perror("Unable to enqueue task to command-queue");
  exit(1);}

要使用 CMake 构建程序,请导航到 Ch3/double_support 目录,并输入 make。它应该会生成一个名为 DoubleSupport 的二进制文件,你可以执行它来观察结果。在两个测试机器上,小规模运行的结果,即 64 个浮点值,在 CPU 和 GPU 上的运行都是好的。

Number of OpenCL platforms found: 1
Number of detected OpenCL devices: 2
Kernel name: add3 with arity: 3
About to create command queue and enqueue this kernel...
Task has been enqueued successfully!
Checking data of size: 64
Check passed!
Kernel name: add3 with arity: 3
About to create command queue and enqueue this kernel...
Task has been enqueued successfully!
Checking data of size: 64
Check passed!

在这个例子中,代码的构建方式是这样的,即使不支持 double,程序也能运行。在检查代码时,你会发现它的用例是保存两个 float 值相加的结果(有意不会溢出),但在其他情况下,你可能想使用 double,即使用条件指令,也就是 #ifdef#else#endif 来检查设备是否支持双精度浮点数,这是一个标准技术。

它是如何工作的...

类型 cl_device_fp_config 实际上由几个值组成(如下表所示),你可以通过执行位与操作来确定某个特性是否受支持。例如,如果我们想确定双精度操作中支持哪些舍入模式,那么我们将有以下的代码:

cl_device_fp_config config;
clGetDeviceInfo( deviceId, CL_DEVICE_DOUBLE_FP_CONFIG, sizeof(config), &config, NULL);
if (config & CL_FP_ROUND_TO_NEAREST) printf("Round to nearest is supported on the device!");
参数 float double half
CL_FP_DENORM 可选 支持 可选
CL_FP_INF_NAN 支持 支持 支持
CL_FP_ROUND_TO_NEAREST 支持 支持 可选
CL_FP_ROUND_TO_ZERO 可选 支持 支持
CL_FP_ROUND_TO_INF 可选 支持 支持
CL_FP_FMA 可选 支持 可选
CL_FP_SOFT_FLOAT 可选 可选 可选

对于倾向于使用 OpenCL 1.2 的人来说,规范已将双精度作为一个可选特性而不是扩展,这意味着你不需要检查设备是否存在扩展 cl_khr_fp64cl_amd_fp64,你只需检查当传递参数 CL_DEVICE_PREFERRED_VECTOR_WIDTH_DOUBLECL_DEVICE_NATIVE_VECTOR_WIDTHclGetDeviceInfo 函数时返回的值,如果设备支持双精度,则该值必须等于 1。以下代码片段说明了如何检查内置标量类型可以放入向量的首选原生向量宽度大小:

cl_uint vectorWidth;
size_t returned_size;
clGetDeviceInfo( deviceId, CL_DEVICE_PREFERRED_VECTOR_WIDTH_DOUBLE,sizeof(cl_uint), &vectorWidth, &returned_size);
if(vectorWidth > 0) printf("Vectors of size %d for 'double' are:", vectorWidth);

第四章:使用 OpenCL 函数

在本章中,我们将介绍以下食谱:

  • 将向量存储到数组中

  • 从数组中加载向量

  • 使用几何函数

  • 使用整数函数

  • 使用浮点函数

  • 使用三角函数

  • OpenCL 中的算术和舍入

  • 在 OpenCL 中使用 shuffle 函数

  • 在 OpenCL 中使用选择函数

简介

在本章中,我们将探讨如何在你的代码中利用 OpenCL 提供的常用函数。我们正在检查的函数将主要是应用于元素的数学运算,特别是应用于元素向量。回想一下,向量是 OpenCL 允许多个元素在硬件上并行处理的主要方式。由于 OpenCL 供应商通常可以生成矢量化硬件指令来有效地加载和存储此类元素,因此尽可能多地使用它们。

详细来说,我们将深入了解以下内容的工作原理:

  • 向量的数据加载和存储函数

  • 几何函数

  • 整数函数

  • 浮点函数

  • 三角函数

最后,我们将介绍两个部分,说明如果你选择在你的应用程序中使用 OpenCL 的 shuffleselect 函数,它们将如何工作。

将向量存储到数组中

在前面的章节中,你瞥见了我们如何以各种方式使用向量,从一种将数据以高效方式传输到设备并从设备传输的工具,到 OpenCL 实际上在向量上工作的函数。我们还了解到 OpenCL 提供了大量针对向量的函数。在本节中,我们将探讨如何将向量存储到数组中(当我们在这个上下文中使用数组与向量一起时,我们指的是包含标量值的数组)。

vstore<N> 函数,其中 <N>234816,是你将使用的主要函数,以实际通知 OpenCL 你希望将你的向量元素以并行方式存储到目的地;这通常是一个标量数组或另一个向量。

我们应该清楚,gentypeN 不是一个类似于 C 的数据类型别名,而是一个用于诸如 charucharshortushortintuintlongulongfloatdouble 等类型的逻辑占位符。N 表示它是一个聚合 234816 个元素的数结构。记住,如果你希望存储 double 类型的向量,那么你需要在内核代码中声明任何 double 精度数据类型之前,确保代码中包含指令 #pragma OPENCL EXTENSION cl_khr_fp64 : enable

小贴士

因此,vstoreN API 将将数据提供的 sizeof(gentypeN) 字节写入地址 (p + (offset * N))。如果 gentypecharuchar,则计算为 (p + (offset * N)) 的地址必须是 8 位对齐;如果 gentypeshortushort,则必须是 16 位对齐;如果 gentypeintuint,则必须是 32 位对齐;如果 gentypelongulongdouble,则必须是 64 位对齐。

你应该注意到,内存写入可以跨越全局内存空间(__global),到局部(__local),甚至到工作项私有内存空间(__private),但永远不会到常量内存空间(__constant是只读的)。根据你的算法,你可能需要通过内存屏障(也称为栅栏)来协调对另一个内存空间的写入。

小贴士

你需要内存屏障或栅栏的原因是,通常情况下,内存的读取和写入可能会发生乱序,而造成这种情况的主要原因在于源代码的编译器优化会重新排序指令,以便利用硬件的优势。

为了进一步阐述这个想法,你可能知道 C++有一个关键字volatile,它用于标记一个变量,使得编译器优化通常不会对该变量的任何使用应用优化过的加载/存储;基本上,这种变量的任何使用通常都涉及在每个使用点(也称为序列点)的加载-使用-存储周期。

循环展开是一种优化技术,编译器试图在代码中移除分支,从而发出任何分支预测指令,以便代码高效执行。在你习惯的循环中,你经常会找到一个如下所示的表达式:

for(int i = 0; i  < n; ++i ) { ... }

这里发生的情况是,当这段代码被编译时,你会注意到 ISA 会发出一条指令来比较i的值与n的值,并根据比较结果执行某些操作。当执行线程在条件为真时采取一条路径,条件为假时采取另一条路径时,就会发生分支。通常,CPU 会同时执行这两条路径,直到它以 100%的确定性知道应该采取其中一条路径,CPU 可以丢弃另一条未使用的路径,或者它需要回溯其执行。在任一情况下,当这种情况发生时,你都会损失几个 CPU 周期。因此,开发者可以帮助编译器,在我们的情况下,向编译器提供关于n的值的提示,这样编译器就不必生成代码来检查i < n。不幸的是,OpenCL 1.2 不支持作为扩展的循环展开,而是 AMD APP SDK 和 CUDA 工具包提供了以下 C 指令:

#pragma unroll <unroll-factor>
#pragma unroll 10
for(int i = 0; i < n; ++i) { ... }

没有这些函数,OpenCL 内核可能会为每个处理的元素发出一个内存加载/存储操作,如下面的图示所示:

将向量存储到数组中

让我们通过一个简单的例子来看看我们如何使用这些vstoreN函数。

准备工作

这个配方将向你展示来自 Ch4/simple_vector_store/simple_vector_store.cl 的代码片段,其中使用 vstore16(...) 加载并随后复制一个 16 个元素的向量。这个 API 并不完全等同于 16 个元素的循环展开的糖语法,原因是编译器生成从内存中加载 16 个元素的向量的指令;另外,OpenCL 1.1 中不存在我们知道的循环展开,但如果这有助于理解 vstoreN API 背后的概念,那么从那个角度思考是没有害处的。

如何做…

下面的内核代码是我们将演示数据传输的地方:

//
// This kernel loads 64-elements using a single thread/work-item
// into its __private memory space and writes it back out
__kernel void wideDataTransfer(__global float* in,__global float* out) {
    size_t id = get_global_id(0);
    size_t offsetA = id ;
    size_t offsetB = (id+1);
    size_t offsetC = (id+2);
    size_t offsetD = (id+3);

    // each work-item loads 64-elements
    float16 A = vload16(offsetA, in);
    float16 B = vload16(offsetB, in);
    float16 C = vload16(offsetC, in);
    float16 D = vload16(offsetD, in);

    vstore16(A, offsetA, out);
    vstore16(B, offsetB, out);
    vstore16(C, offsetC, out);
    vstore16(D, offsetD, out);
}

要在 OS X 平台上编译它,你将不得不运行一个类似于以下编译命令:

gcc –std=c99 –Wall –DUNIX –g –DDEBUG –DAPPLE –arch i386 –o VectorStore vector_store.c –framework OpenCL

或者,你可以在 Ch4/simple_vector_store/ 源目录中输入 make。当这样做时,你将得到一个名为 VectorStore 的二进制可执行文件。

要在 OS X 上运行程序,只需执行程序 VectorStore,你应该看到以下输出:Check passed!Check failed!,如下所示:

Check passed!

它是如何工作的…

这段代码可以从这样一个角度来理解:在全局内存空间中存在一个大向量,我们的尝试是将这个向量加载到私有内存中的一个变量中,即每个工作项都有一个名为 t 的唯一变量;对它不做任何操作,并将其存储回全局内存空间中另一个内存数组。

小贴士

如果你对此工作原理感到好奇,内存写入实际上是合并的,以便以字节突发的方式发出写入。这个突发的大小取决于硬件的内部架构。以 AMD 的 ATI GPU 为例,这些内存写入在已知发生 16 次写入后才会发出,这与 GPU 中工作项的实现有关。你可以看到,对于 GPU 来说,为每个工作项发出读取或写入是非常低效的。当你结合这样一个事实,即在一个集群 GPU 解决方案中可能存在数十万个活跃的计算线程,你可以想象,如果制造商实现一个允许开发者以工作项/线程粒度管理程序的逻辑,那么复杂性将是难以想象的。因此,显卡制造商决定,以锁步方式执行一组线程更有效率。ATI 称这组执行线程为波前,NVIDIA 称其为 warp。当你开始在 OpenCL 设备上开发非平凡算法时,这种理解是至关重要的。

当你构建示例应用程序并运行它时,它并没有做我们看到的特别特殊的事情,但看到底层代码是如何生成的是有用的,在这个例子中,Intel OpenCL SDK 是说明性的。

它是如何工作的…

特别的,汇编代码片段是结果翻译到 SSE2/3/4Intel AVX高级向量扩展)代码。

从数组中加载向量

vloadN 函数通常用于从内存中的数组加载多个元素到内存中的目标数据结构,通常是向量。类似于 vstoreN 函数,vloadN 函数也加载来自全局(__global)、局部(__local)、工作项私有(__private)以及最后常量内存空间(__constant)的元素。

我们应该清楚,gentypeN 不是一个类似于 C 的数据类型别名,而是一个用于类型的逻辑占位符:charucharshortushortintuintlongulongfloatdouble,而 N 表示它是一个聚合 234816 个元素的 数据结构。没有这个函数,内核需要发出可能多个内存加载,如下面的图示所示:

从数组中加载向量

准备工作

以下是从 Ch4/simple_vector_load/simple_vector_load.cl 的摘录。我们专注于理解如何在设备内存空间中加载元素向量以进行设备内的计算,即 CPU/GPU。但这次,我们使用了一种称为预取(当你的代码即将使用数据时预热缓存,并且你希望它也接近,也称为空间和时间局部性)的优化技术,通常用于分配给局部内存空间,以便所有工作项都可以从缓存中读取数据,而不会在总线上产生过多的请求。

如何做到这一点…

以下是我们将从中汲取灵感的内核代码:

__kernel void wideDataTransfer(__global float* in, __global float* out) {
  size_t id = get_group_id(0) * get_local_size(0) +get_local_id(0);
  size_t STRIDE = 16;
  size_t offsetA = id;
  prefetch(in + (id*64), 64);
  barrier(CLK_LOCAL_MEM_FENCE);

  float16 A = vload16(offsetA, in);
  float a[16]; 
  a[0] = A.s0;
  a[1] = A.s1;
  a[2] = A.s2;
  a[3] = A.s3;
  a[4] = A.s4;
  a[5] = A.s5;
  a[6] = A.s6;
  a[7] = A.s7;
  a[8] = A.s8;
  a[9] = A.s9;
  a[10] = A.sa;
  a[11] = A.sb;
  a[12] = A.sc;
  a[13] = A.sd;
  a[14] = A.se;
  a[15] = A.sf;
  for( int i = 0; i < 16; ++i ) {
    out[offsetA*STRIDE+i] = a[i];
  }
}

要在 OS X 平台上编译它,你必须运行一个类似于以下命令的编译命令:

gcc –std=c99 –Wall –DUNIX –g –DDEBUG –DAPPLE –arch i386 –o VectorLoad vector_load.c –framework OpenCL

或者,你可以在源目录 Ch4/simple_vector_load/ 中输入 make。当这样做时,你将得到一个名为 VectorLoad 的二进制可执行文件。

要在 OS X 上运行程序,只需执行程序 VectorLoad,你应该会看到以下输出:Check passed!Check failed!

Check passed!

它是如何工作的…

内核将继续从全局内存空间通过工作组中的第一个工作项预取 16float 类型的值到全局缓存,这些值最终将通过 vload16 API 到达工作项的 __private 内存空间。一旦这些值被加载,我们可以将单个浮点数分配给数组,并最终通过显式写入 out__global 内存空间将它们输出到目标位置。这是从驻留在全局内存空间中的标量数组进行内存加载的一种方法。

prefetch(in +(id*64), 64);

前一行是一个优化技术,用于通过在需要之前提供数据来提高数据重用性;这个预取指令应用于工作组中的一个工作项,并且我们选择了每个工作组中的第一个工作项来执行这项操作。在存在大量数据重用的算法中,其好处将比以下示例更显著:

你可能还注意到我们没有编写以下代码:

out[offset*STRIDE + i] = A; // 'A' is a vector of 16 floats

我们没有这样做的原因是 OpenCL 禁止隐式/显式地将向量类型转换为标量。

工作原理…

除了生成的 SSE 指令之外,还有一件有趣的事情值得指出,那就是即使代码只提到了一个预取指令,也会生成多个硬件预取指令。这种伪装允许 OpenCL 供应商根据开放标准实现功能,同时仍然允许供应商向开发者隐藏实际的实现细节。

使用几何函数

几何函数被程序员用于在向量上执行常见计算,例如,叉积或点积,归一化向量,以及向量的长度。为了回顾一下向量的叉积和点积,请记住,在数学意义上,向量代表具有方向和大小的数量,这些向量在计算机图形学中被广泛使用。

很频繁地,我们需要计算两个向量之间的距离(以度或弧度为单位),为此,我们需要计算点积,它被定义为:

使用几何函数

因此,如果 ab 垂直,那么它必须满足 a . b = 0。点积还用于计算矩阵-向量乘法,这解决了被称为 线性系统 的一类问题。两个 3D 向量的叉积将产生一个垂直于它们的向量,可以定义为:

使用几何函数

这些乘积之间的区别在于,点积产生一个标量值,而叉积产生一个向量值。

以下是一个 OpenCL 的几何函数列表:

函数 描述
float4 cross(float4 m, float4 n) float3 cross(float3 m, float3 n) 返回 m.xyzn.xyz 的叉积,结果向量中的 w 分量始终为零
float dot(floatn m, floatn n) 返回两个向量的点积
float distance(floatn m, floatn n) 返回 mn 之间的距离。这是通过 length(m – n) 计算得出的
float length(floatn p) 返回向量 p 的长度
floatn normalize(floatn p) 返回与 p 方向相同但长度为 1 的向量
float fast_distance(floatn p0, floatn p1) 返回 fast_length(p0 – p1)
float fast_length(floatn p) 返回向量 p 的长度,计算方式为:half_sqrt()
floatn fast_normalize(floatn p) 返回与 p 方向相同但长度为 1 的向量。fast_normalize 是通过:p * half_sqrt() 计算得出的

你应该知道,这些函数是在 OpenCL 中使用 四舍五入到最接近偶数 的舍入模式实现的,也称为 rte-mode

接下来,让我们看看一个利用这些函数的示例。

准备工作

Ch4/simple_dot_product/matvecmult.cl 中的代码片段说明了如何计算二维向量和矩阵之间的点积,并将该计算的输出结果写回输出数组。当你刚开始使用 OpenCL 时,可能会有两种可能的方式来编写这个功能,我认为发现这些差异是有教育意义的;然而,我们只展示了演示点积 API 的相关代码片段。

如何操作…

以下是最简单的矩阵点积操作的实现:

__kernel void MatVecMultUsingDotFn(__global float4* matrix,__global float4* vector, __global float* result) {
    int i = get_global_id(0);
    result[i] = dot(matrix[i], vector[0]);
}

要在 OS X 平台上编译此程序,你需要运行一个类似于以下的编译命令:

gcc –std=c99 –Wall –DUNIX –g –DDEBUG –DAPPLE –arch i386 –o MatVecMult matvecmult.c –framework OpenCL

或者,你可以在源目录 Ch4/simple_dot_product/ 中输入 make。当这样做时,你将有一个名为 MatVecMult 的二进制可执行文件。

要在 OS X 上运行程序,只需执行名为 MatVecMult 的程序,你应该会看到以下输出:Check passed!Check failed!

Check passed!

它是如何工作的…

之前的代码片段可能是你想要编写的最简单的实现矩阵点积操作的代码。内核实际上从两个输入的 __global 内存空间中读取 4 个浮点数向量,计算它们之间的点积,并将结果写回目标 __global 内存空间。之前我们提到可能还有另一种编写方式。是的,有,相关的代码如下:

__kernel void MatVecMult(const __global float* M,const __global float* V, uint width, uint height,__global float* W) {
    // Row index
    uint y = get_global_id(0);
    if (y < height) {
       // Row pointer
       const __global float* row = M + y * width;
       // Compute dot product
       float dotProduct = 0;
      for (int x = 0; x < width; ++x)
        dotProduct += row[x] * V[x];
    // Write result to global memory
    W[y] = dotProduct;
    }
}

当你比较这个没有使用点积 API 的实现时,你会发现你不仅需要输入更多,而且你还将增加工作项变量的数量,这些变量恰好位于 __private 内存空间中;通常你不想这样做,因为它阻碍了代码的可读性,并且也非常重要的是可扩展性,因为消耗了太多的寄存器。

注意

在 OpenCL 实现中,他们需要管理设备上的可用资源,这些资源可能是可用内存或可用计算单元。其中一种资源是包含固定数量通用寄存器的寄存器文件,设备使用这些寄存器来执行一个或多个内核。在 OpenCL 内核编译期间,将确定每个内核执行所需的寄存器数量。例如,我们假设开发了一个内核,它使用 __private 内存空间中的 10 个变量,寄存器文件为 65536,这意味着我们可以启动 65536 / 10 = 6553 个工作项来运行我们的内核。如果你以这种方式重写你的内核,使用更多的 __local 内存空间中的数据共享,那么你可以释放更多的寄存器,并且可以更好地扩展你的内核。

使用整数函数

OpenCL 中的整数函数主要提供了有用的方式,你可以使用它们来执行常规的数学计算,例如获取绝对值、将值除以二、定位三个值的最小值或最大值、数字的循环移位,以及为解决特定类问题而设计的特殊乘法形式。我们提到的许多函数,如 minmax,并不以原子的方式执行比较,但如果你确实想确保这一点,则可以使用一类原子函数,我们将在稍后考察它们。

整数函数的一类是原子函数,它允许开发者以原子方式交换值(包括单精度浮点值),其中一些函数实现了CAS比较并交换)语义。通常,你可能想确保某些操作具有原子性,因为没有这个特性,你将遇到竞态条件。

使用整数函数

原子函数通常接受两个输入(它们必须是整型,只有 atomic_xchg 支持单精度浮点类型),第一个参数是一个指向全局(__global)和局部(__local)内存空间中内存位置的指针,它们通常用 volatile 关键字进行标注,这阻止编译器优化与变量使用相关的指令;这很重要,因为读取和写入可能是不按顺序的,可能会影响程序的正确性。以下是一个说明原子操作如何序列化对共享数据的访问的心理模型:

使用整数函数

以下示例 atomic_add 有两种版本,它们可以处理有符号或无符号值:

int atomic_add(volatile __global int*p, int val)
unsigned int atomic_add(volatile __global uint*p, uint val)

你需要注意到的一个另一点是,仅仅因为你可以应用原子性来断言某些值的正确性,并不意味着程序的正确性。

这种情况的原因是由于工作项的实现方式,正如我们在本章前面提到的,NVIDIA 和 ATI 以称为工作组的形式执行工作项,每个工作组包含多个执行线程块,分别称为warp(32 个线程)和wavefront(64 个线程)。因此,当工作组在内核上执行时,该组中的所有工作项都是同步执行的,通常这不会成为问题。问题出现在工作组足够大,可以包含多个 warp/wavefront 的情况下;那么你将面临一个情况,其中一个 warp/wavefront 的执行速度比另一个慢,这可能会成为一个大问题。

真正的问题是,无法在所有符合 OpenCL 标准的设备上强制执行内存排序;因此,唯一的方法是在程序中的某些点上放置内存屏障,来告诉内核我们希望加载和存储操作协调一致。当存在这样的屏障时,编译器将生成指令,确保在执行屏障之后的任何指令之前,所有执行工作项都完成了在屏障之前对全局/局部内存空间的加载和存储操作,这将保证更新的数据被看到;或者用编译器的术语来说:内存加载和存储操作将在任何加载和存储操作之后提交到内存中。

这些 API 为开发者提供了在读取和写入排序、只读或只写方面更好的控制级别。参数标志可以组合 CLK_LOCAL_MEM_FENCE 和/或 CLK_GLOBAL_MEM_FENCE

准备工作

该配方将向你展示 Ch4/par_min/par_min.cl 中的代码片段,用于在设备(即 GPU 或 CPU 内存空间)中的大数组中找到最小值。此示例结合了几个概念,例如使用 OpenCL 的原子指令来启用原子函数和内存屏障来协调内存加载和存储。

如何操作...

以下代码演示了如何在包含大量整数的容器中找到最小数的方法:

#pragma OPENCL EXTENSION cl_khr_local_int32_extended_atomics : enable
#pragma OPENCL EXTENSION cl_khr_global_int32_extended_atomics : enable
__kernel void par_min(__global uint4* src,__global uint * globalMin, __local  uint * localMin,int numOfItems) {
    uint count = ( numOfItems / 4) / get_global_size(0);
    uint index = get_global_id(0) * count;
    uint stride = 1;
    uint partialMin = (uint) -1;
    for(int i = 0; i < count; ++i,index += stride) {
      partialMin = min(partialMin, src[index].x);
      partialMin = min(partialMin, src[index].y);
      partialMin = min(partialMin, src[index].z);
      partialMin = min(partialMin, src[index].w);
    }
    if(get_local_id(0) == 0) localMin[0] = (uint) -1;
      barrier(CLK_LOCAL_MEM_FENCE);
    atomic_min(localMin, partialMin);
    barrier(CLK_LOCAL_MEM_FENCE);
    if (get_local_id(0) == 0)
      globalMin[ get_group_id[0] ] = localMin[0];
}
__kernel void reduce(__global uint4* src,__global uint * globalMin) {
    atom_min(globalMin, globalMin[get_global_id(0)]);
}

要在 OS X 平台上编译它,你必须运行一个类似于以下编译命令:

gcc –std=c99 –Wall –DUNIX –g –DDEBUG –DAPPLE –arch i386 –o ParallelMin par_min.c –framework OpenCL

或者,你可以在源目录 Ch4/par_min/ 中输入 make。当这样做时,你将得到一个名为 ParallelMin 的二进制可执行文件。

在 OS X 操作系统上运行程序,只需执行程序 ParallelMin,你应该会看到以下输出:Check passed!Check failed!,如下所示:

Check passed!

它是如何工作的...

这种工作方式是,一个工作项遍历源缓冲区并尝试并行地找到最小值,当内核在 CPU 或 GPU 上运行时,源缓冲区被均匀地分配给这些线程,每个线程将遍历分配给它们的 __global 缓冲区,并将所有值减少到 __private 内存中的最小值。

随后,所有线程将通过原子操作将它们 __private 内存中的最小值减少到 __local 内存,并将此减少的值刷新到 _ _global 内存。

一旦工作组完成执行,第二个内核,即 reduce,将使用原子操作将所有工作组的值减少到 __global 内存中的单个值。

使用浮点函数

到目前为止,你已经看到了几个函数,它们接受单精度或双精度浮点数值作为输入或输出。给定一个浮点值 x,OpenCL 浮点函数为你提供了通过 frexp()x 中提取尾数和指数的能力,通过 modf() 分解 x,通过 nextafter() 计算下一个最大的/最小的单精度浮点值,以及其他功能。考虑到有如此多的有用浮点函数,有两个函数非常重要,因为它们在 OpenCL 代码中非常常见。它们是 mad()fma() 函数,分别对应乘加和融合乘加指令。

乘加Multiply-AddMAD)指令执行浮点数乘法后跟浮点数加法,但产品及其中间产品是否四舍五入是不确定的。融合乘加Fused Multiply-AddFMA)指令仅对产品进行四舍五入,不对其任何中间产品进行四舍五入。实现通常在操作的精度和速度之间进行权衡。

我们可能不应该深入这类学术研究;然而,在这样的时代,我们认为指出学术在许多情况下如何帮助我们做出明智的决定可能是有帮助的。话虽如此,代尔夫特理工大学的一项特别研究,题为 CUDA 和 OpenCL 的全面性能比较 链接,表明与 MAD 实现相比,FMA 指令计数更高,这可能会让我们得出结论,即 MAD 应该比 FMA 运行得更快。我们可以通过简单地比较两个指令计数来猜测大约快多少,但我们应指出,这是一个非常简化的观点,因为我们不应忽视编译器供应商在优化编译器中扮演着重要角色的事实,并强调 NVIDIA 进行了一项名为 精度与性能:NVIDIA GPU 的浮点数和 IEEE 754 兼容性 的研究,该研究可在以下链接中阅读:链接。该研究建议 FMA 可以提供精度和性能,NVIDIA 至少是我们所知的一个公司,它们在 GPU 芯片中用 FMA 替换了 MAD。

遵循乘法的主题,你应该知道有针对整数乘法而不是浮点数的指令;这些指令的例子有 mad_himad_satmad24,这些函数为开发者提供了对影响更高效计算以及如何使用这些优化版本实现的精细控制。例如,mad24 只在 32 位整数的低 24 位上操作,因为当操作有符号整数时预期值在 [-223, 223 -1] 范围内,对于无符号整数则是 [0, 224 -1]。

准备工作

Ch4/simple_fma_vs_mad/fma_mad_cmp.cl 中的代码片段演示了如何测试 MAD 和 FMA 指令之间的性能,如果你愿意,可以完成计算。然而,我们将要演示的是简单地依次运行每个内核,并检查两种计算的结果是否相同。

如何做…

以下代码演示了如何在 OpenCL 中使用 MAD 和 FMA 函数:

__kernel void mad_test(__global float* a, __global float* b, __global float* c, __global float* result) {
  float temp = mad(a, b, c);
  result[get_global_id(0)] = temp;
}
__kernel void fma_test(__global float* a, __global float* b,__global float* c, __global float* result) {
  float temp = fma(a, b, c);
  result[get_global_id(0)] = temp;
}

要在 OS X 平台上编译它,你将需要运行一个类似于以下命令的编译命令:

gcc –std=c99 –Wall –DUNIX –g –DDEBUG –DAPPLE –arch i386 –o FmaMadCmp fma_mad_cmp.c –framework OpenCL

或者,你可以在源目录 Ch4/simple_fma_vs_mad/ 中输入 make。当这样做时,你将得到一个名为 FmaMadCmp 的二进制可执行文件。

要在 OS X 上运行程序,只需执行程序 FmaMadCmp,你应该看到以下输出:Check passed!Check failed!,如下所示:

Check passed!

它是如何工作的…

驱动代码使用单精度浮点值通过在 GPU/CPU 上依次运行两个内核来计算方程的值。每个内核都会将值从 __global 内存空间加载到工作项/线程的 __private 内存空间。两个内核之间的区别在于一个使用 FMA 指令,而另一个使用 MAD 指令。检测所选设备上是否支持 FMA 指令的方法是在调用 clGetDeviceInfo 时传递以下参数之一:CL_DEVICE_SINGLE_FP_CONFIGCL_DEVICE_DOUBLE_FP_CONFIGCL_DEVICE_HALF_FP_CONFIG。我们使用标志 CP_FP_FMAFP_FAST_FMA 通过包含头文件 #include <math.h> 在我们的平台上加载 fma 函数。

注意

如果定义了 C 宏 FP_FAST_FMA,则将其设置为 1 的常量,以指示 fma() 通常执行速度与乘法和加法双操作数相当,或者更快。如果此宏未定义,则意味着你的硬件不支持它。在 GNU GCC 编译器套件中,你想要检测的宏是 __FP_FAST_FMA,如果定义了它,则链接到 FP_FAST_FMA,或者将 –mfused-madd 传递给 GCC 编译器(默认开启,如果 ISA 支持,则自动生成 FMA 指令)。

使用三角函数

如果你在计算机图形学行业工作,或者你正在编写用于天气预报、连分数等的模拟程序,三角函数将非常有用。OpenCL 在提供三角函数支持时提供了常用的函数,如 cosacossinasintanatanatanh(双曲反正切)、sinh(双曲正弦)等。

在本节中,我们将探讨流行的三角恒等式函数:

*sin2 + cos2 = 1*

从毕达哥拉斯定理中,我们了解到,在 abc 边的直角三角形中,在 ac 相遇的顶点处有一个角 t,根据定义,cos(t)a/csin(t)b/c,因此 cos2(t) + sin2(t) = (a/c)2 + (b/c)2,结合事实 a2 + b2 = c2,因此 cos2(t) + sin2(t) = 1

在掌握了这些知识之后,你可以用这个恒等式解决许多有趣的问题,但为了说明,让我们假设我们想要找到单位圆的数量。

单位圆是另一种看待我们刚才讨论的恒等式的方法。一个虚构的例子是确定从给定的两个数组中哪些值会是有效的单位圆。

准备工作

Ch4/simple_trigonometry/simple_trigo.cl 中的代码片段展示了用于计算从两个数据源中哪些值可以正确形成一个单位圆的 OpenCL 内核。

注意

如果你还记得你上过的基础三角学课程,当你将 sin(x) + cos(x) 的结果相加,其中 x 是来自正数或负数的值时,它将产生两个不同的直线函数 y = 1y = -1,并且当你对 sin(x)cos(x) 的结果进行平方,cos2(t) + sin2(t) = 1 的结果显然。请参见以下图表以供说明:

准备工作

上述图表和以下图表分别反映了 sin(x)cos(x) 的图表:

准备工作

以下图表说明了如何将前两个图表叠加在一起,从而得到由以下方程表示的直线:

准备工作

如何做…

以下代码片段展示了将确定单位圆的内核代码:

__kernel void find_unit_circles(__global float16* a,__global float16* b, __global float16* result) {
    uint id = get_global_id(0);
    float16 x = a[id];
    float16 y = b[id];
    float16 tresult = sin(x) * sin(x) + cos(y) * cos(y);
    result[id] = tresult;
}

要在 OS X 平台上编译它,你需要运行一个类似于以下命令的编译命令:

gcc –std=c99 –Wall –DUNIX –g –DDEBUG –DAPPLE –arch i386 –o SimpleTrigo simple_trigo.c –framework OpenCL

或者,你可以在源目录 Ch4/simple_trigonometry/ 中输入 make。当这样做时,你将得到一个名为 SimpleTrigo 的二进制可执行文件。

要在 OS X 上运行程序,只需执行程序 SimpleTrigo,你应该会看到以下所示的输出:

Find Unit Circle:
Unit circle with x=1, y=1

它是如何工作的…

驱动程序通过填充值来加载两个数据源,并将数据源注册在设备命令队列上,同时注册了准备执行的内核程序对象。

在内核执行期间,数据源通过单一精度浮点 16 元素向量加载到设备中。正如前几章所强调的,这利用了设备的向量化硬件。内存中的向量被传递到正弦和余弦函数中,这两个版本中一个接受标量值,另一个接受向量值,一旦完成,我们就将结果输出到全局内存中;您会注意到乘法/加法运算符实际上执行的是逐分量乘法和加法。

OpenCL 中的算术和舍入

舍入是 OpenCL 中的一个重要主题,我们还没有真正深入探讨,但这种情况即将改变。OpenCL 1.1 支持四种舍入模式:四舍五入到最接近的数(偶数)、四舍五入到零、四舍五入到正无穷大和四舍五入到负无穷大。OpenCL 1.1 合规设备所需的唯一舍入模式是四舍五入到最近的偶数。

注意

如果结果是两个可表示值之间的中间值,则选择偶数表示。在这里,“偶数”意味着最低位为零。

您应该知道,这些适用于 OpenCL 1.1 支持的单一精度浮点值;我们必须与提供操作双精度浮点值函数的供应商进行核对,尽管作者怀疑它们至少应该遵守以支持四舍五入到最近的偶数模式。

另一点是,您不能通过编程方式配置您的内核以继承/更改调用环境使用的舍入模式,这很可能是您的程序在 CPU 上执行的地方。在 GCC 至少,您实际上可以使用内联汇编指令,例如,asm("assembly code inside quotes"),通过向程序中插入适当的硬件指令来更改程序中的舍入模式。下一节将尝试通过使用常规 C 编程并从 GCC 获得一些帮助来演示如何实现这一点。

注意

在 Intel 64 和 IA-32 架构中,舍入模式由一个 2 位的舍入控制RC)字段控制,其实现在两个硬件寄存器中隐藏:x87 FPU控制寄存器和MXCSR寄存器。这两个寄存器都有 RC 字段,x87 FPU 控制寄存器中的 RC 在 CPU 执行 x87 FPU 计算时被使用,而 MXCSR 中的 RC 字段用于控制使用SSE/SSE2指令执行的SIMD浮点计算的舍入。

准备工作

Ch4/simple_rounding/simple_rounding.cl 代码片段中,我们展示了 四舍五入到最接近的偶数 模式是 OpenCL 1.1 提供的内置函数中的默认模式。示例继续演示了特定内置函数和余数将如何使用默认舍入模式来存储浮点计算的结果。接下来的几个操作是为了演示以下 OpenCL 内置函数的用法,如 rintroundceilfloortrunc

如何做到这一点...

以下代码片段检查了各种舍入模式:

__kernel void rounding_demo(__global float *mod_input, __global float *mod_output, __global float4 *round_input,__global float4 *round_output) {
    mod_output[1] = remainder(mod_input[0], mod_input[1]);
    round_output[0] = rint(*round_input);
    round_output[1] = round(*round_input);
    round_output[2] = ceil(*round_input);
    round_output[3] = floor(*round_input);
    round_output[4] = trunc(*round_input);
}

要在 OS X 平台上编译它,你必须运行一个类似于以下命令的编译命令:

gcc –std=c99 –Wall –DUNIX –g –DDEBUG –DAPPLE –arch i386 –o SimpleRounding simple_rounding.c –framework OpenCL

或者,你可以在源目录 Ch4/simple_rounding/ 中输入 make。当这样做时,你将得到一个名为 SimpleRounding 的二进制可执行文件。

要在 OS X 上运行程序,只需执行程序 SimpleRounding,你应该会看到以下所示的输出:

Input: -4.5f, -1.5f, 1.5f, 4.5f
Rint:
Round:
Ceil:
Floor:
Trunc:

它是如何工作的...

如前所述,主机上的内存数据结构使用值初始化,并在创建设备命令队列后一次性发送到设备;一旦完成,内核就会被发送到命令队列进行执行。随后,结果从设备读取并显示在控制台上。

为了理解这些函数是如何工作的,我们首先通过探究它们的方法签名,然后分析程序执行的结果,以获得对结果是如何产生的洞察是非常重要的。

还有更多……

OpenCL 1.2 为开发者带来了丰富的数学函数,以武装其能力,其中四个常见的函数是计算地板和天花板、四舍五入到整数、截断以及四舍五入浮点数。地板函数的签名是:

gentype floor(gentype x);
// gentype can be float,float2,float3,float4,float8,float16

此函数使用四舍五入到负无穷舍入模式四舍五入到整数值。首先,你的 OpenCL 设备需要支持这种舍入模式,你可以通过在传递 CL_DEVICE_DOUBLE_FP_CONFIGclGetDeviceInfo(device_id, ...) 时检查 CL_FP_ROUND_TO_INF 值的存在来确定这一点。

下一个方法,ceil 的签名是:

gentype ceil(gentype x);
// gentype can be float,float2,float3,float4,float8,float16

此函数使用四舍五入到正无穷舍入模式四舍五入到整数值。

注意,当将介于 -10 之间的值传递给 ceil 时,结果将自动变为 -0

四舍五入到整数值的方法签名如下:

gentype rint(gentype x);
// gentype can be float,float2,float3,float4,float8,float16

此函数使用四舍五入到最接近的偶数舍入模式四舍五入到整数值。

注意,当将介于 -0.50 之间的值传递给 rint 时,结果将自动变为 -0

截断函数在精度不是你优先考虑的事项时非常有用,其方法签名是:

gentype trunc(gentype x);
// gentype can be float,float2,float3,float4,float8,float16

此函数使用四舍五入到零舍入模式四舍五入到整数值。

舍入方法签名如下:

gentype round(gentype x);
// gentype can be float,float2,float3,float4,float8,float16

此函数返回最接近 x 的整数值,对于四舍五入的情况,无论当前舍入方向如何,都舍去零。可用函数的完整列表可以在 OpenCL 1.2 规范的 第 6.12.2 节 中找到。

当你运行程序时,你应该得到以下结果:

Input: -4.5, 1.5, 1.5, 4.5
Rint:  -4.0, -2.0, 2.0, 4.0
Round: -5.0, -2.0, 2.0, 5.0
Ceil:  -4.0, -1.0, 2.0, 5.0
Floor: -5.0, -2.0, 1.0, 4.0
Trunc: -4.0, -1.0, 1.0, 4.0

在 OpenCL 中使用 shuffle 函数

shuffleshuffle2 函数是在 OpenCL 1.1 中引入的,用于从它们的输入(这些输入可以是单个向量或两个向量)构建元素排列,并返回与输入相同类型的向量;返回向量的元素数量由传递给它的参数 mask 决定。让我们看看它的方法签名:

gentypeN shuffle(gentypeM x, ugentypeN mask);
gentypeN shuffle(gentypeM x, gentypeM y, ugentypeN mask);

在签名中使用的 NM 代表返回向量和输入向量的长度,可以取值 {234816}ugentype 代表无符号类型,gentype 代表 OpenCL 中的整数类型以及浮点类型(即半精度、单精度或双精度);如果你选择使用浮点类型,请记住扩展 cl_khr_fp16cl_khr_fp64

这里有一个它如何工作的例子:

uint4 mask = {0,2,4,6};
uint4 elements = {0,1,2,3,4,5,6};
uint4 result = shuffle(elements, mask);
// result = {0,2,4,6};

让我们看看一个简单的实现,我们从流行的 Fisher-Yates ShuffleFYS)算法中汲取灵感。这个 FYS 算法生成一个有限集合的随机排列,其基本过程类似于从一个容器中随机抽取一个编号的票,或者从一副牌中抽取一张牌,依次进行,直到容器/牌堆中没有剩余的牌。这个算法最令人欣赏的特性之一是它保证产生一个无偏的结果。我们的例子将关注于如何进行洗牌,因为它本质上是根据一个应该随机生成的掩码来选择特定的元素。

准备工作

Ch4/simple_shuffle/simple_shuffle.cl 中的代码片段几乎捕捉了我们试图说明的大部分想法。想法很简单,我们想要生成一个掩码,并使用该掩码生成输出数组的排列。我们不会使用梅森旋转器这样的伪随机数生成器,而是依赖于 C 的 stdlib.h 函数,一个具有有效种子的随机函数,从这个函数中生成一系列随机数,其中每个数不能超过输出数组最大大小的值,即 15

注意

stdlib.h 中的 rand() 函数并不真正受欢迎,因为它生成的随机序列比 random() 生成的序列要少,因为 rand() 生成的低十二位数字会通过一个循环模式。

如何操作…

在我们开始洗牌之前,我们需要先对 RNG 进行初始化,这可以通过对 srandom() 进行简单的 API 调用并传递种子来实现。下一步是运行我们的内核多次,我们通过将内核执行封装在循环中来实现这一点。以下是从 Ch4/simple_shuffle/simple_shuffle.c 中的主机代码的代码片段,展示了这一点:

#define ITERATIONS 6
#define DATA_SIZE 1024
srandom(41L);
  for(int iter = 0; iter < ITERATIONS; ++iter) {
    for(int i = 0; i < DATA_SIZE; ++i) {
      mask[i] = random() % DATA_SIZE;
      // kernel is invoked
    }// end of inner-for-loop
   }//end of out-for-loop

以下内核代码通过ab传输输入,它们的组合元素大小为16,掩码正在传输到常量内存空间(即只读)。

__kernel void simple_shuffle(__global float8* a,__global float8* b, __constant uint16 mask,__global float16* result) {
    uint id = get_global_id(0);
    float8 in1 = a[id];
    float8 in2 = b[id];
    result[id] = shuffle2(in1, in2, mask);
}

要在 OS X 平台上编译它,你必须运行一个类似于以下命令的编译命令:

gcc –std=c99 –Wall –DUNIX –g –DDEBUG –DAPPLE –arch i386 –o SimpleShuffle simple_shuffle.c –framework OpenCL

或者,你可以在源目录Ch4/simple_shuffle/.中键入make。当这样做时,你将有一个名为SimpleShuffle的二进制可执行文件。

要在 OS X 上运行程序,只需执行名为SimpleShuffle的程序,你应该会看到如下所示的输出:

Shuffle: -4.5f, -1.5f, 1.5f, 4.5f

它是如何工作的…

以下图表表明,每个正在执行的内核通过从__global内存空间获取数据到__private内存空间的部分源数组工作,该数组包含k个元素。接下来的操作是使用预生成的随机数向量进行洗牌,这些随机数是在主机上预先生成的,对于每个分区数据块,内核将生成一个结果数组;一旦完成,内核将数据刷新到__global内存空间。以下图表说明了这个想法,其中结果数组由其单个组成部分的排列组成的排列数组:

如何工作…

在 OpenCL 中使用 select 函数

select函数首先与我们在上一节中看到的shuffleshuffle2函数相似,也被称为三元选择,并且它是 OpenCL 中的关系函数之一,在 C++和 Java 编程语言中常见;但有一个显著的区别,那就是select函数及其变体bitselect不仅与单精度或双精度浮点类型一起工作,还与单精度或双精度浮点值向量一起工作。下面是它的样子:

(predicate_is_true? eval_expr_if_true : eval_expr_if_false)

因此,当谓词评估为真时,冒号左侧的表达式将被评估;否则,评估冒号右侧的表达式,并且在两种评估中,都会返回一个结果。

使用 OpenCL 中的示例,以下条件语句:

if (x == 1) r = 0.5;
if (x == 2) r = 1.0;

可以使用select()函数重写为:

r = select(r, 0.5, isequal(x, 1));
r = select(r, 1.0, isequal(x, 2));

为了使这种转换正确,原始的if语句不能包含任何调用 I/O 的代码。

select/bitselect提供的主要优势是,供应商可以选择从其实施中消除分支和分支预测,这意味着生成的程序可能更高效。这意味着这两个函数充当了一个门面,使得像 AMD 这样的供应商可以使用 SSE2 的__mm_cmpeq_pd__mm_cmpneq_pd等 ISA 来实现实际的功能;同样,Intel 可以选择 Intel AVX 的 ISA,如__mm_cmp_pd__mm256_cmp_pd或从 SSE2 来实现selectbitselect的功能。

准备工作

以下示例演示了我们可以如何使用 select 函数。该函数展示了它提供的便利性,因为它在向量的抽象上操作,将函数应用于多个数据值。Ch4/simple_select_filter/select_filter.cl 中的代码片段试图通过依次从每个列表中选择元素来执行选择,以建立结果,在这个例子中结果恰好是一个向量。

如何做到这一点…

以下代码片段演示了如何在 OpenCL 中使用 select 函数:

__kernel void filter_by_selection(__global float8* a,__global float8* b, __global float8* result) {
    uint8 mask = (uint8)(0,-1,0,-1,0,-1,0,-1);
    uint id = get_global_id(0);
    float8 in1 = a[id];
    float8 in2 = b[id];
    result[id] = select(in1, in2, mask);
}

要在 OS X 平台上编译它,你将不得不运行一个类似的编译命令:

gcc –std=c99 –Wall –DUNIX –g –DDEBUG –DAPPLE –arch i386 –o SelectFilter simple_select.c –framework OpenCL

或者,你可以在源目录 Ch4/simple_select/ 中输入 make。当这样做时,你将得到一个名为 SelectFilter 的二进制可执行文件。

要在 OS X 上运行程序,只需执行程序 SelectFilter,你应该会看到以下输出:

select: -4.5f, -1.5f, 1.5f, 4.5f

工作原理…

程序继续通过 clGetPlatformIDsclGetDeviceIDs API 建立到 OpenCL 兼容设备的上下文。一旦建立,我们就着手创建内存中的数据结构,并准备将其提交到设备的命令队列。

主机上的内存数据结构是小型数组,我们可以将其提交给设备以供消费,通过将其发送到系统总线来在设备内存中填充这些结构。它们作为局部变量 in1in2 存留在设备内存中。

一旦数据在设备的内存中膨胀,select_filter.cl 中的算法将依次通过位比较来选择每个元素,其中检查最高有效位;如果最高有效位等于 1,则返回 Buffer B 中相应的值;否则返回 Buffer A 中相应的位置。从计算机科学中回忆起来,-1,即一元减 1,在二进制补码表示法中等于 0xffff,因此该值的最高有效位肯定等于 1

以下图表说明了这个选择过程。与之前一样,一旦选择过程完成,它就会被刷新到结果向量 result 中。

工作原理…

第五章:开发直方图 OpenCL 程序

在本章中,我们将介绍以下食谱:

  • 在 C/C++中实现直方图

  • 直方图的 OpenCL 实现

  • 工作项同步

简介

任何在学校学过基础数学的人都知道什么是直方图。它是众多可以可视化两组数据之间关系的方法之一。这两组数据被安排在两个轴上,其中一个轴将代表数据集中的不同值,另一个轴将代表每个值出现的频率。

直方图是一个有趣的研究主题,因为它的实际应用可以在计算图像处理、定量/定性金融、计算流体动力学等领域找到。它是 OpenCL 在 CPU 或 GPU 上运行时的早期示例之一,其中已经进行了多种实现,每种实现都有其优缺点。

在 C/C++中实现直方图

在我们查看如何在 OpenCL 中实现它并在桌面 GPU 上运行应用程序之前,让我们看看如何使用单个执行线程来实现它。

准备工作

对顺序代码的研究很重要,因为我们需要一种方法来确保我们的顺序代码和并行代码产生相同的结果,这通常被称为黄金参考实现。

注意

在您作为 OpenCL 工程师的角色中,您的待办事项列表上的一项可能是将顺序算法转换为并行算法,并且您能够理解如何做到这一点非常重要。我们试图传授一些可能并不完全详尽的技能。最重要的技能之一是能够识别可并行化例程

检查下面的代码,我们可以开始理解直方图程序是如何工作的。

如何做…

在这里,我们展示了整个顺序代码,其中它使用一个执行线程来创建直方图的内存结构。在此阶段,您可以复制以下代码并将其粘贴到您选择的目录中,并将此程序命名为 Ch5/histogram_cpu/histogram.c

#define DATA_SIZE 1024
#define BIN_SIZE 256

int main(int argc, char** argv) {
    unsigned int* data = (unsigned int*) malloc( DATA_SIZE *
                         sizeof(unsigned int));
    unsigned int* bin  = (unsigned int*) malloc( BIN_SIZE *
                         sizeof(unsigned int));
    memset(data, 0x0, DATA_SIZE * sizeof(unsigned int));
    memset(bin, 0x0, BIN_SIZE * sizeof(unsigned int));

    for( int i = 0; i < DATA_SIZE; i++) {
        int indx = rand() % BIN_SIZE;
        data[i] = indx;
    }

    for( int i = 0; i < DATA_SIZE; ++i) {
       bin[data[i]]++;
    }

}

为了构建程序,我们假设您有一个 GNU GCC 编译器。在终端中输入以下命令:

/usr/bin/gcc –o histogram Ch5/histogram_c/histogram.c

或者,在 Ch5/histogram_c 目录下运行 make,一个名为 histogram 的可执行文件将被放置在您执行该命令的目录中。

要运行程序,只需在 Ch5/histogram_c 文件夹中执行名为 histogram 的程序,它应该不会输出任何内容。然而,您可以自由地将 C 的输出函数 printfsprintf 注入到之前的代码中,并说服自己直方图正在按预期工作。

如何工作…

要制作直方图,我们需要一个包含值的初始数据集。直方图中的值是通过扫描数据集并记录扫描值在数据集中出现的次数来计算的。因此,有了数据分箱的概念。以下图表说明了这个概念:

如何工作…

在以下代码中,我们看到第一个for循环将data数组填充了从0255的值:

for( int i = 0; i < DATA_SIZE; i++) {
        int indx = rand() % BIN_SIZE;
        data[i] = indx;
}

第二个for循环遍历data数组并记录每个值的出现次数,最后的for循环用于打印每个值的出现次数。这就是数据分箱的本质。

for( int i = 0; i < DATA_SIZE; ++i) {
       bin[data[i]]++;
}

最后,你会迭代分箱数据并打印出你发现的内容:

for( int i = 0; i < BIN_SIZE; i ++) {
        if (bin[i] == 0) continue; 
        else printf("bin[%d] = %d\n", i, bin[i]);
}

接下来,我们将探讨 OpenCL 如何将其实现应用于数据分箱。

OpenCL 的直方图实现

在本节中,我们将尝试培养你的直觉,以便能够识别可能的并行化区域以及如何使用这些技术来并行化顺序算法。

不想深入太多关于并行化的理论,关于一个例程/算法是否可以并行化的一个关键洞察是检查算法是否允许工作在不同处理元素之间分割。从 OpenCL 的角度来看,处理元素将是处理器,即 CPU/GPU。

注意

回想一下,OpenCL 的工作项是执行元素,它们作用于一组数据并在处理元素上执行。它们通常在工作组中找到,其中所有工作项可以在一定程度上协调数据读取/写入,并且它们共享相同的内核和工作组屏障。

检查代码,你会注意到第一件事可能是能够满足描述的:

“...允许工作在不同处理元素之间分割”

这是为了寻找for循环。这是因为循环意味着代码正在执行相同的指令块以实现某种结果,如果我们做得好,我们应该能够将循环中的工作拆分,并为执行代码的一部分分配多个线程。

准备工作

在许多算法中,你会发现有时将工作拆分并不一定意味着数据需要被干净地分割,这是因为数据是只读的;然而,当算法需要对数据进行读写操作时,你需要想出一个方法来干净地分割它们。最后一句话需要一些解释。回想一下第二章,理解 OpenCL 数据传输和分割,我们讨论了工作项和数据分割,到现在你应该已经理解了,如果你在数据索引计算错误或者引入了数据依赖,OpenCL 不会阻止你,作为开发者,为你的数据创建竞态条件。

权力越大,责任越大。

在构建数据并行算法时,能够理解一些事情非常重要,并且从实现 OpenCL 直方图程序的角度来看,这里有一些建议:

  • 理解你的数据结构:在前几章中,我们看到了如何允许用户定义的结构和常规的 1D 或 2D 数组被输入到内核中进行执行。你应该始终寻找合适的结构来使用,并确保你注意到了偏移量错误(在我的经验中,它们比其他任何东西都更常见)。

  • 决定在一个工作组中应该执行多少个工作项:如果内核只有一个工作项执行大量数据集,由于硬件的工作方式,这样做通常效率不高。合理地配置大量工作项在内核中执行是有意义的,这样它们就可以利用硬件资源,这通常会增加数据的时空局部性,这意味着你的算法运行得更快。

  • 决定如何编写最终结果:在我们选择的直方图实现中,这很重要,因为每个内核将处理数据的一部分,我们需要将它们合并。我们之前没有看到过这样的例子,所以这是我们的机会!

让我们看看这些建议如何应用。基本思路是将一个大数组分配给几个工作组。每个工作组将处理自己的数据(带有适当的索引)并将这些数据存储/存储在硬件提供的暂存器内存中,当工作组完成其处理时,其本地内存将存储回全局内存。

我们选择了一维数组来包含初始数据集,这些数据可能无限,但作者的机器配置没有无限的内存,所以有一个实际的限制。接下来,我们将这个一维数组分成几个块,这很有趣。

每个数据块将被干净地分区并由一个工作组执行。这个工作组选择容纳 128 个工作项,每个工作项将产生一个大小为 256 个元素的桶或 256 个桶。

每个工作组将把这些数据存储到本地内存中,也称为暂存器内存,因为我们不希望不断在全局和设备内存之间来回移动。这会真正影响性能。

在下一节中展示的代码中,你将学习到的一种技术是使用暂存器内存或本地内存来帮助你的算法更快地执行。

注意

局部内存是一种软件控制的临时内存,因此得名。临时内存允许内核明确地将项目加载到该内存空间,并且它们存在于局部内存中,直到内核替换它们,或者直到工作组结束其执行。要声明一块局部内存,使用 __local 关键字,并且可以在内核调用的参数中或在其主体中声明它们。这种内存分配由工作组中的所有工作项共享。

宿主代码不能从或向局部内存读写。只有内核可以访问局部内存。

到目前为止,你已经看到了如何从 OpenCL 设备获取内存分配,并触发内核以消耗输入数据,随后从处理后的数据中读取以进行验证。在接下来的段落中,你可能会感到有些头疼,但请相信自己,我相信我们可以顺利通过。

如何做到这一点…

完整的工作内核如下所示,来自 Ch5/histogram/histogram.cl,我们在代码中添加了一些注释,以便帮助您理解结构背后的动机:

#define MEMORY_BANKS 5U // 32-memory banks.

__kernel

void histogram256(__global const unsigned int4* data,
                               __local uchar* sharedArray,
                               __global uint* binResult) {

// these 4 statements are meant to obtain the ids for the first
// dimension since our data is a 1-d array
size_t localId = get_local_id(0);
size_t globalId = get_global_id(0);
size_t groupId = get_group_id(0);
size_t groupSize = get_local_size(0);

int offSet1 = localId & 31;
int offSet2 = 4 * offSet1;
int bankNumber = localId >> MEMORY_BANKS;

__local uchar4* input = (__local uchar4*) sharedArray;

// In a work-group, each work-item would have an id ranging from
// [0..127]
// since our localThreads in 'main.c' is defined as 128
// Each work-item in the work-group would execute the following
// sequence:
// work-item id = 0, input[128 * [0..63]] = 0
// Not forgetting that input is a vector of 4 unsigned char type,
// that effectively means
// that each work-group would execute this loop 8192 times and each
// time it would set
// 4 bytes to zero => 8192 * 4 bytes = 32-KB and this completes the
// initialization of the
// local shared memory array.

for(int i = 0; i < 64; ++i )
  input[groupSize * i + locald] = 0;

// OpenCL uses a relaxed consistency memory model which means to say
// that the state of
// memory visible to a work-item is not guaranteed to be consistent
// across the collection
// of work-items at all times.
// Within a work-item memory has load/store consistency. Local memory
// is consistent
// across work-items in a single work-group at a work-group barrier.
// The statement below
// is to perform exactly that function.
// However, there are no guarantees of memory consistency between
// different
// work-groups executing a kernel

// This statement means that all work-items in a single work-group
// would have to reach
// this point in execution before ANY of them are allowed to continue
// beyond this point.

barrier(CLK_LOCAL_MEM_FENCE);

// The group of statements next fetch the global memory data and
// creates a binned
// content in the local memory.
// Next, the global memory is divided into 4 chunks where the
// row_size = 64 and'
// column_size = 128\. The access pattern for all work-items in the
// work-group is
// to sweep across this block by accessing all elements in each
// column 64-bytes at a time.
// Once that data is extracted, we need to fill up the 32-KB local
// shared memory so we
// next extract the vector values from the local variable "value" and
// fill them up. The
// pattern we used to store those values is as follows:
// value.s0 can only range from [0..255] and value.s0 * 128 would
// indicate which row
// and column you like to store the value. Now we land in a
// particular row but we need
// to decide which 4-byte chunk its going to store this value since
// value.s0 is a int and
// sharedArray is a uchar-array so we use offSet2 which produces an
// array [0,4,8...124]
// and now we need which chunk its going to land in. At this point,
// you need to remember
// that value.s0 is a value [0..255] or [0x00..0xFF] so we need to
// decide which element in
// this 4-byte sub-array are we going to store the value.
// Finally, we use the value of bankNumber to decide since its range
// is [0..3]
for(int i = 0; i < 64; ++i) {
  uint4 value = data[groupId * groupSize * BIN_SIZE / 4 + i * groupSize + localId];
  sharedArray[value.s0 * 128 + offSet2 + bankNumber]++;
  sharedArray[value.s1 * 128 + offSet2 + bankNumber]++;
  sharedArray[value.s2 * 128 + offSet2 + bankNumber]++;
  sharedArray[value.s3 * 128 + offSet2 + bankNumber]++;
}

// At this point, you should have figured it out that the 128 * 256
// resembles a hashtable
// where the row indices are the keys of the 256-bin i.e. [0..255]
// and the "list" of values
// following each key is what it looks like
// [0]   -> [1,3,5,6 ...]
// [1]   -> [5,6,2,1... ]
// ...
// [255] -> [0,1,5,..]
// Next, we go through this pseudo-hashtable and aggregate the values
// for each key
// and store this result back to the global memory.
// Apply the barrier again to make sure every work-item has completed
// the population of
// values into the local shared memory.

barrier(CLK_LOCAL_MEM_FENCE);

// Now, we merge the histograms
// The merging process is such that it makes a pass over the local
// shared array
// and aggregates the data into 'binCount' where it will make its way
// to the
// global data referenced by 'binResult'

if(localId == 0) { // each work-group only has 1 work-item executing this code block
  for(int i = 0; i < BIN_SIZE; ++i) {
    uint result = 0;
    for(int j = 0; j < groupSize; ++j) {
      result += sharedArray[i * groupSize + j];
    }
    binResult[groupId * BIN_SIZE  + i] = result;
  }
}

要在 OS X 平台上编译它,你需要运行类似于以下命令的编译命令:

gcc –std=c99 –Wall –DUNIX –g –DDEBUG –DAPPLE –arch i386 –o Histogram main.c –framework OpenCL

或者,你可以在 Ch5/histogram 目录下运行 make,你将得到一个名为 Histogram 的二进制可执行文件。

要运行程序,只需执行名为 Histogram 的程序。在我的机器上,一个 OS X 的示例输出如下:

Passed!

它是如何工作的…

在宿主代码中,我们首先分配实现直方图所需的数据结构。Ch5/histogram/main.c 源代码的摘录展示了创建单个设备队列的代码,其中包含内核和你的常规嫌疑人。变量 inputBufferintermediateBinBuffer 指的是未归一化的数组和中间的桶:

queue = clCreateCommandQueue(context, device, 0, &error);

cl_kernel kernel = clCreateKernel(program, "histogram256", &error);

inputBuffer = clCreateBuffer(context,
                             CL_MEM_READ_ONLY|CL_MEM_COPY_HOST_PTR,
                             width * height * sizeof(cl_uint),
                             data,
                             &error);

intermediateBinBuffer = clCreateBuffer(context,
                                       CL_MEM_WRITE_ONLY,
                                       BIN_SIZE * subHistogramCount * sizeof(cl_uint),
                                       NULL,
                                       &error);

clSetKernelArg(kernel, 0, sizeof(cl_mem),(void*)& inputBuffer);

// the importance of uchar being that its unsigned char i.e. value //range[0x00..0xff]
clSetKernelArg(kernel, 1, BIN_SIZE * GROUP_SIZE * sizeof(cl_uchar), NULL); // bounded by LOCAL MEM SIZE in GPU
clSetKernelArg(kernel, 2, sizeof(cl_mem), (void*)& intermediateBinBuffer);

因此,从概念上讲,代码将输入数据分成 256 个元素的块,每个这样的块将被加载到设备的局部内存中,然后由工作组中的工作项进行处理。以下是如何看起来:

它是如何工作的…

现在,想象内核将要执行代码,并且它需要知道如何从全局内存中获取数据,处理它,并将其存储回某些数据存储中。由于我们选择使用局部内存作为临时数据存储,让我们看看局部内存如何帮助我们算法,并最终检查其处理过程。

局部内存与 C 中的任何其他内存非常相似,因此在使用之前需要将其初始化到适当的状态。之后,你需要确保遵守适当的数组索引规则,因为那些一次性错误可能会使你的程序崩溃,并可能导致你的 OpenCL 设备挂起。

局部内存的初始化是通过以下程序语句完成的:

__local uchar* input = (__local uchar4*) sharedArray;

for(int i = 0; i < 64; ++i)
  input[groupSize * i + localId] = 0;

barrier(CLK_LOCAL_MEM_FENCE);

到目前为止,我应该提醒你戴上你的多核帽子,想象一下 128 个线程正在执行这个内核。有了这个理解,你会意识到整个局部内存通过简单的算术被设置为零。现在,如果你还没有意识到,重要的是每个工作项不应执行任何重复的操作。

注意

初始化可以以顺序方式编写,并且仍然可以工作,但这意味着每个工作项的初始化将与某些其他工作项的执行重叠。这通常是不好的,因为在我们这个例子中,它可能是无害的,但在其他情况下,它意味着你可能会花费大量时间调试你的算法。这种同步适用于工作组中的所有工作项,但不会帮助在工作组之间进行同步。

接下来,我们看到一个我们可能之前没有见过的语句。这是一种同步或内存屏障的形式。关于屏障的有趣观察是,所有工作项必须到达这个语句才能继续进行。这就像 100 米赛跑中跑者的起跑线。

原因是,我们算法的正确性取决于这样一个事实:在任何一个工作项希望读取和写入之前,局部内存中的每个元素都必须是 0

注意

你应该知道,你不能为局部内存设置一个大于 OpenCL 设备上可用的值的值。为了确定设备上配置的最大临时存储器内存,你需要使用 API clGetDeviceInfo 并传入参数 CL_DEVICE_LOCAL_MEM_SIZE

从概念上讲,这是前面那段代码所做的事情——每个工作项以列向量的方式将所有元素设置为零,并以128个工作项作为一个工作组集体执行它,从左到右进行扫描。由于每个项都是 uchar4 数据类型,你会看到行数是64而不是256

如何工作…

最后,让我们尝试理解值是如何从全局内存中检索出来并存储在临时存储器中的。

当一个工作组开始执行时,它会访问全局内存并获取四个值的 内容,并将它们存储到局部变量中,一旦完成,接下来的四个语句将由每个工作项执行,以使用组件选择语法处理检索到的每个值,即 value.s0, value.s1, value.s2, value.s3

下面的插图展示了工作项如何潜在地访问临时存储器上的四行数据,并通过递增它们来更新这些行中的四个元素。需要记住的重要一点是,在处理之前,临时存储器中的所有元素都必须被写入,因此这就是屏障。

这种编程技术,我们构建中间数据结构以便最终获得所需的数据结构,在一些圈子中常被称为基于线程的直方图。当我们知道最终数据结构的样子,并使用相同的 ADT 来解决数据的小部分以便最终合并时,通常会采用这种技术。

for(int i = 0; i < 64; i++)
{
       uint4 value =  data[groupId * groupSize * BIN_SIZE/4 + i * groupSize + localId];
       sharedArray[value.s0 * 128 + offSet2 + bankNumber]++;
       sharedArray[value.s1 * 128 + offSet2 + bankNumber]++;
       sharedArray[value.s2 * 128 + offSet2 + bankNumber]++;
       sharedArray[value.s3 * 128 + offSet2 + bankNumber]++;
}
barrier(CLK_LOCAL_MEM_FENCE);

如何工作…

如果你分析内存访问模式,你会意识到我们创建了一个名为抽象数据类型ADT)的哈希表,其中本地内存中的每一行数据代表 0 到 255 之间值出现的频率列表。

有这样的理解,我们就可以进入解决这个问题的最后部分。再次想象工作组已经执行到这一点,你基本上有一个哈希表,你想要合并其他工作组在本地内存中持有的所有其他哈希表。

为了实现这一点,我们基本上需要遍历哈希表,对每一行的所有值进行聚合,然后我们就能得到答案。然而,现在我们只需要一个线程来完成所有这些,否则所有 128 个执行遍历的线程意味着你的值会被重复计算 128 次!因此,为了实现这一点,我们利用每个工作项在工作组中都有一个局部 ID 的事实,并且通过只选择一个特定的工作项来执行此代码。以下代码说明了这一点:

if(localId == 0) {
    for(int i = 0; i < BIN_SIZE; ++i) {
        uint result = 0;
        for(int j = 0; j < 128; ++j)  {
            result += sharedArray[i * 128 + j];
        }
        binResult[groupId * BIN_SIZE + i] = result;
    }
}

选择第一个工作项没有特别的原因,我想这仅仅是一种惯例,选择其他工作项也没有关系,但重要的是要记住,必须只有一个正在执行的代码。

现在,我们将注意力再次转向主代码,因为每个中间桶已经从其相应的大输入数组部分概念上填充了相应的值。

主代码中(稍微)有趣的部分仅仅是遍历intermediateBins中返回的数据,并将它们聚合到deviceBin中:

for(int i = 0; i < subHistogramCount; ++i)
    for( int j = 0; j < BIN_SIZE; ++j) {
        deviceBin[j] += intermediateBins[i * BIN_SIZE + j];
}

我们已经完成了!

工作项同步

本节旨在向您介绍 OpenCL 中的同步概念。OpenCL 中的同步可以分为两组:

  • 命令队列屏障

  • 内存屏障

准备工作

命令队列屏障确保在命令队列中排队的所有先前命令完成执行之后,才能开始执行命令队列中排队的任何后续命令。

工作组屏障在执行内核的工作组中的工作项之间执行同步。工作组中的所有工作项都必须在允许任何工作项在屏障之后继续执行之前执行屏障构造。

如何做到这一点…

命令队列屏障有两个 API,它们是:

cl_int clEnqueueBarrierWithWaitList(cl_command_queue command_queue,
           cl_uint num_events_in_wait_list, 
           const cl_event *event_wait_list,
           cl_event *event)

cl_int clEnqueueMarkerWithWaitList
          (cl_command_queue command_queue,
           cl_uint num_events_in_wait_list, 
           const cl_event *event_wait_list, 
           cl_event *event) 

但截至 OpenCL 1.2,以下命令队列屏障已被弃用:

cl_int clEnqueueBarrier(cl_command_queue queue);
cl_int clEnqueueMarker(cl_command_queue queue, cl_event* event);

OpenCL 1.2/1.1 中的这四个/两个 API,允许我们在各种 OpenCL 命令之间进行同步,但它们并不同步工作项。

注意

没有同步设施可用于在工作组之间进行同步。

我们还没有看到任何关于如何使用此功能的示例代码,但如果我们需要它们,了解它们的存在仍然是好的。

接下来,你可以将障碍放置在执行对本地内存或全局内存进行读写操作的工作组的工作项中。之前,你了解到所有执行内核的工作项必须在任何工作项继续执行超过障碍之前,执行此函数。此类障碍必须被工作组中的所有工作项遇到。

它是如何工作的…

OpenCL API 如下:

void barrier(cl_mem_fence flags);

其中标志可以是 CLK_LOCAL_MEM_FENCECLK_GLOBAL_MEM_FENCE。在内核代码中放置障碍时请小心。如果障碍需要在类似于 if-then-else 的条件语句中,那么你必须确保所有工作项的执行路径都能到达程序中的那个点。

注意

CLK_LOCAL_MEM_FENCE 障碍将清除存储在本地内存中的任何变量,或者排队一个内存栅栏以确保本地内存操作的正确顺序。

CLK_GLOBAL_MEM_FENCE 障碍函数会将内存栅栏排队,以确保全局内存操作的正确顺序。

放置此类障碍的另一个副作用是,当它们要放置在循环结构中时,所有工作项必须在任何工作项继续执行超过障碍之前,执行每个循环迭代的障碍。此类障碍也确保了内存操作到本地或全局内存的正确顺序。

第六章:开发 Sobel 边缘检测滤波器

在本章中,我们将介绍以下食谱:

  • 理解卷积理论

  • 理解一维卷积

  • 理解二维卷积

  • OpenCL 中 Sobel 边缘滤波器的实现

  • 理解 OpenCL 中的性能分析

简介

在本章中,我们将探讨如何开发一个流行的图像处理算法,即边缘检测。这个问题恰好是解决图像分割中更一般问题的部分。

注意

图像分割是将数字图像分割成多个段(像素集,也称为超像素)的过程。分割的目标是简化图像的表示,或将其转换为更有意义且更容易分析的形式。图像分割通常用于在图像中定位对象和边界(线条、曲线等)。

Sobel 算子是一个离散微分算子,用于计算图像密度函数梯度的近似值。Sobel 算子基于在水平和垂直方向上使用一个小型、可分离且具有整数值的滤波器对图像进行卷积。因此,在计算方面相对较为经济。

如果您一开始不理解这些符号,请不要担心,我们将逐步介绍足够的理论和数学知识,并帮助您理解在 OpenCL 中的应用。

简而言之,Sobel 滤波是一个三步过程。两个 3x3 的滤波器分别独立地应用于每个像素,其目的是使用这两个滤波器分别近似 x 和 y 的导数。使用这些滤波器的结果,我们最终可以近似梯度的幅度。

通过在每个像素(以及其相邻的八个像素)上运行 Sobel 边缘检测器计算出的梯度将告诉我们垂直和水平轴(相邻像素所在的位置)是否存在变化。

对于已经熟悉卷积理论的人来说,一般可以跳过本食谱的如何操作部分。

理解卷积理论

在过去,数学家们发展了微积分,以便有一个系统的方法来推理事物变化的方式,而卷积理论实际上就是关于测量这些变化如何相互影响。那时,卷积积分应运而生。

理解卷积理论

并且理解卷积理论算子是传统数学中使用的卷积算子。一个敏锐的读者会立即注意到我们用一个函数替换了另一个函数,这样做的原因是因为卷积算子是交换律的,也就是说,计算的顺序并不重要。积分的计算可以用离散形式进行,并且不失一般性,我们可以将积分符号理解卷积理论替换为求和符号理解卷积理论,有了这个,让我们看看离散时间域中卷积的数学定义。

准备中

后续我们将通过以下方程式在离散时间域中了解它所传达的信息:

准备中

其中 x[n] 是输入信号,h[n] 是脉冲响应,y[n] 是输出。星号(***)表示卷积。请注意,我们将 x[k] 的项与时间移位的 h[n] 的项相乘并求和。理解卷积的关键在于脉冲响应和脉冲分解。

如何操作…

为了理解卷积的意义,我们将从信号分解的概念开始。输入信号可以被分解为加性成分,而输入信号的系统响应是通过将这些成分的输出通过系统相加得到的。

以下部分将说明一维卷积是如何工作的,一旦你精通这个概念,我们将在此基础上说明二维卷积是如何工作的,并且我们将看到 Sobel 边缘检测器的作用!

理解一维卷积

让我们想象一下,一股能量(信号)已经进入我们的系统,它看起来与以下图示相似,其中 x[n] = {1,3,4,2,1}, for n = 0,1,2,3,4

理解一维卷积

假设我们的脉冲函数在 n = 01 时具有非零值,而对于其他所有 n 的值,它将具有零值。

如何操作...

使用前面的信息,让我们通过快速回忆以下方程来计算输出信号:

如何操作...

严格遵循此方程,我们发现输出信号最初被放大,然后迅速衰减,并且通过手动求解(是的,我是指在铅笔和纸上评估方程)我们将会看到以下最终的输出信号:

如何操作...如何操作...

如何工作…

再次查看前面的方程,这次我们重新排列它们并删除所有评估为零的项。让我们尝试看看是否可以发现一个模式:

如何工作…

我相信你可以看到每个输出值都是从前两个输出值(考虑到脉冲函数)计算出来的!现在我们可以相当舒适地得出结论,计算一维卷积的一般公式实际上是以下这个:

如何工作…

最后,你应该意识到(按照惯例),任何未定义的x[i-k]的值都会自动赋予零。这个看似微小、微妙的事实将在我们最终理解 Sobel 边缘检测滤波器中发挥作用,我们将在下一节描述。

最后,对于本节,让我们看看一维序列卷积代码可能是什么样的:

// dataCount is size of elements in the 1D array
// kernelCount is the pre-defined kernel/filter e.g. h[0]=2,h[1]=1 
// h[x]=0 for x ={…,-1,2,3,…}
for(int i = 0; i < dataCount; ++i) {
  y[i] = 0;
  for(int j = 0; j < kernelCount; ++j) {
    y[i] += x[i – j] * h[j]; // statement 1
  }
}

再次检查代码,你可能会注意到我们正在遍历一维数组,最有趣的代码会在语句 1中,因为这里才是真正的动作所在。让我们把新的知识放一边,继续扩展到二维空间。

理解二维卷积

二维卷积实际上是之前描述的理解一维卷积部分的扩展,我们通过在两个维度上计算卷积来实现这一点。

准备工作

脉冲函数也存在于二维空间域中,所以让我们称这个函数为b[x,y],当 x 和 y 为零时,其值为 1,而当 x,y 不为零时,其值为零。当在图像处理中使用时,脉冲函数也被称为滤波器或核。

如何做…

以之前的例子为指南,让我们从信号的角度思考,该信号可以被分解为其组成部分和脉冲函数的总和,它们的双和解释了为什么这个操作在二维空间中的垂直和水平轴上运行。

如何做…

接下来,我认为如果我们用一个例子来说明当我们有两个卷积核来表示我们想在二维数组中的元素上应用的过滤器时,这会非常有帮助。让我们给他们起个名字,SxSy。接下来要做的是尝试在二维环境中方程会如何发展,其中我们想要卷积的元素位于x[1,1],然后我们记录其周围的八个元素,然后看看会发生什么。

如果你思考一下为什么我们选择周围的八个元素,这是我们能测度相对于其他每个元素变化大小的唯一方式。

如何做…如何做…

让我们试试看:

如何做…如何做…

这导致九个元素(包括我们感兴趣的元素)的总和,这个过程会重复应用于二维数组中的所有元素。以下图表说明了二维空间中二维卷积是如何工作的。

注意

你可能希望阅读 Irwin Sobel 于 1964 年的原始博士论文,因为他是发明者,而且这位作者有幸亲自见到这个人。

当你尝试在二维数组边缘的元素周围进行卷积或在图像处理中,它们被称为边缘像素吗?如果你使用这个公式进行计算,你会注意到结果将不准确,因为这些元素是未定义的,因此它们通常从最终计算中省略。一般来说,你可以想象一个 3 x 3 的滤波操作被应用于二维数组的每个元素,所有这些计算都将导致输出数据数组中该元素的新的值。

接下来,你可能想知道对这个输出数组做了什么?记住,这个数组现在包含值,这基本上显示了在特定元素中检测到的变化有多大。当你在一群附近获得这些值时,通常告诉你主要颜色变化,即边缘。

如何做…

它是如何工作的…

通过这种理解,你可能开始欣赏我们为什么努力说明概念背后的理论。

当你想为你的客户构建非平凡的 OpenCL 应用程序时,你必须处理的一件事是学习如何解释问题并将其转换为解决方案。这意味着主要是关于制定算法(或选择适合你情况的现有算法)并验证它是否有效。你可能会遇到的大部分问题都将涉及某种数学理解以及你学习它的能力。你应该把它当作一次冒险!

现在我们已经了解了二维空间中的卷积是什么,让我们首先看看在常规 C/C++代码中二维卷积是如何工作的,以下是一个代码片段:

// find centre position of kernel (assuming a 2D array of equal
// dimensions)
int centerX = kernelCols/2;
int centerY = kernelRows/2;
for(int i = 0; i < numRows2D; ++i) {
  for(int j = 0; j < numCols2D; ++j) {
    for(m = 0; m < kernelRows; ++m) {
          mm = kernelRows - 1 – m;
    for(n = 0; n < kernelCols; ++n) {
              nn = kernelCols - 1 – n;
        ii = i + (m – centerX);
        jj = j + (n – centerY);
        if (ii >= 0 && ii < rows && jj >= 0 && jj < numCols)
       out[i][j] += in[ii][jj] * kernel[mm][nn]; // statement 1
    }
    }
  }
}

这种实现可能是理解概念最直接的方法,尽管它可能不是最快的(因为它不是多核感知的)。但是它有效,因为从概念上讲有两个主要的循环,其中两个外部的for循环用于遍历整个 2D 数组空间,而两个内部的for循环用于遍历元素上的滤波器/内核,即进行卷积并将最终值存储到适当的输出数组中。

现在我们戴上并行算法开发者的帽子,我们发现语句 1似乎是一个很好的工作项执行目标。接下来,让我们看看我们如何利用所学知识在 OpenCL 中构建相同的程序。

Sobel 边缘滤波器的 OpenCL 实现

现在你已经了解了卷积的实际工作原理,你应该能够想象我们的算法可能的样子。简而言之,我们将读取一个输入图像,假设它将以 Windows BMP 格式存在。

准备工作

接下来,我们将构建必要的用于在 OpenCL 设备上传输此图像文件以进行卷积的数据结构,一旦完成,我们将读取并写入数据到另一个图像文件,这样我们就可以比较这两个文件。

注意

可选地,你可以选择使用 OpenCL 提供的 clCreateImage(...) API 来实现这一点,我们将把这个尝试留给读者作为练习。

在接下来的章节中,你将看到从翻译的内容中,我们学到了什么。这不会是最有效的算法,这真的不是我们的目的。相反,我们想展示你如何快速完成这项工作,我们将让你注入那些优化,包括但不限于以下数据分箱、数据分块、共享内存优化、warp / wavefront 级编程、使用快速傅里叶变换实现 2D 卷积,以及许多其他特性。

小贴士

我从阅读 AMD 和 NVIDIA 发布的学术论文以及访问 gpgpu.orgdeveloper.amd.comdeveloper.nvidia.comdeveloper.intel.com 中获得了许多关于解决卷积的最新技术。另一个我可以想到的好资源是你最喜欢的当地书店关于图像处理和计算机视觉的书籍。如果你喜欢,英特尔发布的关于处理器和内存结构的书籍也是很好的资源。

如何做…

我们只展示了位于 Ch6/sobelfilter/sobel_detector.cl 中的内核代码,因为我们的算法翻译将在这里达到顶峰。我们没有展示位于 Ch6/sobelfilter/SobelFilter.c 中的主机代码,因为我们相信你会有信心知道那里通常有什么:

__kernel void SobelDetector(__global uchar4* input, 
                            __global uchar4* output) {
      uint x = get_global_id(0);
      uint y = get_global_id(1);

  uint width = get_global_size(0);
  uint height = get_global_size(1);

  float4 Gx = (float4)(0);
  float4 Gy = (float4)(0);

    // Given that we know the (x,y) coordinates of the pixel we're 
    // looking at, its natural to use (x,y) to look at its
    // neighbouring pixels
    // Convince yourself that the indexing operation below is
    // doing exactly that

    // the variables i00 through to i22 seek to identify the pixels
    // following the naming convention in graphics programming e.g.   
    // OpenGL where i00 refers
    // to the top-left-hand corner and iterates through to the bottom
    // right-hand corner

  if( x >= 1 && x < (width-1) && y >= 1 && y < height - 1)
  {
    float4 i00 = convert_float4(input[(x - 1) + (y - 1) * width]);
    float4 i10 = convert_float4(input[x + (y - 1) * width]);
    float4 i20 = convert_float4(input[(x + 1) + (y - 1) * width]);
    float4 i01 = convert_float4(input[(x - 1) + y * width]);
    float4 i11 = convert_float4(input[x + y * width]);
    float4 i21 = convert_float4(input[(x + 1) + y * width]);
    float4 i02 = convert_float4(input[(x - 1) + (y + 1) * width]);
    float4 i12 = convert_float4(input[x + (y + 1) * width]);
    float4 i22 = convert_float4(input[(x + 1) + (y + 1) * width]);

        // To understand why the masks are applied this way, look
        // at the mask for Gy and Gx which are respectively equal 
        // to the matrices:
        // { {-1, 0, 1}, { {-1,-2,-1},
        //   {-2, 0, 2},   { 0, 0, 0},
        //   {-1, 0, 1}}   { 1, 2, 1}}

Gx = i00 + (float4)(2) * i10 + i20 - i02  - (float4)(2) * i12 -i22;
Gy = i00 - i20  + (float4)(2)*i01 - (float4)(2)*i21 + i02  -  i22;

        // The math operation here is applied to each element of
        // the unsigned char vector and the final result is applied 
        // back to the output image
  output[x + y *width] = convert_uchar4(hypot(Gx, Gy)/(float4)(2));
  }  
}

一个敏锐的读者通过阅读代码可能会发现,GxGy 的导出值应该是以下这样:

Gx = i00 + (float4)(2) * i10 + i20 - i02  - (float4)(2) * i12 - i22+ 0*i01+0*i11+0*i21;
Gy = i00 - i20  + (float4)(2)*i01 - (float4)(2)*i21 + i02 - i22+ 0*i10+0*i11+0*i12;

但因为我们知道它们的值将是零,所以我们不需要在它里面包含计算。尽管我们确实做了,但这实际上是一个小的优化。它减少了 GPU 处理周期!

如前所述,编译步骤与 Ch6/sobelfilter/SobelFilter.c 中的类似,以下命令:

gcc -std=c99 -Wall -DUNIX -g -DDEBUG -DAPPLE -arch i386 -o SobelFilter SobelFilter.c -framework OpenCL

要执行程序,只需在 Ch6/sobelfilter 目录下执行可执行文件(SobelFilter),就会呈现一个名为 OutputImage.bmp 的输出图像文件(它是读取 InputImage.bmp 并对其执行卷积过程的输出)。

最终的效果是输出包含了一个描绘原始输入图像边缘的图像,你甚至可以参考本食谱中 如何工作… 部分的图片来查看这两个图像之间的不同。

如何工作…

首先,我们创建一个像素表示来代表 RGBA 格式中的每个通道。这个结构被赋予了一个简单的名字,uchar4,它由四个无符号 char 数据类型组成,这将正确地表示每个颜色的范围从 [0..255] 或 [0x00..0xFF],因为这是每个颜色范围的传统定义方式。

我们省略了从输入图像中提取像素信息背后的机制描述,到我们如何构建图像在内存中的最终表示。感兴趣的读者可以在互联网上搜索有关 Windows BMP 格式的信息,以了解我们如何解析图像数据或通过load函数读取bmp.h文件中的源代码,我们使用write函数写入图像。

跳过 OpenCL 设备内存分配的描述,因为到目前为止这是标准操作,我们迅速到达了查看内核如何处理输入数据每个像素的部分。

在我们这样做之前,让我们快速回顾内核启动代码,看看分配了多少全局工作项,以及工作组组成是否如下:

clEnqueueNDRangeKernel(command, queue, 2, NULL, globalThreads, localThreads, 0, NULL, NULL);

localThreads被配置为具有大小为{256,1}的工作组,工作项处理输入 2D 图像数据数组的一部分。

当图像被加载到设备内存中时,图像以块的形式进行处理。如果处理图像,每个块包含一定数量的工作项或线程。每个工作项接着执行,对像素中心及其八个邻居执行卷积过程。每个工作项生成的结果值将被输出为像素值到设备内存中。直观地,以下图示说明了典型工作项将执行的操作。

提示

您需要注意,我们实际上使用了数据类型转换函数convert_float4来应用封装在每个像素中的无符号 char 数据值,这有效地扩展了数据类型,以便在应用 Sobel 算子时不会溢出。

最后,一旦我们获得了所需的掩码值,我们需要计算这个梯度的幅度,计算该幅度的标准方法是将如何工作…应用于其中Gx = 如何工作…Gy = 如何工作…

如何工作…

无论此算法是否有效,唯一的方法是通过图像进行检查。以下是对比图,其中第一幅图像是在应用 Sobel 算子之前,第二幅图像是在应用之后。

如何工作…如何工作…

然而,还有一个很好的优化,我们可以进行,如果我们理解一个 3x3 卷积核(例如,Sobel 算子)实际上等同于两个向量的乘积,这将有所帮助。这种认识是分离卷积优化算法背后的原理。

从技术上讲,如果一个二维滤波器可以表示为两个向量的外积,则被认为是可分离的。考虑到这里的 Sobel 算子,我们实际上可以写出如何工作…如何工作…

提示

上标 T 是行向量的转置,它等同于列向量,反之亦然。请注意,卷积本身是结合律的,所以你以何种方式将向量与输入图像矩阵相乘并不重要。

为什么这很重要?主要原因是我们实际上通过使用这个可分离的卷积核节省了处理周期。让我们想象我们有一个 X-by-Y 的图像和一个 M-by-N 的卷积核。使用原始方法,我们会进行 XYMN 次乘法和加法,而使用可分离的卷积技术,我们实际上会进行 XY (M + N) 次乘法和加法。从理论上讲,如果我们将这个方法应用到我们的 3-by-3 卷积核上,我们的性能将提高 50% 或 1.5 倍,当我们使用 9-by-9 的卷积核时,我们的性能将提高到 81 / 18 = 4.5 或 450%。

接下来,我们将讨论如何分析你的算法及其运行时间,以便你可以让你的算法不仅运行得更快,而且更深入地理解算法的工作原理,并且往往可以帮助开发者更好地利用 OpenCL 设备的功能。

理解 OpenCL 中的分析

从 OpenCL 开发者的角度来看,分析是一个相对简单的操作,因为它基本上意味着他/她希望测量特定操作花费了多长时间。这很重要,因为在任何软件开发过程中,系统的用户通常会指定被认为是可接受的延迟,随着你开发更大、更复杂的系统,分析应用程序对于帮助你理解应用程序的瓶颈变得很重要。我们将进行的是开发者通过编程方式进行的分析,以明确测量代码的各个部分。当然,还有另一类分析器,它会在更深层次上分析你的 OpenCL 操作,并对测量的运行时间进行各种细分,但这超出了本书的范围。但我们鼓励读者下载 AMD 和英特尔的分析器来检查它们。

注意

在编写这本书的过程中,AMD 已经发布了其 OpenCL 分析器和一款名为 CodeXL 的通用调试器,可以在 developer.amd.com/tools-and-sdks/heterogeneous-computing/codexl/ 找到。英特尔提供了一款类似的独立包,你可以通过以下网址获取更多详细信息:

software.intel.com/en-us/vcsource/tools/opencl-sdk-2013。至于 NVIDIA GPGPUs,你只能使用 OpenCL 提供的 API。

准备工作

OpenCL 允许开发者深入了解其运行时的两个操作是数据传输操作和内核执行操作;所有时间都是以纳秒为单位测量的。

注意

由于所有设备都无法解析到纳秒级别,因此确定分辨率级别非常重要,你可以通过将CL_DEVICE_PROFILING_TIMER_RESOLUTION标志传递给clGetDeviceInfo以获取适当的设备 ID 来了解这一点。

如何做到这一点…

你所需要做的就是将CL_QUEUE_PROFILING_ENABLE标志作为properties参数的一部分,在通过clCreateCommandQueue创建命令队列时传递。API 看起来是这样的:

cl_command_queue 
clCreateCommandQueue(cl_context context,
                     cl_device_id device,
                     cl_command_queue_properties properties, cl_int* error_ret);

一旦启用分析,接下来你需要做的是将 OpenCL 事件注入到代码的特定区域,你想要了解运行时间如何。为了实现这一点,你需要为你要监控的代码区域创建一个cl_event变量,并将此变量与以下 API 之一关联:

  • 数据传输操作:

    • clEnqueue{Read|Write|Map}Buffer

    • clEnqueue{Read|Write|Map}BufferRect

    • clEnqueue{Read|Write|Map}Image

    • clEnqueueUnmapMemObject

    • clEnqueuCopyBuffer

    • clEnqueueCopyBufferRect

    • clEnqueueCopyImage

    • clEnqueueCopyImageToBuffer

    • clEnqueueCopyBufferToImage

  • 内核操作:

    • clEnqueueNDRangeKernel

    • clEnqueueTask

    • clEnqueueNativeTask

它是如何工作的…

获取这些操作的运行时间的方法是调用clGetEventProfilingInfo API,传递以下标志之一:CL_PROFILING_COMMAND_QUEUEDCL_PROFILING_COMMAND_SUBMITCL_PROFILING_COMMAND_STARTCL_PROFILING_COMMAND_END。API 看起来是这样的:

cl_int
clGetEventProfilingInfo(cl_event event,
                        cl_profiling_info param_name,               
                        size_t param_value_size, 
                        void* param_value,
                        size_t* param_value_size_ret);

要获取命令在队列中花费的时间,你只需一次调用clGetEventProfilingInfo使用CL_PROFILING_COMMAND_SUBMIT,然后在代码区域的末尾再次调用clGetEventProfilingInfo使用CL_PROFILING_COMMAND_QUEUED以获取时间差。

要获取命令执行所花费的时间,一次调用clGetEventProfilingInfo使用CL_PROFILING_COMMAND_START,然后使用相同的 API 调用CL_PROFILING_COMMAND_END,从运行时间的差异中你会得到值。

以下是一个小代码片段,说明了基本机制:

cl_event readEvt;
cl_ulong startTime;
cl_ulong endTime;
cl_ulong timeToRead;
cl_command_queue queue = clCreateCommandQueue(context, device, CL_QUEUE_PROFILING_ENABLE, NULL);
clEnqueueReadBuffer(queue, some_buffer, TRUE, 0, sizeof(data), data,0, NULL, &readEvt);
clGetEventProfilingInfo(readEvt, CL_PROFILING_COMMAND_START,sizeof(startTime),&startTime, NULL);
clGetEventProfilingInfo(readEvt, CL_PROFILING_COMMAND_END,sizeof(endTime),&endTime, NULL);
timeToRead = endTime – startTim;

第七章. 使用 OpenCL 开发矩阵乘法

在本章中,我们将涵盖以下内容:

  • 理解矩阵乘法

  • 矩阵乘法的 OpenCL 实现

  • 通过线程粗化加速矩阵乘法的 OpenCL 实现

  • 通过寄存器细分加速矩阵乘法的 OpenCL 实现

  • 通过矩阵乘法中的共享内存数据预取减少全局内存

简介

在本章中,我们将探讨两个矩阵相乘以产生另一个矩阵的问题。这个问题也被称为矩阵乘法,其应用范围包括数学、金融、物理,并且是解决线性方程的流行系统。为了说明目的,我们提供了一个解决线性方程的典型用例:

介绍

这些方程可以建模为介绍,其中方程的左侧由一个 2x2 的矩阵组成,该矩阵乘以一个 2x1 的矩阵(通常称为向量,它们可以是行向量或列向量),等于右侧的向量。考虑到矩阵可以具有任意行和列的顺序,数学家发明了如下表示法,介绍,为了解这个问题,我们必须确定介绍。在这里,正如我们所看到的,需要知道矩阵的逆。到此为止,这就是我们关于矩阵美妙世界的所有想说的,以免我们陷入兔子洞!

注意

你应该知道,只有方阵才有逆,即使在这样的矩阵中,逆也不一定存在。我们不会在本章或本书中介绍计算逆。

理解矩阵乘法

两个矩阵 A 和 B 的乘积 C 定义为理解矩阵乘法,其中 j 是所有可能的 i 和 k 值的和。这里隐含地对索引 i、j 和 k 进行了求和。矩阵 C 的维度为:理解矩阵乘法,其中理解矩阵乘法表示一个有理解矩阵乘法行和理解矩阵乘法列的矩阵,当我们明确写出乘积时,它看起来如下:

理解矩阵乘法理解矩阵乘法理解矩阵乘法理解矩阵乘法理解矩阵乘法理解矩阵乘法

矩阵乘法的另一个特性是乘法在加法上是结合的,并且对加法是分配的,但它们却不是交换的。

注意

如果两个矩阵 A 和 B 是对角矩阵并且维度相同,则认为它们是交换的。

了解这些属性将帮助我们制定从以下公式开始的初始算法:c[ik] = a[ij]b[jk]*。交换律基本上告诉我们矩阵 A 和 B 之间乘法的顺序很重要,而结合律允许我们探索当两个矩阵 A 和 B 太大而无法适合 OpenCL 设备上的可用内存,并且我们需要将矩阵数据分配到多个设备时会发生什么。以下图表说明了当读取矩阵 A 的行和矩阵 B 的列并将其聚合结果写入输出矩阵 C 的适当位置时会发生什么:

理解矩阵乘法

准备工作

到目前为止,我们已经有很好的基础来尝试矩阵乘法。像以前一样,我们从 C/C++ 的实现开始,这是公式的直接翻译,然后我们将发展更好的直觉,了解如何将其导入 OpenCL 并应用适当的优化。

在本章的剩余部分,我们将构建我们的算法,使其在您的桌面/笔记本电脑上的 GPU 上运行。这样做的原因是因为 GPU 拥有比 CPU 更多的计算单元,并且 GPU 通常配备有其他硬件组件,这允许 OpenCL 利用这些硬件(包括本地数据存储、乱序执行单元、共享数据存储等),这通常允许执行大量的线程。当前的 CPU 处理器不实现 OpenCL 共享内存,因此使用 GPU 可能是最佳选择!

小贴士

获取一个支持 OpenCL 1.1 的 GPU,上述信息足以进行这些实验。

如何做到这一点...

到现在为止,您应该熟悉创建必要的数据结构来表示我们正在讨论的三个矩阵(让我们称它们为 A、B 和 C)。巧合的是,它们恰好是方阵,但这以任何方式都不会影响我们的理解。

当我们从上一节检查这个问题时,我们理解我们基本上想要以下方式遍历两个矩阵:

  1. 从矩阵 A 中选择一行。

  2. 从矩阵 B 中选择一列。

  3. 将所选行的每个元素与所选列的对应元素相乘。

从这个描述中,我们可以开始考虑各种实现方法,其中一种方法可能是以下:

  1. 为 A 和 B 创建两个内存中的数据结构,例如 TmpATmpB

  2. 遍历 A 并选择一个行,将其每个元素存入 TmpA 的对应位置,对所选列做同样的操作,存入 TmpB

      loop until i < number_of_rowsA:
        TmpA[i] = A[i]
      endloop
      loop until i < number_of_colsB:
        TmpB[i] = B[i]
      endloop
    
  3. 遍历 TmpATmpB 并执行矩阵乘法。

  4. 在伪代码中,它看起来像这样:

    loop until (i,j) < (rowA * colB):
      loop through A[i][_] deposit values into TmpA
      loop through B[_][j] deposit values into TmpB
      foreach value in TmpA and TmpB:
        C[a] = TmpA[x] * TmpB[y]
    endloop
    

另一种实现方式与这一种非常相似,只是我们使用标准的 C/C++ 数组索引技术来引用相应的行和列,并在以下章节中展示实现方式。

它是如何工作的…

正如我们之前讨论的那样,在 C/C++ 中实现矩阵乘法算法有多种方式。似乎没有一种最佳的设计方案。我个人一直更喜欢可读的设计而不是复杂的设计。然而,有时有必要编写高性能的代码,以便你可以榨取编程语言或硬件所能提供的全部力量。

小贴士

到目前为止,你可能已经或还没有发展出设计算法所必需的直觉,但一种方法是持续不断地练习使用不同的技术,并使用一些基准测试来衡量每个实现,除非你非常有信心,否则不要将所有优化都集中在一种算法上。

既然我们已经对矩阵乘法的含义有了初步的了解,那么我们现在开始探索算法被转换为顺序形式后的样子是时候了。以下是一个矩阵乘法程序顺序形式的示例(代码仅由一个线程执行):

Void matrixMul(float *C, 
               const float *A, 
               const float *B, 
               unsigned int hA, 
               unsigned int wA, 
               unsigned int wB) {
    for (unsigned int i = 0; i < hA; ++i)
        for (unsigned int j = 0; j < wB; ++j){   
            float sum = 0;
            for (unsigned int k = 0; k < wA; ++k) {   
                double a = A[i * wA + k]; // statement 1
                double b = B[k * wB + j]; // statement 2
                sum += a * b;
            }   

            C[i * wB + j] = (float)sum; // statement 3
        }   
}

当你检查这段代码时,你会注意到有三个循环结构,我们使用常规的 C/C++ 数组索引技术来引用从各自的行和列中引用的后续元素。现在花点时间来确信我们实际上是在计算矩阵乘法。

如前所述,我们戴上并行开发者的帽子,试图看看我们如何提供等效程序的并行 OpenCL 版本。再次,我自然地被循环结构所吸引,我们这里有三个循环结构!

我们注意到,当我们遍历矩阵 A 和 B 时,最内层的循环是执行所有重负载的代码块,包括“语句 1”、“语句 2”和“语句 3”。这些语句将代表我们 OpenCL 内核的核心,让我们去看看我们如何将其映射到 OpenCL。

矩阵乘法的 OpenCL 实现

我们已经花费了大量时间来理解矩阵乘法的工作原理,并研究了它在顺序形式下的样子。现在,我们将尝试以最直接的方式将其映射到 OpenCL。

这里使用的实现技术利用了这样一个事实:我们创建了二维线程块,其中每个维度中的每个线程/工作项都将访问它们在行/列维度中的相应元素。

准备中

在这个菜谱中,我们将使用两个维度为 1024 x 1024 的矩阵(我们称之为 A 和 B),并将这两个矩阵相乘,以产生一个 1024 x 1024 的第三个矩阵,我们称之为 C。

注意

在这个阶段,你可能需要刷新一下你的基本矩阵理论,以确信这是正确的。

我们在主机代码中构建熟悉的数据结构,并用随机值填充它们。Ch7/matrix_multiplication_01/MatrixMultiplication.c 中的主机代码如下:

matrixA = (cl_int*)malloc(widthA * heightA * sizeof(cl_int));
matrixB = (cl_int*)malloc(widthB * heightB * sizeof(cl_int));
matrixC = (cl_int*)malloc(widthB * heightA * sizeof(cl_int));

memset(matrixA, 0, widthA * heightA * sizeof(cl_int));
memset(matrixB, 0, widthB * heightB * sizeof(cl_int));
memset(matrixC, 0, widthB * heightA * sizeof(cl_int));

fillRandom(matrixA, widthA, heightA, 643);
fillRandom(matrixB, widthB, heightB, 991);

接下来,我们设置 OpenCL 命令队列以启用分析,因为我们想继续观察我们将要应用的后续优化的效果。确实,建立一个参考点是至关重要的,这样你的测量结果就可以与之比较。

注意

回想一下,OpenCL 命令队列可以被创建为按顺序执行命令。在这本书中,所有命令队列都是按顺序创建的,以便它们按程序顺序执行,也称为程序读取顺序。

如何做到这一点…

我们展示了我们的第一次尝试,为你提供一个顺序矩阵乘法算法的 OpenCL 版本。内核可以在 Ch7/matrix_multiplication_01/simple_mm_mult.cl 中找到:

__kernel void mmmult(int widthB, 
                     int heightA, 
                      __global int* A, 
                      __global int* B, 
                      __global int* C) {

    int i = get_global_id(0);
    int j = get_global_id(1);
    int tmp = 0;

    if ((i < heightA) && (j < widthB)) {
        tmp = 0;
        for(int k = 0; k < widthB; ++k) {
            tmp += A[i*heightA + k] * B[k*widthB + j];
        }
        C[i*heightA + j] = tmp;
    }
}

给定前面的 OpenCL 内核代码,我们需要构建一个可执行文件,以便它可以在你的平台上执行。和之前一样,编译过程对你来说应该是熟悉的。在我的配置中,使用 Intel Core i7 CPU 和 AMD HD6870x2 GPU 运行 Ubuntu 12.04 LTS,编译过程如下,并且它会在目录中创建一个名为 MatrixMultiplication 的可执行文件:

gcc -std=c99 -Wall -DUNIX -g -DDEBUG -arch i386 -o MatrixMultiplication -framework OpenCL

到目前为止,你应该在那个目录中有一个可执行文件,你现在需要做的就是运行程序,只需在目录中简单地执行 MatrixMultiplication 程序,你应该已经注意到以下输出:

Passed!
Execution of matrix-matrix multiplication took X.Xs

它是如何工作的…

我们讨论了矩阵的初始化方式,接下来要实现的是每个维度中每个工作项对每个元素进行工作的执行模型。为了完成这个任务,我们必须确保执行 OpenCL 内核代码的调用不指定线程块的大小:

size_t globalThreads[] = {widthB, heightA};

cl_event exeEvt; 
cl_ulong executionStart, executionEnd;
error = clEnqueueNDRangeKernel(queue,
                               kernel,
                               2,
                               NULL,
                               globalThreads,
                               NULL, 
                               0,
                               NULL,
                               &exeEvt);
clWaitForEvents(1, &exeEvt);

我们通过将 NULL 值传递给 clEnqueueNDRangeKernel API 中用于指定工作组大小的占位符来实现这一点。接下来,我们将全局工作项的值设置为矩阵 B 的宽度和矩阵 A 的高度,分别由 widthBheightA 变量表示。

以下图表用于说明执行将看起来是什么样子:

它是如何工作的…

一个敏锐的读者可能会开始猜测这不是进行这项业务的最佳方式,你是对的!我们很快将深入探讨如何使这项工作做得更好。

通过线程粗化加速矩阵乘法的 OpenCL 实现

在本节中,让我们尝试通过应用并行编程中的技术:线程粗化,来让这个“野兽”运行得更快。这很重要,因为当你有一个工作项访问一个元素,然后你有大矩阵时,你可能会拥有数百万个工作项在运行!一般来说,这不是一个好现象,因为今天许多设备都无法支持在 n 维度上数百万个工作项,除非它是超级计算机。但通常有巧妙的方法来减少所需的工作项数量。

准备工作

这里的通用技术是探索我们可以合并线程的方法,以便每个线程现在计算多个元素。当我们重新审视前面的代码时,我们可能会想知道我们是否可以用更少的线程并让它们计算更多的元素,实际上我们可以。

我们采用的战略基本上将有一个工作项在遍历矩阵 A 和 B 的同时更新矩阵 C 中的整个行。此时,我们甚至不需要探索在 OpenCL 中使用原子函数,因为这是我们应尽可能延迟探索的方面。不探索使用原子的主要原因很简单,就是它们的执行时间太长,而且还没有充分利用 OpenCL 设备的能力。

如何做到这一点…

这个 OpenCL 内核是基于线程粗化的概念修订的,可以在 Ch7/matrix_multiplication_02/mmult.cl 中找到:

__kernel void mmmult(int widthB, 
                     int heightA, 
                      __global int* A,  
                      __global int* B,  
                      __global int* C) {

    int i = get_global_id(0); 
    int tmp = 0;

    if (i < heightA) {
        for(int j = 0; j < widthB; ++j) {
            tmp = 0;
            for(int k = 0; k < widthB; ++k) {
                tmp += A[i*heightA + k] * B[k*widthB + j]; 
            }   
            C[i*heightA + j] = tmp;
        }   
    }   
}

现在我们已经仔细查看过 OpenCL 内核,我们需要构建一个可执行形式。和之前一样,编译过程对你来说应该很熟悉。在我的配置中,有一个 Intel Core i7 CPU 和 AMD HD6870x2 GPU 运行 Ubuntu 12.04 LTS,编译过程如下,它会在目录中创建一个名为 MatrixMultiplication 的可执行文件:

gcc -std=c99 -Wall -DUNIX -g -DDEBUG -arch i386 -o MatrixMultiplication -framework OpenCL

到目前为止,可执行文件应该已经存放在目录中,要执行它,只需在目录中运行程序 MatrixMultiplication 即可,你应该已经注意到以下输出:

Passed!
Execution of matrix-matrix multiplication took X.Xs

现在如果你要比较之前的结果,你会注意到它运行得更快!

它是如何工作的…

这里的难点在于能够识别出正在应用冗余工作。但在我们的情况下,识别出我们实际上使用了过多的线程并不会花费太多精力。你可能会问,为什么会这样?线索在于原始的矩阵乘法算法是使用一个执行线程运行的,所以我们使用多个工作项的事实确实意味着我们还可以做更多来改进它。

因此,当我们回顾算法时,我们发现了一种通过更富有创意地使用一个工作项获取这些值来使它们运行更快的方法。在这个时候,你应该确信我们刚才查看的 OpenCL 内核确实如预期那样引用了矩阵 A 和 B 中的数据值。

为了实现我们所做的,我们对 Ch7/matrix_multiplication_02/MatrixMultiplication.c 中的代码做了一些修改,如下所示:

size_t globalThreads[] = {heightA};
size_t localThreads[] = {256};
cl_event exeEvt; 
cl_ulong executionStart, executionEnd;
error = clEnqueueNDRangeKernel(queue,                                                                               
                               kernel,
                               1,  
                               NULL,
                               globalThreads,
                               localThreads,
                               0,  
                               NULL,
                               &exeEvt);
clWaitForEvents(1, &exeEvt);

我们知道问题的大小,即对 1024 x 1024 维度的矩阵进行矩阵乘法。我选择工作组大小为 256 的原因是因为我的 GPU 有四个计算单元,你可以通过传递CL_DEVICE_MAX_COMPUTE_UNITSclGetDeviceInfo来发现这一点。以下图表说明了线程粗化后的情况:

它如何工作…

当你能够通过线程粗化减少冗余工作时,内核现在将执行得更快,并且扩展得更好,因为现在更多的处理器可以执行。这看起来可能有些反直觉,因为它违背了常识,因为执行内核的线程越多,它应该执行得越快。好吧,这就是简单的画面。

在底层发生的事情更为复杂,它始于这样一个事实:每个 GPU 都有一定数量的处理器,每个处理器都会执行内核。为了使 GPU 能够以全容量运行,自然其处理器必须填充数据缓存中的数据,指令应该准备好被触发并执行 OpenCL 内核。

然而,由于数据空间和时间局部性较差,数据缓存的表现不佳,这导致指令流水线中的停滞,这转化为延迟执行。另一个问题也与内存访问模式可能是不规则或非归约的事实有关,这转化为缓存未命中和可能的内存驱逐。这最终导致更多的延迟。

回到问题本身,还有另一种优化内核的方法,那就是通过重用工作项的硬件寄存器。

通过注册分块加快矩阵乘法的 OpenCL 实现

注册分块是我们可以对矩阵乘法算法应用的其他技术之一。它基本上意味着探索重用硬件寄存器的机会。在我们的情况下,这意味着我们需要检查内核代码并找到重用寄存器的机会。

现在,我们需要戴上我们硬核 C 开发者帽(这个人需要从处理器核心的层面思考,比如数据如何在总线上移动,内存的加载和存储等等)。一旦你的思维足够敏感到这个层面,事情就会变得更好。

回想一下上一节的内核代码,我们会注意到经过仔细审查后,A[i * heightA + k]语句总是在循环结构中执行,这导致大量的内存流量,因为数据需要从设备内存加载到设备寄存器中。

准备工作

为了减少由A[i * heightA + k]语句引起的全局内存流量,我们可以将这个语句从循环结构中提取出来,创建一个仅对执行线程可见的线程局部内存结构,然后我们可以在后续的计算中重用预取的数据。

如何做到这一点

这个 OpenCL 内核代码位于Ch7/matrix_multiplication_03/mmult.cl

__kernel void mmmult(int, 
                     int widthB heightA, 
                      __global int* A,                      __global int* B, 
                      __global int* C) {

    int i = get_global_id(0); 

    int tmp = 0;

    int tmpData[1024];

    if (i < heightA) {
        for(int k = 0; k < widthB; ++k )
            tmpData[k] = A[i*heightA + k];

        for(int j = 0; j < widthB; ++j) {
            tmp = 0;
            for(int k = 0; k < widthB; ++k) {
                tmp += tmpData[k] * B[k*widthB + j];
            }
            C[i*heightA + j] = tmp;
        }
    }
}

现在我们已经仔细查看过 OpenCL 内核,我们需要构建一个可执行形式,以便我们可以执行。和之前一样,编译过程对你来说应该很熟悉。在我的配置中,使用 Intel Core i7 CPU 和 AMD HD6870x2 GPU 运行 Ubuntu 12.04 LTS,编译过程如下,并且它会在目录中创建一个名为MatrixMultiplication的可执行文件:

gcc -std=c99 -Wall -DUNIX -g -DDEBUG -arch i386 -o MatrixMultiplication -framework OpenCL

到目前为止,可执行文件应该已经在你所在的目录中可用。要运行程序,只需在MatrixMultiplication目录中执行程序,你应该会看到以下输出:

Passed!
Execution of matrix-matrix multiplication took X.Xs

现在如果你将结果与之前的一个比较,你会注意到它运行得更快。

它是如何工作的……

这个想法源自高性能计算中的一种技术,有些人喜欢称之为标量替换。这是我们在这个部分应用的形式。让我们花点时间通过一个简单的算法来理解这一点。

假设我们有一个以下算法:

for i1 = 1 to 6
  for i2 = 1 to 6
    A[i1,i2] = A[i1 – 1, i2] + A[i1,i2 -2]

现在我们展开循环,使其看起来像这样:

for i1 = 1 to 6 step-by-2
  for i2 = 1 to 6 step-by-2
    A[i1,i2] = A[i1 –1, i2] + A[i1,i2 -2]    //statement 1
    A[i1 +1,i2] = A[i1,i2] + A[i1+1,i2 -1]    //statement 2
    A[i1,i2 +1] = A[i1 –1, i2+1] + A[i1,i2]   //statement 3
    A[i1+1,i2+1] = A[i1, i2 +1] + A[i1+1,i2]

当我们仔细观察这段代码时,我们会注意到statement 1statement 2statement 3有一些共同点,那就是这段代码,A[i1,i2]。用计算机科学术语来说,我们注意到有一个存储到内存的操作和两个从内存到寄存器的加载操作。在标量替换中,我们将A[i1,i2]替换为一个变量,暂时称之为X。标量替换后,代码现在看起来如下:

for i1 = 1 to 6 step-by-2
  X = A[i1,0]
  for i2 = 1 to 6 step-by-2
    X          = A[i1 –1, i2] + X
    A[i1 +1,i2] = X + A[i1+1,i2 -1]    
    A[i1,i2 +1] = A[i1 –1, i2+1] + X   
    A[i1+1,i2+1] = A[i1, i2 +1] + A[i1+1,i2]
     A[i1,i2] = X 

当替换工作一致完成,并且算法仍然按预期工作,我们现在就可以了。喝杯茶吧!

让我们看看我们做了什么。我们将数组引用(实际上就是内存引用)替换为标量,这样做的好处是我们实际上通过在寄存器内存中处理这些项目来减少了内存流量。考虑到内存速度比寄存器读写速度慢得多,这个改进的算法形式要好得多。

小贴士

循环展开通常用于展开循环,以便我们可以识别可能重复的表达式或语句,并允许标量替换将这些表达式/语句提取到线程私有寄存器内存中。

实际操作中,标量替换要复杂得多,但这里的演示旨在说明一般概念。

我们还想与你分享的另一件事是优化工作项的内存使用,我们之前在章节中也提到了几个关于它的例子。

通过矩阵乘法中的共享内存数据预取减少全局内存

我们改进的矩阵乘法算法看起来相当不错,但还不是完全如此。该算法仍然在全局内存中对矩阵 B 进行了大量引用,我们实际上可以通过预取数据来减少这种流量。你可能没有注意到,但预取的概念是为了保持缓存“热”(一个从 CPU 借来的想法)。CPU 通常具有相当大的数据和指令缓存(实际上是非常大的硬件寄存器),这样处理器就可以利用数据的时空局部性。这个概念如何映射到其他 OpenCL 设备,例如 GPU?

每个符合 OpenCL 规范的 GPU 都有一小部分内存用于此目的,它们的尺寸通常是 32 KB 到 64 KB。如果你想要确定可用的高速内存的确切数量,只需将CL_DEVICE_LOCAL_MEM_SIZE变量传递给clGetDeviceInfo即可获取设备信息。

准备工作

为了让我们能够减少对全局内存的引用,我们需要对我们的代码进行修改,以便加载我们所需的数据。再次筛选代码后,我们发现确实有一个这样的机会,如下所示:

for(int j = 0; j < widthB; ++j) {
    tmp = 0;
    for(int k = 0; k < widthB; ++k) {
        tmp += tmpData[k] * B[k*widthB + j];
    }
//more code omitted
}

专注于这个循环,我们注意到矩阵 B 总是被加载,并且其值总是被执行此内核的所有工作项重用。我们当然可以将这些数据预加载到共享内存中。这应该会显著减少全局内存请求。

如何做…

以下 OpenCL 内核可以在Ch7/matrix_multiplicatione_04/mmult.cl中找到:

__kernel void mmmult(int widthB, 
                     int heightA, 
                      __global int* A,  
                      __global int* B,  
                      __global int* C,
                      __local  int* shared) {

    int i = get_global_id(0);
    int id = get_local_id(0);
    int size = get_local_size(0);
    int tmp = 0;

    int tmpData[1024];

    if (i < heightA) {
        /*
         Pre-load the data into the work-item's register memory that is 
         Visible to the work-item only. 
         */
        for(int k = 0; k < widthB; ++k ) {
            tmpData[k] = A[i*heightA + k]; 
        }   

        /*
         Data pre-fetching into shared memory allows all work-items
         To read the data off it instead of loading the data from global
         Memory for every work-item
        */
        for(int k = id; k < widthB; k += size) 
            shared[k] = B[k*widthB +k];
        barrier(CLK_LOCAL_MEM_FENCE);

        for(int j = 0; j < widthB; ++j) {
            tmp = 0;
            for(int k = 0; k < widthB; ++k) {
                tmp += tmpData[k] * shared[k];
            }
            C[i*heightA + j] = tmp;
        }
    }
}

现在你已经查看过 OpenCL 内核,你可能会想编译代码并运行它。和之前一样,编译过程对你来说应该是熟悉的。在我的配置中,有一个 Intel Core i7 CPU 和 AMD HD6870x2 GPU,运行 Ubuntu 12.04 LTS,编译过程如下,它会在目录中创建一个名为MatrixMultiplication的可执行文件。

gcc -std=c99 -Wall -DUNIX -g -DDEBUG -arch i386 -o MatrixMultiplication -framework OpenCL

要运行程序,只需在目录中执行MatrixMultiplication程序,你应该会得到一个类似于以下输出的结果:

Passed!
Execution of matrix-matrix multiplication took X.Xs

现在如果你要比较这个结果与之前的结果,你会注意到它运行得快得多!

如何工作…

我们所介绍的代码可能会让你产生一些疑问,因为它看起来是顺序执行的,但实际上在运行时是并行执行的。并行性是通过localThreads变量中指示的值引入的,该值传递给clEnqueueNDRangeKernel。我们在代码中放置的内存屏障的作用是停止所有工作项执行超过该点,直到该点之前的所有函数都已执行,以下图表用于说明这一点:

如何工作…

到目前为止,你已经看到了对 OpenCL 内核代码所做的更改,现在我们需要修改我们的主机代码,以便我们实际上能够完成这项工作。以下代码片段取自Ch7/matrix_multiplication_04/MatrixMultiplication.c

clSetKernelArg(kernel, 0, sizeof(cl_int),(void*)&widthB);
clSetKernelArg(kernel, 1, sizeof(cl_int),(void*)&heightA);
clSetKernelArg(kernel, 2, sizeof(cl_mem),(void*)&matrixAMemObj);
clSetKernelArg(kernel, 3, sizeof(cl_mem),(void*)&matrixBMemObj);
clSetKernelArg(kernel, 4, sizeof(cl_mem),(void*)&matrixCMemObj);
clSetKernelArg(kernel, 5, sizeof(cl_int)*heightA,NULL);

size_t globalThreads[] = {heightA};
size_t localThreads[] = {256};
cl_event exeEvt; 
cl_ulong executionStart, executionEnd;
error = clEnqueueNDRangeKernel(queue,
                               kernel,
                               1,
                               NULL,
                               globalThreads,
                               localThreads,
                               0,
                               NULL,
                               &exeEvt);
clWaitForEvents(1, &exeEvt);

最终算法的框图让我们对算法进行了调整,使其达到一个初始的合理性能,并且可以用以下图表进行概念上的表示:

如何工作…

小贴士

如果你想知道你可以创建多少共享内存,并将CL_DEVICE_LOCAL_MEM_SIZE参数传递给clGetDeviceInfo以获取你的设备,返回的值将以字节为单位。典型值在 32 KB 到 64 KB 之间。

第八章:开发 OpenCL 中的稀疏矩阵向量乘法

在本章中,我们将介绍以下内容:

  • 使用共轭梯度法解决 SpMV稀疏矩阵向量乘法

  • 理解包括 ELLPACK、ELLPACK-R、COO 和 CSR 在内的各种 SpMV 数据存储格式

  • 理解如何使用 ELLPACK-R 格式解决 SpMV

  • 理解如何使用 CSR 格式解决 SpMV

  • 理解如何使用 VexCL 解决 SpMV

简介

在上一章关于矩阵乘法的章节中,我们欣赏了问题空间及其应用领域,但我们之前没有告诉你的是,除了它们的密集和稀疏向量之外,还有密集矩阵和稀疏矩阵。当我们说密集或稀疏矩阵/向量时,我们指的是有很多非零或零值。

从计算角度来看,矩阵是密集的还是稀疏的很重要,因为将任何值与零相乘实际上是没有意义的,结果显然是零;如果你要应用解决这个问题的天真方法,即使用你在矩阵乘法中开发的方法来解决矩阵或向量稀疏的问题,但你不会利用你刚刚购买的全新 OpenCL CPU/GPU,你只是在浪费处理器周期,也在浪费大量的带宽。问题在于以高效的方式解决这个问题,这需要理解如何高效地计算,这解决了问题的一部分。问题的另一部分是要研究如何高效地存储稀疏矩阵,因为为存储主要由零填充的矩阵分配内存空间是浪费内存空间。

我们将快速浏览这个主题,但不会详尽无遗。关于这个主题已经有很多文献发表了。然而,我们将花一些时间通过认识到过去和当前的大部分工作都集中在创建既高效又紧凑的数据结构来表示稀疏结构来形成一个基本和一般的概念。我们还将花一些时间设计那些数据结构上的高效计算方法。至于矩阵而言,我们不会探讨动态矩阵(通过插入或删除)的可能性,而是将重点放在静态稀疏矩阵格式上。

接下来,我们将通过构建我们的知识到共轭梯度(通过最速下降和格拉姆-施密特正交化)来展示解决 SpMV 高效的理论,在应用该算法之前,我们将探讨一些常见的数据存储方案。我们将使用 VexCL 和 共轭梯度CG)方法,这是一个使用 C++ 构建的 OpenCL 框架,来展示一个实现。

以下是一些稀疏矩阵的例子:

介绍

使用共轭梯度法解决稀疏矩阵向量乘法(SpMV)

共轭梯度法是解决稀疏线性系统最流行的迭代方法,我将尝试让您理解它是如何工作的。在这个过程中,我们将探讨最速下降、共轭梯度收敛等问题。

小贴士

我想对 Jonathan Richard Shewchuk(加州大学助理教授)表示衷心的感谢,没有他,我可能无法理解为什么共轭梯度很重要。您可以在 www.cs.cmu.edu/~jrs/ 了解更多关于他的信息。

共轭梯度法在解决稀疏系统问题中受欢迎的原因之一是它不仅很好地处理了非常大的稀疏矩阵,而且效率也非常高。

在上一章关于矩阵乘法的章节中,我们已经看到了两个矩阵相乘的含义,这次我们将关注的问题是如何 使用共轭梯度法解决稀疏矩阵向量乘法,其中 A 是已知的正定方阵,x 是未知向量,b 是已知向量。

准备就绪

两个向量的内积表示为 x^Ty,它代表标量之和 准备就绪xTy 等价于 yTx,如果 xy 正交(彼此成直角,这在研究最速下降时将非常重要),那么 xTy = 0

小贴士

正定矩阵 A 满足对于每个非零向量 xxTAx > 0

二次型实际上是一个向量的标量和二次函数,其形式如下:

准备就绪

正如任何线性函数一样,我们会知道它的梯度,可以用以下导出形式表示 准备就绪(是的,这不是打字错误,我们指的是矩阵 A 的转置),当我们知道矩阵 A 是对称的,即 准备就绪 变为 A 因为 AT=A,那么这个方程就简化为 准备就绪。像任何线性方程的导数一样,我们知道当它等于 0 时,可以通过求解 准备就绪 来找到 准备就绪 的数学解。目标是找到一个特定的 x 值,使其最小化 准备就绪。从图解上看,可以想象成一个像下面图中那样的抛物线,这就是 准备就绪 评估出的确切值:

准备就绪

这为我们研究最速下降及其相关方法——共轭梯度法奠定了基础。在接下来的章节中,让我们首先探讨最速下降背后的概念,然后转向共轭梯度法。

在最速下降法中,我们从任意点x[(0)]开始,滑向抛物面的底部。我们继续采取步骤x(1)x(2),等等,直到我们相当有信心地说我们已经到达了解x。基本上就是这样工作的。一般来说,我们还没有说如何选择下一个滑动到的点,因为像往常一样,魔鬼在细节中。继续前进!

当我们迈出一步时,我们选择准备中减少最快的方向,现在适当地介绍两个向量,我们将使用这两个向量来判断我们是否朝着正确的方向下降(也就是说,如果我们是朝着抛物线的底部移动)。误差向量准备中衡量我们从当前步骤的解有多远。残差向量准备中衡量我们离b的正确值有多远,这个向量可以被认为是下降最快的方向。当我们迈出下一步以便我们更接近实际解x时,我们实际上是在选择一个点准备中,你会注意到另一个变量已经被选择,那就是 alpha,准备中

这个变量准备中无论其值如何,都会告诉我们我们是否到达了抛物线的底部。换一种说法,想象你自己掉进一个沙拉碗(我能想到的最接近的东西)中,你唯一停止下落的方法就是坐在碗底。我们知道从微积分中,你落点准备中的导数是零,也就是说,它的梯度也是0。为了确定这个值,我们必须将这个点的导数设为零,我们已经看到了方程准备中,现在我们知道准备中

如何做...

现在我们来计算当如何做...等于零时的方向导数,因为如何做...最小化f。使用链式法则,我们知道如何做...,并将我们知道的如何做...代入,我们得到以下推导序列,通过这些推导我们得到如何做...的值:

如何做...

总结来说,最速下降法包括以下方程:

如何做...

使用最速下降法意味着我沿着兔子洞走下去,在迈出下一步之前,我会猜测它将会是什么,然后采取行动;如果我猜对了,那就太好了!

共轭梯度法建立在最速下降法的基础上,两者有很多相似之处,即共轭梯度法会做出猜测,最终将导致在x中的解。两种方法都使用残差向量来判断猜测与正确答案的距离。

策略是选择一组正交搜索方向,在每个方向上我们正好走一步(这与我们之前看到的大致相同)如何做...。结果是我们需要使搜索方向A-正交而不是正交。我们说两个向量如何做...如何做...是 A-正交的,如果如何做...。当我们使用搜索方向时,我们想要最小化的是搜索空间的大小,为此我们需要线性无关的向量如何做...。从那里,我们可以使用 Gram-Schmidt 过程来生成它们,并且我们会得到以下:

如何做...

正如我们在最速下降法中所做的那样,让我们使用同样的技巧来确定如何做...是什么,因为它看起来非常熟悉,就像如何做...一样,我们使用以下方法推导它:

如何做...

从前面的方程中,我们插入两个向量是 A-正交的事实,即方程的左边是0,然后我们解右边的方程,结果是如何做...。当我们比较这个值与如何做...时,我们会发现它们几乎相同,除了 CG 方法使用的是线性无关的向量而不是最速下降法中发现的残差向量。

CG 方法建立在 Gram-Schimdt 过程/共轭和最速下降法的基础上,通过消除搜索向量的存在。它更倾向于使用残差向量,这在计算上很重要,否则你的程序需要存储所有的搜索向量,对于一个大的领域空间,这可能是一个非常糟糕的主意。我们跳过了一部分数学,但你可以从以下链接下载Jonathan Shewchuk的原始论文:

www.cs.cmu.edu/~quake-papers/painless-conjugate-gradient.pdf

在共轭梯度法中,我们有以下方程:

如何做...

我们将看看如何将其转换为 OpenCL。但首先,是时候来一杯咖啡了!

既然我们已经对 CG 方法的基本概念有了基本的了解,那么现在是时候看看一个简单的 SpMV 核是如何实现的了。然而,请记住我提到过我们必须理解稀疏矩阵中的数据是如何存储的。这实际上在实现中是至关重要的,因此花上接下来的几节来向您介绍这些众所周知的数据存储格式是合理的。

理解包括 ELLPACK、ELLPACK-R、COO 和 CSR 在内的各种 SpMV 数据存储格式

存储稀疏矩阵的方法有很多种,每种方法都有不同的存储需求,甚至计算特性,而且随着这些特性的不同,你可以以不同的方式访问和操作矩阵的元素。我之前提到过,我们将重点关注静态稀疏矩阵格式,并且在这里我展示了四种已被证明相当流行的存储格式,这不仅因为它们有不错的性能,还因为它们也是一些最早在标量和向量架构中流行的格式,而且最近在 GPGPUs 中也非常流行。

在接下来的段落中,我们将按照以下顺序向您介绍以下稀疏矩阵表示:

  • ELLPACK 格式

  • ELLPACK-R 格式

  • 坐标格式

  • 压缩稀疏行格式

让我们从 ELLPACK 格式开始。这个格式也被称为 ELL。对于一个最大每行有K个非零值的M x N矩阵,ELLPACK 格式将非零值存储到一个名为data的密集M x K数组中,其中每行小于K个非零值的行用零填充。同样,相应的列索引存储在另一个数组中,我们将其命名为indices。再次,使用零或某些哨兵值来填充这个数组。以下矩阵的表示说明了它的样子:

理解各种 SpMV 数据存储格式,包括 ELLPACK、ELLPACK-R、COO 和 CSR

对这个格式的快速分析表明,如果每行中非零值的最大数量与平均值相差不大,那么 ELLPACK 格式相当吸引人,因为它至少对我来说是直观的。

接下来,我们检查 ELLPACK-R 格式。这个格式是 ELLPACK 格式的变体,除了之前看到的数组之外,我们还有一个名为rl的新数组,用于存储每行的实际长度。以下表示说明了它的样子:

理解各种 SpMV 数据存储格式,包括 ELLPACK、ELLPACK-R、COO 和 CSR

现在还不明显这与 ELLPACK 有什么不同,但我们在后面将要看到的串行和并行核将利用这个新数组来使代码和数据传输更加紧密。

我们继续使用坐标格式。坐标格式是一种简单的存储方案。数组rowcoldata分别存储非零矩阵条目的行索引、列索引和值。COO 是一种通用的稀疏矩阵表示,因为所需的存储总是与非零值的数量成比例。以下是 COO 格式的外观:

理解各种 SpMV 数据存储格式,包括 ELLPACK、ELLPACK-R、COO 和 CSR

在这种格式中,有三个一维数组——rowcoldata

列表中的最后一个格式是压缩稀疏行(CSR)格式。CSR 格式是一种流行的通用稀疏矩阵表示。与 COO 格式类似,CSR 格式明确地在数组indicesdata中存储列索引和非零值。第三个数组ptr用于行指针,它表示 CSR 表示。对于一个M x N矩阵,ptr的长度为M + 1,在ptr[i]中存储第i行的偏移量。ptr中的最后一个条目,通常对应于M + 1^(th)行,存储矩阵中的非零值数量。以下表示展示了其外观:

理解各种 SpMV 数据存储格式,包括 ELLPACK、ELLPACK-R、COO 和 CSR

在这一点上,这就是我想讨论关于稀疏矩阵数据表示的所有内容。

小贴士

你应该知道还有其他格式,如DIA,也称为对角格式,混合/混合的 ELL/COO,以及数据包(用于类似向量架构的处理器)。

如何做到这一点...

现在我们已经检查了三种数据存储格式,让我们进一步探讨如何使用 ELLPACK 格式解决 SpMV 问题。像之前一样,我们希望从这个部分开始,通过展示 SpMV CPU 内核的代码来启动:

// num_rows  – number of rows in matrix
// data      – the array that stores the non-zero values
// indices   – the array that stores the column indices for zero, non-
//              zero values in the matrix
// num_cols  – the number of columns.
// vec       - the dense vector
// y         - the output 
void spmv_ell_cpu(const int num_rows,
                  const int num_cols,
                  const int * indices;
                  const float * data,
                  const float * vec, float * out) {
for( int row = 0; row < num_rows, row++) {
    float temp = 0;
      // row-major order
    for(int n = 0; n < num_cols; n++) {
        int col = indices[num_cols * row + n];
        float value = data[num_cols * row + n];
        if (value != 0 && col != 0)
            temp += value * vec[col];
    }
    out[row] += temp;
}
}

花点时间让自己相信我们确实在使用 ELLPACK 格式来解决 SpMV,并且当数据存储在低级内存中时,是以行主序存储的。再次戴上并行开发者的帽子,一种策略是让一个线程/工作项处理矩阵数据的一行,这意味着你可以移除外层循环结构,从而得到可能的 SpMV ELL 内核。

// num_rows  – number of rows in matrix
// data      – the array that stores the non-zero values
// indices   – the array that stores the column indices for zero, non-
//              zero values in the matrix
// num_cols  – the number of columns.
// vec       - the dense vector
// y         - the output 
__kernel void
spmv_ell_gpu(__global const int num_rows,
             __global const int num_cols,
             __global const int * indices;
             __global const float * data,
             __global const float * vec, float * out) {
     int row = get_global_id(0);
     if (row < num_rows) {
    float temp = 0;
	    // row-major order
    for(int n = 0; n < num_cols; n++) {
        int col = indices[num_cols * row + n];
        float value = data[num_cols * row + n];
        if (value != 0 && col != 0)
            temp += value * vec[col];
    }
    out[row] += temp;
}
}

你可能会首先注意到外层循环结构已经被移除,当你考虑到这个结构最初存在是为了我们可以迭代包含矩阵行和向量点积实际工作的内层循环时,这是直观的。

现在,当我们使用我们的细粒度并行策略检查其内存访问模式时,我们会得到以下表示,并且当我们稍后在 SpMV CSR 内核部分查看时,它将表现出类似的问题:

如何做到这一点...

了解如何使用 ELLPACK-R 格式解决 SpMV

ELLPACK-R 是 ELLPACK 格式的变体,显然它对于在 GPU 上实现 SpMV 相当流行。如果没有可以利用的常规子结构,如非对角线或稠密块,则应使用 ELLPACK-R。基本思想是通过将所有非零条目左移来压缩行,并将结果矩阵列按列连续存储在主主机内存中,其中N是每行非零条目的最大数量。

如何做到这一点

SpMV ELLPACK-R 标量内核被称为标量,是因为在 OpenCL 中开发并行程序时,我们没有利用到 GPU 特有的一个特定方面。这个方面被称为波前/ warp 级编程。我们将在下一节的 SpMV CSR 内核介绍中更多地讨论这一点。因此,在这一部分,我们将展示我们的 OpenCL 内核,如下面的代码所示,它采用了使用一个线程处理矩阵数据一行的策略,这次,我们得到了另一个数组rowLengths的帮助,该数组记录了矩阵中每行包含非零值的实际长度:

// data – the 1-D array containing non-zero values
// vec – our dense vector
// cols – column indices indicating where non-zero values are
// rowLengths – the maximum length of non-zeros in each row
// dim – dimension of our square matrix
// out – the 1-D array which our output array will be 
__kernel void
spmv_ellpackr_kernel(__global const float * restrict data,
                     __global const float * restrict vec
                     __global const int * restrict cols,
                     __global const int * restrict rowLengths,
                     const int dim, 
                     __global float * restrict out) {
    int t = get_global_id(0);

    if (t < dim)
    {
        float result = 0.0;
        int max = rowLengths[t];
        for (int i = 0; i < max; i++) {
            int ind = i * dim + t;
            result += data [ind] * vec[cols[ind]];
        }
        out[t] = result;
    }
}

检查前面的代码,我们注意到我们再次通过认识到每个线程或工作项(如果你记得的话,在 OpenCL 术语中)可以在内循环中独立执行工作的事实,将两个for循环减少为一个。

在下面的代码中,我们展示了我们的“向量化”内核,我们认识到我们的 SpMV ELLPACK-R 内核可以通过利用硬件内置的运行多个线程执行代码并同步执行的功能来得到改进。

小贴士

如果你在你的 OpenCL x86 兼容 CPU 上执行此向量化操作,除非 GPU 有可用的向量化硬件,否则它将不起作用。

当需要这种情况时,这非常有用,而且这种情况正是需要的。这导致了我们下面代码中显示的 SpMV ELLPACK-R 向量内核。我们的策略是在矩阵的每一行处理一个 warp,我们将每一行拆分,以便线程可以在 warp 或波前中处理数据:

// data – the 1-D array containing non-zero values
// vec – our dense vector
// cols – column indices indicating where non-zero values are
// rowLengths – the maximum length of non-zeros in each row
// dim – dimension of our square matrix
// out – the 1-D array which our output array will be 
#define VECTOR_SIZE 32 // NVIDIA = 32, AMD = 64
__kernel void
spmv_ellpackr_vector_kernel(__global const float * restrict val,
                            __global const float * restrict vec,
                            __global const int * restrict cols,
                            __global const int * restrict rowLengths,
                            const int dim,
                            __global float * restrict out) {

    // Thread ID in block
    int t = get_local_id(0);
    // Thread id within warp/wavefront
    int id = t & (VECTOR_SIZE-1);
    // one warp/wavefront per row
    int threadsPerBlock = get_local_size(0) / VECTOR_SIZE;
    int row = (get_group_id(0) * threadsPerBlock) + (t / VECTOR_SIZE);

    __local float volatile partialSums[128];

    if (row < dim) {
        float result = 0.0;
        int max = ceil(rowLengths[row]/VECTOR_SIZE);
        // the kernel is vectorized here where simultaneous threads
        // access data in an adjacent fashion, improves memory
        // coalescence and increase device bandwidth
        for (int i = 0; i < max; i ++) {
            int ind = i * (dim * VECTOR_SIZE) + row * VECTOR_SIZE + id;
            result += val[ind] * vec[cols[ind]];
        }
        partialSums[t] = sum;
        barrier(CLK_LOCAL_MEM_FENCE);

        // Reduce partial sums
        // Needs to be modified if there is a change in vector length
        if (id < 16) partialSums[t] += partialSums[t +16];
        barrier(CLK_LOCAL_MEM_FENCE);
        if (id <  8) partialSums[t] += partialSums[t + 8];
        barrier(CLK_LOCAL_MEM_FENCE);
        if (id <  4) partialSums[t] += partialSums[t + 4];
        barrier(CLK_LOCAL_MEM_FENCE);
        if (id <  2) partialSums[t] += partialSums[t + 2];
        barrier(CLK_LOCAL_MEM_FENCE);
        if (id <  1) partialSums[t] += partialSums[t + 1];
        barrier(CLK_LOCAL_MEM_FENCE);

        // Write result
        if (tid == 0)
        {
            out[row] = partialSums[tid];
        }

    }
}

它是如何工作的

这个向量内核利用了两个事实:

  • 内核由线程组执行,并且这些线程同步执行

  • 并行归约:并行归约是正当其时的一个主题,而我们使用的变体技术被称为分段归约

为了帮助你理解并行归约的工作原理,让我们假设并想象我们有一个包含 16 个元素的二维数组,每个数组元素都有一个数字。现在,我想问你如何计算这个给定数组中所有元素的总和?肯定有不止两种方法可以做到这一点,但假设你有八个工作项可以同步执行。你如何利用这一点?

一种方法是将每个工作项添加两个数组元素,这样就会得到部分和,但你是如何将这些部分和相加以产生一个单一的数,这个数代表了数组的求和呢?不深入细节,让我们使用以下图表,看看你是否能弄清楚它是如何工作的:

工作原理

理解如何使用 CSR 格式解决 SpMV 问题

在查看所有这些稀疏矩阵的不同数据表示后,你可能会意识到这幅图比我们之前想象的要复杂得多,这有助于强调研究人员和工程师已经花费了大量时间和精力以高效的方式解决看似简单的问题。因此,在本节中,我们将探讨如何使用 CSR 格式解决 SpMV 问题,并按顺序查看从顺序、标量到最终向量核的各种配方。

准备工作

现在,让我们看看 SpMV 代码在顺序形式下的样子,即在现代 CPU 上执行时,使用 CSR 格式,然后让我们看看 SpMV 的一个简单实现:

// num_rows – number of rows in matrix
// ptr – the array that stores the offset to the i-th row in ptr[i]
// indices – the array that stores the column indices for non-zero
//           values in the matrix
// x       - the dense vector
// y       - the output 
void spmv_csr_cpu(const int num_rows,
                  const int * ptr;
                  const int * indices;
                  const float * data,
                  const float * vec, float * out) {
for( int row = 0; row < num_rows, row++) {
    float temp = 0;
    int start_row = ptr[row];
    int end_row = ptr[row+1];
    for(int jj = start_row; jj < end_row; jj++)
        temp += data[jj] * vec [indices[jj]];
    out[row] += temp;
}
}

检查前面的代码,你会注意到数组ptr被用来选择数组data中的非零元素——这是所希望的,同时ptr也被用来索引indices数组以检索向量vec中的正确元素,这样我们永远不会执行乘以零值的操作。这一点从计算角度来看非常重要,因为它意味着我们不会浪费宝贵的处理器周期去执行我们永远不会使用的工作;从另一个角度来看,这种表示也意味着缓存总是填充着我们需要的值,而不是存储那些本质上为零值的值。

正如承诺的那样,让我们看看另一种解决方案,它专注于在现代桌面 CPU 上执行的矩阵-向量乘法,在这两个例子中,唯一的区别是前面的代码考虑了矩阵是稀疏的,而下面的代码假设矩阵是密集的:

// M – the matrix with dimensions 'height' x 'width'
// V – the dense vector of length 'width'
// W – the output
void matvec_cpu(const float* M, const float* V, int width, int height, float* W)
{
    for (int i = 0; i < height; ++i) {
        double sum = 0;
        for (int j = 0; j < width; ++j) {
            double a = M[i * width + j];
            double b = V[j];
            sum += a * b;
        }
        W[i] = (float)sum;
    }
}

花点时间检查这两个代码库,你会发现节省了多少计算周期和内存带宽,以及无谓地浪费了多少。

小贴士

始终建议将顺序形式与并行形式进行比较,以便您可以推导出关于转换算法的基本指标。

如何操作

现在我们已经做了一些基本的比较,我们需要确定我们的并行化策略。为此,我们需要再次戴上并行开发者的帽子,仔细审查前面显示的 SpMV CSR 串行内核的代码,寻找可并行化的部分。你可能已经注意到的一件事是,矩阵一行与向量vec的点积可以独立于所有其他行来计算。

以下代码演示了实现方式,其中有一个工作项处理矩阵的一行,有些文献会称这为标量内核。在这个内核中,和之前一样,我们的策略集中在查看两个循环结构,我们发现外层循环结构可以被展平并用工作项/线程替换,我们知道如何实现这一点;回到内层循环结构,这是每个工作项/线程实际上正在执行的内容,我们发现我们可以保留其所有执行流程,并在 OpenCL 内核中模拟它。

接下来,让我们看看 SpMV 内核是如何用 CSR 格式编写的:

__kernel void 
spmv_csr_scalar_kernel( __global const float * restrict val,
                        __global const float * restrict vec,
                        __global const int * restrict cols,
                        __global const int * restrict ptr,
                        const int dim, __global float * restrict out){
    int row = get_global_id(0);

    if (row < dim) {
        float temp=0;
        int start = ptr[row];
        int end = ptr[row+1];
        for (int j = start; j < end; j++) {
            int col = cols[j];
            temp += val[j] * vec[col];
        }
        out[row] = temp;
    }
}

如果你能回忆起来,在上一章中我们提到,这种执行模型使用非常细粒度的并行性,这样的内核可能表现不会很好。问题并不在于 CSR 表示法本身,而在于工作项/线程并没有同时访问 CSR 中的那些值。实际上,每个线程在处理矩阵的每一行时,都会在以下图中产生一个内存访问模式。在追踪了 SpMV CSR 内核的四个工作项/线程的执行后,你会发现每个线程都会引用数组val(其中包含矩阵A中的所有非零项)的不同部分,并且内存加载会在缓存(包含内存银行和内存通道/行)上锁定,最后硬件寄存器会执行它们。

注意

从现在开始,你应该从底层工作的角度来思考 GPU 的工作方式。

让我们以前面找到的 CSR 格式的矩阵为例,来说明 SpMV CSR 实际上并不工作得很好。每个缓存实际上是通过通道/行实现的,这样每行可以容纳一定数量的字节,在我们的例子中,它假设每行可以容纳 16 个元素(假设每个元素的大小为 4 字节,相当于 64 字节)。

到现在为止,你应该很明显地看出,有很多缓存带宽的浪费。由于我们的内核是并行的,从理论上讲,我们可以有四条不同的行来持有输入数组的各个部分。我们希望的是一次将所有数据放入,并在处理过程中保持缓存活跃。

实现这一目标的一种方法就是应用你之前学到的技术。恭喜你想到这一点。然而,让我们学习另一种技术,在一些文献中它被称为 warp-/wavefront 级编程。我们在上一节中看到了它的实际应用。

回想一下,在另一章中,我们介绍了某些 OpenCL 设备(特别是 GPU)的线程在处理器中以 lock step 方式执行的事实。以下图示了在 CPU 上以串行方式构建和执行 SpMV CSR 内核时的内存访问模式:

如何做到这一点

小贴士

为了优化你的算法以适应内存访问,让单个 wavefront/warp 中的工作项访问同一缓存行的内存位置。

接下来,你可能想知道如何设计一个内核,能够将所需的元素加载到相同的缓存行中,并利用 warp 或 wavefront 中的线程以 lock step 方式执行的事实。这一事实也意味着你需要协调,但不用担心,我们不需要使用 OpenCL 中找到的原子函数来做这件事。

当我看到“lock step”这个术语时,我立刻会联想到 10 名跑者,就像在 warp/wavefront 中执行线程一样,排成一排进行 100 米赛跑,而与 warp-/wavefront 级编程相比的例外是,所有这些跑者都需要一起到达终点线。我知道这听起来很奇怪,但这就是它的工作方式。协调这批跑者就像给八匹马套上缰绳,用鞭子驱赶马车来加速或减速。

注意

在这一点上,我想要稍微偏离一下主题,并指出英特尔数学内核库Intel MKL)11.0 使用基于 CSR 格式的数据存储格式来实现稀疏求解器,并且它们在 Intel CPU 上运行时性能良好,因为它们不仅优化了内存管理,还利用了指令级并行性ILP)。

现在,你必须识别并想象你的内核将由一组线程执行,作为入门,让我们想象有 32 或 64 个线程同时运行。每个线程都有一个 ID,这是你识别和控制它们的主要方法,即放置允许或限制线程运行的流程控制结构。为了说明这一点,让我们看一下以下改进的 SpMV CSR 向量内核。

SpMV CSR OpenCL 内核位于Ch8/SpMV/spmv.cl

#define VECTOR_SIZE 32 
// Nvidia is 32 threads per warp, ATI is 64 per wavefront
__kernel void
spmv_csr_vector_kernel(__global const float * restrict val,
                       __global const float * restrict vec,
                       __global const int * restrict cols,
                       __global const int * restrict ptr,
                       const int dim, __global float * restrict out){
    int tid = get_local_id(0);
    int id = tid & (VECTOR_SIZE-1);
    // One row per warp
    int threadsPerBlock = get_local_size(0) / VECTOR_SIZE;
    int row = (get_group_id(0) * threadsPerBlock) + (tid / VECTOR_SIZE);

    __local volatile float partialSums[128];
    partialSums[t] = 0;

    if (row < dim)
    {
        int vecStart = ptr[row];
        int vecEnd   = ptr[row+1];
        float sum = 0;
        for (int j = vecStart + id; j < vecEnd; j += VECTOR_SIZE) {
            int col = cols[j];
            sum += val[j] * vec[col];
        }
        partialSums[tid] = sum;
        barrier(CLK_LOCAL_MEM_FENCE);

        // Reduce partial sums
        // Needs to be modified if there is a change in vector length
        if (id < 16) partialSums[tid] += partialSums[t +16];
        barrier(CLK_LOCAL_MEM_FENCE);
        if (id <  8) partialSums[tid] += partialSums[tid + 8];
        barrier(CLK_LOCAL_MEM_FENCE);
        if (id <  4) partialSums[tid] += partialSums[tid + 4];
        barrier(CLK_LOCAL_MEM_FENCE);
        if (id <  2) partialSums[tid] += partialSums[tid + 2];
        barrier(CLK_LOCAL_MEM_FENCE);
        if (id <  1) partialSums[tid] += partialSums[tid + 1];
        barrier(CLK_LOCAL_MEM_FENCE);

        // Write result
        if (id == 0)
        {
            out[row] = partialSums[tid];
        }
    }
}

现在我们已经仔细研究了 OpenCL 内核,我们需要构建一个可执行的形式来执行。和以前一样,编译过程对你来说应该是熟悉的。在我的配置中,有一个 Intel Core i7 CPU 和 AMD HD6870x2 GPU 运行 Ubuntu 12.04 LTS,编译过程如下,它会在工作目录中创建一个名为SpMV的可执行文件:

gcc -std=c99 -Wall -DUNIX -g -DDEBUG -arch i386 -o SpMV -framework OpenCL

到目前为止,可执行文件应该已经在你所在的目录中可用。要运行程序,只需在目录中执行程序 SpMV,你应该会注意到一个类似于以下输出的结果:

Passed!

它是如何工作的

这种工作方式值得大量的解释,但首先,我们必须认识到我们已经将我们的并行归约转换成了另一种形式,这通常被称为分段归约。到这个时候,你应该对代码的其余部分相对熟悉了,所以我不打算带你走一遍,因为你可能会打瞌睡。

在所有形式中,并行归约都是进行跨处理器甚至架构的归约的有效方法。著名的 Hadoop 框架是跨架构并行归约的一个例子,而我们现在看到的形式是局限于 OpenCL GPU 上的处理器。

让我带你看看在我们的 SpMV CSR 矩阵核的分段归约示例中发生了什么。最初,我们在内核中设置了一个共享内存空间来存储 128 个 float 类型的元素:

__local volatile float partialSums[128];

小贴士

你可能好奇为什么在定义数组 partialSums 时需要使用关键字 volatile。主要原因是在 warp/波前级别的编程中,OpenCL 没有我们之前遇到的内存栅栏之类的同步函数,当你没有在声明共享内存时放置 volatile 关键字,编译器可以自由地将存储到和从 __local 内存中的加载替换为寄存器存储,从而引发执行错误。

目的是让 warp/波前中的每个线程将其自己的计算存储到由其线程 ID 标记的自己的槽位中。

接下来,我们看到以下这堆代码:

if (id < 16) partialSums[tid] += partialSums[t +16];
barrier(CLK_LOCAL_MEM_FENCE);
if (id <  8) partialSums[tid] += partialSums[tid + 8];
barrier(CLK_LOCAL_MEM_FENCE);
if (id <  4) partialSums[tid] += partialSums[tid + 4];
barrier(CLK_LOCAL_MEM_FENCE);
if (id <  2) partialSums[tid] += partialSums[tid + 2];
barrier(CLK_LOCAL_MEM_FENCE);
if (id <  1) partialSums[tid] += partialSums[tid + 1];
barrier(CLK_LOCAL_MEM_FENCE);

// Write result
if (id == 0) {
    out[row] = partialSums[tid];
}

这段代码做了两件事——第一是它只允许具有特定 ID 的线程执行,第二件事是它只允许 ID 为 0 的线程,即零,将总和写入输出数组 out 的适当元素中。

让我们深入了解细节。当一个正在执行的线程/工作项尝试执行以下代码片段时,内核首先会确定其 ID 是否允许,ID 在 0 到 15 范围内的线程将得到执行,而以下代码中的线程将不会执行,我们将有 线程发散

if (id < 16) partialSums[tid] += partialSums[t +16];
barrier(CLK_LOCAL_MEM_FENCE);

注意

回想一下,线程发散发生在分支处,即 if-then-else、switch 等等,这基本上将 warp/波前分成两部分,其中一组的一部分执行代码,而另一部分则不执行。

在这一点上,您应该确信整个共享内存数组partialSums进行了成对减少,我在纸上或电脑上追踪它时发现这很有帮助(无论您更喜欢哪种方式)。当执行线程完成并行减少后,请注意没有重叠的写入(这是故意的),我们只需要在那个点放置一个内存栅栏,以确保在继续之前每个线程都已经到达那个点。这个内存栅栏很重要,否则会发生不好的事情。接下来,再次进行并行减少,但这次我们只需要处理数组的一半,并将线程数量限制为8

if (id < 8) partialSums[tid] += partialSums[t +8];
barrier(CLK_LOCAL_MEM_FENCE);

我们通过将可执行线程的数量减半重复这个循环,直到它达到1,此时最终的聚合值将位于数组partialSums的零位置。

一旦我们在数组partialSums的零位置得到最终的聚合值,我们就可以将其写入到数组out中相应的位置,该位置由我们处理的行索引。这种分段减少在以下图中展示:

如何工作

理解如何使用 VexCL 解决 SpMV

最后,我想展示如何使用共轭梯度法解决 SpMV CSR 内核。我们在本章的开头研究了这种方法,希望我们仍然记得它是什么。让我通过刷新您对 CG 方法核心方程的记忆来帮助您:

理解如何使用 VexCL 解决 SpMV

到目前为止,我们已经通过 SpMV ELLPACK、ELLPACK-R 和 CSR 格式在标量和向量形式中,使用各种方法对 SpMV 问题有了相当好的理解,但确实花费了我们一些时间才达到这个目标。在本节中,您将了解到一个用于解决问题的 OpenCL 框架,它被称为 VexCL。可以从以下地址下载:

按照作者的观点,OpenCL 在工具支持方面存在不足,而 VexCL 在作者看来是围绕 OpenCL C++的更好封装之一,我喜欢在这一节中简要向您介绍它,您可以去下载它。

准备工作

为了让 VexCL 与您协同工作,您需要一个符合 C++11 规范的编译器,GNU GCC 4.6 和 Boost 库是合适的选择。在我的设置中,我已经成功编译了 GCC 4.7,并使用了 Boost List 版本 1.53,没有遇到太多麻烦。这意味着我不会列出安装说明,因为安装过程相对直接。

如何操作

以下 OpenCL 内核位于Ch8/SpMV_VexCL/SpMV.cpp

#define VEXCL_SHOW_KERNELS 
// define this macro before VexCL header inclusion to view output   
// kernels

#include <vexcl/vexcl.hpp>
typedef double real;
#include <iostream>
#include <vector>
#include <cstdlib>

void gpuConjugateGradient(const std::vector<size_t> &row,
                          const std::vector<size_t> &col,
                          const std::vector<real> &val,
                          const std::vector<real> &rhs,
                          std::vector<real> &x) {
    /*
     Initialize the OpenCL context
     */
    vex::Context oclCtx(vex::Filter::Type(CL_DEVICE_TYPE_GPU) &&
                        vex::Filter::DoublePrecision);

    size_t n = x.size();
    vex::SpMat<real> A(oclCtx, n, n, row.data(), col.data(), val.data());
    vex::vector<real> f(oclCtx, rhs);
    vex::vector<real> u(oclCtx, x);
    vex::vector<real> r(oclCtx, n);
    vex::vector<real> p(oclCtx, n);
    vex::vector<real> q(oclCtx, n);

    vex::Reductor<real,vex::MAX> max(oclCtx);
    vex::Reductor<real,vex::SUM> sum(oclCtx);

    /*
     Solve the equation Au = f with the "conjugate gradient" method
     See http://en.wikipedia.org/wiki/Conjugate_gradient_method
     */
    float rho1, rho2;
    r = f - A * u;

    for(uint iter = 0; max(fabs(r)) > 1e-8 && iter < n; iter++) {
        rho1 = sum(r * r);
        if(iter == 0 ) {
          p = r;
        } else {
          float beta = rho1 / rho2;
          p = r + beta * p;
        }

        q = A * p;

        float alpha = rho1 / sum(p * q);
        u += alpha * p;
        r -= alpha * q;
        rho2 = rho1;
    }

    using namespace vex;
    vex::copy(u, x); // copy the result back out to the host vector
}

如何工作

主代码基本上用所需的值填充一维数组,以便它们可以符合 CSR 格式。之后,声明设备向量并使用适当的数据类型与相应的宿主向量(复制将会发生,但这是在幕后进行的)相连接,并定义了两个归约器(它们基本上是我们之前见过的归约核);归约器将仅在 OpenCL 设备上使用单个执行线程执行,所以它并不完全像我们之前看到的并行归约;它的归约是好的,但它是以顺序方式执行的。

接下来,我们初始化了一个名为SpMAT的 ADT(抽象数据类型),它持有稀疏矩阵的表示,并且这个 ADT 具有跨越多个设备的能力,这是一个非常理想化的属性,因为编写的代码对其实际底层计算设备是透明的。

在后台,你看到的 C++代码将导致代码生成发生,并且这就是将被使用、编译和再次执行的那个代码;如果你想看到生成的内核代码,只需放置 C 宏VEXCL_SHOW_KERNELS。我们最后使用vex命名空间中的copy函数将处理过的数据从设备内存传输到宿主内存。

第九章。使用 OpenCL 开发位序排序

在本章中,我们将介绍以下食谱:

  • 理解排序网络

  • 理解位序排序

  • 在 OpenCL 中开发位序排序

简介

排序是计算机科学中最重要的问题之一,能够有效地对大量数据进行排序是绝对关键的。排序算法传统上在 CPU 上实现,并且在那里工作得很好,但另一方面,在 GPU 上实现它们可能会很具挑战性。在 OpenCL 编程模型中,我们既有任务并行性也有数据并行性,让排序算法在 OpenCL 模型上工作可能会很具挑战性,但主要是从算法的角度来看,即如何创建一个利用 OpenCL 提供的巨大数据量和任务并行性的算法。

排序方法可以大致分为两种类型:数据驱动和数据独立。数据驱动排序算法根据考虑的键值执行算法的下一步,例如快速排序。数据独立排序算法从这一角度来看是刚性的,因为它们不会根据键值改变处理顺序,因此从这个意义上讲,它们不像数据驱动排序算法。它们可以实现在 GPU 上以利用它提供的海量数据和任务并行性。因此,我们将探讨位序排序,因为它是一个数据独立排序算法的经典例子,我们将看到它如何通过排序网络来表示,最终我们将看到它们如何在 OpenCL 中高效实现以在 GPU 上执行。

注意

Ken Batcher 在 1968 年发明了位序排序。对于 n 个项目,它的大小为 介绍 ,深度为 介绍

位序排序通过在任何时刻比较两个元素来有效地工作,这意味着它消耗两个输入并决定 a 是否等于 b,a 是否小于 b,或者 a 是否大于 b,即算法主要在两个元素上操作,给定一个输入。位序排序是非自适应排序算法的一个例子。

注意

非自适应排序算法是那些操作序列独立于数据顺序的算法,也称为数据独立。

为了让您更具体地了解非自适应排序方法是什么样的,让我们创建一个虚构的指令 cmpxchg,它具有比较两个元素并在必要时交换它们的语义。如果我们要在两个元素之间实现比较-交换操作,它看起来会是这样。在下面的示例中,我们说明了非自适应方法等同于排序的直线程序,并且它们可以用要执行的比较-交换操作的列表来表示。

cmpxchg(a[0], a[1]);
cmpxchg(a[1], a[2]);
cmpxchg(a[0], a[1]);

例如,前面的序列是排序三个元素的直线程序;而且,开发此类算法的目标通常是为每个 n 定义一个固定的 cmpxchg 操作序列,该序列可以排序任何 n 个键的集合。换句话说,算法不考虑要排序的数据是否已经排序或部分排序。

理解排序网络

在上一节中,我们研究了非自适应排序算法及其在基本形式中的本质。在本节中,让我们看看一个常用于研究非自适应排序算法的模型。技术文献将此模型称为排序网络。这种排序形式也称为比较器网络,是位序排序背后的理念。

排序网络是这种研究的最简单模型,因为它们代表了一个抽象机器,它只通过比较-交换操作访问数据,它由原子比较-交换(也称为比较器)组成,这些比较器被连接在一起以实现通用排序的能力。

如何做到这一点...

以下是对排序四个键的说明。按照惯例,我们绘制一个排序网络,用于排序 n 个项目,作为 n 条水平线的序列,比较器连接一对线。我们还想象要排序的键从右到左通过网络,如果需要,在遇到比较器时交换一对数字,以将较小的数字放在顶部:

如何做到这一点...

从前面的图中,你会注意到键在网络中的行上从左到右移动。它们遇到的比较器在必要时会交换键,并不断将较小的键推向这个网络的顶部。一个敏锐的读者会注意到第四个比较器上没有进行交换。这个排序网络可以排序四个键的任何排列。

除了这个网络之外,还有其他排序网络,以下网络也排序与之前相同的输入,但与之前的排序网络相比,它需要两个额外的比较-交换操作。这很有趣,因此这被留给你自己研究作为练习。

如何做到这一点...

它如何工作...

这个排序网络具有一个特定的特性,那就是只要比较器不重叠,我们实际上可以并行执行比较-交换操作。接下来,我们需要了解我们如何通过将可以并行执行并在下一阶段需要执行的操作分组来获得这种并行性。下面是用于排序任何四个键的最优排序网络,我们展示了可以并行进行的操作,这些操作被分为三个排序阶段:

它如何工作...

虽然这不是最有效的方法,但早期的图示展示了任何四个键的可能并行排序网络。在这个并行排序网络中,我们可能启动线程,在三个阶段进行比较交换操作,结果是输入被排序。

小贴士

注意,这个排序四个键的排序网络从计算角度来看是最佳的,因为它只需要在三个阶段执行五个比较交换操作。

理解正序排序

之前我们已经讨论了排序网络,它与正序排序密切相关,因为排序网络被用来实现非自适应排序算法,例如正序排序。在正序排序中,我们基本上有一个输入(在别处定义),它是一个正序序列。正序序列是一种单调增加(减少),达到一个单一的最大(最小)值,然后单调减少(增加)的序列。如果一个序列可以通过循环移位变成这样的序列,那么它被认为是正序的。

通常,我们会考虑几种情况来确定输入是否适合排序(毕竟处理器周期是宝贵的,不浪费它们做无谓的工作是个好主意)。事实上,当我们希望根据特定的排序算法对某些输入进行排序时,我们总是会考虑输入是否已经根据我们的标准排序。在正序排序的上下文中,我们可能会收到一个正序序列,对于这种情况,我们应用所谓的正序分割序列或任意序列,在输入序列上的操作,并一直这样做,直到我们达到最终的排序状态。

注意

正序分割是对正序序列的操作,如果理解正序排序这两个元素被交换,理解正序排序并且操作产生了两个正序序列 A 和 B,使得 A 中的元素小于 B 中的元素。

如何做...

该图展示了如何通过重复应用此排序算法,将两个正序序列(图的上部)概念性地组合成一个更大的序列(图的底部):

如何做...

在我们收到一个任意序列的情况下,即未排序且不是正序的情况下,我们必须基本上从这个未排序的输入中产生一个正序序列,然后应用之前相同的技巧,使用正序分割直到我们达到最终的排序状态。以下图示说明了正序分割或合并(通常称为合并)如何作用于单独的序列,并产生最终排序序列,无论是升序还是降序:

如何做...

在任何情况下,如果分割大小达到了两个,我们就知道何时终止,因为在这个点上,它是 a 和 b 之间的比较操作,其中 a 大于或等于 b 或 b 大于或等于 a。这成立,并且根据排序顺序,我们将它们放置在输出中的适当位置。

位序排序使用 Donald Knuth 创建的原则,被称为 Knuth 的 0/1 原则,即:如果一个排序算法只对所有零和一的序列执行元素比较和交换,然后它对所有的任意数字序列进行排序。

在我们使用 OpenCL 开发位序排序算法之前,适当地只通过其顺序形式介绍它是合适的,这样我们可以开始寻找并行化的机会。

以下代码片段来自src/Ch9/BitonicSort_CPU_02/BitonicSort.c,并显示了相关的代码部分。这个实现是 Batcher 算法的翻译,为了说明目的,它是一个递归的,看起来像这样:

void merge(int a[], int l, int r) {
  int i, m = (l+r)/2;
  if (r == (l+1)) compareXchg(a, l, r);
  if (r < (l+2)) return;

  unshuffle(a, l, r);
  merge(a, l, m);
  merge(a,  m+1, r);
  shuffle(a, l, r);
  // In the original algorithm the statement was the following:
  // for(i = l+1; i < r; i+= 2) compareXchg(a, i, i+1);
  for(i = l; i < r; i+= 2) compareXchg(a, i, i+1);
}

这个递归程序通过反复将其原始输入分成一半,然后对每一半进行排序并将这些半部分合并成更大的段来工作。这个过程一直持续到段达到原始大小。请注意,它使用另外两个辅助函数来完成这项任务,它们被称为shuffleunshuffle。它们的工作方式与 OpenCL 中的相同函数类似(这并不奇怪,因为 OpenCL 中的相同函数受到了它们的启发)。以下是这些函数:

void shuffle(int a[], int l, int r) {
  int* aux = (int*)malloc(sizeof(int) * r);
  int i, j, m = (l+r)/2;
  for(i = l, j = 0; i <= r; i += 2, j++ ) {
    aux[i] = a[l+j];
    aux[i+1] = a[m+1+j];
  }
  for(i = l; i <= r; i++) a[i] = aux[i];
}

void unshuffle(int a[], int l, int r) {
  int* aux = (int*)malloc(sizeof(int) * r);
  int i, j, m = (l+r)/2;
  for(i = l, j = 0; i <= r; i += 2, j++ ) {
    aux[l+j] = a[i];
    aux[m+1+j] = a[i+1];
  }
  for(i = l; i <= r; i++) a[i] = aux[i];
}
void compareXchg(int* arr, int offset1, int offset2) {
  if (arr[offset1] >= arr[offset2]) {
    int t = arr[offset1];
    arr[offset1] = arr[offset2];
    arr[offset2] = t;
  }
}

它们的操作是这样的:洗牌实际上再次将输入分成两半,并从每一半中选取每个元素并将它们并排放置,直到达到两半的末尾。逆洗牌则正好相反,通过移除这些元素并将它们放回它们原来的位置。对于那些算法爱好者来说,你会认识到这是自顶向下的归并排序算法的程序实现,属于使用分治方法的算法类别。作为一个复习,本食谱的如何工作…部分中有一个插图,展示了在这个算法中洗牌和逆洗牌是如何工作的。

它是如何工作的...

在第四章使用 OpenCL 函数中探讨了洗牌和逆洗牌的概念,我们邀请您回到那里,用这些概念来更新自己。以下图表说明了在给定一个假想输入:81241521163514161019137的情况下,shuffleunshuffle(如之前定义的)将如何工作:

它是如何工作的...

类似于我们刚才展示的递归算法对于理解算法的一般流程是好的,但当你希望在 OpenCL GPU 上运行此算法时,它并不适用,因为 GPU 并不完全支持递归。即使你选择一个通过 OpenCL 在 CPU 上运行的实现,它也能工作,但不会是可移植的。

我们需要我们刚才讨论的算法的迭代版本,幸运的是,我们可以将这个递归算法转换为迭代算法。我们将查看以下来自src/Ch9/BitonicSort_CPU_02/BitonicSort.c的解决方案:

void merge_iterative(int a[], int l, int r) {
  int i, j , k, p, N = r -l+1;
  for(p = 1; p < N; p += p)
    for(k = p; k > 0; k /= 2)
     for(j = k%p; j+k < N; j += (k+k))
      for(i = 0; i < k; i++)
        if(j+i+k < N)
          if((j+i)/(p+p) == (j+i+k)/(p+p))
            compareXchg(a, l+j+i, l+j+i+k);
}

此算法被分为由p变量索引的阶段。最后一个阶段是当pN时,每个阶段都将排序和合并应用于大小为N / 2N / 4N / 8的段如何工作...。通过更深入地检查此代码的执行流程,你会注意到它实际上是在计算一个接受 32 个输入(对应于我们输入缓冲区中的输入数量)的排序网络,并且当你从左到右阅读这个图时,你会注意到它是以自下而上的方式来解决这个问题:

如何工作...

我所说的自下而上的方法是指应该从左到右阅读这个图(这也是数据通过这个排序网络的流动方向)。当你围绕第一列画上柱状图时,你会注意到算法创建了大小为二的段。然后第二列和第三列形成大小为 4 的段,然后第四列、第五列和第六列形成大小为 8 的段。它们继续形成,以排序/合并大小为 2 的幂次的段,直到它对输入数组中的所有N个元素进行排序和合并。你可能已经意识到,该算法不会创建任何临时数据结构来存储临时值,它实际上是在原地排序。原地排序算法的直接影响是内存效率高,因为输出直接写入输入,并且不创建任何内存存储。以下是在每个阶段算法工作的分区大小示意图:

如何工作...

为了加深我们对双峰排序和排序网络的理解,了解如何从中提取并行性是很重要的。

在 OpenCL 中开发双峰排序

在本节中,我们将通过使用在 GPU 上运行更好的 OpenCL 中的双峰排序来演示如何对任意输入进行排序的实现。

我们回顾一下,位序排序递归地对输入元素进行排序,通过构建序列并将这些序列合并成更大的序列,然后重复这个循环,它真正执行的两个关键操作是:进行成对比较以确定序列中两个元素的较大/较小,并通过在它们之间应用位序排序来合并这两个序列。

准备中

到目前为止,我们已经看到了如何将位序排序应用于位序序列。接下来我们需要解决的问题是对一个完全随机的输入我们应该怎么做?这个问题的答案是将其转换成一个位序序列,然后应用一系列的位序拆分/合并。最初,对输入中的元素进行成对比较交换操作,在这个阶段的最后,我们得到了大小为两的排序段。下一阶段是将两个大小为两的段组合起来,执行比较交换,产生大小为四的段。这个循环会重复进行,算法会持续创建更大的段,大小如准备中所示。

从上一节回顾,我们看到了位序排序的迭代版本(算法在此处重复),它使用数组索引 p 来表示排序将发生的阶段,并且随着算法的每个阶段,算法会排序和合并大小为两、四、八等的大小段。在此基础上,排序的每个阶段都将并行执行。同时记住,我们需要做两件事:

  • 构建一个比较网络(位序拆分/排序),将两个较小的位序序列排序成一个较大的序列,记住大小是 2 的幂。两个元素之间的这种成对比较将由单个执行线程/工作项进行。

  • 在每个半部分构建位序序列,使得一半是单调递增的,另一半是单调递减的。

如何去做...

我们的战略侧重于使用单个可执行线程执行比较交换操作,以下是一个使用这种简单策略的位序排序 OpenCL 内核:

以下代码摘录来自 Ch9/BitonicSort_GPU/BitonicSort.cl

__kernel
void bitonicSort(__global uint * data,
 const uint stage,
 const uint subStage,
 const uint direction) {

 uint sortIncreasing = direction;
 uint threadId = get_global_id(0);

 // Determine where to conduct the bitonic split
 // by locating the middle-point of this 1D array
 uint distanceBetweenPairs = 1 << (stage - subStage);
 uint blockWidth   = 2 * distanceBetweenPairs;

 // Determine the left and right indexes to data referencing
 uint leftId = (threadId % distanceBetweenPairs) + 
 (threadId / distanceBetweenPairs) * blockWidth;

 uint rightId = leftId + distanceBetweenPairs;

 uint leftElement = data[leftId];
 uint rightElement = data[rightId];

 // Threads are divided into blocks of size
 // 2^sameDirectionBlockWidth
 // and its used to build bitonic subsequences s.t the sorting is 
 // monotically increasing on the left and decreasing on the right
 uint sameDirectionBlockWidth = 1 << stage;

 if((threadId/sameDirectionBlockWidth) % 2 == 1)
 sortIncreasing = 1 - sortIncreasing;

 uint greater;
 uint lesser;
 // perform pairwise comparison between two elements and depending 
 // whether its to build the bitonic that is monotically increasing
 // and decreasing.
 if(leftElement > rightElement) {
 greater = leftElement;
 lesser  = rightElement;
 } else {
 greater = rightElement;
 lesser  = leftElement;
 }

 if(sortIncreasing) {
 input[leftId]  = lesser;
 input[rightId] = greater;
 } else {
 input[leftId]  = greater;
 input[rightId] = lesser;
 }
}

使用前面的 OpenCL 内核代码,我们需要构建一个可执行文件,以便它能在我们的平台上执行。和之前一样,编译过程对你来说应该很熟悉。在我的配置中,使用英特尔酷睿 i7 CPU 和 AMD HD6870x2 GPU 运行 Ubuntu 12.04 LTS,编译过程如下,它会在工作目录中创建一个名为 BitonicSort 的可执行文件:

gcc -std=c99 -Wall -DUNIX -g -DDEBUG -arch i386 -o BitonicSort -framework OpenCL

到目前为止,你应该在那个目录中有一个可执行文件。现在你只需要运行程序,简单地在目录中执行 BitonicSort 程序,你应该已经注意到一个类似于以下输出的结果:

Passed!
Execution of the Bitonic Sort took X.Xs

它是如何工作的...

算法从使用线程进行成对比较-交换操作的基本策略开始。具体来说,主机代码会将原始输入分解为其相应的阶段,为了测试目的,我们有一个包含 1600 万个元素的输入,这相当于 24 个阶段。在主机代码中,我们使用stage变量来表示这一点。接下来,在每一个阶段,算法将应用位序列分割/排序和合并大小从最小的 2 的幂次到最大的 2 的幂次,小于或等于阶段的大小,例如,如果我们正在对大小为八的元素进行排序,那么我们会排序以产生大小为二的段,然后是四,最后我们将对 4-by-4 序列进行排序和合并,以得到八。

详细来说,当内核开始执行时,它必须通过使用位序列分割来开始构建位序列子序列。为此,内核需要知道如何分割数组,考虑到当前排序阶段,它通过以下代码来完成:

 uint distanceBetweenPairs = 1 << (stage - subStage);
 uint blockWidth   = 2 * distanceBetweenPairs;

 // Determine the left and right indexes to data referencing
 uint leftId = (threadId % distanceBetweenPairs) + 
 (threadId / distanceBetweenPairs) * blockWidth;

 uint rightId = leftId + distanceBetweenPairs;

接下来,内核通过使用leftIdrightId索引从数组中加载数据值,并将它们存储在线程的本地寄存器内存中。算法的下一部分是构建位序列,使得一半是单调递增的,另一半是单调递减的。我们使用变量sameDirectionBlockWidth作为启发式方法来指导我们是按递增还是递减排序。以下代码实现了这一点:

 uint sameDirectionBlockWidth = 1 << stage;

 if((threadId/sameDirectionBlockWidth) % 2 == 1)
 sortIncreasing = 1 - sortIncreasing;

例如,假设阶段是三,这意味着sameDirectionBlockWidth是八。以下图示展示了当sortIncreasing变量基于(上述)计算翻转时最终会发生什么,从而产生所需的位序列排序效果:

如何工作...

内核的其余代码与成对比较-交换操作有关,这是我们目前已经熟悉的。

此实现的另一个方面是算法是计算密集型的,并且通过 CPU 在 OpenCL GPU 上迭代执行,内核会通知它所处的阶段及其子阶段。这可以在主机代码中这样完成:

for(cl_uint stage = 0; stage < stages; ++stage) {
  clSetKernelArg(kernel, 1, sizeof(cl_uint),(void*)&stage);

  for(cl_uint subStage = 0; subStage < stage +1; subStage++) {
    clSetKernelArg(kernel, 2, sizeof(cl_uint),(void*)&subStage);
                cl_event exeEvt;
                cl_ulong executionStart, executionEnd;
                error = clEnqueueNDRangeKernel(queue,
                                               kernel,
                                               1,
                                               NULL,
                                               globalThreads,
                                               threadsPerGroup,
                                               0,
                                               NULL,
                                               &exeEvt);
                clWaitForEvents(1, &exeEvt);

代码基本上遍历所有阶段及其子阶段,并调用 GPU 处理相同的输入缓冲区,通过调用clSetKernelArg为适当的参数通知内核它正在执行哪个阶段和子阶段。然后等待在该阶段完成排序后,才开始处理另一个(这是关键的,否则输入缓冲区会被损坏)。为了使输入缓冲区既能被算法读取又能被写入,它被创建如下:

device_A_in = clCreateBuffer(context,
                             CL_MEM_READ_WRITE|CL_MEM_COPY_HOST_PTR,
                             LENGTH * sizeof(cl_int),
                             host_A_in,
                             &error);

该算法的执行将看到执行流程进入宿主,然后离开前往 GPU,并继续这样做,直到阶段结束。这个过程在以下图中得到了说明,尽管它不能缩放:

如何工作...

我们实际上可以通过使用我们迄今为止相当了解的技术对这个内核进行优化,那就是使用共享内存。正如你可能现在已经知道的,共享内存允许开发者减少全局内存流量,因为程序不需要反复从全局内存空间请求元素,而是使用其内部存储中已经存储的内容。以下是对 OpenCL 内存模型的一个复习:

如何工作...

应用我们迄今为止学到的技术,我们实际上有一个可能的应用共享内存技术的点,那就是寻找从全局内存中获取数据的代码。我们将开发一个使用共享内存的解决方案,并将其稍微扩展以使我们的程序以步进方式加载。我们将在稍后讨论这一点。让我们从一个合理的点开始重新设计我们的 bitonicSort 程序,考虑到共享内存的存在:

uint leftElement = data[leftId];
uint rightElement = data[rightId];

我们展示了以下使用共享内存的内核,我们将在 Ch9/BitonicSort_GPU/BitonicSort.cl 中解释其工作原理:

__kernel
void bitonicSort_sharedmem(__global uint * data,
                           const uint stage,
                           const uint subStage,
                           const uint direction,
                           __local uint* sharedMem) {
    // more code omitted here
    // Copy data to shared memory on device
    if (threadId == 0) {
        sharedMem[threadId] = data[leftId];
        sharedMem[threadId+1] = data[rightId];
    } else {
        sharedMem[threadId+1] = data[leftId];
        sharedMem[threadId+2] = data[rightId];
    }
    barrier(CLK_LOCAL_MEM_FENCE);

    // more code omitted
    uint greater;
    uint lesser;

    if (threadId == 0) {
        if(sharedMem[threadId] > sharedMem[threadId+1]) {
            greater = sharedMem[threadId];
            lesser  = sharedMem[threadId+1];
        } else {
            greater = sharedMem[threadId+1];
            lesser  = sharedMem[threadId];
        }
    } else {
        if(sharedMem[threadId+1] > sharedMem[threadId+2]) {
            greater = sharedMem[threadId+1];
            lesser  = sharedMem[threadId+2];
        } else {
            greater = sharedMem[threadId+2];
            lesser  = sharedMem[threadId+1];
        }
    }

我们基本上是引入了一个名为 sharedMem 的变量,加载这些值的策略很简单:每个线程将在共享内存数据存储中存储两个值(相邻的),在后续部分将被读取出来,所有原本指向全局内存的读取现在都在本地/共享内存中进行。

负责分配此内存空间的宿主代码是来自 Ch9/BitonicSort_GPU/BitonicSort.c 的以下代码片段,考虑到每个线程写入两个相邻值。因此,对于 256 线程的工作组,它需要两倍的内存量:

#ifdef USE_SHARED_MEM
clSetKernelArg(kernel, 4, (GROUP_SIZE << 1) *sizeof(cl_uint),NULL);
#endif

要看到它的实际效果,你可以像这样编译程序(直接调用 gcc):

gcc -DUSE_SHARED_MEM -Wall -std=c99 -lOpenCL ./BitonicSort.c -o BitonicSort_GPU

这将 BitonicSort_GPU 程序存放到该目录中;另一种方法是像这样在代码库的根目录下调用 cmake

cmake –DUSE_SHARED_MEM=1 –DDEBUG .

导航到 Ch9/BitonicSort_GPU/ 并像这样调用 make

make clean;make

以下是根据我们刚才描述的方案进行的共享内存写入的示意图。记住,所有后续的读取都是通过 sharedMem 而不是全局内存流量进行的,这意味着节省了大量的带宽:

如何工作...

通过检查原始内核 bitonicSort,我们可以进一步探索算法,其中算法的最后部分涉及在将结果写回全局内存之前的基本比较交换操作。在这种情况下,我们可以通过再次应用共享内存概念来进一步扩展共享内存的概念。我们的策略在这里相当简单:每个执行线程写入两个对,其中每个对是这种 ![如何工作...],并通过键和值引用。在我们的算法中,键指的是输出索引(即 leftIdrightId),而值指的是将驻留在该键处的排序值(即 lessergreater)。以下图示说明了每个线程如何将两个对写入 aux 共享内存,以及它们如何在内存中布局:

如何工作...

在内核名为 bitonicSort_sharedmem_2Ch9/BitonicSort_GPU/BitonicSort.cl 文件中发现了以下内核修改。我们将查看与 bitonicSort_sharedmem 内核相比有所不同的部分:

    // Each thread will write the data elements to its own
    // partition of the shared storage without conflicts.
    const uint stride = 4;
    if(sortIncreasing) {
        aux[threadId*stride] = leftId;
        aux[threadId*stride+1] = lesser;
        aux[threadId*stride+2] = rightId;
        aux[threadId*stride+3] = greater;
    } else {
        aux[threadId*stride] = leftId;
        aux[threadId*stride+1] = greater;
        aux[threadId*stride+2] = rightId;
        aux[threadId*stride+3] = lesser;
    }
    barrier(CLK_LOCAL_MEM_FENCE);

    if(threadId == 0) {
        for(int i = 0; i < GROUP_SIZE * stride; ++i) {
           data[aux[i*stride]] = aux[i*stride+1];
           data[aux[i*stride+2]] = aux[i*stride+3];
        }
    }

内核的最后部分说明了我们如何只允许每个工作组中的一个执行线程,即 ID 为零的线程,从共享内存 aux 将实际写入全局内存的操作执行。请注意,内存栅栏是必要的,因为 aux 中的内存可能不会在 ID 为零的线程开始执行时已填充。因此,它被放置在那里以确保内存一致性。

第十章. 使用 OpenCL 开发 Radix 排序

在本章中,我们将探讨以下食谱:

  • 理解 Radix 排序

  • 理解 MSD 和 LSD Radix 排序

  • 理解归约

  • 在 OpenCL 中开发 Radix 排序

简介

在上一章中,我们学习了如何使用 OpenCL 开发 Bitonic 排序。在本章中,我们将探讨如何使用 OpenCL 开发 Radix 排序。Radix 排序也被称为桶排序,我们将在稍后看到原因。

注意

第一个 Radix 排序算法来自一台名为Hollerith machine的机器,它在 1890 年用于编制美国人口普查,尽管它可能没有像查尔斯·巴贝奇创造的机器那样出名,但它确实在计算机历史中占有一席之地。

理解 Radix 排序

Radix 排序不是基于比较的排序算法,它有一些特性使其更适合并行计算,尤其是在像 GPU 和现代 CPU 这样的向量处理器上。

小贴士

我有些不愿意使用“现代”这个词,因为处理器技术随着时间的推移发展得如此之快,这个词的使用似乎有些过时。

Radix 排序的工作方式与比较排序算法(如快速排序)相比非常有趣;它们之间的主要区别在于它们如何处理输入数据的关键字。Radix 排序通过将关键字分解成更小的子关键字序列(如果可以这样说的話),然后逐个对这些子关键字进行排序来实现这一点。

数字可以转换为二进制,可以看作是位序列;同样的类比也可以从字符串中得出,它们是字符序列。当应用于此类关键字时,Radix 排序不比较单个关键字,而是处理和比较这些关键字的片段。

Radix 排序算法将关键字视为基-R 数系统中的数字。R被称为基数,因此该算法的名称由此而来。不同的R值可以应用于不同的排序类型。例如:

  • R = 256 将是对每个字符都是 8 位 ASCII 值的字符串进行排序

  • R = 65536 将是对每个字符都是 16 位 Unicode 值的 Unicode 字符串进行排序

  • R = 2 将是对二进制数进行排序

如何做到这一点…

在这一点上,让我们通过一个例子来看看 Radix 排序如何对数字 44565、23441、16482、98789 和 56732 进行排序,假设每个数字都是内存中连续位置排列的五位数

44565 23441 16482 98789 56732

我们将按从右到左的顺序提取每个数字,首先检查最低有效位。因此,我们有以下:

5 1 2 9 2

假设我们将计数排序应用于这个数字数组,它将变成以下样子:

1 2 2 5 9

这意味着以下顺序。请注意,排序是稳定的:

23441 16482 56732 44565 98789

接下来,我们将向左移动一位。注意,现在数字数组如下:

4 8 3 6 8

再次应用计数排序并将其转换回数字顺序,我们有:

56732 23441 16482 98789 44565

对于第 1,000 位,我们有:

23441 16482 56732 98789 44565

对于第 10,000 位,我们有:

23441 44565 16482 56732 98789

对于第 100,000 位,我们有:

16482 23441 44565 56732 98789

哇!基数排序已经对五位数的数组进行了排序。我们应该注意,这种排序是稳定的

备注

稳定排序指的是算法能够保持具有相等键的任意两个元素之间的相对顺序的能力。让我们假设有一个整型数组int a[5],包含值123492,通过某种排序算法 X,将元素排序为122349。这里的要点是,我们看到的两个相等的值,即数字2,分别位于位置15(假设数组是零索引)。然后,通过 X,排序后的列表将使得a[1]始终在a[5]之前。

实际上,基数排序有两种基本方法。我们已经看到了一种方法,即我们检查最低有效位并对其进行排序。这通常被称为LSD 基数排序,因为我们从右到左进行操作。另一种方法是从左到右进行操作。

基数排序的关键考虑因素是的概念。根据上下文,键可能是一个单词或字符串,并且它们的长度可以是固定的或可变的。

理解 MSD 和 LSD 基数排序

在我们开始开发 OpenCL 上的等效程序之前,让我们花些时间来理解 MSD 基数排序和 LSD 基数排序是如何工作的。

如何做到这一点…

基数排序假设我们希望首先考虑最高有效位来对基数-R 的数字进行排序。为了实现这一点,我们可以将输入分成R而不是仅仅两个部分,我们之前确实看到过这样做。这是数据分箱,但它通过计数排序进行了扩展。基数排序可以在 ASCII 字符、Unicode 字符、整数(32 位/64 位)或浮点数(排序浮点数是棘手的)上运行。你需要确定什么构成了键。键可以被认为是 8 位键、16 位键等等,现在我们知道基数排序需要重复迭代以提取键,并根据基数R对它们进行排序和分箱。

在下面的代码片段中,我们有一个 MSD 基数排序,它使用编程语言 C 对给定字符串中的字符进行排序,我们使用的基数是 256(无符号 8 位数的最大值,否则有符号 8 位数的范围是-128 到 127):

#define N // integers to be sorted with values from 0 – 256
void MSD(char[] s) {
  msd_sort(s, 0, len(s), 0);
}
void msd_sort(char[][] s, int lhs, int rhs, int d) {
  if (rhs <= lhs + 1) return;
  int* count = (int*)malloc(257 *sizeof(int));
  for(int i = 0; i < N; ++i)
    count[s[i][d]+1]++;
  for(int k = 1; k < 256; ++k) 
    count[k] += count[k-1];
  for(int j = 0; j < N; ++j) 
    temp[count[s[i][d]]++] = a[i];
  for(int i = 0; i < N; ++i) 
    s[i] = temp[i];
  for(int i = 0; i<255;++i)
    msd_sort(s, 1 + count[i], 1 + count[i+1], d+1);
}

基数排序的第二种方法是从右向左扫描输入,并通过对 MSD 基数排序应用类似操作来检查每个元素。这被称为最低有效位LSD)基数排序。LSD 基数排序之所以有效,是因为当任何两个元素不同时,排序将它们放置在正确的相对顺序中,即使这两个元素不同,LSD 表现出稳定的排序特性,这意味着它们的相对顺序仍然保持不变。让我们看看它是如何对排序三个字符字符串起作用的:

如何做…

对给定字符串中的字符进行排序的典型 LSD 基数排序可能看起来像以下代码(假设所有键都有固定的宽度;让我们称它为W):

void lsd_sort(char[][] a) {
  int N = len(a);
  int W = len(a[0]);
  for(int d = W – 1; d >= 0; d--) {
    int[] count = (int*) malloc(sizeof(int) * 256);
    for(int i = 0; i< N; ++i) 
      count[a[i][d]+1]++;
    for(int k = 1; k < 256; k++)
      count[k] += count[k-1];
    for(int i = 0; i< N; ++i) 
      temp[count[a[i][d]]++] = a[i];
    for(int i= 0; i< N; ++i)
      a[i] = temp[i];
  }
}

它是如何工作的…

这两种方法都很相似,因为它们都将字符分入 R 个桶中,即 256 个桶,并且它们还使用了计数排序的思想来确定最终的排序排列,使用临时存储temp,然后使用这个临时存储并将数据移动到它们的排序位置。与 LSD 基数排序相比,MSD 基数排序的优点是 MSD 可能不会检查所有的键,并且适用于可变长度的键;尽管如此,这也带来了另一个问题——MSD 可能会遇到次线性排序;在实践中,当键的大小固定时,通常更倾向于使用 LSD。

与基于划分-征服方法的其它排序算法的运行时间相比,LSD 基数排序的运行时间是它是如何工作的…,你可能倾向于得出结论,基数排序会比基于比较的排序算法(如快速排序)更快,你可能是对的。但是,在实践中,经过良好调优的快速排序可以通过应用更高级的技术来提高缓存友好性,从而比基数排序快 24%。然而,技术不断进步,研究人员和工程师将找到机会最大化性能。

小贴士

你可能想阅读 LaMarca 的论文《缓存对排序的影响》和 Rahman 和 Raman 的论文《将基数排序适应内存层次结构》,以了解更多他们所工作的算法改进。

理解归约

基数排序采用两种技术:归约扫描。这些被归类为数据收集模式,因为它们在并行计算中经常出现。这个配方将专注于归约,它允许使用关联的二进制运算符将数据压缩为单个元素。扫描模式很容易与归约模式混淆,关键区别在于这种模式将集合的每个子序列减少到输入的每个位置。我们将推迟对扫描的讨论,直到我们到达下一节。

在归约模式中,我们通常有一个结合二进制运算符理解归约,我们用它以成对的方式收集容器中的所有元素。我们需要一个结合二进制运算符是一个重要的因素,因为它意味着开发者可以重新组织组合函数以检查其效率;我们稍后会详细讨论这一点。让我们看一下以下代码片段中执行归约的串行算法:

template<typename T>
T reduce(T (*f)(T, T),
         size_t n,
         T a[],
         T identity) {
  T accumulator = identity;
  for(size_t i = 0; i < n ; ++i)
        accumulator = f(accumulator, a[i]);
  return accumulator;
}

该算法基本上接受一个结合二进制运算符f(即函数的指针)和一个长度为n的数组a,并计算数组上的操作理解归约,初始值由identity标识。

结合二进制运算符可以允许开发者从中提取并行性,因为结合性意味着运算符无论应用于元素的顺序如何,都会产生相同的结果。也就是说:

理解归约

上述表达式等同于:

理解归约

穿上多核帽子,我们实际上可以想象一个计算树,其中子树代表形式理解归约的计算。第一次遍历将计算这个子树的结果,而第二次遍历将收集其他子树的结果。一旦你有机会在接下来的两个图中直观地检查它们,这将会很明显:

理解归约

对比这些图示的不同方式对你来说将非常有用。其中一种方式是前者暗示了一个按遍历顺序的操作序列,这与后者(如下图中所示)非常不同:

理解归约

了解结合运算符允许归约并行化是个好消息,但这并不是全部,因为结合性只允许我们分组操作,并不能告诉我们这些二进制操作组是否需要按特定顺序发生。如果你想知道我们是否在谈论交换律,你完全正确!交换律给我们提供了改变应用顺序的重要属性。我们知道一些操作表现出其中之一,而另一些则表现出两者;例如,我们知道数字的加法和乘法既是结合的又是交换的。以下是一个交换律并行归约可能的样子:

理解归约

现在,看到这些信息,你可能会想知道这如何翻译成 OpenCL。我们将在这个菜谱中演示几个归约内核,每个内核都会在之前的基础上提供改进。

如何做到这一点…

对于这个配方,我们将假设我们有一个包含几百万个元素的数组,并且我们希望应用归约算法来计算所有元素的总和。首先要做的是为之前看到的串行版本生成一个并行算法。我们展示的所有内核都在Ch10/Reduction/reduction.cl

在算法的串行版本中,您可能会注意到我们只是简单地将累加器传递给二进制函数以执行操作。然而,在 GPU 上我们不能使用这种方法,因为它不能支持成千上万的执行线程,而且设备可以包含比 x86 CPU 更多的处理器。唯一的解决方案是将数据分区到处理器上,以便每个块处理输入的一部分,当所有处理器并行执行时,我们应该期望工作在短时间内完成。

假设一个块已经计算出了其总和值,我们仍然需要一种方式来汇总所有块的所有部分总和,考虑到 OpenCL 没有全局同步原语或 API,我们有两种选择:让 OpenCL 汇总部分总和,或者让主机代码汇总部分总和;在我们的示例中,选择了第二种选项。

第一个内核reduce0是串行算法的直接翻译:

__kernel void reduce0(__global uint* input, 
                      __global uint* output, 
                      __local uint* sdata) {
    unsigned int tid = get_local_id(0);
    unsigned int bid = get_group_id(0);
    unsigned int gid = get_global_id(0);
    unsigned int blockSize = get_local_size(0);

    sdata[tid] = input[gid];

    barrier(CLK_LOCAL_MEM_FENCE);
    for(unsigned int s = 1; s < BLOCK_SIZE; s <<= 1) {
        // This has a slight problem, the %-operator is rather slow
        // and causes divergence within the wavefront as not all threads
        // within the wavefront is executing.
        if(tid % (2*s) == 0)
        {
            sdata[tid] += sdata[tid + s];
        }
        barrier(CLK_LOCAL_MEM_FENCE);
    }

    // write result for this block to global mem
    if(tid == 0) output[bid] = sdata[0];
}

它是如何工作的...

这个内核块会将元素加载到其共享内存sdata中,我们在sdata中进行各种阶段的归约,这些阶段由for循环控制,允许具有 ID 为 2 的倍数的作业项执行成对归约。因此,在循环的第一迭代中,ID 为{0, 2, 4, 6, 8, 10, 12, 14, ..., 254}的作业项将执行,在第二迭代中,只有 ID 为{0, 4, 8, 12, 252}的作业项将执行,依此类推。按照归约算法,部分总和将被存入sdata[0],最后这个值将由一个 ID 值恰好等于0的线程复制出来。诚然,这个内核相当不错,但它有两个问题:取模运算符执行时间较长,波前发散。这里更大的问题是波前发散问题,因为它意味着波前中的某些作业项正在执行,而有些则没有,在这种情况下,具有奇数 ID 的作业项没有执行,而具有偶数 ID 的作业项则执行,GPU 通过实现预测来解决此问题,这意味着以下代码片段中的所有作业项实际上都会执行。然而,GPU 上的预测单元将应用一个掩码,以便只有那些 ID 与条件匹配的作业项,即if(tid % (2*s) == 0),将执行if语句中的语句,而那些未通过条件的作业项,即false,将使他们的结果无效。显然,这是计算资源的浪费:

if(tid % (2*s) == 0)
{
    sdata[tid] += sdata[tid + s];
}

幸运的是,这可以通过很少的努力来解决,接下来的内核代码展示了这一点:

__kernel void reduce1(__global uint* input, 
                      __global uint* output, 
                      __local uint* sdata) {
    unsigned int tid = get_local_id(0);
    unsigned int bid = get_group_id(0);
    unsigned int gid = get_global_id(0);
    unsigned int blockSize = get_local_size(0);

    sdata[tid] = input[gid];

    barrier(CLK_LOCAL_MEM_FENCE);
    for(unsigned int s = 1; s < BLOCK_SIZE; s <<= 1) {
        int index = 2 * s * tid;
        if(index < BLOCK_SIZE)
        {
            sdata[index] += sdata[index + s];
        }
        barrier(CLK_LOCAL_MEM_FENCE);
    }

    // write result for this block to global mem
    if(tid == 0) output[bid] = sdata[0];
}

我们将模运算符应用后的条件评估替换为更易于接受的内容。令人垂涎的部分是,我们不再有发散的前沿,我们还对共享内存进行了步进访问。

还有更多...

到目前为止,我们已经看到了如何将我们对结合律的理解应用于构建归约内核,以及如何利用我们对交换律的新理解来优化归约过程。交换律归约树实际上比结合律归约树更好,因为它通过压缩归约值更好地利用了共享内存,从而提高了效率;下面的内核,reduce2,反映了这一点:

__kernel void reduce2(__global uint* input, 
                      __global uint* output, 
                      __local uint* sdata) {
    unsigned int tid = get_local_id(0);
    unsigned int bid = get_group_id(0);
    unsigned int gid = get_global_id(0);
    unsigned int blockSize = get_local_size(0);

    sdata[tid] = input[gid];

    barrier(CLK_LOCAL_MEM_FENCE);
    for(unsigned int s = BLOCK_SIZE/2; s > 0 ; s >>= 1) {
        // Notice that half of threads are already idle on first iteration
        // and with each iteration, its halved again. Work efficiency isn't very good
        // now
        if(tid < s)
        {
            sdata[tid] += sdata[tid + s];
        }
        barrier(CLK_LOCAL_MEM_FENCE);
    }

    // write result for this block to global mem
    if(tid == 0) output[bid] = sdata[0];
}

然而,这并不理想,因为在第一次迭代中,我们已经使一半的工作项闲置,效率肯定受到了影响。幸运的是,然而,补救措施很简单。我们减少了一半的块数,在共享内存的活化过程中,我们加载两个元素并将这两个元素的和存储起来,而不是仅仅从全局内存中加载值并将它们存储到共享内存中。内核,reduce3,反映了这一点:

__kernel void reduce3(__global uint* input, 
                      __global uint* output, 
                      __local uint* sdata) {
    unsigned int tid = get_local_id(0);
    unsigned int bid = get_group_id(0);
    unsigned int gid = get_global_id(0);

    // To mitigate the problem of idling threads in 'reduce2' kernel,
    // we can halve the number of blocks while each work-item loads
    // two elements instead of one into shared memory
    unsigned int index = bid*(BLOCK_SIZE*2) + tid;
    sdata[tid] = input[index] + input[index+BLOCK_SIZE];

    barrier(CLK_LOCAL_MEM_FENCE);
    for(unsigned int s = BLOCK_SIZE/2; s > 0 ; s >>= 1) {
        // Notice that half of threads are already idle on first iteration
        // and with each iteration, its halved again. Work efficiency isn't very good
        // now
        if(tid < s)
        {
            sdata[tid] += sdata[tid + s];
        }
        barrier(CLK_LOCAL_MEM_FENCE);
    }

    // write result for this block to global mem
    if(tid == 0) output[bid] = sdata[0];
}

现在,事情开始看起来要好得多,我们使用了所谓的逆序循环(这基本上是反向计数)来消除发散前沿的问题;同时,我们也没有减少我们的元素归约能力,因为我们是在活化共享内存的同时执行这一操作的。问题是是否还有更多我们可以做的?实际上,我们还有一个可以验证的想法,那就是利用在 GPU 上执行的前沿或 warps 的原子性。下一个内核,reduce4,展示了我们如何利用前沿编程来原子性地减少块:

__kernel void reduce4(__global uint* input, 
                      __global uint* output, 
                      __local uint* sdata) {
    unsigned int tid = get_local_id(0);
    unsigned int bid = get_group_id(0);
    unsigned int gid = get_global_id(0);
    unsigned int blockSize = get_local_size(0);

    unsigned int index = bid*(BLOCK_SIZE*2) + tid;
    sdata[tid] = input[index] + input[index+BLOCK_SIZE];

    barrier(CLK_LOCAL_MEM_FENCE);
    for(unsigned int s = BLOCK_SIZE/2; s > 64 ; s >>= 1) {
        // Unrolling the last wavefront and we cut 7 iterations of this
        // for-loop while we practice wavefront-programming
        if(tid < s)
        {
            sdata[tid] += sdata[tid + s];
        }
        barrier(CLK_LOCAL_MEM_FENCE);
    }

    if (tid < 64) {
        if (blockSize >= 128) sdata[tid] += sdata[tid + 64];
        if (blockSize >=  64) sdata[tid] += sdata[tid + 32];
        if (blockSize >=  32) sdata[tid] += sdata[tid + 16];
        if (blockSize >=  16) sdata[tid] += sdata[tid +  8];
        if (blockSize >=   8) sdata[tid] += sdata[tid +  4];
        if (blockSize >=   4) sdata[tid] += sdata[tid +  2];
        if (blockSize >=   2) sdata[tid] += sdata[tid +  1];
    }
    // write result for this block to global mem
    if(tid == 0) output[bid] = sdata[0];
}

在由语句if (tid < 64)定义的代码块中,我们不再需要放置内存屏障,因为该代码块只包含一个原子性地同步执行的前沿。

在 OpenCL 中开发基数排序

从本节开始,我们将为 OpenCL 开发这种排序方法。我们将做两件事:实现论文中描述的并行基数排序,这篇论文是由Marco ZaghaGuy E. Blelloch在 1991 年撰写的,题为向量多处理器的基数排序。前者算法是为 CRAY Y-MP 计算机设计的(该计算机反过来又从在连接机(CM-2)上运行的并行基数排序算法中改编而来)。

准备工作

基数排序试图将键视为多数字数,其中每个数字是一个依赖于基数大小 R 的整数。一个例子就是对一个大型 32 位数字数组进行排序。我们可以看到,这样的每个数字由四个字节组成(在今天的 CPU 和 GPU 处理器上,每个字节是 8 位),如果我们决定假设每个数字是 8 位,那么我们自然会把这个 32 位数字视为由四个数字组成。当你将这个概念应用到由多个字符组成的单词字符串时,这个概念最为自然。

1999 年论文中提出的原始算法基本上使用了计数排序算法,并且有三个主要组件,通过迭代这三个组件直到任务完成来对输入进行排序。伪代码,这是一个串行算法,如下所示:

COUNTING-SORT
  HISTOGRAM-KEYS
    do i = 0 to 2r -1
      Bucket[i] = 0
    do i = 0 to N – 1
      Bucket[D[j]] = Bucket[D[j]] + 1
  SCAN-BUCKETS
    Sum = 0
    do i = 0 to 2r – 1
      Val = Bucket[i]
      Bucket[i] = Sum
      Sum = Sum + Val
  RANK-AND-PERMUTE
    do j = 0 to N – 1
      A = Bucket[D[j]]
      R[A] = K[j]
      Bucket[D[j]] = A + 1

算法 HISTOGRAM-KEYS 是我们在几章之前已经遇到过的,它实际上就是直方图。这个算法计算它在排序过程中遇到的键的分布。这个算法以串行方式表达,也就是说,它应该在单个执行线程上运行;我们已经学过如何并行化它,并且你可以在这里应用这些技术。然而,我们现在要做的将偏离你在上一章中看到的内容,我们很快就会揭示这一点。

下一个算法是 SCAN-BUCKETS,它之所以被命名为这样,是因为它实际上扫描整个直方图来计算前缀和(我们将在后面详细检查前缀和)。在这个扫描操作中,Bucket[i] 包含具有值 j 的数字的数量,其中 j 大于 i,这个值也是位置,即输出中的数组索引。

最终的算法是 RANK-AND-PERMUTE,每个具有值 i 的键通过从 Bucket[i] 获取偏移量并增加桶,以便具有相同值 i 的下一个键放置在下一个位置来放置在其最终位置。你也应该注意到 COUNTING SORT 是稳定的。

在我们深入探讨算法的并行化以及它们如何协同工作之前,重要的是花接下来的几段文字来理解前缀和是什么;下一段文字将强调它们在基数排序中的重要性。

在前面的章节中,我们介绍了 MSD 和 LSD 基数排序,以及前缀和的计算被嵌入到代码中。然而,当时我们没有特别指出这一点。所以,现在是时候了,以下就是代码(取自之前的 lsd_sortmsd_sort 部分):

for(int k = 1; k < 256; k++)
      count[k] += count[k-1];

如果你还记得 MSD/LSD 是如何工作的,我们基本上创建了一个我们遇到值的直方图,并且在排序的每个阶段,我们计算前缀和,以便算法知道在排序顺序中放置输出在哪里。如果你仍然怀疑,你现在应该停下来,翻回到那个部分,并处理三个字符字符串的 LSD 排序。

注意

前缀和实际上是全局和的推广,其原始公式大致如下:

前缀和操作接受一个二元结合运算符准备中,一个有序的 n 个元素的集合准备中,并返回一个有序的集合准备中

我们用一个具体的例子来说明,比如对一个任意数组如[39, 23, 44, 15, 86]进行求和。使用加法运算符,输出将是[39, 62, 108, 125, 211],这种计算为什么重要或者为什么需要并不明显。实际上,由于后续的每个计算都依赖于前一个计算,甚至不清楚是否有直接并行化此算法的方法。

前缀和的顺序版本具有准备中的运行时间,可以表示如下,假设有两个数组in_arrout_arr,并且out_arr被设计用来包含in_arr的前缀和:

sum = 0
out_arr[0] = 0
do i = 0 to lengthOf(in_arr)
  t = in_arr[i+1]
  sum = sum + t
  out_arr[i] = sum

为了从这中提取并行性,我们需要调整我们看待任意输入值数组的观点,而我们正在讨论的调整实际上是将数组想象成被一个计算树消耗。让我们进一步探讨一下为什么。

在这个阶段,我们认为回顾历史并了解谁提出了原始的前缀和计算方法是很重要的。据我所知,1986 年,两位研究人员丹尼尔·希尔利斯盖·L·斯蒂尔ACM(计算机机械协会)杂志上发表的一篇题为数据并行算法的文章中提出了前缀和的一个版本,他们提出的算法如下(在该文章中如此引用):

for j = 1 to log2n do
  for all k in parallel do
    if (k >= 2j) then
      x[k] = x[k – 2j-1] + x[k]
    fi
  endfor
endfor

以下图表(由 NVIDIA 公司的Mark Harris提供),直观地说明了 Hillis 和 Steele 算法的工作原理。它从所有八个元素都被视为二叉树叶子的那一层开始,然后通过计算部分和逐步工作。计算的每一层,d,将基于上一层的计算计算部分和。算法中的一个假设是它假设有与元素一样多的处理器,这通过算法中的条件语句if (k >= 2j)得到证明。它还有一个问题就是效率不高;它的运行时间复杂度为准备中,你将记得我们的顺序扫描以准备中的速度运行,所以它肯定更慢。

准备中

然而,Guy Blelloch找到了改进这种方法的方法,它们基于构建平衡二叉树并在每个节点上进行加法操作(从概念上讲)的想法。因为这样的树有n个叶子(这对应于数组中的元素数量),将有准备中层,每层有2^d个节点,运行时间复杂度为准备中。以下图表展示了平衡二叉树如何计算任意值的数组:

准备中

之前的图表创建了并列关系,并改变了你看同一份数据的方式,即包含任意值的一维扁平数组。想象一个计算树,它扫描并操作两个值。存储这些部分和的一种方法是将值就地写入数组,另一种方法是在设备上使用共享内存。

你敏锐的读者直觉会注意到,我们可能通过允许一个线程读取两个元素,将它们相加,并将结果写回数组,然后在数组中读取最后一个元素以获得最终和,来并行化树中每一层的计算。我们刚才描述的算法被称为归约内核或上推内核(因为我们正在将值向上推到树的根),我们在讨论 OpenCL 中稀疏矩阵计算的章节中看到了它是如何工作的。以下是由Guy Blelloch提出的归约阶段的更正式定义,当它应用于深度准备中的平衡二叉树时:

for d from 0 to (log2 n) – 1
  in parallel for i from 0 to n – 1 by 2d+1
    array[i + 2d+1 – 1] = array[i + 2d – 1] + array[i + 2d+1 – 1]

你可能会认为这个上推内核仍然没有计算前缀和,但似乎我们已经找到了并行解决求和问题的解决方案;在这个时候,以下图表将帮助我们了解上推运行期间实际上发生了什么,我们发现稍微展开循环以检查其内存访问模式是有帮助的。

假设我们的数组中有八个元素(即,n = 8),我们的树将有一个深度为3d的范围从02。想象一下,当我们处于d = 0时,通过到2,我们会得到以下表达式:

d = 0 => i = [0..7,2] array[i + 1] = array[i] + array[i + 1]
d = 1 => i = [0..7,4] array[i + 3] = array[i + 1] + array[i + 3]
d = 2 => i = [0..7,8] array[i + 7] = array[i + 3] + array[i + 7]

下一张图表最好地解释了前面表达式的评估,而图片确实比简单的方程更能揭示故事:

准备就绪

从这张图中,我们可以观察到在树的每一层构建了部分和,这里引入的一个效率是避免了任何重复的加法,也就是说,没有冗余。让我们演示这对于一个包含八个元素的数组是如何工作的,我们还将使用 up-sweep 算法。

以下图表说明了我们在扫描的树的每一层发生的写入操作;在该图表中,蓝色框表示在树的每一层构建的部分和,红色框表示最终的总和值:

准备就绪

为了能够从 up-sweep 阶段计算前缀和,我们需要从树的根开始,并使用Guy Blelloch的此算法进行down-sweep

x[n-1]=0
for d = log2 n – 1 to 0 do
  for all k = 0 to n – 1 by 2d+1 in parallel do
    temp = x[k + 2d – 1]
    x[k + 2d – 1] = x[k + 2d+1 – 1]
    x[k + 2d+1 – 1] = temp + x[k + 2d+1 – 1]
  endfor
endfor

这个 down-sweep 在 reduce 阶段之后从树的顶部(或根)开始向下进行,并构建前缀和。让我们简化循环以检查其内存访问模式。

如同之前的 up-sweep 一样,让我们假设我们有八个元素(即,n = 8);我们会有一个深度为3,这意味着d的范围从02。以下是一些简化后的表达式:

d = 2 => k = [0..7,8] 
    temp = x[k + 3]
    x[k + 3] = x[k + 7]
    x[k + 7] = temp + x[k + 7]
d = 1 => k = [0..7,4]
    temp = x[k + 1]
    x[k + 1] = x[k + 3]
    x[k + 3] = temp + k[x + 3]
d = 0 => k = [0..7,2]
    temp = x[k]
    x[k] = x[k + 1]
    x[k + 1] = temp + x[k + 1]

以下图表最好地表达了从 reduce/up-sweep 阶段计算前缀和的方式:

准备就绪

让我们通过以下图表具体化这些想法,看看在 reduce/up-sweep 阶段之后,down-sweep 阶段将如何进行;input数组是原始数组,我们保留它以便您验证根据之前的算法计算的前缀和是否正确。图表的下部说明了内存的访问方式。请注意,更新是在原地进行的,当您结合 up-sweep 和 down-sweep 阶段的图表时,您会注意到我们对原始输入数组进行了两次遍历,以得到前缀和的解,这正是我们想要的:

准备就绪

如何做到这一点 …

我们在这里提出的内核可以在Ch10/RadixSort_GPU/RadixSort.cl中找到,其实施受到了由Mark ZaghaGuy E. Blelloch撰写的学术论文《向量多处理器的基数排序》(Radix Sort for Vector Multiprocessors)的启发,该论文针对 32 位整数。该算法基于 LSD 基数排序,并迭代所有键,根据选择的基数移动键,并按顺序执行 OpenCL 内核;这在前面的图表中描述得最好。

如前所述,我们展示了基于ZaghaBlelloch的 Radix 排序的顺序版本,就像我们之前所做的那样,这是我们的黄金参考,我们将用它来确定 OpenCL 等效数据计算的准确性。我们不会在这里过多地讨论这个实现,但它作为参考点,当我们展示并行和顺序代码的差异时,你可以从中找到相似之处和不同之处:

int radixSortCPU(cl_uint* unsortedData, cl_uint* hSortedData) {

    cl_uint *histogram = (cl_uint*) malloc(R * sizeof(cl_uint));
    cl_uint *scratch = (cl_uint*) malloc(DATA_SIZE * sizeof(cl_uint));

    if(histogram != NULL && scratch != NULL) {

        memcpy(scratch, unsortedData, DATA_SIZE * sizeof(cl_uint));
        for(int bits = 0; bits < sizeof(cl_uint) * bitsbyte ; bits += bitsbyte) {

            // Initialize histogram bucket to zeros
            memset(histogram, 0, R * sizeof(cl_uint));

            // Calculate 256 histogram for all element
            for(int i = 0; i < DATA_SIZE; ++i)
            {
                cl_uint element = scratch[i];
                cl_uint value = (element >> bits) & R_MASK;
                histogram[value]++;
            }

            // Apply the prefix-sum algorithm to the histogram
            cl_uint sum = 0;
            for(int i = 0; i < R; ++i)
            {
                cl_uint val = histogram[i];
                histogram[i] = sum;
                sum += val;
            }

            // Rearrange the elements based on prescanned histogram
            // Thus far, the preceding code is basically adopted from
            // the "counting sort" algorithm.
            for(int i = 0; i < DATA_SIZE; ++i)
            {
                cl_uint element = scratch[i];
                cl_uint value = (element >> bits) & R_MASK;
                cl_uint index = histogram[value];
                hSortedData[index] = scratch[i];
                histogram[value] = index + 1;
            }

            // Copy to 'scratch' for further use since we are not done yet
            if(bits != bitsbyte * 3)
                memcpy(scratch, hSortedData, DATA_SIZE * sizeof(cl_uint));
        }
    }

    free(scratch);
    free(histogram);
    return 1;
}

这段顺序代码类似于我们之前展示的lsd_sort代码,它本质上构建了一个使用计数排序对它们进行排序的键的直方图,并且它会一直这样做,直到所有数据都被处理。

以下内核来自Ch10/RadixSort_GPU/RadixSort.cl,当我们解释算法的内部工作原理时,我们将引用适当的代码:

#define bitsbyte 8
#define R (1 << bitsbyte)

__kernel void computeHistogram(__global const uint* data,
                               __global uint* buckets,
                               uint shiftBy,
                               __local uint* sharedArray) {

    size_t localId = get_local_id(0);
    size_t globalId = get_global_id(0);
    size_t groupId = get_group_id(0);
    size_t groupSize = get_local_size(0);

    /* Initialize shared array to zero i.e. sharedArray[0..63] = {0}*/
    sharedArray[localId] = 0;
    barrier(CLK_LOCAL_MEM_FENCE);

    /* Calculate thread-histograms local/shared memory range from 32KB to 64KB */

    uint result= (data[globalId] >> shiftBy) & 0xFFU;
    atomic_inc(sharedArray+result);

    barrier(CLK_LOCAL_MEM_FENCE);

    /* Copy calculated histogram bin to global memory */

    uint bucketPos = groupId * groupSize + localId ;
    buckets[bucketPos] = sharedArray[localId];
} 
__kernel void rankNPermute(__global const uint* unsortedData,
                           __global const uint* scannedHistogram,
                           uint shiftCount,
                           __local ushort* sharedBuckets,
                           __global uint* sortedData) {

    size_t groupId = get_group_id(0);
    size_t idx = get_local_id(0);
    size_t gidx = get_global_id(0);
    size_t groupSize = get_local_size(0);

    /* There are now GROUP_SIZE * RADIX buckets and we fill
       the shared memory with those prefix-sums computed previously
     */
    for(int i = 0; i < R; ++i)
    {
        uint bucketPos = groupId * R * groupSize + idx * R + i;
        sharedBuckets[idx * R + i] = scannedHistogram[bucketPos];
    }

    barrier(CLK_LOCAL_MEM_FENCE);

    /* Using the idea behind COUNTING-SORT to place the data values in its sorted
       order based on the current examined key
     */
    for(int i = 0; i < R; ++i)
    {
        uint value = unsortedData[gidx * R + i];
        value = (value >> shiftCount) & 0xFFU;
        uint index = sharedBuckets[idx * R + value];
        sortedData[index] = unsortedData[gidx * R + i];
        sharedBuckets[idx * R + value] = index + 1;
        barrier(CLK_LOCAL_MEM_FENCE);
    }
}
__kernel void blockScan(__global uint *output,
                        __global uint *histogram,
                        __local uint* sharedMem,
                        const uint block_size,
                        __global uint* sumBuffer) {
      int idx = get_local_id(0);
      int gidx = get_global_id(0);
      int gidy = get_global_id(1);
      int bidx = get_group_id(0);
      int bidy = get_group_id(1);

      int gpos = (gidx << bitsbyte) + gidy;
      int groupIndex = bidy * (get_global_size(0)/block_size) + bidx;

      /* Cache the histogram buckets into shared memory
         and memory reads into shared memory is coalesced
      */
      sharedMem[idx] = histogram[gpos];
      barrier(CLK_LOCAL_MEM_FENCE);

    /*
       Build the partial sums sweeping up the tree using
       the idea of Hillis and Steele in 1986
     */
    uint cache = sharedMem[0];
    for(int stride = 1; stride < block_size; stride <<= 1)
    {
        if(idx>=stride)
        {
            cache = sharedMem[idx-stride]+block[idx];
        }
        barrier(CLK_LOCAL_MEM_FENCE); // all threads are blocked here

        sharedMem[idx] = cache;
        barrier(CLK_LOCAL_MEM_FENCE);
    }

    /* write the array of computed prefix-sums back to global memory */
    if(idx == 0)
    {
        /* store the value in sum buffer before making it to 0 */
        sumBuffer[groupIndex] = sharedMem[block_size-1];
        output[gpos] = 0;
    }
    else
    {
        output[gpos] = sharedMem[idx-1];
    }
}
__kernel void unifiedBlockScan(__global uint *output,
                               __global uint *input,
                               __local uint* sharedMem,
                               const uint block_size) {

    int id = get_local_id(0);
    int gid = get_global_id(0);
    int bid = get_group_id(0);

    /* Cache the computational window in shared memory */
    sharedMem[id] = input[gid];

    uint cache = sharedMem[0];

    /* build the sum in place up the tree */
    for(int stride = 1; stride < block_size; stride <<= 1)
    {
        if(id>=stride)
        {
            cache = sharedMem[id-stride]+sharedMem[id];
        }
        barrier(CLK_LOCAL_MEM_FENCE);

        sharedMem[id] = cache;
        barrier(CLK_LOCAL_MEM_FENCE);

    }
    /*write the results back to global memory */
    if(tid == 0) {
        output[gid] = 0;
    } else {
        output[gid] = sharedMem[id-1];
    }
}
__kernel void blockPrefixSum(__global uint* output,
                             __global uint* input,
                             __global uint* summary,
                             int stride) {

     int gidx = get_global_id(0);
     int gidy = get_global_id(1);
     int Index = gidy * stride +gidx;
     output[Index] = 0;

      // Notice that you don't need memory fences in this kernel
      // because there is no race conditions and the assumption
      // here is that the hardware schedules the blocks with lower
      // indices first before blocks with higher indices
     if(gidx > 0)
     {
         for(int i =0;i<gidx;i++)
             output[Index] += input[gidy * stride +i];
     }
     // Write out all the prefix sums computed by this block
     if(gidx == (stride - 1))
         summary[gidy] = output[Index] + input[gidy * stride + (stride -1)];
}

__kernel void blockAdd(__global uint* input,
                       __global uint* output,
                       uint stride) {

      int gidx = get_global_id(0);
      int gidy = get_global_id(1);
      int bidx = get_group_id(0);
      int bidy = get_group_id(1);

      int gpos = gidy + (gidx << bitsbyte);

      int groupIndex = bidy * stride + bidx;

      uint temp;
      temp = input[groupIndex];

      output[gpos] += temp;
}
__kernel void mergePrefixSums(__global uint* input,
                        __global uint* output) {

   int gidx = get_global_id(0);
   int gidy = get_global_id(1);
   int gpos = gidy + (gidx << bitsbyte );
   output[gpos] += input[gidy];
}

它是如何工作的…

我们在这里提出的方法是分解键,即把 32 位整数分解成 8 位数字,然后逐个从最低有效位开始排序。基于这个想法,我们将循环四次,在每次循环编号i时,我们将检查i编号的 8 位数字。

根据之前的描述,以下代码给出了基于一般循环结构的代码:

void runKernels(cl_uint* dSortedData, size_t numOfGroups, size_t groupSize) {
   for(int currByte = 0; currByte < sizeof(cl_uint) * bitsbyte; currByte += bitsbyte) {
    computeHistogram(currByte);
    computeBlockScans();
    computeRankingNPermutations(currByte,groupSize);
  }
}

循环中的三个调用是这个实现的“工作马”,它们调用内核根据当前我们正在查看的字节从输入中计算直方图。该算法基本上会计算它检查过的键的直方图;下一阶段是计算前缀和(我们将使用 Hillis 和 Steele 算法来完成这项工作),最后我们将更新数据结构并以排序顺序写出值。让我们详细了解一下它是如何工作的。

在主机代码中,你需要以与我们之前展示的不同方式准备数据结构,因为这些结构需要在主机代码和内核代码之间切换时共享。以下图示说明了runKernels()的一般概念,这种情况是因为我们创建了一个单个命令队列,所有内核都将按照程序顺序附加到它;这也适用于它们的执行:

它是如何工作的…

对于这个实现,需要读取并共享存储未排序数据的数据结构(即unsortedData_d)。因此,你需要使用带有标志CL_MEM_USE_HOST_PTR的设备缓冲区,因为 OpenCL 规范保证它在多个内核调用之间缓存了它。接下来,我们将看看如何在 GPU 上计算直方图。

直方图的计算基于我们在前一章中引入的线程化直方图,但这次我们决定向您展示另一种实现,该实现基于在 OpenCL 中使用原子函数,特别是使用atomic_inc()atomic_inc函数将更新指向该位置的值加一。由于我们选择了使用共享内存,而 CPU 目前还不支持这一点,因此直方图在 OpenCL 支持的 GPU 上工作。策略是将我们的输入数组划分为N x R元素的块,其中R是基数(在我们的情况下R = 8,因为每个数字是 8 位宽,且2⁸=256),而N是执行该块的线程数。这种策略基于我们的问题大小总是会比可用的线程数大得多的假设,我们在启动内核之前在主机代码中程序化地配置它,如下面的代码所示:

void computeHistogram(int currByte) {
    cl_event execEvt;
    cl_int status;
    size_t globalThreads = DATA_SIZE;
    size_t localThreads  = BIN_SIZE;
    status = clSetKernelArg(histogramKernel, 0, sizeof(cl_mem),
             (void*)&unsortedData_d);
    status = clSetKernelArg(histogramKernel, 1, sizeof(cl_mem),
             (void*)&histogram_d);
    status = clSetKernelArg(histogramKernel, 2, sizeof(cl_int),
             (void*)&currByte);
    status = clSetKernelArg(histogramKernel, 3, sizeof(cl_int) *
             BIN_SIZE, NULL);
    status = clEnqueueNDRangeKernel(
        commandQueue,
        histogramKernel,
        1,
        NULL,
        &globalThreads,
        &localThreads,
        0,
        NULL,
        &execEvt);
    clFlush(commandQueue);
    waitAndReleaseDevice(&execEvt);
}

通过将 OpenCL 线程块设置为等于BIN_SIZE,即 256,内核通过轮询 OpenCL 设备以获取其执行状态来等待计算完成;这种轮询-释放机制由waitAndReleaseDevice()封装。

注意

当你有多个内核调用,其中一个内核等待另一个内核时,你需要同步,OpenCL 通过clGetEventInfoclReleaseEvent提供这种同步。

在直方图内核中,我们通过将输入读取到共享内存中(在将其初始化为零之后)来构建直方图,为了防止任何线程在所有数据加载到共享内存之前执行从共享内存中读取的内核代码,我们放置了一个内存屏障,如下所示:

    /* Initialize shared array to zero i.e. sharedArray[0..63] = {0}*/
    sharedArray[localId] = 0;       
    barrier(CLK_LOCAL_MEM_FENCE);   

注意

我们是否应该初始化共享内存是有争议的,但最佳实践是初始化数据结构,就像在其他编程语言中做的那样。在这种情况下,权衡的是程序正确性与浪费处理器周期。

接下来,我们将数据值(位于共享内存中)通过一个数字shiftBy进行位移,这个数字是我们排序的关键,提取字节,然后原子性地更新局部直方图。之后,我们放置了一个内存屏障。最后,我们将分箱值写入全局直方图中的适当位置,你会注意到这种实现执行了我们所说的分散写入

    uint result= (data[globalId] >> shiftBy) & 0xFFU; //5
    atomic_inc(sharedArray+result);                         //6

    barrier(CLK_LOCAL_MEM_FENCE);                           //7

    /* Copy calculated histogram bin to global memory */

    uint bucketPos = groupId  * groupSize + localId ; //8
    buckets[bucketPos] = sharedArray[localId];        //9

一旦建立了直方图,runKernels()函数接下来的任务就是依次执行内核blockScanblockPrefixSumblockAddunifiedBlockScanmergePrefixSums中的前缀和计算。我们将在接下来的章节中解释每个内核的功能。

此阶段的通用策略(封装在computeBlockScans()中)是在预扫描直方图桶,以便为每个桶生成累加和。然后我们将该值写入辅助数据结构sum_in_d,并将所有中间和写入另一个辅助数据结构scannedHistogram_d。以下是我们发送给blockScan内核的配置:

    size_t numOfGroups = DATA_SIZE / BIN_SIZE;
    size_t globalThreads[2] = {numOfGroups, R};
    size_t localThreads[2] = {GROUP_SIZE, 1};
    cl_uint groupSize = GROUP_SIZE;

status = clSetKernelArg(blockScanKernel, 0, sizeof(cl_mem), (void*)&scannedHistogram_d);
    status = clSetKernelArg(blockScanKernel, 1, sizeof(cl_mem), (void*)&histogram_d);
    status = clSetKernelArg(blockScanKernel, 2, GROUP_SIZE * sizeof(cl_uint), NULL);
    status = clSetKernelArg(blockScanKernel, 3, sizeof(cl_uint), &groupSize);
    status = clSetKernelArg(blockScanKernel, 4, sizeof(cl_mem), &sum_in_d);
    cl_event execEvt;
    status = clEnqueueNDRangeKernel(
                commandQueue,
                blockScanKernel,
                2,
                NULL,
                globalThreads,
                localThreads,
                0,
                NULL,
                &execEvt);
    clFlush(commandQueue);
    waitAndReleaseDevice(&execEvt);

扫描背后的通用策略在以下图中得到说明,其中输入被分成单独的块,每个块将提交进行块扫描。生成的结果是累加和,但我们需要整理所有块的结果以获得一个连贯的视图。之后,使用这些累加和值更新直方图桶,然后最终我们可以使用更新的直方图桶对输入数组进行排序。

如何工作…

让我们通过检查blockScan来看看块扫描是如何进行的。首先,我们将之前计算出的直方图桶的值加载到其共享内存中,如下面的代码所示:

__kernel void blockScan(__global uint *output,
                        __global uint *histogram,
                        __local uint* sharedMem,
                        const uint block_size,
                        __global uint* sumBuffer) {
      int idx = get_local_id(0);
      int gidx = get_global_id(0);
      int gidy = get_global_id(1);
      int bidx = get_group_id(0);
      int bidy = get_group_id(1);

      int gpos = (gidx << bitsbyte) + gidy;
      int groupIndex = bidy * (get_global_size(0)/block_size) + bidx;

      /* Cache the histogram buckets into shared memory
         and memory reads into shared memory is coalesced
      */
      sharedMem[idx] = histogram[gpos];
      barrier(CLK_LOCAL_MEM_FENCE);

接下来,我们在本地执行 Hillis 和 Steele 累加和算法,并为当前块构建累加值:

    /*
       Build the partial sums sweeping up the tree using
       the idea of Hillis and Steele in 1986
     */
    uint cache = sharedMem[0];
    for(int dis = 1; dis < block_size; dis <<= 1)
    {
        if(idx>=dis)
        {
            cache = sharedMem[idx-dis]+block[idx];
        }
        barrier(CLK_LOCAL_MEM_FENCE); // all threads are blocked here

        sharedMem[idx] = cache;
        barrier(CLK_LOCAL_MEM_FENCE);
    }

最后,我们将此块的累加和写入sum_in_d,在以下代码中由sumBuffer表示,并将中间累加和写入scannedHistogram_d对象,在此处由output表示:

    /* write the array of computed prefix-sums back to global memory */
    if(idx == 0)
    {
        /* store the value in sum buffer before making it to 0 */
        sumBuffer[groupIndex] = sharedMem[block_size-1];
        output[gpos] = 0;
    } else {        
        output[gpos] = sharedMem[idx-1];
    }
}

以下图说明了两个并行块扫描的概念(假设我们有一个包含八个元素的共享内存)以及它是如何存储到输出中的:

如何工作…

在这个计算阶段,我们已经成功计算了所有单个块的累加和。我们需要在下一个阶段整理它们,该阶段在blockPrefixSum内核中,每个工作项将累加单个块的累加值。每个线程的工作将计算不同块的总和。根据线程 ID,i将收集从块号0(i – 1)的所有总和。以下blockPrefixSum中的代码说明了这个过程:

__kernel void blockPrefixSum(__global uint* output,
                             __global uint* input,
                             __global uint* summary,
                             int stride) {

     int gidx = get_global_id(0);
     int gidy = get_global_id(1);
     int Index = gidy * stride +gidx;
     output[Index] = 0;

     if(gidx > 0) {
         for(int i =0;i<gidx;i++)
             output[Index] += input[gidy * stride +i];
     }

聪明的读者会注意到我们遗漏了一个块的累加和,以下是通过计算此块的最终累积累加和来获得的补救措施:

     // Write out all the prefix sums computed by this block
     if(gidx == (stride - 1))
         summary[gidy] = output[Index] + input[gidy * stride + (stride -1)];

以下图最好地表示了上一个内核代码中的计算过程。它假设我们有一个 16 个元素的块扫描,已经在blockScanKernel中完成,并且每个元素都包含前缀和。为了整理这些和,我们配置内核运行八个线程,步进因子为8(假设块大小为八个),图表达了八个线程各自在做什么。线程通过计算整个输入的求和,逐步计算如何工作…并将它们写入sum_out_dsummary_in_d来整理这些和。

以下是一个图示,说明了给定输入时,该输入的所有元素都是所有块块扫描的求和值;算法基本上将所有内容相加并写入输出数组:

如何工作…

在这一点上,我们必须汇总计算出的中间前缀和,即在sum_out_d中的如何工作…,以及从scannedHistogram_d中的值。我们基本上使用blockAddKernel将这两个中间求和值相加。以下是我们如何准备在启动内核之前:

        cl_event execEvt2;
        size_t globalThreadsAdd[2] = {numOfGroups, R};
        size_t localThreadsAdd[2] = {GROUP_SIZE, 1};
        status = clSetKernelArg(blockAddKernel, 0, sizeof(cl_mem), (void*)&sum_out_d);
        status = clSetKernelArg(blockAddKernel, 1, sizeof(cl_mem), (void*)&scannedHistogram_d);
        status = clSetKernelArg(blockAddKernel, 2, sizeof(cl_uint), (void*)&stride);
        status = clEnqueueNDRangeKernel(
                    commandQueue,
                    blockAddKernel,
                    2,
                    NULL,
                    globalThreadsAdd,
                    localThreadsAdd,
                    0,
                    NULL,
                    &execEvt2);
        clFlush(commandQueue);
        waitAndReleaseDevice(&execEvt2);

然后,我们基本上使用blockAddKernel将这些值汇总回scannedHistogram_d,其代码如下所示:

__kernel void blockAdd(__global uint* input,
                       __global uint* output,
                       uint stride) {

      int gidx = get_global_id(0);
      int gidy = get_global_id(1);
      int bidx = get_group_id(0);
      int bidy = get_group_id(1);

      int gpos = gidy + (gidx << bitsbyte);

      int groupIndex = bidy * stride + bidx;

      uint temp;
      temp = input[groupIndex];

      output[gpos] += temp;
}

最后,我们执行另一个前缀和操作,以汇总summary_in_d中的值,因为该数组中的所有元素都包含每个单独块的求和值。由于我们选择的基数值是256,我们需要计算出从块0y的前缀和计算,通过如何工作…如何工作…。这在一个以下图中展示,并且封装在unifiedBlockScan内核中。我们不会展示内核代码,因为它与blockPrefixSum内核类似。

如何工作…

在此时刻,我们剩下将之前执行的前缀和写入scannedHistogram_d。这种汇总练习与之前的汇总不同,我们是在块之间收集中间前缀和,但尽管如此,它仍然是一个汇总练习,我们需要将summary_in_d中的值推入。我们通过mergePrefixSumsKernel完成了这项工作,以下是其主机代码的反映:

        cl_event execEvt4;
        size_t globalThreadsOffset[2] = {numOfGroups, R};
        status = clSetKernelArg(mergePrefixSumsKernel, 0, sizeof(cl_mem), (void*)&summary_out_d);
        status = clSetKernelArg(mergePrefixSumsKernel, 1, sizeof(cl_mem), (void*)&scannedHistogram_d);
        status = clEnqueueNDRangeKernel(commandQueue, mergePrefixSumsKernel, 2, NULL, globalThreadsOffset, NULL, 0, NULL, &execEvt4);
        clFlush(commandQueue);
        waitAndReleaseDevice(&execEvt4);

mergePrefixSumsKernel练习是一个相对简单的练习,使用以下内核代码将值移到它们正确的位置:

__kernel void mergePrefixSums(__global uint* input,
                              __global uint* output) {

   int gidx = get_global_id(0);
   int gidy = get_global_id(1);
   int gpos = gidy + (gidx << bitsbyte );
   output[gpos] += input[gidy];
}

通过这种方式,前缀和被正确计算。算法的下一阶段将是使用每个工作项/线程对键进行排序和排列,每个工作项/线程通过预先扫描的直方图桶排列其 256 个元素,封装在computeRankNPermutations()中。以下是为内核启动的主机代码:

void computeRankingNPermutations(int currByte, size_t groupSize) {
    cl_int status;
    cl_event execEvt;

    size_t globalThreads = DATA_SIZE/R;
    size_t localThreads = groupSize;

    status = clSetKernelArg(permuteKernel, 0, sizeof(cl_mem), (void*)&unsortedData_d);
    status = clSetKernelArg(permuteKernel, 1, sizeof(cl_mem), (void*)&scannedHistogram_d);
    status = clSetKernelArg(permuteKernel, 2, sizeof(cl_int), (void*)&currByte);
    status = clSetKernelArg(permuteKernel, 3, groupSize * R * sizeof(cl_ushort), NULL); // shared memory
    status = clSetKernelArg(permuteKernel, 4, sizeof(cl_mem), (void*)&sortedData_d);

    status = clEnqueueNDRangeKernel(commandQueue, permuteKernel, 1, NULL, &globalThreads, &localThreads, 0, NULL, &execEvt);
    clFlush(commandQueue);
    waitAndReleaseDevice(&execEvt);

一旦内核成功完成,数据值将是有序的,并将由sortedData_d保存在设备内存中。我们需要将这些数据再次复制到unsortedData_d中,我们将继续这样做,直到我们完成键的迭代。

rankNPermute内核中,我们再次利用共享内存。将数据放入共享内存中,数据组织为GROUP_SIZE * RADIX,其中GROUP_SIZE = 64RADIX = 256表达式成立,并且由于每个工作组被配置为使用 64 个线程执行,我们基本上有一个线程填充其共享内存中的 256 个元素(以下代码片段演示了这一点):

__kernel void rankNPermute(__global const uint* unsortedData,
                           __global const uint* scannedHistogram,
                           uint shiftCount,
                           __local ushort* sharedBuckets,
                           __global uint* sortedData) {
    size_t groupId = get_group_id(0);
    size_t idx = get_local_id(0);
    size_t gidx = get_global_id(0);
    size_t groupSize = get_local_size(0);
    for(int i = 0; i < R; ++i) {
        uint bucketPos = groupId * R * groupSize + idx * R + i;
        sharedBuckets[idx * R + i] = scannedHistogram[bucketPos];
    }
    barrier(CLK_LOCAL_MEM_FENCE);

接下来,它根据与顺序算法中相同的思想对元素进行排序,你现在应该回顾一下那个算法。不同之处在于,我们从全局设备内存中的unsortedData中提取数据值,在设备内存中处理它们,确定值应该放在哪里,并将它们写入到sortedData中:

    for(int i = 0; i < R; ++i) {
        uint value = unsortedData[gidx * R + i];
        value = (value >> shiftCount) & 0xFFU;
        uint index = sharedBuckets[idx * R + value];
        sortedData[index] = unsortedData[gidx * R + i];
        sharedBuckets[idx * R + value] = index + 1;
        barrier(CLK_LOCAL_MEM_FENCE);
    }

排序和排列完成后,sortedData_d对象中的数据值将根据当前检查的键进行排序。算法会将sortedData_d中的数据复制到unsortedData_d中,以便整个过程可以重复进行,总共四次。

posted @ 2025-10-23 15:11  绝不原创的飞龙  阅读(38)  评论(0)    收藏  举报