C---数据并行教程-全-

C++ 数据并行教程(全)

原文:Data Parallel C++

协议:CC BY-NC-SA 4.0

一、介绍

本章通过涵盖核心概念(包括术语)奠定了基础,当我们学习如何使用数据并行性来加速 C++ 程序时,这些核心概念对于保持头脑中的新鲜感至关重要。

img/489625_1_En_1_Figa_HTML.png

This chapter lays the foundation by covering core concepts, including terminology, that are critical to have fresh in our minds as we learn how to accelerate C++ programs using data parallelism.

C++ 中的数据并行性支持在现代异构系统中访问并行资源。单个 C++ 应用程序可以使用任何设备组合,包括 GPU、CPU、FPGAs 和人工智能专用集成电路(ASICs),这些设备组合都适用于当前的问题。

这本书教授使用 C++ 和 SYCL 的数据并行编程。

SYCL(读作镰刀)是一个行业驱动的 Khronos 标准,它为异构系统的 C++ 增加了数据并行性。SYCL 程序在与支持 SYCL 的 C++ 编译器(如本书中使用的开源数据并行 C++ (DPC++ ))编译器)配合使用时性能最佳。SYCL 不是首字母缩写词;SYCL 只是一个名字。

DPC++ 是一个开源编译器项目,最初由英特尔员工创建,致力于在 C++ 中大力支持数据并行。DPC++ 编译器基于 SYCL、一些扩展、 1 和广泛的异构支持,包括 GPU、CPU 和 FPGA 设备。除了开源版本的 DPC++ 之外,英特尔 oneAPI 工具包中还提供了商业版本。

基于 SYCL 实现的特性受到 DPC++ 编译器的开源和商业版本的支持。本书中的所有例子都可以用或者版本的 DPC++ 编译器编译和工作,而且几乎所有例子都可以用最新的 SYCL 编译器编译。在发布时,我们会仔细注意哪些地方使用了特定于 DPC++ 的扩展。

读这本书,而不是说明书

没有人想被告知“去读说明书吧!”规范很难读懂,SYCL 规范也不例外。像每一个伟大的语言规范一样,它在动机、用法和教学方面都非常精确和简洁。这本书是教授 SYCL 和使用 DPC++ 编译器的“学习指南”。

正如序言中提到的,这本书无法一次性解释所有的事情。因此,这一章做了其他章节都不会做的事情:代码示例包含的编程结构在以后的章节中才会解释。我们应该试着不要完全理解第一章中的代码示例,相信每一章都会变得更好。

SYCL 1.2.1 与 SYCL 2020 和 DPC++ 的对比

在本书付印之际,SYCL 2020 临时规范已经公开征求意见。随着时间的推移,将会出现当前 SYCL 1.2.1 标准的继任者。这个预期的继任者被非正式地称为 SYCL 2020。虽然说这本书教授 SYCL 2020 很好,但这是不可能的,因为该标准尚不存在。

这本书教授 SYCL 扩展,以估计 SYCL 在未来的位置。这些扩展是在 DPC++ 编译器项目中实现的。几乎所有在 DPC++ 中实现的扩展都是临时 SYCL 2020 规范中的新特性。DPC++ 支持的值得注意的新特性是 USM、子组、C++17 支持的语法简化(称为 class 类模板参数演绎),以及无需命名即可使用匿名 lambdas 的能力。

在发布时,没有任何 SYCL 编译器(包括 DPC++)实现了 SYCL 2020 临时规范中的所有功能。

本书中使用的一些特性是特定于 DPC++ 编译器的。其中许多特性最初是英特尔对 SYCL 的扩展,后来被纳入 SYCL 2020 临时规范,在某些情况下,它们的语法在标准化过程中略有变化。其他功能仍在开发或讨论中,可能会包含在未来的 SYCL 标准中,它们的语法也可能类似地被修改。在语言开发过程中,这样的语法变化实际上是非常可取的,因为我们希望特性不断发展和改进,以满足更广泛的开发人员群体的需求和各种设备的功能。本书中的所有代码示例都使用 DPC++ 语法来确保与 DPC++ 编译器的兼容性。

在努力接近 SYCL 的发展方向的同时,随着标准的发展,几乎肯定需要对本书中的信息进行调整,以与标准保持一致。更新信息的重要资源包括 GitHub 一书和勘误表,可从该书的网页(www . a press . com/9781484255735)以及在线 oneAPI DPC++ 语言参考( tinyurl. com/ dpcppref )中找到。

获得 DPC++ 编译器

DPC++ 可以从 GitHub 资源库( github. com/ intel/ llvm )获得。可以在Intel . GitHub . io/llvm-docs/GetStartedGuide . html找到 DPC++ 入门指南,包括如何使用 GitHub 的克隆版本构建开源编译器。

还有 DPC++ 编译器的捆绑版本,增加了用于 DPC++ 编程和支持的其他工具和库,作为更大的 oneAPI 项目的一部分提供。该项目带来了对异构系统的广泛支持,包括库、调试器和其他工具,称为 oneAPI。包括 DPC++ 在内的 oneAPI 工具都是免费提供的(oneAPI . com/implementations)。官方 oneAPI DPC++ 编译器文档,包括扩展列表,可以在Intel . github . io/llvm-docs找到。

这本书的在线伴侣,oneAPI DPC++ 语言参考 online ,是一个很好的资源,可以在这本书的基础上获得更多正式的细节。

GitHub 图书

很快我们会遇到图 1-1 中的代码。如果我们想避免全部键入,我们可以很容易地从 GitHub 存储库中下载本书中的所有示例(www . a press . com/9781484255735—寻找本书的服务:源代码)。该存储库包括带有构建文件的完整代码,因为大多数代码清单省略了重复的或不必要的细节。存储库中有这些例子的最新版本,如果有更新的话,这是很方便的。

img/489625_1_En_1_Fig1_HTML.png

图 1-1

你好数据并行编程

你好,世界!和 SYCL 程序剖析

图 1-1 显示了一个样本 SYCL 程序。使用 DPC++ 编译器编译并运行它,会打印出以下内容:

你好,世界!(还有一些额外的文本留给运行它的人去体验)

在第四章结束时,我们会完全理解这个特殊的例子。在此之前,我们可以观察定义所有 SYCL 构造所需的<CL/sycl.hpp>(第 1 行)的单个 include。所有 sycl 构造都存在于一个名为 SYCL 的名称空间中:

  • 第 3 行让我们避免一遍又一遍地写sycl::

  • 第 11 行为指向特定设备的工作请求建立了一个队列(第二章)。

  • 第 13 行为与设备共享的数据创建一个分配(第三章)。

  • 第 16 行将工作排入设备队列(第章第 4 )。

  • 第 17 行是将在设备上运行的唯一一行代码。所有其他代码都在主机(CPU)上运行。

第 17 行是我们希望在设备上运行的内核代码。内核代码减少一个字符。借助于parallel_for()的能力,内核在我们的秘密字符串中的每个字符上运行,以便将它解码成result字符串。所需的工作没有顺序,一旦parallel_for将工作排队,它实际上相对于主程序异步运行。在查看结果之前有一个等待(第 18 行)是很关键的,以确保内核已经完成,因为在这个特定的例子中,我们使用了一个方便的特性(统一共享内存,第六章)。如果没有等待,输出可能会在所有字符被解密之前发生。还有更多要讨论的,但那是后面章节的工作。

队列和操作

第二章将讨论队列和动作,但是我们现在可以从一个简单的解释开始。队列是唯一允许应用程序在设备上直接完成工作的连接。有两种类型的操作可以放入队列中:(a)要执行的代码和(b)内存操作。要执行的代码通过single_taskparallel_for(用于图 1-1 )或parallel_for_work_group来表示。内存操作执行主机和设备之间的复制操作或填充操作来初始化内存。如果我们寻求比自动为我们做的更多的控制,我们只需要使用内存操作。这些都将在本书后面从第二章开始讨论。现在,我们应该意识到,队列是允许我们命令设备的连接,我们有一组可用于放入队列的操作来执行代码和移动数据。理解被请求的动作被放入队列而不等待也是非常重要的。在将动作提交到队列中之后,主机继续执行程序,而设备将最终异步地执行通过队列请求的动作。

队列将我们与设备联系起来。

我们将动作提交到这些队列中,请求计算工作和数据移动。

动作异步发生。

这完全是关于并行性

因为用 C++ 进行数据并行编程完全是关于并行性的,所以让我们从这个关键概念开始。并行编程的目标是更快地计算一些东西。事实证明这有两个方面:增加吞吐量减少延迟

生产能力

当我们在一定的时间内完成更多的工作时,程序的吞吐量就会增加。像流水线这样的技术实际上可能会延长完成一项工作所需的时间,以允许工作重叠,从而导致单位时间内完成更多的工作。人类在一起工作时经常会遇到这种情况。分担工作的行为本身就包含了协调的开销,这通常会拖慢做一件事情的时间。然而,多人的力量导致更多的吞吐量。计算机也不例外——将工作分散到更多的处理核心会增加每个工作单元的开销,这可能会导致一些延迟,但目标是完成更多的总工作,因为我们有更多的处理核心一起工作。

潜伏

如果我们想更快地完成一件事——例如,分析一个语音命令并制定一个响应,该怎么办?如果我们只关心吞吐量,响应时间可能会变得难以忍受。减少延迟的概念要求我们将一项工作分解成可以并行处理的部分。对于吞吐量,图像处理可能会将整个图像分配给不同的处理单元,在这种情况下,我们的目标可能是优化每秒图像数。对于延迟,图像处理可能会将图像中的每个像素分配给不同的处理核心,在这种情况下,我们的目标可能是最大化单幅图像每秒的像素。

平行思考

成功的并行程序员在他们的编程中使用这两种技术。这是我们寻求平行思考的开始。

我们希望调整我们的思维,首先考虑在我们的算法和应用程序中哪里可以找到并行性。我们还会思考表达并行性的不同方式如何影响我们最终实现的性能。那是一次要接受的很多东西。寻求思考并行成为并行程序员一生的旅程。我们可以在这里学到一些技巧。

阿姆达尔和古斯塔夫森

阿姆达尔定律是由超级计算机先驱吉恩·阿姆达尔在 1967 年提出的,是一个预测使用多个处理器时理论上最大加速的公式。Amdahl 哀叹道,并行性的最大收益受限于(1/(1-p)),其中p是并行运行的程序的一部分。如果我们只并行运行程序的三分之二,那么程序最多可以加速 3 倍。我们绝对需要这个概念深入人心!这是因为无论我们让程序的三分之二运行得多快,另外三分之一仍然需要同样的时间来完成。即使我们添加 100 个 GPU,我们也只能获得 3 倍的性能提升。

多年来,一些人认为这证明了并行计算不会有成效。1988 年,约翰·古斯塔夫森发表了一篇题为“重新评估阿姆达尔定律”的文章。他观察到并行性不是用来加速固定工作负载的,而是用来支持工作的扩展。人类也经历同样的事情。一个送货人不可能在更多人和卡车的帮助下更快地运送一个包裹。然而,一百个人和一辆卡车可以比一个司机驾驶一辆卡车更快地运送一百个包裹。多个驱动程序肯定会增加吞吐量,通常还会减少包裹交付的延迟。阿姆达尔定律告诉我们,一个司机不可能通过增加九十九个司机自己的卡车来更快地运送一个包裹。古斯塔夫森注意到,有了这些额外的司机和卡车,就有机会更快地运送 100 个包裹。

缩放比例

“缩放”一词出现在我们之前的讨论中。缩放是一种衡量当额外计算可用时程序加速多少(简称为“加速”)的方法。如果 100 个包裹与一个包裹在同一时间交付,只要有 100 辆卡车和司机,而不是一辆卡车和司机,就可以实现完美的加速。当然,事实并非如此。在某种程度上,存在一个限制速度提升的瓶颈。配送中心可能没有一百个地方供卡车停靠。在计算机程序中,瓶颈通常涉及到将数据移动到需要处理的地方。向一百辆卡车分发数据类似于向一百个处理核心分发数据。分发的行为不是瞬间的。第三章将开始我们探索如何在异构系统中将数据分布到需要的地方的旅程。我们必须知道数据分发是有成本的,而这种成本会影响我们对应用程序的可伸缩性的预期。

异构系统

短语“异构系统”偷偷溜进了前一段。出于我们的目的,异构系统是任何包含多种类型的计算设备的系统。例如,具有中央处理单元(CPU)和图形处理单元(GPU)的系统是异构系统。CPU 通常只是被称为处理器,尽管当我们把异构系统中的所有处理单元都称为计算处理器时,这可能会令人混淆。为了避免混淆,SYCL 将处理单元称为设备。第二章将开始讨论如何将工作(计算)导向异构系统中的特定设备。

GPU 已经发展成为高性能计算设备,因此有时被称为通用 GPU 或 GPGPUs。出于异构编程的目的,我们可以简单地假设我们正在编写这样强大的 GPGPUs,并将它们称为 GPU。

今天,异构系统中的设备集合可以包括 CPU、GPU、FPGAs(现场可编程门阵列)、DSP(数字信号处理器)、ASICs(专用集成电路)和 AI 芯片(图形、神经形态等)。).

这种设备的设计通常包括复制计算处理器(多处理器)和增加与存储器等数据源的连接(增加带宽)。第一种,多重处理,对于提高吞吐量特别有用。在我们的类比中,这是通过增加额外的司机和卡车来完成的。后者,更高的数据带宽,对于减少延迟特别有用。在我们的类比中,这是通过更多的装载码头来实现的,以使卡车能够平行满载。

拥有多种类型的设备,每种设备具有不同的架构,因此具有不同的特性,这导致每种设备具有不同的编程和优化需求。这成为 SYCL、DPC++ 编译器以及本书大部分内容的动机。

SYCL 的创建是为了应对异构系统的 C++ 数据并行编程的挑战。

数据并行编程

从这本书的标题开始,“数据并行编程”这个短语就一直没有得到解释。数据并行编程侧重于并行性,可以将并行性想象为一组并行操作的数据。这种重心的转移就像古斯塔夫森对阿姆达尔。我们需要交付 100 个包(实际上是大量数据),以便在 100 辆卡车和司机之间分配工作。关键概念归结为我们应该划分什么。我们应该处理整个图像还是在更小的图块中处理它们还是逐个像素地处理它们?我们应该将一组对象作为一个单独的集合来分析,还是作为一组更小的对象组来分析,还是逐个对象地分析?

任何使用 SYCL 和 DPC++ 的并行程序员都有责任选择正确的工作分工,并将工作有效地映射到计算资源上。第四章开始了这一讨论,并贯穿全书的其余部分。

DPC++ 和 SYCL 的关键属性

每个 DPC++(或 SYCL)程序也是一个 C++ 程序。SYCL 和 DPC++ 都不依赖于 C++ 的任何语言变化。两者都可以用模板和 lambda 函数完全实现。

SYCL 编译器 2 存在的原因是以一种依赖于 SYCL 规范的内置知识的方式来优化代码。缺乏任何 SYCL 内置知识的标准 C++ 编译器无法获得与支持 SYCL 的编译器相同的性能水平。

接下来,我们将检查 DPC++ 和 SYCL 的关键属性:单源样式、主机、设备、内核代码和异步任务图。

单源

程序可以是单源的,这意味着同一个翻译单元 3 既包含定义要在设备上执行的计算内核的代码,也包含协调这些计算内核的执行的主机代码。第二章从更详细地了解这种能力开始。如果我们愿意,我们仍然可以将我们的程序源分成不同的文件和主机和设备代码的翻译单元,但关键是我们不必这样做!

圣体

每个程序都是从在主机上运行开始的,程序中的大部分代码通常是给主机的。迄今为止,主机一直是 CPU。标准对此没有要求,所以我们小心翼翼地将其描述为主机。这似乎不太可能是 CPU 以外的任何东西,因为主机需要完全支持 C++17 才能支持所有的 DPC++ 和 SYCL 程序。我们很快就会看到,设备不需要支持所有的 C++17。

设备

在一个程序中使用多个设备是异构编程的原因。这就是为什么自从几页前对异构系统的解释以来,设备这个词一直在本章中反复出现。我们已经了解到,异构系统中的设备集合可以包括 GPU、FPGAs、DSP、ASICs、CPU 和 AI 芯片,但不限于任何固定列表。

设备是 SYCL 承诺的加速卸载的目标。卸载计算的想法通常是将工作转移到可以加速工作完成的设备。我们不得不担心弥补移动数据所损失的时间,这是一个需要我们不断思考的话题。

共享设备

在一个有设备的系统上,比如一个 GPU,我们可以想象两个或者更多的程序正在运行并且想要使用一个设备。它们不必是使用 SYCL 或 DPC++ 的程序。如果另一个程序正在使用该设备,则该设备在处理程序时可能会遇到延迟。这与 C++ 程序中通常用于 CPU 的原理是一样的。如果我们在 CPU 上运行太多活动程序(邮件、浏览器、病毒扫描、视频编辑、照片编辑等),任何系统都可能过载。)一下子。

在超级计算机上,当节点(CPUs 所有连接的设备)被专门授予单个应用程序时,共享通常不是一个问题。在非超级计算机系统上,我们可以注意到,如果有多个应用程序同时使用相同的设备,数据并行 C++ 程序的性能可能会受到影响。

一切仍然工作,没有我们需要做不同的编程。

内核代码

设备的代码被指定为内核。这不是 SYCL 或 DPC++ 独有的概念:它是其他卸载加速语言(包括 OpenCL 和 CUDA)的核心概念。

内核代码有一定的限制,以允许更广泛的设备支持和大规模并行。内核代码中不支持的特性列表包括动态多态、动态内存分配(因此没有使用 new 或 delete 操作符的对象管理)、静态变量、函数指针、运行时类型信息(RTTI)和异常处理。不允许从内核代码中调用虚拟成员函数和变量函数。内核代码中不允许递归。

第三章将描述如何在内核被调用之前和之后进行内存分配,从而确保内核专注于大规模并行计算。第五章将描述与设备相关的异常处理。

C++ 的其余部分是内核中的公平游戏,包括 lambdas、操作符重载、模板、类和静态多态。我们还可以与主机共享数据(参见第三章)并共享(非全局)主机变量的只读值(通过 lambda 捕获)。

内核:向量加法(DAXPY)

任何从事计算复杂代码工作的程序员都应该对内核很熟悉。考虑实现 DAXPY,它代表“双精度 A 乘以 X 加 Y”,这是几十年来的经典。图 1-2 显示了用现代 Fortran、C/C++ 和 SYCL 实现的 DAXPY。令人惊讶的是,计算行(第 3 行)实际上是相同的。第 4 和 10 章将详细解释内核。图 1-2 应该有助于消除对内核难以理解的任何担忧——即使术语对我们来说是新的,它们也应该感觉熟悉。

img/489625_1_En_1_Fig2_HTML.png

图 1-2

Fortran、C++ 和 SYCL 中的 DAXPY 计算

异步任务图

使用 SYCL/DPC++ 编程的异步特性必须而不是被忽略。理解异步编程是至关重要的,原因有两个:(1)正确的使用会给我们带来更好的性能(更好的伸缩性),以及(2)错误会导致并行编程错误(通常是竞争条件),使我们的应用程序不可靠。

异步的本质是因为工作是通过请求动作的“队列”转移到设备上的。宿主程序将请求的动作提交到一个队列中,程序继续运行,不等待任何结果。这个无等待很重要,这样我们就可以努力让计算资源(设备和主机)一直保持忙碌。如果我们必须等待,那将会束缚主机,而不是让主机做有用的工作。它还会在设备完成时产生串行瓶颈,直到我们排队等待新的工作。如前所述,阿姆达尔定律惩罚我们没有平行工作的时间。我们需要构建我们的程序,以便在设备繁忙时将数据移入和移出设备,并在工作可用时保持设备和主机的所有计算能力繁忙。如果做不到这一点,将会给我们带来阿姆达尔法则的诅咒。

第四章将开始讨论把我们的程序想成一个异步任务图,第八章大大扩展了这个概念。

我们犯错时的竞争条件

在我们的第一个代码示例(图 1-1 )中,我们特别在第 18 行做了一个“等待”,以防止第 20 行在result的值可用之前将其写出。我们必须记住这种异步行为。在同一个代码示例中还做了另一件微妙的事情——第 14 行使用std::memcpy来加载输入。因为std::memcpy在主机上运行,所以第 16 行和之后的代码直到第 15 行完成后才执行。在阅读完第三章之后,我们可能会尝试将其改为使用myQ.memcpy(使用 SYCL)。我们已经在第 8 行的图 1-3 中完成了。因为这是一个队列提交,所以不能保证它会在第 10 行之前完成。这就产生了一个竞争条件,这是一种并行编程错误。当程序的两个部分不协调地访问相同的数据时,就存在争用情况。因为我们希望使用第 8 行写入数据,然后在第 10 行读取数据,所以我们不希望出现第 17 行在第 8 行完成之前执行的竞争!这样的竞争条件会使我们的程序不可预测——我们的程序可能在不同的运行和不同的系统上得到不同的结果。解决这个问题的方法是通过在第 8 行末尾添加.wait()来明确等待myQ.memcpy完成后再继续。这不是最好的解决办法。我们可以使用事件依赖来解决这个问题(第八章)。将队列创建为有序队列还会在memcpyparallel_for.之间添加一个隐含的依赖关系。作为替代,在第七章中,我们将看到如何使用缓冲区和访问器编程风格来让 SYCL 管理依赖关系并自动等待我们。

img/489625_1_En_1_Fig3_HTML.png

图 1-3

添加一个竞争条件来说明关于异步的一点

添加一个wait()强制在memcpy和内核之间进行主机同步,这与之前让设备一直忙碌的建议背道而驰。本书的大部分内容涵盖了不同的选项和权衡,平衡了程序的简单性和系统的有效使用。

为了帮助检测程序(包括内核)中的数据争用情况,Intel Inspector(在“获取 DPC++ 编译器”中提到的 oneAPI 工具中提供)等工具可能会有所帮助。这些工具使用的有些复杂的方法通常不能在所有设备上工作。检测竞争条件最好的方法是让所有的内核都在一个 CPU 上运行,这可以作为开发工作中的一种调试技术来完成。这个调试技巧在第二章中作为方法#2 讨论。

第四章会告诉我们“lambdas 不被认为是有害的。”为了更好地使用 DPC++、SYCL 和现代 C++,我们应该熟悉 lambda 函数。

C++ Lambda 函数

并行编程技术大量使用的现代 C++ 的一个特性是 lambda 函数。内核(在设备上运行的代码)可以有多种表达方式,最常见的是 lambda 函数。第十章讨论了内核可以采取的各种形式,包括 lambda 函数。在这里,我们复习了 C++ lambda 函数以及一些关于定义内核的注意事项。第十章在我们在中间章节中学习了更多关于 SYCL 的知识后,将详细阐述内核方面。

图 1-3 中的代码具有 lambda 函数。我们可以看到它,因为它从非常确定的[=]开始。在 C++ 中,lambda 以方括号开始,右方括号前的信息表示如何捕获在 lambda 中使用的变量,但这些变量没有作为参数显式传递给它。对于内核,捕获必须是值为的,这由括号内包含的等号表示。

对 lambda 表达式的支持是在 C++11 中引入的。它们用于创建匿名函数对象(尽管我们可以将它们赋给命名变量),这些对象可以从封闭范围中捕获变量。C++ lambda 表达式的基本语法是

img/489625_1_En_1_Figb_HTML.png

在哪里

  • 捕获列表是一个逗号分隔的捕获列表。我们通过在捕获列表中列出变量名来按值捕获变量。我们通过引用捕获一个变量,在它前面加上一个&符号,例如,&v 还有适用于所有作用域内自动变量的简写:[=]用于通过值和通过引用捕获主体中使用的所有自动变量,[&]用于通过引用捕获主体和当前对象中使用的所有自动变量,[]什么都不捕获。在 SYCL 中,[=]几乎总是被使用,因为在内核中不允许通过引用来捕获变量。根据 C++ 标准,全局变量在 lambda 中不是被捕获的。非全局静态变量可以在内核中使用,但是只能在const中使用。

  • params是函数参数列表,就像命名函数一样。SYCL 提供了参数来标识内核被调用来处理的元素:这可以是唯一的 id(一维的)或 2D 或 3D id。这些将在第四章中讨论。

  • ret是返回类型。如果未指定->ret,则从返回语句中推断出来。缺少 return 语句,或者 return 没有值,意味着 return 类型为void。SYCL 内核必须总是有一个返回类型void,所以我们不应该用这个语法来指定内核的返回类型。

  • body是函数体。对于 SYCL 内核,这个内核的内容有一些限制(参见本章前面的“内核代码”一节)。

图 1-4 显示了一个 C++ lambda 表达式,它通过值捕捉一个变量i,通过引用捕捉另一个变量j。它还有一个参数k0和另一个通过引用接收的参数l0。运行该示例将产生如图 1-5 所示的输出。

img/489625_1_En_1_Fig5_HTML.png

图 1-5

图 1-4 中 lambda 函数演示代码的输出

img/489625_1_En_1_Fig4_HTML.png

图 1-4

C++ 代码中的 Lambda 函数

我们可以把 lambda 表达式看作一个函数对象的实例,但是编译器为我们创建了类定义。例如,我们在前面的例子中使用的 lambda 表达式类似于图 1-6 中所示的类的实例。无论我们在哪里使用 C++ lambda 表达式,我们都可以用一个函数对象的实例来代替它,如图 1-6 所示。

img/489625_1_En_1_Fig6_HTML.png

图 1-6

函数对象而不是 lambda(在第十章中有更多关于这方面的内容)

每当我们定义一个函数对象时,我们都需要给它赋一个名字(图 1-6 中的函子)。内嵌表达的 Lambdas(如图 1-4 所示)是匿名的,因为它们不需要名字。

可移植性和直接编程

可移植性是 SYCL 和 DPC++ 的一个关键目标;但是,两者都不能保证。一门语言和编译器所能做的就是当我们想在应用程序中实现可移植性时,让它变得更容易一些。

可移植性是一个复杂的话题,包括功能可移植性性能可移植性的概念。有了功能上的可移植性,我们希望我们的程序可以在各种各样的平台上同等地编译和运行。有了性能可移植性,我们希望我们的程序能在各种平台上获得合理的性能。虽然这是一个相当软的定义,但反过来可能更清楚——我们不希望编写一个在一个平台上运行超快的程序,却发现它在另一个平台上慢得不合理。事实上,我们更希望它能充分利用运行它的任何平台。考虑到异构系统中各种各样的设备,性能可移植性需要我们作为程序员付出巨大的努力。

幸运的是,SYCL 定义了一种可以提高性能可移植性的编码方式。首先,通用内核可以在任何地方运行。在有限的情况下,这可能就足够了。更常见的是,可以为不同类型的设备编写几个版本的重要内核。具体来说,一个内核可能有一个通用的 GPU 版本和一个通用的 CPU 版本。有时候,我们可能想为特定的设备(比如特定的 GPU)专门化我们的内核。当这种情况发生时,我们可以编写多个版本,并针对不同的 GPU 模型进行专门化。或者我们可以参数化一个版本,使用 GPU 的属性来修改我们的 GPU 内核如何运行,以适应现有的 GPU。

当我们作为程序员自己负责设计一个有效的性能移植计划时,SYCL 定义了允许我们实现计划的结构。如前所述,功能可以分层,首先为所有设备提供一个内核,然后根据需要逐渐引入更多、更专业的内核版本。这听起来很棒,但是程序的整体流程也会产生深远的影响,因为数据移动和整体算法选择很重要。了解了这一点,就能理解为什么没有人会声称 SYCL(或其他直接编程解决方案)解决了性能可移植性。然而,它是我们工具箱中帮助我们应对这些挑战的工具。

并发性与并行性

术语并发平行是不等价的,尽管它们有时会被误解。重要的是要知道,并发性所需的任何编程考虑对于并行性也很重要。

术语并发指的是可以前进但不一定在同一时刻的代码。在我们的计算机上,如果我们有一个打开的Mail程序和一个Web Browser,那么它们是并发运行的。在只有一个处理器的系统上,通过时间分片过程(在运行每个程序之间快速来回切换),可以发生并发。

Tip

并发性所需的任何编程考虑对于并行性也很重要。

术语并行是指代码可以在同一时刻前进。并行性要求系统实际上一次可以做多件事情。异构系统总是可以并行地做事情,这是由它至少具有两个计算设备的本质决定的。当然,SYCL 程序不需要异构系统,因为它可以在只有主机的系统上运行。今天,任何主机系统都不可能不具备并行执行的能力。

代码的并发执行通常面临与代码的并行执行相同的问题,因为任何特定的代码序列都不能假定它是改变世界(数据位置、I/O 等)的唯一代码。).

摘要

本章提供了 SYCL 和 DPC++ 所需的术语,并提供了对 SYCL 和 DPC++ 至关重要的并行编程和 C++ 的关键方面的更新。第 2 、 3 和 4 章详细阐述了 SYCL 编程的三个关键:需要给设备分配工作(发送代码以在其上运行)、提供数据(发送数据以在其上使用)以及拥有编写代码的方法(内核)。

Creative Commons

开放存取本章根据知识共享署名 4.0 国际许可证(http://Creative Commons . org/licenses/by/4.0/)的条款获得许可,该许可证允许以任何媒体或格式使用、共享、改编、分发和复制,只要您适当注明原作者和来源,提供知识共享许可证的链接并指明是否进行了更改。

本章中的图像或其他第三方材料包含在本章的知识共享许可中,除非在材料的信用额度中另有说明。如果材料不包括在本章的知识共享许可中,并且您的预期使用不被法律法规允许或超出了允许的使用范围,您将需要直接从版权所有者处获得许可。

二、代码执行的地方

img/489625_1_En_2_Figa_HTML.gif

并行编程并不是真的在快车道上行驶。这实际上是在 ?? 所有的车道上开快车。这一章是关于让我们能够把我们的代码放在我们能放的任何地方。只要有意义,我们就会选择启用异构系统中的所有计算资源。因此,我们需要知道这些计算资源藏在哪里(找到它们),并让它们发挥作用(在其上执行我们的代码)。

我们可以控制代码在哪里执行——换句话说,我们可以控制哪些设备用于哪些内核。SYCL 为异构编程提供了一个框架,其中代码可以在主机 CPU 和设备上混合执行。决定代码在哪里执行的机制对于我们理解和使用非常重要。

本章描述了代码可以在哪里执行,何时执行,以及用于控制执行位置的机制。第三章将描述如何管理数据,以便它到达我们执行代码的地方,然后第四章回到代码本身,讨论内核的编写。

单源

SYCL 程序可以是单源的,这意味着同一个翻译单元(通常是一个源文件及其头文件)既包含定义要在 SYCL 设备上执行的计算内核的代码,也包含协调这些内核执行的主机代码。图 2-1 以图形方式显示了这两条代码路径,图 2-2 提供了一个标记了主机和设备代码区域的示例应用。

将设备和宿主代码组合到一个源文件(或翻译单元)中,可以使理解和维护异构应用程序变得更加容易。这种组合还提供了改进的语言类型安全性,并能使我们的代码得到更多的编译器优化。

img/489625_1_En_2_Fig2_HTML.png

图 2-2

简单 SYCL 程序

img/489625_1_En_2_Fig1_HTML.png

图 2-1

单源代码包含主机代码(运行在 CPU 上)和设备代码(运行在 SYCL 设备上)

主机代码

应用程序包含 C++ 宿主代码,由操作系统启动应用程序的 CPU 执行。宿主代码是应用程序的主干,它定义和控制向可用设备分配工作。它也是一个接口,通过它我们可以定义应该由运行时管理的数据和依赖关系。

宿主代码是标准的 C++,增加了特定于 SYCL 的构造和类,这些构造和类被设计成可作为 C++ 库来实现。这使得推断宿主代码中允许的内容(C++ 中允许的任何内容)变得更加容易,并且可以简化与构建系统的集成。

SYCL 应用程序是标准的 C++,增加了可以作为 C++ 库实现的结构。

SYCL 编译器可以通过“理解”这些结构为程序提供更高的性能。

应用程序中的主机代码协调数据移动和设备的计算卸载,但也可以自己执行计算密集型工作,并可以像任何 C++ 应用程序一样使用库。

设备码

设备对应于加速器或处理器,它们在概念上独立于执行主机代码的 CPU。如本章后面所述,实现必须将主机处理器也作为设备公开,但是主机处理器和设备应该被认为是逻辑上相互独立的。主机处理器运行本机 C++ 代码,而设备运行设备代码。

队列是一种机制,通过它可以将工作提交给设备以供将来执行。需要理解设备代码的三个重要属性:

  1. 它与主机代码异步执行。主机程序将设备代码提交给设备,只有当所有的执行依赖关系都满足时,运行时才会跟踪并启动该工作(更多信息请参见第三章)。主机程序执行在提交的工作在设备上开始之前进行,提供了设备上的执行与主机程序执行异步的属性,除非我们明确地将二者联系在一起。

  2. 为了能够在加速器设备上编译和实现性能,对设备代码有一些限制。例如,设备代码中不支持动态内存分配和运行时类型信息(RTTI ),因为它们会导致许多加速器的性能下降。第十章详细介绍了设备代码限制。

  3. 由 SYCL 定义的一些函数和查询只在设备代码中可用,因为它们只在那里有意义,例如,允许设备代码的执行实例在更大的数据并行范围内查询其位置的工作项标识符查询(在第四章中描述)。

一般来说,我们将包括提交到队列的设备代码的工作称为动作。在第三章中,我们将了解到动作不仅仅包括要执行的设备代码;动作还包括内存移动命令。在这一章中,由于我们关心的是动作的设备代码方面,我们将在大部分时间里特别提到设备代码。

选择设备

为了探索让我们控制设备代码将在何处执行的机制,我们将查看五个用例:

  • 方法#1:在某个地方运行设备代码,而我们并不关心使用的是哪个设备。这通常是开发的第一步,因为这是最简单的。

** 方法#2:在主机设备上显式运行设备代码,这通常用于调试。保证主机设备在任何系统上都始终可用。

*   方法#3:将设备代码分派给 GPU 或另一个加速器。

*   方法#4:将设备代码分派给一组不同的设备,比如 GPU 和 FPGA。

*   方法#5:从更一般的器件类别中选择特定的器件,例如从一组可用的 FPGA 类型中选择特定类型的 FPGA。* 

*开发人员通常会尽可能多地使用方法 2 来调试他们的代码,并且只有当代码已经用方法 2 尽可能多地进行了测试时,才转移到方法 3 到 5。

方法 1:在任何类型的设备上运行

当我们不关心我们的设备代码将在哪里运行时,很容易让运行时为我们选择。这种自动选择是为了在我们还不关心选择什么设备时,使开始编写和运行代码变得容易。这个设备选择没有考虑要运行的代码,所以应该被认为是一个任意的选择,可能不是最佳的。

在讨论设备的选择之前,即使是实现为我们选择的设备,我们也应该首先了解程序与设备交互的机制:队列。

行列

一个queue是一个抽象,动作被提交给它以便在单个设备上执行。图 2-3 和 2-4 中给出了queue等级的简化定义。动作通常是数据并行计算的启动,尽管其他命令也是可用的,例如当我们需要比运行时提供的自动移动更多的控制时,可以手动控制数据移动。提交给queue的工作可以在运行时跟踪的先决条件满足后执行,比如输入数据的可用性。这些先决条件包含在第 3 和 8 章中。

img/489625_1_En_2_Fig4_HTML.png

图 2-4

queue类中关键成员函数的简化定义

img/489625_1_En_2_Fig3_HTML.png

图 2-3

queue类的构造器的简化定义

一个queue被绑定到一个单独的device,这个绑定发生在队列的构造上。理解提交给队列的工作是在该队列所绑定的单个设备上执行的是很重要的。不能将队列映射到设备集合,因为这将导致哪个设备应该执行工作不明确。类似地,队列不能将提交给它的工作分散到多个设备上。相反,在一个队列和提交给该队列的工作将在其上执行的设备之间有一个明确的映射,如图 2-5 所示。

img/489625_1_En_2_Fig5_HTML.png

图 2-5

一个队列绑定到一个设备。提交到队列的工作在该设备上执行

一个程序中可以创建多个队列,按照我们对应用程序架构或编程风格所期望的任何方式。例如,可以创建多个队列,每个队列与不同的设备绑定,或者由主机程序中的不同线程使用。多个不同的队列可以绑定到单个设备,如 GPU,提交到这些不同的队列将导致在设备上执行组合工作。这方面的一个例子如图 2-6 所示。相反,正如我们前面提到的,一个队列不能绑定到多个设备,因为在请求执行动作的位置上不能有任何模糊性。例如,如果我们想要一个跨多个设备负载平衡的队列,那么我们可以在代码中创建这个抽象。

img/489625_1_En_2_Fig6_HTML.png

图 2-6

多个队列可以绑定到一个设备

因为队列被绑定到一个特定的设备,所以队列构造是代码中最常见的选择设备的方式,提交到队列的操作将在该设备上执行。构建队列时设备的选择是通过设备选择器抽象和相关的device_selector类实现的。

将队列绑定到设备,任何设备都可以

图 2-7 是一个没有指定队列应该绑定的设备的例子。不带任何参数的普通队列构造器(如图 2-7 所示)只是在幕后选择一些可用的设备。SYCL 保证至少有一个设备始终可用,即主机设备。主机设备可以运行内核代码,并且是主机程序在其上执行的处理器的抽象,因此总是存在。

img/489625_1_En_2_Fig7_HTML.png

图 2-7

通过简单的队列构造实现隐式默认设备选择器

使用简单的队列构造器是开始应用程序开发和启动并运行设备代码的简单方法。当它变得与我们的应用程序相关时,可以添加对绑定到队列的设备的选择的更多控制。

方法#2:使用主机设备进行开发和调试

主机设备可以被认为是使主机 CPU 能够像一个独立的设备一样工作,允许我们的设备代码执行,而不管系统中可用的加速器。我们总是有一些处理器运行主机程序,因此主机设备对我们的应用程序总是可用的。主机设备保证设备代码可以一直运行(不依赖于加速器硬件),并且有几个主要用途:

  1. 在没有任何加速器的低性能系统上开发设备代码:一个常见的用途是在本地系统上开发和测试设备代码,然后部署到 HPC 集群进行性能测试和优化。

  2. 使用非加速器工具调试设备代码:加速器通常通过较低级别的 API 公开,这些 API 可能没有主机 CPU 可用的高级调试工具。考虑到这一点,主机设备应该支持使用 CPU 开发人员熟悉的标准工具进行调试。

  3. 备份如果没有其他设备可用,保证设备代码可以功能性地执行:主机设备实现可能不会将性能作为主要目标,因此应被视为功能性备份,以确保设备代码可以始终在任何应用中执行,但不一定是性能的途径。

主机设备在功能上类似于硬件加速器设备,因为队列可以绑定到它,并且它可以执行设备代码。图 2-8 显示了主机设备如何成为系统中其他可用加速器的对等设备。它可以执行设备代码,就像 CPU、GPU 或 FPGA 一样,并且可以构建一个或多个绑定到它的队列。

img/489625_1_En_2_Fig8_HTML.png

图 2-8

始终可用的主机设备可以像任何加速器一样执行设备代码

应用程序可以选择创建一个绑定到主机设备的队列,方法是将host_selector显式传递给队列构造器,如图 2-9 所示。

img/489625_1_En_2_Fig9_HTML.png

图 2-9

使用host_selector类选择主机设备

即使没有特别请求(例如使用host_selector),默认选择器也可能会选择主机设备,如图 2-7 中的输出所示。

定义了设备选择器类的几个变体,以便于我们定位设备类型。host_selector是这些选择器类的一个例子,我们将在接下来的章节中讨论其他的。

方法 3:使用 GPU(或其他加速器)

下一个例子展示了 GPU,但是任何类型的加速器都同样适用。为了更容易找到常见的加速器类别,设备被分成几大类,SYCL 为它们提供了内置的选择器类别。要从广泛的设备类型类别中进行选择,如“系统中可用的任何 GPU”,相应的代码非常简短,如本节所述。

设备类型

队列可以绑定到两大类设备:

  1. 已经描述过的主机设备。

  2. 加速器设备,如 GPU、FPGA 或 CPU 设备,用于加速我们应用程序中的工作负载。

加速器设备

有几大类促进剂类型:

  1. CPU 设备

  2. GPU 设备

  3. 加速器,它捕获既不是 CPU 设备也不是 GPU 设备的设备。这包括 FPGA 和 DSP 器件。

这些类别中的任何一个设备都可以很容易地使用内置的选择器类绑定到队列,这些选择器类可以传递给队列(和其他一些类)构造器。

设备选择器

必须绑定到特定设备的类,比如queue类,有可以接受从device_selector派生的类的构造器。例如,队列构造器是


queue( const device_selector &deviceSelector,
  const property_list &propList = {});

有五个内置的选择器用于各种常见设备:

| `default_selector` | 实现选择的任何设备。 | | `host_selector` | 选择主机设备(始终可用)。 | | `cpu_selector` | 选择在设备查询中将自己标识为 CPU 的设备。 | | `gpu_selector` | 选择在设备查询中将自己标识为 GPU 的设备。 | | `accelerator_selector` | 选择一个将自己标识为“加速器”的设备,包括 FPGAs。 |

DPC++ 中包含的一个附加选择器(SYCL 中没有)可以通过包含头"CL/sycl/intel/fpga_extensions.hpp":来获得

| `INTEL::fpga_selector` | 选择将自身标识为 FPGA 的设备。 |

可以使用内置选择器之一来构造队列,例如

img/489625_1_En_2_Figc_HTML.gif

图 2-10 显示了使用cpu_selector的完整示例,图 2-11 显示了队列与可用 CPU 设备的对应绑定。

图 2-12 显示了一个使用各种内置选择器类的例子,也展示了设备选择器与另一个接受构造上的device_selector的类(device)的使用。

img/489625_1_En_2_Fig12_HTML.png

图 2-12

来自各种设备选择器类的示例设备标识输出,以及设备选择器不仅可用于构建队列(在这种情况下,构建设备类实例)的演示

img/489625_1_En_2_Fig11_HTML.png

图 2-11

绑定到应用程序可用的 CPU 设备的队列

img/489625_1_En_2_Fig10_HTML.png

图 2-10

CPU 设备选择器示例

当设备选择失败时

如果在创建一个对象(比如队列)时使用了一个gpu_selector,并且没有可供运行时使用的 GPU 设备,那么选择器就会抛出一个runtime_error异常。对于所有的设备选择器类都是如此,因为如果没有所需类的设备可用,那么就会抛出一个runtime_error异常。对于复杂的应用程序来说,捕捉该错误并获取不太理想的(对于应用程序/算法)设备类作为替代是合理的。异常和错误处理将在第五章中详细讨论。

方法 4:使用多种设备

如图 2-5 和 2-6 所示,我们可以在一个应用中构建多个队列。我们可以将这些队列绑定到单个设备(队列的总工作量集中到单个设备中)、多个设备,或者这些设备的某种组合。图 2-13 提供了一个创建一个绑定到 GPU 的队列和另一个绑定到 FPGA 的队列的例子。相应的映射如图 2-14 所示。

img/489625_1_En_2_Fig14_HTML.png

图 2-14

GPU + FPGA 设备选择器示例:一个队列绑定到 GPU,另一个绑定到 FPGA

img/489625_1_En_2_Fig13_HTML.png

图 2-13

为 GPU 和 FPGA 设备创建队列

方法 5:定制(非常具体的)设备选择

我们现在来看看如何编写一个自定义选择器。除了本章中的示例,第十二章中还显示了一些示例。内置的设备选择器旨在让我们快速启动并运行代码。实际应用通常需要专门选择设备,例如从系统中可用的一组 GPU 类型中选择所需的 GPU。设备选择机制很容易扩展到任意复杂的逻辑,因此我们可以编写任何需要的代码来选择我们喜欢的设备。

device_selector基础类

所有的设备选择器都从抽象的device_selector基类派生,并在派生类中定义函数调用操作符:

img/489625_1_En_2_Figb_HTML.png

在从device_selector派生的类中定义这个操作符是定义任何复杂的选择逻辑所需要的,一旦我们知道了三件事:

  1. 对于运行时发现应用程序可以访问的每个设备,包括主机设备,都会自动调用一次函数调用运算符。

  2. 该运算符每次被调用时都返回一个整数。所有可用设备中得分最高的是选择器选择的设备。

  3. 函数调用操作符返回的负整数意味着不能选择所考虑的设备。

对设备进行评分的机制

我们有许多选项来创建对应于特定设备的整数分数,例如:

  1. 为特定设备类别返回正值。

  2. 设备名称和/或设备供应商字符串匹配。

  3. 基于设备或平台查询,我们在代码中可以想象的任何导致整数值的东西。

例如,选择英特尔 Arria 系列 FPGA 器件的一种可能方法如图 2-15 所示。

img/489625_1_En_2_Fig15_HTML.png

图 2-15

面向英特尔 Arria FPGA 设备的定制选择器

第十二章有更多关于器件选择的讨论和示例(图 12-2 和 12-3 )并更深入地讨论get_info方法。

在 CPU 上执行设备代码的三种途径

一个潜在的混淆来源是多种机制,通过这些机制,CPU 可以执行代码,如图 2-16 所示。

img/489625_1_En_2_Fig16_HTML.png

图 2-16

在 CPU 上执行的 SYCL 机制

CPU 执行的第一个也是最明显的路径是宿主代码,它或者是单源应用程序(宿主代码区域)的一部分,或者是链接到宿主代码并从宿主代码中调用,如库函数。

另外两条可用路径执行设备代码。设备代码的第一个 CPU 路径是通过主机设备,这在本章前面已经描述过了。它总是可用的,并被期望在执行主机代码的同一 CPU 上执行设备代码。

在 SYCL 中,在 CPU 上执行设备代码的第二条路径是可选的,它是一个针对性能进行了优化的 CPU 加速器设备。该设备通常由 OpenCL 等较低级别的运行时实现,因此其可用性可能取决于系统上安装的驱动程序和其他运行时。SYCL 描述了这一原理,其中主机设备旨在可使用本机 CPU 工具进行调试,而 CPU 设备可以构建在针对性能优化的实现上,而本机 CPU 调试器不可用。

虽然我们在本书中没有涉及到,但是当任务图中的先决条件得到满足时,有一种机制可以将常规 CPU 代码排队(图 2-16 的顶部)。这项高级功能可用于在任务图中执行常规 CPU 代码和设备代码,称为主机任务。

在设备上创建作品

应用程序通常包含主机代码和设备代码的组合。有几个类成员允许我们提交设备代码以供执行,因为这些工作分派构造是提交设备代码的唯一方式,它们允许我们容易地将设备代码与主机代码区分开。

本章的剩余部分介绍了一些工作分派结构,目的是帮助我们理解和识别设备代码和在主机处理器上本地执行的主机代码之间的区别。

任务图简介

SYCL 执行模型中的一个基本概念是节点图。该图中的每个节点(工作单元)都包含一个要在设备上执行的操作,最常见的操作是数据并行设备内核调用。图 2-17 显示了一个有四个节点的示例图,其中每个节点都可以被认为是一个设备内核调用。

图 2-17 中的节点具有依赖边,定义了何时开始执行节点的工作是合法的。依赖边通常是从数据依赖关系自动生成的,尽管我们可以在需要时手动添加额外的自定义依赖关系。例如,图中的节点 B 具有与节点 A 的依赖边。该边意味着在节点 B 的动作开始之前,节点 A 必须完成执行,并且最有可能(取决于依赖关系的细节)使生成的数据在节点 B 将执行的设备上可用。运行时完全与宿主程序的执行异步地控制依赖关系的解析和节点执行的触发。定义应用程序的节点图在本书中将被称为任务图,在第三章中会有更详细的介绍。

img/489625_1_En_2_Fig18_HTML.png

图 2-18

提交设备代码

img/489625_1_En_2_Fig17_HTML.png

图 2-17

任务图定义了要在一个或多个设备上执行的动作(与主机程序异步),还定义了确定何时执行动作是安全的依赖关系

设备代码在哪里?

有多种机制可用于定义将在设备上执行的代码,但一个简单的示例显示了如何识别此类代码。即使示例中的模式初看起来很复杂,但该模式在所有设备代码定义中保持不变,很快就成为第二天性。

作为最后一个参数传递给parallel_for的代码,在图 2-18 中定义为λ,是要在设备上执行的设备代码。本例中的parallel_for是让我们区分设备代码和主机代码的结构。parallel_for是一小组设备调度机制中的一个,所有成员都是handler类的成员,它们定义了要在设备上执行的代码。图 2-19 给出了handler等级的简化定义。

img/489625_1_En_2_Fig19_HTML.png

图 2-19

handler类中成员函数的简化定义

除了调用handler类的成员提交设备代码,还有queue类的成员允许提交工作。图 2-20 中显示的queue类成员是简化某些模式的快捷方式,我们将在以后的章节中看到这些快捷方式的使用。

img/489625_1_En_2_Fig20_HTML.png

图 2-20

queue类中成员函数的简化定义,作为handler类中等价函数的简写符号

行动

图 2-18 中的代码包含一个parallel_for,它定义了要在设备上执行的工作。parallel_for位于提交给queue的命令组(CG)内,queue定义了将要执行工作的设备。在命令组中,有两类代码:

  1. 恰好一个对动作的调用,该动作或者将设备代码排队等待执行,或者执行手动内存操作,例如copy

  2. 建立依赖关系的宿主代码,定义运行时何时开始执行(1)中定义的工作是安全的,例如创建缓冲区的访问器(在第三章中描述)。

handler 类包含一小组成员函数,这些函数定义了执行任务图节点时要执行的操作。图 2-21 总结了这些动作。

img/489625_1_En_2_Fig21_HTML.png

图 2-21

调用设备代码或执行显式内存操作的操作

在一个命令组中只能调用图 2-21 中的一个动作(调用多个是错误的),并且每个submit调用只能提交一个命令组到一个队列中。这样做的结果是,图 2-21 中的单个操作存在于每个任务图节点中,当满足节点依赖性并且运行时确定可以安全执行时,该操作将被执行。

一个命令组中必须有一个动作,例如内核启动或显式内存操作。

将来异步执行代码的想法是作为主机程序的一部分在 CPU 上运行的代码和将来在满足依赖性时运行的设备代码之间的关键区别。命令组通常包含每个类别的代码,定义依赖关系的代码作为宿主程序的一部分运行(以便运行时知道依赖关系是什么),设备代码在依赖关系得到满足后运行。

图 2-22 中有三类代码:

img/489625_1_En_2_Fig22_HTML.png

图 2-22

提交设备代码

  1. 宿主代码:驱动应用程序,包括创建和管理数据缓冲区,以及将工作提交到队列中,以在任务图中形成新的节点来进行异步执行。

  2. 命令组中的主机代码:该代码运行在主机代码正在执行的处理器上,并在submit调用返回之前立即执行。例如,这段代码通过创建访问器来设置节点依赖关系。任何任意的 CPU 代码都可以在这里执行,但是最佳实践是将其限制为配置节点依赖关系的代码。

  3. 一个动作:图 2-21 中列出的任何动作都可以包含在一个命令组中,它定义了未来满足节点需求时异步执行的工作(由(2)设置)。

要了解应用程序中的代码何时运行,请注意传递给图 2-21 中列出的启动设备代码执行的动作的任何东西,或者图 2-21 中列出的显式内存操作,将在满足 DAG 节点依赖关系后异步执行。所有其他代码作为宿主程序的一部分立即运行,正如典型的 C++ 代码所预期的那样。

撤退

通常一个命令组是在我们提交给它的命令队列中执行的。然而,可能存在命令组未能提交到队列的情况(例如,当所请求的工作大小对于设备的限制来说太大时),或者当成功提交的操作不能开始执行时(例如,当硬件设备发生故障时)。为了处理这种情况,可以为要执行的命令组指定一个后备队列。作者不推荐这种错误管理技术,因为它提供的控制很少,相反,我们建议捕捉和管理初始错误,如第五章所述。我们在这里简单介绍一下回退队列,因为有些人更喜欢这种风格,它是 SYCL 中众所周知的一部分。

这种回退方式适用于机器上存在的设备的失败队列提交。这不是解决加速器不存在问题的后备机制。在没有 GPU 设备的系统上,图 2-23 中的程序会在Q声明(试图构造)中抛出一个错误,表明“没有请求类型的设备可用”

img/489625_1_En_2_Fig23_HTML.png

图 2-23

回退队列示例

基于现有设备的回退主题将在第十二章中讨论。

图 2-23 显示了由于所要求的工作组规模,将无法在某些 GPU 上开始执行的代码。我们可以指定一个辅助队列作为 submit 函数的参数,如果命令组无法加入主队列,就使用这个辅助队列(在本例中是主机设备)。

通过将辅助队列传递给submit调用来启用回退队列。作者建议捕捉初始错误并处理它,如第五章所述,而不是使用提供较少控制的回退队列机制。

摘要

在本章中,我们提供了队列的概述,选择与队列相关的设备,以及如何创建自定义设备选择器。我们还概述了当满足依赖性时在设备上异步执行的代码和作为 C++ 应用程序宿主代码的一部分执行的代码。第三章描述了如何控制数据移动。

Creative Commons

开放存取本章根据知识共享署名 4.0 国际许可证(http://Creative Commons . org/licenses/by/4.0/)的条款获得许可,该许可证允许以任何媒体或格式使用、共享、改编、分发和复制,只要您适当注明原作者和来源,提供知识共享许可证的链接并指明是否进行了更改。

本章中的图像或其他第三方材料包含在本章的知识共享许可中,除非在材料的信用额度中另有说明。如果材料不包括在本章的知识共享许可中,并且您的预期使用不被法律法规允许或超出了允许的使用范围,您将需要直接从版权所有者处获得许可。*

三、数据管理

img/489625_1_En_3_Figa_HTML.gif

超级计算机架构师经常感叹我们需要“喂野兽”。短语“喂养野兽”指的是当我们使用大量并行时,我们创建的计算机的“野兽”,向它提供数据成为需要解决的关键挑战。

在异构机器上输入数据并行 C++ 程序需要注意确保数据在需要的时候出现在需要的地方。在大型程序中,这可能需要大量的工作。在一个预先存在的 C++ 程序中,仅仅是整理如何管理所有需要的数据移动就可能是一场噩梦。

我们将仔细解释管理数据的两种方法:统一共享内存(USM)和缓冲区。USM 是基于指针的,这是 C++ 程序员所熟悉的。缓冲区提供了更高层次的抽象。选择是好事。

我们需要控制数据的移动,这一章涵盖了实现这一点的选项。

在第二章中,我们学习了如何控制代码在哪里执行。我们的代码需要数据作为输入,并产生数据作为输出。因为我们的代码可能在多个设备上运行,而这些设备不一定共享内存,所以我们需要管理数据移动。即使数据是共享的,例如 USM,同步和一致性也是我们需要理解和管理的概念。

一个合乎逻辑的问题可能是“为什么编译器不自动为我们做所有的事情?”虽然我们可以自动处理很多事情,但是如果我们不坚持自己的程序员身份,性能通常是次优的。实际上,为了获得最佳性能,在编写异构程序时,我们需要关注代码放置(第二章)和数据移动(本章)。

本章概述了数据管理,包括控制数据使用的顺序。它是对前一章的补充,前一章向我们展示了如何控制代码在哪里运行。本章帮助我们有效地使数据出现在我们要求代码运行的地方,这不仅对于正确执行我们的应用程序很重要,而且对于最小化执行时间和功耗也很重要。

介绍

没有数据,计算什么都不是。加速计算的目的是为了更快地得出答案。这意味着数据并行计算最重要的方面之一是它们如何访问数据,在机器中引入加速器设备会使情况进一步复杂化。在传统的基于单插槽 CPU 的系统中,我们只有一个内存。加速器设备通常有自己的附加存储器,不能从主机直接访问。因此,支持分立设备的并行编程模型必须提供管理这些多个存储器并在它们之间移动数据的机制。

在这一章中,我们将概述各种数据管理机制。我们介绍了用于数据管理的统一共享内存和缓冲区抽象,并描述了内核执行和数据移动之间的关系。

数据管理问题

从历史上看,并行编程的共享内存模型的优势之一是它们提供了一个单一的共享内存视图。拥有这种单一的内存视图简化了生活。我们不需要做任何特殊的事情来从并行任务中访问内存(除了正确的同步以避免数据竞争)。虽然某些类型的加速器设备(如集成 GPU)与主机 CPU 共享内存,但许多分立加速器拥有自己的独立于 CPU 的本地内存,如图 3-1 所示。

img/489625_1_En_3_Fig1_HTML.png

图 3-1

多个离散存储器

设备本地与设备远程

当使用直接连接到设备的内存而不是远程内存来读写数据时,设备上运行的程序性能会更好。我们将对直接连接的存储器的访问称为本地访问。对另一个设备内存的访问是远程访问。远程访问往往比本地访问慢,因为它们必须通过带宽较低和/或延迟较高的数据链路传输。这意味着将计算和它将使用的数据放在一起通常是有利的。为了做到这一点,我们必须设法确保数据在不同的内存之间复制或迁移,以便将数据移动到离计算发生地更近的地方。

img/489625_1_En_3_Fig2_HTML.png

图 3-2

数据移动和内核执行

管理多个存储器

大体上说,管理多个内存可以通过两种方式来完成:通过我们的程序显式地管理内存,或者由运行时隐式地管理内存。每种方法都有其优点和缺点,我们可以根据情况或个人喜好选择其中之一。

显式数据移动

管理多个存储器的一种选择是在不同的存储器之间显式地复制数据。图 3-2 显示了一个带有独立加速器的系统,我们必须首先将内核需要的任何数据从主机内存复制到 GPU 内存。在内核计算结果之后,我们必须将这些结果复制回 CPU,然后主机程序才能使用这些数据。

显式数据移动的主要优势在于,我们可以完全控制数据在不同内存之间传输的时间。这一点很重要,因为在某些硬件上,重叠计算和数据传输对于获得最佳性能至关重要。

显式数据移动的缺点是,指定所有数据移动可能会很繁琐且容易出错。传输不正确的数据量,或者在内核开始计算之前不确保所有数据都已传输,都可能导致不正确的结果。从一开始就让所有数据正确移动可能是一项非常耗时的任务。

隐式数据移动

程序控制的显式数据移动的替代方法是由并行运行时或驱动程序控制的隐式数据移动。在这种情况下,并行运行时不需要在不同的内存之间进行显式复制,而是负责确保数据在使用之前被传输到适当的内存。

隐式数据移动的优势在于,让应用程序利用直接连接到设备的更快的内存需要更少的努力。所有繁重的工作都由运行时自动完成。这也减少了将错误引入程序的机会,因为运行时将自动识别何时必须执行数据传输以及必须传输多少数据。

隐式数据移动的缺点是我们对运行时隐式机制的行为控制很少或没有控制。运行时将提供功能正确性,但可能不会以确保计算与数据传输最大重叠的最佳方式移动数据,这可能会对程序性能产生负面影响。

选择正确的策略

为一个项目选择最佳策略取决于许多不同的因素。不同的策略可能适用于程序开发的不同阶段。我们甚至可以决定最好的解决方案是混合和匹配程序不同部分的显式和隐式方法。我们可能会选择开始使用隐式数据移动来简化将应用程序移植到新设备的过程。当我们开始调整应用程序的性能时,我们可能会在代码的性能关键部分用显式数据移动来代替隐式数据移动。未来的章节将会介绍数据传输如何与计算重叠以优化性能。

USM、缓冲区和图像

管理内存有三个抽象概念:统一共享内存(USM)、缓冲区和映像。USM 是一种基于指针的方法,应该为 C/C++ 程序员所熟悉。USM 的一个优势是更容易与现有的操作指针的 C++ 代码集成。由buffer模板类表示的缓冲区描述了一维、二维或三维数组。它们提供了可以在主机或设备上访问的内存的抽象视图。程序不直接访问缓冲区,而是通过accessor对象来使用。图像充当一种特殊类型的缓冲区,提供特定于图像处理的额外功能。这个功能包括支持特殊的图像格式,使用 sampler 对象读取图像,等等。缓冲区和映像是解决许多问题的强大抽象,但是重写现有代码中的所有接口以接受缓冲区或访问器可能非常耗时。由于缓冲区和图像的接口基本相同,本章的其余部分将只关注 USM 和缓冲区。

统一共享内存

USM 是我们可以使用的一种数据管理工具。USM 是一种基于指针的方法,使用mallocnew分配数据的 C 和 C++ 程序员应该很熟悉。当移植大量使用指针的现有 C/C++ 代码时,USM 简化了工作。支持 USM 的设备支持统一的虚拟地址空间。拥有统一的虚拟地址空间意味着主机上的 USM 分配例程返回的任何指针值都将是设备上的有效指针值。我们不必手动转换主机指针来获得“设备版本”,我们在主机和设备上都看到相同的指针值。

USM 的更详细描述可在第六章中找到。

通过指针访问内存

由于当系统包含主机内存和一定数量的设备连接本地内存时,并非所有内存都是相同的,USM 定义了三种不同类型的分配:devicehostshared。所有类型的分配都在主机上执行。图 3-3 总结了每种分配类型的特点。

img/489625_1_En_3_Fig3_HTML.png

图 3-3

USM 分配类型

在设备连接内存中进行device分配。这种分配可以在设备上读取和写入,但不能从主机直接访问。我们必须使用显式复制操作在主机内存的常规分配和device分配之间移动数据。

host分配发生在可在主机和设备上访问的主机内存中。这意味着相同的指针值在主机代码和设备内核中都有效。然而,当访问这样的指针时,数据总是来自主机内存。如果在设备上访问,数据不会从主机迁移到设备本地内存。相反,数据通常通过将设备连接到主机的总线(例如 PCI-Express (PCI-E ))发送。

在主机和设备上都可以访问shared分配。在这方面,它与主机分配非常相似,但不同之处在于数据现在可以在主机内存和设备本地内存之间迁移。这意味着在迁移发生后,对设备的访问是从速度更快的设备本地内存进行的,而不是通过更高延迟的连接远程访问主机内存。通常,这是通过运行时内部的机制和对我们隐藏的低级驱动程序来实现的。

USM 和数据移动

USM 支持显式和隐式数据移动策略,不同的分配类型映射到不同的策略。设备分配要求我们在主机和设备之间显式移动数据,而主机和共享分配提供隐式数据移动。

USM 中的显式数据移动

使用 USM 的显式数据移动是通过device分配以及队列和处理程序类中的特殊memcpy()来完成的。我们对memcpy()操作(动作)进行排队,以便将数据从主机传输到设备,或者从设备传输到主机。

图 3-4 包含一个操作设备分配的内核。在内核执行之前和之后,使用memcpy()操作在hostArraydeviceArray之间复制数据。对队列上的wait()的调用确保在内核执行之前对设备的复制已经完成,并且确保在数据复制回主机之前内核已经完成。我们将在本章的后面学习如何消除这些调用。

img/489625_1_En_3_Fig4_HTML.png

图 3-4

USM 显式数据移动

USM 中的隐式数据移动

使用 USM 的隐式数据移动是通过hostshared分配完成的。使用这些类型的分配,我们不需要显式插入拷贝操作来在主机和设备之间移动数据。相反,我们只需访问内核中的指针,任何所需的数据移动都会自动执行,无需程序员干预(只要您的设备支持这些分配)。这极大地简化了现有代码的移植:只需用适当的 USM 分配函数替换任何 malloc 或 new(以及对free释放内存的调用),一切都将正常工作。

img/489625_1_En_3_Fig5_HTML.png

图 3-5

USM 隐式数据移动

在图 3-5 中,我们创建了两个数组hostArraysharedArray,它们分别是主机和共享分配。虽然主机和共享分配都可以在主机代码中直接访问,但是我们在这里只初始化hostArray。类似地,可以在内核内部直接访问它,执行数据的远程读取。运行时确保sharedArray在内核访问它之前在设备上可用,并且当它稍后被主机代码读取时被移回,所有这些都不需要程序员的干预。

缓冲

为数据管理提供的另一个抽象是缓冲区对象。缓冲区是一种数据抽象,表示一个或多个给定 C++ 类型的对象。缓冲区对象的元素可以是标量数据类型(如intfloatdouble)、矢量数据类型(第十一章)或用户定义的类或结构。缓冲区中的数据结构必须是 C++ 普通可复制的,这意味着一个对象可以被安全地逐字节复制,而不需要调用复制构造器。

虽然缓冲区本身是单个对象,但缓冲区封装的 C++ 类型可以是包含多个对象的数组。缓冲区代表的是数据对象而不是具体的内存地址,所以不能像普通的 C++ 数组一样直接访问。实际上,出于性能原因,缓冲区对象可能会映射到几个不同设备上的多个不同内存位置,甚至是同一设备上的多个不同内存位置。相反,我们使用访问器对象来读写缓冲区。

第七章对缓冲器进行了更详细的描述。

创建缓冲区

可以通过多种方式创建缓冲区。最简单的方法是简单地用指定缓冲区大小的范围构造一个新的缓冲区。然而,以这种方式创建缓冲区并不初始化其数据,这意味着我们必须先通过其他方式初始化缓冲区,然后再尝试从中读取有用的数据。

也可以从主机上的现有数据创建缓冲区。这是通过调用几个构造器中的一个来完成的,这些构造器要么接受一个指向现有主机分配的指针,一组InputIterators,要么接受一个具有特定属性的容器。在缓冲区构造期间,数据从现有的主机分配中复制到缓冲区对象的主机内存中。如果我们使用 OpenCL 的 SYCL 互操作性特性,也可以从现有的cl_mem对象创建一个缓冲区。

访问缓冲区

主机和设备可能无法直接访问缓冲区(除非通过此处未描述的高级且不常用的机制)。相反,我们必须创建访问器来读写缓冲区。访问器为运行时提供关于我们计划如何使用缓冲区中的数据的信息,允许它正确地调度数据移动。七

img/489625_1_En_3_Fig7_HTML.png

图 3-7

缓冲区访问模式

img/489625_1_En_3_Fig6_HTML.png

图 3-6

缓冲区和存取器

7

访问模式

当创建访问器时,我们可以通知运行时我们将如何使用它来提供更多的优化信息。我们通过指定一个访问模式来做到这一点。访问模式在图 3-7 中描述的access::mode enum中定义。在图 3-6 所示的代码示例中,访问器myAccessor是用默认的访问模式access::mode::read_write创建的。这让运行时知道我们打算通过myAccessor读写缓冲区。访问模式是运行库优化隐式数据移动的方式。例如,access::mode::read告诉运行时,在内核开始执行之前,数据需要在设备上可用。如果内核只通过一个访问器读取数据,那么在内核完成后就没有必要将数据复制回主机,因为我们没有修改它。同样,access::mode::write让运行时知道我们将修改缓冲区的内容,并且可能需要在计算结束后将结果复制回来。

用适当的模式创建访问器给了运行时更多关于我们如何在程序中使用数据的信息。运行时使用访问器对数据的使用进行排序,但它也可以使用这些数据来优化内核和数据移动的调度。访问模式和优化标签在第七章中有更详细的描述。

数据使用的排序

内核可以被看作是提交执行的异步任务。这些任务必须提交到一个队列中,在那里它们被安排在一个设备上执行。在许多情况下,内核必须按照特定的顺序执行,这样才能计算出正确的结果。如果获得正确的结果需要任务A在任务B之前执行,我们说任务AB之间存在依赖 1

然而,内核并不是必须被调度的任务的唯一形式。在内核开始执行之前,内核访问的任何数据都需要在设备上可用。这些数据依赖性会以从一个设备到另一个设备的数据传输的形式产生额外的任务。数据传输任务可以是显式编码的复制操作,也可以是运行时执行的更常见的隐式数据移动。

如果我们把一个程序中的所有任务以及它们之间存在的依赖关系都拿出来,我们就可以用这个来把信息可视化为一个图形。该任务图具体是有向无环图(DAG ),其中节点是任务,边是依赖关系。该图是定向的,因为依赖关系是单向的:任务A必须发生在任务B之前。这个图是非循环的,因为它不包含任何从一个节点回到自身的循环或路径。

在图 3-8 中,任务A必须在任务BC之前执行。同样,BC必须在任务D之前执行。由于BC彼此之间没有依赖关系,只要任务A已经执行,运行时就可以自由地以任何顺序(甚至并行)执行它们。因此,该图可能的法律顺序是A``B``C``D``A``C``B``D,如果BC可以并发执行,甚至是A``{B,C}``D

img/489625_1_En_3_Fig8_HTML.png

图 3-8

简单任务图

任务可能依赖于所有任务的子集。在这些情况下,我们只想指定关系到正确性的依赖关系。这种灵活性为运行时优化任务图的执行顺序提供了空间。在图 3-9 中,我们从图 3-8 扩展了之前的任务图,增加了任务EF,其中E必须在F之前执行。然而,任务EF与节点ABCD没有依赖关系。这允许运行时从许多可能的合法顺序中选择来执行所有任务。

img/489625_1_En_3_Fig9_HTML.png

图 3-9

具有不相交依赖关系的任务图

有两种不同的方法来模拟任务在队列中的执行,比如内核的启动:队列可以按照提交的顺序执行任务,也可以按照我们定义的任何依赖关系按照任何顺序执行任务。我们有几种机制来定义正确排序所需的依赖关系。

有序队列

对任务进行排序的最简单的选择是将它们提交给有序的queue对象。有序队列按照任务提交的顺序执行任务,如图 3-10 所示。尽管有序队列的直观任务排序在简单性方面提供了优势,但它也提供了一个缺点,即即使独立任务之间不存在依赖性,任务的执行也会串行化。有序队列在启动应用程序时非常有用,因为它们简单、直观、确定执行顺序,并且适用于许多代码。

img/489625_1_En_3_Fig10_HTML.png

图 3-10

有序队列使用

无序(OoO)队列

由于queue对象是无序队列(除非用in-order queue 属性创建),它们必须提供对提交给它们的任务进行排序的方法。队列通过让我们通知运行时它们之间的依赖关系来排序任务。这些依赖性可以使用命令组明确或隐含地指定。

命令组是指定任务及其依赖性的对象。命令组通常被写成 C++ lambdas,作为参数传递给队列对象的submit()方法。这个 lambda 唯一的参数是对一个handler对象的引用。handler 对象在命令组中用于指定操作、创建访问器和指定依赖关系。

事件的显式依赖性

任务之间的显式依赖看起来就像我们已经看到的例子(图 3-8 ),其中任务 A 必须在任务 b 之前执行。以这种方式表达依赖侧重于基于发生的计算的显式排序,而不是基于计算访问的数据。请注意,表达计算之间的依赖性主要与使用 USM 的代码相关,因为使用缓冲区的代码通过访问器表达大多数依赖性。在图 3-4 和 3-5 中,我们只是告诉队列等待所有之前提交的任务完成,然后再继续。相反,我们可以通过事件对象来表达任务依赖性。向队列提交命令组时,submit()方法返回一个事件对象。然后,这些事件可以以两种方式使用。

首先,我们可以通过在事件上显式调用wait()方法来通过主机进行同步。这将强制运行时等待生成事件的任务完成执行,然后宿主程序才能继续执行。显式等待事件对于调试应用程序非常有用,但是wait()会过度限制任务的异步执行,因为它会停止主机线程上的所有执行。类似地,也可以在队列对象上调用wait(),这将阻塞主机上的执行,直到所有排队的任务完成。如果我们不想跟踪排队任务返回的所有事件,这可能是一个有用的工具。

这就把我们带到了使用事件的第二种方式。handler 类包含一个名为depends_on()的方法。此方法接受单个事件或事件向量,并通知运行时正在提交的命令组要求在命令组中的操作可以执行之前完成指定的事件。图 3-11 显示了如何使用depends_on()来订购任务的示例。

img/489625_1_En_3_Fig11_HTML.png

图 3-11

使用事件和depends_on

访问器的隐式依赖

任务之间的隐式依赖关系是从数据依赖关系创建的。任务之间的数据依赖有三种形式,如图 3-12 所示。

img/489625_1_En_3_Fig12_HTML.png

图 3-12

三种形式的数据相关性

数据依赖以两种方式向运行时表达:访问器和程序顺序。运行时必须使用这两者来正确计算数据依赖关系。如图 3-13 和 3-14 所示。

img/489625_1_En_3_Fig14_HTML.png

图 3-14

原始任务图

img/489625_1_En_3_Fig13_HTML.png

图 3-13

写后读

在图 3-13 和 3-14 中,我们执行三个内核— computeBreadAcomputeC—and,然后在主机上读回最终结果。内核computeB的命令组创建了两个访问器,accAaccB。这些访问器使用访问标签read_onlywrite_only进行优化,指定我们不使用默认的访问模式access::mode::read_write。我们将在第七章中了解更多关于访问标签的内容。内核computeB读取缓冲区A并写入缓冲区B。内核开始执行之前,必须将缓冲区A从主机复制到设备。

内核readA也为缓冲区A创建一个只读访问器。由于内核readA是在内核computeB之后提交的,这就产生了一个读后读(RAR)的场景。然而,rar 对运行时没有额外的限制,内核可以以任何顺序自由执行。事实上,运行时可能更喜欢在内核computeB之前执行内核readA,或者甚至同时执行两者。两者都要求缓冲区A被复制到设备,但是内核computeB也要求缓冲区B被复制,以防任何现有的值没有被computeB覆盖并且可能被后来的内核使用。这意味着当缓冲区B的数据传输发生时,运行时可以执行内核readA,这也表明即使内核只写入缓冲区,缓冲区的原始内容仍可能被移动到设备,因为不能保证缓冲区中的所有值都会被内核写入(参见第七章,了解在这些情况下让我们进行优化的标签)。

内核computeC读取缓冲区B,这是我们在内核computeB中计算的。因为我们在提交内核computeB之后提交了内核computeC,这意味着内核computeC对缓冲区B有原始数据依赖。原始相关性也被称为真实相关性或流相关性,因为数据需要从一个计算流到另一个计算,以便计算正确的结果。最后,我们还在内核computeC和主机之间创建了一个对缓冲区C的原始依赖,因为主机希望在内核完成后再调用read C。这迫使运行时将缓冲区C复制回主机。由于没有写入设备上的缓冲区A,运行时不需要将该缓冲区复制回主机,因为主机已经有了最新的副本。

img/489625_1_En_3_Fig16_HTML.png

图 3-16

战争和战时任务图

img/489625_1_En_3_Fig15_HTML.png

图 3-15

读后写和写后写

在图 3-15 和 3-16 中,我们再次执行三个内核:computeBrewriteArewriteB。内核computeB再次读取缓冲区A并写入缓冲区B,内核rewriteA写入缓冲区A,内核rewriteB写入缓冲区B。内核rewriteA理论上可以早于内核computeB执行,因为在内核准备好之前需要传输的数据较少,但是它必须等到内核computeB完成之后,因为存在对缓冲区A的 WAR 依赖。

在这个例子中,内核computeB需要来自主机的 A 的原始值,如果内核rewriteA在内核computeB之前执行,它将读取错误的值。战争依赖也被称为反依赖。原始依赖关系确保数据以正确的方向正确流动,而 WAR 依赖关系确保现有值在被读取之前不会被覆盖。内核重写中发现的 WAW 对缓冲区B的依赖类似地起作用。如果在内核computeBrewriteB之间提交了对缓冲区B的任何读取,它们将导致原始和 WAR 依赖,这将正确排序任务。然而,在这个例子中,内核rewriteB和主机之间有一个隐含的依赖关系,因为最终的数据必须写回主机。我们将在第七章中了解导致这种写回的更多原因。WAW 依赖性,也称为输出依赖性,确保最终输出在主机上是正确的。

选择数据管理策略

为我们的应用程序选择正确的数据管理策略在很大程度上取决于个人偏好。事实上,我们可能从一种策略开始,随着程序的成熟,我们会切换到另一种策略。然而,有一些有用的指导方针可以帮助我们选择满足我们需求的策略。

要做的第一个决定是我们想要使用显式还是隐式数据移动,因为这极大地影响了我们需要对程序做什么。隐式数据移动通常是一个更容易开始的地方,因为所有的数据移动都是为我们处理的,让我们专注于计算的表达式。

如果我们决定从一开始就完全控制所有数据移动,那么使用 USM 设备分配的显式数据移动就是我们想要开始的地方。我们只需要确保在主机和设备之间添加所有必要的副本!

在选择隐式数据移动策略时,我们仍然可以选择是使用缓冲区还是 USM 主机或共享指针。同样,这个选择是个人偏好的问题,但是有几个问题可以帮助我们选择其中一个。如果我们正在移植一个使用指针的现有 C/C++ 程序,USM 可能是一个更容易的途径,因为大多数代码不需要改变。如果数据表示没有引导我们选择,我们可以问的另一个问题是我们希望如何表达内核之间的依赖关系。如果我们更喜欢考虑内核之间的数据依赖,选择缓冲区。如果我们更愿意将依赖关系理解为在执行一个计算之前执行另一个计算,并希望使用有序队列、显式事件或内核间等待来表达,请选择 USM。

当使用 USM 指针时(通过显式或隐式数据移动),我们可以选择使用哪种类型的队列。有序队列简单而直观,但是它们约束了运行时间,并且可能会限制性能。无序队列更复杂,但是它们给了运行时更多的重新排序和重叠执行的自由。如果我们的程序在内核之间有复杂的依赖关系,无序队列类是正确的选择。如果我们的程序只是一个接一个地运行许多内核,那么有序队列将是我们更好的选择。

处理程序类:关键成员

我们已经展示了许多使用handler类的方法。图 3-17 和 3-18 提供了这个非常重要的类别的关键成员的更详细的解释。我们还没有使用所有这些成员,但是在本书的后面会用到它们。这是摆放它们的最佳地点。

一个密切相关的类,即queue类,在第二章的结尾有类似的解释。在线 oneAPI DPC++ 语言参考对这两个类提供了更详细的解释。

img/489625_1_En_3_Fig18_HTML.png

图 3-18

处理程序类的访问器成员的简化定义

img/489625_1_En_3_Fig17_HTML.png

图 3-17

处理程序类的非访问器成员的简化定义

摘要

在这一章中,我们介绍了解决数据管理问题的机制以及如何安排数据的使用。使用加速器设备时,管理对不同内存的访问是一个关键挑战,我们有不同的选项来满足我们的需求。

我们概述了数据使用之间可能存在的不同类型的依赖关系,并描述了如何向队列提供关于这些依赖关系的信息,以便它们正确地对任务进行排序。

本章概述了统一共享内存和缓冲区。我们将在第六章中更详细地探讨 USM 的所有模式和行为。第七章将更深入地探索缓冲区,包括创建缓冲区和控制其行为的所有不同方法。第八章将再次讨论控制内核执行和数据移动顺序的队列调度机制。

Creative Commons

开放存取本章根据知识共享署名 4.0 国际许可证(http://Creative Commons . org/licenses/by/4.0/)的条款获得许可,该许可证允许以任何媒体或格式使用、共享、改编、分发和复制,只要您适当注明原作者和来源,提供知识共享许可证的链接并指明是否进行了更改。

本章中的图像或其他第三方材料包含在本章的知识共享许可中,除非在材料的信用额度中另有说明。如果材料不包括在本章的知识共享许可中,并且您的预期使用不被法律法规允许或超出了允许的使用范围,您将需要直接从版权所有者处获得许可。

四、表达并行

img/489625_1_En_4_Figa_HTML.gif

现在我们可以把第一批拼图拼在一起了。我们已经知道如何在设备上放置代码(第二章)和数据(第三章)——我们现在必须做的就是决定如何处理它们。为此,我们现在来补充一些我们到目前为止方便地忽略或掩饰的东西。本章标志着从简单的教学示例到真实世界并行代码的过渡,并扩展了我们在前面章节中随意展示的代码示例的细节。

用一种新的并行语言编写我们的第一个程序似乎是一项艰巨的任务,尤其是如果我们是并行编程的新手。语言规范不是为应用程序开发人员编写的,通常假设他们熟悉一些术语;它们不包含以下问题的答案:

  • 为什么排比的表达方式不止一种?

  • 我应该用哪种表达排比的方法?

  • 我真的需要了解执行模型多少?

本章试图解决这些问题以及更多的问题。我们介绍了数据并行内核的概念,使用工作代码示例讨论了不同内核形式的优缺点,并强调了内核执行模型最重要的方面。

内核内部的并行性

近年来,并行内核作为一种表达数据并行性的强大手段出现了。基于内核的方法的主要设计目标是跨多种设备的可移植性和高程序员生产率。这样,内核通常不被硬编码以与特定数量或配置的硬件资源(例如,内核、硬件线程、SIMD[单指令、多数据]指令)一起工作。相反,内核根据抽象概念来描述并行性,实现(即编译器和运行时的组合)然后可以映射到特定目标设备上可用的硬件并行性。尽管这种映射是由实现定义的,但是我们可以(也应该)相信实现会选择一种合理的、能够有效利用硬件并行性的映射。

以独立于硬件的方式展示大量并行性可确保应用程序可以扩展(或缩小)以适应不同平台的功能,但是…

保证功能的可移植性并不等同于保证高性能!

支持的器件有很大的多样性,我们必须记住,不同的架构是针对不同的使用情况而设计和优化的。每当我们希望在特定设备上实现最高水平的性能时,我们应该总是期望需要一些额外的手动优化工作——不管我们使用的是什么编程语言!这种特定于设备的优化的示例包括针对特定高速缓存大小的分块、选择分摊调度开销的粒度、利用专门的指令或硬件单元,以及最重要的是,选择适当的算法。其中一些例子将在第 15 、 16 和 17 章中再次出现。

在应用程序开发过程中,在性能、可移植性和生产力之间取得恰当的平衡是我们都必须面对的挑战——也是本书无法完全解决的挑战。然而,我们希望表明,DPC++ 提供了使用一种高级编程语言来维护通用可移植代码和优化的特定于目标的代码所需的所有工具。剩下的留给读者作为练习!

多维核

许多其他语言的并行构造是一维的,将工作直接映射到相应的一维硬件资源(例如,硬件线程的数量)。并行内核是比这更高级的概念,它们的维度更能反映我们的代码通常试图解决的问题(在一维、二维或三维空间中)。

然而,我们必须记住,由并行内核提供的多维索引是在底层一维空间之上实现的,方便了程序员。理解这种映射的行为是某些优化的重要部分(例如,调整内存访问模式)。

一个重要的考虑是哪个维度是连续的单位步长(即,多维空间中的哪些位置在一维空间中彼此相邻)。SYCL 中与并行性相关的所有多维量都使用相同的约定:维度从 0 到 N-1 编号,其中维度 N-1 对应于连续维度。在多维数量被写成列表(例如,在构造器中)或者一个类支持多个下标操作符的地方,这种编号从左到右应用。这个约定与标准 C++ 中多维数组的行为一致。

图 4-1 显示了一个使用 SYCL 约定将二维空间映射到线性索引的例子。我们当然可以打破这一惯例,采用自己的指数线性化方法,但必须谨慎行事,因为打破 SYCL 惯例可能会对受益于 stride-1 访问的器件产生负面性能影响。

img/489625_1_En_4_Fig1_HTML.png

图 4-1

映射到线性索引的二维大小范围(2,8)

如果一个应用程序需要三个以上的维度,我们必须负责使用模运算手动映射多维和线性索引。

循环与内核

迭代循环是一种内在的串行结构:循环的每次迭代都是按顺序执行的(即,按次序)。优化编译器可能能够确定循环的一些或所有迭代可以并行执行,但它必须是保守的——如果编译器不够智能或没有足够的信息来证明并行执行总是安全的,它必须保持循环的顺序语义的正确性。

img/489625_1_En_4_Fig2_HTML.png

图 4-2

将向量加法表示为串行循环

考虑图 4-2 中的循环,它描述了一个简单的向量加法。即使在这种简单的情况下,证明循环可以并行执行也不是小事:只有当c不与ab重叠时,并行执行才是安全的,在一般情况下,没有运行时检查就无法证明这一点!为了解决这种情况,语言增加了一些功能,使我们能够为编译器提供额外的信息,这些信息可以简化分析(例如,断言指针不与restrict重叠)或完全覆盖所有分析(例如,声明循环的所有迭代都是独立的,或准确定义如何将循环调度到并行资源)。

并行循环的确切含义有些模糊——由于不同的并行编程语言对该术语的重载——但是许多常见的并行循环结构表示应用于顺序循环的编译器转换。这种编程模型使我们能够编写连续的循环,并在以后提供关于如何安全并行执行不同迭代的信息。这些模型非常强大,可以与其他最新的编译器优化很好地集成,并极大地简化了并行编程,但并不总是鼓励我们在开发的早期阶段考虑并行性。

并行内核不是一个循环,没有迭代。更确切地说,一个内核描述了一个单一的操作,它可以被实例化多次并应用于不同的输入数据;当内核并行启动时,该操作的多个实例同时执行。

img/489625_1_En_4_Fig3_HTML.png

图 4-3

将循环重写(用伪代码)为并行内核

图 4-3 显示了我们使用伪代码重写为内核的简单循环示例。这个内核中并行性的机会是清楚而明确的:内核可以由任意数量的实例并行执行,并且每个实例独立地应用于单独的数据。通过将该操作编写为内核,我们断言并行运行是安全的(理想情况下应该是安全的)。

简而言之,基于内核的编程不是一种将并行性改进到现有顺序代码中的方法,而是一种用于编写显式并行应用程序的方法。

我们越早将我们的思维从并行循环转移到内核,使用数据并行 C++ 编写有效的并行程序就越容易。

语言功能概述

一旦我们决定编写一个并行内核,我们必须决定我们想要启动什么类型的内核,以及如何在我们的程序中表示它。表达并行内核的方式有很多种,如果我们想掌握这门语言,我们需要熟悉每一种方式。

从主机代码中分离内核

我们有几种分离主机和设备代码的替代方法,我们可以在应用程序中混合和匹配它们:C++ lambda 表达式或函数对象(函子)、OpenCL C 源代码字符串或二进制文件。这些选项中的一些已经在第二章中介绍过了,所有这些选项都将在第十章中详细介绍。

所有这些选项都共享表达并行性的基本概念。为了一致和简洁,本章中的所有代码示例都使用 C++ lambdas 表示内核。

Lambdas Not Considered Harmful

为了开始使用 DPC++,不需要完全理解 C++ 规范中关于 lambda 的所有内容——我们只需要知道 lambda 的主体表示内核,并且捕获的变量(通过值)将作为参数传递给内核。

使用 lambdas 而不是更详细的机制来定义内核不会对性能产生影响。DPC++ 编译器总是能理解 lambda 何时代表并行内核的主体,并能相应地针对并行执行进行优化。

关于 C++ lambda 函数的复习,以及它们在 SYCL 中的用法,请参见第一章。关于使用 lambdas 定义内核的更多细节,请参见第十章。

不同形式的并行内核

有三种不同的内核形式,支持不同的执行模型和语法。使用任何内核形式编写可移植的内核都是可能的,并且以任何形式编写的内核都可以进行调整,以在各种设备类型上实现高性能。然而,有时我们可能希望使用特定的形式来使特定的并行算法更容易表达,或者利用否则无法访问的语言功能。

第一种形式用于基本的数据并行内核,并为编写内核提供了最温和的介绍。对于基本内核,我们牺牲了对调度等底层特性的控制,以使内核的表达尽可能简单。单个内核实例如何映射到硬件资源完全由实现来控制,因此随着基本内核复杂性的增加,推断它们的性能变得越来越困难。

第二种形式扩展了基本内核,以提供对低级性能调优特性的访问。出于历史原因,这第二种形式被称为 ND-range (N 维范围)数据并行,要记住的最重要的事情是,它使某些内核实例能够被分组在一起,允许我们对数据局部性以及各个内核实例和将用于执行它们的硬件资源之间的映射进行一些控制。

第三种形式提供了另一种语法,使用嵌套的内核结构来简化 ND 范围内核的表达式。这第三种形式被称为分层数据并行,指的是出现在用户源代码中的嵌套内核结构的层次结构。

一旦我们更详细地讨论了它们的特性,我们将在本章的最后再次讨论如何在不同的内核形式之间进行选择。

基本数据并行内核

并行内核的最基本形式适用于令人尴尬的并行操作(例如,可以完全独立地以任何顺序应用于每一段数据的操作)。通过使用这种形式,我们可以让实现完全控制工作的调度。因此,这是一个描述性编程结构的例子——我们描述操作是令人尴尬的并行操作,所有的调度决策都由实现做出。

基本的数据并行内核是以单个程序、多数据(SPMD)风格编写的——单个“程序”(内核)应用于多个数据片段。注意,这种编程模型仍然允许内核的每个实例在代码中采用不同的路径,这是数据相关分支的结果。

SPMD 编程模型的最大优势之一是,它允许同一个“程序”映射到多个并行级别和类型,而无需我们给出任何明确的指示。同一个程序的实例可以流水线化,打包在一起用 SIMD 指令执行,分布在多个线程上,或者三者兼而有之。

理解基本数据-并行内核

一个基本并行内核的执行空间称为其执行范围,内核的每个实例称为一个。这在图 4-4 中有图解表示。

img/489625_1_En_4_Fig4_HTML.png

图 4-4

基本并行内核的执行空间,显示了 64 个项目的 2D 范围

基本数据并行内核的执行模型非常简单:它允许完全并行执行,但不保证需要它。项目可以按任何顺序执行,包括在单个硬件线程上按顺序执行(即没有任何并行性)!假设所有项目将被并行执行(例如,通过尝试同步项目)的内核因此非常容易导致程序在一些设备上挂起。

然而,为了保证正确性,我们必须总是在假设它们可以被并行执行的情况下编写我们的内核。例如,我们有责任确保对内存的并发访问被原子内存操作适当地保护(见第十九章),以防止竞争情况。

编写基本数据-并行内核

基本的数据并行内核使用parallel_for函数来表示。图 4-5 展示了如何使用这个函数来表达一个向量加法,这是我们对“你好,世界!”用于并行加速器编程。

img/489625_1_En_4_Fig5_HTML.png

图 4-5

parallel_for表示向量加法核

该函数只接受两个参数:第一个参数是指定在每个维度中启动的项目数量的range,第二个参数是为该范围中的每个索引执行的内核函数。有几个不同的类可以作为内核函数的参数,应该使用哪个取决于哪个类公开了所需的功能——我们将在后面再讨论这个问题。

图 4-6 显示了该函数的一个非常类似的用法来表示矩阵加法,除了二维数据之外,它(在数学上)与向量加法相同。这反映在内核中——两个代码片段之间的唯一区别是所使用的rangeid类的维度!这样写代码是可能的,因为一个 SYCL accessor可以被一个多维id索引。虽然看起来很奇怪,但这可能非常强大,使我们能够根据数据的维度编写内核模板。

img/489625_1_En_4_Fig6_HTML.png

图 4-6

parallel_for表示矩阵加法核

在 C/C++ 中更常见的是使用多个索引和多个下标操作符来索引多维数据结构,并且这种显式索引也受到访问器的支持。当内核同时对不同维度的数据进行操作时,或者当内核的内存访问模式比直接使用项目的id更复杂时,以这种方式使用多个索引可以提高可读性。

例如,图 4-7 中的矩阵乘法内核必须提取索引的两个独立分量,以便能够描述两个矩阵的行和列之间的点积。在我们看来,一致地使用多个下标操作符(如[j][k])比混合多种索引模式和构造二维id对象(如id(j,k))更具可读性,但这只是个人喜好问题。

本章剩余部分的例子都使用了多个下标操作符,以确保被访问的缓冲区的维数没有歧义。

img/489625_1_En_4_Fig8_HTML.png

图 4-8

将矩阵乘法工作映射到执行范围内的项目

img/489625_1_En_4_Fig7_HTML.png

图 4-7

parallel_for表示方阵的简单矩阵乘法核

图 4-8 中的图表显示了矩阵乘法内核中的工作是如何映射到单个项目的。注意,项目的数量来自于输出范围的大小,并且相同的输入值可以由多个项目读取:每个项目通过顺序迭代 A 矩阵的(连续)行和 B 矩阵的(非连续)列来计算 C 矩阵的单个值。

基本数据的细节-并行内核

基本数据并行内核的功能通过三个 C++ 类公开:rangeiditem。我们已经在前面的章节中见过几次rangeid类,但是我们在这里以不同的焦点重新审视它们。

range

range代表一维、二维或三维范围。range的维度是一个模板参数,因此必须在编译时知道,但是它在每个维度上的大小是动态的,在运行时传递给构造器。range类的实例用于描述并行结构的执行范围和缓冲区的大小。

图 4-9 显示了range类的简化定义,显示了构造器和查询其范围的各种方法。

img/489625_1_En_4_Fig9_HTML.png

图 4-9

range类的简化定义

id

id表示一维、二维或三维范围的索引。id的定义在许多方面与range相似:它的维数在编译时也必须是已知的,并且它可以用于索引并行结构中内核的单个实例或缓冲区中的偏移量。

如图 4-10 中id类的简化定义所示,id在概念上只不过是一个、两个或三个整数的容器。我们可用的操作也非常简单:我们可以在每个维度中查询索引的组成部分,并且我们可以执行简单的运算来计算新的索引。

虽然我们可以构造一个id来表示任意的索引,但是为了获得与特定内核实例相关联的id,我们必须接受它(或者包含它的item)作为内核函数的参数。这个id(或者由它的成员函数返回的值)必须被转发到我们想要在其中查询索引的任何函数——目前没有任何免费的函数可以在程序中的任意点查询索引,但是这可能会在 DPC++ 的未来版本中解决。

接受id的内核的每个实例只知道它被分配计算的范围中的索引,而对范围本身一无所知。如果我们希望我们的内核实例知道它们自己的索引范围,我们需要使用item类来代替。

img/489625_1_En_4_Fig10_HTML.png

图 4-10

id类的简化定义

item

一个item代表一个内核函数的单个实例,封装了内核的执行范围和该范围内实例的索引(分别使用一个range和一个id)。像rangeid一样,它的维数必须在编译时已知。

图 4-11 给出了item等级的简化定义。itemid的主要区别在于item公开了额外的函数来查询执行范围的属性(例如,大小、偏移量)以及计算线性化索引的便利函数。与id一样,获得与特定内核实例相关联的item的唯一方式是接受它作为内核函数的参数。

img/489625_1_En_4_Fig11_HTML.png

图 4-11

item类的简化定义

显式 ND 范围核

第二种形式的并行内核用一个项目属于组的执行范围代替了基本数据并行内核的平面执行范围,并且适用于我们希望在内核中表达一些局部性概念的情况。为不同类型的组定义和保证不同的行为,使我们能够更深入地了解和/或控制如何将工作映射到特定的硬件平台。

因此,这些显式的 ND-range 内核是一个更加规定的并行构造的例子——我们规定将工作映射到每种类型的组,并且实现必须服从该映射。然而,它并不是完全规定的,因为组本身可以以任何顺序执行,并且实现在如何将每种类型的组映射到硬件资源上保留了一些自由。这种说明性和描述性编程的结合使我们能够针对局部性设计和调优我们的内核,而不影响它们的可移植性。

像基本的数据并行内核一样,ND-range 内核以 SPMD 风格编写,其中所有工作项执行应用于多条数据的相同内核“程序”。关键的区别在于,每个程序实例可以查询它在包含它的组中的位置,并且可以访问特定于每种类型的组的附加功能。

理解显式 ND 范围并行核

ND-range 内核的执行范围被分为工作组、子组和工作项目。ND-range 表示总的执行范围,该范围被划分成统一大小的工作组(即,工作组大小必须在每个维度上精确地划分 ND-range 大小)。每个工作组可以通过实现进一步划分为子组。理解为工作项和每种类型的组定义的执行模型是编写正确的可移植程序的重要部分。

图 4-12 显示了一个 ND 尺寸范围(8,8,8)的示例,该范围分为 8 个尺寸工作组(4,4,4)。每个工作组包含 4 个工作项的 16 个一维子组。请仔细注意维度的编号:子组始终是一维的,因此 nd 范围和工作组的维度 2 成为子组的维度 0。

img/489625_1_En_4_Fig12_HTML.png

图 4-12

分为工作组、子组和工作项目的三维 ND-range

从每种类型的组到硬件资源的精确映射是实现定义的,正是这种灵活性使得程序能够在各种各样的硬件上执行。例如,工作项目可以完全顺序执行,由硬件线程和/或 SIMD 指令并行执行,或者甚至由为特定内核专门配置的硬件流水线执行。

在这一章中,我们只关注 ND-range 执行模型在通用目标平台方面的语义保证,我们不会涉及它到任何一个平台的映射。分别参见第 15 、 16 和 17 章了解 GPU、CPU 和 FPGAs 的硬件映射和性能建议的详细信息。

工作项目

工作项代表一个内核函数的单个实例。在没有其他分组的情况下,工作项目可以以任何顺序执行,并且不能相互通信或同步,除非通过对全局内存的原子内存操作(见第十九章)。

工作组

ND 范围中的工作项被组织成工作组。工作组可以以任何顺序执行,不同工作组中的工作项目不能相互通信,除非通过对全局内存的原子内存操作(见第十九章)。然而,当使用某些结构时,一个工作组中的工作项具有并发调度保证,并且这种局部性提供了一些额外的能力:

  1. 一个工作组中的工作项目可以访问工作组本地存储器,它可以映射到一些设备上的专用快速存储器(参见第九章)。

  2. 一个工作组中的工作项可以使用工作组屏障来同步,并使用工作组内存屏障来保证内存一致性(参见第九章)。

  3. 工作组中的工作项目可以访问组功能,提供通用通信例程(参见第九章)和通用并行模式(如缩减和扫描)的实现(参见第十四章)。

通常在运行时为每个内核配置工作组中的工作项目数量,因为最佳分组将取决于可用的并行性数量(即 nd 范围的大小)和目标设备的属性。我们可以使用device类的查询函数来确定特定设备支持的每个工作组的最大工作项目数(参见第十二章),我们有责任确保每个内核请求的工作组大小是有效的。

工作组执行模型中有一些微妙之处值得强调。

首先,尽管工作组中的工作项目被调度到单个计算单元,但是在工作组的数量和计算单元的数量之间不需要任何关系。事实上,ND-range 中的工作组数量可能比给定设备可以并发执行的工作组数量大很多倍!我们可能会尝试通过依赖非常聪明的特定于设备的调度来编写跨工作组同步的内核,但我们强烈建议不要这样做——这样的内核今天可能看起来可以工作,但不能保证它们可以在未来的实现中工作,并且在移动到不同的设备时很可能会崩溃。

第二,尽管一个工作组中的工作项是并发调度的,但不能保证它们独立地向前进展——在一个工作组内,在障碍和集合之间顺序地执行工作项是一种有效的实现。只有在使用提供的屏障和集合函数执行时,才能保证同一工作组中工作项之间的通信和同步是安全的,并且手工编码的同步例程可能会死锁。

Thinking in Work-Groups

工作组在许多方面类似于其他编程模型中的任务概念(例如,线程构建块):任务可以以任何顺序执行(由调度器控制);让一台机器超额预定任务是可能的(甚至是可取的);试图在一组任务之间实现屏障通常不是一个好主意(因为它可能非常昂贵或者与调度器不兼容)。如果我们已经熟悉了基于任务的编程模型,我们可能会发现将工作组想象成数据并行任务是很有用的。

子群体

在许多现代硬件平台上,工作组中被称为子组的工作项目子集在额外的调度保证下执行。例如,作为编译器向量化的结果,子组中的工作项目可以同时执行,和/或子组本身可以在向前进度保证下执行,因为它们被映射到独立的硬件线程。

当使用单一平台时,很容易将关于这些执行模型的假设融入到我们的代码中,但这使得它们本质上不安全且不可移植——当在不同编译器之间移动时,甚至当在同一供应商的不同代硬件之间移动时,它们可能会中断!

将子组定义为语言的核心部分,为我们提供了一个安全的替代方案,来做出后来可能被证明是特定于设备的假设。利用子组功能还允许我们在较低的级别(即,接近硬件)推理工作项目的执行,并且是在许多平台上实现非常高的性能水平的关键。

与工作组一样,子组中的工作项可以同步,保证内存一致性,或者通过组函数执行常见的并行模式。但是,对于子组,没有等效的工作组本地内存(即,没有子组本地内存)。相反,子组中的工作项可以使用混洗操作(第九章)直接交换数据,而不需要显式的内存操作。

子组的某些方面是实现定义的,不在我们的控制之内。然而,对于设备、内核和 ND-range 的给定组合,一个子组有一个固定的(一维)大小,我们可以使用kernel类的查询函数来查询这个大小(参见第十章)。默认情况下,每个子组的工作项数量也是由实现选择的——我们可以通过在编译时请求特定的子组大小来覆盖这种行为,但是必须确保我们请求的子组大小与设备兼容。

像工作组一样,子组中的工作项只能保证并发执行——实现可以自由地顺序执行子组中的每个工作项,并且只在遇到子组集合函数时在工作项之间切换。子组的特殊之处在于,一些设备保证它们独立地向前进展——在一些设备上,一个工作组内的所有子组都保证最终执行(取得进展),这是几个生产者-消费者模式的基石。可以使用设备查询来确定这种独立的前向进度保证是否成立。

Thinking in Sub-Groups

如果我们来自一个要求我们考虑显式矢量化的编程模型,那么将每个子组视为一组打包到 SIMD 寄存器中的工作项可能是有用的,其中子组中的每个工作项对应于一个 SIMD 通道。当多个子组同时运行,并且设备保证它们将向前推进时,这种心理模型扩展到将每个子组视为并行执行的独立矢量指令流。

img/489625_1_En_4_Fig13_HTML.png

图 4-13

用 ND-range 表示一个简单的矩阵乘法核parallel_for

编写显式 ND 范围数据并行内核

图 4-13 重新实现了我们之前看到的使用 ND-range 并行内核语法的矩阵乘法内核,图 4-14 中的图表显示了该内核中的工作如何映射到每个工作组中的工作项目。以这种方式对我们的工作项进行分组确保了访问的局部性,并且有望提高缓存命中率:例如,图 4-14 中的工作组具有(4,4)的局部范围,并且包含 16 个工作项,但是访问的数据是单个工作项的四倍——换句话说,我们从内存中加载的每个值都可以重用四次。

img/489625_1_En_4_Fig14_HTML.png

图 4-14

将矩阵乘法映射到工作组和工作项

到目前为止,我们的矩阵乘法示例依赖于硬件缓存来优化来自同一工作组中的工作项对 A 和 B 矩阵的重复访问。这种硬件高速缓存在传统 CPU 架构上很常见,并且在 GPU 架构上变得越来越常见,但是还有其他具有显式管理的“便笺式”存储器的架构(例如,上一代 GPU、FPGAs)。ND-range 内核可以使用本地访问器来描述应该放在工作组本地内存中的分配,然后实现可以自由地将这些分配映射到特殊内存(如果存在的话)。该工作组本地存储器的使用将在第九章中介绍。

显式 ND 范围数据并行内核的详细信息

与基本数据并行内核相比,ND-range 数据并行内核使用不同的类:rangend_range ,代替,itemnd_item代替。还有两个新的类,代表一个工作项可能属于的不同类型的组:绑定到工作组的功能封装在group类中,绑定到子组的功能封装在sub_group类中。

nd_range

一个nd_range使用两个range类的实例表示一个分组的执行范围:一个表示全局执行范围,另一个表示每个工作组的局部执行范围。图 4-15 给出了nd_range等级的简化定义。

可能有点奇怪的是,nd_range类根本没有提到子组:子组范围在构造时没有指定,无法查询。这一遗漏有两个原因。首先,子组是底层的实现细节,对于许多内核来说可以忽略。其次,有几个设备正好支持一个有效的子组大小,在任何地方指定这个大小都是不必要的冗长。所有与子组相关的功能都封装在一个专门的类中,稍后将讨论这个类。

img/489625_1_En_4_Fig15_HTML.png

图 4-15

nd_range类的简化定义

nd_item

一个nd_item是一个item的 ND-range 形式,同样封装了内核的执行范围和该范围内的项目索引。nd_itemitem的不同之处在于其在范围中的位置是如何查询和表示的,如图 4-16 中简化的类定义所示。例如,我们可以使用get_global_id()函数查询(全局)ND 范围中的项目索引,或者使用get_local_id()函数查询(本地)父工作组中的项目索引。

nd_item类还提供了获取描述项目所属的组和子组的类的句柄的函数。这些类为查询 ND 范围内的项目索引提供了另一种接口。我们强烈建议使用这些类来编写内核,而不是直接依赖于nd_item:使用groupsub_group类通常更干净,更清楚地传达意图,并且更符合 DPC++ 的未来方向。

img/489625_1_En_4_Fig16_HTML.png

图 4-16

nd_item类的简化定义

group

group类封装了所有与工作组相关的功能,简化的定义如图 4-17 所示。

img/489625_1_En_4_Fig17_HTML.png

图 4-17

group类的简化定义

group类提供的许多函数在nd_item类中都有等价的函数:例如,调用group.get_id()相当于调用item.get_group_id(),,调用group.get_local_range()相当于调用item.get_local_range().如果我们没有使用该类公开的任何工作组函数,我们还应该使用它吗?直接使用nd_item中的函数,而不是创建一个中间的group对象,不是更简单吗?这里有一个折衷:使用group需要我们编写稍微多一点的代码,但是这些代码可能更容易阅读。例如,考虑图 4-18 中的代码片段:很明显body期望被group中的所有工作项调用,很明显parallel_for体中的get_local_range()返回的rangegroup的范围。同样的代码可以很容易地只用nd_item来编写,但是读者可能很难理解。

img/489625_1_En_4_Fig18_HTML.png

图 4-18

使用group类提高可读性

sub_group

sub_group类封装了与子组相关的所有功能,简化的定义如图 4-19 所示。与工作组不同,sub_group类是访问子组功能的唯一方式;它的功能在nd_item中没有任何重复。sub_group类中的查询都是相对于调用工作项来解释的:例如,get_local_id()返回子组中调用工作项的本地索引。

img/489625_1_En_4_Fig19_HTML.png

图 4-19

sub_group类的简化定义

注意,有单独的函数用于查询当前子组中的工作条目的数量以及工作组内任何子组中的工作条目的最大数量。这些是否不同以及如何不同取决于子组对于特定设备是如何实现的,但是目的是反映编译器所针对的子组大小和运行时子组大小之间的任何差异。例如,非常小的工作组可能包含比编译时子组大小更少的工作项,或者不同大小的子组可用于处理不能被子组大小整除的工作组。

分层并行核

分层数据并行内核提供了一种实验性的替代语法,用于根据工作组和工作项来表达内核,其中使用嵌套调用parallel_for函数来编程分层的每一层。这种自顶向下的编程风格旨在类似于编写并行循环,可能比其他两种内核形式使用的自底向上的编程风格更熟悉。

分层内核的一个复杂性是对parallel_for的每次嵌套调用都会创建一个单独的 SPMD 环境;每个作用域定义了一个新的“程序”,该程序应该由与该作用域相关联的所有并行工作器执行。这种复杂性要求编译器执行额外的分析,并且会使某些设备的代码生成变得复杂;某些平台上的分层并行内核的编译器技术仍然相对不成熟,性能将与特定编译器实现的质量紧密相关。

由于分层数据并行内核与为特定设备生成的代码之间的关系依赖于编译器,因此分层内核应被视为比显式 ND-range 内核更具描述性的构造。然而,由于分级内核保留了控制工作到工作项和工作组的映射的能力,它们比基本内核更具规定性

理解分层数据-并行内核

分层数据并行内核的底层执行模型与显式 ND 范围数据并行内核的执行模型相同。工作项、子组和工作组具有相同的语义和执行保证。

然而,编译器将分层内核的不同范围映射到不同的执行资源:外部范围为每个工作组执行一次(如同由单个工作项目执行),而内部范围由工作组内的工作项目并行执行。不同的作用域还控制着不同变量在内存中的分配位置,作用域的打开和关闭意味着工作组的障碍(以加强内存的一致性)。

尽管一个工作组中的工作项仍然被分成子组,但是目前不能从一个分层的并行内核中访问sub_group类;将子组的概念结合到 SYCL 层次并行中需要比引入一个新类更大的改变,这方面的工作正在进行中。

编写分层数据并行内核

在分层内核中,parallel_for函数被parallel_for_work_groupparallel_for_work_item函数所取代,它们分别对应于工作组和工作项并行性。在parallel_for_work_group范围内的任何代码对于每个工作组只执行一次,在parallel_for_work_group范围内分配的变量对于所有工作项都是可见的(也就是说,它们被分配在工作组本地内存中)。parallel_for_work_item范围内的任何代码都由工作组的工作项并行执行,分配在parallel_for_work_item范围内的变量对单个工作项可见(即,它们被分配在工作项私有内存中)。

如图 4-20 所示,使用层次并行表示的内核与 ND-range 内核非常相似。因此,我们应该将层次并行主要视为一种生产力特征;它不会暴露任何尚未通过 ND-range 内核暴露的功能,但它可能会提高我们代码的可读性和/或减少我们必须编写的代码量。

img/489625_1_En_4_Fig20_HTML.png

图 4-20

用层次并行表达一个简单的矩阵乘法核

值得注意的是,传递给parallel_for_work_group函数的范围指定了组的数量和可选的组大小,没有指定工作项目的总数和组大小,就像 ND-range parallel_for的情况一样。内核函数接受一个group类的实例,反映出外部作用域与工作组相关联,而不是与单个工作项相关联。

parallel_for_work_itemgroup类的成员函数,只能在parallel_for_work_group范围内调用。在其最简单的形式中,它唯一的参数是一个接受h_item类实例的函数,该函数执行的次数等于每个工作组请求的工作项的数量;该功能按物理工作项目执行一次。parallel_for_work_item的一个额外的生产力特性是它能够支持一个逻辑范围,这个范围作为一个额外的参数传递给函数。当指定了逻辑范围时,每个物理工作项目执行零个或多个函数实例,并且逻辑范围的逻辑项目被分配给物理工作项目的循环。

图 4-21 显示了由 11 个逻辑工作项组成的逻辑范围和由 8 个物理工作项组成的底层物理范围之间的映射示例。前三个工作项被分配了两个函数实例,所有其他工作项只被分配了一个。

img/489625_1_En_4_Fig21_HTML.png

图 4-21

将大小为 11 的逻辑范围映射到大小为 8 的物理范围

如图 4-22 所示,将可选的组大小parallel_for_work_group与逻辑范围parallel_for_work_item结合起来,实现可以自由选择工作组大小,而不会牺牲我们使用嵌套并行结构方便地描述执行范围的能力。请注意,每组完成的工作量与图 4-20 中的相同,但是工作量已经从实际的工作组规模中分离出来。

img/489625_1_En_4_Fig22_HTML.png

图 4-22

用分层并行性和逻辑范围表达一个简单的矩阵乘法核心

分层数据并行内核的详细信息

分层数据并行内核重用了 ND-range 数据并行内核中的group类,但是用h_item替换了nd_item。引入了一个新的private_memory类来对parallel_for_work_group范围内的分配提供更严格的控制。

h_item

一个h_item是一个item的变体,它只在一个parallel_for_work_item范围内可用。如图 4-23 所示,它提供了一个与nd_item类似的接口,有一个显著的区别:该项的索引可以相对于一个工作组的物理执行范围(用get_physical_local_id())或者一个parallel_for_work_item构造的逻辑执行范围(用get_logical_local_id())来查询。

img/489625_1_En_4_Fig23_HTML.png

图 4-23

h_item类的简化定义

private_memory

private_memory类提供了一种机制来声明每个工作项私有的变量,但是这些变量可以通过嵌套在同一个parallel_for_work_group范围内的多个parallel_for_work_item构造来访问。

这个类是必要的,因为在不同的层次并行作用域中声明的变量的行为方式:如果编译器可以证明这样做是安全的,在外部作用域中声明的变量才是私有的,而在内部作用域中声明的变量是逻辑工作项而不是物理工作项的私有变量。对于我们来说,仅仅使用作用域来表达一个变量对于每个物理工作项来说是私有的是不可能的。

为了了解为什么这是一个问题,让我们回到图 4-22 中的矩阵乘法内核。ibjb变量是在parallel_for_work_group范围内声明的,默认情况下应该分配在工作组本地内存中!一个优化的编译器很有可能不会犯这个错误,因为变量是只读的,它们的值足够简单,可以在每个工作项上进行冗余计算,但是语言没有这样的保证。如果我们想确定变量是在工作项私有内存中声明的,我们必须将变量声明包装在private_memory类的实例中,如图 4-24 所示。

img/489625_1_En_4_Fig24_HTML.png

图 4-24

private_memory类的简化定义

例如,如果我们要使用private_memory类重写矩阵乘法内核,我们会将变量定义为private_memory<int> ib(grp),并且对这些变量的每次访问都会变成ib[item]。在这种情况下,使用private_memory类会导致代码更难阅读,而在parallel_for_work_item范围内声明值会更清晰。

我们的建议是,如果一个工作项私有变量在同一个parallel_for_work_group内的多个parallel_for_work_item范围内使用,重复计算的代价太大,或者它的计算有副作用,阻止它被冗余地计算,那么只使用private_memory类。否则,我们应该默认依赖现代优化编译器的能力,只有在分析失败时才在parallel_for_work_item范围内声明变量(记住还要向编译器供应商报告这个问题)。

将计算映射到工作项

到目前为止,大多数代码示例都假设一个内核函数的每个实例对应于对一段数据的单个操作。这是一种编写内核的简单方法,但是这种一对一的映射并不是由 DPC++ 或任何内核形式决定的——我们总是能够完全控制数据(和计算)到单个工作项的分配,并且使这种分配参数化是提高性能可移植性的好方法。

一对一映射

当我们编写内核时,工作与工作项之间存在一对一的映射,这些内核必须总是以大小与需要完成的工作量完全匹配的rangend_range启动。这是编写内核最显而易见的方式,在许多情况下,它工作得非常好——我们可以相信一个实现可以有效地将工作项映射到硬件。

但是,在针对系统和实现的特定组合进行性能调优时,可能有必要更加关注底层调度行为。计算资源的工作组调度是由实现定义的,并且可能是动态的(即,当计算资源完成一个工作组时,它执行的下一个工作组可能来自共享队列)。动态调度对性能的影响不是固定的,并且其重要性取决于包括内核功能的每个实例的执行时间以及调度是在软件(例如,在 CPU 上)还是硬件(例如,在 GPU 上)中实现的因素。

多对一映射

另一种方法是编写工作到工作项的多对一映射的内核。在这种情况下,范围的含义发生了微妙的变化:范围不再描述要完成的工作量,而是要使用的工人数量。通过改变工人的数量和分配给每个工人的工作量,我们可以微调工作分配以最大化性能。

编写这种形式的内核需要做两处修改:

  1. 内核必须接受一个描述工作总量的参数。

  2. 内核必须包含一个将工作分配给工作项的循环。

图 4-25 给出了这种内核的一个简单例子。注意内核内部的循环有一个稍微不寻常的形式——起始索引是工作项在全局范围内的索引,步距是工作项的总数。数据到工作项的这种循环调度确保循环的所有N迭代将由一个工作项执行,而且线性工作项访问连续的内存位置(以改善缓存局部性和矢量化行为)。工作可以类似地跨组分布,或者将工作项分布在单个组中,以进一步提高局部性。

img/489625_1_En_4_Fig25_HTML.png

图 4-25

具有独立数据和执行范围的内核

这些工作分配模式很常见,当使用具有逻辑范围的分层并行时,可以非常简洁地表达它们。我们期望 DPC++ 的未来版本将引入语法糖来简化 ND-range 内核中工作分配的表达。

选择内核形式

在不同的内核形式之间进行选择很大程度上是个人偏好的问题,并且受到其他并行编程模型和语言的经验的严重影响。

选择特定内核形式的另一个主要原因是,它是公开内核所需的某些功能的唯一形式。不幸的是,在开发开始之前很难确定哪些功能是必需的——尤其是当我们还不熟悉不同的内核形式以及它们与各种类的交互时。

为了帮助我们驾驭这个复杂的空间,我们根据自己的经验编写了两本指南。这些指南应该被认为是经验法则,绝对不是要取代我们自己的实验——在不同的内核形式之间进行选择的最佳方式总是花一些时间来编写每一种形式,以便了解哪种形式最适合我们的应用程序和开发风格。

第一个指南是图 4-26 中的流程图,它选择一个内核表单基于

img/489625_1_En_4_Fig26_HTML.png

图 4-26

帮助我们为内核选择正确的形式

  1. 我们是否有并行编程的经验

  2. 无论我们是从头开始编写新代码,还是移植用不同语言编写的现有并行程序

  3. 我们的内核是令人尴尬的并行,已经包含嵌套并行,还是在内核函数的不同实例之间重用数据

  4. 无论我们是在 SYCL 中编写一个新的内核来最大化性能还是提高代码的可移植性,还是因为它提供了一种比低级语言更有效的表达并行性的方式

第二个指南是图 4-27 中的表格,它总结了每种内核形式的功能。值得注意的是,该表反映了本书出版时 DPC++ 的状态,并且每个内核形式可用的特性应该会随着语言的发展而变化。然而,我们预计基本趋势将保持不变:基本数据并行内核将不会公开位置感知功能,显式 ND-range 内核将公开所有性能支持功能,而分层内核在公开功能方面将落后于显式 ND-range 内核,但它们对这些功能的表达将使用更高级别的抽象。

img/489625_1_En_4_Fig27_HTML.png

图 4-27

每种内核形式可用的特性

摘要

本章介绍了在 DPC++ 中表达并行性的基础,并讨论了编写数据并行内核的每种方法的优缺点。

DPC++ 和 SYCL 支持多种形式的并行性,我们希望我们已经提供了足够的信息,让读者可以开始编写代码了!

我们只是触及了表面,对本章中介绍的许多概念和类的更深入的探究即将到来:本地内存、屏障和通信例程的使用将在第九章中讨论;除了使用 lambda 表达式,定义内核的不同方法将在第十章中讨论;第 15 、 16 和 17 章将探讨 ND-range 执行模型到具体硬件的详细映射;使用 DPC++ 表达通用并行模式的最佳实践将在第十四章中介绍。

Creative Commons

开放存取本章根据知识共享署名 4.0 国际许可证(http://Creative Commons . org/licenses/by/4.0/)的条款获得许可,该许可证允许以任何媒体或格式使用、共享、改编、分发和复制,只要您适当注明原作者和来源,提供知识共享许可证的链接并指明是否进行了更改。

本章中的图像或其他第三方材料包含在本章的知识共享许可中,除非在材料的信用额度中另有说明。如果材料不包括在本章的知识共享许可中,并且您的预期使用不被法律法规允许或超出了允许的使用范围,您将需要直接从版权所有者处获得许可。

五、错误处理

img/489625_1_En_5_Figa_HTML.gif

阿加莎·克里斯蒂在 1969 年写道:“只要计算机努力,人为错误就不算什么。”作为程序员,我们要收拾残局,这并不奇怪。错误处理机制可以捕捉其他人可能犯的程序员错误。由于我们不打算自己犯错,我们可以专注于使用错误处理来处理现实世界中由于和其他原因而可能发生的情况。

检测和处理意外情况和错误在应用程序开发过程中可能是有帮助的(想想:在项目中工作的另一个程序员确实犯了错误),但更重要的是在稳定和安全的生产应用程序和库方面起着关键作用。我们用这一章来描述 SYCL 中可用的错误处理机制,这样我们就可以了解我们的选择,以及如果我们关心错误的检测和管理,如何构建应用程序。

本章概述了 SYCL 中的同步和异步错误,描述了如果我们在代码中不做任何事情来处理错误时应用程序的行为,并深入探讨了 SYCL 特有的允许我们处理异步错误的机制。

安全第一

C++ 错误处理的一个核心方面是,如果我们对已经检测到(抛出)的错误不做任何处理,那么应用程序将会终止并指示出错。这种行为允许我们在编写应用程序时不必关注错误管理,并且仍然相信错误会以某种方式通知开发人员或用户。当然,我们并不是建议我们应该忽略错误处理!生产应用程序应该将错误管理作为架构的核心部分来编写,但是应用程序通常在开始开发时没有这样的关注点。C++ 的目标是让不处理错误的代码仍然能够观察到错误,即使它们没有被显式处理。

由于 SYCL 是数据并行 C++,同样的原理也成立:如果我们在代码中不做任何事情来管理错误,并且检测到错误,程序将发生异常终止,让我们知道发生了不好的事情。生产应用程序当然应该将错误管理视为软件架构的核心部分,不仅仅是报告错误,还经常从错误状态中恢复。

如果我们不添加任何错误管理代码,当错误发生时,我们仍然会看到一个异常的程序终止,这是一个深入挖掘的指示。

错误类型

C++ 通过其异常机制提供了一个通知和处理错误的框架。除此之外,异构编程还需要额外级别的错误管理,因为有些错误发生在设备上,或者在尝试在设备上启动工作时。这些错误通常在时间上与宿主程序的执行分离,因此它们不能与经典的 C++ 异常处理机制完全集成。为了解决这个问题,有额外的机制使异步错误像常规 C++ 异常一样易于管理和控制。

图 5-1 显示了一个典型应用的两个组成部分:(1)顺序运行的主机代码,并将工作提交给任务图以备将来执行;( 2)任务图,它与主机程序异步运行,并在必要的依赖关系满足时在设备上执行内核或其他动作。该示例显示了作为任务图的一部分异步执行的操作parallel_for,但是其他操作也是可能的,如第 3 、 4 和 8 章中所讨论的。

img/489625_1_En_5_Fig1_HTML.png

图 5-1

主机程序和任务图执行的分离

图 5-1 的左侧和右侧(主机和任务图)的区别是理解同步异步错误之间差异的关键。

同步当主机程序执行某项操作(如 API 调用或对象构造器)时检测到错误条件时,就会发生错误。它们可以在图左侧的指令完成之前被检测到,并且错误可以由导致错误的操作立即抛出。我们可以在图的左侧用一个try-catch结构包装特定的指令,期望在 try 块结束之前检测到由于try内的操作而产生的错误(并因此被捕获)。C++ 异常机制就是为处理这些类型的错误而设计的。

或者,异步错误出现在图 5-1 右侧的部分,只有当执行任务图中的操作时才会检测到错误。当异步错误作为任务图执行的一部分被检测到时,主机程序通常已经继续执行了,所以没有代码可以用try-catch构造来捕获这些错误。取而代之的是一个异步异常处理框架来处理这些相对于主机程序执行看似随机发生的错误。

让我们制造一些错误!

作为本章剩余部分的例子,并允许我们进行实验,我们将在下面的部分创建同步和异步错误。

img/489625_1_En_5_Fig2_HTML.png

图 5-2

创建同步错误

同步误差

在图 5-2 中,从一个缓冲区创建了一个子缓冲区,但其大小非法(大于原始缓冲区)。子缓冲区的构造器检测到这个错误,并在构造器执行完成之前抛出异常。这是一个同步错误,因为它作为宿主程序执行的一部分(与之同步)发生。在构造器返回之前,错误是可以检测到的,因此可以在宿主程序中的错误起源点或检测点立即处理错误。

我们的代码示例不做任何事情来捕获和处理 C++ 异常,所以默认的 C++ 未捕获异常处理程序为我们调用std::terminate,发出出错的信号。

异步误差

生成异步错误有点棘手,因为实现会尽可能同步地检测和报告错误。同步错误更容易调试,因为它们发生在宿主程序中特定的起始点,所以只要有可能就应该优先考虑。不过,出于演示目的,生成异步错误的一种方法是在命令组提交中添加一个后备/辅助队列,并丢弃碰巧抛出的同步异常。图 5-3 显示了这样的代码,它调用我们的handle_async_error函数来允许我们进行实验。没有辅助/后备队列也可能发生和报告异步错误,因此请注意,辅助队列只是示例的一部分,绝不是异步错误的必要条件。

img/489625_1_En_5_Fig3_HTML.png

图 5-3

创建异步错误

应用程序错误处理策略

C++ 异常特性被设计成将程序中检测到错误的地方和可能处理错误的地方完全分开,这个概念非常适合 SYCL 中的同步和异步错误。通过throwcatch机制,可以定义处理程序的层次结构,这在生产应用程序中很重要。

构建一个能够以一致和可靠的方式处理错误的应用程序需要预先制定一个策略,并为错误管理构建一个软件架构。C++ 提供了灵活的工具来实现许多可供选择的策略,但是这种架构超出了本章的范围。有许多书籍和其他参考资料专门讨论这个主题,所以我们鼓励大家去查阅它们,以全面了解 C++ 错误管理策略。

也就是说,错误检测和报告并不总是需要生产规模的。如果目标只是在执行过程中检测错误并报告错误(但不一定是从错误中恢复),那么可以通过最少的代码可靠地检测和报告程序中的错误。接下来的部分首先介绍了如果我们忽略错误处理并且什么都不做会发生什么(默认行为并没有那么糟糕!),后面是推荐的错误报告,它在基本应用程序中很容易实现。

忽略错误处理

C++ 和 SYCL 旨在告诉我们,即使我们没有显式地处理错误,也会出现问题。未处理的同步或异步错误的默认结果是程序异常终止,操作系统应该告诉我们这一点。下面的两个例子分别模拟了如果我们不处理同步和异步错误时将会发生的行为。

图 5-4 显示了一个未处理的 C++ 异常的结果,例如,这可能是一个未处理的 SYCL 同步错误。我们可以使用这段代码来测试在这种情况下特定的操作系统会报告什么。

img/489625_1_En_5_Fig4_HTML.png

图 5-4

C++ 中未处理的异常

图 5-5 显示了被调用的std: :terminate的示例输出,这将是我们的应用程序中未处理的 SYCL 异步错误的结果。我们可以使用这段代码来测试在这种情况下特定的操作系统会报告什么。

img/489625_1_En_5_Fig5_HTML.png

图 5-5

std: :terminate在 SYCL 异步异常未处理时调用

虽然我们可能应该处理程序中的错误,但是由于未被捕获的错误将被捕获,程序将被终止,所以我们不需要担心程序会无声无息地失败!

同步错误处理

我们保持这一节非常短,因为 SYCL 同步错误只是 C++ 异常。SYCL 中添加的大多数额外错误机制都与异步错误有关,我们将在下一节中讨论,但是同步错误很重要,因为实现会尝试同步检测和报告尽可能多的错误,因为它们更容易推理和处理。

SYCL 定义的同步错误是从sycl::exception类型的std::exception衍生而来的一个类,它允许我们通过一个try-catch结构来捕捉 SYCL 错误,如图 5-6 所示。

img/489625_1_En_5_Fig6_HTML.png

图 5-6

具体要抓的模式sycl::exception

在 C++ 错误处理机制之上,SYCL 为运行时抛出的异常添加了一个sycl::exception类型。其他的都是标准的 C++ 异常处理,所以大多数开发人员都很熟悉。

图 5-7 提供了一个稍微完整的例子,其中处理了额外的异常类,以及通过从main()返回而结束的程序。

img/489625_1_En_5_Fig7_HTML.png

图 5-7

从代码块中捕捉异常的模式

异步错误处理

异步错误由 SYCL 运行时(或底层后端)检测,错误的发生与宿主程序中命令的执行无关。这些错误存储在 SYCL 运行时内部的列表中,只在程序员可以控制的特定点上进行处理。为了涵盖异步错误的处理,我们需要讨论两个主题:

  1. 当有未完成的异步错误要处理时调用的异步处理程序

  2. 调用异步处理程序时

异步处理程序

异步处理程序是应用程序定义的函数,它向 SYCL 上下文和/或队列注册。在下一节定义的时间,如果有任何未处理的异步异常可供处理,那么 SYCL 运行时将调用异步处理程序,并向其传递这些异常的列表。

异步处理程序作为一个std::function传递给一个上下文或队列构造器,并且可以根据我们的偏好以常规函数、lambda 或仿函数等方式定义。处理程序必须接受一个sycl::exception_list参数,例如图 5-8 中所示的示例处理程序。

img/489625_1_En_5_Fig8_HTML.png

图 5-8

定义为 lambda 的异步处理程序实现示例

在图 5-8 中,std::rethrow_exception后接特定异常类型的 catch 提供了异常类型的过滤,在这种情况下只过滤到sycl::exception。我们还可以在 C++ 中使用其他过滤方法,或者选择处理所有异常,而不管其类型。

该处理程序在构建时与一个队列或上下文相关联(在第六章中详细介绍了底层细节)。例如,要用我们正在创建的队列注册图 5-8 中定义的处理程序,我们可以写


queue my_queue{ gpu_selector{}, handle_async_error };

同样,要用我们正在创建的上下文注册图 5-8 中定义的处理程序,我们可以写


context my_context{ handle_async_error };

大多数应用程序不需要显式创建或管理上下文(它们是在后台自动为我们创建的),因此如果要使用异步处理程序,大多数开发人员应该将这种处理程序与为特定设备(而不是显式上下文)构建的队列相关联。

在定义异步处理程序时,大多数开发人员应该在队列中定义它们,除非出于其他原因已经显式地管理了上下文。

如果没有为队列或队列的父上下文定义异步处理程序,并且在该队列上(或上下文中)发生了必须处理的异步错误,则调用默认的异步处理程序。默认处理程序的运行方式如同图 5-9 所示的编码。

img/489625_1_En_5_Fig9_HTML.png

图 5-9

默认异步处理程序的行为示例

默认处理程序应该向用户显示一些异常列表中的错误信息,然后异常终止应用程序,这也会导致操作系统报告终止异常。

我们在异步处理程序中放什么由我们自己决定。它的范围可以从记录错误到应用程序终止,再到恢复错误条件,以便应用程序可以继续正常执行。常见的情况是通过调用sycl::exception::what()来报告错误的任何细节,然后终止应用程序。

尽管由我们来决定异步处理程序在内部做什么,但一个常见的错误是打印一条错误消息(在程序的其他消息中可能会被忽略),然后完成处理程序函数。除非我们有适当的错误管理原则,允许我们恢复已知的程序状态,并确信继续执行是安全的,否则我们应该考虑在异步处理函数中终止应用程序。这减少了错误结果出现在程序中的机会,在该程序中检测到错误,但是应用程序被无意中允许继续执行。在许多程序中,一旦我们遇到异步异常,异常终止是首选结果。

如果没有全面的错误恢复和管理机制,在输出有关错误的信息后,考虑在异步处理程序中终止应用程序。

处理程序的调用

运行时在特定的时间调用异步处理程序。错误发生时不会立即报告,因为如果出现这种情况,错误管理和安全应用程序编程(尤其是多线程)将变得更加困难和昂贵。相反,异步处理程序在以下特定时间被调用:

  1. 当宿主程序调用特定队列上的queue::throw_asynchronous()

  2. 当宿主程序调用特定队列上的queue::wait_and_throw()

  3. 当宿主程序在特定事件上调用event::wait_and_throw()

  4. 当一个queue被破坏时

  5. 当一个context被破坏时

方法 1–3 为宿主程序提供了一种机制来控制何时处理异步异常,以便可以管理特定于应用程序的线程安全和其他细节。它们有效地提供了异步异常进入宿主程序控制流的受控点,并且可以像处理同步错误一样进行处理。

如果用户没有显式调用方法 1-3 中的一个,那么当队列和上下文被销毁时,在程序拆卸期间通常会报告异步错误。这通常足以向用户发出信号,表明出了问题,程序结果不应该被信任。

然而,在程序拆卸期间依靠错误检测并不是在所有情况下都有效。例如,如果程序将仅在达到某些算法收敛标准时终止,并且如果这些标准仅可通过成功执行设备内核来实现,则异步异常可能发信号通知该算法将永远不会收敛并开始拆卸(将会注意到该错误)。在这些情况下,以及在有更完整的错误处理策略的生产应用中,在程序中的常规和受控点调用throw_asynchronous()wait_and_throw()是有意义的(例如,在检查算法收敛是否发生之前)。

设备上的错误

本章中讨论的错误检测和处理机制是基于主机的。它们是一些机制,通过这些机制,主机程序可以检测和处理在主机程序中或者在设备上执行内核期间可能出现的错误。我们还没有介绍的是,如何从我们编写的设备代码中发出信号,表明有什么地方出错了。这种遗漏不是错误,而是相当故意的。

SYCL 明确禁止在设备代码中使用 C++ 异常处理机制(比如throw),因为对于某些类型的设备来说,这是我们通常不想付出的性能代价。如果我们检测到设备代码中出现了错误,我们应该使用现有的非基于异常的技术发出错误信号。例如,我们可以写入一个记录错误的缓冲区,或者从我们定义的表示发生了错误的数值计算中返回一些无效的结果。在这些情况下,正确的策略是非常具体的应用。

摘要

在这一章中,我们介绍了同步和异步错误,讨论了如果我们对可能发生的错误无所作为时的默认行为,并讨论了在应用程序的受控点处理异步错误的机制。错误管理策略是软件工程中的一个主要话题,也是许多应用程序中编写的代码的重要组成部分。SYCL 集成了我们在错误处理方面已经掌握的 C++ 知识,并提供了灵活的机制来集成我们首选的错误管理策略。

Creative Commons

开放存取本章根据知识共享署名 4.0 国际许可证(http://Creative Commons . org/licenses/by/4.0/)的条款获得许可,该许可证允许以任何媒体或格式使用、共享、改编、分发和复制,只要您适当注明原作者和来源,提供知识共享许可证的链接并指明是否进行了更改。

本章中的图像或其他第三方材料包含在本章的知识共享许可中,除非在材料的信用额度中另有说明。如果材料不包括在本章的知识共享许可中,并且您的预期使用不被法律法规允许或超出了允许的使用范围,您将需要直接从版权所有者处获得许可。

六、统一共享内存

img/489625_1_En_6_Figa_HTML.gif

接下来的两章深入探讨了如何管理数据。有两种互为补充的不同方法:统一共享内存(USM)和缓冲区。USM 公开了与缓冲区不同的内存抽象级别—USM 有指针,而缓冲区是更高级别的接口。本章重点介绍 USM。下一章将集中讨论缓冲区。

除非我们明确知道要使用缓冲区,否则 USM 是一个很好的起点。USM 是一个基于指针的模型,允许通过常规 C++ 指针读写内存。

我们为什么要使用 USM?

因为 USM 是基于 C++ 指针的,所以对于现有的基于指针的 C++ 代码来说,它是一个自然的起点。将指针作为参数的现有函数无需修改即可继续工作。在大多数情况下,唯一需要的改变是用特定于 USM 的分配例程替换现有的对mallocnew的调用,我们将在本章后面讨论这些例程。

分配类型

虽然 USM 基于 C++ 指针,但并非所有指针都是平等的。USM 定义了三种不同类型的分配,每种类型都有独特的语义。设备可能不支持所有类型(甚至任何类型)的 USM 分配。稍后我们将学习如何查询设备支持什么。图 6-1 总结了三种分配类型及其特点。

img/489625_1_En_6_Fig1_HTML.png

图 6-1

USM 分配类型

设备分配

这第一种类型的分配是我们需要的,以便有一个指向设备的附加存储器的指针,例如(G)DDR 或 HBM。设备分配可以由运行在设备上的内核读取或写入,但不能从主机上执行的代码直接访问。试图访问主机上的设备分配可能会导致数据不正确或程序因错误而崩溃。我们必须使用显式 USM memcpy机制在主机和设备之间复制数据,该机制指定了在两个位置之间必须复制多少数据,这将在本章的后面部分介绍。

主机分配

第二种类型的分配比设备分配更容易使用,因为我们不必在主机和设备之间手动拷贝数据。主机分配是主机内存中的分配,可在主机和设备上访问。这些分配虽然可以在设备上访问,但不能迁移到设备的附加内存。取而代之的是,读写这个内存的内核是远程完成的,通常是通过较慢的总线,比如 PCI-Express。便利性和性能之间的权衡是我们必须考虑的。尽管主机分配会导致更高的访问成本,但仍然有充分的理由使用它们。示例包括很少访问的数据或无法容纳在设备附加内存中的大型数据集。

*### 共享分配

最后一种分配结合了设备和主机分配的属性,将主机分配的程序员便利性与设备分配提供的更高性能结合在一起。与主机分配一样,共享分配在主机和设备上都是可访问的。它们之间的区别在于,共享分配可以在主机内存和设备连接内存之间自由迁移,自动进行,无需我们的干预。如果某个分配已经迁移到该设备,则在该设备上执行的任何内核访问该分配的性能都将优于从主机远程访问该分配。然而,共享分配并不能给我们所有的好处而没有任何缺点。

自动迁移可以通过多种方式实现。无论运行时选择哪种方式来实现共享分配,它们通常都要付出延迟增加的代价。通过设备分配,我们可以准确地知道需要复制多少内存,并可以安排尽快开始复制。自动迁移机制看不到未来,在某些情况下,直到内核试图访问数据时才开始移动数据。然后,内核必须等待或阻塞,直到数据移动完成,然后才能继续执行。在其他情况下,运行时可能不知道内核将访问多少数据,并且可能保守地移动比所需数量更多的数据,这也增加了内核的延迟。

我们还应该注意,虽然共享分配可以迁移,但这并不一定意味着 DPC++ 的所有实现都将迁移它们。我们预计大多数实现都将共享分配与迁移一起实现,但是一些设备可能更喜欢将它们实现为与主机分配相同。在这样的实现中,分配在主机和设备上仍然可见,但是我们可能看不到迁移实现可以提供的性能提升。

分配内存

USM 允许我们以各种不同的方式分配内存,以满足不同的需求和偏好。然而,在我们更详细地讨论所有方法之前,我们应该讨论 USM 分配与常规 C++ 分配有何不同。

我们需要知道什么?

常规的 C++ 程序可以通过多种方式分配内存:newmalloc或分配器。无论我们喜欢哪种语法,内存分配最终都是由主机操作系统中的系统分配器来执行的。当我们在 C++ 中分配内存时,唯一关心的是“我们需要多少内存?”以及“有多少内存可供分配?”但是,USM 需要额外的信息才能执行分配。

首先,USM 分配需要指定所需的分配类型:设备、主机或共享。请求正确的分配类型是很重要的,以便获得该分配所需的行为。接下来,每个 USM 分配必须指定一个context对象,分配将针对该对象进行。context对象还没有太多的讨论,所以这里值得说一点。上下文代表我们可以在其上执行内核的一个或一组设备。我们可以把上下文看作是一个方便的地方,让运行时保存一些关于它正在做什么的状态。在大多数 DPC++ 程序中,除了传递上下文之外,程序员不太可能直接与上下文交互。

USM 分配不能保证在不同的上下文中可用——所有 USM 分配、队列和内核共享同一个context对象是很重要的。通常,我们可以从用于向设备提交工作的队列中获得这个上下文。最后,device分配还要求我们指定哪个设备将为分配提供内存。这一点很重要,因为我们不想超额预订设备的内存(除非设备能够支持这一点,我们将在本章稍后讨论数据迁移时对此进行详细说明)。通过添加这些额外的参数,可以将 USM 分配例程与它们的 C++ 类似物区分开来。

多种风格

有时候,试图用一个单一的选项来取悦每个人被证明是一个不可能的任务,就像有些人喜欢咖啡胜过茶,或者喜欢emacs胜过vi.如果我们问程序员分配接口应该是什么样子,我们会得到几个不同的答案。USM 支持这种多样性的选择,并提供了几种不同风格的分配界面。这些不同的风格是 C 风格、C++ 风格和 C++ 分配器风格。我们现在将讨论每一个并指出它们的相似之处和不同之处。

c 级津贴

第一种类型的分配函数(在图 6-2 中列出,稍后在图 6-6 和 6-7 中显示的示例中使用)是在 C: malloc函数中的内存分配之后建模的,这些函数采用多个字节进行分配并返回一个void *指针。这种类型的函数是类型不可知的。我们必须指定要分配的总字节数,这意味着如果我们想要分配类型为XN对象,我们必须请求N * sizeof(X)总字节数。返回的指针属于类型void *,这意味着我们必须将它转换为类型X的适当指针。这种样式非常简单,但是由于需要进行大小计算和类型转换,可能会很冗长。

我们可以进一步将这种分配方式分为两类:命名函数和单一函数。这两种风格的区别在于我们如何指定所需的 USM 分配类型。对于命名函数(malloc_devicemalloc_hostmalloc_shared),USM 分配的类型编码在函数名中。单一功能malloc要求将 USM 分配类型指定为附加参数。没有一种味道比另一种更好,选择使用哪一种取决于我们的偏好。

我们不能在不简要提及对齐的情况下继续讨论。每个版本的malloc也有一个对应的aligned_allocmalloc函数返回与设备默认行为一致的内存。它将返回一个具有有效对齐方式的合法指针,但是在某些情况下,我们可能更愿意手动指定对齐方式。在这些情况下,我们应该使用aligned_alloc变量中的一个,它也要求我们为分配指定期望的对齐。如果我们指定了一个非法的对齐,就不要指望程序能正常工作!合法对齐是 2 的幂。值得注意的是,在许多设备上,分配是最大限度地对齐的,以对应于硬件的功能,因此尽管我们可能要求分配是 4、8、16 或 32 字节对齐的,但实际上我们可能会看到更大的对齐,这给了我们所要求的,甚至更多。

img/489625_1_En_6_Fig2_HTML.png

图 6-2

c 风格的 USM 分配功能

C++ 分配

USM 分配函数的下一种风格(在图 6-3 中列出)与第一种非常相似,但更多的是 C++ 的外观和感觉。我们再次拥有了分配例程的命名和单个函数版本,以及我们的默认和用户指定的对齐版本。不同之处在于,现在我们的函数是 C++ 模板化的函数,它分配类型为TCount对象,并返回类型为T *的指针。利用现代 C++ 简化了事情,因为我们不再需要以字节为单位手动计算分配的总大小,或者将返回的指针转换为适当的类型。这也有助于在代码中生成更紧凑、更不易出错的表达式。然而,我们应该注意到,与 C++ 中的“new”不同,malloc 风格的接口不为被分配的对象调用构造器——我们只是分配足够的字节来适应该类型。

这种类型的分配是用 USM 编写新代码的良好开端。对于已经大量使用 C 或 C++ malloc的现有 C++ 代码来说,前面的 C 风格是一个很好的起点,我们将在其中添加 USM 的使用。

img/489625_1_En_6_Fig3_HTML.png

图 6-3

C++ 风格的 USM 分配函数

C++ 分配器

USM 分配的最终版本(图 6-4 )比之前的版本更加拥抱现代 C++。这种风格基于 C++ 分配器接口,该接口定义了用于在容器(如std::vector)中直接或间接执行内存分配的对象。如果我们的代码大量使用可以对用户隐藏内存分配和释放细节的容器对象,这种分配器风格是最有用的,简化了代码并减少了出错的机会。

img/489625_1_En_6_Fig4_HTML.png

图 6-4

C++ 分配器风格的 USM 分配函数

释放内存

程序分配的任何东西最终都必须被释放。USM 定义了一个free方法来释放由mallocaligned_malloc函数分配的内存。这个free方法还将分配内存的上下文作为一个额外的参数。队列也可以代替上下文。如果内存是用 C++ 分配器对象分配的,那么也应该使用该对象来释放内存。

img/489625_1_En_6_Fig5_HTML.png

图 6-5

三种分配方式

分配示例

在图 6-5 中,我们展示了如何使用刚刚描述的三种风格来执行相同的分配。在这个例子中,我们将N单精度浮点数分配为共享分配。第一次分配f1使用 C 风格的void *返回 malloc 例程。对于这种分配,我们显式地传递从队列中获得的设备和上下文。我们还必须将结果强制转换回一个float *。第二个分配f2做了同样的事情,但是使用了 C++ 风格的模板 malloc。因为我们将元素的类型float传递给分配例程,所以我们只需要指定我们想要分配多少个浮点数,而不需要对结果进行强制转换。我们还使用接受队列而不是设备和上下文的形式,产生了一个非常简单和紧凑的语句。第三个分配f3使用 USM C++ 分配器类。我们实例化适当类型的分配器对象,然后使用该对象执行分配。最后,我们展示如何正确地释放每个分配。

数据管理

现在我们已经了解了如何使用 USM 分配内存,我们将讨论如何管理数据。我们可以从两个方面来看这个问题:数据初始化和数据移动。

初始化

数据初始化涉及到在我们执行计算之前用值填充我们的内存。常见初始化模式的一个例子是在使用分配之前用零填充分配。如果我们要使用 USM 分配来做到这一点,我们可以通过多种方式来实现。首先,我们可以编写一个内核来做这件事。如果我们的数据集特别大,或者初始化需要复杂的计算,这是一种合理的方法,因为初始化可以并行执行(并且它使初始化的数据准备就绪,可以在设备上运行)。第二,我们可以在分配的所有元素上实现一个循环,将每个元素设置为零。然而,这种方法有一个潜在的问题。对于主机分配和共享分配,循环可以很好地工作,因为它们在主机上是可访问的。然而,因为设备分配在主机上是可访问的,所以主机代码中的循环将不能写入它们。这让我们想到了第三个选择。

memset函数旨在有效地实现这种初始化模式。USM 提供了一个版本的memset,它是handlerqueue类的成员函数。它有三个参数:表示我们要设置的内存基址的指针,表示要设置的字节模式的字节值,以及要设置为该模式的字节数。与主机上的循环不同,memset并行发生,也与device分配一起工作。

虽然memset是一个有用的操作,但它只允许我们指定一个字节模式来填充分配,这是相当有限的。USM 还提供了一个fill方法(作为handlerqueue类的成员),让我们用任意模式填充内存。fill 方法是一个函数,它以我们想要写入分配的模式类型为模板。用一个int模板化它,我们可以用数字“42”填充一个分配。类似于memset , fill有三个参数:指向要填充的分配基址的指针、要填充的值以及我们希望将该值写入分配的次数。

数据传送

数据移动可能是 USM 需要理解的最重要的方面。如果正确的数据没有在正确的时间出现在正确的位置,我们的程序就会产生错误的结果。USM 定义了我们可以用来管理数据的两种策略:显式和隐式。选择我们想要使用的策略与我们的硬件支持的 USM 分配类型或我们想要使用的类型有关。

明确的

USM 提供的第一个策略是显式数据移动(图 6-6 )。这里,我们必须在主机和设备之间显式复制数据。我们可以通过调用handlerqueue类中的memcpy方法来实现。memcpy方法有三个参数:一个指向目标内存的指针,一个指向源内存的指针,以及要在主机和设备之间复制的字节数。我们不需要指定复制应该在哪个方向发生—这在源和目标指针中是隐含的。

显式数据移动的最常见用法是在 USM 中向/从device分配拷贝数据,因为它们在主机上不可访问。必须插入数据的显式拷贝确实需要我们付出努力。此外,它也可能是错误的来源:副本可能被意外忽略,可能复制了不正确的数据量,或者源或目标指针可能不正确。

然而,显式数据移动不仅有缺点。这给了我们很大的优势:对数据移动的完全控制。在某些应用程序中,控制复制多少数据以及何时复制数据对于实现最佳性能非常重要。理想情况下,我们可以尽可能将计算与数据移动重叠,确保硬件以高利用率运行。

其他类型的 USM 分配hostshared都可以在主机和设备上访问,不需要显式复制到设备。这让我们想到了 USM 中的另一种数据移动策略。

img/489625_1_En_6_Fig6_HTML.png

图 6-6

USM 显式数据移动示例

隐形的

USM 提供的第二种策略是隐式数据移动(示例用法如图 6-7 所示)。在这种策略中,数据移动以隐含的方式发生,也就是说,不需要我们的输入。使用隐式数据移动,我们不需要插入对memcpy的调用,因为我们可以通过 USM 指针直接访问数据,无论我们想在哪里使用它。相反,确保数据在被使用时在正确的位置可用成为系统的工作。

对于主机分配,人们可能会争论它们是否真的会导致数据移动。根据定义,它们始终是指向主机内存的指针,因此由给定主机指针表示的内存不能存储在设备上。但是,在设备上访问主机分配时,确实会发生数据移动。我们读取或写入的值通过适当的接口传入或传出内核,而不是将内存迁移到设备。这对于数据不需要驻留在设备上的流式内核非常有用。

隐式数据移动主要涉及 USM 共享分配。这种类型的分配在主机和设备上都可以访问,更重要的是,可以在主机和设备之间迁移。关键在于,这种迁移是自动发生的,或者说是隐式发生的,只需访问不同位置的数据即可。接下来,我们将讨论在为共享分配进行数据迁移时需要考虑的几个问题。

img/489625_1_En_6_Fig7_HTML.png

图 6-7

USM 隐式数据移动示例

移动

通过显式数据移动,我们可以控制发生多少数据移动。使用隐式数据移动,系统会为我们处理这一点,但它可能不会这样高效。DPC++ 运行时不是一个 Oracle——它不能在应用程序访问数据之前预测应用程序将访问哪些数据。此外,指针分析对于编译器来说仍然是一个非常困难的问题,编译器可能无法准确地分析和识别内核中可能使用的每个分配。因此,隐式数据移动机制的实现可能会根据支持 USM 的设备的功能做出不同的决定,这既会影响共享分配的使用方式,也会影响其执行方式。

如果一个设备非常强大,它可能能够按需迁移内存。在这种情况下,在主机或设备尝试访问当前不在所需位置的分配后,会发生数据移动。按需数据极大地简化了编程,因为它提供了所需的语义,即 USM 共享指针可以在任何地方访问并正常工作。如果一个设备不支持按需迁移(第十二章解释了如何查询一个设备的能力),它可能仍然能够保证相同的语义,但对如何使用共享指针有额外的限制

USM 共享分配的受限形式决定了何时何地可以访问共享指针,以及共享分配可以有多大。如果设备不能按需迁移内存,这意味着运行时必须保守,并假设内核可以访问其设备附加内存中的任何分配。这带来了几个后果。

首先,这意味着主机和设备不应试图同时访问共享分配。应用程序应该分阶段交替访问。主机可以访问分配,然后内核可以使用该数据进行计算,最后主机可以读取结果。如果没有这种限制,主机可以自由地访问内核当前接触的分配的不同部分。这种并发访问通常发生在设备存储器页面的粒度上。主机可以访问一个页面,而设备可以访问另一个页面。原子地访问同一块数据将在第十九章中介绍。

这种受限形式的共享分配的下一个后果是,分配受到连接到设备的内存总量的限制。如果设备不能按需迁移内存,它就不能将数据迁移到主机来腾出空间引入不同的数据。如果设备支持按需迁移,则有可能超额订阅其连接的内存,允许内核计算超过设备内存正常容量的数据,尽管这种灵活性可能会因额外的数据移动而带来性能损失。

细粒度控制

当设备支持共享分配的按需迁移时,在当前不驻留内存的位置访问内存后,会发生数据移动。但是,在等待数据移动完成时,内核可能会停止。它执行的下一条语句甚至可能导致更多的数据移动,并给内核执行带来额外的延迟。

DPC++ 为我们提供了一种修改自动迁移机制性能的方法。它通过定义两个函数来做到这一点:prefetchmem_advise。图 6-8 显示了每种方法的简单应用。这些函数让我们向运行时提示内核将如何访问数据,这样运行时就可以选择在内核试图访问数据之前开始移动数据。注意,这个例子使用了队列快捷方式方法,这些方法直接调用queue对象上的parallel_for,而不是在传递给submit方法(一个命令组)的 lambda 内部调用。

img/489625_1_En_6_Fig8_HTML.png

图 6-8

通过prefetchmem_advise进行精细控制

对我们来说,最简单的方法就是调用prefetch。这个函数作为handlerqueue类的成员函数被调用,并接受一个基指针和字节数。这让我们可以通知运行时,某个设备上将要使用某些数据,以便它可以急切地开始迁移这些数据。理想情况下,我们应该足够早地发出这些预取提示,以便在内核接触数据时,它已经驻留在设备上,从而消除我们之前描述的延迟。

DPC++ 提供的另一个函数是mem_advise。这个函数允许我们提供特定于设备的关于内核如何使用内存的提示。我们可以指定的这种可能的建议的一个例子是,数据将只在内核中读取,而不是写入。在这种情况下,系统可以意识到它可以复制设备上的数据,这样在内核完成后就不需要更新主机的版本。然而,传递给mem_advise建议是特定于特定设备的,因此在使用该功能之前,请务必查看硬件文档。

问题

最后,并非所有设备都支持 USM 的所有功能。如果我们希望我们的程序可以在不同的设备上移植,我们不应该假设所有的 USM 特性都是可用的。USM 定义了我们可以查询的几项内容。这些查询可以分为两类:指针查询和设备能力查询。图 6-9 显示了每种方法的简单应用。

USM 中的指针查询回答了两个问题。第一个问题是“这个指针指向什么类型的 USM 分配?”get_pointer_type函数接受一个指针和 DPC++ 上下文并返回一个类型为usm::alloc的结果,它可以有四个可能的值:主机设备共享未知。第二个问题是“这个 USM 指针是针对哪个设备分配的?”我们可以向函数get_pointer_device传递一个指针和一个上下文,并获取一个设备对象。这主要用于设备或共享 USM 分配,因为它对主机分配没有多大意义。

USM 提供的第二种类型的查询涉及设备的功能。USM 通过在设备对象上调用get_info来扩展可以查询的设备信息描述符列表。这些查询可用于测试设备支持哪些类型的 USM 分配。此外,我们可以通过本章前面介绍的方式查询设备上的共享分配是否受到限制。完整的查询列表如图 6-10 所示。在第十二章中,我们将更详细地了解查询机制。

img/489625_1_En_6_Fig10_HTML.png

图 6-10

USM 设备信息描述符

img/489625_1_En_6_Fig9_HTML.png

图 6-9

对 USM 指针和设备的查询

摘要

在这一章中,我们描述了统一共享内存,一种基于指针的数据管理策略。我们讨论了 USM 定义的三种分配类型。我们讨论了使用 USM 分配和取消分配内存的所有不同方式,以及如何由我们(程序员)对设备分配进行显式控制,或者由系统对共享分配进行隐式控制。最后,我们讨论了如何查询设备支持的不同 USM 功能,以及如何查询程序中关于 USM 指针的信息。

因为我们还没有在本书中详细讨论同步,所以在后面的章节中,当我们讨论调度、通信和同步时,会有更多关于 USM 的内容。具体来说,我们在第 8 、 9 和 19 章中涵盖了 USM 的这些额外考虑。

在下一章,我们将讨论数据管理的第二个策略:缓冲区。

Creative Commons

开放存取本章根据知识共享署名 4.0 国际许可证(http://Creative Commons . org/licenses/by/4.0/)的条款获得许可,该许可证允许以任何媒体或格式使用、共享、改编、分发和复制,只要您适当注明原作者和来源,提供知识共享许可证的链接并指明是否进行了更改。

本章中的图像或其他第三方材料包含在本章的知识共享许可中,除非在材料的信用额度中另有说明。如果材料不包括在本章的知识共享许可中,并且您的预期使用不被法律法规允许或超出了允许的使用范围,您将需要直接从版权所有者处获得许可。*

七、缓冲

img/489625_1_En_7_Figa_HTML.gif

在这一章中,我们将学习缓冲抽象。在前一章中,我们学习了统一共享内存(USM),这是一种基于指针的数据管理策略。USM 迫使我们思考内存在哪里,什么应该在哪里可以访问。缓冲区抽象是一个更高级的模型,它对程序员隐藏了这一点。缓冲区只是表示数据,管理数据在内存中的存储和移动就成了运行时的工作。

本章介绍了管理数据的另一种方法。缓冲区和 USM 之间的选择通常取决于个人偏好和现有代码的风格,应用程序可以自由混合和匹配这两种风格来表示应用程序中的不同数据。

USM 只是公开了不同的内存抽象。USM 有指针,缓冲区是更高层次的抽象。缓冲区的抽象级别允许在应用程序中的任何设备上使用其中包含的数据,其中运行时管理使数据可用所需的任何内容。选择是好的,所以让我们进入缓冲区。

我们将更仔细地研究如何创建和使用缓冲区。如果不讨论存取器,对缓冲区的讨论将是不完整的。虽然缓冲区抽象了我们如何在程序中表示和存储数据,但我们并不使用缓冲区直接访问数据。相反,我们使用访问器对象来通知运行时我们打算如何使用我们正在访问的数据,并且访问器与任务图中强大的数据依赖机制紧密耦合。在我们讲述了我们可以用缓冲区做的所有事情之后,我们还将探索如何在我们的程序中创建和使用访问器。

缓冲

缓冲区是数据的高级抽象。缓冲区不一定局限于单个位置或虚拟内存地址。实际上,运行时可以自由地使用内存中许多不同的位置(甚至跨不同的设备)来表示一个缓冲区,但是运行时必须确保总是给我们一个一致的数据视图。可以在主机和任何设备上访问缓冲区。

img/489625_1_En_7_Fig1_HTML.png

图 7-1

缓冲类定义

buffer类是一个模板类,有三个模板参数,如图 7-1 所示。第一个模板参数是缓冲区将包含的对象的类型。按照 C++ 的定义,这个类型必须是可简单复制的,这基本上意味着不使用任何特殊的复制或移动构造器就可以安全地逐字节复制这个对象。下一个模板参数是一个描述缓冲区维数的整数。最后一个模板参数是可选的,默认值通常是所使用的值。该参数指定了一个 C++ 风格的分配器类,用于在主机上执行缓冲区所需的任何内存分配。首先,我们将研究创建缓冲区对象的许多方法。

创造

在下图中,我们展示了创建缓冲区对象的几种方法。如何在应用程序代码中创建缓冲区的选择是需要如何使用缓冲区和个人编码偏好的组合。让我们浏览一下这个例子,看看每个实例。

img/489625_1_En_7_Fig2_HTML.png

图 7-2

创建缓冲区,第一部分

我们在图 7-2 ,b1中创建的第一个缓冲区是一个包含十个整数的二维缓冲区。我们显式传递所有模板参数,甚至显式传递默认值buffer_allocator作为分配器类型。然而,使用现代 C++,我们可以更简洁地表达这一点。缓冲区b2也是一个使用默认分配器的十个整数的二维缓冲区。这里我们利用 C++17 的类模板参数演绎(CTAD)来自动推断我们必须表达的模板参数。CTAD 是一个要么全有要么全无的工具——它要么推断一个类的每个模板参数,要么一个都不推断。在这种情况下,我们使用一个带两个参数的范围来初始化b2来推断它是一个二维范围。分配器模板参数有一个默认值,所以我们在创建缓冲区时不需要显式地列出它。

通过 buffer b3,我们创建了一个 20 浮点的缓冲区,并使用默认构造的std: :allocator<float>来分配主机上任何必要的内存。当使用带有缓冲区的自定义分配器类型时,我们通常希望将实际的分配器对象传递给缓冲区来使用,而不是默认构造的分配器对象。Buffer b4展示了如何做到这一点,在对其构造器的调用中,在范围之后获取分配器对象。

对于我们示例中的前四个缓冲区,我们让缓冲区分配它需要的任何内存,并且不在创建它们时用任何值初始化数据。使用缓冲区有效地包装现有的 C++ 分配是一种常见的模式,这些分配可能已经用数据进行了初始化。我们可以通过将初始值的源传递给缓冲区构造器来实现这一点。这样做允许我们做几件事,我们将在下一个例子中看到。

img/489625_1_En_7_Fig3_HTML.png

图 7-3

创建缓冲区,第二部分

在图 7-3 中,buffer b5创建了一个四个双精度的一维缓冲区。除了指定缓冲区大小的范围之外,我们还将指向 C 数组myDoubles的主机指针传递给缓冲区构造器。这里我们可以充分利用 CTAD 来推断我们缓冲区的所有模板参数。我们传递的主机指针指向 doubles,这给了我们缓冲区的数据类型。维数是从一维范围自动推断出来的,一维范围本身是推断出来的,因为它是用一个数创建的。最后,使用默认的分配器,所以我们不必指定它。

传递一个主机指针有一些我们应该知道的分支。通过传递一个指向主机内存的指针,我们向运行时承诺,在缓冲区的生存期内,我们不会尝试访问主机内存。SYCL 实施不会(也不能)强制执行这一点——我们有责任确保不违反此合同。我们不应该在缓冲区活动时尝试访问该内存的一个原因是,缓冲区可能会选择使用主机上的不同内存来表示缓冲区内容,这通常是出于优化的原因。如果是这样,这些值将从主机指针复制到这个新的内存中。如果后续内核修改了缓冲区,则原始主机指针将不会反映更新后的值,直到某些指定的同步点。在这一章的后面,我们将更多地讨论数据何时被写回主机指针。

缓冲器b6与缓冲器b5非常相似,但有一个主要区别。这一次,我们用一个指向const double的指针初始化缓冲区。这意味着我们只能通过主机指针读取值,而不能写入值。然而,本例中我们的缓冲区类型仍然是double,而不是const double,因为扣除指南没有考虑const- ness。这意味着缓冲区可以被内核写入,但是在缓冲区过期后,我们必须使用不同的机制来更新主机(这将在本章后面讨论)。

也可以使用 C++ 共享指针对象初始化缓冲区。如果我们的应用程序已经使用了共享指针,这是很有用的,因为这种初始化方法将正确地计算引用,并确保内存不会被释放。Buffer b7从单个整数初始化一个缓冲区,并使用共享指针初始化。

img/489625_1_En_7_Fig4_HTML.png

图 7-4

创建缓冲区,第三部分

容器是现代 C++ 应用程序中常用的,例子包括std::arraystd::vectorstd::liststd::map。我们可以用两种不同的方式使用容器初始化一维缓冲区。第一种方式,如图 7-4 缓冲区b8所示,使用输入迭代器。我们将两个迭代器而不是主机指针传递给缓冲区构造器,一个表示数据的开始,另一个表示结束。缓冲区的大小是通过递增起始迭代器直到它等于结束迭代器返回的元素数来计算的。这对于任何实现 C++ InputIterator接口的数据类型都很有用。如果为缓冲区提供初始值的容器对象也是连续的,那么我们可以使用更简单的形式来创建缓冲区。Buffer b9通过简单地将向量传递给构造器来创建一个缓冲区。缓冲区的大小由用来初始化它的容器的大小决定,缓冲区数据的类型来自容器数据的类型。使用这种方法创建缓冲区是常见的,并推荐使用容器,如std::vectorstd: :array

缓冲区创建的最后一个例子说明了 buffer 类的另一个特性。可以从另一个缓冲区或子缓冲区创建一个缓冲区的视图。子缓冲区需要三样东西:对父缓冲区的引用、基索引和子缓冲区的范围。不能从子缓冲区创建子缓冲区。可以从同一个缓冲区创建多个子缓冲区,并且它们可以自由重叠。Buffer b10的创建与 buffer b2完全一样,是一个二维整数缓冲区,每行有五个整数。接下来,我们从缓冲区b10创建两个子缓冲区,子缓冲区b11b12。子缓冲器b11从索引(0,0)开始,包含第一行中的每个元素。类似地,子缓冲区b12从索引(1,0)开始,包含第二行中的每个元素。这产生了两个不相交的子缓冲器。由于子缓冲区不重叠,不同的内核可以同时在不同的子缓冲区上运行,但是我们将在下一章更多地讨论调度执行图和依赖关系。

img/489625_1_En_7_Fig5_HTML.png

图 7-5

缓冲区属性

缓冲区属性

缓冲区也可以用改变其行为的特殊属性来创建。在图 7-5 中,我们将浏览三个不同可选缓冲属性的示例,并讨论如何使用它们。请注意,这些属性在大多数代码中相对不常见。

使用主机指针

在缓冲区创建期间,可以选择指定的第一个属性是use_host_ptr。当存在时,该属性要求缓冲区不在主机上分配任何内存,并且在缓冲区构造上传递或指定的任何分配器实际上都被忽略。相反,缓冲区必须使用传递给构造器的主机指针所指向的内存。请注意,这并不要求设备使用相同的内存来保存缓冲区的数据。设备可以自由地将缓冲区的内容缓存到它所连接的内存中。另请注意,该属性只能在将主机指针传递给构造器时使用。当程序希望完全控制所有主机内存分配时,此选项会很有用。

在图 7-5 的例子中,我们创建了一个缓冲区b,就像我们在前面的例子中看到的那样。接下来我们创建缓冲区b1,并用一个指向myInts的指针初始化它。我们还传递属性use_host_ptr,这意味着缓冲区b1将只使用myInts指向的内存,而不会分配任何额外的临时存储。

使用互斥锁

下一个属性use_mutex,涉及缓冲区和主机代码之间的细粒度内存共享。缓冲区b2是使用这个属性创建的。该属性引用了一个 mutex 对象,稍后可以从缓冲区中查询该对象,如我们在示例中所见。此属性还要求将一个主机指针传递给构造器,它让运行库确定何时通过提供的主机指针访问主机代码中的更新值是安全的。在运行时保证主机指针看到缓冲区的最新值之前,我们不能锁定互斥体。虽然这可以与use_host_ptr属性合并,但这不是必需的。use_mutex是一种机制,允许主机代码在缓冲区仍然存在时访问缓冲区内的数据,而不使用主机访问器机制(稍后描述)。一般来说,除非我们有特定的理由使用互斥体,否则应该首选主机访问器机制,特别是因为无法保证在成功锁定互斥体和数据准备好供主机代码使用之前需要多长时间。

上下文绑定

在我们的示例中,最后一个属性显示在缓冲区b3的创建中。这里,我们的 42 个整数的缓冲区是用context_bound属性创建的。属性采用对上下文对象的引用。通常,缓冲区可以在任何设备或上下文中自由使用。但是,如果使用此属性,它会将缓冲区锁定到指定的上下文。试图在另一个上下文中使用缓冲区将导致运行时错误。例如,通过识别内核可能被提交到错误队列的情况,这对于调试程序可能是有用的。实际上,我们并不期望在许多程序中使用这个属性,缓冲区在任何上下文中的任何设备上被访问的能力是缓冲区抽象的最强大的属性之一(这个属性撤销了这个属性)。

我们能用缓冲器做什么?

使用缓冲区对象可以做很多事情。我们可以查询缓冲区的特征,确定在缓冲区被破坏后是否有任何数据被写回主机内存以及在哪里,或者将缓冲区重新解释为具有不同特征的缓冲区。然而,有一件事是不能做的,那就是直接访问缓冲区所代表的数据。相反,我们必须创建访问器对象来访问数据,我们将在本章的后面了解这一点。

可以对缓冲区进行查询的示例包括它的范围、它所代表的数据元素的总数以及存储其元素所需的字节数。我们还可以查询缓冲区正在使用哪个分配器对象,以及该缓冲区是否是子缓冲区。

当缓冲区被破坏时更新主机内存是使用缓冲区时要考虑的一个重要方面。根据缓冲区的创建方式,在缓冲区销毁后,主机内存可能会更新,也可能不会更新计算结果。如果从指向非const数据的主机指针创建并初始化缓冲区,则当缓冲区被销毁时,用更新的数据更新该指针。然而,还有一种方法可以更新主机内存,而不管缓冲区是如何创建的。set_final_data方法是buffer的模板方法,可以接受原始指针、C++ OutputIteratorstd::weak_ptr。当缓冲区被销毁时,缓冲区包含的数据将使用提供的位置写入主机。注意,如果缓冲区是从指向非const数据的主机指针创建和初始化的,就好像用那个指针调用了set_final_data。从技术上讲,原始指针是OutputIterator的特例。如果传递给set_final_data的参数是一个std::weak_ptr,如果指针已经过期或已经被删除,数据不会被写入主机。是否发生写回也可以由set_write_back方法控制。

附件

由缓冲区表示的数据不能通过缓冲区对象直接访问。相反,我们必须创建允许我们安全访问缓冲区数据的访问器对象。访问器通知运行时我们希望在哪里以及如何访问数据,允许运行时确保正确的数据在正确的时间位于正确的位置。这是一个非常强大的概念,尤其是当与部分基于数据依赖性来调度内核执行的任务图结合使用时。

访问器对象是从模板化的accessor类实例化的。这个类有五个模板参数。第一个参数是被访问数据的类型。这应该与相应缓冲区存储的数据类型相同。类似地,第二个参数描述了数据和缓冲区的维度,默认值为 1。

img/489625_1_En_7_Fig6_HTML.png

图 7-6

访问模式

接下来的三个模板参数是访问者独有的。第一个是访问模式。访问模式描述了我们打算如何在程序中使用访问器。图 7-6 中列出了可能的模式。我们将在第八章中学习如何使用这些模式来命令内核的执行和执行数据移动。如果没有指定或自动推断,则访问模式参数有默认值。如果我们没有另外指定,对于非const数据类型,访问器将默认为read_write访问模式,对于const数据类型,访问器将默认为read。这些默认值总是正确的,但是提供更准确的信息可能会提高运行时执行优化的能力。在开始应用程序开发时,简单地不指定访问模式是安全和简洁的,然后我们可以基于对应用程序的性能关键区域的分析来细化访问模式。

img/489625_1_En_7_Fig7_HTML.png

图 7-7

访问目标

下一个模板参数是访问目标。缓冲区是数据的抽象,并不描述数据存储在哪里以及如何存储。访问目标描述了我们正在访问什么类型的数据,以及哪个内存将包含这些数据。图 7-7 中列出了可能的访问目标。数据类型是两种类型之一:缓冲区或图像。本书中讨论了图像,但我们可以将它们视为专用缓冲区,为图像处理提供特定于域的操作。

访问目标的另一个方面是我们应该关注的。设备可能有不同类型的可用存储器。这些存储器由不同的地址空间表示。最常用的内存类型是设备的全局内存。内核中的大多数访问器将使用这个目标,所以 global 是默认目标(如果我们没有指定)。常量和本地缓冲区使用专用内存。顾名思义,常量内存用于存储在内核调用期间保持不变的值。本地内存是一个工作组可用的特殊内存,其他工作组无法访问。我们将在第九章中学习如何使用本地内存。另一个值得注意的目标是主机缓冲区,这是访问主机上的缓冲区时使用的目标。这个模板参数的默认值是global_buffer,所以在大多数情况下,我们不需要在代码中指定目标。

最后一个模板参数决定了一个访问器是否是一个占位符访问器。这不是一个程序员可能会直接设置的参数。占位符访问器是在命令组之外声明的,但是用于访问内核内部设备上的数据。一旦我们看了访问器创建的例子,我们将看到占位符访问器和非占位符访问器的区别。

虽然可以使用缓冲区对象的get_access方法从缓冲区对象中提取访问器,但是直接创建(构造)它们更简单。这是我们将在接下来的例子中使用的风格,因为它很容易理解,也很简洁。

访问者创建

图 7-8 显示了一个示例程序,其中包含了我们开始使用访问器所需的一切。在这个例子中,我们有三个缓冲器,ABC。我们提交给队列的第一个任务是为每个缓冲区创建访问器,并定义一个内核,使用这些访问器用一些值初始化缓冲区。每个访问器都是用它将访问的缓冲区的引用以及由我们提交给队列的命令组定义的处理程序对象来构造的。这有效地将访问器绑定到我们作为命令组的一部分提交的内核。常规访问器是设备访问器,因为默认情况下,它们的目标是存储在设备内存中的全局缓冲区。这是最常见的用例。

img/489625_1_En_7_Fig8_HTML.png

图 7-8

简单的访问器创建

我们提交的第二个任务也定义了三个缓冲区的访问器。然后我们在第二个内核中使用这些访问器将缓冲区AB的元素添加到缓冲区C中。由于第二个任务与第一个任务操作相同的数据,运行时将在第一个任务完成后执行该任务。我们将在下一章详细了解这一点。

第三个任务展示了如何使用占位符访问器。在我们创建了缓冲区之后,访问器pC在图 7-8 中的例子的开头被声明。请注意,没有向构造器传递 handler 对象,因为我们没有要传递的对象。这让我们可以提前创建一个可重用的访问器对象。然而,为了在内核中使用这个访问器,我们需要在提交期间将它绑定到一个命令组。我们使用处理程序对象的require方法来完成这项工作。一旦我们将占位符访问器绑定到命令组,我们就可以像使用其他访问器一样在内核中使用它。

最后,我们创建一个host_accessor对象,以便在主机上读取我们的计算结果。请注意,这与我们在内核中使用的类型不同。主机访问器使用一个单独的host_accessor类来允许正确推断模板参数,提供一个简单的接口。请注意,本例中的主机访问器result也没有处理程序对象,因为我们也没有传递对象。主机访问器的特殊类型也让我们能够将它们与占位符区分开来。主机访问器的一个重要方面是,构造器仅在数据可供主机使用时才完成,这意味着主机访问器的构造可能需要很长时间。构造器必须等待任何产生要复制的数据的内核完成执行,以及等待复制本身完成。一旦主机访问器构造完成,就可以安全地在主机上直接使用它所访问的数据,并且我们可以保证在主机上获得最新版本的数据。

虽然这个例子是完全正确的,但是我们并没有说我们在创建访问器时打算如何使用它们。相反,我们对缓冲区中的非const int数据使用默认访问模式read-write。这可能会过度保守,并且可能会在操作之间创建不必要的依赖关系或多余的数据移动。如果一个运行时有更多关于我们计划如何使用我们创建的访问器的信息,它可能会做得更好。然而,在我们看一个这样做的例子之前,我们应该首先介绍另一个工具——访问标记。

访问标记是表达访问者所需的访问模式和目标组合的一种简洁方式。使用时,访问标记作为参数传递给访问器的构造器。可能的标签如图 7-9 所示。当用标记参数构造访问器时,C++ CTAD 可以正确地推导出所需的访问模式和目标,提供了一种简单的方法来覆盖那些模板参数的默认值。我们也可以手动指定所需的模板参数,但是标记提供了一种更简单、更紧凑的方式来获得相同的结果,而无需拼写出完全模板化的访问器。

img/489625_1_En_7_Fig9_HTML.png

图 7-9

访问标签

让我们以前面的例子为例,重写它以添加访问标记。这个新的改进示例如图 7-10 所示。

img/489625_1_En_7_Fig10_HTML.png

图 7-10

使用指定的用法创建访问者

我们首先声明我们的缓冲区,如图 7-8 所示。我们还创建了占位符访问器,我们将在后面使用。现在让我们看看提交给队列的第一个任务。以前,我们通过传递对命令组的缓冲区和处理程序对象的引用来创建我们的访问器。现在,我们向构造器调用添加两个额外的参数。第一个新参数是访问标记。因为这个内核正在为我们的缓冲区写初始值,所以我们使用了write_only访问标记。这让运行时知道这个内核正在产生新的数据,并且不会从缓冲区中读取。

第二个新参数是一个可选的访问器属性,类似于我们在本章前面看到的缓冲区的可选属性。我们传递的属性noinit让运行时知道缓冲区中以前的内容可以被丢弃。这很有用,因为它可以让运行时消除不必要的数据移动。在这个例子中,因为第一个任务是为我们的缓冲区写初始值,所以运行时没有必要在内核执行之前将未初始化的主机内存复制到设备上。noinit属性在这个例子中很有用,但是它不应该用于读-修改-写的情况,也不应该用于只能更新缓冲区中某些值的内核。

我们提交给队列的第二个任务与之前相同,但是现在我们向我们的访问器添加了访问标记。这里,我们给访问器aAaB添加标签read_only,让运行时知道我们将只通过这些访问器读取缓冲区AB的值。第三个访问器aC获得read_write访问标记,因为我们将AB的元素之和累加到C中。我们在示例中显式地使用标签来保持一致,但是这是不必要的,因为默认的访问模式是read_write

默认用法保留在第三个任务中,在这里我们使用占位符访问器。这与我们在图 7-8 中看到的简化示例保持不变。我们的最后一个访问器,主机访问器result,现在在我们创建它时会收到一个访问标记。因为我们只读取主机上的最终值,所以我们将read_only标记传递给构造器。如果我们以破坏主机访问器的方式重写程序,启动另一个在缓冲区C上运行的内核不需要将它写回设备,因为read_only标签让运行时知道它不会被主机修改。

我们可以用访问器做什么?

使用访问器对象可以完成许多事情。然而,我们能做的最重要的事情是在访问器的名字中拼写出来——访问数据。这通常是通过访问器的[]操作符来完成的。我们在图 7-8 和 7-10 的示例中使用了[]操作符。这个操作符要么接受一个可以正确索引多维数据的id对象,要么接受一个size_t。当访问者有多个维度时,使用第二种情况。它返回一个对象,然后用[]再次索引该对象,直到我们得到一个标量值,这在二维情况下将是a[i][j]的形式。请记住,访问器维度的排序遵循 C++ 的约定,其中最右边的维度是单位步长维度(迭代“最快”)。

访问器还可以返回指向基础数据的指针。这个指针可以按照正常的 C++ 规则直接访问。注意,关于这个指针的地址空间,可能涉及额外的复杂性。地址空间和它们的怪癖将在后面的章节中讨论。

许多东西也可以从访问器对象中查询。示例包括通过访问器可访问的元素数量、它所覆盖的缓冲区区域的字节大小或可访问的数据范围。

访问器为 C++ 容器提供了一个类似的接口,可以在许多容器被传递的情况下使用。访问器支持的容器接口包括data方法,相当于get_pointer,以及几种向前和向后迭代器。

摘要

在本章中,我们已经学习了缓冲区和存取器。缓冲区是对数据的抽象,它对程序员隐藏了内存管理的底层细节。他们这样做是为了提供一个更简单、更高层次的抽象。我们通过几个例子向我们展示了构造缓冲区的不同方法,以及可以被指定来改变它们的行为的不同可选属性。我们学习了如何用来自主机内存的数据初始化缓冲区,以及如何在使用完缓冲区时将数据写回主机内存。

因为我们不应该直接访问缓冲区,所以我们学习了如何使用访问器对象来访问缓冲区中的数据。我们了解了设备访问器和主机访问器之间的区别。我们讨论了不同的访问模式和目标,以及它们如何通知运行时程序将如何以及在哪里使用访问器。我们展示了使用默认访问模式和目标来使用访问器的最简单方法,并且我们学习了如何区分占位符访问器和非占位符访问器。然后,我们看到了如何通过向我们的访问器声明添加访问标记,为运行时提供更多关于我们的访问器用法的信息,从而进一步优化示例程序。最后,我们讨论了在程序中使用访问器的许多不同方式。

在下一章,我们将更详细地了解运行时如何使用我们通过访问器给它的信息来调度不同内核的执行。我们还将了解这些信息如何通知运行时缓冲区中的数据何时以及如何需要在主机和设备之间复制。我们将了解如何显式控制涉及缓冲区的数据移动——以及 USM 分配。

Creative Commons

开放存取本章根据知识共享署名 4.0 国际许可证(http://Creative Commons . org/licenses/by/4.0/)的条款获得许可,该许可证允许以任何媒体或格式使用、共享、改编、分发和复制,只要您适当注明原作者和来源,提供知识共享许可证的链接并指明是否进行了更改。

本章中的图像或其他第三方材料包含在本章的知识共享许可中,除非在材料的信用额度中另有说明。如果材料不包括在本章的知识共享许可中,并且您的预期使用不被法律法规允许或超出了允许的使用范围,您将需要直接从版权所有者处获得许可。

八、调度内核和数据移动

img/489625_1_En_8_Figa_HTML.gif

我们需要讨论一下我们作为平行项目的音乐会指挥的角色。并行程序的适当编排是一件美妙的事情——代码全速运行而不等待数据,因为我们已经安排所有数据在适当的时间到达和离开。代码分解良好,以保持硬件最大限度地忙碌。这是构成梦想的东西!

快车道上的生活——不仅仅是一条车道!—要求我们认真对待指挥工作。为了做到这一点,我们可以根据任务图来考虑我们的工作。

因此,在这一章中,我们将讨论任务图,这种机制用于正确有效地运行复杂的内核序列。在应用程序中有两件事情需要排序:内核和数据移动。任务图是我们用来实现正确排序的机制。

首先,我们将快速回顾如何使用依赖关系来排序第三章中的任务。接下来,我们将介绍 DPC++ 运行时如何构建图形。我们将讨论 DPC++ 图形的基本构件,命令组。然后,我们将举例说明构建常见模式图的不同方法。我们还将讨论数据移动,无论是显式的还是隐式的,是如何在图中表示的。最后,我们将讨论使我们的图表与主机同步的各种方法。

什么是图表调度?

在第三章中,我们讨论了数据管理和数据使用的排序。那一章描述了 DPC++ 中图形背后的关键抽象:依赖性。内核之间的依赖关系基本上是基于内核访问的数据。内核在计算输出之前需要确定它读取了正确的数据。

我们描述了对确保正确执行很重要的三种类型的数据依赖。第一种是写后读(RAW ),发生在一个任务需要读取另一个任务产生的数据时。这种类型的依赖描述了两个内核之间的数据流。第二种依赖发生在一个任务需要在另一个任务读取数据后更新数据的时候。我们称这种类型的依赖为读后写(WAR)依赖。最后一种类型的数据依赖发生在两个任务试图写入相同的数据时。这就是所谓的写后写(WAW)依赖性。

数据相关性是我们用来构建图表的基础。这组依赖关系是我们表达简单的线性核链和具有数百个具有复杂依赖关系的核的大型复杂图所需要的全部。无论计算需要哪种类型的图,DPC++ 图都可以确保程序根据所表达的依赖关系正确执行。然而,确保一个图正确地表达程序中的所有依赖关系是程序员的责任。

图形如何在 DPC++ 中工作

一个命令组可以包含三种不同的东西:一个动作、它的依赖项和各种各样的主机代码。在这三件事情中,最需要的是行动,因为没有它,指挥组真的什么也做不了。大多数命令组也会表达依赖性,但也有不表达的情况。一个这样的例子是在程序中提交的第一个动作。它不依赖于任何东西来开始执行;因此,我们不会指定任何依赖关系。命令组中可能出现的另一个东西是在主机上执行的任意 C++ 代码。这是完全合法的,并且有助于指定动作或其依赖项,并且在创建命令组时执行该代码(而不是在基于已满足的依赖项执行动作时)。

命令组通常表示为传递给 submit 方法的 C++ lambda 表达式。命令组也可以通过队列对象上的快捷方式来表达,队列对象采用一个内核和一组基于事件的依赖关系。

命令组操作

命令组可以执行两种类型的操作:内核和显式内存操作。一个命令组只能执行一个动作。正如我们在前面章节中看到的,内核是通过调用parallel_forsingle_task方法来定义的,并表达我们想要在设备上执行的计算。显式数据移动操作是第二种类型的操作。USM 的例子包括memcpymemsetfill操作。缓冲器的例子包括copyfillupdate_host

命令组如何声明依赖关系

命令组的另一个主要组成部分是在组定义的动作可以执行之前必须满足的依赖集。DPC++ 允许以多种方式指定这些依赖关系。

如果程序使用有序 DPC++ 队列,队列的有序语义指定连续排队的命令组之间的隐式依赖关系。在之前提交的任务完成之前,一个任务无法执行。

基于事件的依赖性是指定在命令组可以执行之前必须完成什么的另一种方式。这些基于事件的依赖性可以用两种方式来指定。当命令组被指定为传递给队列的submit方法的 lambda 时,使用第一种方法。在这种情况下,程序员调用命令组处理程序对象的depends_on方法,将事件或事件向量作为参数传递。当从队列对象上定义的快捷方法创建命令组时,使用另一种方法。当程序员直接调用队列上的parallel_forsingle_task时,事件或事件向量可能会作为额外的参数传递。

指定依赖关系的最后一种方法是通过创建访问器对象。访问器指定如何使用它们在缓冲区对象中读取或写入数据,让运行时使用这些信息来确定不同内核之间存在的数据依赖关系。正如我们在本章开始时所回顾的,数据依赖的例子包括一个内核读取另一个内核产生的数据,两个内核写入相同的数据,或者一个内核在另一个内核读取数据后修改数据。

例子

现在,我们将用几个例子来说明我们刚刚学到的一切。我们将展示如何用几种方式表达两种不同的依赖模式。我们将说明的两种模式是线性依赖链,其中一个任务在另一个任务之后执行,以及“Y”模式,其中两个独立的任务必须在连续的任务之前执行。

这些依赖模式的图表可以在图 8-1 和 8-2 中看到。图 8-1 描绘了一个线性依赖链。第一个节点表示数据的初始化,而第二个节点表示将数据累积到单个结果中的归约操作。图 8-2 描绘了一个“Y”模式,我们独立地初始化两个不同的数据。数据初始化后,加法核将把两个向量加在一起。最后,图中的最后一个节点将结果累积成一个值。

img/489625_1_En_8_Fig2_HTML.png

图 8-2

“Y”型依赖图

img/489625_1_En_8_Fig1_HTML.png

图 8-1

线性相关链图

对于每种模式,我们将展示三种不同的实现。第一个实现将使用有序队列。第二种将使用基于事件的依赖关系。最后一个实现将使用缓冲区和存取器来表达命令组之间的数据依赖性。

图 8-3 显示了如何使用有序队列表达线性依赖链。这个例子非常简单,因为有序队列的语义已经保证了命令组之间的执行顺序。我们提交的第一个内核将数组的元素初始化为 1。然后,下一个内核获取这些元素,并将它们汇总到第一个元素中。因为我们的队列是有序的,所以我们不需要做任何其他事情来表示第二个内核应该在第一个内核完成之前不执行。最后,我们等待队列执行完所有任务,并检查我们是否获得了预期的结果。

img/489625_1_En_8_Fig3_HTML.png

图 8-3

具有有序队列的线性相关链

图 8-4 显示了使用无序队列和基于事件的依赖关系的相同例子。这里,我们捕获第一次调用parallel_for返回的事件。然后,第二个内核能够通过将它作为参数传递给depends_on来指定对该事件及其所代表的内核执行的依赖。我们将在图 8-6 中看到如何使用定义内核的快捷方法之一来缩短第二个内核的表达式。

img/489625_1_En_8_Fig4_HTML.png

图 8-4

事件线性相关链

图 8-5 使用缓冲区和存取器代替 USM 指针重写了我们的线性依赖链示例。这里我们再次使用无序队列,但是使用通过访问器指定的数据依赖关系,而不是基于事件的依赖关系来排序命令组的执行。第二个内核读取第一个内核产生的数据,运行时可以看到这一点,因为我们基于相同的底层缓冲区对象声明了访问器。与前面的例子不同,我们不等待队列执行完所有任务。相反,我们声明一个主机访问器,它定义了第二个内核的输出和我们的断言(我们在主机上计算了正确的答案)之间的数据依赖关系。请注意,虽然主机访问器为我们提供了主机上数据的最新视图,但它并不保证原始主机内存已经更新(如果在创建缓冲区时指定了任何内存)。我们不能安全地访问原始主机内存,除非缓冲区首先被破坏,或者除非我们使用更高级的机制,如第七章中描述的互斥机制。

img/489625_1_En_8_Fig5_HTML.png

图 8-5

具有缓冲器和附件的线性相关链

图 8-6 显示了如何使用有序队列表达一个“Y”模式。在这个例子中,我们声明了两个数组,data1data2。然后我们定义两个内核,每个内核初始化一个数组。这些内核并不相互依赖,但是因为队列是有序的,所以内核必须一个接一个地执行。注意,在这个例子中交换这两个内核的顺序是完全合法的。在第二个内核执行之后,第三个内核将第二个数组的元素添加到第一个数组的元素中。最终的内核将第一个数组的元素相加,计算出与我们在线性依赖链的例子中相同的结果。这个求和核依赖于前面的核,但是这个线性链也被有序队列捕获。最后,我们等待所有内核完成,并验证我们成功地计算了我们的幻数。

img/489625_1_En_8_Fig6_HTML.png

图 8-6

具有有序队列的“Y”型模式

图 8-7 显示了我们的“Y”模式示例,使用无序队列代替有序队列。由于队列的顺序,依赖性不再是隐式的,我们必须使用事件显式地指定命令组之间的依赖性。如图 8-6 所示,我们从定义两个没有初始依赖关系的独立内核开始。我们用两个事件来表示这些内核,e1e2。当我们定义第三个内核时,我们必须指定它依赖于前两个内核。我们这样做是因为它依赖于事件e1e2在执行之前完成。然而,在这个例子中,我们使用一种快捷方式来指定这些依赖关系,而不是处理程序的depends_on方法。这里,我们将事件作为额外参数传递给parallel_for。因为我们想一次传递多个事件,所以我们使用接受一个std::vector事件的表单,但是幸运的是,现代 C++ 通过自动将表达式{e1, e2}转换成适当的向量,为我们简化了这个过程。

img/489625_1_En_8_Fig7_HTML.png

图 8-7

事件的“Y”型模式

在我们最后的例子中,如图 8-8 所示,我们再次用缓冲区和访问器替换 USM 指针和事件。这个例子将两个数组data1data2表示为缓冲对象。我们的内核不再使用快捷方式来定义内核,因为我们必须将访问器与命令组处理程序相关联。同样,第三个内核必须捕获对前两个内核的依赖。在这里,这是通过为我们的缓冲区声明访问器来实现的。因为我们之前已经为这些缓冲区声明了访问器,所以运行时能够正确地排序这些内核的执行。此外,当我们声明访问器b时,我们还在这里向运行时提供额外的信息。我们添加了访问标签read_only来让运行时知道我们只是要读取这些数据,而不是产生新的值。正如我们在线性依赖链的缓冲区和存取器示例中看到的,我们的最终内核通过更新第三个内核中产生的值来进行自我排序。我们通过声明一个主机访问器来检索我们计算的最终值,该主机访问器将等待最终内核完成执行,然后将数据移回主机,在那里我们可以读取数据并断言我们计算了正确的结果。

img/489625_1_En_8_Fig8_HTML.png

图 8-8

带存取器的“Y”型模式

CG 的各个部分是什么时候执行的?

因为任务图是异步的,所以想知道命令组何时被执行是有意义的。到目前为止,应该很清楚,一旦满足了内核的依赖性,就可以执行内核,但是命令组的主机部分会发生什么情况呢?

当一个命令组被提交到一个队列时,它会立即在主机上执行(在submit调用返回之前)。命令组的主机部分只执行一次。命令组中定义的任何内核或显式数据操作都将在设备上排队等待执行。

数据传送

数据移动是 DPC++ 中图形的另一个非常重要的方面,对于理解应用程序性能至关重要。但是,如果数据移动是在程序中隐式发生的,无论是使用缓冲区和访问器还是使用 USM 共享分配,这一点经常会被意外忽略。接下来,我们将研究在 DPC++ 中数据移动影响图形执行的不同方式。

明确的

显式数据移动的优点是它在图中显式地出现,让程序员清楚地看到图的执行过程。我们将把显式数据操作分为 USM 操作和缓冲区操作。

正如我们在第六章中了解到的,当我们需要在设备分配和主机之间拷贝数据时,USM 中会发生显式数据移动。这是通过在队列和处理程序类中都可以找到的memcpy方法来完成的。提交操作或命令组会返回一个事件,该事件可用于与其他命令组一起订购副本。

通过调用命令组处理程序对象的copyupdate_host方法,使用缓冲区进行显式数据移动。copy方法可用于在主机内存和设备上的访问器对象之间手动交换数据。这样做有多种原因。一个简单的例子是对长时间运行的计算序列进行检查点操作。使用拷贝方法,数据可以以单向方式从设备写入任意主机内存。如果这是使用缓冲区完成的,大多数情况下(即缓冲区不是用use_host_ptr创建的)需要先将数据复制到主机,然后从缓冲区的存储器复制到所需的主机存储器。

update_host方法是copy的一种非常特殊的形式。如果在主机指针周围创建了缓冲区,此方法会将访问器表示的数据复制回原始主机内存。如果一个程序用一个用特殊的use_mutex属性创建的缓冲区手动同步主机数据,这可能是有用的。然而,这种用例不太可能在大多数程序中出现。

隐形的

隐式数据移动可能会对 DPC++ 中的命令组和任务图产生隐藏的后果。通过隐式数据移动,数据通过 DPC++ 运行时或硬件和软件的某种组合在主机和设备之间复制。在任一情况下,复制都是在没有用户明确输入的情况下进行的。让我们再次分别看一下 USM 和 buffer 案例。

使用 USM,隐式数据移动随着hostshared分配而发生。正如我们在第六章中了解到的,host分配并不真正移动数据,而是远程访问数据,shared分配可能会在主机和设备之间迁移。因为这种迁移是自动发生的,所以 USM 隐式数据移动和命令组真的没有什么可考虑的。然而,关于shared的分配有一些细微差别值得记住。

prefetch操作的工作方式与memcpy相似,目的是让运行时在内核尝试使用共享分配之前开始迁移它们。然而,与为了确保正确结果而必须复制数据的memcpy不同,预取通常被视为对运行时的提示以提高性能,并且预取不会使内存中的指针值无效(就像复制到新的地址范围时的复制一样)。如果在内核开始执行之前预取没有完成,程序仍将正确执行,并且许多代码可能选择使图形中的命令组不依赖于预取操作,因为它们不是功能需求。

缓冲区也有一些细微差别。使用缓冲区时,命令组必须为缓冲区构造指定如何使用数据的访问器。这些数据依赖关系表达了不同命令组之间的顺序,并允许我们构建任务图。然而,带有缓冲区的命令组有时还有另一个用途:它们指定数据移动的要求。

访问器指定内核将读取或写入缓冲区。由此得出的推论是,数据也必须在设备上可用,如果不可用,运行时必须在内核开始执行之前将数据转移到设备上。因此,DPC++ 运行时必须跟踪缓冲区的当前版本,以便可以调度数据移动操作。访问器创建有效地在图中创建了一个额外的隐藏节点。如果数据移动是必要的,运行时必须首先执行它。只有这样,提交的内核才能执行。

让我们再看看图 8-8 。在这个例子中,我们的前两个内核需要将缓冲区data1data2复制到设备中;运行时隐式创建额外的图形节点来执行数据移动。当提交第三个内核的命令组时,这些缓冲区很可能仍然在设备上,因此运行时不需要执行任何额外的数据移动。第四个内核的数据也可能不需要任何额外的数据移动,但是主机访问器的创建需要运行时在访问器可用之前安排将缓冲区data1移回主机。

与主机同步

我们将讨论的最后一个主题是如何与主机同步图形执行。我们已经在这一章中谈到了这一点,但是我们现在将检查一个程序可以做到这一点的所有不同方式。

主机同步的第一种方法是我们在前面的许多例子中使用过的:等待一个队列。队列对象有两个方法,waitwait_and_throw,它们阻塞执行,直到提交给队列的每个命令组都完成为止。这是一个非常简单的方法,可以处理许多常见的情况。但是,值得指出的是,这种方法是非常粗粒度的。如果需要更细粒度的同步,我们将讨论的另一种方法可能更适合应用程序的需求。

主机同步的下一种方法是对事件进行同步。这比同步队列更加灵活,因为它允许应用程序只同步特定的操作或命令组。这是通过调用事件上的wait方法或者调用事件类上的静态方法wait来完成的,后者可以接受事件的向量。

我们已经看到了图 8-5 和 8-8 中使用的下一个方法:主机访问器。主机访问者执行两个功能。首先,顾名思义,它们使主机上的数据可供访问。第二,它们通过在当前访问的图和主机之间定义新的依赖关系来与主机同步。这确保了复制回主机的数据是图形正在执行的计算的正确值。但是,我们再次注意到,如果缓冲区是从现有的主机内存中构造的,则不能保证这个原始内存包含更新后的值。

请注意,主机访问者正在阻塞。在数据可用之前,主机上的执行可能不会超过主机访问器的创建。同样,当主机访问器存在并保持其数据可用时,不能在设备上使用缓冲区。一种常见的模式是在附加的 C++ 范围内创建主机访问器,以便在不再需要主机访问器时释放数据。这是下一种主机同步方法的示例。

DPC++ 中的某些对象在被销毁时有特殊的行为,它们的析构函数被调用。我们刚刚了解了主机访问者如何使数据保留在主机上,直到它们被销毁。缓冲区和图像在被销毁或离开作用域时也有特殊的行为。当一个缓冲区被销毁时,它会等待所有使用该缓冲区的命令组完成执行。一旦缓冲区不再被任何内核或内存操作使用,运行时可能必须将数据复制回主机。如果缓冲区是用主机指针初始化的,或者如果主机指针被传递给方法set_final_data,就会发生这种复制。然后,运行库将复制回该缓冲区的数据,并在对象被销毁之前更新主机指针。

与主机同步的最后一个选项涉及一个在第七章中首次描述的不常见功能。回想一下,缓冲区对象的构造器可以选择接受一个属性列表。创建缓冲区时可以传递的有效属性之一是use_mutex。当以这种方式创建缓冲区时,它增加了缓冲区所拥有的内存可以与宿主应用程序共享的要求。对这个内存的访问是由用来初始化缓冲区的互斥体控制的。当访问与缓冲区共享的内存是安全的时,主机能够获得互斥锁。如果无法获得锁,用户可能需要将内存移动操作排入队列,以便与主机同步数据。这种用法非常特殊,不太可能在大多数 DPC++ 应用程序中找到。

摘要

在这一章中,我们已经学习了图形以及在 DPC++ 中如何构建、调度和执行图形。我们详细介绍了什么是命令组以及它们的功能。我们讨论了命令组中可能包含的三样东西:依赖性、动作和各种主机代码。我们回顾了如何使用事件以及通过访问器描述的数据依赖性来指定任务之间的依赖性。我们了解到命令组中的单个操作可以是内核操作,也可以是显式内存操作,然后我们看了几个例子,这些例子展示了我们可以构建通用执行图模式的不同方式。接下来,我们回顾了数据移动是 DPC++ 图形的一个重要部分,并且我们了解了它是如何在图形中显式或隐式出现的。最后,我们研究了所有将图形的执行与主机同步的方法。

理解程序流可以使我们理解如果我们有运行时故障要调试时可以打印的那种调试信息。第十三章在“调试运行时故障”一节中有一个表格,考虑到我们在书中学到的知识,这个表格会更有意义一些。然而,本书并不试图详细讨论这些高级编译器转储。

希望这让您感觉自己像一个图形专家,能够构建复杂的图形,从线性链到具有数百个节点和复杂数据和任务依赖关系的巨大图形!在下一章中,我们将开始深入到对提高特定设备上的应用程序的性能有用的底层细节。

Creative Commons

开放存取本章根据知识共享署名 4.0 国际许可证(http://Creative Commons . org/licenses/by/4.0/)的条款获得许可,该许可证允许以任何媒体或格式使用、共享、改编、分发和复制,只要您适当注明原作者和来源,提供知识共享许可证的链接并指明是否进行了更改。

本章中的图像或其他第三方材料包含在本章的知识共享许可中,除非在材料的信用额度中另有说明。如果材料不包括在本章的知识共享许可中,并且您的预期使用不被法律法规允许或超出了允许的使用范围,您将需要直接从版权所有者处获得许可。

九、通信和同步

img/489625_1_En_9_Figa_HTML.gif

在第四章中,我们讨论了表达并行性的方法,要么使用基本的数据并行内核,显式 ND-range 内核,要么使用分层并行内核。我们讨论了基本的数据并行内核如何独立地对每一块数据应用相同的操作。我们还讨论了显式 ND-range 内核和分层并行内核如何将执行范围划分为工作项目的工作组。

在这一章中,我们将在继续寻求平行思考的过程中,重新审视如何将问题分解成小块的问题。本章提供了关于显式 ND-range 内核和分层并行内核的更多细节,并描述了如何使用工作项分组来提高某些类型算法的性能。我们将描述工作项组如何为并行工作的执行提供额外的保证,并且我们将介绍支持工作项组的语言特性。在第 15 、 16 和 17 章中优化特定设备的程序时,以及在第十四章中描述常见的并行模式时,这些想法和概念中的许多都很重要。

工作组和工作项

回想一下第四章中的内容,显式 ND-range 和分层并行内核将工作项组织成工作组,并且工作组中的工作项保证同时执行。这个属性很重要,因为当工作项被保证并发执行时,一个工作组中的工作项可以合作解决一个问题。

图 9-1 显示了一个分为多个工作组的 ND 范围,每个工作组用不同的颜色表示。每个工作组中的工作项保证并发执行,因此一个工作项可以与共享相同颜色的其他工作项进行通信。

img/489625_1_En_9_Fig1_HTML.png

图 9-1

二维 ND-大小范围(8,8)分为四个大小工作组(4,4)

因为不同工作组中的工作项目不能保证同时执行,所以具有一种颜色的工作项目不能与具有不同颜色的工作项目可靠地通信,并且如果一个工作项目试图与当前没有执行的另一个工作项目通信,则内核可能会死锁。因为我们希望我们的内核完成执行,我们必须确保当一个工作项目与另一个工作项目通信时,它们在同一个工作组中。

高效沟通的构建模块

本节描述支持组中工作项之间高效通信的构建块。一些是基本的构建模块,支持定制算法的构建,而另一些是更高级的,描述许多内核使用的通用操作。

通过屏障实现同步

沟通最基本的构件是屏障功能。屏障功能有两个主要目的:

首先,barrier 函数同步组中工作项的执行。通过同步执行,一个工作项可以确保另一个工作项在使用该操作的结果之前已经完成了该操作。或者,在另一个工作项使用操作结果之前,给一个工作项时间来完成其操作。

第二,barrier 函数同步每个工作项如何看待内存的状态。这种类型的同步操作被称为强制内存一致性防护内存(更多细节在第十九章)。存储器一致性至少与同步执行一样重要,因为它确保了在屏障之前执行的存储器操作的结果对于屏障之后的其他工作项目是可见的。没有内存一致性,一个工作项中的操作就像森林中倒下的一棵树,声音可能被其他工作项听到,也可能听不到!

图 9-2 显示了一个组中的四个工作项,它们在一个障碍函数中同步。尽管每个工作项的执行时间可能不同,但是直到所有工作项都执行了屏障,才可以执行越过屏障的工作项。在执行屏障函数之后,所有的工作项都有一个一致的内存视图。

img/489625_1_En_9_Fig2_HTML.png

图 9-2

一个组中的四个工作项在屏障函数处同步

WHY ISN’T MEMORY CONSISTENT BY DEFAULT?

对于许多程序员来说,内存一致性的想法——以及不同的工作项可以有不同的内存视图——可能感觉非常奇怪。如果默认情况下所有工作项的内存都是一致的,不是更容易吗?简而言之,答案是肯定的,但实施起来也会非常昂贵。通过允许工作项目具有不一致的存储器视图,并且在程序执行期间仅要求在定义的点处的存储器一致性,加速器硬件可能更便宜,可能执行得更好,或者两者兼而有之。

因为屏障函数同步执行,所以要么组中的所有工作项目都执行屏障,要么组中没有工作项目执行屏障,这一点至关重要。如果组中的一些工作项绕过任何障碍函数,组中的其他工作项可能会永远等待障碍——或者至少直到用户放弃并终止程序!

COLLECTIVE FUNCTIONS

当一个功能需要由一个组中的所有工作项目执行时,它可以被称为一个集合功能,因为该操作是由该组执行的,而不是由该组中的单个工作项目执行的。屏障函数不是 SYCL 中唯一可用的集合函数。其他集合函数将在本章后面介绍。

工作组本地存储器

工作组屏障功能足以协调工作组中工作项目之间的通信,但是通信本身必须通过记忆发生。通信可以通过 USM 或缓冲区进行,但这可能不方便且效率低下:它需要专用于通信的分配,并且需要在工作组之间划分分配。

为了简化内核开发并加速工作组中工作项之间的通信,SYCL 定义了一个特殊的本地内存空间,专门用于工作组中工作项之间的通信。

在图 9-3 中,显示了两个工作组。两个工作组都可以访问全局内存空间中的 USM 和缓冲区。每个工作组可以访问自己的本地内存空间中的变量,但不能访问另一个工作组的本地内存中的变量。

img/489625_1_En_9_Fig3_HTML.png

图 9-3

每个工作组可以访问所有全局内存,但只能访问自己的本地内存

当一个工作组开始时,它的本地内存的内容是未初始化的,并且在一个工作组完成执行后,本地内存不再存在。由于这些特性,当一个工作组正在执行时,本地存储器只能用于临时存储。

对于一些设备,例如对于许多 CPU 设备,本地存储器是软件抽象,并且使用与全局存储器相同的存储器子系统来实现。在这些设备上,使用本地内存主要是一种方便的通信机制。一些编译器可以使用内存空间信息进行编译器优化,但是在其他方面,使用本地内存进行通信并不会比通过这些设备上的全局内存进行通信的性能更好。

但是对于其他设备,如许多 GPU 设备,本地内存有专用资源,在这些设备上,通过本地内存进行通信将比通过全局内存进行通信性能更好。

当使用本地内存时,一个工作组中的工作项之间的通信会更加方便和快捷!

我们可以使用设备查询info::device: :local_mem_type来确定加速器是否有专用于本地存储器的资源,或者本地存储器是否被实现为全局存储器的软件抽象。有关查询设备属性的更多信息,请参考第十二章;有关如何为 CPU、GPU 和 FPGAs 实现本地存储器的更多信息,请参考第 15 、 16 和 17 章。

使用工作组障碍和本地记忆

既然我们已经确定了工作项之间有效通信的基本构件,我们可以描述如何在内核中表达工作组障碍和本地内存。请记住,工作项之间的通信需要工作项分组的概念,因此这些概念只能针对 ND 范围内核和分层内核来表达,而不包括在基本数据并行内核的执行模型中。

本章将在第四章介绍的简单矩阵乘法核心示例的基础上,介绍执行矩阵乘法的工作组中工作项目之间的通信。在许多设备上——但不一定是全部!—通过本地内存进行通信将提高矩阵乘法内核的性能。

A NOTE ABOUT MATRIX MULTIPLICATION

在本书中,矩阵乘法内核用于演示内核的变化如何影响性能。虽然使用本章介绍的技术可以提高某些设备的矩阵乘法性能,但矩阵乘法是一种非常重要和常见的运算,许多供应商已经实现了高度优化的矩阵乘法版本。厂商投入大量的时间和精力来实现和验证特定设备的功能,并且在某些情况下可能使用在标准并行内核中难以或不可能使用的功能或技术。

USE VENDOR-PROVIDED LIBRARIES!

当供应商提供一个函数的库实现时,使用它比将函数重新实现为并行内核更有益!对于矩阵乘法,人们可以将 oneMKL 作为英特尔 oneAPI 工具包的一部分,来寻找适合 DPC++ 程序员的解决方案。

图 9-4 显示了我们将要开始的朴素的矩阵乘法内核,摘自第四章。

img/489625_1_En_9_Fig4_HTML.png

图 9-4

第四章中的简单矩阵乘法内核

在第四章中,我们观察到矩阵乘法算法具有高度的重用性,并且对工作项进行分组可以提高访问的局部性,从而提高缓存命中率。在这一章中,我们没有依靠隐式缓存行为来提高性能,而是使用本地内存作为显式缓存,以保证访问的局部性。

对于许多算法来说,将本地内存视为显式缓存是有帮助的。

图 9-5 是第四章的修改图,显示了一个由单行组成的工作组,这使得使用本地存储器的算法更容易理解。注意,对于结果矩阵的一行中的元素,每个结果元素都是使用来自输入矩阵之一的唯一数据列计算的,以蓝色和橙色显示。因为这个输入矩阵没有数据共享,所以它不是本地内存的理想选择。但是,请注意,该行中的每个结果元素都访问另一个输入矩阵中完全相同的数据,以绿色显示。因为这些数据是重用的,所以它是受益于工作组本地内存的绝佳候选对象。

img/489625_1_En_9_Fig5_HTML.png

图 9-5

矩阵乘法到工作组和工作项的映射

因为我们想要乘可能非常大的矩阵,并且因为工作组本地存储器可能是有限的资源,所以我们修改的内核将处理每个矩阵的子部分,我们将这些子部分称为矩阵。对于每个图块,我们修改后的内核会将图块的数据加载到本地内存中,同步组中的工作项,然后从本地内存而不是全局内存中加载数据。第一个图块的访问数据如图 9-6 所示。

img/489625_1_En_9_Fig6_HTML.png

图 9-6

处理第一个图块:绿色输入数据(X 的左侧)被重用并从本地内存中读取,蓝色和橙色输入数据(X 的右侧)从全局内存中读取

在我们的内核中,我们选择了与工作组大小相等的瓦片大小。这不是必需的,但是因为它简化了进出本地存储器的传输,所以选择工作组大小的倍数的切片大小是常见且方便的。

ND-Range 核中的工作组障碍和局部记忆

本节描述了工作组障碍和局部记忆是如何在 ND-range 核中表示的。对于 ND-range 内核,表示是显式的:内核声明并操作表示本地地址空间中的分配的本地存取器,并调用屏障函数来同步工作组中的工作项目。

本地访问者

要声明在 ND-range 内核中使用的本地内存,使用一个本地访问器。像其他访问器对象一样,本地访问器是在命令组处理程序中构造的,但是与第 3 和 7 章中讨论的访问器对象不同,本地访问器不是从缓冲区对象创建的。相反,通过指定类型和描述该类型元素数量的范围来创建局部访问器。像其他访问器一样,局部访问器可以是一维、二维或三维的。图 9-7 展示了如何声明本地访问器并在内核中使用它们。

img/489625_1_En_9_Fig7_HTML.png

图 9-7

声明和使用本地访问器

请记住,当每个工作组开始时,本地内存是未初始化的,并且在每个工作组完成后不会持续存在。这意味着本地访问器必须总是read_write,因为否则内核将无法分配本地内存的内容或查看分配的结果。但是,本地访问器也可以是原子的,在这种情况下,通过访问器对本地存储器的访问是原子地执行的。原子访问将在第十九章中详细讨论。

同步功能

为了同步 ND-range 内核工作组中的工作项,调用nd_item类中的barrier函数。因为屏障函数是nd_item类的成员,所以它只对 ND-range 内核可用,对基本数据并行内核或分层内核不可用。

barrier 函数目前接受一个参数来描述要同步的内存空间或 fence ,但是随着内存模型在 SYCL 和 DPC++ 中的发展,barrier 函数的参数将来可能会改变。然而,在所有情况下,屏障函数的参数提供了关于同步的内存空间或内存同步的范围的额外控制。

当没有参数传递给屏障函数时,屏障函数将使用功能正确且保守的默认值。本章中的代码示例使用这种语法以获得最大的可移植性和可读性。对于高度优化的内核,建议精确描述哪些内存空间或哪些工作项必须同步,这样可以提高性能。

一个完整的 ND 范围内核示例

现在我们知道了如何声明一个本地内存访问器,并使用屏障函数同步对它的访问,我们可以实现一个 ND-range 内核版本的矩阵乘法,它协调工作组中工作项之间的通信,以减少全局内存的流量。完整的示例如图 9-8 所示。

img/489625_1_En_9_Fig8_HTML.png

图 9-8

用 ND-range parallel_for和工作组本地存储器表示平铺矩阵乘法内核

这个内核中的主循环可以被认为是两个不同的阶段:在第一阶段,工作组中的工作项协作将共享数据从 A 矩阵加载到工作组本地内存中;在第二种情况下,工作项使用共享数据执行自己的计算。为了确保所有的工作项在进入第二阶段之前已经完成了第一阶段,这两个阶段通过调用barrier来同步所有的工作项并提供一个内存栅栏来分开。这种模式很常见,在内核中使用工作组本地内存几乎总是需要使用工作组屏障。

注意,还必须调用barrier来同步当前图块的计算阶段和下一个矩阵图块的加载阶段之间的执行。如果没有这种同步操作,当前矩阵片的一部分可能会在另一个工作项完成计算之前被工作组中的一个工作项覆盖。一般来说,每当一个工作项在本地内存中读取或写入由另一个工作项读取或写入的数据时,就需要同步。在图 9-8 中,同步是在循环结束时进行的,但是在每次循环迭代开始时进行同步也同样正确。

等级核中的工作组障碍和局部记忆

本节描述了如何在分层内核中表达工作组障碍和本地记忆。与 ND-range 内核不同,分层内核中的本地内存和屏障是隐式的,不需要特殊的语法或函数调用。一些程序员会发现分层内核表示更加直观和易于使用,而其他程序员会喜欢 ND-range 内核提供的直接控制。在大多数情况下,可以使用两种表示来描述相同的算法,因此我们可以选择我们认为最容易开发和维护的表示。

本地内存和屏障的范围

回想一下第四章中的,分层内核通过使用parallel_for_work_groupparallel_for_work_item函数表达了两个级别的并行执行。并行执行的这两个级别或范围用于表示变量是否在工作组本地存储器中并且在工作组中的所有工作项之间共享,或者变量是否在每个工作项的私有存储器中,该私有存储器不在工作项之间共享。这两个作用域还用于同步一个工作组中的工作项,并加强内存一致性。

图 9-9 显示了一个示例层次内核,它在本地内存的工作组范围内声明一个变量,加载到其中,然后在工作项范围内使用该变量。在工作组范围内写入本地内存和在工作项范围内从本地内存读取之间存在一个隐含的障碍。

img/489625_1_En_9_Fig9_HTML.png

图 9-9

具有本地存储器变量的分层内核

分层内核表示的主要优点是它看起来非常类似于标准的 C++ 代码,其中一些变量可能在一个作用域中赋值,而在一个嵌套的作用域中使用。当然,这也可能被认为是一个缺点,因为它并不直接清楚哪些变量在本地存储器中,以及何时必须由分层内核编译器插入屏障。对于屏障昂贵的设备来说尤其如此!

一个完整的分层内核示例

现在我们知道了如何在分层内核中表达本地内存和屏障,我们可以编写一个分层内核,实现与图 9-7 中 ND-range 内核相同的算法。该内核如图 9-10 所示。

img/489625_1_En_9_Fig10_HTML.png

图 9-10

作为分层内核实现的平铺矩阵乘法内核

虽然分层内核与 ND-range 内核非常相似,但有一个关键的区别:在 ND-range 内核中,矩阵乘法的结果在写入内存中的输出矩阵之前被累积到每个工作项变量sum中,而分层内核则累积到内存中。我们也可以在分层内核中累加到每个工作项的变量中,但是这需要一个特殊的private_memory语法来在工作组范围内声明每个工作项的数据,我们选择使用分层内核语法的原因之一是为了避免特殊语法!

分层内核不需要特殊的语法来声明工作组本地内存中的变量,但是它们需要特殊的语法来声明工作项私有内存中的一些变量!

为了避免特殊的每工作项数据语法,分层内核中工作项循环的常见模式是将中间结果写入工作组本地内存或全局内存。

图 9-10 中内核最后一个有趣的属性与循环迭代变量kk有关:由于循环是在工作组范围内,循环迭代变量kk可以在工作组本地内存之外分配,就像tileA数组一样。不过在这种情况下,由于kk的值对于工作组中的所有工作项都是相同的,所以智能编译器可能会选择从每个工作项的内存中分配kk,特别是对于工作组本地内存是稀缺资源的设备。

子群体

到目前为止,根据内核的编写方式,通过工作组本地内存交换数据,以及通过隐式或显式屏障函数进行同步,工作项已经与工作组中的其他工作项进行了通信。

在第四章中,我们讨论了另一组工作项目。子组是工作组中工作项目的实现定义的子集,它们在相同的硬件资源上一起执行或者具有额外的调度保证。因为实现决定了如何将工作项分组为子组,所以子组中的工作项可能能够比任意工作组中的工作项更有效地进行通信或同步。

本节描述了子组中工作项之间通信的构建块。注意,子组目前仅针对 ND-range 内核实现,并且子组不能通过分层内核来表达。

通过子群障碍的同步

就像 ND-range 内核中的工作组中的工作项目可以如何使用工作组屏障函数来同步一样,子组中的工作项目可以使用子组屏障函数来同步。工作组中的工作项通过调用nd_item类中的group_barrier函数或barrier函数进行同步,子组中的工作项通过调用特殊sub_group类中的group_barrier函数或barrier函数进行同步,该类可从nd_item类中查询,如图 9-11 所示。

img/489625_1_En_9_Fig11_HTML.png

图 9-11

查询和使用sub_group

与工作组屏障一样,子组屏障可以接受可选参数,以更精确地控制屏障操作。不管子组屏障功能是同步全局存储器还是本地存储器,仅同步子组中的工作项可能比同步工作组中的所有工作项更便宜。

在子组内交换数据

与工作组不同,子组没有用于交换数据的专用内存空间。相反,子组中的工作项可以通过工作组本地内存、全局内存或者更常见的通过使用子组集合函数来交换数据。

如前所述,集合函数是描述由一组工作项目而不是单个工作项目执行的操作的函数,并且因为屏障同步函数是由一组工作项目执行的操作,所以它是集合函数的一个例子。

其他集合函数表示常见的通信模式。我们将在本章后面详细描述许多集合函数的语义,但是现在,我们将简要描述我们将使用子组实现矩阵乘法的broadcast集合函数。

broadcast集合函数从组中的一个工作项中获取一个值,并将其传递给组中的所有其他工作项。示例如图 9-12 所示。注意,broadcast 函数的语义要求标识组中哪个值要通信的local_id对于组中的所有工作项必须是相同的,确保 broadcast 函数的结果对于组中的所有工作项也是相同的。

img/489625_1_En_9_Fig12_HTML.png

图 9-12

broadcast功能处理

如果我们查看本地内存矩阵乘法内核的最内层循环,如图 9-13 所示,我们可以看到对矩阵块的访问是一种广播,因为组中的每个工作项从矩阵块中读取相同的值。

img/489625_1_En_9_Fig13_HTML.png

图 9-13

矩阵乘法内核包括一个广播操作

我们将使用子组广播函数来实现一个矩阵乘法内核,它不需要工作组本地内存或屏障。在许多设备上,子组广播比带有工作组本地内存和障碍的广播更快。

一个完整的子群 ND-Range 核示例

图 9-14 是一个使用子群实现矩阵乘法的完整例子。请注意,这个内核不需要工作组本地内存或显式同步,而是使用子组广播集合函数在工作项之间传递矩阵平铺的内容。

img/489625_1_En_9_Fig14_HTML.png

图 9-14

用 ND-range parallel_for和子群集合函数表示的平铺矩阵乘法核

集体职能

在本章的“子组”一节中,我们描述了集体函数以及集体函数如何表达常见的通信模式。我们特别讨论了 broadcast collective 函数,它用于将一个组中的一个工作项的值传递给组中的其他工作项。本节描述附加的集合函数。

虽然本节中描述的集合功能可以使用诸如原子、工作组本地存储器和屏障之类的特性直接在我们的程序中实现,但是许多设备都包括专用硬件来加速集合功能。即使设备不包含专用硬件,供应商提供的集合函数的实现也可能针对运行它们的设备进行了调整,因此调用内置的集合函数通常会比我们编写的通用实现执行得更好。

使用通用通信模式的集合函数来简化代码和提高性能!

工作组和子组都支持许多集合功能。其他集合功能仅支持子组。

广播

broadcast函数允许一个组中的一个工作项与该组中的所有其他工作项共享一个变量的值。图 9-12 中显示了广播功能的工作原理。工作组和子组都支持broadcast功能。

投票

any_ofall_of函数(以下统称为“投票”函数)使工作项能够比较其组中布尔条件的结果:any_of如果组中至少一个工作项的条件为真,则返回真,只有当组中所有工作项的条件为真时,all_of才返回真。图 9-15 显示了这两个功能的比较。

img/489625_1_En_9_Fig15_HTML.png

图 9-15

any_ofall_of功能的比较

工作组和子组都支持any_ofall_of投票功能。

洗牌

子组最有用的特性之一是能够在单个工作项之间直接通信,而不需要显式的内存操作。在许多情况下,例如子组矩阵乘法内核,这些混洗操作使我们能够从内核中移除工作组本地内存使用和/或避免对全局内存的不必要的重复访问。有几种风格的随机播放功能可用。

最通用的混洗功能称为shuffle,如图 9-16 所示,它允许子组中任何一对工作项之间的任意通信。然而,这种通用性可能是以性能为代价的,我们强烈鼓励尽可能使用更专业的随机播放函数。

img/489625_1_En_9_Fig16_HTML.png

图 9-16

基于预先计算的置换索引,使用通用的shufflex值进行排序

在图 9-16 中,使用预先计算的排列索引,使用通用混洗来对子组的x值进行排序。对于子组中的一个工作项目显示了箭头,其中混洗的结果是工作项目的 x 值,其中local_id等于 7。

注意,子组broadcast函数可以被认为是通用shuffle函数的特殊版本,其中混洗索引对于子组中的所有工作项都是相同的。当混洗索引对于子组中的所有工作项都是相同的时,使用broadcast而不是shuffle为编译器提供了额外的信息,并且可以提高某些实现的性能。

shuffle_upshuffle_down功能有效将子组的内容向给定方向移动固定数量的元素,如图 9-17 所示。注意,返回到子组中最后五个工作项的值是未定义的,在图 9-17 中显示为空白。移位对于并行化具有循环相关性的循环或实现通用算法(如互斥或包含扫描)非常有用。

img/489625_1_En_9_Fig17_HTML.png

图 9-17

使用shuffle_down将子组的x值移动五项

shuffle_xor函数交换两个工作项的值,这由应用于工作项的子组本地 id 和固定常量的 XOR 运算的结果指定。如图 9-18 和 9-19 所示,几种常见的通信模式可以用异或来表示:例如,交换相邻值对

img/489625_1_En_9_Fig19_HTML.png

图 9-19

使用shuffle_xor反转x的值

img/489625_1_En_9_Fig18_HTML.png

图 9-18

使用shuffle_xor交换相邻的x

或者反转子组值。

SUB-GROUP OPTIMIZATIONS USING BROADCAST, VOTE, AND COLLECTIVES

应用于子组的 broadcast、vote 和其他集合函数的行为与应用于工作组时是相同的,但它们值得额外关注,因为它们可能会在某些编译器中实现激进的优化。例如,编译器可能能够减少向子组中的所有工作项广播的变量的寄存器使用,或者可能能够基于any_ofall_of函数的使用来推断控制流分歧。

装载和存储

子组加载和存储功能有两个目的:第一,通知编译器子组中的所有工作项正在加载从内存中相同(统一)位置开始的连续数据,第二,使我们能够请求大量连续数据的优化加载/存储。

对于 ND-range parallel_for,编译器可能不清楚不同工作项计算的地址如何相互关联。例如,如图 9-20 所示,从索引[0,32]访问一个连续的内存块,从每个工作项的角度来看,似乎有一个跨步的访问模式。

img/489625_1_En_9_Fig20_HTML.png

图 9-20

访问四个连续块的子组的存储器访问模式

一些体系结构包括专用硬件来检测子组中的工作项目何时访问连续数据并组合它们的存储器请求,而其他体系结构要求提前知道这一点并将其编码在加载/存储指令中。子组加载和存储在任何平台上都不是正确性所必需的,但在某些平台上可能会提高性能,因此应被视为一种优化提示。

摘要

本章讨论了一个组中的工作项如何通信和协作来提高某些类型内核的性能。

我们首先讨论了 ND-range 内核和分层内核是如何支持将工作项分组到工作组中的。我们讨论了将工作项分组到工作组中是如何改变并行执行模型的,从而保证工作组中的工作项并发执行,并支持通信和同步。

接下来,我们讨论了一个工作组中的工作项如何使用屏障进行同步,以及屏障如何针对 ND-range 内核进行显式表达,或者针对分层内核在工作组和工作项范围之间进行隐式表达。我们还讨论了如何通过工作组本地内存执行工作组中工作项之间的通信,以简化内核并提高性能,我们还讨论了如何使用 ND-range 内核的本地访问器或分层内核的工作组范围内的分配来表示工作组本地内存。

我们讨论了 ND-range 内核中的工作组如何进一步划分为工作项目的子组,其中工作项目的子组可以支持额外的通信模式或调度保证。

对于工作组和子组,我们讨论了如何通过使用集体功能来表达和加速常见的交流模式。

本章中的概念是理解第十四章中描述的常见并行模式以及理解如何针对第 15 、 16 和 17 章中的特定器件进行优化的重要基础。

Creative Commons

开放存取本章根据知识共享署名 4.0 国际许可证(http://Creative Commons . org/licenses/by/4.0/)的条款获得许可,该许可证允许以任何媒体或格式使用、共享、改编、分发和复制,只要您适当注明原作者和来源,提供知识共享许可证的链接并指明是否进行了更改。

本章中的图像或其他第三方材料包含在本章的知识共享许可中,除非在材料的信用额度中另有说明。如果材料不包括在本章的知识共享许可中,并且您的预期使用不被法律法规允许或超出了允许的使用范围,您将需要直接从版权所有者处获得许可。

十、定义内核

img/489625_1_En_10_Figa_HTML.gif

到目前为止,在本书中,我们的代码示例已经使用 C++ lambda 表达式表示了内核。Lambda 表达式是一种简洁而方便的方法,可以在使用它的地方表示内核,但它不是 SYCL 中表示内核的唯一方法。在这一章中,我们将详细探索定义内核的各种方法,帮助我们选择最适合我们 C++ 编码需求的内核形式。

本章解释并比较了表示内核的三种方式:

  • λ表达式

  • 命名函数对象(仿函数)

  • 与通过其他语言或 API 创建的内核的互操作性

本章最后讨论了如何在一个程序对象中显式地操作内核来控制内核何时以及如何被编译。

为什么用三种方式来表示一个内核?

在深入细节之前,让我们先总结一下为什么有三种定义内核的方法,以及每种方法的优缺点。图 10-1 给出了一个有用的总结。

请记住,内核是用来表示一个计算单元的,许多内核实例通常会在一个加速器上并行执行。SYCL 支持多种方式来表达内核,以自然、无缝地集成到各种代码库中,同时在各种加速器类型上高效执行。

img/489625_1_En_10_Fig1_HTML.png

图 10-1

表示内核的三种方式

作为 Lambda 表达式的内核

C++ lambda 表达式,也称为匿名函数对象未命名函数对象闭包,或者简称为 lambdas ,是一种在使用内核时表达内核权利的便捷方式。本节描述如何将内核表示为 C++ lambda 表达式。这扩展了第一章中关于 C++ lambda 函数的介绍性复习,其中包括一些带有输出的编码示例。

C++ lambda 表达式非常强大,并且具有表达性语法,但是在表达内核时,只需要(并且支持)完整 C++ lambda 表达式语法的特定子集。

img/489625_1_En_10_Fig2_HTML.png

图 10-2

使用 lambda 表达式定义的内核

内核 Lambda 表达式的元素

图 10-2 显示了一个以典型的 lambda 表达式编写的内核——本书中的代码示例已经使用了这种语法。

图 10-3 中的插图显示了更多可用于内核的 lambda 表达式的元素,但这些元素中有许多并不典型。在大多数情况下,lambda 缺省值就足够了,所以一个典型的内核 lambda 表达式看起来更像图 10-2 中的 lambda 表达式,而不是图 10-3 中更复杂的 lambda 表达式。

img/489625_1_En_10_Fig3_HTML.png

图 10-3

内核 lambda 表达式的更多元素,包括可选元素

  1. lambda 表达式的第一部分描述 lambda 捕获从周围的作用域中捕获一个变量使它能够在 lambda 表达式中使用,而不需要显式地将它作为参数传递给 lambda 表达式。

    C++ lambda 表达式支持通过复制变量或创建对变量的引用来捕获变量,但对于内核 lambda 表达式,变量只能通过复制来捕获。一般的做法是简单地使用默认的捕获模式[=],它通过值隐式地捕获所有变量,尽管也可以显式地命名每个捕获的变量。内核中使用的任何变量如果没有被值捕获,都会导致编译时错误。

  2. lambda 表达式的第二部分描述传递给 lambda 表达式的参数,就像传递给命名函数的参数一样。

    对于内核 lambda 表达式,参数取决于内核是如何被调用的,并且通常标识并行执行空间中工作项的索引。有关各种并行执行空间以及如何标识每个执行空间中工作项的索引的更多详细信息,请参考第四章。

  3. lambda 表达式的最后一部分定义了 lambda 函数体。对于内核 lambda 表达式,函数体描述了应该在并行执行空间中的每个索引处执行的操作。

    内核支持 lambda 表达式的其他部分,但这些部分要么是可选的,要么很少使用:

  4. 一些说明符(如mutable)可能会受到支持,但不建议使用它们,并且在 SYCL(在临时 SYCL 2020 或 DPC++ 的未来版本中可能会删除支持。示例代码中没有显示任何内容。

  5. 支持异常规范,但是如果提供的话必须是noexcept,因为内核不支持异常。

  6. λ属性被支持,并且可以用来控制内核如何被编译。例如,reqd_work_group_size属性可用于要求内核的特定工作组大小。

  7. 可以指定返回类型,但是如果提供的话必须是void,因为内核不支持非void返回类型。

LAMBDA CAPTURES: IMPLICIT OR EXPLICIT?

一些 C++ 风格指南建议不要对 lambda 表达式进行隐式(或默认)捕获,因为可能会出现悬空指针问题,尤其是当 lambda 表达式跨越范围边界时。当使用 lambda 表示内核时,可能会出现相同的问题,因为内核 lambda 在设备上异步执行,与主机代码分离。

因为隐式捕获有用且简洁,所以它是 SYCL 内核的常见实践,也是我们在本书中使用的约定,但最终是我们决定是喜欢隐式捕获的简洁还是显式捕获的清晰。

命名内核 Lambda 表达式

当内核被写成 lambda 表达式时,在某些情况下还必须提供一个元素:因为 lambda 表达式是匿名的,有时 SYCL 需要一个显式的内核名称模板参数来唯一地标识被写成 lambda 表达式的内核。

img/489625_1_En_10_Fig4_HTML.png

图 10-4

命名内核 lambda 表达式

当内核由单独的设备代码编译器编译时,命名内核 lambda 表达式是主机代码编译器识别调用哪个内核的一种方式。命名一个内核 lambda 还支持编译后内核的运行时自省,或者通过名字构建一个内核,如图 10-9 所示。

为了在不需要内核名称模板参数时支持更简洁的代码,DPC++ 编译器支持通过-fsycl-unnamed-lambda编译器选项省略内核 lambda 的内核名称模板参数。使用该选项时,不需要显式的内核名称模板参数,如图 10-5 所示。

img/489625_1_En_10_Fig5_HTML.png

图 10-5

使用未命名的内核 lambda 表达式

因为 lambda 表达式的内核名称模板参数在大多数情况下是不需要的,所以我们通常可以从一个未命名的 lambda 开始,只有在需要内核名称模板参数的特定情况下才添加内核名称。

当不需要内核名称模板参数时,最好使用未命名的内核 lambdas 来减少冗余。

作为命名函数对象的内核

命名函数对象,也称为函子,是 C++ 中的一种既定模式,它允许对任意数据集合进行操作,同时保持定义良好的接口。当用于表示内核时,命名函数对象的成员变量定义内核可以操作的状态,并且为并行执行空间中的每个工作项目调用重载函数调用operator()

命名函数对象需要比 lambda 表达式更多的代码来表达内核,但是额外的冗长提供了更多的控制和额外的能力。例如,分析和优化表示为命名函数对象的内核可能更容易,因为内核使用的任何缓冲区和数据值都必须显式传递给内核,而不是自动捕获。

最后,因为命名函数对象就像任何其他 C++ 类一样,表达为命名函数对象的内核可以是模板化的,这与表达为 lambda 表达式的内核不同。表示为命名函数对象的内核也更容易重用,并且可以作为单独头文件或库的一部分提供。

内核命名函数对象的元素

图 10-6 中的代码描述了一个被命名为函数对象的内核元素。

img/489625_1_En_10_Fig6_HTML.png

图 10-6

作为命名函数对象的内核

当一个内核被表示为一个命名的函数对象时,命名的函数对象类型必须遵循 C++11 规则,以便能够简单地复制。非正式地,这意味着命名的函数对象可以被安全地逐字节复制,使得命名的函数对象的成员变量能够被传递给在设备上执行的内核代码并由其访问。

重载函数调用operator()的参数取决于内核如何启动,就像用 lambda 表达式表示的内核一样。

因为函数对象是命名的,所以宿主代码编译器可以使用函数对象类型与设备代码编译器生成的内核代码关联,即使函数对象是模板化的。因此,不需要额外的内核名称模板参数来命名内核函数对象。

与其他 API 的互操作性

当 SYCL 实现建立在另一个 API 之上时,该实现可能能够与使用底层 API 机制定义的内核进行互操作。这使得应用程序可以轻松地、渐进地将 SYCL 集成到现有的代码库中。

因为 SYCL 实现可能位于许多其他 API 之上,所以本节描述的功能是可选的,并且可能不是所有实现都支持。根据具体的设备类型或设备供应商,底层 API 甚至可能有所不同!

概括地说,一个实现可能支持两种互操作性机制:来自 API 定义的源或中间表示(IR)或来自 API 特定的句柄。在这两种机制中,从 API 定义的源或中间表示创建内核的能力更容易移植,因为一些源或 IR 格式受多个 API 支持。例如,OpenCL C 内核可以被许多 API 直接使用,或者可以被编译成 API 可以理解的某种形式,但是来自一个 API 的特定于 API 的内核句柄不太可能被不同的 API 理解。

请记住,所有形式的互操作性都是可选的!

不同的 SYCL 实现可能支持从不同的 API 特定句柄创建内核——或者根本不支持。

请务必查看文档以了解详细信息!

与 API 定义的源语言的互操作性

通过这种形式的互操作性,内核的内容被描述为源代码,或者使用 SYCL 没有定义的中间表示,但是内核对象仍然是使用 SYCL API 调用创建的。这种形式的互操作性允许重用用其他源语言编写的内核库,或者使用特定领域语言(DSL)以中间表示形式生成代码。

实现必须理解内核源代码或中间表示,才能利用这种形式的互操作性。例如,如果内核是使用 OpenCL C 以源代码形式编写的,那么实现必须支持从 OpenCL C 内核源代码构建 SYCL 程序。

图 10-7 显示了如何将 SYCL 内核写成 OpenCL C 内核源代码。

img/489625_1_En_10_Fig7_HTML.png

图 10-7

从 OpenCL C 内核源代码创建的内核

在这个例子中,内核源字符串在 SYCL 主机 API 调用的同一个文件中被表示为 C++ 原始字符串文字,但并不要求必须如此,一些应用程序可能会从文件中读取内核源字符串,甚至实时生成它。

因为 SYCL 编译器无法看到用 API 定义的源语言编写的 SYCL 内核,所以任何内核参数都必须使用set_arg()set_args()接口显式传递。SYCL 运行时和 API 定义的源语言必须就将对象作为内核参数传递的约定达成一致。在这个例子中,访问器dataAcc作为全局指针内核参数data被传递。

build_with_source()接口支持传递可选的 API 定义的构建选项,以精确控制内核的编译方式。在本例中,程序构建选项-cl-fast-relaxed-math用于指示内核编译器可以使用精度宽松的更快的数学库。程序构建选项是可选的,如果不需要构建选项,可以省略。

与 API 定义的内核对象的互操作性

有了这种形式的互操作性,内核对象本身在另一个 API 中创建,然后导入 SYCL。这种形式的互操作性使应用程序的一部分能够使用底层 API 直接创建和使用内核对象,而应用程序的另一部分能够使用 SYCL APIs 重用相同的内核。图 10-8 中的代码显示了如何从 OpenCL 内核对象创建 SYCL 内核。

img/489625_1_En_10_Fig8_HTML.png

图 10-8

从 OpenCL 内核对象创建的内核

与其他形式的互操作性一样,SYCL 编译器无法看到 API 定义的内核对象。因此,必须使用set_arg()set_args()接口显式传递内核参数,并且 SYCL 运行时和底层 API 必须就传递内核参数的约定达成一致。

程序对象中的内核

在前面的章节中,当内核从 API 定义的表示或者从 API 特定的句柄创建时,内核分两步创建:首先通过创建一个程序对象,然后通过从程序对象创建内核。程序对象是作为一个单元编译的内核和它们调用的函数的集合。

对于表示为 lambda 表达式或命名函数对象的内核,包含内核的程序对象通常是隐式的,对应用程序不可见。对于需要更多控制的应用程序,应用程序可以显式地管理内核和封装它们的程序对象。为了描述为什么这可能是有益的,简单看一下有多少 SYCL 实现管理实时(JIT)内核编译是有帮助的。

虽然规范没有要求,但许多实现都“懒惰地”编译内核这通常是一个好策略,因为它确保了应用程序的快速启动,并且不会不必要地编译从不执行的内核。这种策略的缺点是内核的第一次使用通常比随后的使用需要更长的时间,因为它包括编译内核所需的时间,加上提交和执行内核所需的时间。对于一些复杂的内核,编译内核所需的时间可能会很长,因此需要在应用程序执行期间将编译转移到不同的点,例如当应用程序正在加载时,或者在单独的后台线程中。

一些内核也可能受益于实现定义的“构建选项”,以精确控制内核的编译方式。例如,对于某些实现,可以指示内核编译器使用精度更低、性能更好的数学库。

为了更好地控制内核编译的时间和方式,应用程序可以使用特定的编译选项,明确请求在使用内核之前编译内核。然后,像往常一样,可以将预编译的内核提交到队列中执行。图 10-9 显示了这是如何工作的。

img/489625_1_En_10_Fig9_HTML.png

图 10-9

使用构建选项编译内核 lambdas

在这个例子中,一个程序对象是从 SYCL 上下文中创建的,由指定的模板参数定义的内核是使用build_with_kernel_type函数构建的。对于这个例子,程序构建选项-cl-fast-relaxed-math表明内核编译器可以使用具有宽松精度的更快的数学库,但是程序构建选项是可选的,如果不需要特殊的程序构建选项,可以省略。在这种情况下,命名内核 lambda 的模板参数是必需的,以标识要编译的内核。

程序对象也可以从上下文和设备的特定列表中创建,而不是从上下文中的所有设备中创建,从而允许一组设备的程序对象用与另一组设备的另一程序对象不同的构建选项来编译。

除了通常的内核 lambda 表达式之外,还使用get_kernel函数将之前编译的内核传递给parallel_for。这确保了使用宽松数学库构建的先前编译的内核得到使用。如果先前编译的内核没有被传递给parallel_for,那么内核将被再次编译,没有任何特殊的编译选项。这可能在功能上是正确的,但肯定不是预期的行为!

在许多情况下,例如在前面显示的简单示例中,这些额外的步骤不太可能对应用程序的行为产生明显的改变,为了清楚起见,可以省略这些步骤,但是在针对性能调整应用程序时,应该考虑这些步骤。

IMPROVING INTEROPERABILITY AND PROGRAM OBJECT MANAGEMENT

尽管本章中描述的 SYCL 互操作性和程序对象管理接口非常有用,但它们可能会在 SYCL 和 DPC++ 的未来版本中得到改进和增强。请参考最新的 SYCL 和 DPC++ 文档,查找本书中没有提供或不够稳定的更新!

摘要

在这一章中,我们探索了定义内核的不同方法。我们描述了如何通过将内核表示为 C++ lambda 表达式或命名函数对象来无缝集成到现有的 C++ 代码库中。对于新的代码库,我们还讨论了不同内核表示的优缺点,以帮助根据应用程序或库的需求选择定义内核的最佳方式。

我们还描述了如何与其他 API 进行互操作,或者通过从 API 定义的源语言或中间表示创建内核,或者通过从内核的 API 表示的句柄创建内核对象。互操作性使应用程序能够随着时间的推移从较低级别的 API 迁移到 SYCL,或者与为其他 API 编写的库接口。

最后,我们描述了如何在 SYCL 应用程序中编译内核,以及如何直接操作程序对象中的内核来控制编译过程。尽管大多数应用程序不需要这种级别的控制,但在调优应用程序时,这是一项需要注意的有用技术。

Creative Commons

开放存取本章根据知识共享署名 4.0 国际许可证(http://Creative Commons . org/licenses/by/4.0/)的条款获得许可,该许可证允许以任何媒体或格式使用、共享、改编、分发和复制,只要您适当注明原作者和来源,提供知识共享许可证的链接并指明是否进行了更改。

本章中的图像或其他第三方材料包含在该章的知识共享许可中,除非该材料的信用额度中另有说明。如果材料未包含在本章的知识共享许可中,并且您的预期用途不被法定法规允许或超出了允许的用途,您将需要直接从版权所有者处获得许可。

十一、向量

img/489625_1_En_11_Figa_HTML.gif

向量是数据的集合。这可能很有用,因为我们计算机中的并行性来自计算硬件的集合,并且数据通常在相关分组中处理(例如,RGB 像素中的颜色通道)。听起来像是天作之合吗?这非常重要,我们将用一章来讨论向量类型的优点以及如何利用它们。在这一章中,我们不会深入探讨矢量化,因为矢量化会因设备类型和实现而异。矢量化将在第 15 和 16 章节中介绍。

本章试图解决以下问题:

  • 什么是向量类型?

  • 我真的需要了解多少关于 vector 接口的知识?

  • 是否应该用向量类型来表示并行性?

  • 什么时候应该使用向量类型?

我们使用工作代码示例讨论可用向量类型的优点和缺点,并强调利用向量类型的最重要的方面。

如何思考向量

当我们与并行编程专家交谈时,向量是一个令人惊讶的有争议的话题,根据作者的经验,这是因为不同的人以不同的方式定义和思考这个术语。

有两种广义的方式来考虑向量数据类型(数据集合):

  1. 作为一种方便的类型,它将您可能想要引用和操作的数据分组为一组,例如,将一个像素的颜色通道(如 RGB、YUV)分组为一个变量(如float3),该变量可以是一个向量。我们可以定义一个 pixel 类或结构,并在其上定义像+这样的数学运算符,但是向量类型可以方便地为我们开箱即用。便利类型可以在许多用于编程 GPU 的着色器语言中找到,因此这种思维方式在许多 GPU 开发人员中已经很常见。

  2. 作为描述代码如何映射到硬件中的 SIMD 指令集的机制。例如,在一些语言和实现中,float8上的操作理论上可以映射到硬件中的八通道 SIMD 指令。向量类型在多种语言中被用作针对特定指令集的 CPU 特定 SIMD 内部函数的一种方便的高级替代方法,因此这种思维方式在许多 CPU 开发人员中已经很普遍了。

虽然这两种解释非常不同,但当 SYCL 和其他语言变得适用于 CPU 和 GPU 时,它们无意中被结合在一起并混淆在一起。SYCL 1.2.1 规范中的 vector 与这两种解释都是兼容的(我们将在后面重新讨论这一点),所以在进一步讨论之前,我们需要澄清一下我们在 DPC++ 中推荐的思路。

在本书中,我们讨论了如何将工作项组合在一起,以公开强大的通信和同步原语,例如子组障碍和洗牌。为了使这些操作在向量硬件上有效,假设子组中的不同工作项组合并映射到 SIMD 指令。换句话说,多个工作项被编译器组合在一起,此时它们可以映射到硬件中的 SIMD 指令。请记住第四章中的内容,这是在矢量硬件上运行的 SPMD 编程模型的基本前提,其中单个工作项构成了硬件中可能是 SIMD 指令的通道,而不是定义硬件中 SIMD 指令的整个操作的工作项。当在硬件中映射到 SIMD 指令时,当使用 DPC++ 编译器以 SPMD 风格编程时,您可以认为编译器总是跨工作项进行矢量化。

对于本书中描述的功能和硬件,向量主要用于本节的第一种解释——向量是方便的类型,不应被视为映射到硬件中的 SIMD 指令。在适用的平台(CPU、GPU)上,工作项被组合在一起形成硬件中的 SIMD 指令。向量应该被认为是提供了方便的操作符,如 swizzles 和数学函数,使我们的代码中对数据组的常见操作变得简洁(例如,添加两个 RGB 像素)。

对于来自没有向量的语言或来自 GPU 着色语言的开发人员,我们可以将 SYCL 向量视为工作项的本地向量,因为如果添加两个四元素向量,该添加可能需要硬件中的四条指令(从工作项的角度来看,它将被标量化)。向量的每个元素将通过硬件中不同的指令/时钟周期相加。根据这种解释,向量是一种便利,因为我们可以在源代码中的一次操作中添加两个向量,而不是在源代码中执行四次标量操作。

对于来自 CPU 背景的开发人员,我们应该知道,在编译器中默认情况下,隐式向量化到 SIMD 硬件以几种独立于向量类型的方式发生。编译器跨工作项执行这种隐式矢量化,从格式良好的循环中提取矢量操作,或者在映射到矢量指令时支持矢量类型——有关更多信息,请参见第十六章。

OTHER IMPLEMENTATIONS POSSIBLE!

SYCL 和 DPC++ 的不同编译器和实现在理论上可以对代码中的向量数据类型如何映射到向量硬件指令做出不同的决定。我们应该阅读供应商的文档和优化指南,以了解如何编写能够映射到高效 SIMD 指令的代码。这本书主要是针对 DPC++ 编译器编写的,因此记录了围绕它构建的思维和编程模式。

CHANGES ARE ON THE HORIZON

我们刚刚说过,在考虑映射到设备上的硬件时,将向量类型视为便利类型,并期望跨工作项的矢量化。这有望成为 DPC++ 编译器和工具链未来的默认解释。然而,有两个额外的前瞻性变化需要注意。

首先,我们可以期待一些未来的 DPC++ 特性,允许我们编写直接映射到硬件中 SIMD 指令的显式矢量代码,特别是对于那些希望针对特定架构调整代码细节并从编译器矢量器中获得控制权的专家。这是一个很少开发人员会使用的利基特性,但是我们可以预期编程机制最终会在可能的地方存在。这些编程机制将非常清楚哪些代码是以显式矢量风格编写的,因此我们今天编写的代码和新的更显式(且可移植性更差)的风格之间不会混淆。

第二,这本书的这一部分(讨论向量的解释)强调了对向量的含义存在混淆,这将在未来的 SYCL 中得到解决。在 SYCL 2020 临时规范中对此有所暗示,其中描述了一种数学数组类型(marray),这显然是本节的第一种解释——一种与矢量硬件指令无关的方便类型。我们应该期待另一种类型也最终出现来覆盖第二种解释,很可能与 C++ std::simd模板一致。由于这两种类型与 vector 数据类型的特定解释明确相关,我们作为程序员的意图将从我们编写的代码中变得清晰。这将更不容易出错,更不容易混淆,甚至可能减少专家开发者之间的激烈讨论,当问题出现时“什么是向量?”

向量类型

SYCL 中的 Vector 类型是跨平台的类模板,可以在设备和主机 C++ 代码中高效工作,并允许在主机及其设备之间共享 vector。Vector 类型包括允许从一组重组的组件元素构建新 vector 的方法,这意味着新 vector 的元素可以以任意顺序从旧 vector 的元素中选取。vec是一种 vector 类型,可以在目标设备后端编译成内置的 vector 类型,并在主机上提供兼容的支持。

vec类根据其元素数量和元素类型进行模板化。元素数参数numElements可以是 1、2、3、4、8 或 16 中的一个。任何其他值都将导致编译失败。元素类型参数dataT必须是设备代码支持的基本标量类型之一。

SYCL vec类模板提供了与由vector_t定义的底层向量类型的互操作性,后者仅在为设备编译时可用。vec类可以从vector_t的实例构建,并且可以隐式转换为vector_t的实例,以便支持与来自内核函数的本地 SYCL 后端(例如 OpenCL 后端)的互操作性。当元素数量为 1 时,为了使单元素向量和标量易于互换,vec类模板的实例也可以隐式转换为数据类型的实例。

为了编程方便,SYCL 提供了许多形式为using <type><elems> = vec<<storage-type><elems>>的类型别名,其中<elems>234816 ,整数类型的<type><storage-type>的配对是char【⇔int8_tuchar uint8_tuint8_t uint uint32_tlong int64_tand ulong uint64_t对于浮点型halffloatdouble。 例如,uint4vec < uint32_t4 >float16vec < float16 > 的别名。

矢量接口

向量类型的功能通过类vec公开。vec类表示一组组合在一起的数据元素。vec类模板的构造器、成员函数和非成员函数的接口如图 11-1 、 11-4 和 11-5 所示。

图 11-2 中列出的 XYZW 成员仅在numElements <= 4时可用。RGBA 会员仅在numElements == 4时可用。

图 11-3 中的lohioddeven成员仅在numElements > 1时可用。

img/489625_1_En_11_Fig5_HTML.png

图 11-5

vec非成员函数

img/489625_1_En_11_Fig4_HTML.png

图 11-4

vec成员函数

img/489625_1_En_11_Fig3_HTML.png

图 11-3

vec运算符界面

img/489625_1_En_11_Fig2_HTML.png

图 11-2

swizzled_vec成员函数

img/489625_1_En_11_Fig1_HTML.png

图 11-1

vec类声明和成员函数

加载和存储成员函数

向量加载和存储操作是vec类的成员,用于加载和存储向量的元素。这些操作可以对与向量的通道类型相同的元素数组进行。示例如图 11-6 所示。

img/489625_1_En_11_Fig6_HTML.png

图 11-6

使用加载和存储成员函数。

vec类中,dataTnumElements是反映vec的组件类型和维度的模板参数。

load()成员函数模板将从multi_ptr地址的内存中读取dataT类型的值,在dataT的元素中偏移numElements*offset,并将这些值写入 vec 的通道。

store()成员函数模板将读取 vec 的通道,并将这些值写入 multi_ptr 地址的内存,在dataT的元素中偏移numElements*offset

该参数是一个multi_ptr而不是一个访问器,这样本地创建的指针和从主机传递的指针都可以使用。

multi_ptr的数据类型是dataT,``vec类专门化的组件的数据类型。这要求传递给load()store()的指针必须匹配vec实例本身的类型。

调酒业务

图形应用中,重组意味着重新排列向量的数据元素。例如,如果a = {1, 2, 3, 4,},并且知道一个四元素向量的分量可以称为{x, y, z, w},我们可以写b = a.wxyz().,变量b中的结果将是{4, 1, 2, 3}。这种形式的代码在 GPU 应用中很常见,在这些应用中有高效的硬件来执行这种操作。调酒有两种方式:

  • 通过调用一个vec的 swizzle 成员函数,该函数接受在0numElements-1之间的可变数量的整数模板参数,指定 swizzle 索引

  • 通过调用一个简单的 swizzle 成员函数,比如XYZW_SWIZZLERGBA_SWIZZLE

请注意,简单的 swizzle 函数仅适用于最多四个元素的向量,并且仅在包含sycl.hpp之前定义宏SYCL_SIMPLE_SWIZZLES时可用。在这两种情况下,返回类型总是一个__swizzled_vec__的实例,一个实现定义的临时类,表示原始vec实例的重组。swizzle 成员函数模板和简单的 swizzle 成员函数都允许重复使用 swizzle 索引。图 11-7 显示了__swizzled_vec__的简单用法。

img/489625_1_En_11_Fig7_HTML.png

图 11-7

使用__swizzled_vec__类的例子

并行内核中的向量执行

如章节 4 和 9 所述,一个工作项是并行层次结构的叶节点,代表一个内核函数的单个实例。工作项目可以以任何顺序执行,并且不能彼此通信或同步,除非通过对本地和全局存储器的原子存储器操作或通过组集合函数(例如,shufflebarrier)。

正如本章开始时所描述的,DPC++ 中的 vector 应该被解释为方便我们编写代码。每个向量对于单个工作项来说是局部的(而不是与硬件中的矢量化相关),因此可以被认为相当于我们工作项中的私有数组numElements。例如,“float4 y4”申报的存储相当于float y4[4]。考虑图 11-8 所示的例子。

img/489625_1_En_11_Fig8_HTML.png

图 11-8

向量执行示例

对于标量变量 x,在具有 SIMD 指令的硬件(例如,CPU、GPU)上具有多个工作项目的内核执行的结果可能使用向量寄存器和 SIMD 指令,但是矢量化是跨工作项目的,并且与我们代码中的任何向量类型无关。每个工作项可以在隐式vec_x中的不同位置上操作,如图 11-9 所示。工作项中的标量数据可以被认为是跨同时执行的工作项隐式矢量化(组合到 SIMD 硬件指令中),在一些实现中和在一些硬件上,但是我们编写的工作项代码不以任何方式对此进行编码——这是 SPMD 编程风格的核心。

img/489625_1_En_11_Fig9_HTML.png

图 11-9

从标量变量xvec_x[8]的向量扩展

如图 11-9 所示,通过编译器从标量变量xvec_x[8]的隐式向量扩展,编译器从出现在多个工作项中的标量操作在硬件中创建 SIMD 操作。

对于向量变量y4,多个工作项的内核执行结果,例如八个工作项,不通过使用硬件中的向量运算来处理 vec4。相反,每个工作项独立地看到自己的向量,向量上元素的操作跨多个时钟周期/指令发生(向量被编译器标量化),如图 11-10 所示。

img/489625_1_En_11_Fig10_HTML.png

图 11-10

垂直扩展到相当于八个工作项的y4vec_y[8][4]

每个工作项都可以看到 y4 的原始数据布局,这为推理和调整提供了一个直观的模型。性能下降是编译器必须为 CPU 和 GPU 生成聚集/分散内存指令,如图 11-11 所示(向量在内存中是连续的,相邻的工作项并行操作不同的向量),因此当编译器跨工作项(例如,跨子组)进行矢量化时,标量通常是一种比显式向量更有效的方法。详见第十五章和第十六章。

img/489625_1_En_11_Fig11_HTML.png

图 11-11

带有地址转义的矢量代码示例

当编译器能够证明y4的地址没有从当前内核工作项中转义或者所有被调用函数都将被内联时,编译器可以执行优化,就像使用一组向量寄存器从y4vec_y[4][8]进行水平单位步长扩展一样,如图 11-12 所示。在这种情况下,编译器无需为 CPU 和 GPU 生成聚集/分散 SIMD 指令,就能获得最佳性能。编译器优化报告为程序员提供了关于这种类型的转换的信息,无论它是否发生,并且可以提供关于如何调整我们的代码以提高性能的提示。

img/489625_1_En_11_Fig12_HTML.png

图 11-12

水平单位步幅扩展到y4vec_y[4][8]

向量并行性

尽管 DPC++ 源代码中的向量应该被解释为只局限于单个工作项的便利工具,但是如果没有提到硬件中的 SIMD 指令是如何操作的,这一章关于向量的内容是不完整的。这一讨论与我们源代码中的向量无关,但提供了正交背景,这将有助于我们进入本书后面描述特定设备类型(GPU、CPU、FPGA)的章节。

现代的 CPU 和 GPU 包含 SIMD 指令硬件,其对包含在一个向量寄存器或寄存器文件中的多个数据值进行操作。例如,借助英特尔 x86 AVX-512 和其他现代 CPU SIMD 硬件,SIMD 指令可用于开发数据并行性。在提供 SIMD 硬件的 CPU 和 GPU 上,我们可以考虑一个向量加法运算,比如对一个八元素向量,如图 11-13 所示。

img/489625_1_En_11_Fig13_HTML.png

图 11-13

八路数据并行的 SIMD 加法

这个例子中的向量加法可以在向量硬件上的单个指令中执行,将向量寄存器vec_xvec_y与 SIMD 指令并行相加。

以独立于硬件的方式展示潜在的并行性,确保我们的应用可以扩展(或缩小)以适应不同平台的功能,包括那些具有矢量硬件指令的平台。在应用程序开发过程中,在工作项目和其他形式的并行性之间取得正确的平衡是我们都必须面对的挑战,这将在第 15 、 16 和 17 章中详细介绍。

摘要

在编程语言中,术语向量有多种解释,当我们想要编写高性能和可伸缩的代码时,理解特定语言或编译器的解释是非常重要的。DPC++ 和 DPC++ 编译器是围绕这样的思想构建的,即源代码中的向量是工作项本地的便利函数,编译器跨工作项的隐式向量化可以映射到硬件中的 SIMD 指令。当我们想要编写直接映射到 vector 硬件的代码时,我们应该查看供应商文档以及 SYCL 和 DPC++ 的未来扩展。使用多个工作项(例如 ND-range)编写我们的内核并依靠编译器跨工作项进行矢量化应该是大多数应用程序的编写方式,因为这样做利用了 SPMD 的强大抽象,它提供了一个易于理解的编程模型,并提供了跨设备和架构的可扩展性能。

本章描述了vec接口,当我们想要对相似类型的数据进行分组操作时,它提供了开箱即用的便利(例如,一个像素有多个颜色通道)。它还简要介绍了硬件中的 SIMD 指令,为我们在第 15 和 16 章节中更详细的讨论做准备。

Creative Commons

开放存取本章根据知识共享署名 4.0 国际许可证(http://Creative Commons . org/licenses/by/4.0/)的条款获得许可,该许可证允许以任何媒体或格式使用、共享、改编、分发和复制,只要您适当注明原作者和来源,提供知识共享许可证的链接并指明是否进行了更改。

本章中的图像或其他第三方材料包含在该章的知识共享许可中,除非该材料的信用额度中另有说明。如果材料未包含在本章的知识共享许可中,并且您的预期用途不被法定法规允许或超出了允许的用途,您将需要直接从版权所有者处获得许可。

十二、设备信息

img/489625_1_En_12_Figa_HTML.gif

第二章向我们介绍了将工作导向特定设备的机制——控制代码执行的在本章中,我们将探讨如何适应运行时出现的设备。

我们希望我们的程序是可移植的。为了便于携带,我们需要我们的程序适应设备的功能。我们可以将程序参数化,只使用现有的功能,并根据设备的具体情况调整代码。如果我们的程序不是为适应而设计的,那么就会发生不好的事情,包括执行缓慢或程序失败。

幸运的是,SYCL 规范的创建者考虑到了这一点,并给了我们接口让我们解决这个问题。SYCL 规范定义了一个device类,它封装了一个可以执行内核的设备。查询设备类的能力,使我们的程序能够适应设备的特性和能力,是本章所教授的核心。

我们中的许多人会从有逻辑来弄清楚“是否有 GPU 存在?”通知我们的程序在执行时将做出的选择。这是本章内容的开始。正如我们将看到的,有更多的信息可以帮助我们使我们的程序健壮和高性能。

将程序参数化有助于正确性、可移植性、性能可移植性和未来的检验。

本章深入探讨了最重要的查询以及如何在我们的程序中有效地使用它们。

特定于设备的属性可以使用get_info, but来查询。DPC++ 与 SYCL 1.2.1 的不同之处在于,它完全重载了get_info,以减少使用get_work_group_info来获取工作组信息的需要,而工作组信息实际上是特定于设备的信息。DPC++ 不支持使用get_work_group_info。这一变化意味着特定于设备的内核和工作组属性可以作为特定于设备的属性的查询被正确地找到(get_info)。这纠正了继承自 OpenCL 的 SYCL 1.2.1 中仍然存在的令人困惑的历史异常。

精炼内核代码,使其更具规范性

考虑到我们的编码,一个内核一个内核地,大致可以分为三类:

  • 通用内核代码:在任何地方运行,不针对特定的设备类别。

  • 特定于设备类型的内核代码:在一种类型的设备(例如,GPU、CPU、FPGA)上运行,不针对设备类型的特定型号进行调整。这非常有用,因为许多设备类型具有共同的特性,所以可以放心地做出一些假设,这些假设不适用于为所有设备编写的完全通用的代码。

  • 特定于设备的调优内核代码:在一种设备上运行,针对设备的特定参数进行调优——这涵盖了从少量调优到非常详细的优化工作的广泛可能性。

    作为程序员,我们的工作是确定不同的设备类型何时需要不同的模式。我们用第十四章、 15 章、 16 章和 17 章来阐明这一重要思想。

最常见的是从实现通用内核代码开始,让它工作起来。第二章专门讨论了在开始内核实现时什么方法最容易调试。一旦我们有了一个工作的内核,我们就可以对它进行改进,以针对特定设备类型或设备型号的功能。

第十四章提供了一个思考框架,在我们深入考虑设备之前,先考虑并行性。我们选择的模式(也就是算法)决定了我们的代码,作为程序员,我们的工作就是决定不同的设备何时需要不同的模式。第 15 (GPU)、 16 (CPU)和 17 (FPGA)章节更深入地探究了区分这些设备类型的品质,并激发了对使用模式的选择。当不同设备类型上的方法(模式选择)不同时,正是这些品质促使我们考虑为不同设备编写不同版本的内核。

当我们为特定类型的设备(例如,特定的 CPU、GPU、FPGA 等)编写内核时。),使其适应特定厂商甚至此类设备的型号是合乎逻辑的。良好的编码风格是基于特性(例如,从设备查询中找到的项目大小支持)对代码进行参数化。

我们应该编写代码来查询描述设备实际功能的参数,而不是其营销信息;查询设备的型号并对其做出反应是非常糟糕的编程实践——这样的代码可移植性较差。

通常为我们想要支持的每种设备类型编写不同的内核(内核的 GPU 版本和内核的 FPGA 版本,可能还有内核的通用版本)。当我们变得更具体时,为了支持特定的设备供应商或甚至设备模型,当我们可以参数化内核而不是复制它时,我们可能会受益。如果我们认为合适,我们可以自由选择。有太多参数调整的代码可能难以阅读,或者在运行时负担过重。然而,参数可以整齐地放入内核的一个版本是很常见的。

当算法大体相同,但针对特定设备的功能进行了调整时,参数化最有意义。当使用完全不同的方法、模式或算法时,编写不同的内核要干净得多。

如何枚举设备和功能

第二章列举并解释了选择执行设备的五种方法。本质上,方法#1 是最不规范的在某个地方运行它,我们进化到最规范的方法#5,它考虑在一系列设备中的一个相当精确的设备模型上执行。介于两者之间的列举方法混合了灵活性和规定性。图 12-1 、 12-2 和 12-3 有助于说明我们如何选择器件。

图 12-1 显示,即使我们允许实现为我们选择一个默认设备(第二章中的方法#1),我们仍然可以查询关于所选设备的信息。

图 12-2 展示了我们如何尝试使用一个特定的设备(在这个例子中,是一个 GPU)来建立一个队列,但是如果没有 GPU 可用的话,就明确地回到主机上。这给了我们一些设备选择的控制权。如果我们简单地使用默认队列,我们可能会以意外的设备类型(例如,DSP、FPGA)结束。如果我们明确地想要在没有 GPU 设备的情况下使用主机设备,这段代码会为我们做到这一点。回想一下,主机设备总是保证存在的,所以我们不需要担心使用host_selector

不建议我们使用图 12-2 所示的解决方案。除了看起来有点吓人和容易出错之外,图 12-2 并没有让我们控制选择哪一个 GPU,因为如果有多个可用的 GPU,我们得到哪一个取决于实现。尽管既有启发性又有实用性,但还有更好的方法。建议我们编写自定义的设备选择器,如下一个代码示例所示(图 12-3 )。

自定义设备选择器

图 12-3 使用自定义设备选择器。自定义设备选择器在第二章中首次讨论,作为选择代码运行位置的方法#5(图 2-15 )。定制设备选择器使其operator(),如图 12-3 所示,为应用可用的每个设备调用。所选设备是得分最高的设备。 1 在这个例子中,我们将为我们的选择器开一点玩笑:

  • 拒绝供应商名称包含“Martian”(return–1)字样的 GPU。

  • 偏爱供应商名称包含单词“ACME”的 GPU(返回 824)。

  • 其他任何 GPU 都是好的(return 799)。

  • 如果没有 GPU,我们选择主机设备(返回 99)。

  • 忽略所有其他设备(return–1)。

下一部分,“好奇:get_info<>”深入到get_devices(), get_platforms()get_info<>提供的丰富信息。这些接口打开了我们可能想要用来挑选设备的任何类型的逻辑,包括图 2-15 和 12-3 所示的简单的供应商名称检查。

img/489625_1_En_12_Fig1_HTML.png

图 12-1

默认情况下分配给我们的设备

关于设备的查询依赖于安装的软件(特殊的用户级驱动程序)来响应关于设备的查询。SYCL 和 DPC++ 依赖于此,就像操作系统需要驱动程序来访问硬件一样——仅仅将硬件安装在机器上是不够的。

img/489625_1_En_12_Fig3_HTML.png

图 12-3

定制设备选择器—我们的首选解决方案

img/489625_1_En_12_Fig2_HTML.png

图 12-2

如果可能,使用 try-catch 选择 GPU 设备,否则选择主机设备

好奇:get_info<>

为了让我们的程序“知道”哪些设备在运行时可用,我们可以让我们的程序从 device 类中查询可用的设备,然后我们可以使用get_info<>查询特定的设备来了解更多的细节。我们提供了一个简单的程序,名为好奇(见图 12-4 ),它使用这些接口将信息转储出来让我们直接查看。这对于在开发或调试使用这些接口的程序时进行健全性检查非常有用。这个程序不能按预期工作通常可以告诉我们,我们需要的软件驱动程序没有正确安装。图 12-5 显示了该程序的示例输出,其中包含关于当前设备的高级信息。

img/489625_1_En_12_Fig5_HTML.png

图 12-5

好奇. cpp 的示例输出

img/489625_1_En_12_Fig4_HTML.png

图 12-4

设备查询机制的简单使用

更好奇:详细的枚举代码

我们提供了一个程序,我们将其命名为 verycurious.cpp(图 12-6 ,来说明使用get_info<>可以获得的一些详细信息。同样,我们发现自己编写这样的代码有助于开发或调试程序。图 12-5 显示了该程序的输出样本,以及关于当前设备的底层信息。

现在我们已经展示了如何访问信息,我们将讨论在应用程序中查询和操作最重要的信息字段。

img/489625_1_En_12_Fig6_HTML.png

图 12-6

设备查询机制的更详细的使用:query 好奇. cpp

好奇:get_info<>

has_extension()接口允许程序直接测试一个特性,而不是像前面的代码示例那样遍历来自get_info <info::platform::extensions>的扩展列表。SYCL 2020 临时规范定义了新的机制来查询设备的扩展和详细方面,但我们不会在本书中涵盖这些功能(这些功能刚刚完成)。更多信息请参考在线 oneAPI DPC++ 语言参考

设备信息描述符

本章前面使用的“好奇”程序示例利用了最常用的 SYCL 设备类成员函数(即is_host, is_cpu, is_gpu, is_accelerator, get_info, has_extension)。这些成员函数记录在 SYCL 规范的“SYCL 设备类的成员函数”表中(在 SYCL 1.2.1 中,是表 4.18)。

“好奇”程序示例也使用get_info成员函数查询信息。包括主机设备在内的所有 SYCL 设备都必须支持一组查询。SYCL 规范中题为“器件信息描述符”的表格描述了此类项目的完整列表(在 SYCL 1.2.1 中为表 4.20)。

设备特定的内核信息描述符

像平台和设备一样,我们可以使用get_info函数查询关于我们内核的信息。这些信息(例如,支持的工作组大小、首选的工作组大小、每个工作项所需的私有内存量)是特定于设备的,因此kernel类的get_info成员函数接受一个device作为参数。

DEVICE-SPECIFIC KERNEL INFORMATION IN SYCL 1.2.1

出于 OpenCL 命名的历史原因,SYCL 继承了名为kernel::get_infokernel::get_work_group_info的查询组合,分别返回关于内核对象的信息和关于内核在特定设备上执行的信息。

在 DPC++ 和 SYCL(从 2020 年临时版本开始)中使用重载允许通过单一的get_info API 支持这两种类型的信息。

细节:那些“正确”的细节

我们将把细节分为关于必要条件(正确性)的信息和对调优有用但对正确性不必要的信息。

在这第一个正确性类别中,我们将列举内核正常启动应该满足的条件。不遵守这些设备限制将导致程序失败。图 12-7 显示了我们如何获取这些参数中的一部分,使得这些值可以在主机代码和内核代码中使用(通过 lambda 捕获)。我们可以修改代码来利用这些信息;例如,它可以指导我们关于缓冲区大小或工作组大小的代码。

img/489625_1_En_12_Fig7_HTML.png

图 12-7

获取可用于塑造内核的参数

提交不满足这些条件的内核将会产生错误。

设备查询

device_type: cpu, gpu, accelerator, custom, 2 automatic, host, all。这些最常由is_host(), is_cpu, is_gpu(),等测试(见图 12-6 ):

  • max_work_item_sizes``:``nd_range工作组每个维度允许的最大工作项数。非定制设备的最小值为(1, 1, 1)

  • 在单个计算单元上执行内核的工作组中允许的最大工作项目数。最小值为 1。

  • global_mem_size:全局内存的大小,以字节为单位。

  • local_mem_size:本地内存的大小,以字节为单位。除定制设备外,最小尺寸为 32 K。

  • 在 SYCL 规范中没有详细说明的设备特定信息,通常是供应商特定的,如我们的verycurious程序所示(图 12-6 )。

  • max_compute_units:表示设备上可用的并行数量——由实施定义,请小心解读!

  • sub_group_sizes:返回设备支持的子组大小集合。

  • 如果该设备支持显式 USM 中描述的设备分配,则usm_device_allocations:返回true

  • 如果该设备可以访问主机分配,则usm_host_allocations:返回true

  • 如果该设备支持共享分配,则usm_shared_allocations:返回true

  • 如果该设备支持由设备上的“受限 USM”的限制所管理的共享分配,则usm_restricted_shared_allocations:返回true。该属性要求属性usm_shared_allocations为该设备返回true

  • 如果系统分配器可以代替 USM 分配机制用于该设备上的共享分配,则usm_system_allocator:返回true

我们建议在程序逻辑中避免 max_compute_units。

我们发现应该避免查询计算单元的最大数量,部分原因是这个定义不够清晰,无法用于代码调优。大多数程序应该表达它们的并行性,并让运行时将其映射到可用的并行性上,而不是使用max_compute_units。依赖于max_compute_units的正确性只有在增加了特定于实现和设备的信息时才有意义。专家可能会这样做,但大多数开发人员没有也不需要这样做!在这种情况下,让运行时完成它的工作!

内核查询

执行这些内核查询需要第十章“程序对象中的内核”中讨论的机制:

  • work_group_size:返回可用于在特定设备上执行内核的最大工作组大小

  • compile_work_group_size:返回由内核指定的工作组大小(如果适用);否则返回(0,0,0)

  • 如果适用,返回由内核指定的子组大小;否则返回 0

  • 如果适用,返回由内核指定的子组的数量;否则返回 0

  • max_sub_group_size:返回以指定工作组大小启动的内核的最大子组大小

  • max_num_sub_groups:返回内核子组的最大数量

细节:那些“调整/优化”

有几个额外的参数可以考虑作为我们内核的微调参数。这些可以被忽略,而不会危及程序的正确性。这些允许我们的内核真正利用硬件的细节来提高性能。

关注这些查询的结果有助于优化缓存(如果存在的话)。

设备查询

  • global_mem_cache_line_size :全局内存缓存行的大小,以字节为单位。

  • global_mem_cache_size:全局内存缓存的大小,以字节为单位。

  • local_mem_type:支持的本地存储器类型。这可以是暗示专用本地存储器存储的info::local_mem_type::local,例如 SRAM 或info::local_mem_type::global。后一种类型意味着本地内存只是作为全局内存之上的一种抽象来实现,没有任何性能提升。对于自定义设备(仅限),本地内存类型也可以是info::local_mem_type::none,表示不支持本地内存。

内核查询

  • preferred_work_group_size:在特定设备上执行内核的首选工作组规模。

  • 在特定设备上执行内核的首选工作组规模

运行时与编译时属性

本章中描述的查询是通过运行时 API(get_info)执行的,这意味着直到运行时才知道结果。这涵盖了许多用例,但 SYCL 规范也正在努力提供编译时的属性查询,当工具链知道它们时,允许更高级的编程技术,如基于设备属性的内核模板化。对于现有的运行时查询,基于查询的代码编译时适应是不可能的,这种能力对于高级优化或编写使用一些扩展的内核非常重要。在编写本书时,这些接口还没有定义得足够好来描述这些接口,但是我们可以期待 SYCL 和 DPC++ 中即将出现的更强大的查询和代码适应机制!查看在线 oneAPI DPC++ 语言参考和 SYCL 规范以获取更新。

摘要

最具移植性的程序会查询系统中可用的设备,并根据运行时信息调整它们的行为。这一章打开了通向丰富信息的大门,这些信息允许对我们的代码进行这样的裁剪,以适应运行时出现的硬件。

通过将我们的应用程序参数化以适应硬件的特性,我们的程序可以变得更加可移植,性能更加可移植,并且更加经得起未来的考验。我们还可以测试当前的硬件是否在我们在程序设计中所做的任何假设的范围内,并且当发现硬件超出我们的假设范围时,发出警告或中止。

Creative Commons

开放存取本章根据知识共享署名 4.0 国际许可证(http://Creative Commons . org/licenses/by/4.0/)的条款获得许可,该许可证允许以任何媒体或格式使用、共享、改编、分发和复制,只要您适当注明原作者和来源,提供知识共享许可证的链接并指明是否进行了更改。

本章中的图像或其他第三方材料包含在本章的知识共享许可中,除非在材料的信用额度中另有说明。如果材料不包括在本章的知识共享许可中,并且您的预期使用不被法律法规允许或超出了允许的使用范围,您将需要直接从版权所有者处获得许可。

十三、实用技巧

img/489625_1_En_13_Figa_HTML.gif

这一章包含了许多有用的信息、实用的技巧、建议和技术,它们在编程 SYCL 和使用 DPC++ 时被证明是有用的。这些主题没有一个是详尽的,所以目的是提高认识和鼓励更多的学习。

获取 DPC++ 编译器和代码示例

第一章讲述了如何获得 DPC++ 编译器(oneapi . com/implementationsgithub. com/ intel/ llvm )以及从哪里获得代码示例(www . a press . com/9781484255735—寻找本书的服务:源代码)。再次提到这一点是为了强调尝试这些示例是多么有用(包括进行修改!)来获得实践经验。加入那些知道图 1-1 中的代码实际打印出什么的人的俱乐部吧!

在线论坛和文档

英特尔开发人员专区举办了一个论坛,用于讨论 DPC++ 编译器、DPC++ 库(第章第十八部分)、DPC++ 兼容性工具(用于 CUDA 迁移——将在本章稍后讨论)以及 oneAPI 工具包中包含的 gdb(本章也涉及调试)。这是一个张贴关于编写代码的问题(包括可疑的编译器错误)的绝佳位置。你会在这个论坛上找到一些作者的帖子,尤其是在写这本书的时候。论坛可在线访问 https://software.intel.com/en-us/forums/oneapi-data-parallel-c-compiler

在线 oneAPI DPC++ 语言参考是一个很好的资源,可以找到类和成员定义的完整列表、编译器选项的详细信息等等。

平台模型

SYCL 或 DPC++ 编译器被设计成和我们曾经使用过的任何其他 C++ 编译器一样的行为和感觉。一个显著的区别是,常规 C++ 编译器只为 CPU 生成代码。在高层次上理解内部工作是值得的,它使编译器能够为主机 CPU 设备产生代码。

SYCL 和 DPC++ 使用的平台模型(图 13-1 )指定了一个主机来协调和控制在设备上执行的计算工作。第二章描述了如何给设备分配工作,第四章深入探讨了如何给设备编程。第十二章描述了在不同的特性级别使用平台模型。

正如我们在第二章中讨论的,总有一个设备对应着主机,称为主机设备。为设备代码提供这个保证可用的目标,允许在假设至少有一个设备可用的情况下编写设备代码,即使它是主机本身!选择在哪些设备上运行设备代码是在程序控制之下的——作为程序员,如果我们想在特定的设备上执行代码,以及如何执行代码,这完全是我们的选择。

img/489625_1_En_13_Fig1_HTML.png

图 13-1

平台模型:可以抽象使用,也可以具体使用

多架构二进制文件

因为我们的目标是用一个单一的源代码来支持一个异构的机器,所以自然希望得到一个单一的可执行文件。

多架构二进制文件(又名胖二进制文件)是一个单一的二进制文件,它已经被扩展为包含我们的异构机器所需的所有编译和中间代码。多架构二进制文件的概念并不新鲜。例如,一些操作系统支持多架构 32 位和 64 位库和可执行文件。多架构二进制代码的行为就像我们习惯的任何其他a.outA.exe一样——但是它包含了异构机器所需的一切。这有助于为特定设备选择正确的运行代码。正如我们接下来讨论的,fat 二进制中设备代码的一种可能形式是一种中间格式,它将设备指令的最终创建推迟到运行时。

编译模型

SYCL 和 DPC++ 的单源特性允许编译的行为和感觉像普通的 C++ 编译。我们不需要为设备调用额外的通道或者处理绑定设备和主机代码。这些都是由编译器自动为我们处理的。当然,理解正在发生的事情的细节是很重要的,原因有几个。如果我们想要更有效地针对特定的架构,这是很有用的知识,并且了解我们是否需要调试编译过程中发生的故障也很重要。

我们将回顾编译模型,以便我们在需要这些知识的时候得到教育。由于编译模型支持同时在一个主机和潜在的几个设备上执行的代码,编译器、链接器和其他支持工具发出的命令比我们习惯的 C++ 编译更复杂(只针对一种架构)。欢迎来到异类世界!

DPC++ 编译器故意对我们隐藏了这种异构的复杂性,并且“正好可以工作”

DPC++ 编译器可以生成类似于传统 C++ 编译器的特定于目标的可执行代码(提前 (AOT)编译,有时也称为离线内核编译),或者它可以生成一个中间表示,可以在运行时即时 (JIT)编译到特定的目标。

如果设备目标提前已知(在我们编译程序的时候),编译器只能提前编译。推迟即时编译提供了更多的灵活性,但是需要编译器和运行时在我们的应用程序运行时执行额外的工作。

DPC++ 编译可以是“提前”的,也可以是“及时”的。

默认情况下,当我们为大多数设备编译代码时,设备代码的输出以中间形式存储。在运行时,系统上的设备处理程序将即时将中间形式编译成在设备上运行的代码,以匹配系统上可用的内容。

我们可以要求编译器提前为特定的设备或设备类别进行编译。这有节省运行时间的优点,但是也有增加编译时间和二进制文件的缺点!提前编译的代码不如实时编译的代码可移植,因为它不能在运行时进行调整。我们可以将两者都包含在我们的二进制文件中,以获得两者的好处。

提前针对特定设备进行编译还有助于我们在构建时检查我们的程序是否应该在该设备上运行。使用即时编译,程序可能会在运行时编译失败(使用第五章中的机制可以发现这一点)。在本章接下来的“调试”部分有一些调试技巧,第五章详细介绍了如何在运行时捕捉这些错误,以避免要求我们的应用程序中止。

图 13-2 说明了从源代码到 fat 二进制(可执行)的 DPC++ 编译过程。我们选择的任何组合都组合成一个胖二进制。当应用程序执行时,运行时使用 fat 二进制文件(这是我们在主机上执行的二进制文件!).有时,我们可能希望在单独的编译中为特定设备编译设备代码。我们希望这样一个单独编译的结果最终被合并到我们的胖二进制文件中。当完全编译(进行完全综合布局布线)时间可能非常长时,这对于 FPGA 开发可能非常有用,并且事实上这是 FPGA 开发的要求,以避免要求在运行时系统上安装综合工具。图 13-3 显示了支持此类需求的捆绑/拆分活动的流程。我们总是可以选择一次编译所有内容,但是在开发过程中,选择分解编译会非常有用。

每个 SYCL 和 DPC++ 编译器都有一个目标相同的编译模型,但是具体的实现细节会有所不同。这里显示的图表是针对 DPC++ 编译器工具链的。

一个特定于 DPC++ 的组件如图 13-2 所示,作为本书中不再提及的集成头生成器。我们甚至不需要知道它是什么或做什么就可以编程。然而,为了满足好奇心,这里有一些信息:集成头文件生成器生成一个头文件,提供关于翻译单元中 SYCL 内核的信息。这包括 SYCL 内核类型的名称如何映射到符号名称,以及关于内核参数及其在相应的 lambda 或 functor 对象中的位置的信息,这些对象是由编译器创建来捕获它们的。integration header 是一种机制,用于通过 C++ lambda/functor 对象实现从主机代码调用内核的便捷方式,这将我们从设置单个参数、按名称解析内核等耗时的任务中解放出来。

img/489625_1_En_13_Fig3_HTML.png

图 13-3

编译过程:卸载捆绑器/解捆绑器

img/489625_1_En_13_Fig2_HTML.png

图 13-2

编译过程:提前和及时选项

向现有 C++ 程序添加 SYCL

向现有的 C++ 程序添加适当的并行性是使用 SYCL 的第一步。如果一个 C++ 应用程序已经在利用并行执行,这可能是一个意外收获,也可能是一个令人头疼的问题。这是因为我们将应用程序的工作划分为并行执行的方式极大地影响了我们可以用它做什么。当程序员谈论重构一个程序时,他们指的是重新安排程序内的执行和数据流,以使其准备好利用并行性。这是一个复杂的话题,我们只简单地谈一下。关于如何为并行化准备应用,没有一个通用的答案,但是有一些提示值得注意。

当向 C++ 应用程序添加并行性时,一个简单的方法是在程序中找到一个孤立点,在那里并行性的机会最大。我们可以从这里开始修改,然后根据需要继续在其他领域添加并行性。一个复杂的因素是重构(例如,重新安排程序流和重新设计数据结构)可以提高并行性的机会。

一旦我们在程序中找到一个最有可能实现并行的孤立点,我们就需要考虑如何在程序中的这个点上使用 SYCL。这就是本书其余部分所教导的。

概括地说,引入并行性的关键步骤包括

  1. 并发安全(在传统 CPU 编程中通常称为线程安全):调整所有共享的可变数据(可以改变并被并发共享的数据)以便并发使用

  2. 引入并发性和/或并行性

  3. 针对并行性进行调整(最佳扩展,针对吞吐量或延迟进行优化)

首先考虑步骤 1 是很重要的。许多应用程序已经针对并发性进行了重构,但许多还没有。由于 SYCL 是并行性的唯一来源,我们重点关注内核中使用的数据以及可能与主机共享的数据的安全性。如果我们的程序中有其他技术(OpenMP、MPI、TBB 等)。)引入了并行性,这是我们 SYCL 编程的另一个关注点。需要注意的是,在一个程序中使用多种技术是可以的——SYCL 不需要成为一个程序中唯一的并行来源。这本书没有涵盖与其他并行技术混合的高级主题。

排除故障

本节给出了一些适度的调试建议,以缓解调试并行程序所特有的挑战,尤其是针对异构机器的调试。

我们永远不要忘记,当我们的应用程序在主机设备上运行时,我们可以选择调试它们。该调试提示在第二章中被描述为方法#2。因为设备的架构通常包含较少的调试挂钩,所以工具通常可以更精确地探测主机上的代码。在主机上运行 everything 的另一个好处是,许多与同步相关的错误将会消失,包括在主机和设备之间来回移动内存。虽然我们最终需要调试所有这样的错误,但这允许增量调试,因此我们可以在其他错误之前解决一些错误。

运行在主机设备上的调试提示是一个强大的调试工具。

当在主机上运行所有代码时,并行编程错误,特别是数据竞争和死锁,通常更容易被工具检测和消除。令我们懊恼的是,当在主机和设备的组合上运行时,我们将最经常地看到由于这种并行编程错误而导致的程序失败。当这样的问题出现时,记住回退到 host-only 是一个强大的调试工具是非常有用的。幸运的是,SYCL 和 DPC++ 经过精心设计,让我们可以使用这个选项,并且易于访问。

调试提示如果一个程序死锁,检查主机访问器是否被正确销毁。

当我们开始调试时,下面的 DPC++ 编译器选项是一个好主意:

  • -g:输出调试信息。

  • -ferror-limit=1:将 C++ 与 SYCL/DPC++ 等模板库一起使用时保持理智。

  • 让编译器强制执行良好的编码,以帮助避免在运行时产生错误的代码来调试。

我们真的不需要为了使用 DPC++ 而陷入修复迂腐警告的困境,所以选择不使用-Wpedantic是可以理解的。

当我们让代码在运行时被及时编译时,我们就可以检查代码了。这高度依赖于我们的编译器所使用的层,因此查看编译器文档以获得建议是一个好主意。

调试内核代码

调试内核代码时,首先在主机设备上运行(如第二章所述)。第二章中设备选择器的代码可以很容易地修改,以接受运行时选项,或编译时选项,在我们调试时将工作重定向到主机设备。

在调试内核代码时,SYCL 定义了一个可以在内核内部使用的 C++ 风格的stream(图 13-4 )。DPC++ 还提供了一个 C 风格printf的实验性实现,它有一些有用的功能,但有一些限制。更多详情请见在线 oneAPI DPC++ 语言参考

img/489625_1_En_13_Fig4_HTML.png

图 13-4

sycl::stream

调试内核代码时,经验鼓励我们将断点放在parallel_for之前或parallel_for,内部,但实际上不要放在parallel_for上。放置在parallel_for上的断点可以多次触发断点,即使在执行下一个操作之后。这个 C++ 调试建议适用于许多模板扩展,如 SYCL 中的模板扩展,其中模板调用上的断点在被编译器扩展时会转化为一组复杂的断点。可能有一些实现可以缓解这种情况,但这里的关键点是,我们可以通过不在parallel_for本身上精确设置断点来避免所有实现上的一些混淆。

调试运行时故障

当在编译时发生运行时错误时,我们要么是在处理编译器/运行时错误,要么是我们意外地编写了无意义的程序,直到它在运行时出错并产生难以理解的运行时错误消息时才被发现。深入这些 bug 可能有点吓人,但即使粗略地看一下,也可能让我们更好地了解导致特定问题的原因。它可能会产生一些额外的知识来指导我们避免这个问题,或者它可能只是帮助我们向编译器团队提交一个简短的错误报告。无论哪种方式,知道一些工具的存在是很重要的。

表明运行时失败的程序输出可能如下所示:


origin>: error: Invalid record (Producer: 'LLVM9.0.0' Reader: 'LLVM 9.0.0')
terminate called after throwing an instance of 'cl::sycl::compile_program_error'

看到这里提到的这个抛出让我们知道我们的宿主程序可以被构造来捕捉这个错误。虽然这可能不能解决我们的问题,但它确实意味着运行时编译器故障不需要中止我们的应用程序。第五章深入探讨这个话题。

当我们看到一个运行时故障并且很难快速调试它时,简单地尝试使用提前编译进行重建是值得的。如果我们的目标设备有提前编译选项,这可能是一件容易尝试的事情,可能会产生更容易理解的诊断。如果我们的错误可以在编译时而不是在 JIT 或运行时被看到,通常会在来自编译器的错误消息中发现更多有用的信息,而不是我们通常在 JIT 或运行时看到的少量错误信息。具体选项,查看在线 oneAPI DPC++ 文档进行提前编译

当我们的 SYCL 程序运行在 OpenCL 运行时之上并使用 OpenCL 后端时,我们可以使用 OpenCL 拦截层运行我们的程序:github . com/Intel/OpenCL-Intercept-Layer。这是一个可以检查、记录和修改应用程序(或高级运行时)生成的 OpenCL 命令的工具。它支持很多控件,但是最初设置的好控件是ErrorLoggingBuildLogging,可能还有CallLogging(尽管它会生成很多输出)。使用DumpProgramSPIRV可以进行有用的转储。OpenCL Intercept 层是一个独立的实用程序,不属于任何特定的 OpenCL 实现,因此它可以与许多 SYCL 编译器一起工作。

对于采用英特尔 GPU 的 Linux 系统上的可疑编译器问题,我们可以转储英特尔图形编译器的中间编译器输出。我们通过将环境变量IGC_ShaderDumpEnable设置为 1(对于某些输出)或者将环境变量IGC_ShaderDumpEnableAll设置为 1(对于大量输出)来实现这一点。倾销的产品进入/tmp/IntelIGC。这种技术可能不适用于所有的图形驱动程序,但值得一试,看看它是否适用于我们的系统。

图 13-5 列出了编译器或运行时支持的这些和一些额外的环境变量(在 Windows 和 Linux 上支持),以帮助高级调试。这些是依赖于 DPC++ 实现的高级调试选项,用于检查和控制编译模型。本书没有讨论或利用它们。在线 oneAPI DPC++ 语言参考是了解更多信息的好地方。

img/489625_1_En_13_Fig5_HTML.png

图 13-5

DPC++ 高级调试选项

这些选项在本书中没有详细描述,但是在这里提到它们是为了根据需要打开高级调试的通道。这些选项可能让我们深入了解如何解决问题或错误。有可能我们的源代码无意中触发了一个问题,这个问题可以通过更正源代码来解决。否则,使用这些选项是为了对编译器本身进行非常高级的调试。因此,他们更多地与编译器开发人员联系在一起,而不是编译器的用户。一些高级用户发现这些选项很有用;因此,它们在这里被提及,在本书中不再提及。为了更深入地挖掘,GitHub for DPC++ 在llvm/sycl/doc/environment variables 下有一个针对所有环境变量的文档。md

调试技巧当其他选项用尽,我们需要调试一个运行时问题时,我们会寻找一些转储工具,这些工具可能会给我们一些提示。

初始化数据和访问内核输出

在这一节中,我们将深入探讨一个让 SYCL 新用户感到困惑的话题,这也是我们作为 SYCL 开发新手遇到的最常见的错误。

简而言之,当我们从主机内存分配(例如,数组或向量)创建缓冲区时,我们不能直接访问主机分配,直到缓冲区被销毁。在缓冲区的整个生命周期内,缓冲区拥有在构造时传递给它的任何主机分配。很少使用的机制让我们在缓冲区仍然存在时访问主机分配(例如,缓冲区互斥),但这些高级功能对这里描述的早期错误没有帮助。

如果我们从一个主机内存分配中构造一个缓冲区,在缓冲区被销毁之前,我们不能直接访问主机内存分配!当缓冲区处于活动状态时,它拥有分配。

当缓冲区仍然拥有主机分配时,主机程序访问该分配时会出现一个常见的错误。一旦发生这种情况,一切都完了,因为我们不知道缓冲区使用分配的目的是什么。如果数据不正确,不要感到惊讶——我们试图从中读取输出的内核可能还没有开始运行!如第 3 和 8 章所述,SYCL 是围绕异步任务图机制构建的。在我们尝试使用来自任务图操作的输出数据之前,我们需要确保我们已经到达了代码中的同步点,在那里图形已经执行并使数据对主机可用。缓冲区销毁和主机访问器的创建都是导致这种同步的操作。

图 13-6 显示了我们经常编写的一种常见代码模式,其中我们通过关闭定义缓冲区的块范围来销毁缓冲区。通过使缓冲区超出范围并被销毁,我们可以通过传递给缓冲区构造器的原始主机分配安全地读取内核结果。

img/489625_1_En_13_Fig6_HTML.png

图 13-6

通用模式—从主机分配创建缓冲区

如图 13-6 所示,将缓冲区与现有主机内存关联有两个常见原因:

  1. 简化缓冲区中数据的初始化。我们可以从我们(或应用程序的另一部分)已经初始化的主机内存中构造缓冲区。

  2. 减少键入的字符,因为用'}'结束作用域比创建缓冲区的host_accessor更简洁(尽管更容易出错)。

如果我们使用主机分配来转储或验证内核的输出值,我们需要将缓冲区分配放入块范围(或其他范围),以便我们可以控制它何时被销毁。然后,在我们访问主机分配以获得内核输出之前,我们必须确保缓冲区被销毁。图 13-6 显示这是正确完成的,而图 13-7 显示了一个常见的错误,即当缓冲区仍然存在时,输出被访问。

img/489625_1_En_13_Fig7_HTML.png

图 13-7

常见错误:在缓冲区生存期内直接从主机分配中读取数据

高级用户可能更喜欢使用缓冲区销毁将结果数据从内核返回到主机内存分配中。但是对于大多数用户,尤其是新开发人员,建议使用限定了作用域的主机访问器。

更喜欢使用主机访问器而不是缓冲区的作用域,尤其是在入门时。

为了避免这些错误,我们建议在开始使用 SYCL 和 DPC++ 时使用主机访问器而不是缓冲区范围。主机访问器提供从主机到缓冲区的访问,一旦它们的构造器已经完成运行,我们保证任何先前对缓冲区的写入(例如,来自在host_accessor被创建之前提交的内核)已经执行并且是可见的。本书混合使用了这两种风格(即,主机访问器和传递给缓冲区构造器的主机分配),以使读者熟悉这两种风格。在开始使用时,使用主机访问器往往不容易出错。图 13-8 展示了如何使用主机访问器从内核中读取输出,而不需要首先破坏缓冲区。

img/489625_1_En_13_Fig8_HTML.png

图 13-8

建议:使用主机访问器读取内核结果

只要缓冲区是活动的,就可以使用主机访问器,比如在典型缓冲区生命周期的两端——用于缓冲区内容的初始化和从内核读取结果。图 13-9 显示了这种模式的一个例子。

img/489625_1_En_13_Fig9_HTML.png

图 13-9

建议:使用主机访问器进行缓冲区初始化和结果读取

要提到最后一个细节是,主机访问器有时会在应用程序中引起相反的错误,因为它们也有生存期。当一个缓冲区的host_accessor处于活动状态时,运行时将不允许任何设备使用该缓冲区!运行时不分析我们的宿主程序来确定它们何时可能访问宿主访问器,所以它知道宿主程序已经完成访问缓冲区的唯一方法是运行host_accessor析构函数。如图 13-10 所示,如果我们的主机程序正在等待一些内核运行(例如queue::wait()或获取另一个主机访问器),并且如果 DPC++ 运行时正在等待我们的早期主机访问器被销毁,然后才能运行使用缓冲区的内核,这可能会导致应用程序看起来挂起。

img/489625_1_En_13_Fig10_HTML.png

图 13-10

Bug(挂!)来自host_accessors的不当使用

使用主机访问器时,请确保在内核或其他主机访问器不再需要解锁缓冲区时销毁它们。

多个翻译单元

当我们想要调用内核中定义在不同翻译单元中的函数时,这些函数需要用SYCL_EXTERNAL标记。如果没有这个属性,编译器将只编译一个在设备代码之外使用的函数(从设备代码内部调用这个外部函数是非法的)。

如果我们在同一个翻译单元中定义函数,那么对于SYCL_EXTERNAL函数有一些限制是不适用的:

  • SYCL_EXTERNAL只能用于函数。

  • SYCL_EXTERNAL函数不能使用原始指针作为参数或返回类型。必须改用显式指针类。

  • SYCL_EXTERNAL函数不能调用parallel_for_work_item方法。

  • SYCL_EXTERNAL不能从parallel_for_work_group范围内调用函数。

如果我们试图编译一个内核,它调用的函数不在同一个翻译单元内,也没有用SYCL_EXTERNAL声明,那么我们可能会遇到类似如下的编译错误

error: SYCL kernel cannot call an undefined function without SYCL_EXTERNAL attribute

如果函数本身在没有SYCL_EXTERNAL属性的情况下被编译,我们可能会看到链接或运行时失败,比如


terminate called after throwing an instance of 'cl::sycl::compile_program_error'
...error: undefined reference to ...

DPC++ 支持SYCL_EXTERNAL. SYCL 不要求编译器支持SYCL_EXTERNAL;一般来说,这是一个可选的功能。

多个翻译单元对性能的影响

编译模型的一个含义(见本章前面)是,如果我们将设备代码分散到多个翻译单元中,这可能会比我们的设备代码位于同一位置时触发更多的即时编译调用。这高度依赖于实现,并且随着实现的成熟会随着时间的推移而变化。

这种对性能的影响很小,在我们的大多数开发工作中可以忽略,但是当我们进行微调以最大限度地提高代码性能时,我们可以考虑两件事情来减轻这些影响:(1)将设备代码组合在同一个翻译单元中,以及(2)使用提前编译来完全避免即时编译的影响。由于这两者都需要我们付出一些努力,所以我们只有在完成开发并试图充分利用应用程序的性能时才会这样做。当我们求助于这种详细的调优时,有必要测试这些变化,以观察它们对我们正在使用的 SYCL 实现的影响。

当匿名的兰姆达需要名字的时候

SYCL 提供了指定定义为 lambdas 的名称,以备工具需要和用于调试目的(例如,根据用户定义的名称启用显示)。在本书的大部分内容中,匿名 lambda 被用于内核,因为使用 DPC++ 时不需要名字(除了编译选项的传递,如第十章中关于 lambda 命名的讨论所述)。从 SYCL 2020 暂定开始,它们也是可选的。

当我们有在一个代码库上混合来自多个供应商的 SYCL 工具的高级需求时,工具可能要求我们命名为 lambdas。这是通过在使用 lambda 的 SYCL 动作构造中添加一个<class uniquename>(例如parallel_for)来实现的。这种命名允许来自多个厂商的工具在一次编译中以一种定义的方式进行交互,并且还可以通过显示我们在调试工具和层中定义的内核名称来提供帮助。

从 CUDA 迁移到 SYCL

将 CUDA 代码迁移到 SYCL 或 DPC++ 在本书中没有详细介绍。有一些工具和资源可以探索如何做到这一点。移植 CUDA 代码相对简单,因为它是一种基于内核的并行方法。一旦用 SYCL 或 DPC++ 编写,这个新程序就能针对比 CUDA 单独支持的更多的设备。新增强的程序仍然可以针对 NVIDIA GPU,使用支持 NVIDIA GPU 的 SYCL 编译器。

迁移到 SYCL 打开了 SYCL 支持的设备多样性的大门,这远远超出了 GPU。

当使用 DPC++ 兼容性工具时,--report-type= value选项提供了关于移植代码的非常有用的统计信息。这本书的一位评论家称之为“英特尔dpct提供的一面美丽的旗帜。”根据项目的源代码组织,在移植 CUDA 代码时,--in-root选项可以证明非常有用。

要了解关于 CUDA 迁移的更多信息,有两个资源是很好的起点:

摘要

今天的流行文化经常把小费称为生活窍门。不幸的是,编程文化经常赋予黑客一个负面的含义,所以作者避免将这一章命名为“SYCL 黑客”毫无疑问,本章只是触及了使用 SYCL 和 DPC++ 的一些实用技巧。更多的技巧可以在在线论坛上分享,我们一起学习如何用 DPC++ 充分利用 SYCL。

Creative Commons

开放存取本章根据知识共享署名 4.0 国际许可证(http://Creative Commons . org/licenses/by/4.0/)的条款获得许可,该许可证允许以任何媒体或格式使用、共享、改编、分发和复制,只要您适当注明原作者和来源,提供知识共享许可证的链接并指明是否进行了更改。

本章中的图像或其他第三方材料包含在本章的知识共享许可中,除非在材料的信用额度中另有说明。如果材料不包括在本章的知识共享许可中,并且您的预期使用不被法律法规允许或超出了允许的使用范围,您将需要直接从版权所有者处获得许可。

十四、常见的并行模式

img/489625_1_En_14_Figa_HTML.gif

当我们处于程序员的最佳状态时,我们识别工作中的模式,并应用被时间证明是最佳解决方案的技术。并行编程也不例外,如果不研究在这个领域中已经被证明有用的模式,那将是一个严重的错误。考虑大数据应用采用的 MapReduce 框架;他们的成功很大程度上源于基于两个简单而有效的并行模式——mapreduce

并行编程中有许多常见的模式,它们一次又一次地出现,与我们使用的编程语言无关。这些模式是通用的,可以在任何并行级别(例如子组、工作组、完整设备)和任何设备(例如 CPU、GPU、FPGAs)上使用。然而,模式的某些属性(比如它们的可伸缩性)可能会影响它们对不同设备的适用性。在某些情况下,使应用程序适应新设备可能只需要选择适当的参数或微调模式的实现;在其他情况下,我们可以通过选择完全不同的模式来提高性能。

理解如何、何时以及在哪里使用这些常见的并行模式是提高我们对 DPC++(以及一般的并行编程)的熟练程度的关键部分。对于那些已经有并行编程经验的人来说,了解这些模式在 DPC++ 中是如何表达的,是一种快速提高和熟悉该语言功能的方法。

本章旨在回答以下问题:

  • 理解哪些模式是最重要的?

  • 这些模式与不同设备的功能有什么关系?

  • 哪些模式已经作为 DPC++ 函数和库提供了?

  • 如何使用直接编程来实现这些模式?

理解模式

这里讨论的模式是麦克库尔等人在《结构化并行编程》一书中描述的并行模式的一个子集。我们不讨论与并行的类型相关的模式(例如,fork-join,branch-and-bound ),而是集中讨论对编写数据并行内核最有用的算法模式。

我们完全相信,理解并行模式的子集对于成为一名有效的 DPC++ 程序员至关重要。图 14-1 中的表格提供了不同模式的高级概述,包括它们的主要用例、关键属性以及它们的属性如何影响它们与不同硬件设备的关联性。

img/489625_1_En_14_Fig1_HTML.png

图 14-1

并行模式及其对不同设备类型的相似性

地图

map 模式是所有模式中最简单的并行模式,具有函数式编程语言经验的读者会很快熟悉它。如图 14-2 所示,通过应用一些函数,一个范围的每个输入元素被独立地映射到一个输出。许多数据并行操作可以表示为映射模式的实例(例如,向量加法)。

img/489625_1_En_14_Fig2_HTML.png

图 14-2

地图图案

由于函数的每个应用程序都是完全独立的,map 的表达式通常非常简单,依靠编译器和/或运行时来完成大部分困难的工作。我们应该期望写入 map 模式的内核适用于任何设备,并且这些内核的性能能够很好地与可用的硬件并行性相适应。

然而,在决定将整个应用程序重写为一系列地图内核之前,我们应该仔细考虑!这种开发方法效率很高,并保证应用程序可以移植到各种各样的设备类型,但鼓励我们忽略可能显著提高性能的优化(例如,提高数据重用、融合内核)。

蜡纸

模板图案与贴图图案密切相关。如图 14-3 所示,一个函数应用于一个输入和一组由模板描述的相邻输入,以产生一个输出。模板图案经常出现在许多领域,包括科学/工程应用(例如有限差分代码)和计算机视觉/机器学习应用(例如图像卷积)。

img/489625_1_En_14_Fig3_HTML.png

图 14-3

模板图案

当模板模式不在适当位置执行时(即,将输出写入单独的存储位置),该功能可以独立地应用于每个输入。现实世界中调度模板通常比这更复杂:计算相邻的输出需要相同的数据,多次从内存中加载该数据会降低性能;我们可能希望就地应用模板(即,覆盖原始输入值)以减少应用程序的内存占用。

因此,模板内核对不同设备的适用性高度依赖于模板的属性和输入问题。根据经验法则:

  • 小模板可以从 GPU 的暂存存储中受益。

  • 大型模板可以受益于(相对)较大的 CPU 缓存。

  • 通过在 FPGAs 上实现脉动阵列,对小输入进行操作的小模板可以实现显著的性能增益。

由于模板很容易描述,但实现起来很复杂,因此模板是领域特定语言(DSL)开发中最活跃的领域之一。已经有几个嵌入式 DSL 利用 C++ 的模板元编程功能在编译时生成高性能的模板内核,我们希望这些框架移植到 DPC++ 只是时间问题。

减少

归约是一种常见的并行模式,使用通常为关联交换的操作符(例如,加法)组合来自内核调用的每个实例的部分结果。缩减的最普遍的例子是计算总和(例如,当计算点积时)或计算最小/最大值(例如,使用最大速度来设置时间步长)。

图 14-4 显示了通过树归约实现的归约模式,这是一种流行的实现方式,需要对一系列 N 输入元素进行 log 2 ( N )组合运算。虽然树归约很常见,但其他实现也是可能的——一般来说,我们不应该假设归约以特定的顺序组合值。

img/489625_1_En_14_Fig4_HTML.png

图 14-4

还原模式

在现实生活中,内核很少是令人尴尬的并行,即使它们是并行的,它们也经常与 Reduce 成对出现(如在 MapReduce 框架中)来总结它们的结果。这使得 reductions 成为需要理解的最重要的并行模式之一,也是我们必须能够在任何设备上高效执行的模式。

针对不同设备调整缩减是计算部分结果所花费的时间和组合它们所花费的时间之间的微妙平衡;使用太少的并行会增加计算时间,而使用太多的并行会增加组合时间。

通过使用不同的设备执行计算和组合步骤来提高整体系统利用率可能很有吸引力,但是这种调整工作必须仔细考虑在设备之间移动数据的成本。在实践中,我们发现在数据产生时直接在同一设备上执行缩减通常是最佳方法。因此,使用多个设备来提高归约模式的性能不依赖于任务并行性,而是依赖于另一个级别的数据并行性(即,每个设备对部分输入数据执行归约)。

扫描

扫描模式使用二元关联运算符计算广义前缀和,输出的每个元素代表一个部分结果。如果元素 i 的部分和是范围【0, i 中所有元素的和(即包括 i 的和),则称一次扫描为包含。如果元素 i 的部分和是范围[0, i ]中所有元素的和(即不包括 i 的和,则称扫描为互斥

乍一看,扫描似乎是一个固有的串行操作,因为每个输出的值取决于前一个输出的值!虽然 scan 确实比其他模式具有更少的并行机会(因此可扩展性可能更差),但图 14-5 显示了在相同数据上使用多次扫描来实现并行扫描是可能的。

img/489625_1_En_14_Fig5_HTML.png

图 14-5

扫描模式

因为扫描操作中的并行机会有限,所以执行扫描的最佳设备在很大程度上取决于问题大小:较小的问题更适合 CPU,因为只有较大的问题才会包含足以使 GPU 饱和的数据并行度。对于 FPGAs 和其他空间架构来说,问题的大小并不重要,因为扫描自然有助于流水线并行。与缩减的情况一样,一个很好的经验法则是在生成数据的同一设备上执行扫描操作,在优化期间考虑扫描操作适合应用程序的位置和方式通常会比单独优化扫描操作产生更好的结果。

打包和拆包

打包和解包模式与扫描密切相关,通常在扫描功能的基础上实现。我们在这里将它们分开讨论,因为它们实现了可能与前缀和没有明显联系的常见操作的高性能实现(例如,添加到列表中)。

包装

如图 14-6 所示,填充模式基于布尔条件丢弃输入范围的元素,将未被丢弃的元素填充到输出范围的连续位置。该布尔条件可以是预先计算的掩码,或者可以通过对每个输入元素应用某个函数来在线计算。

img/489625_1_En_14_Fig6_HTML.png

图 14-6

包装模式

与扫描一样,打包操作具有内在的串行性质。给定要打包/复制的输入元素,计算其在输出范围中的位置需要关于有多少先前元素也被打包/复制到输出中的信息。该信息相当于对驱动包的布尔条件的排他扫描。

解除…的负担

如图 14-7 所示(顾名思义),解包模式与打包模式相反。输入范围的连续元素被解包到输出范围的非连续元素中,其他元素保持不变。这种模式最明显的用例是解包先前打包的数据,但是它也可以用来填充先前计算产生的数据中的“空隙”。

img/489625_1_En_14_Fig7_HTML.png

图 14-7

解包模式

使用内置函数和库

这些模式中有许多可以直接使用 DPC++ 的内置功能或供应商提供的用 DPC++ 编写的库来表达。在真正的大型软件工程项目中,利用这些函数和库是平衡性能、可移植性和生产率的最佳方式。

DPC++ 简化库

DPC++ 提供了一种方便的抽象来描述具有归约语义的变量,而不是要求我们每个人都维护自己的可移植的高性能归约内核库。这种抽象简化了归约核的表达式,并使归约被执行的事实显式化,从而允许实现为设备、数据类型和归约操作的不同组合在不同的归约算法之间进行选择。

图 14-8 中的内核展示了一个使用归约库的例子。注意,内核体不包含任何对归约的引用——我们必须指定的是,内核包含一个归约,它使用plus仿函数组合了sum变量的实例。这为自动生成优化的缩减序列的实现提供了足够的信息。

img/489625_1_En_14_Fig8_HTML.png

图 14-8

使用归约库表示为 ND 范围数据并行核的归约

在撰写本文时,归约库只支持具有单个归约变量的内核。DPC++ 的未来版本有望支持同时执行多个归约的内核,方法是在传递给parallel_fornd_range和仿函数参数之间指定多个归约,并将多个归约器作为内核仿函数的参数。

在内核完成之前,不保证归约的结果会被写回原始变量。除了这个限制之外,访问归约结果的行为与访问 SYCL 中的任何其他变量的行为相同:访问存储在缓冲区中的归约结果需要创建适当的设备或主机访问器,而访问存储在 USM 分配中的归约结果可能需要显式同步和/或内存移动。

DPC++ 归约库不同于其他语言中的归约抽象的一个重要方面是,它限制了我们在内核执行期间对归约变量的访问——我们不能检查归约变量的中间值,并且我们被禁止使用除指定组合函数之外的任何东西来更新归约变量。这些限制防止我们犯难以调试的错误(例如,在试图计算最大值时添加缩减变量),并确保缩减可以在各种不同的设备上有效地实现。

reduction

reduction类是我们用来描述内核中的缩减的接口。构造归约对象的唯一方法是使用图 14-9 所示的函数之一。

img/489625_1_En_14_Fig9_HTML.png

图 14-9

reduction函数的函数原型

该函数的第一个版本允许我们指定归约变量和用于合并每个工作项贡献的操作符。第二个版本允许我们提供一个与归约操作符相关联的可选标识值——这是对用户定义的归约的一个优化,我们稍后将再次讨论。

注意,reduction函数的返回类型是未指定的,而reduction类本身完全是实现定义的。尽管这对于 C++ 类来说可能有点不寻常,但它允许一个实现使用不同的类(或者一个具有任意数量模板参数的类)来表示不同的归约算法。DPC++ 的未来版本可能会决定重新考虑这种设计,以便使我们能够在特定的执行上下文中显式地请求特定的归约算法。

reducer

reducer类的一个实例封装了一个归约变量,公开了一个有限的接口,确保我们不能以任何实现认为不安全的方式更新归约变量。图 14-10 中显示了reducer等级的简化定义。像reduction类一样,reducer类的精确定义是实现定义的——缩减器的类型将取决于缩减是如何执行的,为了最大化性能,在编译时知道这一点很重要。然而,允许我们更新归约变量的函数和操作符是定义良好的,并且保证受任何 DPC++ 实现的支持。

img/489625_1_En_14_Fig10_HTML.png

图 14-10

reducer类的简化定义

具体来说,每个 reducer 都提供了一个combine()函数,它将部分结果(来自单个工作项)与 reduction 变量的值结合起来。这个组合函数的行为是由实现定义的,但这不是我们在编写内核时需要担心的。根据归约运算符,还需要一个归约运算符来使其他运算符可用;例如,+=运算符是为plus归约而定义的。提供这些额外的运算符只是为了方便程序员并提高可读性;在它们可用的地方,这些操作符具有与直接调用combine()相同的行为。

用户定义的缩减

几个常见的归约算法(例如,树归约)并不看到每个工作项直接更新单个共享变量,而是在私有变量中累积一些部分结果,这些部分结果将在将来的某个时刻被组合。这样的私有变量引入了一个问题:实现应该如何初始化它们?将变量初始化为每个工作项的第一个贡献具有潜在的性能影响,因为需要额外的逻辑来检测和处理未初始化的变量。相反,将变量初始化为归约运算符的标识可以避免性能损失,但只有在标识已知的情况下才有可能。

当归约操作在简单算术类型上并且归约运算符是标准函子(例如,plus)时,DPC++ 实现只能自动确定要使用的正确标识值。对于用户定义的约简(即那些对用户定义的类型进行操作和/或使用用户定义的函子的约简),我们可以通过直接指定标识值来提高性能。

对用户定义的归约的支持仅限于普通的可复制类型和没有副作用的组合函数,但这足以支持许多现实生活中的用例。例如,图 14-11 中的代码演示了使用用户定义的归约来计算向量中的最小元素及其位置。

img/489625_1_En_14_Fig11_HTML.png

图 14-11

使用用户定义的约简,通过 ND-range 核找到最小值的位置

oneAPI DPC++ 库

C++ 标准模板库(STL)包含了几个与本章讨论的并行模式相对应的算法。STL 中的算法通常适用于由成对迭代器指定的序列,并且从 C++17 开始,支持一个执行策略参数,表示它们应该顺序执行还是并行执行。

oneAPI DPC++ 库(oneDPL)利用这一执行策略参数来提供一种高效的并行编程方法,这种方法利用了在幕后用 DPC++ 编写的内核。如果一个应用程序可以单独使用 STL 算法的功能来表达,那么 oneDPL 就可以在不编写任何 DPC++ 内核代码的情况下利用我们系统中的加速器!

图 14-12 中的表格显示了 STL 中可用的算法如何与本章中描述的并行模式相关联,以及如何与传统串行算法(在 C++17 之前可用)相关联。关于如何在 DPC++ 应用中使用这些算法的更详细的解释可以在第十八章中找到。

img/489625_1_En_14_Fig12_HTML.png

图 14-12

将并行模式与 C++17 算法库相关联

群组功能

DPC++ 设备代码中对并行模式的支持由单独的组函数库提供。这些组函数利用特定工作项目组(即工作组或子组)的并行性来在有限的范围内实现通用并行算法,并且可以用作构建块来构造其他更复杂的算法。

与 oneDPL 一样,DPC++ 中组函数的语法基于 C++ 中算法库的语法。每个函数的第一个参数接受一个groupsub_group对象来代替执行策略,C++ 算法的任何限制都适用。组功能由指定组中的所有工作项目协作执行,因此必须类似于组屏障来处理——组中的所有工作项目必须在聚合控制流中遇到相同的算法(即,组中的所有工作项目必须类似地遇到或不遇到算法调用),并且所有工作项目必须提供相同的功能参数,以便确保它们在正在执行的操作上达成一致。

在撰写本文时,reduceexclusive_scaninclusive_scan函数仅限于支持原始数据类型和最常见的归约运算符(例如,plusminimummaximum)。这对于许多用例来说已经足够了,但是 DPC++ 的未来版本有望将集体支持扩展到用户定义的类型和操作符。

直接编程

尽管我们建议尽可能地利用库,但是我们可以通过查看如何使用“本地”DPC++ 内核实现每个模式来学习很多东西。

本章剩余部分中的内核不应期望达到与高度调优的库相同的性能水平,但有助于更好地理解 DPC++ 的功能,甚至可以作为构建新库功能原型的起点。

USE VENDOR-PROVIDED LIBRARIES!

当供应商提供一个函数的库实现时,使用它比将函数重新实现为内核几乎总是有益的!

地图

由于其简单性,map 模式可以直接实现为一个基本的并行内核。图 14-13 所示的代码显示了这样一个实现,使用 map 模式计算一个范围内每个输入元素的平方根。

img/489625_1_En_14_Fig13_HTML.png

图 14-13

在数据并行内核中实现 map 模式

蜡纸

如图 14-14 所示,将模板直接实现为具有多维缓冲区的多维基本数据并行内核,简单易懂。

img/489625_1_En_14_Fig14_HTML.png

图 14-14

在数据并行内核中实现模板模式

然而,模板模式的这种表达非常幼稚,不应该期望表现得很好。正如本章前面提到的,众所周知,需要利用局部性(通过空间或时间分块)来避免从内存中重复读取相同的数据。图 14-15 显示了一个使用工作组本地内存的简单空间分块示例。

img/489625_1_En_14_Fig15_HTML.png

图 14-15

使用工作组本地内存在 ND-range 内核中实现模板模式

为给定模板选择最佳优化需要编译时对块大小、邻域和模板函数本身进行自省,这需要比这里讨论的更复杂的方法。

减少

通过利用在工作项之间提供同步和通信能力的语言特性(例如,原子操作、工作组和子组功能、子组洗牌),可以在 DPC++ 中实现归约内核。图 14-16 和 14-17 中的内核显示了两种可能的归约实现:使用基本parallel_for的简单归约和每个工作项的原子操作;还有一个稍微聪明一点的缩减,分别使用 ND-range parallel_for和 work-group reduce函数来利用局部性。我们将在第十九章更详细地回顾这些原子操作。

img/489625_1_En_14_Fig17_HTML.png

图 14-17

实现一个表示为 ND-range 核的简单约简

img/489625_1_En_14_Fig16_HTML.png

图 14-16

实现表示为数据并行内核的简单约简

有许多其他方式来编写归约内核,并且不同的设备可能会偏好不同的实现,这是由于对原子操作的硬件支持、工作组本地存储器大小、全局存储器大小、快速设备范围屏障的可用性,或者甚至专用归约指令的可用性的差异。在某些架构上,它甚至可能更快(或者是必要的!)使用 log 2 ( N )个单独的内核调用来执行树缩减。

我们强烈建议,只有在 DPC++ reduction 库不支持的情况下,或者当针对特定设备的功能对内核进行微调时,才考虑手动实现 reduction——即使这样,也只有在 100%确定 reduction 库性能不佳之后!

扫描

正如我们在本章前面所看到的,实现并行扫描需要对数据进行多次扫描,并且在每次扫描之间进行同步。由于 DPC++ 不提供同步 ND 范围内所有工作项的机制,因此必须使用多个内核来直接实现设备范围的扫描,这些内核通过全局内存来传递部分结果。

如图 14-18 、 14-19 和 14-20 所示的代码展示了使用几个内核实现的包容性扫描。第一个内核跨工作组分发输入值,在工作组本地内存中计算工作组本地扫描(注意,我们可以使用工作组inclusive_scan函数代替)。第二个内核使用单个工作组计算局部扫描,这次是基于每个块的最终值。第三个内核组合这些中间结果来最终确定前缀和。这三个内核对应着图 14-5 中图的三层。

img/489625_1_En_14_Fig20_HTML.png

图 14-20

在 ND 范围内核中实现全局包含扫描的第 3 阶段(最终阶段)

img/489625_1_En_14_Fig19_HTML.png

图 14-19

在 ND-range 内核中实现全局包含扫描的第 2 阶段:扫描每个工作组的结果

img/489625_1_En_14_Fig18_HTML.png

图 14-18

在 ND-range 内核中实现全局包含扫描的第 1 阶段:跨每个工作组进行计算

图 14-18 和 14-19 非常相似;唯一的区别是范围的大小以及输入和输出值的处理方式。这种模式的实际实现可以使用一个带有不同参数的函数来实现这两个阶段,出于教学原因,这里只将它们作为不同的代码。

打包和拆包

打包和解包也称为收集和分散操作。这些操作处理数据在内存中的排列方式以及我们希望将其呈现给计算资源的方式之间的差异。

包装

由于 pack 依赖于独占扫描,所以实现适用于 ND-range 的所有元素的 pack 也必须通过全局内存并在几个内核入队的过程中进行。但是,有一种常见的包装用例,它不要求将操作应用于 ND 范围的所有元素,即只在特定工作组或子组中的项目上应用包装。

图 14-21 中的片段显示了如何在独占扫描的基础上实现组包操作。

img/489625_1_En_14_Fig21_HTML.png

图 14-21

在独占扫描的基础上实现组打包操作

图 14-22 中的代码演示了如何在内核中使用这种打包操作来构建需要一些额外后处理的元素列表(在未来的内核中)。所示的例子基于来自分子动力学模拟的真实内核:分配给粒子 i 的子组中的工作项合作识别在 i 的固定距离内的所有其他粒子,并且只有该“邻居列表”中的粒子将用于计算作用在每个粒子上的力。

img/489625_1_En_14_Fig22_HTML.png

图 14-22

使用子组打包操作来构建需要附加后处理的元素列表

请注意,pack 模式不会对元素进行重新排序——打包到输出数组中的元素会按照它们在输入中的顺序出现。pack 的这个属性很重要,它使我们能够使用 pack 功能来实现其他更抽象的并行算法(比如std::copy_ifstd::stable_partition)。然而,有其他的并行算法可以在不需要维护顺序的包功能之上实现(例如std::partition)。

解除…的负担

与 pack 一样,我们可以使用 scan 实现 unpack。图 14-23 显示了如何在独占扫描之上实现子组解包操作。

img/489625_1_En_14_Fig23_HTML.png

图 14-23

在独占扫描之上实现子组解包操作

图 14-24 中的代码演示了如何使用这样的子组解包操作来改善具有分散控制流的内核中的负载平衡(在这种情况下,计算 Mandelbrot 集)。每个工作项被分配一个单独的像素进行计算,并进行迭代,直到达到收敛或最大迭代次数。然后使用解包操作用新像素替换完成的像素。

img/489625_1_En_14_Fig24_HTML.png

图 14-24

使用子组解包操作来改善具有不同控制流的内核的负载平衡

这种方法提高效率(和减少执行时间)的程度高度依赖于应用程序和输入,因为检查完成和执行解包操作都会引入一些开销!因此,在实际的应用程序中成功地使用这种模式将需要基于存在的差异量和正在执行的计算进行一些微调(例如,只有当活动工作项目的数量低于某个阈值时,才引入启发式方法来执行解包操作)。

摘要

本章展示了如何使用 DPC++ 和 SYCL 特性实现一些最常见的并行模式,包括内置函数和库。

SYCL 和 DPC++ 生态系统仍在开发中,随着开发人员从生产级应用程序和库的开发中获得更多的语言经验,我们期望发现这些模式的新的最佳实践。

更多信息

Creative Commons

开放存取本章根据知识共享署名 4.0 国际许可证(http://Creative Commons . org/licenses/by/4.0/)的条款获得许可,该许可证允许以任何媒体或格式使用、共享、改编、分发和复制,只要您适当注明原作者和来源,提供知识共享许可证的链接并指明是否进行了更改。

本章中的图像或其他第三方材料包含在本章的知识共享许可中,除非在材料的信用额度中另有说明。如果材料不包括在本章的知识共享许可中,并且您的预期使用不被法律法规允许或超出了允许的使用范围,您将需要直接从版权所有者处获得许可。

十五、GPU 编程

img/489625_1_En_15_Figa_HTML.gif

在过去的几十年里,图形处理单元(GPU)已经从能够在屏幕上绘制图像的专用硬件设备发展成为能够执行复杂并行内核的通用设备。如今,几乎每台计算机都在传统 CPU 旁边包含一个 GPU,许多程序可以通过将并行算法的一部分从 CPU 卸载到 GPU 来加速。

在本章中,我们将描述典型的 GPU 如何工作,GPU 软件和硬件如何执行 SYCL 应用程序,以及在为 GPU 编写和优化并行内核时需要记住的技巧和技术。

性能警告

与任何处理器类型一样,GPU 因供应商而异,甚至因产品而异;因此,一种设备的最佳实践可能不是另一种设备的最佳实践。本章中的建议可能会让许多 GPU 受益,无论是现在还是将来,但是…

为了实现特定 GPU 的最佳性能,请始终查阅 GPU 供应商的文档!

本章末尾提供了许多 GPU 供应商的文档链接。

GPU 如何工作

本节描述典型的 GPU 如何工作,以及 GPU 与其他类型的加速器有何不同。

GPU 构建模块

图 15-1 显示了一个非常简化的 GPU,由三个高级构建模块组成:

  1. 执行资源:GPU 的执行资源是执行计算工作的处理器。不同的 GPU 供应商对其执行资源使用不同的名称,但所有现代 GPU 都由多个可编程处理器组成。处理器可能是异构的并专门用于特定的任务,或者它们可能是同构的并可互换。大多数现代 GPU 的处理器都是同类且可互换的。

  2. 固定功能:GPU 固定功能是比执行资源更不可编程的硬件单元,专门用于单一任务。当 GPU 用于图形时,图形管道的许多部分(如光栅化或光线跟踪)都是使用固定功能来执行的,以提高能效和性能。当 GPU 用于数据并行计算时,固定函数可以用于诸如工作负荷调度、纹理采样和依赖性跟踪之类的任务。

  3. Caches and memory: Like other processor types , GPUs frequently have caches to store data accessed by the execution resources. GPU caches may be implicit , in which case they require no action from the programmer, or may be explicit scratchpad memories, in which case a programmer must purposefully move data into a cache before using it. Many GPUs also have a large pool of memory to provide fast access to data used by the execution resources.

    img/489625_1_En_15_Fig1_HTML.png

    图 15-1

    典型的 GPU 构建模块—不符合比例!

更简单的处理器(但更多)

传统上,在执行图形操作时,GPU 会处理大量数据。例如,典型的游戏帧或渲染工作负载涉及数千个顶点,每帧产生数百万个像素。为了保持交互式帧速率,必须尽可能快地处理这些大批量数据。

一个典型的 GPU 设计权衡是从构成执行资源的处理器中删除一些功能,以加速单线程性能,并使用这些节省来构建更多的处理器,如图 15-2 所示。例如,GPU 处理器可能不包括由其他类型的处理器使用的复杂无序执行能力或分支预测逻辑。由于这些权衡,单个数据元素在 GPU 上的处理速度可能比在另一个处理器上慢,但更多的处理器使 GPU 能够快速高效地处理许多数据元素。

img/489625_1_En_15_Fig2_HTML.png

图 15-2

GPU 处理器更简单,但数量更多

为了在执行内核时利用这种权衡,给 GPU 足够大的数据元素处理范围是很重要的。为了证明卸载大量数据的重要性,考虑一下我们在本书中一直在开发和修改的矩阵乘法内核。

A REMINDER ABOUT MATRIX MULTIPLICATION

在本书中,矩阵乘法内核用于演示内核的变化或其调度方式如何影响性能。尽管使用本章中描述的技术可以显著提高矩阵乘法的性能,但矩阵乘法是一种非常重要和常见的运算,许多硬件(GPU、CPU、FPGA、DSP 等)都无法使用它。)供应商已经实现了包括矩阵乘法在内的许多例程的高度优化版本。这些厂商投入了大量的时间和精力来实现和验证特定设备的功能,并且在某些情况下可能使用在标准内核中难以或不可能使用的功能或技术。

USE VENDOR-PROVIDED LIBRARIES!

当供应商提供一个函数的库实现时,使用它比将函数重新实现为内核几乎总是有益的!对于矩阵乘法,人们可以将 oneMKL 作为英特尔 oneAPI 工具包的一部分,来寻找适合 DPC++ 程序员的解决方案。

通过将矩阵乘法内核作为单个任务提交到队列中,可以在 GPU 上轻松执行矩阵乘法内核。这个矩阵乘法内核的主体看起来就像一个在主机 CPU 上执行的函数,如图 15-3 所示。

img/489625_1_En_15_Fig3_HTML.png

图 15-3

单任务矩阵乘法看起来很像 CPU 主机代码

如果我们尝试在 CPU 上执行这个内核,它可能会执行得很好——不是很好,因为它不会利用 CPU 的任何并行能力,但对于小矩阵大小来说可能足够好了。如图 15-4 所示,如果我们试图在一个 GPU 上执行这个内核,它的性能可能会非常差,因为单个任务将只使用一个 GPU 处理器。

img/489625_1_En_15_Fig4_HTML.png

图 15-4

GPU 上的单个任务内核会导致许多执行资源闲置

表达平行

为了提高这个内核对于 CPU 和 GPU 的性能,我们可以通过将一个循环转换为一个parallel_for来提交一系列数据元素进行并行处理。对于矩阵乘法内核,我们可以选择提交代表两个最外层循环之一的数据元素范围。在图 15-5 中,我们选择并行处理结果矩阵的行。

img/489625_1_En_15_Fig5_HTML.png

图 15-5

有点并行的矩阵乘法

CHOOSING HOW TO PARALLELIZE

选择哪个维度进行并行化是针对 GPU 和其他设备类型调整应用的一种非常重要的方式。本章的后续部分将描述为什么在一个维度上进行并行化可能比在不同维度上进行并行化性能更好的一些原因。

尽管有些并行的内核与单任务内核非常相似,但它应该在 CPU 上运行得更好,在 GPU 上运行得更好。如图 15-6 所示,parallel_for使代表结果矩阵行的工作项能够在多个处理器资源上并行处理,因此所有执行资源都保持忙碌。

img/489625_1_En_15_Fig6_HTML.png

图 15-6

有些并行的内核会占用更多的处理器资源

请注意,没有指定行被分区和分配到不同处理器资源的确切方式,这为实现提供了选择如何在设备上最好地执行内核的灵活性。例如,实现可以选择在同一处理器上执行连续的行,而不是在一个处理器上执行单独的行,以获得局部性好处。

表达更多并行性

通过选择并行处理两个外部循环,我们可以进一步并行化矩阵乘法内核。因为parallel_for可以表达多达三维的平行循环,这是简单明了的,如图 15-7 所示。在图 15-7 中,注意传递给parallel_for的范围和表示并行执行空间中索引的项目现在都是二维的。

img/489625_1_En_15_Fig7_HTML.png

图 15-7

甚至更多的并行矩阵乘法

当在 GPU 上运行时,展示额外的并行性可能会提高矩阵乘法内核的性能。即使当矩阵行数超过 GPU 处理器数时,这种情况也可能发生。接下来的几节描述了出现这种情况的可能原因。

简化的控制逻辑(SIMD 指令)

许多 GPU 处理器通过利用大多数数据元素倾向于采用相同的控制流路径通过内核来优化控制逻辑。例如,在矩阵乘法内核中,由于循环边界不变,每个数据元素执行最内层循环的次数相同。

当数据元素采用相同的控制流路径通过内核时,处理器可以通过在多个数据元素之间共享控制逻辑并将它们作为一组来执行来降低管理指令流的成本。做到这一点的一种方法是实现一个单指令、多数据SIMD 指令集,其中多个数据元素由一个单指令同时处理。

THREADS VS. INSTRUCTION STREAMS

在许多并行编程环境和 GPU 文献中,术语“线程”用来表示“指令流”在这些环境中,“线程”不同于传统的操作系统线程,并且通常更加轻量级。然而,情况并不总是这样,在某些情况下,“线程”被用来描述完全不同的东西。

由于术语“线程”被过度使用并且容易被误解,本章使用术语“指令流”来代替。

img/489625_1_En_15_Fig8_HTML.png

图 15-8

四宽 SIMD 处理器:四个 alu 共享提取/解码逻辑

一条指令同时处理的数据元素的数量有时被称为该指令或执行该指令的处理器的 SIMD 宽度。在图 15-8 中,四个 alu 共享相同的控制逻辑,因此这可以被描述为一个四宽 SIMD 处理器。

GPU 处理器并不是唯一实现 SIMD 指令集的处理器。其他处理器类型也实现 SIMD 指令集,以提高处理大型数据集时的效率。GPU 处理器与其他处理器类型的主要区别在于,GPU 处理器依靠并行执行多个数据元素来实现良好的性能,并且 GPU 处理器可能比其他处理器类型支持更宽的 SIMD 宽度。例如,GPU 处理器支持 16、32 或更多数据元素的 SIMD 宽度并不少见。

PROGRAMMING MODELS: SPMD AND SIMD

虽然 GPU 处理器实现了不同宽度的 SIMD 指令集,但这通常是一个实现细节,对于在 GPU 处理器上执行数据并行内核的应用程序是透明的。这是因为许多 GPU 编译器和运行时 API 实现了单程序、多数据SPMD 编程模型,其中 GPU 编译器和运行时 API 确定最有效的一组数据元素,以便用 SIMD 指令流进行处理,而不是显式表达 SIMD 指令。第九章的“子组”部分探讨了数据元素分组对应用程序可见的情况。

在图 15-9 中,我们扩大了每个执行资源以支持四宽 SIMD,允许我们并行处理四倍多的矩阵行。

img/489625_1_En_15_Fig9_HTML.png

图 15-9

在 SIMD 处理器上执行某种程度上并行的内核

使用并行处理多个数据元素的 SIMD 指令是图 15-5 和 15-7 中的并行矩阵乘法内核的性能能够超越处理器数量的方式之一。通过在同一处理器上执行连续的数据元素,SIMD 指令的使用在许多情况下还提供了自然的局部性优势,包括矩阵乘法。

内核受益于处理器间的并行和处理器内的并行!

预测和掩蔽

只要所有数据元素通过内核中的条件代码采用相同的路径,在多个数据元素之间共享指令流就能很好地工作。当数据元素通过条件代码采取不同的路径时,控制流被称为分叉。当控制流在 SIMD 指令流中分叉时,通常两条控制流路径都被执行,一些通道被屏蔽或者被断言。这确保了正确的行为,但是正确性是以性能为代价的,因为被屏蔽的通道不执行有用的工作。

为了展示预测和屏蔽是如何工作的,考虑图 15-10 中的内核,它将每个具有“奇数”索引的数据元素乘以 2,并将每个具有“偶数”索引的数据元素递增 1。

img/489625_1_En_15_Fig10_HTML.png

图 15-10

具有发散控制流的内核

假设我们在图 15-8 所示的四宽 SIMD 处理器上执行这个内核,我们在一个 SIMD 指令流中执行前四个数据元素,在另一个 SIMD 指令流中执行接下来的四个数据元素,依此类推。图 15-11 显示了通道可能被屏蔽和执行可能被预测的方法之一,以正确执行这个具有不同控制流的内核。

img/489625_1_En_15_Fig11_HTML.png

图 15-11

发散核的可能通道掩码

SIMD 效率

SIMD 效率衡量 SIMD 指令流与同等标量指令流相比表现如何。在图 15-11 中,由于控制流将通道划分为两个相等的组,因此在分叉控制流中的每个指令都以一半的效率执行。在最坏的情况下,对于高度分散的内核,效率可能会因处理器的 SIMD 宽度而降低。

实现 SIMD 指令集的所有处理器都将遭受影响 SIMD 效率的发散惩罚,但是因为 GPU 处理器通常比其他处理器类型支持更宽的 SIMD 宽度,所以当优化 GPU 的内核时,重构算法以最小化发散控制流并最大化收敛执行可能特别有益。这并不总是可能的,但是作为一个例子,选择沿着一个执行更集中的维度进行并行化可能比沿着一个执行高度分散的不同维度进行并行化性能更好。

SIMD 效率和项目组

到目前为止,本章中的所有内核都是基本的数据并行内核,没有指定执行范围内的任何项目分组,这为设备选择最佳分组提供了实现自由。例如,具有较宽 SIMD 宽度的设备可能偏好较大的分组,但是具有较窄 SIMD 宽度的设备可能适合较小的分组。

当一个内核是具有显式工作项分组的 ND 范围内核时,应该注意选择最大化 SIMD 效率的 ND 范围工作组大小。当一个工作组的大小不能被处理器的 SIMD 宽度整除时,工作组的一部分可能会在整个内核运行期间禁用通道。内核preferred_work_group_size_multiple查询可以用来选择有效的工作组规模。有关如何查询设备属性的更多信息,请参阅第十二章。

选择由单个工作项组成的工作组规模可能会执行得很差,因为许多 GPU 会通过屏蔽除一个通道之外的所有 SIMD 通道来实现单个工作项工作组。例如,图 15-12 中的内核可能会比图 15-5 中非常相似的内核性能差得多,尽管两者之间唯一显著的区别是从基本的数据并行内核转变为低效的单工作项 ND-range 内核(nd_range<1>{M, 1})。

img/489625_1_En_15_Fig12_HTML.png

图 15-12

低效的单项、有点并行的矩阵乘法

切换工作以隐藏延迟

许多 GPU 实现了另一种技术来简化控制逻辑,最大化执行资源,并提高性能:许多 GPU 允许多个指令流同时驻留在处理器上,而不是在处理器上执行单个指令流。

让多个指令流驻留在一个处理器上是有益的,因为它给每个处理器一个执行工作的选择。如果一个指令流正在执行长等待时间的操作,例如从内存中读取,处理器可以切换到另一个准备运行的指令流,而不是等待操作完成。有了足够的指令流,到处理器切换回原始指令流时,长等待时间操作可能已经完成,而根本不需要处理器等待。

图 15-13 显示了处理器如何使用多个同步指令流来隐藏延迟并提高性能。尽管第一个指令流与多个指令流一起执行的时间稍长,但通过切换到其他指令流,处理器能够找到准备好执行的工作,并且永远不需要空闲地等待长时间的操作完成。

img/489625_1_En_15_Fig13_HTML.png

图 15-13

切换指令流以隐藏延迟

GPU 剖析工具可以使用诸如占用率之类的术语来描述 GPU 处理器当前正在执行的指令流的数量与指令流的理论总数。

低占用率并不一定意味着低性能,因为少量的指令流可能会使处理器忙碌。同样,高占用率并不一定意味着高性能,因为如果所有指令流都执行低效、长等待时间的操作,GPU 处理器可能仍然需要等待。在其他条件相同的情况下,增加占用率可以最大限度地提高 GPU 处理器隐藏延迟的能力,通常会提高性能。增加占用率是图 15-7 中使用更多并行内核可以提高性能的另一个原因。

这种在多个指令流之间切换以隐藏延迟的技术特别适合 GPU 和数据并行处理。回想一下图 15-2 中,GPU 处理器通常比其他类型的处理器简单,因此缺乏复杂的延迟隐藏特性。这使得 GPU 处理器更容易受到延迟问题的影响,但由于数据并行编程涉及处理大量数据,GPU 处理器通常有大量的指令流要执行!

将内核卸载到 GPU

本节描述应用程序、SYCL 运行时库和 GPU 软件驱动程序如何协同工作,在 GPU 硬件上卸载内核。图 15-14 中的图表显示了具有这些抽象层的典型软件栈。在许多情况下,这些层的存在对应用程序是透明的,但在调试或分析我们的应用程序时,理解并考虑它们是很重要的。

img/489625_1_En_15_Fig14_HTML.png

图 15-14

将并行内核卸载到 GPU(简化)

SYCL 运行时库

SYCL 运行时库是 SYCL 应用程序与之交互的主要软件库。运行时库负责实现queuesbuffersaccessors等类以及这些类的成员函数。运行时库的一部分可能在头文件中,因此直接编译成应用程序可执行文件。运行时库的其他部分是作为库函数实现的,它们作为应用程序构建过程的一部分与应用程序可执行文件相链接。运行时库通常不是特定于设备的,同一个运行时库可以协调卸载到 CPU、GPU、FPGAs 或其他设备。

GPU 软件驱动程序

虽然从理论上讲,SYCL 运行时库可以直接卸载到 GPU,但实际上,大多数 SYCL 运行时库都与 GPU 软件驱动程序接口,以向 GPU 提交工作。

GPU 软件驱动程序通常是 API 的实现,如 OpenCL、Level Zero 或 CUDA。大多数 GPU 软件驱动程序都是在 SYCL 运行时调用的用户模式驱动程序库中实现的,用户模式驱动程序可能会调用操作系统或内核模式驱动程序来执行系统级任务,如分配内存或向设备提交工作。用户模式驱动程序也可以调用其他用户模式库;例如,GPU 驱动程序可以调用 GPU 编译器将内核从中间表示即时编译成 GPU ISA(指令集架构)。这些软件模块以及它们之间的交互如图 15-15 所示。

img/489625_1_En_15_Fig15_HTML.png

图 15-15

典型的 GPU 软件驱动模块

GPU 硬件

当运行时库或 GPU 软件用户模式驱动程序被明确请求提交工作时,或者当 GPU 软件试探性地确定应该开始工作时,它通常会通过操作系统或内核模式驱动程序调用,以开始在 GPU 上执行工作。在某些情况下,GPU 软件用户模式驱动程序可能会直接向 GPU 提交工作,但这种情况不太常见,可能不是所有设备或操作系统都支持。

当在 GPU 上执行的工作的结果被主机处理器或另一个加速器消耗时,GPU 必须发出信号来指示工作完成。工作完成中涉及的步骤与工作提交的步骤非常相似,只是执行顺序相反:GPU 可能会向操作系统或内核模式驱动程序发出信号,表明它已完成执行,然后用户模式驱动程序将得到通知,最后运行时库将通过 GPU 软件 API 调用观察到工作已完成。

这些步骤中的每一步都会引入延迟,在许多情况下,运行时库和 GPU 软件会在更低的延迟和更高的吞吐量之间进行权衡。例如,更频繁地向 GPU 提交工作可以减少延迟,但是频繁地提交也会由于每次提交的开销而减少吞吐量。收集大量工作会增加延迟,但会将提交开销分摊到更多工作上,并为并行执行带来更多机会。运行时和驱动程序被调整以做出正确的权衡,并且通常做得很好,但是如果我们怀疑驱动程序试探法低效地提交工作,我们应该查阅文档,看看是否有方法使用特定于 API 甚至特定于实现的机制来覆盖默认的驱动程序行为。

当心卸货的成本!

尽管 SYCL 实现和 GPU 供应商在不断创新和优化,以降低将工作卸载到 GPU 的成本,但在 GPU 上开始工作和在主机或其他设备上观察结果时,总会涉及开销。当选择在何处执行算法时,既要考虑在设备上执行算法的好处,也要考虑将算法及其所需的任何数据移动到设备的成本。在某些情况下,使用主机处理器执行并行操作可能是最有效的,或者在 GPU 上低效地执行算法的串行部分,以避免将算法从一个处理器移动到另一个处理器的开销。

从整体上考虑我们算法的性能——在一个设备上低效地执行算法的一部分可能比将执行转移到另一个设备上更有效!

与设备内存之间的传输

在具有专用内存的 GPU 上,要特别注意专用 GPU 内存和主机或其他设备上的内存之间的传输成本。图 15-16 显示了系统中不同内存类型之间的典型内存带宽差异。

img/489625_1_En_15_Fig16_HTML.png

图 15-16

设备内存、远程内存和主机内存之间的典型差异

回想一下第三章,GPU 更喜欢在专用设备内存上运行,这可以快一个数量级或更多,而不是在主机内存或另一个设备的内存上运行。尽管访问专用设备内存比访问远程内存或系统内存快得多,但如果数据不在专用设备内存中,则必须对其进行复制或迁移。

只要数据将被频繁访问,将它移动到专用设备内存是有益的,特别是当 GPU 执行资源忙于处理另一个任务时,传输可以异步执行。当数据很少或不可预测地被访问时,即使每次访问的成本更高,也可以节省传输成本并远程或在系统内存中操作数据。第六章描述了控制内存分配的方法,以及将数据复制和预取到专用设备内存的不同技术。这些技术在为 GPU 优化程序执行时非常重要。

GPU 内核最佳实践

前面几节描述了传递给parallel_for的分派参数如何影响内核如何分配给 GPU 处理器资源,以及在 GPU 上执行内核所涉及的软件层和开销。本节描述了内核在 GPU 上执行时的最佳实践。

从广义上讲,内核要么是受内存限制的,这意味着它们的性能受到进出 GPU 上执行资源的数据读写操作的限制,要么是受计算限制的,这意味着它们的性能受到 GPU 上执行资源的限制。为 GPU 和许多其他处理器优化内核的良好开端!—确定我们的内核是内存受限还是计算受限,因为改善内存受限内核的技术通常不会使计算受限内核受益,反之亦然。GPU 供应商通常提供分析工具来帮助做出这一决定。

根据我们的内核是内存受限还是计算受限,需要不同的优化技术!

因为 GPU 倾向于拥有许多处理器和较宽的 SIMD 宽度,内核倾向于更多地受到内存的限制,而不是计算的限制。如果我们不确定从哪里开始,检查我们的内核如何访问内存是一个很好的第一步。

访问全局内存

高效地访问全局内存对于优化应用程序性能至关重要,因为工作项或工作组操作的几乎所有数据都源自全局内存。如果内核对全局内存的操作效率很低,那么它的性能几乎总是很差。尽管 GPU 通常包括专用硬件收集分散单元,用于读取和写入内存中的任意位置,但对全局内存的访问性能通常由数据访问的位置决定。如果工作组中的一个工作项目正在访问存储器中的一个元素,该元素与工作组中的另一个工作项目所访问的元素相邻,则全局存储器访问性能可能是好的。如果一个工作组中的工作项改为访问步进或随机的内存,则全局内存访问性能可能会更差。一些 GPU 文档将对邻近内存访问的操作描述为合并内存访问。

回想一下,对于我们在图 15-15 ,中有些并行的矩阵乘法内核,我们可以选择是并行处理结果矩阵的一行还是一列,我们选择并行处理结果矩阵的行。这被证明是一个糟糕的选择:如果一个id等于m的工作项与一个 id 等于m-1m+1的相邻工作项被分组,那么用于访问matrixB的索引对于每个工作项都是相同的,但是用于访问matrixA的索引相差K,这意味着访问是高度跨越的。matrixA的访问模式如图 15-17 所示。

img/489625_1_En_15_Fig17_HTML.png

图 15-17

matrixA的访问速度很快,效率很低

相反,如果我们选择并行处理结果矩阵的列,则访问模式具有更好的局部性。图 15-18 中的内核在结构上与图 15-5 中的内核非常相似,唯一的区别是图 15-18 中的每个工作项操作结果矩阵的一列,而不是结果矩阵的一行。

img/489625_1_En_15_Fig18_HTML.png

图 15-18

并行计算结果矩阵的列,而不是行

尽管这两个内核在结构上非常相似,但在许多 GPU 上操作数据列的内核将明显优于操作数据行的内核,这纯粹是因为更高效的内存访问:如果一个 id 等于n的工作项与一个 id 等于n-1n+1的相邻工作项分组,则每个工作项用于访问matrixA的索引现在是相同的,并且用于访问matrixB的索引是连续的。matrixB的访问模式如图 15-19 所示。

img/489625_1_En_15_Fig19_HTML.png

图 15-19

matrixB的访问是连续且高效的

对连续数据的访问通常非常高效。一个很好的经验法则是,一组工作项对全局内存的访问性能是被访问的 GPU 缓存线数量的函数。如果所有访问都在单个高速缓存行内,则访问将以最高性能执行。如果访问需要两个高速缓存行,比如说通过访问每隔一个元素或者从高速缓存未对齐的地址开始,则访问可能以一半的性能运行。当组中的每个工作项目访问一个唯一的高速缓存行时,比方说对于非常快速或随机的访问,该访问可能以最低的性能运行。

PROFILING KERNEL VARIANTS

对于矩阵乘法,选择沿一维并行显然会导致更高效的内存访问,但对于其他内核,选择可能不那么明显。对于实现最佳性能至关重要的内核,如果不清楚要并行化哪个维度,有时有必要开发和分析沿每个维度并行化的不同内核变体,以了解哪种内核更适合设备和数据集。

访问工作组本地内存

在上一节中,我们描述了对全局内存的访问如何受益于位置,从而最大化缓存性能。正如我们所看到的,在某些情况下,我们可以设计我们的算法来有效地访问内存,例如通过选择在一个维度而不是另一个维度进行并行化。然而,这种技术并不是在所有情况下都可行。本节描述了我们如何使用工作组本地内存来有效地支持更多的内存访问模式。

回想一下第九章,工作组中的工作项可以通过工作组本地内存通信和使用工作组屏障同步来合作解决问题。这种技术对 GPU 尤其有益,因为典型的 GPU 都有专门的硬件来实现屏障和工作组本地内存。不同的 GPU 供应商和不同的产品可能会以不同的方式实现工作组本地内存,但与全局内存相比,工作组本地内存通常有两个好处:本地内存可能支持比访问全局内存更高的带宽和更低的延迟,即使当全局内存访问命中缓存时也是如此,并且本地内存通常被分成不同的内存区域,称为。只要一个组中的每个工作项目访问不同的存储体,本地存储器访问就以全性能执行。分库访问允许本地内存支持比全局内存多得多的具有最高性能的访问模式。

许多 GPU 厂商会将连续的本地内存地址分配给不同的存储体。这确保了连续的存储器访问总是以全性能运行,而不管起始地址如何。然而,当存储器访问被跨越时,一个组中的一些工作项目可能访问分配给同一存储体的存储器地址。当这种情况发生时,它被认为是一个存储体冲突,并导致串行访问和较低的性能。

为了获得最高的全局内存性能,请尽量减少访问的缓存线数量。

为了获得最大的本地内存性能,请尽量减少存储体冲突的数量!

图 15-20 总结了全局存储器和本地存储器的访问模式和预期性能。假设当ptr指向全局内存时,指针与 GPU 缓存行的大小对齐。从缓存对齐的地址开始连续访问内存,可以获得访问全局内存的最佳性能。访问未对齐的地址可能会降低全局内存性能,因为访问可能需要访问额外的高速缓存行。因为访问未对齐的本地地址不会导致额外的存储体冲突,所以本地存储器性能不会改变。

跨越的情况值得更详细地描述。访问全局内存中的所有其他元素需要访问更多的缓存行,这可能会降低性能。访问本地内存中的所有其他元素可能会导致内存块冲突和性能下降,但前提是内存块的数量能被 2 整除。如果银行的数量是奇数,这种情况下也将满负荷运行。

当访问之间的跨度很大时,每个工作项访问一个唯一的缓存行,从而导致最差的性能。然而对于本地存储器,性能取决于步幅和存储体的数量。当跨距N等于存储体数量时,每次访问都会导致存储体冲突,所有访问都是串行的,导致性能最差。然而,如果步幅M和存储体的数量没有共同因素,则访问将以全性能运行。出于这个原因,许多优化的 GPU 内核将在本地内存中填充数据结构,以选择减少或消除存储体冲突的步长。

img/489625_1_En_15_Fig20_HTML.png

图 15-20

不同访问模式、全局和本地内存的可能性能

用子组完全避免本地存储

正如在第九章中所讨论的,子组集合函数是一种在组中的工作项之间交换数据的替代方法。对于许多 GPU 来说,子组代表由单个指令流处理的工作项的集合。在这些情况下,子组中的工作项可以在不使用工作组本地内存的情况下廉价地交换数据和同步。许多性能最好的 GPU 内核使用子组,因此对于昂贵的内核,我们的算法是否可以重新制定以使用子组集合函数是非常值得研究的。

使用小数据类型优化计算

本节描述了在消除或减少内存访问瓶颈后优化内核的技术。要记住的一个非常重要的观点是,GPU 传统上被设计为在屏幕上绘制图片。尽管随着时间的推移,GPU 的纯计算能力已经得到了发展和提高,但在某些领域,它们的图形继承仍然显而易见。

例如,考虑对内核数据类型的支持。许多 GPU 针对 32 位浮点运算进行了高度优化,因为这些运算在图形和游戏中很常见。对于可以处理较低精度的算法,许多 GPU 也支持较低精度的 16 位浮点类型,以精度换取更快的处理速度。相反,尽管许多 GPU 支持 64 位双精度浮点运算,但额外的精度是有代价的,32 位运算的性能通常比 64 位运算好得多。

整数数据类型也是如此,32 位整数数据类型的性能通常比 64 位整数数据类型好,16 位整数的性能甚至可能更好。如果我们可以使用更小的整数来构建我们的计算,我们的内核可能会执行得更快。需要特别注意的一个方面是寻址操作,它通常对 64 位size_t数据类型进行操作,但有时可以重新安排使用 32 位数据类型来执行大多数计算。在某些本地内存情况下,16 位索引就足够了,因为大多数本地内存分配都很小。

优化数学函数

另一个内核可能为了性能而牺牲准确性的领域涉及 SYCL 内置函数。SYCL 包括一组丰富的数学函数,在一系列输入中具有明确的精度。大多数 GPU 本身不支持这些功能,而是使用一长串其他指令来实现它们。虽然数学函数的实现通常针对 GPU 进行了很好的优化,但是如果我们的应用程序可以容忍较低的精度,我们应该考虑一种精度较低、性能较高的不同实现。有关 SYCL 内置函数的更多信息,请参见第十八章。

对于常用的数学函数,SYCL 库包括fastnative函数变量,具有降低的或实现定义的精度要求。对于一些 GPU 来说,这些函数可以比它们精确的对等函数快一个数量级,所以如果它们对算法来说有足够的精度,那么它们是非常值得考虑的。例如,许多图像后处理算法具有定义明确的输入,可以容忍较低的精度,因此非常适合使用fastnative数学函数。

如果一个算法可以容忍较低的精度,我们可以使用较小的数据类型或较低精度的数学函数来提高性能!

专用功能和扩展

为 GPU 优化内核的最后一个考虑是许多 GPU 中常见的专用指令。举个例子,几乎所有的 GPU 都支持在单个时钟内执行两个操作的madfma乘加指令。GPU 编译器通常非常擅长识别和优化单个乘法和加法,以使用单个指令来代替,但 SYCL 也包括可以显式调用的madfma函数。当然,如果我们希望我们的 GPU 编译器为我们优化乘法和加法,我们应该确保我们不会通过禁用浮点收缩来阻止优化!

其他专用 GPU 指令可能只能通过编译器优化或 SYCL 语言扩展来获得。例如,一些 GPU 支持专门的点积累加指令,编译器会尝试识别并优化这些指令,或者直接调用这些指令。有关如何查询 GPU 实现所支持的扩展的更多信息,请参考第十二章。

摘要

在这一章中,我们首先描述了典型的 GPU 是如何工作的,以及 GPU 与传统 CPU 有何不同。我们描述了 GPU 如何针对大量数据进行优化,方法是用处理器特性来加速额外处理器的单个指令流。

我们描述了 GPU 如何使用宽 SIMD 指令并行处理多个数据元素,以及 GPU 如何使用 SIMD 指令使用预测和屏蔽来执行具有复杂流控制的内核。我们讨论了预测和屏蔽如何降低高度发散的内核的 SIMD 效率和性能,以及如何选择沿一个维度与另一个维度并行化可以减少 SIMD 发散。

由于 GPU 有如此多的处理资源,我们讨论了给予 GPU 足够的工作以保持高占用率是多么重要。我们还描述了 GPU 如何使用指令流来隐藏延迟,这使得让 GPU 执行大量工作变得更加重要。

接下来,我们讨论了将内核卸载到 GPU 所涉及的软件和硬件层,以及卸载的成本。我们讨论了在单个设备上执行算法如何比将执行从一个设备转移到另一个设备更有效。

最后,我们描述了内核在 GPU 上执行时的最佳实践。我们描述了有多少内核从内存限制开始,以及如何有效地访问全局内存和本地内存,或者如何通过使用子组操作来完全避免本地内存。相反,当内核受计算限制时,我们描述了如何通过用较低的精度换取较高的性能或使用定制的 GPU 扩展来访问专门的指令来优化计算。

更多信息

关于 GPU 编程还有很多要学的,这一章只是触及了皮毛!

GPU 规格和白皮书是了解特定 GPU 和 GPU 架构更多信息的绝佳途径。许多 GPU 供应商提供了关于他们的 GPU 以及如何编程的非常详细的信息。

在撰写本文时,可以在软件上找到关于主要 GPU 的相关阅读资料。英特尔。comdevblogs。英伟达。com ,以及 amd。com

有些 GPU 厂商有开源驱动或驱动组件。如果可能的话,检查或单步执行驱动程序代码可能会有所帮助,从而了解应用程序中哪些操作是昂贵的,或者哪里可能存在开销。

本章完全专注于通过缓冲存取器或统一共享内存对全局内存的传统访问,但大多数 GPU 也包括一个固定功能的纹理采样器,可以加速图像操作。有关图像和采样器的更多信息,请参考 SYCL 规范。

Creative Commons

开放存取本章根据知识共享署名 4.0 国际许可证(http://Creative Commons . org/licenses/by/4.0/)的条款获得许可,该许可证允许以任何媒体或格式使用、共享、改编、分发和复制,只要您适当注明原作者和来源,提供知识共享许可证的链接并指明是否进行了更改。

本章中的图像或其他第三方材料包含在本章的知识共享许可中,除非在材料的信用额度中另有说明。如果材料不包括在本章的知识共享许可中,并且您的预期使用不被法律法规允许或超出了允许的使用范围,您将需要直接从版权所有者处获得许可。

十六、CPU 编程

img/489625_1_En_16_Figa_HTML.gif

内核编程最初流行是作为一种对 GPU 编程的方式。随着内核编程的推广,理解我们的编程风格如何影响代码到 CPU 的映射是很重要的。

CPU 已经发展了很多年。一个主要的转变发生在 2005 年左右,当时提高时钟速度带来的性能收益减少了。并行性成为最受欢迎的解决方案——CPU 生产商没有提高时钟速度,而是引入了多核芯片。计算机在同时执行多项任务时变得更加有效!

虽然多核成为提高硬件性能的主流途径,但在软件中释放这种优势需要付出巨大的努力。多核处理器要求开发人员提出不同的算法,这样硬件的改进才能引人注目,而这并不总是容易的。我们拥有的内核越多,就越难让它们高效地工作。DPC++ 是解决这些挑战的编程语言之一,它具有许多有助于利用 CPU(和其他架构)上各种形式的并行性的构造。

本章讨论了 CPU 架构的一些细节,CPU 硬件如何执行 DPC++ 应用程序,并提供了为 CPU 平台编写 DPC++ 代码的最佳实践。

性能警告

DPC++ 为并行化我们的应用程序或从头开始开发并行应用程序铺平了道路。当在 CPU 上运行时,程序的应用程序性能在很大程度上取决于以下因素:

  • 内核代码的单次调用和执行的底层性能

  • 在并行内核中运行的程序的百分比及其可伸缩性

  • CPU 利用率、有效的数据共享、数据局部性和负载平衡

  • 工作项之间的同步和通信量

  • 创建、恢复、管理、挂起、销毁和同步执行工作项的线程所带来的开销,串行到并行或并行到串行转换的数量会使这种开销变得更大

  • 共享内存或错误共享内存导致的内存冲突

  • 共享资源(如内存、写组合缓冲区和内存带宽)的性能限制

此外,与任何处理器类型一样,CPU 可能因供应商而异,甚至因产品的不同而不同。一个 CPU 的最佳实践可能不是另一个 CPU 和配置的最佳实践。

要在 CPU 上实现最佳性能,请尽可能多地了解 CPU 架构的特征!

通用 CPU 的基础知识

多核 CPU 的出现和快速发展推动了共享内存并行计算平台的广泛接受。CPU 提供了笔记本电脑、台式机和服务器级别的并行计算平台,使它们无处不在,并在几乎任何地方展示性能。最常见的 CPU 架构形式是缓存一致的非一致内存访问(cc-NUMA),其特点是访问时间不完全一致。甚至很多小型双插槽通用 CPU 系统都有这种内存系统。这种架构已经成为主流,因为处理器中内核的数量以及插槽的数量都在不断增加。

在 cc-NUMA CPU 系统中,每个插槽都连接到系统中总内存的一个子集。缓存一致的互连将所有的套接字粘合在一起,并为程序员提供单一的系统视图。这种存储器系统是可扩展的,因为总的存储器带宽随着系统中插座的数量而扩展。互连的好处是应用程序可以透明地访问系统中的所有内存,而不管数据驻留在哪里。然而,这是有代价的:从存储器访问数据和指令的等待时间不再一致(例如,固定的访问等待时间)。相反,延迟取决于数据在系统中的存储位置。在一个好的例子中,数据来自直接连接到代码运行的套接字的内存。在糟糕的情况下,数据必须来自连接到系统中远处插槽的内存,并且由于 cc-NUMA CPU 系统上插槽之间互连的跳数,内存访问的成本可能会增加。

在图 16-1 中,显示了带有 cc-NUMA 存储器的通用 CPU 架构。这是一个简化的系统架构,包含当今通用多插槽系统中的内核和存储器组件。在本章的其余部分,该图将用于说明相应代码示例的映射。

为了实现最佳性能,我们需要确保理解特定系统的 cc-NUMA 配置的特征。例如,英特尔最近推出的服务器就采用了网状互连架构。在这种配置中,内核、高速缓存和内存控制器被组织成行和列。在努力实现系统的最高性能时,了解处理器与内存的连接是至关重要的。

img/489625_1_En_16_Fig1_HTML.png

图 16-1

通用多核 CPU 系统

图 16-1 中的系统有两个插槽,每个插槽有两个内核,每个内核有四个硬件线程。每个内核都有自己的一级(l 1)高速缓存。L1 缓存连接到共享的末级缓存,末级缓存连接到套接字上的内存系统。套接字内的内存访问延迟是一致的,这意味着它是一致的,并且可以准确预测。

这两个套接字通过缓存一致的互连进行连接。内存分布在整个系统中,但是可以从系统中的任何地方透明地访问所有内存。当访问不在进行访问的代码正在运行的套接字中的内存时,内存读写延迟是不一致的,这意味着当从远程套接字访问数据时,它可能会施加更长且不一致的延迟。然而,互连的一个关键方面是一致性。我们不需要担心数据在整个内存系统中变得不一致(这将是一个功能问题),相反,我们只需要担心我们如何访问分布式内存系统对性能的影响。

CPU 中的硬件线程是执行工具。这些是执行指令流(CPU 术语中的线程)的单元。图 16-1 中的硬件线程从 0 到 15 连续编号,这是用于简化本章示例讨论的符号。除非另有说明,本章中所有提及的 CPU 系统都是指图 16-1 中所示的 cc-NUMA 系统。

SIMD 硬件基础

1996 年,第一个广泛部署的 SIMD(根据 Flynn 的分类,单指令,多数据)指令集是 x86 架构上的 MMX 扩展。此后,许多 SIMD 指令集扩展在英特尔架构和更广泛的行业中得到应用。CPU 核心通过执行指令来执行其工作,并且核心知道如何执行的特定指令由其实现的指令集(例如,x86、x86_64、AltiVec、NEON)和指令集扩展(例如,SSE、AVX、AVX-512)来定义。指令集扩展增加的许多操作都集中在 SIMD 指令上。

SIMD 指令通过使用比正在处理的基本数据单元更大的寄存器和硬件,允许在单个内核上同时执行多个计算。使用 512 位寄存器,我们可以用一条机器指令执行八次 64 位计算。

图 16-2 中所示的例子可以给我们带来八倍的速度提升。实际上,它可能会有所缩减,因为八倍加速的一部分用于消除一个瓶颈并暴露下一个瓶颈,如内存吞吐量。一般来说,使用 SIMD 的性能优势因具体场景而异,在少数情况下,它的性能甚至比更简单的非 SIMD 等效代码还要差。也就是说,当我们知道何时以及如何应用(或者让编译器应用)SIMD 时,在今天的处理器上可以获得相当大的收益。与所有性能优化一样,程序员应该在将一台典型的目标机器投入生产之前测量它的增益。在本章的以下部分中有关于预期性能增益的更多细节。

img/489625_1_En_16_Fig2_HTML.png

图 16-2

CPU 硬件线程中的 SIMD 执行

带有 SIMD 单元的 cc-NUMA CPU 架构构成了多核处理器的基础,该处理器可以从指令级并行开始,以五种不同的方式利用广泛的并行,如图 16-3 所示。

img/489625_1_En_16_Fig3_HTML.png

图 16-3

并行执行指令的五种方式

在图 16-3 中,指令级并行可以通过单个线程内标量指令的无序执行和 SIMD(单指令,多数据)数据并行来实现。线程级并行可以通过在同一内核或不同规模的多个内核上执行多个线程来实现。更具体地说,线程级并行性可以从以下方面得到体现:

  • 现代 CPU 架构允许一个内核同时执行两个或多个线程的指令。

  • 在每个处理器中包含两个或更多大脑的多核架构。操作系统将其每个执行核心视为一个独立的处理器,拥有所有相关的执行资源。

  • 处理器(芯片)级的多重处理,可以通过执行完全独立的代码线程来实现。因此,处理器可以让一个线程从一个应用程序运行,另一个线程从一个操作系统运行,也可以让多个并行线程从一个应用程序中运行。

  • 分布式处理,可以通过在计算机集群上执行由多个线程组成的进程来实现,这些进程通常通过消息传递框架进行通信。

为了充分利用多核处理器资源,编写软件时必须将其工作负载分散到多个内核中。这种方法被称为利用线程级并行或简单的线程

随着多处理器计算机以及采用超线程(HT)技术和多核技术的处理器变得越来越普遍,将并行处理技术作为提高性能的标准实践变得非常重要。本章的后面几节将介绍 DPC++ 中的编码方法和性能调整技术,它们使我们能够在多核 CPU 上实现最高性能。

像其他并行处理硬件(例如,GPU)一样,为 CPU 提供足够大的数据元素集进行处理非常重要。为了展示利用多级并行处理处理大量数据的重要性,考虑一个简单的 C++ 流三元组程序,如图 16-4 所示。

img/489625_1_En_16_Fig4_HTML.png

图 16-4

流三元组 C++ 循环

A NOTE ABOUT STREAM TRIAD WORKLOAD

流三元组工作负载( www.cs.virginia.edu/stream )是一种重要且流行的基准测试工作负载,CPU 供应商使用它来展示高度调优的性能。我们使用 STREAM Triad 内核来演示并行内核的代码生成,以及通过本章描述的技术来显著提高性能的方法。STREAM Triad 是一个相对简单的工作负载,但足以以一种可理解的方式展示许多优化。

USE VENDOR-PROVIDED LIBRARIES!

当供应商提供一个函数的库实现时,使用它比将函数重新实现为并行内核更有益!

流三元组循环可以在使用单个 CPU 核心进行串行执行的 CPU 上被平凡地执行。一个好的 C++ 编译器会执行循环矢量化,为具有 SIMD 硬件的 CPU 生成 SIMD 代码,以利用指令级 SIMD 并行性。例如,对于支持 AVX-512 的英特尔至强处理器,英特尔 C++ 编译器生成如图 16-5 所示的 SIMD 代码。重要的是,编译器对代码的转换通过在运行时对每个循环迭代做更多的工作(SIMD 宽度和展开迭代),减少了执行时的循环迭代次数!

img/489625_1_En_16_Fig5_HTML.png

图 16-5

流三元组 C++ 循环的 AVX-512 代码

如图 16-5 所示,编译器能够以两种方式利用指令级并行性。首先是通过使用 SIMD 指令,利用指令级数据并行性,其中一条指令可以同时并行处理八个双精度数据元素(每条指令)。第二,基于硬件多路指令调度,编译器应用循环展开来获得这些指令之间没有依赖关系的乱序执行效果。

如果我们尝试在 CPU 上执行这个函数,它可能会运行得很好——虽然不是很好,因为它没有利用 CPU 的任何多核或线程功能,但对于小型阵列来说已经足够好了。但是,如果我们试图在一个 CPU 上用一个大的数组来执行这个函数,它的性能可能会很差,因为单线程将只利用一个 CPU 内核,当它达到该内核的内存带宽饱和时就会出现瓶颈。

利用线程级并行

为了提高 CPU 和 GPU 的 STREAM Triad 内核的性能,我们可以通过将循环转换为parallel_for内核来计算一系列可以并行处理的数据元素。

通过将流三元组内核提交到队列中进行并行执行,可以在 CPU 上轻松地执行流三元组内核。这个 STREAM Triad DPC++ 并行内核的主体看起来与在 CPU 上以串行 C++ 执行的 STREAM Triad 循环的主体完全一样,如图 16-6 所示。

img/489625_1_En_16_Fig6_HTML.png

图 16-6

DPC++ 流三元组parallel_for内核代码

尽管并行内核非常类似于编写为带循环的串行 C++ 的 STREAM Triad 函数,但它在 CPU 上运行得更快,因为parallel_for使阵列的不同元素能够在多个内核上并行处理。如图 16-7 所示,假设我们有一个系统,有一个插槽,四个内核,每个内核有两个超线程;有 1024 个双精度数据元素需要处理;并且在实现中,在每个包含 32 个数据元素的工作组中处理数据。这意味着我们有 8 个线程和 32 个工作组。工作组调度可以按循环顺序进行,即线程 id = 工作组 id mod 8。本质上,每个线程将执行四个工作组。每轮可以并行执行八个工作组。注意,在这种情况下,工作组是由 DPC++ 编译器和运行时隐式形成的一组工作项。

img/489625_1_En_16_Fig7_HTML.png

图 16-7

流三元组并行核的映射

请注意,在 DPC++ 程序中,不需要指定将数据元素分区并分配给不同处理器内核(或超线程)的确切方式。这为 DPC++ 实现提供了选择如何在特定 CPU 上最好地执行并行内核的灵活性。也就是说,一个实现可以为程序员提供某种程度的控制来实现性能调优。

虽然 CPU 可能会强加相对较高的线程上下文切换和同步开销,但是在处理器内核上驻留更多的软件线程是有益的,因为这为每个处理器内核提供了执行工作的选择。如果一个软件线程正在等待另一个线程产生数据,则处理器内核可以切换到准备运行的不同软件线程,而不会使处理器内核空闲。

CHOOSING HOW TO BIND AND SCHEDULE THREADS

选择一个有效的方案来在线程之间划分和调度工作,对于在 CPU 和其他设备类型上调优应用程序非常重要。后续部分将描述一些技术。

线程相似性洞察

线程关联性指定特定线程在其上执行的 CPU 核心。如果线程在内核之间移动,性能会受到影响,例如,如果线程不在同一个内核上执行,如果数据在不同内核之间来回切换,缓存局部性会变得低效。

DPC++ 运行时库支持通过环境变量 DPCPP_CPU_CU_AFFINITY、DPCPP_CPU_PLACES、DPCPP_CPU_NUM_CUS 和 DPCPP_CPU_SCHEDULE 将线程绑定到核心的几种方案,这些变量不是由 SYCL 定义的。

第一个是环境变量DPCPP_CPU_CU_AFFINITY。使用这些环境变量控件进行调优既简单又成本低廉,并且可以对许多应用程序产生巨大影响。该环境变量的描述如图 16-8 所示。

img/489625_1_En_16_Fig8_HTML.png

图 16-8

DPCPP_CPU_CU_AFFINITY环境变量

当环境变量DPCPP_CPU_CU_AFFINITY被指定时,软件线程通过以下公式绑定到超线程:

$$ sprea: boundHT=\left( tid;\mathit{\operatorname{mod}}; numHT\right)+\left( tid;\mathit{\operatorname{mod}}; numSocket\right)\times numHT $$

$$ close: boundHT= tid;\mathit{\operatorname{mod}};\left( numSocket\times numHT\right) $$

在哪里

  • tid表示软件线程标识符。

  • boundHT表示线程tid绑定到的超级线程(逻辑核心)。

  • numHT表示每个套接字的超线程数量。

  • numSocket表示系统中插座的数量。

假设我们在双核双插槽超线程系统上运行一个具有八个线程的程序,换句话说,我们有四个内核,总共有八个超线程可供编程。图 16-9 显示了线程如何映射到不同DPCPP_CPU_CU_AFFINITY设置的超线程和内核的示例。

img/489625_1_En_16_Fig9_HTML.png

图 16-9

使用超线程将线程映射到内核

与环境变量DPCPP_CPU_CU_AFFINITY一起,还有其他支持 CPU 性能调整的环境变量:

  • DPC PP _ CPU _ NUM _ CUS=[n],设置内核执行使用的线程数量。它的默认值是系统中硬件线程的数量。

  • DPC PP _ CPU _ PLACES=[sockets|numa_domains|cores|threads],类似于 OpenMP 5.1 中的OMP_PLACES指定了将要设置亲缘关系的位置。默认设置为cores

  • DPC PP _ CPU _ SCHEDULE=[dynamic|affinity|static],指定了调度工作组的算法。其默认设置为dynamic

    • 动态:启用 TBB auto_partitioner,它通常执行足够的拆分来平衡工作线程之间的负载。

    • 关联:启用 TBB affinity_partitioner,这提高了缓存关联,并在将子范围映射到工作线程时使用比例分割。

    • 静态:启用 TBB static_partitioner,它尽可能均匀地在工作线程之间分配迭代。

TBB 划分器使用粒度来控制工作划分,默认粒度为 1,表示所有的工作组都可以独立执行。更多信息可在规格中找到。oneapi。com/versions/latest/elements/one TBB/source/algorithms。html #分区器

缺少线程关联性调优并不一定意味着性能降低。性能通常更多地取决于并行执行的线程总数,而不是线程和数据的关联和绑定程度。使用基准测试应用程序是确定线程关联性是否会影响性能的一种方式。如图 16-1 所示的 DPC++ STREAM Triad 代码,在没有线程关联设置的情况下,开始时性能较低。通过控制亲缘性设置和使用通过环境变量的软件线程的静态调度(对于 Linux 在下面显示了导出),性能得到了提高:


export DPCPP_CPU_PLACES=numa_domains
export DPCPP_CPU_CU_AFFINITY=close

通过使用numa_domains作为亲缘关系的位置设置,TBB 任务竞技场被绑定到 NUMA 节点或套接字,并且工作被均匀地分布在任务竞技场上。一般情况下,环境变量DPCPP_CPU_PLACES推荐与DPCPP_CPU_CU_AFFINITY一起使用。这些环境变量设置帮助我们在 Skylake 服务器系统上实现了约 30%的性能提升,该系统具有 2 个插槽,每个插槽有 28 个双向超线程内核,运行频率为 2.5 GHz。但是,我们仍然可以做得更好,以进一步提高这台 CPU 的性能。

注意记忆的第一次接触

内存存储在第一次接触(使用)的地方。由于我们示例中的初始化循环没有并行化,它由主机线程串行执行,导致所有内存都与主机线程运行所在的套接字相关联。其他套接字的后续访问将从附加到初始套接字(用于初始化)的内存中访问数据,这显然对性能不利。我们可以通过并行化初始化循环来控制套接字之间的首次接触效应,从而在 STREAM Triad 内核上实现更高的性能,如图 16-10 所示。

img/489625_1_En_16_Fig10_HTML.png

图 16-10

控制首次触摸效果的流三元组并行初始化内核

利用初始化代码中的并行性可以提高内核在 CPU 上运行时的性能。在这种情况下,我们在英特尔至强处理器系统上实现了大约 2 倍的性能提升。

本章最近的章节已经表明,通过开发线程级并行,我们可以有效地利用 CPU 内核和超线程。然而,我们还需要在 CPU 核心硬件中利用 SIMD 矢量级并行,以实现最高性能。

DPC++ 并行内核受益于内核和超线程的线程级并行性!

CPU 上的 SIMD 向量化

虽然一个没有交叉工作项依赖的编写良好的 DPC++ 内核可以在 CPU 上有效地并行运行,但我们也可以将矢量化应用到 DPC++ 内核,以利用 SIMD 硬件,类似于第十五章中描述的 GPU 支持。本质上,CPU 处理器可以通过利用大多数数据元素通常在连续的存储器中并且通过数据并行内核采取相同的控制流路径的事实,使用 SIMD 指令来优化存储器加载、存储和操作。例如,在具有语句a[i] = a[i] + b[i]的内核中,通过在多个数据元素之间共享硬件逻辑并将它们作为一组来执行,每个数据元素以相同的指令流加载加载添加存储来执行,这可以自然地映射到硬件的 SIMD 指令集。具体来说,一条指令可以同时处理多个数据元素。

由一条指令同时处理的数据元素的数量有时被称为指令或执行它的处理器的向量长度(或 SIMD 宽度)。在图 16-11 中,我们的指令流以四路 SIMD 执行方式运行。

img/489625_1_En_16_Fig11_HTML.png

图 16-11

SIMD 执行的指令流

CPU 处理器不是唯一实现 SIMD 指令集的处理器。GPU 等其他处理器在处理大型数据集时会执行 SIMD 指令来提高效率。与其他类型的处理器相比,英特尔至强 CPU 处理器的一个关键区别在于它拥有三个固定大小的 SIMD 寄存器宽度 128 位 XMM、256 位 YMM 和 512 位 ZMM,而不是可变长度的 SIMD 宽度。当我们使用子组或向量类型编写具有 SIMD 并行的 DPC++ 代码时,我们需要注意硬件中的 SIMD 宽度和 SIMD 向量寄存器的数量。

确保 SIMD 执行的合法性

语义上,DPC++ 执行模型确保 SIMD 执行可以应用于任何内核,并且每个工作组(即子组)中的一组工作项目可以使用 SIMD 指令并发执行。一些实现可能改为选择使用 SIMD 指令在内核中执行循环,但是当且仅当所有原始数据依赖性被保留,或者数据依赖性被编译器基于私有化和归约语义解决时,这才是可能的。

使用工作组内的 SIMD 指令,单个 DPC++ 内核执行可以从单个工作项的处理转换为一组工作项。在 ND-range 模型下,增长最快的(单位步长)维由编译器矢量器选择,在其上生成 SIMD 代码。本质上,要在给定 ND-range 的情况下启用矢量化,同一子组中的任何两个工作项之间都不应该有跨工作项依赖,或者编译器需要保留同一子组中的跨工作项向前依赖。

当工作项的内核执行被映射到 CPU 上的线程时,细粒度的同步代价很高,线程上下文切换开销也很高。因此,在为 CPU 编写 DPC++ 内核时,消除工作组内工作项之间的依赖性是一项重要的性能优化。另一个有效的方法是将这种依赖性限制在一个子组中的工作项上,如图 16-12 中的写前读依赖性所示。如果在 SIMD 执行模型下执行子组,则编译器可以将内核中的子组屏障视为空操作,并且在运行时不会产生真正的同步成本。

img/489625_1_En_16_Fig12_HTML.png

图 16-12

使用子群向量化具有前向相关性的循环

内核被矢量化(矢量长度为 8 ,其 SIMD 执行如图 16-13 所示。以组大小(1,8)形成一个工作组,内核内部的循环迭代分布在这些子组工作项上,并以八向 SIMD 并行方式执行。

img/489625_1_En_16_Fig13_HTML.png

图 16-13

具有前向相关性的循环的 SIMD 向量化

在本例中,如果内核中的循环控制了性能,那么允许子组中的 SIMD 向量化将会显著提高性能。

使用并行处理数据元素的 SIMD 指令是让内核性能超越 CPU 内核和超线程数量的一种方式。

SIMD 掩蔽和成本

在实际应用中,我们可能会遇到像if语句这样的条件语句,像a = b > a? a: b这样的条件表达式,迭代次数可变的循环,switch语句,等等。任何有条件的都可能导致标量控制流不执行相同的代码路径,就像在 GPU 上一样(第十五章),可能导致性能下降。SIMD 掩码是一组值为10的位,由内核中的条件语句生成。考虑具有A={1, 2, 3, 4}, B={3, 7, 8, 1}和比较表达式a < b.的示例,比较返回具有四个值{1, 1, 1, 0}的掩码,其可以存储在硬件掩码寄存器中,以指示后面的 SIMD 指令的哪些通道应该执行由比较保护(启用)的代码。

如果内核包含条件代码,它将通过基于与每个数据元素相关联的屏蔽位(SIMD 指令中的 lane)执行的屏蔽指令进行矢量化。每个数据元素的屏蔽位是屏蔽寄存器中的相应位。

使用掩码可能会导致比相应的非掩码代码更低的性能。这可能是由以下原因造成的

  • 每次加载时的额外遮罩混合操作

  • 对目的地的依赖

屏蔽是有代价的,所以只在必要的时候使用它。当内核是 ND 范围内核,并且在执行范围内具有工作项目的显式分组时,在选择 ND 范围工作组大小时应该小心,以便通过最小化屏蔽成本来最大化 SIMD 效率。当一个工作组的大小不能被处理器的 SIMD 宽度整除时,工作组的一部分可以在内核屏蔽的情况下执行。

图 16-14 显示了使用合并屏蔽如何产生对目标寄存器的依赖:

img/489625_1_En_16_Fig14_HTML.png

图 16-14

内核屏蔽的三代屏蔽代码

  • 在没有屏蔽的情况下,处理器每个周期执行两次乘法(vmulps)。

  • 使用合并屏蔽,处理器每四个周期执行两次乘法,因为乘法指令(vmulps)将结果保存在目的寄存器中,如图 16-17 所示。

  • 零屏蔽不依赖于目标寄存器,因此每个周期可以执行两次乘法(vmulps)。

访问缓存对齐的数据比访问非对齐的数据提供更好的性能。在许多情况下,地址在编译时是未知的,或者是已知的但没有对齐。在这些情况下,可以实现对存储器访问的剥离,以通过并行内核中的多版本化技术,使用屏蔽的访问来处理最初的几个元素,直到第一个对齐的地址,然后处理未屏蔽的访问,随后是屏蔽的剩余部分。这种方法增加了代码量,但总体上改善了数据处理。

避免结构数组以提高 SIMD 效率

AOS(结构阵列)结构导致聚集和分散,这既会影响 SIMD 效率,又会为内存访问带来额外的带宽和延迟。硬件聚集-分散机制的存在并没有消除这种转换的需要——聚集-分散访问通常需要比连续加载高得多的带宽和延迟。给定一个struct {float x; float y; float z; float w;} a[4],的 AOS 数据布局,考虑一个在其上运行的内核,如图 16-15 所示。

img/489625_1_En_16_Fig15_HTML.png

图 16-15

SIMD 聚在一个内核里

当编译器沿着一组工作项目对内核进行矢量化处理时,由于需要非单位步长的内存访问,它会导致 SIMD 收集指令的生成。例如,a[0].xa[1].xa[2].xa[3].x的步距是 4,而不是更有效的单位步距 1。

img/489625_1_En_16_Figb_HTML.gif

在内核中,我们通常可以通过消除内存聚集-分散操作来实现更高的 SIMD 效率。一些代码受益于数据布局的变化,这种变化将以结构数组(AOS)表示形式编写的数据结构转换为数组结构(SOA)表示形式,也就是说,每个结构字段都有单独的数组,以在执行 SIMD 矢量化时保持内存访问的连续性。例如,考虑如下所示的struct {float x[4]; float y[4]; float z[4]; float w[4];} a;的 SOA 数据布局:

img/489625_1_En_16_Figc_HTML.gif

如图 16-16 所示,内核可以使用单位步长(连续)向量加载和存储对数据进行操作,即使是在矢量化的情况下!

img/489625_1_En_16_Fig16_HTML.png

图 16-16

内核中 SIMD 单位步幅向量加载

SOA 数据布局有助于在访问跨数组元素的结构的一个字段时防止聚集,并有助于编译器对与工作项相关联的连续数组元素上的内核进行矢量化。请注意,考虑到使用这些数据结构的所有地方,这种 AOS 到 SOA 或 AOSOA 的数据布局转换预计将在程序级别(由我们)完成。仅仅在循环层次上这样做将会涉及到循环前后格式之间代价高昂的转换。然而,我们也可以依靠编译器来执行向量加载和洗牌优化 AOS 数据布局,这需要一些成本。当 SOA(或 AOS)数据布局的成员具有向量类型时,编译器矢量化将根据底层硬件执行水平扩展或垂直扩展,如第十一章所述,以生成最佳代码。

数据类型对 SIMD 效率的影响

每当 C++ 程序员知道数据适合 32 位有符号类型时,他们通常使用整数数据类型,这通常会产生如下代码


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

然而,鉴于get_global_id(0)的返回类型是size_t (无符号整数,通常是 64 位),在某些情况下,转换会减少编译器可以合法执行的优化。例如,当编译器在内核中对代码进行矢量化处理时,这可能会导致 SIMD 收集/分散指令

  • 读取a[get_global_id(0)]导致 SIMD 单位步幅向量加载。

  • a[(int)get_global_id(0)]的读取导致非单位步幅采集指令。

这种微妙的情况是由从size_tint(或uint)的数据类型转换的绕回行为(C++ 标准中未指定的行为和/或定义良好的绕回行为)引入的,这主要是基于 C 语言发展的历史产物。具体来说,跨某些转换的溢出是未定义的行为,这实际上允许编译器假设这种情况永远不会发生,并进行更积极的优化。图 16-17 为那些想了解细节的人展示了一些例子。

img/489625_1_En_16_Fig17_HTML.png

图 16-17

整数type value回绕的例子

SIMD 收集/分散指令比 SIMD 单位步长向量加载/存储操作慢。为了实现最佳的 SIMD 效率,无论使用哪种编程语言,避免聚集/分散对于应用来说都是至关重要的。

大多数 SYCL get_*_id()系列函数都有相同的细节,尽管许多情况都符合MAX_INT的范围,因为可能的返回值是有限的(例如,一个工作组内的最大 id)。因此,只要合法,DPC++ 编译器将假设相邻工作项块上的单位步长内存地址,以避免聚集/分散。如果由于全局 id 的值和/或全局 id 的导数值可能溢出,编译器不能安全地生成线性单位步长向量内存加载/存储,编译器将生成聚集/分散。

在为用户提供最佳性能的理念下,DPC++ 编译器假定没有溢出,并且在实践中几乎总是捕捉真实情况,因此编译器可以生成最佳 SIMD 代码以实现良好的性能。但是,DPC++ 编译器提供了一个覆盖编译器宏—D _ _ SYCL _ DISABLE _ ID _ TO _ INT _ conv _ _—来告诉编译器将会有溢出,并且从 ID 查询中导出的矢量化访问可能不安全。这可能会对性能产生很大影响,只要不安全,就应该使用这种方法来假设没有溢出。

SIMD 执行使用single_task

在单任务执行模型下,与向量类型和函数相关的优化取决于编译器。编译器和运行时可以自由地在single_task内核中启用显式 SIMD 执行或选择标量执行,结果将取决于编译器的实现。例如,DPC++ CPU 编译器支持向量类型,并为 CPU SIMD 执行生成 SIMD 指令。vec load、store 和 swizzle 函数将直接对向量变量执行操作,通知编译器数据元素正在访问从内存中相同(统一)位置开始的连续数据,并使我们能够请求连续数据的优化加载/存储。

img/489625_1_En_16_Fig18_HTML.png

图 16-18

single_task内核中使用矢量类型和 swizzle 操作

在图 16-18 所示的例子中,在单任务执行下,声明了一个带有三个数据元素的向量。使用old_v.abgr().执行 swizzle 操作如果 CPU 为一些 swizzle 操作提供 SIMD 硬件指令,我们可以在应用程序中使用 swizzle 操作来获得一些性能优势。

SIMD VECTORIZATION GUIDELINES

CPU 处理器实现具有不同 SIMD 宽度的 SIMD 指令集。在许多情况下,这是一个实现细节,对于在 CPU 上执行内核的应用程序是透明的,因为编译器可以确定一组有效的数据元素,以特定的 SIMD 大小进行处理,而不是要求我们显式地使用 SIMD 指令。子组可以用于更直接地表达数据元素的分组应该在内核中经受 SIMD 执行的情况。

考虑到计算的复杂性,选择最适合矢量化的代码和数据布局可能最终会带来更高的性能。选择数据结构时,请尝试选择数据布局、对齐方式和数据宽度,以便最频繁执行的计算能够以 SIMD 友好的方式以最大的并行度访问内存,如本章所述。

摘要

为了充分利用 CPU 上的线程级并行和 SIMD 矢量级并行,我们需要牢记以下目标:

  • 熟悉所有类型的 DPC++ 并行性和我们希望瞄准的底层 CPU 架构。

  • 在与硬件资源最匹配的线程级别上,利用适量的并行性,不多也不少。使用供应商工具,如分析器和剖析器,来帮助指导我们的调优工作,以实现这一目标。

  • 请注意线程关联和内存首次接触对程序性能的影响。

  • 设计具有数据布局、对齐方式和数据宽度的数据结构,以便最频繁执行的计算能够以 SIMD 友好的方式访问内存,并具有最大的 SIMD 并行性。

  • 注意平衡屏蔽和代码分支的成本。

  • 使用清晰的编程风格,最大限度地减少潜在的内存混淆和副作用。

  • 请注意使用 vector 类型和接口的可伸缩性限制。如果编译器实现将它们映射到硬件 SIMD 指令,固定的向量大小可能无法在多代 CPU 和来自不同供应商的 CPU 之间很好地匹配 SIMD 寄存器的 SIMD 宽度。

Creative Commons

开放存取本章根据知识共享署名 4.0 国际许可证(http://Creative Commons . org/licenses/by/4.0/)的条款获得许可,该许可证允许以任何媒体或格式使用、共享、改编、分发和复制,只要您适当注明原作者和来源,提供知识共享许可证的链接并指明是否进行了更改。

本章中的图像或其他第三方材料包含在本章的知识共享许可中,除非在材料的信用额度中另有说明。如果材料不包括在本章的知识共享许可中,并且您的预期使用不被法律法规允许或超出了允许的使用范围,您将需要直接从版权所有者处获得许可。

十七、FPGAs 编程

img/489625_1_En_17_Figa_HTML.gif

基于内核的编程最初流行起来是作为访问 GPU 的一种方式。因为它现在已经被推广到许多类型的加速器,所以了解我们的编程风格如何影响代码到 FPGA 的映射也很重要。

现场可编程门阵列(FPGA)对于大多数软件开发人员来说是陌生的,部分原因是大多数台式计算机在典型的 CPU 和 GPU 旁边不包括 FPGA。但是 FPGA值得了解,因为它们在许多应用中提供了优势。我们需要问与其他加速器相同的问题,比如“我什么时候应该使用 FPGA?”,“我的应用的哪些部分应该卸载到 FPGA?”,以及“如何编写在 FPGA 上运行良好的代码?”

本章为我们提供了开始回答这些问题的知识,至少在这一点上,我们可以决定 FPGA 是否适合我们的应用,并了解哪些结构通常用于实现性能。这一章是一个起点,我们可以从这里开始阅读供应商文档,以填充特定产品和工具链的细节。我们首先概述程序如何映射到 FPGA 等空间架构,然后讨论 FPGA 作为加速器的一些特性,最后介绍用于提高性能的编程结构。

本章中的“如何考虑 FPGA”一节适用于考虑任何 FPGA。SYCL 允许供应商指定 CPU 和 GPU 之外的设备,但没有具体说明如何支持 FPGA。FPGA 的特定供应商支持目前是 DPC++ 独有的,即 FPGA 选择器和管道。FPGA 选择器和管道是本章中使用的唯一的 DPC++ 扩展。人们希望供应商们能够在支持 FPGAs 的相似或兼容的方法上达成一致,这也是 DPC++ 作为一个开源项目所鼓励的。

性能警告

与任何处理器或加速器一样,FPGA 设备因供应商而异,甚至因产品的不同而不同;因此,一种设备的最佳实践可能不是另一种设备的最佳实践。无论是现在还是将来,本章中的建议都可能使许多 FPGA 器件受益,但是…

…要实现特定 FPGA 的最佳性能,请务必查阅供应商的文档!

如何看待 FPGAs

FPGAs 通常被归类为空间架构。与使用指令集架构(ISA)的设备(包括大多数人更熟悉的 CPU 和 GPU)相比,它们受益于非常不同的编码风格和并行形式。为了开始了解 FPGAs,我们将简要介绍基于 ISA 的加速器的一些想法,以便强调关键差异。

就我们的目的而言,基于 ISA 的加速器是指设备可以执行许多不同的指令,一次执行一条或几条。这些指令通常相对简单,比如“从内存地址 A 加载”或“添加下列数字”一连串的操作串在一起形成一个程序,处理器在概念上一个接一个地执行指令。

在基于 ISA 的加速器中,芯片的单个区域(或整个芯片)在每个时钟周期执行来自程序的不同指令。指令在固定的硬件架构上执行,该架构可以在不同的时间运行不同的指令,如图 17-1 所示。例如,提供加法运算的内存加载单元可能与提供减法运算的内存加载单元相同。类似地,同一个算术单元可能既用于执行加法指令,也用于执行减法指令。随着程序的执行,芯片上的硬件被不同的指令重用

img/489625_1_En_17_Fig1_HTML.png

图 17-1

简单的基于 ISA 的(临时)处理:随着时间的推移重用硬件(区域)

空间建筑是不同的。它们不是基于在共享硬件上执行各种指令的机器,而是从相反的角度出发。一个程序的空间实现在概念上把整个程序作为一个整体,并把它一次放在设备上。设备的不同区域在程序中执行不同的指令。这在许多方面与随时间推移在指令之间共享硬件(例如 ISA)的观点相反——在空间架构中,每个指令接收其自己的专用硬件,该硬件可以与实现其他指令的硬件同时执行(相同的时钟周期)。图 17-2 显示了这个想法,它是整个程序(在这个例子中是一个非常简单的程序)的空间实现。

img/489625_1_En_17_Fig2_HTML.png

图 17-2

空间处理:每次操作使用设备的不同区域

对程序的空间实现的这种描述过于简单,但它抓住了这样一个思想,即在空间架构中,程序的不同部分在设备的不同部分上执行,而不是随着时间的推移被发布到一组共享的更通用的硬件上。

由于 FPGA 的不同区域被编程为执行不同的操作,一些通常与基于 ISA 的加速器相关联的硬件是不必要的。例如,图 17-2 显示我们不再需要取指令或解码单元、程序计数器或寄存器文件。空间架构将一条指令的输出连接到下一条指令的输入,而不是将数据存储在寄存器文件中供未来指令使用,这就是空间架构通常被称为数据流架构的原因。

我们介绍的到 FPGA 的映射出现了几个明显的问题。首先,由于程序中的每条指令都要占用设备空间的一定比例,如果程序需要的空间超过 100%,会发生什么呢?一些解决方案提供了资源共享机制,使更大的程序能够以性能成本来适应,但 FPGAs 确实有程序适应的概念。这既是优点也是缺点:

  • 好处:如果一个程序使用 FPGA 上的大部分区域,并且每个时钟周期都有足够的工作让所有硬件忙碌,那么在设备上执行程序会非常高效,因为它具有极高的并行性。更一般的架构每个时钟周期可能有大量未使用的硬件,而对于 FPGA,面积的使用可以针对特定应用进行完美定制,不会造成浪费。这种定制可以让应用通过大规模并行运行得更快,通常具有令人信服的能效。

  • 不利的一面是:大型程序可能需要调整和重构,以适应设备。编译器的资源共享功能有助于解决这一问题,但通常会降低性能,从而降低使用 FPGA 的优势。基于 ISA 的加速器是非常高效的资源共享实现方式,事实证明,当应用的架构能够充分利用大部分可用区域时,FPGAs 对计算最有价值。

在极端情况下,FPGA 上的资源共享解决方案导致一种看起来像基于 ISA 的加速器的架构,但它内置于可重新配置的逻辑,而不是在固定硅中进行优化。相对于固定芯片设计,可重新配置的逻辑会带来额外开销,因此,通常不会选择 FPGA 来实现 ISA,当应用能够利用资源来实现高效的数据流算法时,FPGA 具有最大的优势,我们将在接下来的章节中讨论这一点。

流水线并行性

图 17-2 中经常出现的另一个问题是程序的空间实现与时钟频率的关系,以及程序从头到尾执行的速度。在所示的例子中,很容易相信可以从内存中加载数据,执行乘法和加法运算,并将结果存储回内存中,速度非常快。随着程序变得越来越大,可能在整个 FPGA 器件上有成千上万个操作,很明显,对于一个接一个地操作所有指令(操作通常取决于先前操作产生的结果),考虑到每个操作引入的处理延迟,可能需要很长时间。

操作之间的中间结果在图 17-3 所示的空间架构中随时间更新(传播)。例如,加载执行,然后将其结果传递给乘法器,然后将其结果传递给加法器,依此类推。一段时间后,中间数据一直传播到操作链的末端,最终结果可用或存储到内存中。

img/489625_1_En_17_Fig3_HTML.png

图 17-3

一个简单空间计算实现的传播时间

如图 17-3 所示的空间实现是非常低效的,因为大部分硬件只在一小部分时间里做有用的工作。大多数情况下,乘法之类的操作要么等待来自加载的新数据,要么保持其输出,以便链中稍后的操作可以使用其结果。大多数空间编译器和实现通过流水线来解决这种低效率,这意味着单个程序的执行分散在许多时钟周期中。这是通过在一些操作之间插入寄存器(硬件中的数据存储原语)来实现的,其中每个寄存器在一个时钟周期内保存一个二进制值。通过保存操作输出的结果,以便链中的下一个操作可以看到并操作该保存的值,前一个操作可以自由地进行不同的计算,而不会影响后续操作的输入。

算法流水线的目标是让每个操作(硬件单元)在每个时钟周期都保持忙碌。图 17-4 显示了前面简单例子的流水线实现。请记住,编译器会为我们完成所有的流水线操作和平衡工作!我们讨论这个主题是为了让我们能够理解如何在接下来的部分中用工作填充管道,而不是因为我们需要担心在代码中手工管道化任何东西。

img/489625_1_En_17_Fig4_HTML.png

图 17-4

计算的流水线化:阶段并行执行

当空间实现被流水线化时,它变得非常高效,就像工厂流水线一样。每个管道阶段只执行整体工作的一小部分,但是它执行得很快,然后立即开始处理下一个工作单元。从开始到结束,流水线处理一个单个计算需要许多时钟周期,但是流水线可以同时对不同数据计算许多不同的计算实例。

当足够多的工作开始在流水线中执行时,经过足够多的连续时钟周期,然后每个单独的流水线阶段以及程序中的操作可以在每个时钟周期期间执行有用的工作,这意味着整个空间设备同时执行工作。这就是空间架构的强大之处之一——整个设备可以一直并行工作。我们称之为流水线并行

流水线并行是在 FPGAs 上实现性能的主要并行形式。

PIPELINING IS AUTOMATIC

在用于 FPGA 的 DPC++ 的英特尔实现中,以及在用于 FPGA 的其他高级编程解决方案中,算法的流水线由编译器自动执行。大致了解空间架构上的实现是有用的,如本节所述,因为这样可以更容易地构建应用程序来利用管道并行性。应该明确的是,流水线寄存器插入和平衡是由编译器执行的,而不是由开发人员手动执行的。

真实的程序和算法通常具有控制流(例如,if/else 结构),该控制流使程序的某些部分在时钟周期的某个百分比不活动。FPGA 编译器通常在可能的情况下组合分支两端的硬件,以最小化浪费的空间面积,并在控制流分流期间最大化计算效率。这使得控制流分歧比其他架构,尤其是向量化的架构,成本更低,开发问题更少。

内核消耗芯片“面积”

在现有的实现中,DPC++ 应用中的每个内核都会生成一个空间流水线,该流水线会消耗 FPGA 的一些资源(我们可以将此视为设备上的空间区域),这在概念上如图 17-5 所示。

img/489625_1_En_17_Fig5_HTML.png

图 17-5

同一个 FPGA 二进制文件中的多个内核:内核可以并发运行

由于内核在设备上使用自己的区域,不同的内核可以并发执行。如果一个内核正在等待诸如存储器访问之类的事情,FPGA 上的其他内核可以继续执行,因为它们是芯片上其他地方的独立流水线。这种想法,正式描述为内核之间的独立向前进展,是 FPGA 空间计算的一个关键属性。

何时使用 FPGA

与任何加速器架构一样,预测 FPGA 何时是加速器的正确选择,何时是替代方案,通常取决于对架构、应用特性和系统瓶颈的了解。本节描述了要考虑的应用程序的一些特征。

很多很多的工作

像大多数现代计算加速器一样,实现良好的性能需要执行大量的工作。如果从单个数据元素计算单个结果,那么利用加速器可能根本没有用(任何种类的)。这和 FPGAs 没什么区别。知道 FPGA 编译器利用流水线并行性后,这一点变得更加明显。一个算法的流水线实现有许多阶段,通常有数千个或更多,每个阶段在任何时钟周期内都应该有不同的工作。如果没有足够的工作在大部分时间占据大部分流水线阶段,那么效率就会很低。我们将一段时间内流水线阶段的平均利用率称为流水线的占用率。这和优化 GPU 等其他架构时使用的占有率定义是不一样的!

有多种方法可以在 FPGA 上生成工作来填充流水线阶段,我们将在接下来的章节中讨论。

自定义操作或操作宽度

FPGAs 最初设计用于执行有效的整数和按位运算,并作为粘合逻辑,可以调整其他芯片的接口以相互配合工作。虽然 FPGAs 已经发展成为计算能力强大的解决方案,而不仅仅是粘合逻辑解决方案,但它们在按位运算、自定义数据宽度或类型的整数数学运算以及数据包报头中任意位字段的运算方面仍然非常高效。

本章末尾描述的 FPGA 的细粒度架构意味着可以高效地实现新颖和任意的数据类型。例如,如果我们需要 33 位整数乘法器或 129 位加法器,FPGAs 可以非常高效地提供这些自定义操作。由于这种灵活性,FPGAs 通常用于快速发展的领域,例如最近的机器学习,其中数据宽度和操作的变化速度超过了 ASICs 的内置速度。

标量数据流

从图 17-4 可以明显看出,FPGA 空间流水线的一个重要方面是,操作之间的中间数据不仅留在片内(不存储到外部存储器),而且每个流水线级之间的中间数据都有专用的存储寄存器。FPGA 并行性来自于计算的流水线操作,使得许多操作同时执行,每个操作在流水线的不同阶段执行。这不同于向量架构,在向量架构中,多个计算作为共享向量指令的通道来执行。

空间管道中并行性的标量性质对于许多应用程序来说是重要的,因为即使在工作单元之间存在紧密的数据依赖性时,它仍然适用。这些数据依赖可以在不损失性能的情况下处理,我们将在本章后面讨论循环携带的依赖时讨论。其结果是,空间管道,因此 FPGAs,对于跨工作单元(如工作项)的数据依赖性不能被破坏并且必须进行细粒度通信的算法来说是有吸引力的。针对其他加速器的许多优化技术关注于通过各种技术打破这些依赖性,或者通过诸如子组之类的特性在受控规模上管理通信。相反,FPGAs 可以很好地执行紧密依赖的通信,并且应该被考虑用于存在这种模式的算法类别。

LOOPS ARE FINE!

对数据流架构的一个常见误解是,具有固定或动态迭代计数的循环会导致数据流性能不佳,因为它们不是简单的前馈管道。至少对于英特尔 DPC++ 和 FPGA 工具链来说,情况并非如此。相反,循环迭代是在管道内产生高占用率的好方法,编译器是围绕允许多个循环迭代以重叠方式执行的概念构建的。循环提供了一种简单的机制来让管道忙于工作!

低延迟和丰富的连接

利用器件上丰富的输入和输出收发器的 FPGAs 的更多传统用途同样适用于使用 DPC++ 的开发人员。例如,如图 17-6 所示,一些 FPGA 加速卡具有网络接口,可以将数据直接传输到设备中,进行处理,然后将结果直接传输回网络。当需要最小化处理等待时间时,以及当通过操作系统网络栈的处理太慢或需要卸载时,通常寻求这样的系统。

img/489625_1_En_17_Fig6_HTML.png

图 17-6

低延迟 I/O 流:FPGA 紧密连接网络数据和计算

当考虑通过 FPGA 收发器进行直接输入/输出时,机会几乎是无限的,但选择确实取决于构成加速器的电路板上的可用器件。由于依赖于特定的加速卡和各种这样的用途,除了在下一节中描述管道语言构造之外,本章不深入这些应用程序。相反,我们应该阅读与特定加速卡相关的供应商文档,或者搜索符合我们特定接口需求的加速卡。

定制的内存系统

FPGA 上的存储器系统,例如函数私有存储器或工作组本地存储器,是由小块片上存储器构建而成的。这很重要,因为每个内存系统都是为使用它的算法或内核的特定部分定制的。FPGAs 具有显著的片内存储器带宽,结合定制存储器系统的形成,它们可以在具有非典型存储器访问模式和结构的应用中表现出色。图 17-7 显示了在 FPGA 上实现内存系统时,编译器可以执行的一些优化。

img/489625_1_En_17_Fig7_HTML.png

图 17-7

FPGA 内存系统是由编译器为我们的特定代码定制的

其他架构,如 GPU,有固定的内存结构,有经验的开发人员很容易推理,但在许多情况下也很难优化。例如,其他加速器上的许多优化都集中在内存模式修改上,以避免存储体冲突。如果我们的算法可以受益于定制的存储器结构,例如每个存储体的不同数量的访问端口或不同寻常的存储体数量,那么 FPGAs 可以提供立竿见影的优势。从概念上讲,区别在于编写代码以有效地使用固定的内存系统(大多数其他加速器)和让编译器定制设计的内存系统对我们的特定代码有效(FPGA)。

在 FPGA 上运行

在 FPGA 上运行内核有两个步骤(与任何提前编译加速器一样):

  1. 将源代码编译成可以在我们感兴趣的硬件上运行的二进制文件

  2. 在运行时选择我们感兴趣的正确加速器

要编译内核以便它们可以在 FPGA 硬件上运行,我们可以使用命令行:


dpcpp -fintelfpga my_source_code.cpp -Xshardware

该命令告诉编译器将my_source_code.cpp中的所有内核转换成可以在英特尔 FPGA 加速器上运行的二进制文件,然后将它们打包到生成的主机二进制文件中。当我们执行主机二进制程序时(例如,通过在 Linux 上运行./a.out,在执行提交的内核之前,运行时将根据需要自动编程任何附加的 FPGA,如图 17-8 所示。

img/489625_1_En_17_Fig8_HTML.png

图 17-8

FPGA 在运行时自动编程

FPGA 编程二进制文件嵌入在我们在主机上运行的已编译的 DPC++ 可执行文件中。FPGA 是在幕后自动为我们配置的。

当我们运行一个主机程序并提交第一个内核以便在 FPGA 上执行时,在内核开始执行之前可能会有一点延迟,因为 FPGA 正在被编程。为额外的执行重新提交内核不会看到同样的延迟,因为内核已经被编程到设备中并准备好运行。

第二章介绍了运行时 FPGA 器件的选择。我们需要告诉主机程序我们希望内核在哪里运行,因为除了 FPGA 之外,通常还有多个加速器选项可用,例如 CPU 和 GPU。为了快速回顾在程序执行期间选择 FPGA 的一种方法,我们可以使用如图 17-9 所示的代码。

img/489625_1_En_17_Fig9_HTML.png

图 17-9

使用fpga_selector在运行时选择 FPGA 器件

编译时间

传言称,编译 FPGA 设计可能需要很长时间,比编译基于 ISA 的加速器要长得多。谣言是真的!本章结尾概述了 FPGA 的细粒度架构元素,这些元素既带来了 FPGA 的优势,也带来了计算密集型编译(布局布线优化),在某些情况下可能需要数小时。

从源代码到 FPGA 硬件执行的编译时间足够长,我们不想专门在硬件中开发和迭代我们的代码。FPGA 开发流程提供了几个阶段,最大限度地减少了硬件编译的数量,使我们在硬件编译时间有限的情况下也能高效工作。图 17-10 显示了典型的阶段,在这些阶段,我们的大部分时间都花在提供快速周转和快速迭代的早期步骤上。

img/489625_1_En_17_Fig10_HTML.png

图 17-10

大多数验证和优化发生在冗长的硬件编译之前

来自编译器的仿真和静态报告是 DPC++ 中 FPGA 代码开发的基石。仿真器就像 FPGA 一样工作,包括支持相关扩展和仿真执行模型,但运行在主机处理器上。因此,编译时间与我们预期的编译到 CPU 设备的时间相同,尽管我们不会看到在实际 FPGA 硬件上执行所带来的性能提升。模拟器对于在应用程序中建立和测试功能正确性非常有用。

像仿真一样,静态报告由工具链快速生成。它们报告编译器创建的 FPGA 结构和编译器识别的瓶颈。这两者都可以用来预测我们的设计在 FPGA 硬件上运行时是否会获得良好的性能,并用来优化我们的代码。请阅读供应商的文档以获得关于报告的信息,这些报告通常会随着工具链的发布而不断改进(请参阅文档以了解最新和最棒的特性!).供应商提供了关于如何根据报告进行解释和优化的大量文档。这些信息将是另一本书的主题,所以我们不能在这一章中深入讨论细节。

FPGA 仿真器

仿真主要用于从功能上调试我们的应用程序,以确保它的行为符合预期并产生正确的结果。没有理由在编译时间更长的实际 FPGA 硬件上进行这种级别的开发。通过从dpcpp编译命令中移除-Xshardware标志,同时在我们的主机代码中使用INTEL::fpga_emulator_selector而不是INTEL::fpga_selector来激活仿真流。我们将使用如下命令进行编译


dpcpp -fintelfpga my_source_code.cpp

同时,我们将在运行时使用如图 17-11 所示的代码选择 FPGA 仿真器。通过使用fpga_emulator_selector, which使用主机处理器来仿真 FPGA,我们在必须为实际 FPGA 硬件进行更长时间的编译之前,保持了快速的开发和调试过程。

img/489625_1_En_17_Fig11_HTML.png

图 17-11

使用fpga_emulator_selector进行快速开发和调试

如果我们经常在硬件和仿真器之间切换,在程序中使用宏从命令行在设备选择器之间切换是有意义的。如果需要,请查看供应商的文档和在线 FPGA DPC++ 代码示例。

FPGA 硬件编译“提前”发生

图 17-10 中的完全编译和硬件剖析阶段在 SYCL 术语中是一个提前编译的。这意味着内核到设备二进制文件的编译发生在我们最初编译程序的时候,而不是程序提交到设备上运行的时候。在 FPGA 上,这一点尤为重要,因为

  1. 编译需要很长时间,这是我们在运行应用程序时通常不希望发生的。

  2. DPC++ 程序可以在没有主处理器的系统上执行。FPGA 二进制文件的编译过程得益于具有大量附加存储器的快速处理器。提前编译让我们可以很容易地选择编译发生的位置,而不是在程序部署的系统上运行。

A LOT HAPPENS BEHIND THE SCENES WITH DPC++ ON AN FPGA!

传统的 FPGA 设计(不使用高级语言)可能非常复杂。除了编写我们的内核之外,还有许多步骤,例如构建和配置与片外存储器通信的接口,以及通过插入所需的寄存器来关闭时序,以使编译后的设计运行得足够快,从而与某些外设通信。DPC++ 为我们解决了这一切,让我们不需要了解任何常规 FPGA 设计的细节就可以实现工作应用!该工具将我们的内核视为优化和提高设备效率的代码,然后自动处理与片外外设对话、关闭时序和为我们设置驱动程序的所有细节。

与任何其他加速器一样,在 FPGA 上实现最高性能仍然需要详细的架构知识,但与传统 FPGA 流程相比,使用 DPC++ 从代码到工作设计的步骤要简单得多,效率也更高。

为 FPGAs 编写内核

一旦我们决定在我们的应用中使用 FPGA,或者只是决定试用一种,了解如何编写代码以获得良好的性能是非常重要的。本节描述了突出重要概念的主题,并涵盖了一些经常引起混淆的主题,以使入门更快。

暴露并行性

我们已经了解了如何利用流水线并行在 FPGA 上高效执行工作。另一个简单的管道示例如图 17-12 所示。

img/489625_1_En_17_Fig12_HTML.png

图 17-12

具有五个阶段的简单流水线:六个时钟周期处理一个数据元素

在这条管道中,有五个阶段。每个时钟周期,数据从一个阶段移动到下一个阶段一次,因此在这个非常简单的例子中,从数据进入阶段 1 到从阶段 5 退出需要 6 个时钟周期。

流水线的一个主要目标是使多个数据元素能够在流水线的不同阶段同时被处理。为了确保这一点是清楚的,图 17-13 显示了一个没有足够工作的流水线(在这种情况下只有一个数据元素),这导致每个流水线级在大多数时钟周期内都没有被使用。这是对 FPGA 资源的低效使用,因为大部分硬件在大部分时间都是空闲的。

img/489625_1_En_17_Fig13_HTML.png

图 17-13

如果只处理单个工作元素,流水线阶段通常是不使用的

为了更好地占用管道阶段,想象一下在第一阶段之前等待的未启动工作队列是有用的,第一阶段管道提供信息。每个时钟周期,流水线可以从队列中消耗并启动多一个工作元素,如图 17-14 所示。在一些初始启动周期之后,流水线的每个阶段都被占用,并在每个时钟周期做有用的工作,从而有效利用 FPGA 资源。

img/489625_1_En_17_Fig14_HTML.png

图 17-14

当每个流水线级保持忙碌时,就实现了高效利用

接下来的两个部分将介绍一些方法,这些方法可以让队列中充满准备好开始的工作。我们会看看

  1. nd-range 核

在这些选项之间进行选择会影响运行在 FPGA 上的内核的基本架构。在某些情况下,算法很适合这种或那种风格,而在其他情况下,程序员的偏好和经验决定了应该选择哪种方法。

使用 ND-range 保持流水线繁忙

第章第四部分描述了 ND-range 分级执行模型。图 17-15 说明了关键的概念:一个 ND-range 执行模型,其中有一个工作项目的层次分组,工作项目是内核定义的基本工作单元。该模型最初被开发来实现 GPU 的高效编程,其中工作项目可以在执行模型层级的不同级别上并发执行。为了匹配 GPU 硬件高效的工作类型,ND 范围工作项在大多数应用程序中不会频繁地相互通信。

img/489625_1_En_17_Fig15_HTML.png

图 17-15

ND-range 执行模型:工作项目的层次分组

使用 ND-range 可以非常有效地填充 FPGA 空间流水线。FPGA 完全支持这种编程风格,我们可以将其视为图 17-16 所示,在每个时钟周期,不同的工作项进入流水线的第一阶段。

img/489625_1_En_17_Fig16_HTML.png

图 17-16

ND-range 供给空间管道

什么时候我们应该使用工作项在 FPGA 上创建一个 ND-range 内核来保持流水线被占用?很简单。每当我们可以将我们的算法或应用程序构建为不需要经常通信(或者理想情况下根本不需要通信)的独立工作项时,我们都应该使用 ND-range!如果工作项确实需要经常通信,或者如果我们不自然地考虑 ND 范围,那么循环(在下一节中描述)也提供了一种表达我们算法的有效方式。

如果我们可以构建我们的算法,使得工作项不需要太多的通信(或者根本不需要),那么 ND-range 是一个生成工作以保持空间管道满的好方法!

一个很好的例子是一个随机数发生器,在这个例子中,序列中的数字的创建独立于先前生成的数字。

图 17-17 显示了一个 ND-range 内核,它将为 16 × 16 × 16 范围内的每个工作项调用一次随机数生成函数。注意随机数生成函数是如何将工作项 id 作为输入的。

img/489625_1_En_17_Fig17_HTML.png

图 17-17

多个工作项(16 × 16 × 16)调用一个随机数生成器

这个例子展示了一个使用了一个rangeparallel_for调用,只指定了一个全局大小。我们也可以使用带有nd_rangeparallel_for调用风格,其中指定了全局工作大小和本地工作组大小。FPGAs 可以非常有效地从片内资源实现工作组本地存储器,因此只要有意义就可以随意使用工作组,因为我们需要工作组本地存储器,或者因为有了工作组 id 可以简化我们的代码。

PARALLEL RANDOM NUMBER GENERATORS

图 17-17 中的例子假设generate_random_number_from_ID(I)是一个随机数发生器,当以并行方式调用时,它是安全和正确的。例如,如果在parallel_for范围内的不同工作项执行该函数,我们期望每个工作项创建不同的序列,每个序列都遵循生成器期望的分布。并行随机数生成器本身就是一个复杂的主题,所以使用库或者通过诸如块跳转算法之类的技术来了解这个主题是一个好主意。

管道不介意数据依赖!

当对一些工作项目作为向量指令通道一起执行的向量架构(例如,GPU)进行编程时,挑战之一是在工作项目之间没有大量通信的情况下构造高效的算法。有些算法和应用程序非常适合矢量硬件,有些则不适合。映射不佳的一个常见原因是算法需要大量共享数据,这是由于数据依赖于在某种意义上相邻的其他计算。如第十四章所述,子组通过在同一个子组中的工作项之间提供有效的通信来解决向量架构上的一些挑战。

对于不能分解成独立工作的算法,FPGAs 起着重要的作用。FPGA 空间流水线不是跨工作项矢量化,而是跨流水线阶段执行连续的工作项。这种并行性的实现意味着工作项(甚至是不同工作组中的工作项)之间的细粒度通信可以在空间管道中轻松有效地实现!

一个例子是随机数发生器,其中输出 N+1 取决于知道输出 N 是什么。这在两个输出之间产生了数据依赖性,并且如果每个输出都是由 nd 范围中的工作项生成的,那么在一些架构上,工作项之间存在数据依赖性,这可能需要复杂并且通常昂贵的同步。当串行编码这样的算法时,通常会编写一个循环,其中迭代 N+1 使用来自迭代 N 的计算,如图 17-18 所示。每次迭代依赖于前一次迭代计算的状态。这是一种非常常见的模式。

img/489625_1_En_17_Fig18_HTML.png

图 17-18

循环携带的数据相关性(state)

空间实现可以非常有效地将结果在管道中向后传递给在稍后的周期中开始的工作(即,在管道中的较早阶段工作),并且空间编译器围绕该模式实现了许多优化。图 17-19 显示了从阶段 5 到阶段 4 的数据反向通信的想法。空间管道不会跨工作项进行矢量化。这通过在管道中向后传递结果实现了高效的数据依赖通信!

img/489625_1_En_17_Fig19_HTML.png

图 17-19

反向通信支持高效的数据相关通信

向后传递数据(传递到管道中的早期阶段)的能力是空间架构的关键,但如何编写利用这一点的代码并不明显。有两种方法可以轻松表达这种模式:

  1. 具有 ND 范围内核的内核内管道

第二种选择是基于管道的,我们将在本章后面介绍,但它远不如循环那样常见,所以为了完整起见我们提到了它,但在这里就不详述了。供应商文档提供了关于管道方法的更多细节,但是更容易坚持下面描述的循环,除非有理由这样做。

循环的空间流水线实现

当对具有数据依赖性的算法进行编程时,循环是一种自然的选择。循环经常表示迭代之间的依赖关系,即使在最基本的循环例子中,决定循环何时退出的计数器也是跨迭代执行的(图 17-20 中的变量i)。

img/489625_1_En_17_Fig20_HTML.png

图 17-20

具有两个循环携带依赖项(即ia)的循环

在图 17-20 的简单循环中,a= a + i右侧的a的值反映了前一次循环迭代存储的值,如果是循环的第一次迭代,则为初始值。当空间编译器实现一个循环时,循环的迭代可以用来填充流水线的各个阶段,如图 17-21 所示。注意,现在准备开始的工作队列包含循环迭代,而不是工作项!

img/489625_1_En_17_Fig21_HTML.png

图 17-21

由循环的连续迭代供给的流水线阶段

图 17-22 显示了一个修改后的随机数发生器示例。在这种情况下,不是根据工作项的 id 生成一个数字,如图 17-17 所示,生成器将之前计算的值作为一个参数。

img/489625_1_En_17_Fig22_HTML.png

图 17-22

取决于先前生成的值的随机数生成器

这个例子使用了single_task而不是parallel_for,因为重复的工作是由单个任务中的一个循环表示的,所以没有理由在这个代码中也包含多个工作项(通过parallel_for)。single_task中的循环使得将之前计算的temp值传递给随机数生成函数的每次调用变得更加容易(便于编程)。

在如图 17-22 的情况下,FPGA 可以有效地实现循环。在许多情况下,它可以保持一个完全占用的管道,或者至少可以通过报告告诉我们要改变什么来增加占用率。考虑到这一点,很明显,如果用工作项替换循环迭代,这个算法将更加难以描述,其中一个工作项生成的值需要传递给另一个工作项,以便在增量计算中使用。代码的复杂性会迅速增加,特别是如果工作不能被批处理,那么每个工作项实际上是在计算它自己独立的随机数序列。

循环启动间隔

从概念上讲,我们可能认为 C++ 中的循环迭代是一个接一个地执行,如图 17-23 所示。这就是编程模型,也是思考循环的正确方式。然而,在实现中,只要程序的大多数行为(即,定义的和无竞争的行为)没有明显改变,编译器就可以自由地执行许多优化。不考虑编译器优化,重要的是循环看起来执行,就好像图 17-23 是如何发生的。

img/489625_1_En_17_Fig23_HTML.png

图 17-23

从概念上讲,循环迭代一个接一个地执行

从空间编译器的角度来看,图 17-24 显示了循环流水线优化,其中循环迭代的执行在时间上是重叠的。不同的迭代将执行彼此不同的空间流水线阶段,并且跨流水线阶段的数据依赖性可以由编译器管理,以确保程序看起来好像迭代是连续的一样执行(除了循环将更快地完成执行!).

img/489625_1_En_17_Fig24_HTML.png

图 17-24

循环流水线操作允许循环的迭代在流水线阶段之间重叠

认识到循环迭代中的许多结果可能在循环迭代完成其所有工作之前完成计算,并且在空间流水线中,当编译器决定这样做时,结果可以被传递到更早的流水线阶段,循环流水线是容易理解的。图 17-25 显示了这个想法,其中阶段 1 的结果在流水线中被反馈,允许未来的循环迭代在前一次迭代完成之前尽早使用该结果。

img/489625_1_En_17_Fig25_HTML.png

图 17-25

增量随机数发生器的流水线实现

使用循环流水线,一个循环的多次迭代的执行有可能重叠。这种重叠意味着,即使存在循环携带的数据依赖性,循环迭代仍然可以用来填充工作管道,从而实现高效利用。图 17-26 显示了在如图 17-25 所示的同一条简单流水线中,循环迭代如何重叠它们的执行,即使有循环携带的数据依赖。

img/489625_1_En_17_Fig26_HTML.png

图 17-26

循环流水线同时处理多个循环迭代的部分

在实际算法中,通常不可能在每个时钟周期启动新的循环迭代,因为数据相关性可能需要多个时钟周期来计算。如果存储器查找,特别是从片外存储器的查找,在相关性计算的关键路径上,这经常发生。结果是流水线只能每N个时钟周期启动一次新的循环迭代,我们称之为N周期的启动间隔 ( II)。示例如图 17-27 所示。两个循环启动间隔(II)意味着新的循环迭代可以每隔一个周期开始,这导致流水线级的次优占用。

img/489625_1_En_17_Fig27_HTML.png

图 17-27

流水线级的次优占用

大于 1 的II会导致流水线效率低下,因为每一级的平均占用率会降低。从图 17-27 中可以明显看出,其中II=2和管道级未使用的比例很大(50%!)的时间。有多种方法可以改善这种情况。

编译器会尽可能地执行大量优化来减少 II,因此它的报告还会告诉我们每个循环的初始间隔是多少,如果发生这种情况,还会告诉我们为什么它大于 1。基于报告在循环中重构计算通常可以减少II,特别是因为作为开发人员,我们可以进行编译器不允许的循环结构更改(因为它们会被观察到)。阅读编译器报告,了解如何在特定情况下减少II

另一种降低大于 1 的II的低效率的方法是通过嵌套循环,这可以通过将外部循环迭代与具有II>1的内部循环迭代交错来填充所有流水线阶段。有关使用这种技术的详细信息,请查阅供应商文档和编译器报告。

管道

空间和其他架构中的一个重要概念是先进先出(FIFO)缓冲器。FIFOs 之所以重要,有很多原因,但在考虑编程时,有两个属性特别有用:

  1. 伴随数据的还有隐含的控制信息。这些信号告诉我们 FIFO 是空的还是满的,这在将问题分解成独立的部分时很有用。

  2. FIFOs 有存储容量。这使得在存在动态行为(如访问内存时高度可变的延迟)的情况下实现性能变得更加容易。

图 17-28 显示了一个 FIFO 操作的简单例子。

img/489625_1_En_17_Fig28_HTML.png

图 17-28

一段时间内 FIFO 的操作示例

在 DPC++ 中,FIFOs 是通过一个名为管道的特性公开的。在编写 FPGA 程序时,我们应该关心管道的主要原因是,它们允许我们将问题分解为更小的部分,以便以更模块化的方式专注于开发和优化。它们还允许利用 FPGA 丰富的通信功能。图 17-29 用图形显示了这两种情况。

img/489625_1_En_17_Fig29_HTML.png

图 17-29

管道简化了模块化设计和对硬件外设的访问

请记住,FPGA 内核可以同时存在于器件上(位于芯片的不同区域),在高效设计中,内核的所有部分在每个时钟周期都是活动的。这意味着优化 FPGA 应用需要考虑内核或部分内核之间的交互方式,而管道提供了一种抽象来简化这一过程。

管道是使用 FPGA 上的片内存储器实现的 FIFOs,因此它们允许我们在运行的内核之间和内部进行通信,而无需将数据移动到片外存储器。这提供了廉价的通信,并且与管道(空/满信号)耦合的控制信息提供了轻量级的同步机制。

DO WE NEED PIPES?

不。不使用管道也可以编写高效的内核。我们可以使用所有的 FPGA 资源,并使用没有管道的传统编程风格实现最高性能。但是对于大多数开发人员来说,编程和优化更模块化的空间设计更容易,管道是实现这一点的好方法。

如图 17-30 所示,有四种通用类型的管道可供选择。在本节的剩余部分,我们将讨论第一种类型(内核间管道),因为它们足以说明什么是管道以及如何使用管道。管道还可以在单个内核中通信,并与主机或输入/输出外围设备通信。关于管道的形式和用途的更多信息,请查阅供应商文档,我们在这里没有空间深入讨论。

img/489625_1_En_17_Fig30_HTML.png

图 17-30

DPC++ 中管道连接的类型

一个简单的例子如图 17-31 所示。在这种情况下,有两个内核通过管道进行通信,每个读或写操作在一个int单元上进行。

img/489625_1_En_17_Fig31_HTML.png

图 17-31

两个内核之间的管道:(1) ND-range 和(2)具有循环的单个任务

从图 17-31 中可以观察到几点。首先,两个内核使用管道相互通信。如果内核之间没有访问器或事件依赖,DPC++ 运行时将同时执行两个,允许它们通过管道而不是完整的 SYCL 内存缓冲区或 USM 进行通信。

*使用基于类型的方法识别管道,其中每个管道使用管道类型的参数化进行识别,如图 17-32 所示。管道类型的参数化标识了特定的管道。对同一管道类型的读取或写入是对同一 FIFO 的。有三个模板参数共同定义了管道的类型和标识。

img/489625_1_En_17_Fig32_HTML.png

图 17-32

管道类型的参数化

建议使用类型别名来定义我们的管道类型,如图 17-31 的第一行代码所示,以减少编程错误,提高代码可读性。

使用类型别名来标识管道。这简化了代码,并防止意外创建意外管道。

管道有一个min_capacity参数。它默认为 0,这是自动选择,但是如果指定,它保证至少该数量的字可以被写入管道而不被读出。此参数在以下情况下很有用

  1. 与管道通信的两个内核不同时运行,我们需要管道中有足够的容量供第一个内核在第二个内核开始运行并从管道中读取数据之前写入其所有输出。

  2. 如果内核以突发方式生成或消耗数据,那么增加管道的容量可以在内核之间提供隔离,使它们的性能彼此分离。例如,产生数据的内核可以继续写入(直到管道容量变满),即使消耗该数据的内核很忙,还没有准备好消耗任何东西。这提供了内核相对于彼此执行的灵活性,代价仅仅是 FPGA 上的一些存储器资源。

阻塞和非阻塞管道入口

和大多数 FIFO 接口一样,管道有两种接口风格:阻塞非阻塞。阻塞访问等待(阻塞/暂停执行!)以使操作成功,而非阻塞访问会立即返回,并设置一个布尔值来指示操作是否成功。

成功的定义很简单:如果我们从管道中读取,并且有数据可供读取(管道不为空),那么读取成功。如果我们正在写并且管道还没有满,那么写成功。图 17-33 显示了 pipe 类访问成员函数的两种形式。我们看到管道的成员函数允许它被写入或读取。回想一下,对管道的访问可以是阻塞的,也可以是非阻塞的。

img/489625_1_En_17_Fig33_HTML.png

图 17-33

允许对其进行读写的管道成员函数

阻塞访问和非阻塞访问都有其用途,这取决于我们的应用程序试图实现的目标。如果内核在从管道中读取数据之前不能做更多的工作,那么使用阻塞读取可能是有意义的。相反,如果内核希望从一组管道中的任何一个读取数据,并且不确定哪个管道可能有可用的数据,那么使用非阻塞调用从管道读取数据更有意义。在这种情况下,内核可以从管道中读取数据并处理数据(如果有数据的话),但是如果管道是空的,它可以继续尝试从下一个可能有数据可用的管道中读取数据。

有关管道的更多信息

在这一章中,我们只能触及管道的表面,但是我们现在应该对它们有一个概念,以及如何使用它们的基本知识。FPGA 供应商文档提供了更多信息,以及它们在不同类型应用中的使用示例,因此,如果我们认为管道与我们的特定需求相关,我们应该查看一下。

定制存储系统

在为大多数加速器编程时,大部分优化工作都倾向于使内存访问更加高效。FPGA 设计也是如此,尤其是当输入和输出数据通过片外存储器时。

FPGA 上的存储器访问值得优化有两个主要原因:

  1. 减少所需的带宽,特别是在一些带宽使用效率低下的情况下

  2. 修改导致空间流水线中不必要的停顿的存储器上的访问模式

有必要简单谈谈空间管道中的失速。编译器内置了关于从特定类型的内存中读取或向其写入所需时间的假设,并相应地优化和平衡了流水线,从而隐藏了进程中的内存延迟。但是,如果我们以低效的方式访问内存,我们可能会引入更长的延迟,并且作为一种副产品在流水线中停止,其中早期阶段无法取得执行进展,因为它们被等待某些东西(例如,内存访问)的流水线阶段阻塞。图 17-34 显示的就是这样一种情况,负载上方的管线停滞不前,无法前进。

img/489625_1_En_17_Fig34_HTML.png

图 17-34

内存停顿如何导致早期管道阶段也停顿

内存系统优化可以在几个方面进行。像往常一样,编译器报告是我们了解编译器为我们实现了什么,以及什么可能值得调整或改进的主要指南。我们在这里列出了几个优化主题,以突出我们可用的一些自由度。优化通常可以通过显式控件和修改代码来实现,以允许编译器推断出我们想要的结构。编译器静态报告和供应商文档是内存系统优化的关键部分,有时会在硬件执行期间与评测工具结合使用,以捕获实际的内存行为,用于验证或最终的调整阶段。

  1. 静态合并(Static coalescing):编译器会将内存访问合并成数量更少、范围更广的访问。这降低了存储器系统在流水线中加载或存储单元的数量、存储器系统上的端口、仲裁网络的大小和复杂性以及其它存储器系统细节方面的复杂性。一般来说,我们希望尽可能启用静态合并,这可以通过编译器报告来确认。简化内核中的寻址逻辑有时足以让编译器执行更积极的静态合并,因此请始终检查报告,确保编译器已经推断出我们所期望的内容!

  2. 内存访问 风格:编译器为内存访问创建加载或存储单元,这些单元针对被访问的内存技术(例如片上与 DDR 或 HBM)以及从源代码推断的访问模式(例如流、动态合并/加宽,或者可能受益于特定大小的高速缓存)而定制。编译器报告告诉我们编译器推断出了什么,并允许我们在相关的地方修改或添加控制到我们的代码,以提高性能。

  3. 内存系统 结构:内存系统(片内和片外)可以具有由编译器实现的分组结构和众多优化。有许多控件和模式修改可用于控制这些结构和调整空间实现的特定方面。

一些结束主题

在与初学 FPGAs 的开发人员交谈时,我们发现从较高的层面理解组成器件的元件以及提到时钟频率(这似乎是一个困惑点)通常会有所帮助。我们以这些话题结束这一章。

FPGA 构建模块

为了帮助理解工具流(特别是编译时),有必要提一下构成 FPGA 的构建模块。这些构建块是通过 DPC++ 和 SYCL 抽象出来的,它们的知识在典型的应用程序开发中不起作用(至少在使代码功能化的意义上)。然而,它们的存在确实会影响空间架构优化和工具流的直觉发展,例如,在为我们的应用程序选择理想的数据类型时,偶尔会影响高级优化。

一个非常简化的现代 FPGA 器件由五个基本元件组成。

  1. 查找表:有几根二进制输入线并产生二进制输出的基本块。相对于输入的输出通过编程到查找表中的条目来定义。这些是非常原始的模块,但是在用于计算的典型现代 FPGA 上有许多(数百万)这样的模块。这些是我们大部分设计实现的基础!

  2. 数学引擎:对于常见的数学运算,如单精度浮点数的加法或乘法,FPGAs 有专门的硬件来使这些运算非常高效。一个现代的 FPGA 有数千个这样的模块——有些设备有超过 8000 个——这样至少这些浮点原语操作可以在每个时钟周期并行执行!大多数 FPGAs 将这些数学引擎命名为数字信号处理器(DSP)。

  3. 片内存储器:这是 FPGAs 区别于其他加速器的一个方面,存储器有两种类型(更确切地说,但我们不会在这里讨论):(1)寄存器,用于操作和其他一些目的之间的管道传输,以及(2)块存储器,提供遍布整个设备的小型随机存取存储器。现代 FPGA 可以拥有数百万个寄存器位和超过 10,000 个 20 Kbit RAM 存储模块。由于每一个时钟周期都可以激活,因此如果有效利用,片内存储器容量和带宽会非常可观。

  4. 片外硬件接口:FPGA 的发展在一定程度上是因为其非常灵活的收发器和输入/输出连接,允许与从片外存储器到网络接口等几乎任何东西进行通信。

  5. 路由结构 之间所有其他元素:在一个典型的 FPGA 上,前面提到的每个元素都有很多,它们之间的连接是不固定的。复杂的可编程路由结构允许信号在构成 FPGA 的细粒度元素之间传递。

给定每种特定类型的 FPGA 上的块的数量(一些块以百万计)和那些块的精细粒度,例如查找表,在生成 FPGA 配置比特流时看到的编译时间可能更有意义。不仅需要将功能分配给每个细粒度的资源,还需要在它们之间配置路由。在优化开始之前,大部分编译时间来自于找到我们的设计到 FPGA 结构的第一个合法映射!

时钟频率

FPGA 非常灵活且可配置,且与加固到 CPU 或任何其他固定计算机架构中等效设计相比,这种可配置性对 FPGA 运行的频率带来了一些成本。但这不是问题!FPGA 的空间架构不仅弥补了时钟频率,因为有如此多的独立操作同时发生,分布在 FPGA 的整个区域。简而言之,由于可配置的设计,FPGA 的频率低于其他架构,但每个时钟周期会发生更多的变化,从而平衡频率。在基准测试和比较加速器时,我们应该比较计算吞吐量(例如,每秒操作数)而不是原始频率。

也就是说,当 FPGA 上的资源利用率接近 100%时,工作频率可能会开始下降。这主要是由于设备上的信号路由资源被过度使用。有一些方法可以解决这个问题,通常是以增加编译时间为代价。但是,对于大多数应用,最好避免使用 FPGA 上超过 80–90%的资源,除非我们愿意深入细节以抵消频率下降。

经验法则是尽量不要超过 FPGA 上任何资源的 90%,当然也不要超过多个资源的 90%。超出可能会导致路由资源耗尽,从而导致工作频率降低,除非我们愿意深入研究较低级别的 FPGA 细节来抵消这一点。

摘要

在本章中,我们介绍了流水线如何将算法映射到 FPGA 的空间架构。我们还讨论了一些概念,这些概念可以帮助我们判断 FPGA 对我们的应用是否有用,并且可以帮助我们更快地启动和运行开发代码。从这一点出发,我们应该能够浏览供应商的编程和优化手册,并开始编写 FPGA 代码!FPGAs 提供的性能和支持的应用在其他加速器上没有意义,所以我们应该把它们放在我们大脑工具箱的前端!

Creative Commons

开放存取本章根据知识共享署名 4.0 国际许可证(http://Creative Commons . org/licenses/by/4.0/)的条款获得许可,该许可证允许以任何媒体或格式使用、共享、改编、分发和复制,只要您适当注明原作者和来源,提供知识共享许可证的链接并指明是否进行了更改。

本章中的图像或其他第三方材料包含在本章的知识共享许可中,除非在材料的信用额度中另有说明。如果材料不包括在本章的知识共享许可中,并且您的预期使用不被法律法规允许或超出了允许的使用范围,您将需要直接从版权所有者处获得许可。*

十八、库

img/489625_1_En_18_Figa_HTML.gif

我们花了整本书来宣传编写我们自己的代码的艺术。现在我们终于承认,一些伟大的程序员已经写出了我们可以直接使用的代码。库是我们完成工作的最好方式。这不是懒惰的问题,而是有比重新发明他人的工作更好的事情要做的问题。这是一块值得拥有的拼图。

开源的 DPC++ 项目包括一些库。这些库可以帮助我们继续使用 libstdc++、libc++ 和 MSVC 库函数,甚至是在我们的内核代码中。这些库包含在英特尔的 DPC++ 和 oneAPI 产品中。这些库不依赖于 DPC++ 编译器,因此它们可以与任何 SYCL 编译器一起使用。

DPC++ 库为创建异构应用程序和解决方案的程序员提供了另一种选择。它的 API 基于熟悉的标准——c++ STL、并行 STL (PSTL)和 SYCL——为程序员提供高生产率的 API。这可以最大限度地减少 CPU、GPU 和 FPGAs 之间的编程工作量,同时实现可移植的高性能并行应用。

SYCL 标准定义了一组丰富的内置函数,为主机和设备代码提供功能,也值得考虑。DPC++ 和许多 SYCL 实现用数学库实现了关键的数学内置。

本章讨论的库和内置是编译器不可知的。换句话说,它们同样适用于 DPC++ 编译器或 SYCL 编译器。fpga_device_policy类是用于 FPGA 支持的 DPC++ 特性。

由于在命名和功能上有重叠,本章将从 SYCL 内置函数的简单介绍开始。

内置函数

DPC++ 针对各种数据类型提供了一组丰富的 SYCL 内置函数。这些内置函数在主机和设备上的sycl命名空间中可用,根据编译器选项对目标设备提供低、中、高精度支持,例如 DPC++ 编译器提供的-mfma-ffast-math-ffp-contract=fast。主机和设备上的这些内置功能可分为以下几类:

  • 浮点数学函数:asin、acoslog, sqrtfloor等。在图 18-2 中列出。

  • 整数函数:absmax, min等。如图 18-3 所示。

  • 常用功能:clampsmoothstep等。如图 18-4 所示。

  • 几何函数:crossdotdistance等。在图 18-5 中列出。

  • 关系函数:isequalislessisfinite等。在图 18-6 中列出。

如果 C++ std 库提供了一个函数,如图 18-8 所列,以及一个 SYCL 内置函数,那么 DPC++ 程序员可以使用其中任何一个。图 18-1 展示了主机和设备的 C++ std::log函数和 SYCL 内置sycl::log函数,两个函数产生相同的数值结果。在示例中,内置的关系函数sycl::isequal用于比较std:logsycl:的结果。

img/489625_1_En_18_Fig1_HTML.png

图 18-1

使用std::logsycl:: log

除了 SYCL 中支持的数据类型,DPC++ 设备库还支持将std:complex作为一种数据类型,以及 C++ std 库中定义的相应数学函数。

对内置函数使用前缀sycl::

调用 SYCL 内置函数时,应该在名字前添加一个明确的sycl::。根据当前的 SYCL 规范,即使使用了“using namespace sycl;”,也不能保证只调用sqrt()就能调用所有实现中内置的 SYCL。

调用 SYCL 内置函数时,应该在内置名称前加上一个明确的sycl::。不遵循这个建议可能会导致奇怪和不可移植的结果。

如果内置函数名与我们应用程序中的非模板化函数冲突,在许多实现中(包括 DPC++ ),我们的函数将优先,这要归功于 C++ 重载决策规则,它更喜欢非模板化函数而不是模板化函数。然而,如果我们的代码有一个与内置名相同的函数名,最方便的做法是避免using namespace sycl;或者确保没有实际冲突发生。否则,一些 SYCL 编译器会因为实现中无法解决的冲突而拒绝编译代码。这样的冲突不会沉寂。因此,如果我们的代码今天编译,我们可以安全地忽略未来问题的可能性。

img/489625_1_En_18_Fig6_HTML.png

图 18-6

内置关系函数

img/489625_1_En_18_Fig5_HTML.png

图 18-5

内置几何函数

img/489625_1_En_18_Fig4_HTML.png

图 18-4

内置常用功能

img/489625_1_En_18_Fig3a_HTML.png

图 18-3

内置整数函数

img/489625_1_En_18_Fig2_HTML.png

图 18-2

内置数学函数

DPC++ 库

DPC++ 库由以下组件组成:

  • 一组经过测试的 C++ 标准 API——我们只需要包含相应的 C++ 标准头文件,并使用std名称空间。

  • 包含相应头文件的并行 STL。我们简单地使用#include <dpstd/...>来包含它们。DPC++ 库将名称空间dpstd用于扩展的 API 类和函数。

DPC++ 中的标准 c++ API

DPC++ 库包含一组经过测试的标准 c++ API。已经开发了许多 C++ 标准 API 的基本功能,使得这些 API 可以在设备内核中使用,类似于它们在典型 C++ 主机应用程序的代码中的使用方式。图 18-7 显示了如何在设备代码中使用std::swap的例子。

img/489625_1_En_18_Fig7_HTML.png

图 18-7

在设备代码中使用std::swap

我们可以使用下面的命令来构建和运行程序(假设它驻留在stdswap.cpp文件中):


dpcpp –std=c++17 stdswap.cpp –o stdswap.exe
./stdswap.exe

打印结果是:


8, 9
9, 8

图 18-8 列出了带有“ Y 的 C++ 标准 API,以表明在撰写本文时,这些 API 已经过测试,可用于 CPU、 FPGA 设备的 DPC++ 内核。空白表示本书出版时覆盖范围不完整(并非所有三种设备类型)。作为在线 DPC++ 语言参考指南的一部分,还包括一个表,该表将随着时间的推移而更新——dpc++ 中的库支持将继续扩展其支持。

在 DPC++ 库中,一些 C++ std函数是基于它们在设备上对应的内置函数实现的,以达到与这些函数的 SYCL 版本相同的性能水平。

img/489625_1_En_18_Fig8a_HTML.png img/489625_1_En_18_Fig8b_HTML.png

图 18-8

包含 CPU/GPU/FPGA 的库支持(在图书出版时)

测试的标准 C++ API 在libstdc++ (GNU)的gcc 7.4.0 和libc++ (LLVM)的clang 10.0 中得到支持,MSVC 标准 c++ 库的 Microsoft Visual Studio 2017 也支持主机 CPU。

在 Linux 上,GNU libstdc++是 DPC++ 编译器的默认 C++ 标准库,因此不需要编译或链接选项。如果我们想使用libc++,使用编译选项-stdlib=libc++ -nostdinc++来利用libc++,并且不包括系统中的 C++ std 头文件。已经在 Linux 上的 DPC++ 内核中使用libc++验证了 DPC++ 编译器,但是需要使用libc++而不是libstdc++来重建 DPC++ 运行时。详情在 https://intel.github.io/llvm-docs/GetStartedGuide.html#build-dpc-toolchain-with-libc-library 。由于这些额外的步骤,libc++不是我们通常使用的推荐的 C++ 标准库。

在 FreeBSD 上,libc++是默认的标准库,不需要-stdlib=libc++选项。更多详情请见 https://libcxx.llvm.org/docs/UsingLibcxx.html 。在 Windows 上,只能使用 MSVC C++ 库。

为了实现跨架构的可移植性,如果一个 std 函数在图 18-8 中没有标注“Y”,我们在编写设备函数的时候就需要牢记可移植性!

DPC++ 并行 STL

并行 STL 是支持执行策略的 C++ 标准库算法的实现,如 ISO/IEC 14882:2017 标准(通常称为 C++17)中所规定的。现有实现还支持在并行 ts 版本 2 中指定的未排序执行策略,并在 C++ 工作组论文 P1001R1 中为下一版本的 C++ 标准提出了该策略。

当使用算法和执行策略时,如果没有 C++17 标准库的特定于供应商的实现,则指定名称空间std::execution,否则指定pstl::execution

对于任何已实现的算法,我们可以传递值sequnseqparpar_unseq中的一个作为算法调用的第一个参数,以指定所需的执行策略。这些策略具有以下含义:

|

执行策略

|

意为

|
| --- | --- |
| seq | 顺序执行。 |
| unseq | 未排序的 SIMD 处决。该政策要求所提供的所有功能在 SIMD 均可安全执行。 |
| par | 多线程并行执行。 |
| par_unseq | unseqpar.的综合效果 |

DPC++ 的并行 STL 通过使用特殊的执行策略扩展了对 DPC++ 设备的支持。DPC++ 执行策略指定了并行 STL 算法在哪里以及如何运行。它继承了标准的 C++ 执行策略,封装了 SYCL 设备或队列,并允许我们设置可选的内核名称。DPC++ 执行策略可以与所有支持符合 C++17 标准的执行策略的标准 C++ 算法一起使用。

DPC++ 执行策略

目前,DPC++ 库只支持并行未排序策略(par_unseq)。为了使用 DPC++ 执行策略,有三个步骤:

  1. #include <dpstd/execution>添加到我们的代码中。

  2. 通过提供标准策略类型、作为模板参数的唯一内核名称的类类型(可选)以及下列构造器参数之一来创建策略对象:

    • 赛 CL 队列

    • SYCL 设备

    • SYCL 设备选择器

    • 具有不同内核名称的现有策略对象

  3. 将创建的策略对象传递给并行 STL 算法。

一个dpstd::execution::default_policy对象是一个预定义的device_policy,使用默认内核名和默认队列创建。这可用于创建自定义策略对象,或者在调用算法时直接传递(如果默认选项足够的话)。

图 18-9 显示了引用policy类和函数时假设使用using namespace dpstd::execution;指令的例子。

img/489625_1_En_18_Fig9_HTML.png

图 18-9

创建执行策略

FPGA 执行策略

fpga_device_policy类是一种 DPC++ 策略,旨在 FPGA 硬件设备上实现更好的并行算法性能。在 FPGA 硬件或 FPGA 仿真设备上运行应用程序时使用策略:

  1. 定义在 FPGA 设备上运行的_PSTL_FPGA_DEVICE宏,以及在 FPGA 仿真设备上运行的_PSTL_FPGA_EMU

  2. 将#include 添加到我们的代码中。

  3. 通过提供唯一内核名称的类类型和展开因子(见第十七章)作为模板参数(两者都是可选的)和下列构造器参数之一,创建一个策略对象:

  4. 将创建的策略对象传递给并行 STL 算法。

fpga_device_policy的默认构造器创建一个对象,该对象带有为fpga_selector构造的 SYCL 队列,或者如果定义了_PSTL_FPGA_EMU的话,为fpga_emulator_selector构造的 SYCL 队列。

dpstd::execution::fpga_policy是用默认内核名和默认展开因子创建的fpga_device_policy类的预定义对象。使用它来创建定制的策略对象,或者在调用算法时直接传递它。

图 18-10 中的代码假设using namespace dpstd::execution;用于策略,而using namespace sycl;用于队列和设备选择器。

为策略指定展开因子可以在算法实现中实现循环展开。默认值为 1。要了解如何选择更好的值,请参见第十七章。

img/489625_1_En_18_Fig10_HTML.png

图 18-10

使用 FPGA 策略

使用 DPC++ 并行 STL

为了使用 DPC++ 并行 STL,我们需要通过添加以下行的子集来包含并行 STL 头文件。这些行取决于我们打算使用的算法:

  • #include <dpstd/algorithm>

  • #include <dpstd/numeric>

  • #include <dpstd/memory>

dpstd::begindpstd::end是特殊的帮助函数,允许我们将 SYCL 缓冲区传递给并行 STL 算法。这些函数接受 SYCL 缓冲区,并返回满足以下要求的未指定类型的对象:

  • CopyConstructibleCopyAssignable,与操作者==!=可比。

  • 以下表达式有效:a + na – na – b,其中ab是类型的对象,n是整数值。

  • 有一个没有参数的get_buffer方法。该方法返回传递给dpstd::begindpstd::end函数的 SYCL 缓冲区。

要使用这些助手函数,请将#include <dpstd/iterators>添加到我们的代码中。参见图 18-11 和 18-12 中使用std::fill函数的代码,作为使用开始/结束帮助器的示例。

img/489625_1_En_18_Fig11_HTML.png

图 18-11

使用std::fill

REDUCE DATA COPYING BETWEEN THE HOST AND DEVICE

并行 STL 算法可以用普通(主机端)迭代器调用,如图 18-11 中的代码示例所示。

在这种情况下,会创建一个临时 SYCL 缓冲区,并将数据复制到该缓冲区。在对设备上的临时缓冲区的处理完成之后,数据被复制回主机。建议尽可能直接使用现有的 SYCL 缓冲区,以减少主机和设备之间的数据移动,以及任何不必要的缓冲区创建和销毁开销。

img/489625_1_En_18_Fig12_HTML.png

图 18-12

使用默认策略的std::fill

图 18-13 显示了一个为所提供的搜索序列中的每个值执行输入序列二分搜索法的例子。作为对搜索序列的第i 元素的搜索结果,指示在输入序列中是否找到搜索值的布尔值被分配给结果序列的第i 个元素。该算法返回一个迭代器,该迭代器指向分配了结果的结果序列的最后一个元素之后的元素。该算法假设输入序列已经由所提供的比较器排序。如果没有提供比较器,那么将使用一个使用operator<来比较元素的函数对象。

前面描述的复杂性强调了我们应该尽可能地利用库函数,而不是编写我们自己的类似算法的实现,这可能需要大量的调试和调优时间。我们可以利用的库的作者通常是他们正在编码的设备架构内部的专家,并且可能访问我们不知道的信息,所以当优化的库可用时,我们应该总是利用它们。

图 18-13 所示的代码示例展示了使用 DPC++ 并行 STL 算法的三个典型步骤:

  • 创建 DPC++ 迭代器。

  • 从现有策略创建命名策略。

  • 调用并行算法。

img/489625_1_En_18_Fig13_HTML.png

图 18-13

使用binary_search

图 18-13 中的示例使用dpstd::binary_search算法根据我们的器件选择在 CPU、GPU 或 FPGA 上执行二分搜索法。

使用 USM 并行 STL

以下示例描述了将并行 STL 算法与 USM 结合使用的两种方式:

  • 通过 USM 指针

  • 通过 USM 分配器

如果我们有一个 USM 分配,我们可以将指向分配起点和终点的指针传递给一个并行算法。务必确保执行策略和分配本身是为同一队列或上下文创建的,以避免运行时出现未定义的行为。

如果相同的分配要由几个算法处理,要么使用有序队列,要么在下一个算法中使用相同的分配之前明确等待每个算法完成(这是使用 USM 时的典型操作排序)。同样等待完成后再访问主机上的数据,如图 18-14 所示。

或者,我们可以使用带有 USM 分配器的std::vector,如图 18-15 所示。

img/489625_1_En_18_Fig15_HTML.png

图 18-15

通过 USM 分配器使用并行 STL

img/489625_1_En_18_Fig14_HTML.png

图 18-14

使用带有 USM 指针的并行 STL

DPC++ 执行策略的错误处理

如第五章所述,DPC++ 错误处理模型支持两种类型的错误。对于同步错误,运行时抛出异常,而异步错误只在程序执行期间的指定时间在用户提供的错误处理程序中处理。

对于用 DPC++ 策略执行的并行 STL 算法,处理所有的错误,不管是同步的还是异步的,都是调用者的责任。明确地

  • 算法不会显式抛出异常。

  • 由运行时在主机 CPU 上引发的异常(包括 DPC++ 同步异常)被传递给调用方。

  • 并行 STL 不处理 DPC++ 异步错误,所以必须由调用应用程序处理(如果需要处理的话)。

若要处理 DPC++ 异步错误,必须使用错误处理程序对象创建与 DPC++ 策略关联的队列。预定义的策略对象(default_policy和其他)没有错误处理程序,所以如果我们需要处理异步错误,我们应该创建自己的策略。

摘要

DPC++ 库是 DPC++ 编译器的配套产品。它使用预构建和调优的通用函数和并行模式库,帮助我们解决部分异构应用程序的问题。DPC++ 库允许在内核中显式使用 C++ STL API,它通过并行 STL 算法扩展简化了跨架构编程,并通过自定义迭代器增加了并行算法的成功应用。除了支持熟悉的库(libstdc++、libc++、MSVS),DPC++ 还提供了对 SYCL 内置函数的全面支持。本章概述了利用他人成果而不是自己编写所有内容的方法,我们应该尽可能使用这种方法来简化应用程序开发,并经常实现卓越的性能。

Creative Commons

开放存取本章根据知识共享署名 4.0 国际许可证(http://Creative Commons . org/licenses/by/4.0/)的条款获得许可,该许可证允许以任何媒体或格式使用、共享、改编、分发和复制,只要您适当注明原作者和来源,提供知识共享许可证的链接并指明是否进行了更改。

本章中的图像或其他第三方材料包含在本章的知识共享许可中,除非在材料的信用额度中另有说明。如果材料不包括在本章的知识共享许可中,并且您的预期使用不被法律法规允许或超出了允许的使用范围,您将需要直接从版权所有者处获得许可。

十九、内存模型和原子

img/489625_1_En_19_Figa_HTML.gif

如果我们想成为优秀的并行程序员,内存一致性并不是一个深奥的概念。它是我们难题的关键部分,帮助我们确保数据在我们需要的时候出现在我们需要的地方,并且它的值是我们所期望的。这一章揭示了我们需要掌握的关键东西,以确保我们的程序正确运行。这个主题不是 SYCL 或 DPC++ 所独有的。

对编程语言的内存(一致性)模型有一个基本的理解对于任何想要允许对内存进行并发更新的程序员来说都是必要的(无论这些更新来自同一个内核中的多个工作项、多个设备,还是两者都有)。不管内存是如何分配的,这都是正确的,无论我们选择使用缓冲区还是 USM 分配,本章的内容对我们来说都是同样重要的。

在前面的章节中,我们已经关注了简单内核的开发,其中程序实例要么对完全独立的数据进行操作,要么使用结构化的通信模式共享数据,这些模式可以使用语言和/或库功能直接表达。随着我们向编写更复杂和更现实的内核迈进,我们很可能会遇到程序实例需要以更少结构化的方式进行通信的情况——理解内存模型如何与 DPC++ 语言特性和我们所针对的硬件功能相关联是设计正确、可移植和高效程序的必要前提。

标准 C++ 的内存一致性模型足以编写完全在主机设备上执行的应用程序,但 DPC++ 对其进行了修改,以解决在对异构系统进行编程时以及在讨论不能完全映射到 C++ 线程概念的程序实例时可能出现的复杂性。具体来说,我们需要能够

  • 系统中的哪些设备可以访问哪些类型的内存分配:使用缓冲区和 USM。

  • 在内核执行期间防止不安全的并发内存访问(数据竞争):使用屏障和原子。

  • 启用执行相同内核的程序实例之间的安全通信和不同设备之间的安全通信:使用屏障、栅栏、原子、内存顺序和内存范围。

  • 防止可能会以不符合我们预期的方式改变并行应用程序行为的优化:使用屏障、栅栏、原子、内存顺序和内存范围。

  • 启用依赖于程序员意图知识的优化:使用内存顺序和内存范围。

内存模型是一个复杂的话题,但有一个很好的理由——处理器架构师关心让处理器和加速器尽可能高效地执行我们的代码!在这一章中,我们努力打破这种复杂性,突出最关键的概念和语言特征。这一章让我们不仅对内存模型了如指掌,还享受了很多人不知道的并行编程的一个重要方面。如果在阅读了这里的描述和示例代码后仍有疑问,我们强烈建议访问本章末尾列出的网站或参考 C++、SYCL 和 DPC++ 语言规范。

内存模型中有什么?

本节详细阐述了编程语言包含内存模型的动机,并介绍了并行程序员应该熟悉的几个核心概念:

  • 数据竞争和同步

  • 栅栏和围栏

  • 原子操作

  • 内存排序

要理解这些概念在 C++、SYCL 和 DPC++ 中的表达和用法,从高层次理解它们是必要的。在并行编程,尤其是使用 C++ 方面有丰富经验的读者可能希望跳过这一步。

数据竞争和同步

我们在程序中编写的操作通常不会直接映射到单个硬件指令或微操作。一个简单的加法操作,如data[i] += x,可以分解成一系列的指令或微操作:

  1. data[i]从内存加载到一个临时寄存器中。

  2. 计算xdata[i].相加的结果

  3. 将结果存储回data[i].

这不是我们在开发顺序应用程序时需要担心的事情——加法的三个阶段将按照我们期望的顺序执行,如图 19-1 所示。

img/489625_1_En_19_Fig1_HTML.png

图 19-1

data[i] += x的顺序执行分为三个独立的操作

切换到并行应用程序开发带来了额外的复杂性:如果我们有多个操作并发地应用于相同的数据,我们如何确定他们对该数据的观点是一致的?考虑图 19-2 所示的情况,其中data[i] += x的两次执行已经交错。如果两次执行使用不同的i值,应用程序将正确执行。如果它们使用相同的i值,两者都从内存中加载相同的值,并且一个结果被另一个覆盖!这只是调度它们的操作的许多可能方式之一,我们的应用程序的行为取决于哪个程序实例先获得哪个数据——我们的应用程序包含一个数据竞争

img/489625_1_En_19_Fig2_HTML.png

图 19-2

并发执行的data[i] += x的一个可能交错

图 19-3 中的代码和图 19-4 中的输出显示了这在实践中是多么容易发生。如果M大于等于N,则j在每个程序实例中的值是唯一的;否则,j的值将会冲突,更新可能会丢失。我们说可能会丢失,因为包含数据竞争的程序仍然可以在某些时候或所有时间产生正确的答案(取决于实现和硬件如何调度工作)。编译器和硬件都不可能知道这个程序打算做什么,或者NM的值在运行时可能是什么——作为程序员,我们有责任了解我们的程序是否可能包含数据竞争,以及它们是否对执行顺序敏感。

img/489625_1_En_19_Fig4_HTML.png

图 19-4

图 19-3 中NM的小值代码输出示例

img/489625_1_En_19_Fig3_HTML.png

图 19-3

包含数据竞争的内核

一般来说,当开发大规模并行应用程序时,我们不应该关心单个工作项执行的确切顺序——希望有数百个(或数千个!)并发执行的工作项,试图对它们强加特定的顺序将会对可伸缩性和性能产生负面影响。相反,我们的重点应该是开发正确执行的可移植应用程序,这可以通过向编译器(和硬件)提供有关程序实例何时共享数据、共享发生时需要什么保证以及哪些执行顺序是合法的信息来实现。

大规模并行应用程序不应该关心单个工作项执行的确切顺序!

栅栏和围栏

防止同一组中的工作项之间的数据竞争的一种方法是使用工作组屏障和适当的内存栅栏在不同的程序实例之间引入同步。我们可以使用一个工作组屏障来排序我们对data[i]的更新,如图 19-5 所示,我们示例内核的更新版本如图 19-6 所示。请注意,因为工作组屏障不同步不同组中的工作项,所以只有将我们自己限制在一个工作组中,我们的简单示例才能保证正确执行!

img/489625_1_En_19_Fig6_HTML.png

图 19-6

使用屏障避免数据竞争

img/489625_1_En_19_Fig5_HTML.png

图 19-5

被屏障隔开的两个data[i] += x实例

虽然使用屏障来实现这种模式是可能的,但是通常并不鼓励这样做——它会强制一个组中的工作项按照特定的顺序依次执行,这可能会导致在出现负载不平衡的情况下长时间处于不活动状态。它也可能引入比严格必要的更多的同步——如果不同的程序实例碰巧使用不同的i值,它们仍将被迫在关卡处同步。

屏障同步是一个有用的工具,可以确保工作组或子组中的所有工作项在进入下一个阶段之前完成内核的某个阶段,但是对于细粒度(并且可能依赖于数据)同步来说太笨重了。对于更一般的同步模式,我们必须关注原子操作。

原子操作

原子操作允许对存储器位置的并发访问,而不会引入数据竞争。当多个原子操作访问同一个内存时,保证它们不会重叠。请注意,如果只有一些访问使用原子性,那么这种保证就不适用,作为程序员,我们有责任确保我们不会使用具有不同原子性保证的操作同时访问相同的数据。

同时在相同的内存位置上混合原子和非原子操作会导致未定义的行为!

如果我们的简单加法是用原子操作来表达的,结果可能如图 19-8 所示——每次更新现在都是一个不可分割的工作块,我们的应用程序将总是产生正确的结果。相应的代码如图 19-7 所示——我们将在本章后面重新讨论atomic_ref类及其模板参数的含义。

img/489625_1_En_19_Fig8_HTML.png

图 19-8

与原子操作同时执行的data[i] += x的交错

img/489625_1_En_19_Fig7_HTML.png

图 19-7

使用原子操作避免数据竞争

但是,需要注意的是,这仍然只是一种可能的执行顺序。使用原子操作保证了两个更新不会重叠(如果两个实例使用相同的值i),但是仍然不能保证两个实例中的哪一个将首先执行。更重要的是,对于不同程序实例中的任何非原子操作,无法保证这些原子操作的顺序。

内存排序

即使在顺序应用中,如果优化编译器和硬件不改变应用的可观察行为,它们也可以自由地重新排序操作。换句话说,应用程序必须像程序员编写的那样运行。

不幸的是,这种假设保证不足以帮助我们对并行程序的执行进行推理。我们现在有两个重新排序的来源要担心:编译器和硬件可能会重新排序每个顺序程序实例中语句的执行,程序实例本身可能会以任何顺序(可能是交错的)执行。为了设计和实现程序实例之间的安全通信协议,我们需要能够约束这种重新排序。通过向编译器提供关于我们期望的内存顺序的信息,我们可以防止与我们的应用程序的预期行为不兼容的重新排序优化。

三种常用的内存排序是

  1. 一个放松的记忆排序

  2. 一个获取-释放释放-获取内存排序

  3. 一个顺序一致的内存排序

在宽松的存储器排序下,存储器操作可以被重新排序而没有任何限制。宽松内存模型最常见的用法是增加共享变量(例如,单个计数器、直方图计算期间的值数组)。

在获取-释放内存排序下,一个程序实例释放一个原子变量,而另一个程序实例获取相同的原子变量,这充当这两个程序实例之间的同步点,并保证由释放实例发出的任何先前对内存的写入对获取实例可见。非正式地,我们可以认为原子操作将其他内存操作的副作用释放给其他程序实例,或者获取内存操作对其他程序实例的副作用。如果我们希望通过内存在程序实例对之间传递值,就需要这样的内存模型,这可能比我们想象的更常见。当一个程序获得一个锁时,它通常会继续执行一些额外的计算,并在最终释放锁之前修改一些内存——只有锁变量会被自动更新,但我们希望由锁保护的内存更新能够避免数据竞争。这种行为依赖于获取-释放内存顺序来保证正确性,试图使用宽松的内存顺序来实现锁是行不通的。

在顺序一致的存储器排序下,获取-释放排序的保证仍然成立,但是另外存在所有原子操作的单个全局顺序。这种内存排序的行为是三种行为中最直观的,也是最接近我们在开发顺序应用程序时习惯依赖的原始假设保证的行为。有了顺序一致性,推理程序实例组(而不是对)之间的通信就变得容易多了,因为所有程序实例必须在所有原子操作的全局排序上达成一致。

了解编程模型和设备的组合支持哪些内存顺序是设计可移植并行应用程序的必要部分。明确描述我们的应用程序所需的内存顺序,可以确保当我们所需的行为不受支持时,它们会以可预测的方式失败(例如,在编译时),并防止我们做出不安全的假设。

内存模型

到目前为止,本章已经介绍了理解内存模型所需的概念。本章的剩余部分详细解释了内存模型,包括

  • 如何表达我们内核的内存排序需求

  • 如何查询特定设备支持的内存顺序

  • 关于不相交的地址空间和多个设备,存储器模型如何表现

  • 内存模型如何与障碍、栅栏和原子相互作用

  • 缓冲区和 USM 之间原子操作的使用有何不同

内存模型基于标准 C++ 的内存模型,但在一些重要方面有所不同。这些差异反映了我们的长期愿景,即 DPC++ 和 SYCL 应该有助于为未来的 C++ 标准提供信息:类的默认行为和命名与 C++ 标准库紧密结合,旨在扩展标准 C++ 功能,而不是限制它。

图 19-9 中的表格总结了不同的内存模型概念如何在标准 C++ (C++11、C++14、C++17、C++20)与 SYCL 和 DPC++ 中作为语言特性公开。C++14、C++17 和 C++20 标准还包括一些影响 C++ 实现的说明。这些澄清不应该影响我们编写的应用程序代码,所以我们在这里不涉及它们。

img/489625_1_En_19_Fig9_HTML.png

图 19-9

比较标准 C++ 和 SYCL/DPC++ 内存模型

memory_order枚举类

内存模型通过memory_order枚举类的六个值公开了不同的内存顺序,这些值可以作为参数提供给栅栏和原子操作。为一个操作提供一个内存顺序参数,告诉编译器相对于该操作的所有其他内存操作(对任何地址)需要什么样的内存顺序保证,如下所述:

  • memory_order::relaxed

    读写操作可以在操作之前或之后重新排序,没有任何限制。没有订购保证。

  • memory_order::acquire

    程序中出现在操作之后的读和写操作必须发生在该操作之后(即,它们不能在操作之前重新排序)。

  • memory_order::release

    出现在程序中的操作之前的读和写操作必须发生在它之前(即,它们不能在操作之后被重新排序),并且之前的写操作保证对于已经被相应的获取操作(即,使用相同变量和memory_order::acquire或屏障函数的原子操作)同步的其他程序实例是可见的。

  • memory_order::acq_rel

    该操作同时充当获取和释放。读取和写入操作不能围绕操作重新排序,必须使之前的写入可见,如之前针对memory_order::release所述。

  • memory_order::seq_cst

    根据是读、写还是读-修改-写操作,该操作分别充当获取、释放或两者。具有这种记忆顺序的所有操作都是以连续一致的顺序观察的。

每个操作支持的内存顺序有几个限制。图 19-10 中的表格总结了哪些组合是有效的。

img/489625_1_En_19_Fig10_HTML.png

图 19-10

memory_order支持原子操作

加载操作不将值写入内存,因此与释放语义不兼容。类似地,存储操作不从内存中读取值,因此与获取语义不兼容。其余的读-修改-写原子操作和栅栏与所有存储器排序兼容。

MEMORY ORDER IN C++

C++ 内存模型还包括memory_order::consume,其行为类似于memory_order::acquire。然而,C++17 标准不鼓励使用它,指出它的定义正在被修改。因此,它在 DPC++ 中的包含被推迟到未来的版本中。

memory_scope枚举类

标准 C++ 内存模型假设应用程序在具有单一地址空间的单一设备上执行。这些假设对于 DPC++ 应用程序都不成立:应用程序的不同部分在不同的设备上执行(即,主机设备和一个或多个加速器设备);每个设备具有多个地址空间(即私有、本地和全局);并且每个设备的全局地址空间可能不相交,也可能不相交(取决于 USM 支持)。

为了解决这个问题,DPC++ 扩展了 C++ 的内存顺序概念,以包括原子操作的范围,表示给定内存顺序约束适用的最小工作项集。范围集是通过一个memory_scope枚举类来定义的:

  • memory_scope::work_item

    内存排序约束仅适用于调用工作项。这个作用域只对图像操作有用,因为一个工作项中的所有其他操作已经保证按程序顺序执行。

  • memory_scope::sub_group, memory_scope::work_group

    内存排序约束仅适用于与调用工作项在同一子组或工作组中的工作项。

  • memory_scope::device

    内存排序约束仅适用于在与调用工作项相同的设备上执行的工作项。

  • memory_scope::system

    内存排序约束适用于系统中的所有工作项目。

除了设备能力所施加的限制,所有内存范围都是所有原子和隔离操作的有效参数。但是,在以下三种情况下,范围参数可能会自动降级到更窄的范围:

  1. 如果一个原子操作更新了工作组本地内存中的一个值,那么任何比memory_scope::work_group更宽的范围都会变窄(因为本地内存只对同一工作组中的工作项可见)。

  2. 如果一个设备不支持 USM,指定memory_scope::system总是等同于memory_scope::device(因为多个设备不能并发访问缓冲区)。

  3. 如果一个原子操作使用memory_order::relaxed,没有排序保证,内存范围参数实际上被忽略了。

查询设备功能

为了确保与以前版本的 SYCL 支持的设备兼容并最大限度地提高可移植性,DPC++ 支持 OpenCL 1.2 设备和其他可能不支持完整 C++ 内存模型的硬件(例如,某些类别的嵌入式设备)。DPC++ 提供设备查询来帮助我们推断系统中可用设备支持的内存顺序和内存范围:

  • atomic_memory_order_capabilities

    atomic_fence_order_capabilities

    返回特定设备上原子和隔离操作支持的所有内存排序的列表。要求所有设备至少支持memory_order::relaxed,要求主机设备支持所有内存排序。

  • atomic_memory_scope_capabilities

    atomic_fence_scope_capabilities

    返回特定设备上原子和隔离操作支持的所有内存范围的列表。要求所有设备至少支持memory_order::work_group,要求主机设备支持所有内存范围。

起初可能很难记住功能和设备能力的哪些组合支持哪些存储器顺序和范围。在实践中,我们可以通过遵循下面概述的两种开发方法之一来避免这种复杂性:

  1. 开发具有顺序一致性和系统防护的应用程序。

    仅考虑在性能调优期间采用不太严格的内存顺序。

  2. 用宽松的一致性和工作组界限开发应用程序。

    只有在正确性需要时,才考虑采用更严格的内存顺序和更宽的内存范围。

第一种方法确保所有原子操作和栅栏的语义与标准 C++ 的默认行为相匹配。这是最简单、最不容易出错的选项,但是具有最差的性能和可移植性。

第二种方法更符合以前版本的 SYCL 和 OpenCL 等语言的默认行为。虽然更复杂——因为它要求我们更加熟悉不同的内存顺序和范围——但它确保了我们编写的大部分 DPC++ 代码可以在任何设备上工作,而不会影响性能。

栅栏和围栏

到目前为止,本书中所有以前使用的障碍和栅栏都忽略了记忆顺序和范围的问题,依赖于默认行为。

DPC++ 中的每个组屏障都充当调用工作项可访问的所有地址空间的获取-释放栅栏,并使之前的写入至少对同一组中的所有其他工作项可见。这确保了一个障碍后一组工作项中的内存一致性,符合我们对同步含义的直觉(以及 C++ 中的 synchronizes-with 关系的定义)。

atomic_fence函数给了我们比这更细粒度的控制,允许工作项以指定的内存顺序和范围执行栅栏。在 DPC++ 的未来版本中,组栅栏可能同样接受可选参数来调整与栅栏相关联的获取-释放栅栏的内存范围。

DPC++ 中的原子操作

DPC++ 支持对各种数据类型的多种原子操作。所有器件都保证支持常见操作的原子版本(例如加载、存储、算术运算符),以及实现无锁算法所需的原子比较和交换操作。该语言为所有基本整数、浮点和指针类型定义了这些操作,所有设备都必须支持 32 位类型的这些操作,但 64 位类型的支持是可选的。

atomic

C++11 的std::atomic类提供了一个创建和操作原子变量的接口。原子类的实例拥有自己的数据,不能移动或复制,只能使用原子操作进行更新。这些限制大大减少了错误使用该类和引入未定义行为的机会。不幸的是,它们也阻止了该类在 DPC++ 内核中的使用——不可能在主机上创建原子对象并将它们传输到设备上!我们可以继续在我们的主机代码中使用std::atomic,但是试图在设备内核中使用它会导致编译错误。

ATOMIC CLASS DEPRECATED IN SYCL 2020 AND DPC++

SYCL 1.2.1 规范包含一个cl::sycl::atomic类,它松散地基于 C++11 的std::atomic类。我们笼统地说,因为这两个类的接口之间存在一些差异,最明显的是 SYCL 1.2.1 版本不拥有自己的数据,默认采用宽松的内存排序。

DPC++ 完全支持cl::sycl::atomic类,但是为了避免混淆,不鼓励使用它。我们建议使用atomic_ref类(将在下一节中介绍)来代替它。

atomic_ref

C++20 的std::atomic_ref类为原子操作提供了另一个接口,它比std::atomic提供了更大的灵活性。这两个类最大的区别是std::atomic_ref的实例不拥有它们的数据,而是从一个现有的非原子变量中构造的。创建原子引用实际上是一种承诺,即在引用的生命周期内,被引用的变量只能被原子地访问。这些正是 DPC++ 所需要的语义,因为它们允许我们在主机上创建非原子数据,将这些数据传输到设备上,并且只有在传输之后才将其视为原子数据。因此,DPC++ 内核中使用的atomic_ref类是基于std::atomic_ref的。

我们说是基于,因为该类的 DPC++ 版本包括三个额外的模板参数,如图 19-11 所示。

img/489625_1_En_19_Fig11_HTML.png

图 19-11

atomic_ref类的构造器和静态成员

如前所述,不同的 DPC++ 设备的功能各不相同。为 DPC++ 的原子类选择默认行为是一个困难的命题:默认标准 C++ 行为(即memory_order::seq_cst, memory_scope::system)将代码限制为只能在最有能力的设备上执行;另一方面,打破 C++ 惯例,默认使用最小公分母(即memory_order::relaxed, memory_scope::work_group)可能会导致在迁移现有 C++ 代码时出现意外行为。DPC++ 采用的设计提供了一个折衷方案,允许我们将我们想要的默认行为定义为对象类型的一部分(使用DefaultOrderDefaultScope模板参数)。其他排序和作用域可以作为运行时参数提供给我们认为合适的特定原子操作——DefaultOrderDefaultScope只影响我们没有或不能覆盖默认行为的操作(例如,当使用像+=这样的简写操作符时)。最后一个模板参数表示被引用对象所分配的地址空间。

原子引用根据其引用的对象类型为不同的操作提供支持。图 19-12 显示了所有类型支持的基本操作,提供了将数据自动移入和移出内存的能力。

img/489625_1_En_19_Fig12_HTML.png

图 19-12

atomic_ref对所有类型进行基本操作

对整型和浮点型对象的原子引用扩展了可用的原子操作集,以包括算术运算,如图 19-13 和 19-14 所示。要求设备支持原子浮点类型,而不管它们在硬件中是否具有对浮点原子的本机支持,并且许多设备被期望使用原子比较交换来模拟原子浮点加法。这种模拟是在 DPC++ 中提供性能和可移植性的一个重要部分,只要算法需要,我们就可以在任何地方自由使用浮点原子——生成的代码将在任何地方都能正确工作,并将受益于浮点原子硬件的未来改进,而无需任何修改!

img/489625_1_En_19_Fig14_HTML.png

图 19-14

使用atomic_ref的附加操作仅适用于浮点类型

img/489625_1_En_19_Fig13_HTML.png

图 19-13

使用atomic_ref的附加操作仅适用于整型

使用带缓冲区的原子

正如上一节所讨论的,在 DPC++ 中没有办法分配原子数据并在主机和设备之间移动它。要将原子操作与缓冲区结合使用,我们必须创建一个非原子数据的缓冲区,然后通过原子引用访问该数据。

img/489625_1_En_19_Fig15_HTML.png

图 19-15

通过显式创建的atomic_ref访问缓冲区

图 19-15 中的代码是一个使用显式创建的原子引用对象在 DPC++ 中表达原子性的例子。缓冲区存储普通整数,我们需要一个具有读写权限的访问器。然后,我们可以为每个数据访问创建一个atomic_ref实例,使用+=操作符作为fetch_add成员函数的简写替代。

如果我们希望在同一个内核中混合对缓冲区的原子和非原子访问,以避免在不需要原子操作时支付原子操作的性能开销,这种模式非常有用。如果我们知道缓冲区中只有一个内存位置的子集将被多个工作项同时访问,那么我们只需要在访问那个子集时使用原子引用。或者,如果我们知道同一工作组中的工作项仅在内核的一个阶段(即,在两个工作组屏障之间)并发访问本地存储器,那么我们只需要在该阶段使用原子引用。

有时我们很乐意为每次访问支付原子性的开销,要么是因为每次访问都必须是原子性的,以保证正确性,要么是因为我们对生产率比对性能更感兴趣。对于这种情况,DPC++ 提供了一种声明访问器必须总是使用原子操作的简写方式,如图 19-16 所示。

img/489625_1_En_19_Fig16_HTML.png

图 19-16

通过原子访问器隐式创建的atomic_ref访问缓冲区

缓冲区像以前一样存储普通的整数,但是我们用一个特殊的atomic_accessor类型替换了常规的访问器。使用这种原子访问器会自动使用原子引用包装缓冲区的每个成员,从而简化内核代码。

直接使用原子引用类还是通过访问器使用原子引用类是最好的,这取决于我们的用例。为了在原型开发和初始开发过程中简单起见,我们的建议是从访问器开始,只有在性能调优过程中有必要时(例如,如果分析显示原子操作是性能瓶颈)或者只有在定义明确的内核阶段(例如,在本章后面的直方图代码中)才需要原子性时,才使用更显式的语法。

在统一共享内存中使用原子

如图 19-17 (转载自图 19-7 )所示,我们可以从 USM 中存储的数据中构造原子引用,就像我们对缓冲区所做的一样。事实上,这段代码与图 19-15 所示代码的唯一区别在于 USM 代码不需要缓冲区或存取器。

img/489625_1_En_19_Fig17_HTML.png

图 19-17

通过显式创建的atomic_ref访问 USM 分配

没有办法只使用标准的 DPC++ 特性来模仿原子访问器为 USM 指针提供的速记语法。然而,我们期望 DPC++ 的未来版本将提供一个建立在为 C++23 提出的mdspan类之上的简写。

在现实生活中使用原子

原子的潜在用法如此广泛和多样,以至于我们不可能在本书中提供每种用法的例子。我们提供了两个具有代表性的例子,它们在各个领域都有广泛的适用性:

  1. 计算直方图

  2. 实现设备范围的同步

计算直方图

图 19-18 中的代码演示了如何使用宽松原子结合工作组障碍来计算直方图。屏障将内核分为三个阶段,每个阶段都有自己的原子性需求。请记住,屏障同时充当同步点和获取-释放栅栏——这确保了一个阶段中的任何读取和写入对于后面阶段中工作组中的所有工作项都是可见的。

第一阶段将一些工作组本地内存的内容设置为零。每个工作组中的工作项通过设计来更新工作组本地内存中的独立位置——不会出现竞争情况,并且不需要原子性。

第二阶段在本地存储器中累积部分直方图结果。同一个工作组中的工作项可能会更新工作组本地内存中的相同位置,但是同步可以推迟到阶段结束时——我们可以使用memory_order::relaxedmemory_scope::work_group来满足原子性需求。

第三阶段将部分直方图结果贡献给存储在全局存储器中的总数。相同工作组中的工作项保证从工作组本地内存中的独立位置读取,但是可以更新全局内存中的相同位置——我们不再需要工作组本地内存的原子性,并且可以像以前一样使用memory_order::relaxedmemory_scope::system来满足全局内存的原子性要求。

img/489625_1_En_19_Fig18_HTML.png

图 19-18

使用不同存储空间中的原子引用计算直方图

实现设备范围的同步

回到第四章,我们警告过不要编写试图跨工作组同步工作项目的内核。然而,我们完全期望本章的几个读者将渴望在原子操作之上实现他们自己的设备范围的同步例程,并且我们的警告将被忽略。

设备范围的同步目前是不可移植的,最好留给专业程序员。该语言的未来版本将解决这个问题。

本节中讨论的代码是危险的,不应该期望在所有设备上都能工作,因为在调度和并发保证方面存在潜在的差异。由原子提供的存储器排序保证与前向进度保证正交;而且,在撰写本文时,SYCL 和 DPC++ 中的工作组调度完全是由实现定义的。形式化讨论执行模型和调度保证所需的概念和术语是当前活跃的学术研究领域,DPC++ 的未来版本有望在此基础上提供额外的调度查询和控制。目前,这些话题应该被认为是专家专有的。

图 19-19 显示了一个简单的设备范围闩锁(一个一次性栅栏)的实现,图 19-20 显示了其使用的一个简单例子。每个工作组选择一个工作项来通知该组到达闩锁处,并使用一个简单的旋转循环等待其他组的到达,而其他工作项使用工作组屏障等待所选择的工作项。正是这种自旋循环使得设备范围的同步不安全;如果任何工作组还没有开始执行,或者当前正在执行的工作组没有得到公平的调度,代码可能会死锁。

在没有独立的前向进度保证的情况下,仅仅依靠内存顺序来实现同步原语可能会导致死锁!

为了使代码正确工作,必须满足以下三个条件:

  1. 原子操作必须使用至少与所示一样严格的内存顺序,以保证生成正确的栅栏。

  2. ND 范围中的每个工作组必须能够向前进展,以避免循环中旋转的单个工作组使尚未递增计数器的工作组饥饿。

  3. The device must be capable of executing all work-groups in the ND-range concurrently, in order to ensure that all work-groups in the ND-range eventually reach the latch.

    img/489625_1_En_19_Fig20_HTML.png

    图 19-20

    使用图 19-19 中的全设备锁

    img/489625_1_En_19_Fig19_HTML.png

    图 19-19

    在原子引用的基础上构建一个简单的设备级锁存器

虽然不能保证这段代码是可移植的,但我们在这里包含它是为了强调两个要点:1) DPC++ 的表达能力足以支持特定于设备的调优,有时会牺牲可移植性;以及 2) DPC++ 已经包含了实现高级同步例程所必需的构件,这些构件可能包含在该语言的未来版本中。

摘要

本章提供了对内存模型和原子类的高级介绍。了解如何使用(以及如何不使用!)这些类是开发正确的、可移植的和高效的并行程序的关键。

内存模型是一个极其复杂的主题,我们在这里的重点是为编写真正的应用程序建立一个基础。如果需要更多的信息,有几个网站、书籍和讲座专门介绍下面提到的内存模型。

更多信息

Creative Commons

开放存取本章根据知识共享署名 4.0 国际许可证(http://Creative Commons . org/licenses/by/4.0/)的条款获得许可,该许可证允许以任何媒体或格式使用、共享、改编、分发和复制,只要您适当注明原作者和来源,提供知识共享许可证的链接并指明是否进行了更改。

本章中的图像或其他第三方材料包含在本章的知识共享许可中,除非在材料的信用额度中另有说明。如果材料不包括在本章的知识共享许可中,并且您的预期使用不被法律法规允许或超出了允许的使用范围,您将需要直接从版权所有者处获得许可。

posted @ 2024-08-05 14:01  绝不原创的飞龙  阅读(8)  评论(0编辑  收藏  举报