From Ken He

1.CUDA简介

1.1 我们为什么要使用GPU

GPU(Graphics Processing Unit)在相同的价格和功率范围内,比CPU提供更高的指令吞吐量和内存带宽。许多应用程序利用这些更高的能力,在GPU上比在CPU上运行得更快(参见GPU应用程序)。其他计算设备,如FPGA,也非常节能,但提供的编程灵活性要比GPU少得多。

GPU和CPU在功能上的差异是因为它们的设计目标不同。虽然 CPU 旨在以尽可能快的速度执行一系列称为线程的操作,并且可以并行执行数十个这样的线程。但GPU却能并行执行成千上万个(摊销较慢的单线程性能以实现更大的吞吐量)。

GPU 专门用于高度并行计算,因此设计时更多的晶体管用于数据处理,而不是数据缓存和流量控制。

下图显示了 CPU 与 GPU 的芯片资源分布示例。

The GPU Devotes More Transistors to Data Processing

将更多晶体管用于数据处理,例如浮点计算,有利于高度并行计算。GPU可以通过计算隐藏内存访问延迟,而不是依靠大数据缓存和复杂的流控制来避免长时间的内存访问延迟,这两者在晶体管方面都是昂贵的。

1.2 CUDA®:通用并行计算平台和编程模型

2006 年 11 月,NVIDIA® 推出了 CUDA®,这是一种通用并行计算平台和编程模型,它利用 NVIDIA GPU 中的并行计算引擎以比 CPU 更有效的方式解决许多复杂的计算问题。

CUDA 附带一个软件环境,允许开发人员使用 C++ 作为高级编程语言。 如下图所示,支持其他语言、应用程序编程接口或基于指令的方法,例如 FORTRAN、DirectCompute、OpenACC。

gpu-computing-applications.png

1.3 可扩展的编程模型

多核 CPU 和众核 GPU 的出现意味着主流处理器芯片现在是并行系统。挑战在于开发能够透明地扩展可并行的应用软件,来利用不断增加的处理器内核数量。就像 3D 图形应用程序透明地将其并行性扩展到具有广泛不同内核数量的多核 GPU 一样。

CUDA 并行编程模型旨在克服这一挑战,同时为熟悉 C 等标准编程语言的程序员保持较低的学习曲线。

其核心是三个关键抽象——线程组的层次结构、共享内存和屏障同步——它们只是作为最小的语言扩展集向程序员公开。

这些抽象提供了细粒度的数据并行和线程并行,嵌套在粗粒度的数据并行和任务并行中。它们指导程序员将问题划分为可以由线程块并行独立解决的粗略子问题,并将每个子问题划分为可以由块内所有线程并行协作解决的更精细的部分。

这种分解通过允许线程在解决每个子问题时进行协作来保留语言表达能力,同时实现自动可扩展性。实际上,每个线程块都可以在 GPU 内的任何可用multiprocessor上以乱序、并发或顺序调度,以便编译的 CUDA 程序可以在任意数量的多处理器上执行,如下图所示,并且只有运行时系统需要知道物理multiprocessor个数。

这种可扩展的编程模型允许 GPU 架构通过简单地扩展multiprocessor和内存分区的数量来跨越广泛的市场范围:高性能发烧友 GeForce GPU ,专业的 Quadro 和 Tesla 计算产品 (有关所有支持 CUDA 的 GPU 的列表,请参阅支持 CUDA 的 GPU)。

automatic-scalability.png

注意:GPU 是围绕一系列流式多处理器 (SM: Streaming Multiprocessors) 构建的(有关详细信息,请参阅硬件实现)。 多线程程序被划分为彼此独立执行的线程块,因此具有更多multiprocessor的 GPU 将比具有更少多处理器的 GPU 在更短的时间内完成程序执行。

2.编程模型

本章通过概述CUDA编程模型是如何在c++中公开的,来介绍CUDA的主要概念。

编程接口中给出了对 CUDA C++ 的广泛描述。

本章和下一章中使用的向量加法示例的完整代码可以在 vectorAdd CUDA示例中找到。

2.1 内核

CUDA C++ 通过允许程序员定义称为kernel的 C++ 函数来扩展 C++,当调用内核时,由 N 个不同的 CUDA 线程并行执行 N 次,而不是像常规 C++ 函数那样只执行一次。

使用 __global__ 声明说明符定义内核,并使用新的 <<<...>>> 执行配置语法指定内核调用的 CUDA 线程数(请参阅 C++ 语言扩展)。 每个执行内核的线程都有一个唯一的线程 ID,可以通过内置变量在内核中访问。

作为说明,以下示例代码使用内置变量 threadIdx 将两个大小为 N 的向量 A 和 B 相加,并将结果存储到向量 C 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Kernel definition
__global__ void VecAdd(float* A, float* B, float* C)
{
int i = threadIdx.x;
C[i] = A[i] + B[i];
}

int main()
{
...
// Kernel invocation with N threads
VecAdd<<<1, N>>>(A, B, C);
...
}

这里,执行 VecAdd() 的 N 个线程中的每一个线程都会执行一个加法。

2.2 线程层次

为方便起见,threadIdx 是一个 3 分量向量,因此可以使用一维、二维或三维的线程索引来识别线程,形成一个一维、二维或三维的线程块,称为block。 这提供了一种跨域的元素(例如向量、矩阵或体积)调用计算的方法。

线程的索引和它的线程 ID 以一种直接的方式相互关联:对于一维块,它们是相同的; 对于大小为(Dx, Dy)的二维块,索引为(x, y)的线程的线程ID为(x + y*Dx); 对于大小为 (Dx, Dy, Dz) 的三维块,索引为 (x, y, z) 的线程的线程 ID 为 (x + y*Dx + z*Dx*Dy)。

例如,下面的代码将两个大小为NxN的矩阵A和B相加,并将结果存储到矩阵C中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Kernel definition
__global__ void MatAdd(float A[N][N], float B[N][N],
float C[N][N])
{
int i = threadIdx.x;
int j = threadIdx.y;
C[i][j] = A[i][j] + B[i][j];
}

int main()
{
...
// Kernel invocation with one block of N * N * 1 threads
int numBlocks = 1;
dim3 threadsPerBlock(N, N);
MatAdd<<<numBlocks, threadsPerBlock>>>(A, B, C);
...
}

每个块的线程数量是有限制的,因为一个块的所有线程都应该驻留在同一个处理器核心上,并且必须共享该核心有限的内存资源。在当前的gpu上,一个线程块可能包含多达1024个线程。

但是,一个内核可以由多个形状相同的线程块执行,因此线程总数等于每个块的线程数乘以块数。

块被组织成一维、二维或三维的线程块网格(grid),如下图所示。网格中的线程块数量通常由正在处理的数据的大小决定,通常超过系统中的处理器数量。

grid-of-thread-blocks.png

```语法中指定的每个块的线程数和每个网格的块数可以是 ```int``` 或 `dim3` 类型。如上例所示,可以指定二维块或网格。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

网格中的每个块都可以由一个一维、二维或三维的惟一索引标识,该索引可以通过内置的`blockIdx`变量在内核中访问。线程块的维度可以通过内置的`blockDim`变量在内核中访问。

扩展前面的`MatAdd()`示例来处理多个块,代码如下所示。

```C++
// Kernel definition
__global__ void MatAdd(float A[N][N], float B[N][N],
float C[N][N])
{
int i = blockIdx.x * blockDim.x + threadIdx.x;
int j = blockIdx.y * blockDim.y + threadIdx.y;
if (i < N && j < N)
C[i][j] = A[i][j] + B[i][j];
}

int main()
{
...
// Kernel invocation
dim3 threadsPerBlock(16, 16);
dim3 numBlocks(N / threadsPerBlock.x, N / threadsPerBlock.y);
MatAdd<<<numBlocks, threadsPerBlock>>>(A, B, C);
...
}

线程块大小为16x16(256个线程),尽管在本例中是任意更改的,但这是一种常见的选择。网格是用足够的块创建的,这样每个矩阵元素就有一个线程来处理。为简单起见,本例假设每个维度中每个网格的线程数可以被该维度中每个块的线程数整除,尽管事实并非如此。

程块需要独立执行:必须可以以任何顺序执行它们,并行或串行。 这种独立性要求允许跨任意数量的内核以任意顺序调度线程块,如下图所示,使程序员能够编写随内核数量扩展的代码。

automatic-scalability.png

块内的线程可以通过一些共享内存共享数据并通过同步它们的执行来协调内存访问来进行协作。 更准确地说,可以通过调用 __syncthreads() 内部函数来指定内核中的同步点; __syncthreads() 充当屏障,块中的所有线程必须等待,然后才能继续。 Shared Memory 给出了一个使用共享内存的例子。 除了 __syncthreads() 之外,Cooperative Groups API 还提供了一组丰富的线程同步示例。

为了高效协作,共享内存是每个处理器内核附近的低延迟内存(很像 L1 缓存),并且 __syncthreads() 是轻量级的。

2.3 存储单元层次

CUDA 线程可以在执行期间从多个内存空间访问数据,如下图所示。每个线程都有私有的本地内存。 每个线程块都具有对该块的所有线程可见的共享内存,并且具有与该块相同的生命周期。 所有线程都可以访问相同的全局内存。

memory-hierarchy.png

还有两个额外的只读内存空间可供所有线程访问:常量和纹理内存空间。 全局、常量和纹理内存空间针对不同的内存使用进行了优化(请参阅设备内存访问)。 纹理内存还为某些特定数据格式提供不同的寻址模式以及数据过滤(请参阅纹理和表面内存)。

全局、常量和纹理内存空间在同一应用程序的内核启动中是持久的。

2.4 异构编程

如下图所示,CUDA 编程模型假定 CUDA 线程在物理独立的设备上执行,该设备作为运行 C++ 程序的主机的协处理器运行。例如,当内核在 GPU 上执行而 C++ 程序的其余部分在 CPU 上执行时,就是这种情况。

heterogeneous-programming.png

CUDA 编程模型还假设主机(host)和设备(device)都在 DRAM 中维护自己独立的内存空间,分别称为主机内存和设备内存。因此,程序通过调用 CUDA 运行时(在编程接口中描述)来管理内核可见的全局、常量和纹理内存空间。这包括设备内存分配和释放以及主机和设备内存之间的数据传输。

统一内存提供托管内存来桥接主机和设备内存空间。托管内存可从系统中的所有 CPU 和 GPU 访问,作为具有公共地址空间的单个连贯内存映像。此功能可实现设备内存的超额订阅,并且无需在主机和设备上显式镜像数据,从而大大简化了移植应用程序的任务。有关统一内存的介绍,请参阅统一内存编程

注:串行代码在主机(host)上执行,并行代码在设备(device)上执行。

2.5 异步SIMT编程模型

在 CUDA 编程模型中,线程是进行计算或内存操作的最低抽象级别。 从基于 NVIDIA Ampere GPU 架构的设备开始,CUDA 编程模型通过异步编程模型为内存操作提供加速。 异步编程模型定义了与 CUDA 线程相关的异步操作的行为。

异步编程模型为 CUDA 线程之间的同步定义了异步屏障的行为。 该模型还解释并定义了如何使用 cuda::memcpy_async 在 GPU计算时从全局内存中异步移动数据。

2.5.1 异步操作

异步操作定义为由CUDA线程发起的操作,并且与其他线程一样异步执行。在结构良好的程序中,一个或多个CUDA线程与异步操作同步。发起异步操作的CUDA线程不需要在同步线程中.

这样的异步线程(as-if 线程)总是与发起异步操作的 CUDA 线程相关联。异步操作使用同步对象来同步操作的完成。这样的同步对象可以由用户显式管理(例如,cuda::memcpy_async)或在库中隐式管理(例如,cooperative_groups::memcpy_async)。

同步对象可以是 cuda::barriercuda::pipeline。这些对象在Asynchronous BarrierAsynchronous Data Copies using cuda::pipeline.中进行了详细说明。这些同步对象可以在不同的线程范围内使用。作用域定义了一组线程,这些线程可以使用同步对象与异步操作进行同步。下表定义了CUDA c++中可用的线程作用域,以及可以与每个线程同步的线程。

Thread Scope Description
cuda::thread_scope::thread_scope_thread Only the CUDA thread which initiated asynchronous operations synchronizes.
cuda::thread_scope::thread_scope_block All or any CUDA threads within the same thread block as the initiating thread synchronizes.
cuda::thread_scope::thread_scope_device All or any CUDA threads in the same GPU device as the initiating thread synchronizes.
cuda::thread_scope::thread_scope_system All or any CUDA or CPU threads in the same system as the initiating thread synchronizes.

这些线程作用域是在CUDA标准c++库中作为标准c++的扩展实现的。

2.6 Compute Capability

设备的Compute Capability由版本号表示,有时也称其“SM版本”。该版本号标识GPU硬件支持的特性,并由应用程序在运行时使用,以确定当前GPU上可用的硬件特性和指令。

Compute Capability包括一个主要版本号X和一个次要版本号Y,用X.Y表示

主版本号相同的设备具有相同的核心架构。设备的主要修订号是8,为NVIDIA Ampere GPU的体系结构的基础上,7基于Volta设备架构,6设备基于Pascal架构,5设备基于Maxwell架构,3基于Kepler架构的设备,2设备基于Fermi架构,1是基于Tesla架构的设备。

次要修订号对应于对核心架构的增量改进,可能包括新特性。

Turing是计算能力7.5的设备架构,是基于Volta架构的增量更新。

CUDA-Enabled GPUs 列出了所有支持 CUDA 的设备及其计算能力。Compute Capabilities给出了每个计算能力的技术规格。

注意:特定GPU的计算能力版本不应与CUDA版本(如CUDA 7.5、CUDA 8、CUDA 9)混淆,CUDA版本指的是CUDA软件平台的版本。CUDA平台被应用开发人员用来创建运行在许多代GPU架构上的应用程序,包括未来尚未发明的GPU架构。尽管CUDA平台的新版本通常会通过支持新的GPU架构的计算能力版本来增加对该架构的本地支持,但CUDA平台的新版本通常也会包含软件功能。

从CUDA 7.0和CUDA 9.0开始,不再支持TeslaFermi架构。

第三章编程接口

CUDA C++ 为熟悉 C++ 编程语言的用户提供了一种简单的途径,可以轻松编写由设备执行的程序。

它由c++语言的最小扩展集和运行时库组成。

编程模型中引入了核心语言扩展。它们允许程序员将内核定义为 C++ 函数,并在每次调用函数时使用一些新语法来指定网格和块的维度。所有扩展的完整描述可以在 C++ 语言扩展中找到。任何包含这些扩展名的源文件都必须使用 nvcc 进行编译,如使用NVCC编译中所述。

运行时在 CUDA Runtime 中引入。它提供了在主机上执行的 C 和 C++ 函数,用于分配和释放设备内存、在主机内存和设备内存之间传输数据、管理具有多个设备的系统等。运行时的完整描述可以在 CUDA 参考手册中找到。

运行时构建在较低级别的 C API(即 CUDA 驱动程序 API)之上,应用程序也可以访问该 API。驱动程序 API 通过公开诸如 CUDA 上下文(类似于设备的主机进程)和 CUDA 模块(类似于设备的动态加载库)等较低级别的概念来提供额外的控制级别。大多数应用程序不使用驱动程序 API,因为它们不需要这种额外的控制级别,并且在使用运行时时,上下文和模块管理是隐式的,从而产生更简洁的代码。由于运行时可与驱动程序 API 互操作,因此大多数需要驱动程序 API 功能的应用程序可以默认使用运行时 API,并且仅在需要时使用驱动程序 API。 Driver API 中介绍了驱动API并在参考手册中进行了全面描述。

3.1利用NVCC编译

内核可以使用称为 PTX 的 CUDA 指令集架构来编写,PTX 参考手册中对此进行了描述。 然而,使用高级编程语言(如 C++)通常更有效。 在这两种情况下,内核都必须通过 nvcc 编译成二进制代码才能在设备上执行。

nvcc 是一种编译器驱动程序,可简化编译 C++PTX 代码:它提供简单且熟悉的命令行选项,并通过调用实现不同编译阶段的工具集合来执行它们。 本节概述了 nvcc 工作流程和命令选项。 完整的描述可以在 nvcc 用户手册中找到。

3.1.1编译流程

3.1.1.1 离线编译

使用 nvcc 编译的源文件可以包含主机代码(即在host上执行的代码)和设备代码(即在device上执行的代码。 nvcc 的基本工作流程包括将设备代码与主机代码分离,然后:

  • 将设备代码编译成汇编形式(PTX 代码)或二进制形式(cubin 对象)
  • 并通过CUDA运行时函数的调用来替换 <<<…>>> 语法对主机代码进行修改,以从 PTX 代码或 cubin 对象加载和启动每个编译的内核。

修改后的主机代码要么作为 C++ 代码输出,然后使用另一个工具编译,要么直接作为目标代码输出,方法是让 nvcc 在最后编译阶段调用主机编译器。

然后应用程序可以:

  • 链接到已编译的主机代码(这是最常见的情况),
  • 或者忽略修改后的主机代码(如果有)并使用 CUDA 驱动程序 API(请参阅驱动程序 API)来加载和执行 PTX 代码或 cubin 对象。

3.1.1.2 即时编译

应用程序在运行时加载的任何 PTX 代码都由设备驱动程序进一步编译为二进制代码。这称为即时编译。即时编译增加了应用程序加载时间,但允许应用程序受益于每个新设备驱动程序带来的任何新编译器改进。它也是应用程序能够运行在编译时不存在的设备上的唯一方式,如应用程序兼容性中所述。

当设备驱动程序为某些应用程序实时编译一些 PTX 代码时,它会自动缓存生成二进制代码的副本,以避免在应用程序的后续调用中重复编译。缓存(称为计算缓存)在设备驱动程序升级时自动失效,因此应用程序可以从设备驱动程序中内置的新即时编译器的改进中受益。

环境变量可用于控制即时编译,如 CUDA 环境变量中所述

作为使用 nvcc 编译 CUDA C++ 设备代码的替代方法,NVRTC 可用于在运行时将 CUDA C++ 设备代码编译为 PTX。 NVRTC 是 CUDA C++ 的运行时编译库;更多信息可以在 NVRTC 用户指南中找到。

3.1.2 Binary 兼容性

二进制代码是特定于体系结构的。 使用指定目标体系结构的编译器选项 -code 生成 cubin 对象:例如,使用 -code=sm_35 编译会为计算能力为 3.5 的设备生成二进制代码。 从一个次要修订版到下一个修订版都保证了二进制兼容性,但不能保证从一个次要修订版到前一个修订版或跨主要修订版。 换句话说,为计算能力 X.y 生成的 cubin 对象只会在计算能力 X.z 且 z≥y 的设备上执行。

注意:仅桌面支持二进制兼容性。 Tegra 不支持它。 此外,不支持桌面和 Tegra 之间的二进制兼容性。

3.1.3 PTX 兼容性

某些 PTX 指令仅在具有更高计算能力的设备上受支持。 例如,Warp Shuffle Functions 仅在计算能力 3.0 及以上的设备上支持。 -arch 编译器选项指定将 C++ 编译为 PTX 代码时假定的计算能力。 因此,例如,包含 warp shuffle 的代码必须使用 -arch=compute_30(或更高版本)进行编译。

为某些特定计算能力生成的 PTX 代码始终可以编译为具有更大或相等计算能力的二进制代码。 请注意,从早期 PTX 版本编译的二进制文件可能无法使用某些硬件功能。 例如,从为计算能力 6.0 (Pascal) 生成的 PTX 编译的计算能力 7.0 (Volta) 的二进制目标设备将不会使用 Tensor Core 指令,因为这些指令在 Pascal 上不可用。 因此,最终二进制文件的性能可能会比使用最新版本的 PTX 生成的二进制文件更差。

3.1.4 应用程序兼容性

要在具有特定计算能力的设备上执行代码,应用程序必须加载与此计算能力兼容的二进制或 PTX 代码,如二进制兼容性PTX 兼容性中所述。 特别是,为了能够在具有更高计算能力的未来架构上执行代码(尚无法生成二进制代码),应用程序必须加载将为这些设备实时编译的 PTX 代码(参见即时编译)。

哪些 PTX 和二进制代码嵌入到 CUDA C++ 应用程序中由 -arch-code 编译器选项或 -gencode 编译器选项控制,详见 nvcc 用户手册。 例如:

1
2
3
4
nvcc x.cu
-gencode arch=compute_50,code=sm_50
-gencode arch=compute_60,code=sm_60
-gencode arch=compute_70,code=\"compute_70,sm_70\"

嵌入与计算能力 5.0 和 6.0(第一和第二-gencode 选项)兼容的二进制代码以及与计算能力 7.0(第三-gencode 选项)兼容的 PTX 和二进制代码。

生成主机代码以在运行时自动选择最合适的代码来加载和执行,在上面的示例中,这些代码将是:

  • 具有计算能力 5.0 和 5.2 的设备的 5.0 二进制代码,
  • 具有计算能力 6.0 和 6.1 的设备的 6.0 二进制代码,
  • 具有计算能力 7.0 和 7.5 的设备的 7.0 二进制代码,
  • PTX 代码在运行时编译为具有计算能力 8.0 和 8.6 的设备的二进制代码。

例如,x.cu 可以有一个优化代码的方法,使用 warp shuffle 操作,这些操作仅在计算能力 3.0 及更高版本的设备中受支持。 __CUDA_ARCH__ 宏可用于根据计算能力区分各种代码方案。 它仅为设备代码定义。 例如,当使用 -arch=compute_35 编译时,__CUDA_ARCH__ 等于 350。

使用驱动 API 的应用程序必须编译代码以分离文件并在运行时显式加载和执行最合适的文件。

Volta 架构引入了独立线程调度,它改变了在 GPU 上调度线程的方式。 对于依赖于以前架构中 SIMT 调度的特定行为的代码,独立线程调度可能会改变参与线程的集合,从而导致不正确的结果。 为了在实现独立线程调度中详述的纠正措施的同时帮助迁移,Volta 开发人员可以使用编译器选项组合 -arch=compute_60 -code=sm_70 选择加入 Pascal 的线程调度。

nvcc 用户手册列出了 -arch、-code-gencode 编译器选项的各种简写。 例如,-arch=sm_70-arch=compute_70 -code=compute_70,sm_70 的简写(与 -gencode arch=compute_70,code=\"compute_70,sm_70\" 相同)。

3.1.5 C++兼容性

编译器前端根据 C++ 语法规则处理 CUDA 源文件。 主机代码支持完整的 C++。 但是,设备代码仅完全支持 C++ 的一个子集,如 C++ 语言支持中所述。

3.1.6 64位支持

64 位版本的 nvcc 以 64 位模式编译设备代码(即指针是 64 位的)。 以 64 位模式编译的设备代码仅支持以 64 位模式编译的主机代码。

同样,32 位版本的 nvcc 以 32 位模式编译设备代码,而以 32 位模式编译的设备代码仅支持以 32 位模式编译的主机代码。

32 位版本的 nvcc 也可以使用 -m64 编译器选项以 64 位模式编译设备代码。

64 位版本的 nvcc 也可以使用 -m32 编译器选项以 32 位模式编译设备代码。

3.2 CUDA运行时

运行时在 cudart 库中实现,该库链接到应用程序,可以通过 cudart.liblibcudart.a 静态链接,也可以通过 cudart.dlllibcudart.so 动态链接。 需要 cudart.dllcudart.so 进行动态链接的应用程序通常将它们作为应用程序安装包的一部分。 只有在链接到同一 CUDA 运行时实例的组件之间传递 CUDA 运行时符号的地址才是安全的。

它的所有入口都以 cuda 为前缀。

异构编程中所述,CUDA 编程模型假设系统由主机和设备组成,每个设备都有自己独立的内存。 设备内存概述了用于管理设备内存的运行时函数。

共享内存说明了使用线程层次结构中引入的共享内存来最大化性能。

Page-Locked Host Memory 引入了 page-locked 主机内存,它需要将内核执行与主机设备内存之间的数据传输重叠。

异步并发执行描述了用于在系统的各个级别启用异步并发执行的概念和 API。

多设备系统展示了编程模型如何扩展到具有多个设备连接到同一主机的系统。

错误检查描述了如何正确检查运行时生成的错误。

调用堆栈提到了用于管理 CUDA C++ 调用堆栈的运行时函数。

Texture and Surface Memory 呈现了纹理和表面内存空间,它们提供了另一种访问设备内存的方式;它们还公开了 GPU 纹理硬件的一个子集。

图形互操作性介绍了运行时提供的各种功能,用于与两个主要图形 API(OpenGL 和 Direct3D)进行互操作。

3.2.1 初始化

运行时没有显式的初始化函数;它在第一次调用运行时函数时进行初始化(更具体地说,除了参考手册的错误处理和版本管理部分中的函数之外的任何函数)。在计时运行时函数调用以及将第一次调用的错误代码解释到运行时时,需要牢记这一点。

运行时为系统中的每个设备创建一个 CUDA 上下文(有关 CUDA 上下文的更多详细信息,请参阅上下文)。此context是此设备的主要上下文,并在需要此设备上的活动上下文的第一个运行时函数中初始化。它在应用程序的所有主机线程之间共享。作为此上下文创建的一部分,设备代码会在必要时进行即时编译(请参阅即时编译)并加载到设备内存中。这一切都是透明地发生的。如果需要,例如对于驱动程序 API 互操作性,可以从驱动程序 API 访问设备的主要上下文,如运行时和驱动程序 API 之间的互操作性中所述。

当主机线程调用 cudaDeviceReset() 时,这会破坏主机线程当前操作的设备的主要上下文(即设备选择中定义的当前设备)。 任何将此设备作为当前设备的主机线程进行的下一个运行时函数调用将为该设备创建一个新的主上下文。

注意:CUDA接口使用全局状态,在主机程序初始化时初始化,在主机程序终止时销毁。 CUDA 运行时和驱动程序无法检测此状态是否无效,因此在程序启动或 main 后终止期间使用任何这些接口(隐式或显式)将导致未定义的行为。

3.2.2 设备存储

异构编程中所述,CUDA 编程模型假设系统由主机和设备组成,每个设备都有自己独立的内存。 内核在设备内存之外运行,因此运行时提供了分配、解除分配和复制设备内存以及在主机内存和设备内存之间传输数据的功能。

设备内存可以分配为线性内存或 CUDA 数组

CUDA 数组是针对纹理获取优化的不透明内存布局。 它们在纹理和表面内存中有所描述。

线性内存分配在一个统一的地址空间中,这意味着单独分配的实体可以通过指针相互引用,例如在二叉树或链表中。 地址空间的大小取决于主机系统 (CPU) 和所用 GPU 的计算能力:

Table 1. Linear Memory Address Space

x86_64 (AMD64) POWER (ppc64le) ARM64
up to compute capability 5.3 (Maxwell) 40bit 40bit 40bit
compute capability 6.0 (Pascal) or newer up to 47bit up to 49bit up to 48bit

注意:在计算能力为 5.3 (Maxwell) 及更早版本的设备上,CUDA 驱动程序会创建一个未提交的 40 位虚拟地址预留,以确保内存分配(指针)在支持的范围内。 此预留显示为预留虚拟内存,但在程序实际分配内存之前不会占用任何物理内存。

线性内存通常使用 cudaMalloc() 分配并使用 cudaFree() 释放,主机内存和设备内存之间的数据传输通常使用 cudaMemcpy() 完成。 在Kernels的向量加法代码示例中,需要将向量从主机内存复制到设备内存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// Device code
__global__ void VecAdd(float* A, float* B, float* C, int N)
{
int i = blockDim.x * blockIdx.x + threadIdx.x;
if (i < N)
C[i] = A[i] + B[i];
}

// Host code
int main()
{
int N = ...;
size_t size = N * sizeof(float);

// Allocate input vectors h_A and h_B in host memory
float* h_A = (float*)malloc(size);
float* h_B = (float*)malloc(size);
float* h_C = (float*)malloc(size);

// Initialize input vectors
...

// Allocate vectors in device memory
float* d_A;
cudaMalloc(&d_A, size);
float* d_B;
cudaMalloc(&d_B, size);
float* d_C;
cudaMalloc(&d_C, size);

// Copy vectors from host memory to device memory
cudaMemcpy(d_A, h_A, size, cudaMemcpyHostToDevice);
cudaMemcpy(d_B, h_B, size, cudaMemcpyHostToDevice);

// Invoke kernel
int threadsPerBlock = 256;
int blocksPerGrid =
(N + threadsPerBlock - 1) / threadsPerBlock;
VecAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, N);

// Copy result from device memory to host memory
// h_C contains the result in host memory
cudaMemcpy(h_C, d_C, size, cudaMemcpyDeviceToHost);

// Free device memory
cudaFree(d_A);
cudaFree(d_B);
cudaFree(d_C);

// Free host memory
...
}

线性内存也可以通过 cudaMallocPitch()cudaMalloc3D() 分配。 建议将这些函数用于 2D 或 3D 数组的分配,因为它确保分配被适当地填充以满足设备内存访问中描述的对齐要求,从而确保在访问行地址或在 2D 数组和其他区域设备内存之间执行复制时获得最佳性能(使用 cudaMemcpy2D() 和 cudaMemcpy3D() 函数)。 返回的间距(或步幅)必须用于访问数组元素。 以下代码示例分配一个width x height的2D浮点数组,并显示如何在设备代码中循环遍历数组元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Host code
int width = 64, height = 64;
float* devPtr;
size_t pitch;
cudaMallocPitch(&devPtr, &pitch,
width * sizeof(float), height);
MyKernel<<<100, 512>>>(devPtr, pitch, width, height);

// Device code
__global__ void MyKernel(float* devPtr,
size_t pitch, int width, int height)
{
for (int r = 0; r < height; ++r) {
float* row = (float*)((char*)devPtr + r * pitch);
for (int c = 0; c < width; ++c) {
float element = row[c];
}
}
}

以下代码示例分配了一个width x height x depth 的3D浮点数组,并展示了如何在设备代码中循环遍历数组元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Host code
int width = 64, height = 64, depth = 64;
cudaExtent extent = make_cudaExtent(width * sizeof(float),
height, depth);
cudaPitchedPtr devPitchedPtr;
cudaMalloc3D(&devPitchedPtr, extent);
MyKernel<<<100, 512>>>(devPitchedPtr, width, height, depth);

// Device code
__global__ void MyKernel(cudaPitchedPtr devPitchedPtr,
int width, int height, int depth)
{
char* devPtr = devPitchedPtr.ptr;
size_t pitch = devPitchedPtr.pitch;
size_t slicePitch = pitch * height;
for (int z = 0; z < depth; ++z) {
char* slice = devPtr + z * slicePitch;
for (int y = 0; y < height; ++y) {
float* row = (float*)(slice + y * pitch);
for (int x = 0; x < width; ++x) {
float element = row[x];
}
}
}
}

注意:为避免分配过多内存从而影响系统范围的性能,请根据问题大小向用户请求分配参数。 如果分配失败,您可以回退到其他较慢的内存类型(cudaMallocHost()、cudaHostRegister() 等),或者返回一个错误,告诉用户需要多少内存被拒绝。 如果您的应用程序由于某种原因无法请求分配参数,我们建议对支持它的平台使用 cudaMallocManaged()。

参考手册列出了用于在使用 cudaMalloc() 分配的线性内存、使用 cudaMallocPitch()cudaMalloc3D() 分配的线性内存、CUDA 数组以及为在全局或常量内存空间中声明的变量分配的内存之间复制内存的所有各种函数。

以下代码示例说明了通过运行时 API 访问全局变量的各种方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
__constant__ float constData[256];
float data[256];
cudaMemcpyToSymbol(constData, data, sizeof(data));
cudaMemcpyFromSymbol(data, constData, sizeof(data));

__device__ float devData;
float value = 3.14f;
cudaMemcpyToSymbol(devData, &value, sizeof(float));

__device__ float* devPointer;
float* ptr;
cudaMalloc(&ptr, 256 * sizeof(float));
cudaMemcpyToSymbol(devPointer, &ptr, sizeof(ptr));

cudaGetSymbolAddress() 用于检索指向为全局内存空间中声明的变量分配的内存的地址。 分配内存的大小是通过 cudaGetSymbolSize() 获得的。

3.2.3 L2级设备内存管理

当一个 CUDA 内核重复访问全局内存中的一个数据区域时,这种数据访问可以被认为是持久化的。 另一方面,如果数据只被访问一次,那么这种数据访问可以被认为是流式的。

从 CUDA 11.0 开始,计算能力 8.0 及以上的设备能够影响 L2 缓存中数据的持久性,从而可能提供对全局内存的更高带宽和更低延迟的访问。

3.2.3.1 为持久访问预留L2缓存

可以留出一部分 L2 缓存用于持久化对全局内存的数据访问。 持久访问优先使用 L2 缓存的这个预留部分,而对全局内存的正常访问或流式访问只能在持久访问未使用 L2 的这一部分使用。

可以在以下限制内调整用于持久访问的 L2 缓存预留大小:

1
2
3
cudaGetDeviceProperties(&prop, device_id);                
size_t size = min(int(prop.l2CacheSize * 0.75), prop.persistingL2CacheMaxSize);
cudaDeviceSetLimit(cudaLimitPersistingL2CacheSize, size); /* set-aside 3/4 of L2 cache for persisting accesses or the max allowed*/

在多实例 GPU (MIG) 模式下配置 GPU 时,L2 缓存预留功能被禁用。

使用多进程服务 (MPS) 时,cudaDeviceSetLimit 无法更改 L2 缓存预留大小。 相反,只能在 MPS 服务器启动时通过环境变量 CUDA_DEVICE_DEFAULT_PERSISTING_L2_CACHE_PERCENTAGE_LIMIT 指定预留大小。

3.2.3.2 L2持久化访问策略

访问策略窗口指定全局内存的连续区域和L2缓存中的持久性属性,用于该区域内的访问。

下面的代码示例显示了如何使用 CUDA 流设置L2持久访问窗口。

1
2
3
4
5
6
7
8
9
10
cudaStreamAttrValue stream_attribute;                                         // Stream level attributes data structure
stream_attribute.accessPolicyWindow.base_ptr = reinterpret_cast<void*>(ptr); // Global Memory data pointer
stream_attribute.accessPolicyWindow.num_bytes = num_bytes; // Number of bytes for persistence access.
// (Must be less than cudaDeviceProp::accessPolicyMaxWindowSize)
stream_attribute.accessPolicyWindow.hitRatio = 0.6; // Hint for cache hit ratio
stream_attribute.accessPolicyWindow.hitProp = cudaAccessPropertyPersisting; // Type of access property on cache hit
stream_attribute.accessPolicyWindow.missProp = cudaAccessPropertyStreaming; // Type of access property on cache miss.

//Set the attributes to a CUDA stream of type cudaStream_t
cudaStreamSetAttribute(stream, cudaStreamAttributeAccessPolicyWindow, &stream_attribute);

当内核随后在 CUDA 流中执行时,全局内存范围 [ptr..ptr+num_bytes) 内的内存访问比对其他全局内存位置的访问更有可能保留在 L2 缓存中。

也可以为 CUDA Graph Kernel Node节点设置 L2 持久性,如下例所示:

1
2
3
4
5
6
7
8
9
10
cudaKernelNodeAttrValue node_attribute;                                     // Kernel level attributes data structure
node_attribute.accessPolicyWindow.base_ptr = reinterpret_cast<void*>(ptr); // Global Memory data pointer
node_attribute.accessPolicyWindow.num_bytes = num_bytes; // Number of bytes for persistence access.
// (Must be less than cudaDeviceProp::accessPolicyMaxWindowSize)
node_attribute.accessPolicyWindow.hitRatio = 0.6; // Hint for cache hit ratio
node_attribute.accessPolicyWindow.hitProp = cudaAccessPropertyPersisting; // Type of access property on cache hit
node_attribute.accessPolicyWindow.missProp = cudaAccessPropertyStreaming; // Type of access property on cache miss.

//Set the attributes to a CUDA Graph Kernel node of type cudaGraphNode_t
cudaGraphKernelNodeSetAttribute(node, cudaKernelNodeAttributeAccessPolicyWindow, &node_attribute);

hitRatio 参数可用于指定接收 hitProp 属性的访问比例。 在上面的两个示例中,全局内存区域 [ptr..ptr+num_bytes) 中 60% 的内存访问具有持久属性,40% 的内存访问具有流属性。 哪些特定的内存访问被归类为持久(hitProp)是随机的,概率大约为 hitRatio; 概率分布取决于硬件架构和内存范围。

例如,如果 L2 预留缓存大小为 16KB,而 accessPolicyWindow 中的 num_bytes 为 32KB:

  • hitRatio 为 0.5 时,硬件将随机选择 32KB 窗口中的 16KB 指定为持久化并缓存在预留的 L2 缓存区域中。
  • hitRatio 为 1.0 时,硬件将尝试在预留的 L2 缓存区域中缓存整个 32KB 窗口。 由于预留区域小于窗口,缓存行将被逐出以将 32KB 数据中最近使用的 16KB 保留在 L2 缓存的预留部分中。

因此,hitRatio 可用于避免缓存的破坏,并总体减少移入和移出 L2 高速缓存的数据量。

低于 1.0 的 hitRatio 值可用于手动控制来自并发 CUDA 流的不同 accessPolicyWindows 可以缓存在 L2 中的数据量。 例如,让 L2 预留缓存大小为 16KB; 两个不同 CUDA 流中的两个并发内核,每个都有一个 16KB 的 accessPolicyWindow,并且两者的 hitRatio 值都为 1.0,在竞争共享 L2 资源时,可能会驱逐彼此的缓存。 但是,如果两个 accessPolicyWindowshitRatio 值都为 0.5,则它们将不太可能逐出自己或彼此的持久缓存。

3.2.3.3 L2访问属性

为不同的全局内存数据访问定义了三种类型的访问属性:

  1. cudaAccessPropertyStreaming:使用流属性发生的内存访问不太可能在 L2 缓存中持续存在,因为这些访问优先被驱逐。
  2. cudaAccessPropertyPersisting:使用持久属性发生的内存访问更有可能保留在 L2 缓存中,因为这些访问优先保留在 L2 缓存的预留部分中。
  3. cudaAccessPropertyNormal:此访问属性强制将先前应用的持久访问属性重置为正常状态。来自先前 CUDA 内核的具有持久性属性的内存访问可能会在其预期用途之后很长时间保留在 L2 缓存中。这种使用后的持久性减少了不使用持久性属性的后续内核可用的 L2 缓存量。使用 cudaAccessPropertyNormal 属性重置访问属性窗口会删除先前访问的持久(优先保留)状态,就像先前访问没有访问属性一样。

3.2.3.4 L2持久性示例

以下示例显示如何为持久访问预留 L2 缓存,通过 CUDA Stream 在 CUDA 内核中使用预留的 L2 缓存,然后重置 L2 缓存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
cudaStream_t stream;
cudaStreamCreate(&stream); // Create CUDA stream

cudaDeviceProp prop; // CUDA device properties variable
cudaGetDeviceProperties( &prop, device_id); // Query GPU properties
size_t size = min( int(prop.l2CacheSize * 0.75) , prop.persistingL2CacheMaxSize );
cudaDeviceSetLimit( cudaLimitPersistingL2CacheSize, size); // set-aside 3/4 of L2 cache for persisting accesses or the max allowed

size_t window_size = min(prop.accessPolicyMaxWindowSize, num_bytes); // Select minimum of user defined num_bytes and max window size.

cudaStreamAttrValue stream_attribute; // Stream level attributes data structure
stream_attribute.accessPolicyWindow.base_ptr = reinterpret_cast<void*>(data1); // Global Memory data pointer
stream_attribute.accessPolicyWindow.num_bytes = window_size; // Number of bytes for persistence access
stream_attribute.accessPolicyWindow.hitRatio = 0.6; // Hint for cache hit ratio
stream_attribute.accessPolicyWindow.hitProp = cudaAccessPropertyPersisting; // Persistence Property
stream_attribute.accessPolicyWindow.missProp = cudaAccessPropertyStreaming; // Type of access property on cache miss

cudaStreamSetAttribute(stream, cudaStreamAttributeAccessPolicyWindow, &stream_attribute); // Set the attributes to a CUDA Stream

for(int i = 0; i < 10; i++) {
cuda_kernelA<<<grid_size,block_size,0,stream>>>(data1); // This data1 is used by a kernel multiple times
} // [data1 + num_bytes) benefits from L2 persistence
cuda_kernelB<<<grid_size,block_size,0,stream>>>(data1); // A different kernel in the same stream can also benefit
// from the persistence of data1

stream_attribute.accessPolicyWindow.num_bytes = 0; // Setting the window size to 0 disable it
cudaStreamSetAttribute(stream, cudaStreamAttributeAccessPolicyWindow, &stream_attribute); // Overwrite the access policy attribute to a CUDA Stream
cudaCtxResetPersistingL2Cache(); // Remove any persistent lines in L2

cuda_kernelC<<<grid_size,block_size,0,stream>>>(data2); // data2 can now benefit from full L2 in normal mode

3.2.3.5 将L2 Access重置为Normal

来自之前CUDA内核的L2缓存在被使用后可能会长期保存在L2中。因此,L2缓存重设为正常状态对于流或正常内存访问很重要,以便以正常优先级使用L2缓存。有三种方法可以将持久访问重置为正常状态。

  1. 使用访问属性cudaAccessPropertyNormal重置之前的持久化内存区域。
  2. 通过调用cudaCtxResetPersistingL2Cache()将所有持久L2缓存线重置为正常。
  3. 最终,未触及的空间会自动重置为正常。对自动复位的依赖性很强

3.2.3.6 管理L2预留缓存的利用率

在不同 CUDA 流中同时执行的多个 CUDA 内核可能具有分配给它们的流的不同访问策略窗口。 但是,L2 预留缓存部分在所有这些并发 CUDA 内核之间共享。 因此,这个预留缓存部分的净利用率是所有并发内核单独使用的总和。 将内存访问指定为持久访问的好处会随着持久访问的数量超过预留的 L2 缓存容量而减少。

要管理预留 L2 缓存部分的利用率,应用程序必须考虑以下事项:

  • L2 预留缓存的大小。
  • 可以同时执行的 CUDA 内核。
  • 可以同时执行的所有 CUDA 内核的访问策略窗口。
  • 何时以及如何需要 L2 重置以允许正常或流式访问以同等优先级利用先前预留的 L2 缓存。

3.2.3.7 查询L2缓存属性

与 L2 缓存相关的属性是 cudaDeviceProp 结构的一部分,可以使用 CUDA 运行时 API cudaGetDeviceProperties 进行查询

CUDA 设备属性包括:

  • l2CacheSize:GPU 上可用的二级缓存数量。
  • persistingL2CacheMaxSize:可以为持久内存访问留出的 L2 缓存的最大数量。
  • accessPolicyMaxWindowSize:访问策略窗口的最大尺寸。

3.2.3.8 控制L2缓存预留大小用于持久内存访问

使用 CUDA 运行时 API cudaDeviceGetLimit 查询用于持久内存访问的 L2 预留缓存大小,并使用 CUDA 运行时 API cudaDeviceSetLimit 作为 cudaLimit 进行设置。 设置此限制的最大值是 cudaDeviceProp::persistingL2CacheMaxSize

1
2
3
4
enum cudaLimit {
/* other fields not shown */
cudaLimitPersistingL2CacheSize
};

3.2.4共享内存

可变内存空间说明中所述,共享内存是使用 __shared__ 内存空间说明符分配的。

正如线程层次结构中提到的和共享内存中详述的那样,共享内存预计比全局内存快得多。 它可以用作暂存器内存(或软件管理的缓存),以最大限度地减少来自 CUDA 块的全局内存访问,如下面的矩阵乘法示例所示。

matrix-multiplication-without-shared-memory.png

以下代码示例是不利用共享内存的矩阵乘法的简单实现。 每个线程读取 A 的一行和 B 的一列,并计算 C 的相应元素,如图所示。因此,从全局内存中读取 A 为 B.width 次,而 B 为读取 A.height 次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
// Matrices are stored in row-major order:
// M(row, col) = *(M.elements + row * M.width + col)
typedef struct {
int width;
int height;
float* elements;
} Matrix;

// Thread block size
#define BLOCK_SIZE 16

// Forward declaration of the matrix multiplication kernel
__global__ void MatMulKernel(const Matrix, const Matrix, Matrix);

// Matrix multiplication - Host code
// Matrix dimensions are assumed to be multiples of BLOCK_SIZE
void MatMul(const Matrix A, const Matrix B, Matrix C)
{
// Load A and B to device memory
Matrix d_A;
d_A.width = A.width; d_A.height = A.height;
size_t size = A.width * A.height * sizeof(float);
cudaMalloc(&d_A.elements, size);
cudaMemcpy(d_A.elements, A.elements, size,
cudaMemcpyHostToDevice);
Matrix d_B;
d_B.width = B.width; d_B.height = B.height;
size = B.width * B.height * sizeof(float);
cudaMalloc(&d_B.elements, size);
cudaMemcpy(d_B.elements, B.elements, size,
cudaMemcpyHostToDevice);

// Allocate C in device memory
Matrix d_C;
d_C.width = C.width; d_C.height = C.height;
size = C.width * C.height * sizeof(float);
cudaMalloc(&d_C.elements, size);

// Invoke kernel
dim3 dimBlock(BLOCK_SIZE, BLOCK_SIZE);
dim3 dimGrid(B.width / dimBlock.x, A.height / dimBlock.y);
MatMulKernel<<<dimGrid, dimBlock>>>(d_A, d_B, d_C);

// Read C from device memory
cudaMemcpy(C.elements, d_C.elements, size,
cudaMemcpyDeviceToHost);

// Free device memory
cudaFree(d_A.elements);
cudaFree(d_B.elements);
cudaFree(d_C.elements);
}

// Matrix multiplication kernel called by MatMul()
__global__ void MatMulKernel(Matrix A, Matrix B, Matrix C)
{
// Each thread computes one element of C
// by accumulating results into Cvalue
float Cvalue = 0;
int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
for (int e = 0; e < A.width; ++e)
Cvalue += A.elements[row * A.width + e]
* B.elements[e * B.width + col];
C.elements[row * C.width + col] = Cvalue;
}

以下代码示例是利用共享内存的矩阵乘法实现。在这个实现中,每个线程块负责计算C的一个方形子矩阵Csub,块内的每个线程负责计算Csub的一个元素。如图所示,Csub 等于两个矩形矩阵的乘积:维度 A 的子矩阵 (A.width, block_size) 与 Csub 具有相同的行索引,以及维度 B 的子矩阵(block_size, A.width ) 具有与 Csub 相同的列索引。为了适应设备的资源,这两个矩形矩阵根据需要被分成多个尺寸为 block_size 的方阵,并且 Csub 被计算为这些方阵的乘积之和。这些乘积中的每一个都是通过首先将两个对应的方阵从全局内存加载到共享内存中的,一个线程加载每个矩阵的一个元素,然后让每个线程计算乘积的一个元素。每个线程将这些乘积中的每一个的结果累积到一个寄存器中,并在完成后将结果写入全局内存。

matrix-multiplication-with-shared-memory.png

通过以这种方式将计算分块,我们利用了快速共享内存并节省了大量的全局内存带宽,因为 A 只从全局内存中读取 (B.width / block_size) 次,而 B 被读取 (A.height / block_size) 次.

前面代码示例中的 Matrix 类型增加了一个 stride 字段,因此子矩阵可以用相同的类型有效地表示。 __device__ 函数用于获取和设置元素并从矩阵构建任何子矩阵。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
// Matrices are stored in row-major order:
// M(row, col) = *(M.elements + row * M.stride + col)
typedef struct {
int width;
int height;
int stride;
float* elements;
} Matrix;

// Get a matrix element
__device__ float GetElement(const Matrix A, int row, int col)
{
return A.elements[row * A.stride + col];
}

// Set a matrix element
__device__ void SetElement(Matrix A, int row, int col,
float value)
{
A.elements[row * A.stride + col] = value;
}

// Get the BLOCK_SIZExBLOCK_SIZE sub-matrix Asub of A that is
// located col sub-matrices to the right and row sub-matrices down
// from the upper-left corner of A
__device__ Matrix GetSubMatrix(Matrix A, int row, int col)
{
Matrix Asub;
Asub.width = BLOCK_SIZE;
Asub.height = BLOCK_SIZE;
Asub.stride = A.stride;
Asub.elements = &A.elements[A.stride * BLOCK_SIZE * row
+ BLOCK_SIZE * col];
return Asub;
}

// Thread block size
#define BLOCK_SIZE 16

// Forward declaration of the matrix multiplication kernel
__global__ void MatMulKernel(const Matrix, const Matrix, Matrix);

// Matrix multiplication - Host code
// Matrix dimensions are assumed to be multiples of BLOCK_SIZE
void MatMul(const Matrix A, const Matrix B, Matrix C)
{
// Load A and B to device memory
Matrix d_A;
d_A.width = d_A.stride = A.width; d_A.height = A.height;
size_t size = A.width * A.height * sizeof(float);
cudaMalloc(&d_A.elements, size);
cudaMemcpy(d_A.elements, A.elements, size,
cudaMemcpyHostToDevice);
Matrix d_B;
d_B.width = d_B.stride = B.width; d_B.height = B.height;
size = B.width * B.height * sizeof(float);
cudaMalloc(&d_B.elements, size);
cudaMemcpy(d_B.elements, B.elements, size,
cudaMemcpyHostToDevice);

// Allocate C in device memory
Matrix d_C;
d_C.width = d_C.stride = C.width; d_C.height = C.height;
size = C.width * C.height * sizeof(float);
cudaMalloc(&d_C.elements, size);

// Invoke kernel
dim3 dimBlock(BLOCK_SIZE, BLOCK_SIZE);
dim3 dimGrid(B.width / dimBlock.x, A.height / dimBlock.y);
MatMulKernel<<<dimGrid, dimBlock>>>(d_A, d_B, d_C);

// Read C from device memory
cudaMemcpy(C.elements, d_C.elements, size,
cudaMemcpyDeviceToHost);

// Free device memory
cudaFree(d_A.elements);
cudaFree(d_B.elements);
cudaFree(d_C.elements);
}

// Matrix multiplication kernel called by MatMul()
__global__ void MatMulKernel(Matrix A, Matrix B, Matrix C)
{
// Block row and column
int blockRow = blockIdx.y;
int blockCol = blockIdx.x;

// Each thread block computes one sub-matrix Csub of C
Matrix Csub = GetSubMatrix(C, blockRow, blockCol);

// Each thread computes one element of Csub
// by accumulating results into Cvalue
float Cvalue = 0;

// Thread row and column within Csub
int row = threadIdx.y;
int col = threadIdx.x;

// Loop over all the sub-matrices of A and B that are
// required to compute Csub
// Multiply each pair of sub-matrices together
// and accumulate the results
for (int m = 0; m < (A.width / BLOCK_SIZE); ++m) {

// Get sub-matrix Asub of A
Matrix Asub = GetSubMatrix(A, blockRow, m);

// Get sub-matrix Bsub of B
Matrix Bsub = GetSubMatrix(B, m, blockCol);

// Shared memory used to store Asub and Bsub respectively
__shared__ float As[BLOCK_SIZE][BLOCK_SIZE];
__shared__ float Bs[BLOCK_SIZE][BLOCK_SIZE];

// Load Asub and Bsub from device memory to shared memory
// Each thread loads one element of each sub-matrix
As[row][col] = GetElement(Asub, row, col);
Bs[row][col] = GetElement(Bsub, row, col);

// Synchronize to make sure the sub-matrices are loaded
// before starting the computation
__syncthreads();
// Multiply Asub and Bsub together
for (int e = 0; e < BLOCK_SIZE; ++e)
Cvalue += As[row][e] * Bs[e][col];

// Synchronize to make sure that the preceding
// computation is done before loading two new
// sub-matrices of A and B in the next iteration
__syncthreads();
}

// Write Csub to device memory
// Each thread writes one element
SetElement(Csub, row, col, Cvalue);
}

3.2.5 Page-Locked主机内存

运行时提供的函数允许使用锁页(也称为固定)主机内存(与 malloc() 分配的常规可分页主机内存相反):

  • cudaHostAlloc()cudaFreeHost() 分配和释放锁页主机内存;
  • cudaHostRegister()malloc() 分配的内存范围变为锁页内存(有关限制,请参阅参考手册)。

使用页面锁定的主机内存有几个好处:

  • 锁页主机内存和设备内存之间的复制可以与异步并发执行中提到的某些设备的内核执行同时执行。
  • 在某些设备上,锁页主机内存可以映射到设备的地址空间,从而无需将其复制到设备内存或从设备内存复制,如映射内存中所述。
  • 在具有前端总线的系统上,如果主机内存被分配为页锁定,则主机内存和设备内存之间的带宽更高,如果另外分配为合并访存,则它甚至更高,如合并写入内存中所述。

然而,锁页主机内存是一种稀缺资源,因此锁页内存中的分配将在可分页内存中分配之前很久就开始失败。 此外,通过减少操作系统可用于分页的物理内存量,消耗过多的页面锁定内存会降低整体系统性能。

注意:页面锁定的主机内存不会缓存在非 I/O 一致的 Tegra 设备上。 此外,非 I/O 一致的 Tegra 设备不支持 cudaHostRegister()。

简单的零拷贝 CUDA 示例附带关于页面锁定内存 API 的详细文档。

3.2.5.1 Portable Memory

一块锁页内存可以与系统中的任何设备一起使用(有关多设备系统的更多详细信息,请参阅多设备系统),但默认情况下,使用上述锁页内存的好处只是与分配块时当前的设备一起可用(并且所有设备共享相同的统一地址空间,如果有,如统一虚拟地址空间中所述)。块需要通过将标志cudaHostAllocPortable传递给cudaHostAlloc()来分配,或者通过将标志cudaHostRegisterPortable传递给cudaHostRegister()来锁定页面。

3.2.5.2 写合并内存

默认情况下,锁页主机内存被分配为可缓存的。它可以选择分配为写组合,而不是通过将标志 cudaHostAllocWriteCombined 传递给 cudaHostAlloc()。 写入组合内存释放了主机的 L1 和 L2 缓存资源,为应用程序的其余部分提供更多缓存。 此外,在通过 PCI Express 总线的传输过程中,写入组合内存不会被窥探,这可以将传输性能提高多达 40%。

从主机读取写组合内存非常慢,因此写组合内存通常应用于仅主机写入的内存。

应避免在 WC 内存上使用 CPU 原子指令,因为并非所有 CPU 实现都能保证该功能。

3.2.5.3 Mapped Memory

通过将标志 cudaHostAllocMapped 传递给 cudaHostAlloc() 或通过将标志 cudaHostRegisterMapped 传递给 cudaHostRegister(),也可以将锁页主机内存块映射到设备的地址空间。因此,这样的块通常有两个地址:一个在主机内存中,由 cudaHostAlloc()malloc() 返回,另一个在设备内存中,可以使用 cudaHostGetDevicePointer() 检索,然后用于从内核中访问该块。唯一的例外是使用 cudaHostAlloc() 分配的指针,以及统一虚拟地址空间中提到的主机和设备使用统一地址空间。

直接从内核中访问主机内存不会提供与设备内存相同的带宽,但确实有一些优势:

  • 无需在设备内存中分配一个块,并在该块和主机内存中的块之间复制数据;数据传输是根据内核的需要隐式执行的;
  • 无需使用流(请参阅并发数据传输)将数据传输与内核执行重叠;内核发起的数据传输自动与内核执行重叠。

然而,由于映射的锁页内存在主机和设备之间共享,因此应用程序必须使用流或事件同步内存访问(请参阅异步并发执行)以避免任何潜在的 read-after-write、write-after-read 或 write-after-write危险。

为了能够检索到任何映射的锁页内存的设备指针,必须在执行任何其他 CUDA 调用之前通过使用 cudaDeviceMapHost 标志调用 cudaSetDeviceFlags() 来启用页面锁定内存映射。否则, cudaHostGetDevicePointer() 将返回错误。

如果设备不支持映射的锁页主机内存,cudaHostGetDevicePointer() 也会返回错误。应用程序可以通过检查 canMapHostMemory 设备属性(请参阅[设备枚举](https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#device-enumeration)来查询此功能,对于支持映射锁页主机内存的设备,该属性等于 1。

请注意,从主机或其他设备的角度来看,在映射的锁页内存上运行的原子函数(请参阅原子函数)不是原子的。

另请注意,CUDA 运行时要求从主机和其他设备的角度来看,从设备启动到主机内存的 1 字节、2 字节、4 字节和 8 字节自然对齐的加载和存储保留为单一访问设备。在某些平台上,内存的原子操作可能会被硬件分解为单独的加载和存储操作。这些组件加载和存储操作对保留自然对齐的访问具有相同的要求。例如,CUDA 运行时不支持 PCI Express 总线拓扑,其中 PCI Express 桥将 8 字节自然对齐的写入拆分为设备和主机之间的两个 4 字节写入。

3.2.6 异步并发执行

CUDA 将以下操作公开为可以彼此同时操作的独立任务:

  • 在主机上计算;
  • 设备上的计算;
  • 从主机到设备的内存传输;
  • 从设备到主机的内存传输;
  • 在给定设备的内存中进行内存传输;
  • 设备之间的内存传输。

这些操作之间实现的并发级别将取决于设备的功能和计算能力,如下所述。

3.2.6.1 主机和设备之间的并发执行

在设备完成请求的任务之前,异步库函数将控制权返回给宿主线程,从而促进了主机的并发执行。使用异步调用,许多设备操作可以在适当的设备资源可用时排队,由CUDA驱动程序执行。这减轻了主机线程管理设备的大部分责任,让它自由地执行其他任务。以下设备操作对主机是异步的:

  • 内核启动;
  • 内存复制在单个设备的内存中;
  • 从主机到设备内存拷贝的内存块大小不超过64kb的;
  • 由带有Async后缀的函数执行的内存拷贝;
  • 内存设置函数调用。
    程序员可以通过将CUDA_LAUNCH_BLOCKING环境变量设置为1来全局禁用系统上运行的所有CUDA应用程序的内核启动的异步性。此特性仅用于调试目的,不应用作使生产软件可靠运行的一种方法。

如果通过分析器(Nsight、Visual Profiler)收集硬件计数器,则内核启动是同步的,除非启用了并发内核分析。如果异步内存复制涉及非页面锁定的主机内存,它们也将是同步的。

3.2.6.2 并行执行内核

某些计算能力 2.x 及更高版本的设备可以同时执行多个内核。 应用程序可以通过检查 concurrentKernels 设备属性(请参阅设备枚举)来查询此功能,对于支持它的设备,该属性等于 1。

设备可以同时执行的内核启动的最大数量取决于其计算能力,并在表15 中列出。

来自一个 CUDA 上下文的内核不能与来自另一个 CUDA 上下文的内核同时执行。

使用许多纹理或大量本地内存的内核不太可能与其他内核同时执

3.2.6.3 数据传输和内核执行的重叠

一些设备可以在内核执行的同时执行与 GPU 之间的异步内存复制。 应用程序可以通过检查 asyncEngineCount 设备属性(请参阅设备枚举)来查询此功能,对于支持它的设备,该属性大于零。 如果复制中涉及主机内存,则它必须是页锁定的。

还可以与内核执行(在支持 concurrentKernels 设备属性的设备上)或与设备之间的拷贝(对于支持 asyncEngineCount 属性的设备)同时执行设备内复制。 使用标准内存复制功能启动设备内复制,目标地址和源地址位于同一设备上。

3.2.6.4 并行数据传输

某些计算能力为 2.x 及更高版本的设备可以重叠设备之间的数据拷贝。 应用程序可以通过检查 asyncEngineCount 设备属性(请参阅设备枚举)来查询此功能,对于支持它的设备,该属性等于 2。 为了重叠,传输中涉及的任何主机内存都必须是页面锁定的。

3.2.6.5 流

应用程序通过流管理上述并发操作。 流是按顺序执行的命令序列(可能由不同的主机线程发出)。 另一方面,不同的流可能会彼此乱序或同时执行它们的命令; 不能保证此行为,因此不应依赖其正确性(例如,内核间通信未定义)。 当满足命令的所有依赖项时,可以执行在流上发出的命令。 依赖关系可以是先前在同一流上启动的命令或来自其他流的依赖关系。 同步调用的成功完成保证了所有启动的命令都完成了。

3.2.6.5.1 创建与销毁

流是通过创建一个流对象并将其指定为一系列内核启动和主机 <-> 设备内存拷贝的流参数来定义的。 以下代码示例创建两个流并在锁页内存中分配一个浮点数组 hostPtr

1
2
3
4
5
cudaStream_t stream[2];
for (int i = 0; i < 2; ++i)
cudaStreamCreate(&stream[i]);
float* hostPtr;
cudaMallocHost(&hostPtr, 2 * size);

这些流中的每一个都由以下代码示例定义为从主机到设备的一次内存复制、一次内核启动和从设备到主机的一次内存复制的序列:

1
2
3
4
5
6
7
8
for (int i = 0; i < 2; ++i) {
cudaMemcpyAsync(inputDevPtr + i * size, hostPtr + i * size,
size, cudaMemcpyHostToDevice, stream[i]);
MyKernel <<<100, 512, 0, stream[i]>>>
(outputDevPtr + i * size, inputDevPtr + i * size, size);
cudaMemcpyAsync(hostPtr + i * size, outputDevPtr + i * size,
size, cudaMemcpyDeviceToHost, stream[i]);
}

每个流将其输入数组 hostPtr 的部分复制到设备内存中的数组 inputDevPtr,通过调用 MyKernel() 处理设备上的 inputDevPtr,并将结果 outputDevPtr 复制回 hostPtr 的同一部分。 重叠行为描述了此示例中的流如何根据设备的功能重叠。 请注意,hostPtr 必须指向锁页主机内存才能发生重叠。

通过调用 cudaStreamDestroy() 释放流:

1
2
for (int i = 0; i < 2; ++i)
cudaStreamDestroy(stream[i]);

如果调用 cudaStreamDestroy() 时设备仍在流中工作,则该函数将立即返回,并且一旦设备完成流中的所有工作,与流关联的资源将自动释放。

3.2.6.5.2 默认流

未指定任何流参数或等效地将流参数设置为零的内核启动和主机 <-> 设备内存拷贝将发布到默认流。因此它们按顺序执行。

对于使用 --default-stream per-thread 编译标志编译的代码(或在包含 CUDA 头文件(cuda.h 和 cuda_runtime.h)之前定义 CUDA_API_PER_THREAD_DEFAULT_STREAM 宏),默认流是常规流,并且每个主机线程有自己的默认流。

注意:当代码由 nvcc 编译时,#define CUDA_API_PER_THREAD_DEFAULT_STREAM 1 不能用于启用此行为,因为 nvcc 在翻译单元的顶部隐式包含 cuda_runtime.h。在这种情况下,需要使用 --default-stream 每个线程编译标志,或者需要使用 -DCUDA_API_PER_THREAD_DEFAULT_STREAM=1 编译器标志定义 CUDA_API_PER_THREAD_DEFAULT_STREAM 宏。

对于使用 --default-stream legacy 编译标志编译的代码,默认流是称为 NULL 流的特殊流,每个设备都有一个用于所有主机线程的 NULL 流。 NULL 流很特殊,因为它会导致隐式同步,如隐式同步中所述。

对于在没有指定 --default-stream 编译标志的情况下编译的代码, --default-stream legacy 被假定为默认值。

3.2.6.5.3 显式同步

有多种方法可以显式地同步流。

cudaDeviceSynchronize() 一直等待,直到所有主机线程的所有流中的所有先前命令都完成。

cudaStreamSynchronize() 将流作为参数并等待,直到给定流中的所有先前命令都已完成。 它可用于将主机与特定流同步,允许其他流继续在设备上执行。

cudaStreamWaitEvent() 将流和事件作为参数(有关事件的描述,请参阅事件),并在调用 cudaStreamWaitEvent() 后使添加到给定流的所有命令延迟执行,直到给定事件完成。

cudaStreamQuery() 为应用程序提供了一种方法来了解流中所有前面的命令是否已完成。

3.2.6.5.4 隐式同步

如果主机线程在它们之间发出以下任一操作,则来自不同流的两个命令不能同时运行:

  • 页面锁定的主机内存分配,
  • 设备内存分配,
  • 设备内存设置,
  • 两个地址之间的内存拷贝到同一设备内存,
  • 对 NULL 流的任何 CUDA 命令,
  • 计算能力 3.x 计算能力 7.x 中描述的 L1/共享内存配置之间的切换。

对于支持并发内核执行且计算能力为 3.0 或更低的设备,任何需要依赖项检查以查看流内核启动是否完成的操作:

  • 仅当从 CUDA 上下文中的任何流启动的所有先前内核的所有线程块都已开始执行时,才能开始执行;
  • 阻止所有以后从 CUDA 上下文中的任何流启动内核,直到检查内核启动完成。

需要依赖检查的操作包括与正在检查的启动相同的流中的任何其他命令以及对该流的任何 cudaStreamQuery() 调用。 因此,应用程序应遵循以下准则来提高并发内核执行的潜力:

  • 所有独立操作都应该在依赖操作之前发出,
  • 任何类型的同步都应该尽可能地延迟。
3.2.6.5.5 重叠行为

两个流之间的执行重叠量取决于向每个流发出命令的顺序以及设备是否支持数据传输和内核执行的重叠(请参阅数据传输和内核执行的重叠)、并发内核执行( 请参阅并发内核执行)和并发数据传输(请参阅并发数据传输)。

例如,在设备不支持并行数据传输,这两个流的代码示例创建和销毁不重叠,因为由stream[1]发起的内存复制会在stream[0]发起的内存复制之后执行。如果代码以以下方式重写(并且假设设备支持数据传输和内核执行的重叠)

1
2
3
4
5
6
7
8
9
for (int i = 0; i < 2; ++i)
cudaMemcpyAsync(inputDevPtr + i * size, hostPtr + i * size,
size, cudaMemcpyHostToDevice, stream[i]);
for (int i = 0; i < 2; ++i)
MyKernel<<<100, 512, 0, stream[i]>>>
(outputDevPtr + i * size, inputDevPtr + i * size, size);
for (int i = 0; i < 2; ++i)
cudaMemcpyAsync(hostPtr + i * size, outputDevPtr + i * size,
size, cudaMemcpyDeviceToHost, stream[i]);

那么在stream[1]上从主机到设备的内存复制 与stream[0]上内核启动重叠。

在支持并发数据传输的设备上,Creation 和 Destruction 的代码示例的两个流确实重叠:在stream[1]上从主机到设备的内存复制 与在stream[0]上从设备到主机的内存复制甚至在stream[0]上内核启动(假设设备支持数据传输和内核执行的重叠)。但是,对于计算能力为 3.0 或更低的设备,内核执行不可能重叠,因为在stream[0]上从设备到主机的内存复制之后,第二次在stream[1]上内核启动,因此它被阻塞,直到根据隐式同步,在stream[0]上第一个内核启动已完成。如果代码如上重写,内核执行重叠(假设设备支持并发内核执行),因为在stream[0]上从设备到主机的内存复制之前,第二次在stream[1]上内核启动被。但是,在这种情况下,根据隐式同步,在stream[0]上从设备到主机的内存复制仅与在stream[1]上内核启动的最后一个线程块重叠,这只能代表总数的一小部分内核的执行时间。

3.2.6.5.6 Host函数(回调)

运行时提供了一种通过 cudaLaunchHostFunc() 在任何点将 CPU 函数调用插入到流中的方法。 在回调之前向流发出的所有命令都完成后,在主机上执行提供的函数。

以下代码示例在向每个流发出主机到设备内存副本、内核启动和设备到主机内存副本后,将主机函数 MyCallback 添加到两个流中的每一个。 每个设备到主机的内存复制完成后,该函数将在主机上开始执行。

1
2
3
4
5
6
7
8
9
10
void CUDART_CB MyCallback(cudaStream_t stream, cudaError_t status, void *data){
printf("Inside callback %d\n", (size_t)data);
}
...
for (size_t i = 0; i < 2; ++i) {
cudaMemcpyAsync(devPtrIn[i], hostPtr[i], size, cudaMemcpyHostToDevice, stream[i]);
MyKernel<<<100, 512, 0, stream[i]>>>(devPtrOut[i], devPtrIn[i], size);
cudaMemcpyAsync(hostPtr[i], devPtrOut[i], size, cudaMemcpyDeviceToHost, stream[i]);
cudaLaunchHostFunc(stream[i], MyCallback, (void*)i);
}

在主机函数之后在流中发出的命令不会在函数完成之前开始执行。

在流中的主机函数不得进行 CUDA API 调用(直接或间接),因为如果它进行这样的调用导致死锁,它可能最终会等待自身。

3.2.6.5.7 流优先级

可以在创建时使用 cudaStreamCreateWithPriority() 指定流的相对优先级。 可以使用 cudaDeviceGetStreamPriorityRange() 函数获得允许的优先级范围,按 [最高优先级,最低优先级] 排序。 在运行时,高优先级流中的待处理工作优先于低优先级流中的待处理工作。

以下代码示例获取当前设备允许的优先级范围,并创建具有最高和最低可用优先级的流。

1
2
3
4
5
6
7
// get the range of stream priorities for this device
int priority_high, priority_low;
cudaDeviceGetStreamPriorityRange(&priority_low, &priority_high);
// create streams with highest and lowest available priorities
cudaStream_t st_high, st_low;
cudaStreamCreateWithPriority(&st_high, cudaStreamNonBlocking, priority_high);
cudaStreamCreateWithPriority(&st_low, cudaStreamNonBlocking, priority_low);

3.2.6.6 CUDA图

CUDA Graphs 为 CUDA 中的工作提交提供了一种新模型。图是一系列操作,例如内核启动,由依赖关系连接,独立于其执行定义。这允许一个图被定义一次,然后重复启动。将图的定义与其执行分开可以实现许多优化:首先,与流相比,CPU 启动成本降低,因为大部分设置都是提前完成的;其次,将整个工作流程呈现给 CUDA 可以实现优化,这可能无法通过流的分段工作提交机制实现。

要查看图形可能的优化,请考虑流中发生的情况:当您将内核放入流中时,主机驱动程序会执行一系列操作,以准备在 GPU 上执行内核。这些设置和启动内核所必需的操作是必须为发布的每个内核支付的间接成本。对于执行时间较短的 GPU 内核,这种开销成本可能是整个端到端执行时间的很大一部分。

使用图的工作提交分为三个不同的阶段:定义、实例化和执行。

  • 在定义阶段,程序创建图中操作的描述以及它们之间的依赖关系。
  • 实例化获取图模板的快照,对其进行验证,并执行大部分工作的设置和初始化,目的是最大限度地减少启动时需要完成的工作。 生成的实例称为可执行图。
  • 可执行图可以启动到流中,类似于任何其他 CUDA 工作。 它可以在不重复实例化的情况下启动任意次数。
3.2.6.6.1图架构

一个操作在图中形成一个节点。 操作之间的依赖关系是边。 这些依赖关系限制了操作的执行顺序。

一个操作可以在它所依赖的节点完成后随时调度。 调度由 CUDA 系统决定。

3.2.6.6.1.1 节点类型

图节点可以是以下之一:

  • 核函数
  • CPU函数调用
  • 内存拷贝
  • 内存设置
  • 空节点
  • 等待事件
  • 记录事件
  • 发出外部信号量的信号
  • 等待外部信号量
  • 子图:执行单独的嵌套图。 请参下图。

child-graph.png

3.2.6.6.2利用API创建图

可以通过两种机制创建图:显式 API 和流捕获。 以下是创建和执行下图的示例。
create-a-graph.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Create the graph - it starts out empty
cudaGraphCreate(&graph, 0);

// For the purpose of this example, we'll create
// the nodes separately from the dependencies to
// demonstrate that it can be done in two stages.
// Note that dependencies can also be specified
// at node creation.
cudaGraphAddKernelNode(&a, graph, NULL, 0, &nodeParams);
cudaGraphAddKernelNode(&b, graph, NULL, 0, &nodeParams);
cudaGraphAddKernelNode(&c, graph, NULL, 0, &nodeParams);
cudaGraphAddKernelNode(&d, graph, NULL, 0, &nodeParams);

// Now set up dependencies on each node
cudaGraphAddDependencies(graph, &a, &b, 1); // A->B
cudaGraphAddDependencies(graph, &a, &c, 1); // A->C
cudaGraphAddDependencies(graph, &b, &d, 1); // B->D
cudaGraphAddDependencies(graph, &c, &d, 1); // C->D
3.2.6.6.3 使用流捕获创建图

流捕获提供了一种从现有的基于流的 API 创建图的机制。 将工作启动到流中的一段代码,包括现有代码,可以等同于用与 cudaStreamBeginCapture()cudaStreamEndCapture() 的调用。

1
2
3
4
5
6
7
8
9
10
cudaGraph_t graph;

cudaStreamBeginCapture(stream);

kernel_A<<< ..., stream >>>(...);
kernel_B<<< ..., stream >>>(...);
libraryCall(stream);
kernel_C<<< ..., stream >>>(...);

cudaStreamEndCapture(stream, &graph);

cudaStreamBeginCapture() 的调用将流置于捕获模式。 捕获流时,启动到流中的工作不会排队执行。 相反,它被附加到正在逐步构建的内部图中。 然后通过调用 cudaStreamEndCapture() 返回此图,这也结束了流的捕获模式。 由流捕获主动构建的图称为捕获图(capture graph)。

流捕获可用于除 cudaStreamLegacy(“NULL 流”)之外的任何 CUDA 流。 请注意,它可以在 cudaStreamPerThread 上使用。 如果程序正在使用legacy stream,则可以将stream 0 重新定义为不更改功能的每线程流。 请参阅默认流

可以使用 cudaStreamIsCapturing() 查询是否正在捕获流。

3.2.6.6.3.1 跨流依赖性和事件

流捕获可以处理用 cudaEventRecord()cudaStreamWaitEvent() 表示的跨流依赖关系,前提是正在等待的事件被记录到同一个捕获图中。

当事件记录在处于捕获模式的流中时,它会导致捕获事件。捕获的事件表示捕获图中的一组节点。

当流等待捕获的事件时,如果尚未将流置于捕获模式,则它会将流置于捕获模式,并且流中的下一个项目将对捕获事件中的节点具有额外的依赖关系。然后将两个流捕获到同一个捕获图。

当流捕获中存在跨流依赖时,仍然必须在调用 cudaStreamBeginCapture() 的同一流中调用 cudaStreamEndCapture();这是原始流。由于基于事件的依赖关系,被捕获到同一捕获图的任何其他流也必须连接回原始流。如下所示。在 cudaStreamEndCapture() 时,捕获到同一捕获图的所有流都将退出捕获模式。未能重新加入原始流将导致整个捕获操作失败。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// stream1 is the origin stream
cudaStreamBeginCapture(stream1);

kernel_A<<< ..., stream1 >>>(...);

// Fork into stream2
cudaEventRecord(event1, stream1);
cudaStreamWaitEvent(stream2, event1);

kernel_B<<< ..., stream1 >>>(...);
kernel_C<<< ..., stream2 >>>(...);

// Join stream2 back to origin stream (stream1)
cudaEventRecord(event2, stream2);
cudaStreamWaitEvent(stream1, event2);

kernel_D<<< ..., stream1 >>>(...);

// End capture in the origin stream
cudaStreamEndCapture(stream1, &graph);

// stream1 and stream2 no longer in capture mode

上述代码返回的图如图 10 所示。

注意:当流退出捕获模式时,流中的下一个未捕获项(如果有)仍将依赖于最近的先前未捕获项,尽管已删除中间项。

3.2.6.6.3.2 禁止和未处理的操作

同步或查询正在捕获的流或捕获的事件的执行状态是无效的,因为它们不代表计划执行的项目。当任何关联流处于捕获模式时,查询包含活动流捕获的更广泛句柄(例如设备或上下文句柄)的执行状态或同步也是无效的。

当捕获同一上下文中的任何流时,并且它不是使用 cudaStreamNonBlocking 创建的,任何使用旧流的尝试都是无效的。这是因为legacy stream句柄始终包含这些其他流;legacy stream将创建对正在捕获的流的依赖,并且查询它或同步它会查询或同步正在捕获的流。

因此在这种情况下调用同步 API 也是无效的。同步 API,例如 cudaMemcpy(),将工作legacy stream并在返回之前对其进行同步。

注意:作为一般规则,当依赖关系将捕获的内容与未捕获的内容联系起来并排队执行时,CUDA 更喜欢返回错误而不是忽略依赖关系。将流放入或退出捕获模式时会出现异常;这切断了在模式转换之前和之后添加到流中的项目之间的依赖关系。

通过等待来自正在捕获并且与与事件不同的捕获图相关联的流中的捕获事件来合并两个单独的捕获图是无效的。等待正在捕获的流中的未捕获事件是无效的。

图中当前不支持将异步操作排入流的少量 API,如果使用正在捕获的流调用,则会返回错误,例如 cudaStreamAttachMemAsync()

3.2.6.6.3.3失效

在流捕获期间尝试无效操作时,任何关联的捕获图都将失效。 当捕获图无效时,进一步使用正在捕获的任何流或与该图关联的捕获事件将无效并将返回错误,直到使用 cudaStreamEndCapture() 结束流捕获。 此调用将使关联的流脱离捕获模式,但也会返回错误值和 NULL 图。

3.2.6.6.4 更新实例化图

使用图的工作提交分为三个不同的阶段:定义、实例化和执行。在工作流不改变的情况下,定义和实例化的开销可以分摊到许多执行中,并且图提供了明显优于流的优势。

图是工作流的快照,包括内核、参数和依赖项,以便尽可能快速有效地重放它。在工作流发生变化的情况下,图会过时,必须进行修改。对图结构(例如拓扑或节点类型)的重大更改将需要重新实例化源图,因为必须重新应用各种与拓扑相关的优化技术。

重复实例化的成本会降低图执行带来的整体性能优势,但通常只有节点参数(例如内核参数和 cudaMemcpy 地址)发生变化,而图拓扑保持不变。对于这种情况,CUDA 提供了一种称为“图形更新”的轻量级机制,它允许就地修改某些节点参数,而无需重建整个图形。这比重新实例化要有效得多。

更新将在下次启动图时生效,因此它们不会影响以前的图启动,即使它们在更新时正在运行。一个图可能会被重复更新和重新启动,因此多个更新/启动可以在一个流上排队。

CUDA 提供了两种更新实例化图的机制,全图更新和单个节点更新。整个图更新允许用户提供一个拓扑相同的 cudaGraph_t 对象,其节点包含更新的参数。单个节点更新允许用户显式更新单个节点的参数。当大量节点被更新时,或者当调用者不知道图拓扑时(即,图是由库调用的流捕获产生的),使用更新的 cudaGraph_t 会更方便。当更改的数量很少并且用户拥有需要更新的节点的句柄时,首选使用单个节点更新。单个节点更新跳过未更改节点的拓扑检查和比较,因此在许多情况下它可以更有效。以下部分更详细地解释了每种方法。

3.2.6.6.4.1 图更新限制

内核节点:

  • 函数的所属上下文不能改变。
  • 其功能最初未使用 CUDA 动态并行性的节点无法更新为使用 CUDA 动态并行性的功能。

cudaMemset 和 cudaMemcpy 节点:

  • 操作数分配/映射到的 CUDA 设备不能更改。

  • 源/目标内存必须从与原始源/目标内存相同的上下文中分配。

  • 只能更改一维 cudaMemset/cudaMemcpy 节点。

  • 额外的 memcpy 节点限制:

  • 不支持更改源或目标内存类型(即 cudaPitchedPtr、cudaArray_t 等)或传输类型(即 cudaMemcpyKind)。

外部信号量等待节点和记录节点:

  • 不支持更改信号量的数量。
  • 对主机节点、事件记录节点或事件等待节点的更新没有限制。
3.2.6.6.4.2全图更新

cudaGraphExecUpdate() 允许使用相同拓扑图(“更新”图)中的参数更新实例化图(“原始图”)。 更新图的拓扑必须与用于实例化 cudaGraphExec_t 的原始图相同。 此外,将节点添加到原始图或从中删除的顺序必须与将节点添加到更新图(或从中删除)的顺序相匹配。 因此,在使用流捕获时,必须以相同的顺序捕获节点,而在使用显式图形节点创建 API 时,必须以相同的顺序添加或删除所有节点。

以下示例显示了如何使用 API 更新实例化图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
cudaGraphExec_t graphExec = NULL;

for (int i = 0; i < 10; i++) {
cudaGraph_t graph;
cudaGraphExecUpdateResult updateResult;
cudaGraphNode_t errorNode;

// In this example we use stream capture to create the graph.
// You can also use the Graph API to produce a graph.
cudaStreamBeginCapture(stream, cudaStreamCaptureModeGlobal);

// Call a user-defined, stream based workload, for example
do_cuda_work(stream);

cudaStreamEndCapture(stream, &graph);

// If we've already instantiated the graph, try to update it directly
// and avoid the instantiation overhead
if (graphExec != NULL) {
// If the graph fails to update, errorNode will be set to the
// node causing the failure and updateResult will be set to a
// reason code.
cudaGraphExecUpdate(graphExec, graph, &errorNode, &updateResult);
}

// Instantiate during the first iteration or whenever the update
// fails for any reason
if (graphExec == NULL || updateResult != cudaGraphExecUpdateSuccess) {

// If a previous update failed, destroy the cudaGraphExec_t
// before re-instantiating it
if (graphExec != NULL) {
cudaGraphExecDestroy(graphExec);
}
// Instantiate graphExec from graph. The error node and
// error message parameters are unused here.
cudaGraphInstantiate(&graphExec, graph, NULL, NULL, 0);
}

cudaGraphDestroy(graph);
cudaGraphLaunch(graphExec, stream);
cudaStreamSynchronize(stream);
}

典型的工作流程是使用流捕获或图 API 创建初始 cudaGraph_t。 然后 cudaGraph_t 被实例化并正常启动。 初始启动后,使用与初始图相同的方法创建新的 cudaGraph_t,并调用 cudaGraphExecUpdate()。 如果图更新成功,由上面示例中的 updateResult 参数指示,则启动更新的 cudaGraphExec_t。 如果由于任何原因更新失败,则调用 cudaGraphExecDestroy()cudaGraphInstantiate() 来销毁原始的 cudaGraphExec_t 并实例化一个新的。

也可以直接更新 cudaGraph_t 节点(即,使用 cudaGraphKernelNodeSetParams())并随后更新 cudaGraphExec_t,但是使用下一节中介绍的显式节点更新 API 会更有效。

有关使用情况和当前限制的更多信息,请参阅 Graph API

3.2.6.6.4.3 单个节点更新

实例化的图节点参数可以直接更新。 这消除了实例化的开销以及创建新 cudaGraph_t 的开销。 如果需要更新的节点数相对于图中的总节点数较小,则最好单独更新节点。 以下方法可用于更新 cudaGraphExec_t 节点:

  • cudaGraphExecKernelNodeSetParams()
  • cudaGraphExecMemcpyNodeSetParams()
  • cudaGraphExecMemsetNodeSetParams()
  • cudaGraphExecHostNodeSetParams()
  • cudaGraphExecChildGraphNodeSetParams()
  • cudaGraphExecEventRecordNodeSetEvent()
  • cudaGraphExecEventWaitNodeSetEvent()
  • cudaGraphExecExternalSemaphoresSignalNodeSetParams()
  • cudaGraphExecExternalSemaphoresWaitNodeSetParams()

有关使用情况和当前限制的更多信息,请参阅 Graph API

3.2.6.6.5 使用图API

cudaGraph_t 对象不是线程安全的。 用户有责任确保多个线程不会同时访问同一个 cudaGraph_t

cudaGraphExec_t 不能与自身同时运行。 cudaGraphExec_t 的启动将在之前启动相同的可执行图之后进行。

图形执行在流中完成,以便与其他异步工作进行排序。 但是,流仅用于排序; 它不限制图的内部并行性,也不影响图节点的执行位置。

请参阅图API

3.2.6.7 事件

运行时还提供了一种密切监视设备进度以及执行准确计时的方法,方法是让应用程序异步记录程序中任何点的事件,并查询这些事件何时完成。 当事件之前的所有任务(或给定流中的所有命令)都已完成时,事件已完成。 空流中的事件在所有流中的所有先前任务和命令都完成后完成。

3.2.6.7.1 创建和销毁

以下代码示例创建两个事件:

1
2
3
cudaEvent_t start, stop;
cudaEventCreate(&start);
cudaEventCreate(&stop);

它们以这种方式被销毁:

1
2
cudaEventDestroy(start);
cudaEventDestroy(stop);
3.2.6.7.2 计算时间

可以用以下方式来计时:

1
2
3
4
5
6
7
8
9
10
11
12
13
cudaEventRecord(start, 0);
for (int i = 0; i < 2; ++i) {
cudaMemcpyAsync(inputDev + i * size, inputHost + i * size,
size, cudaMemcpyHostToDevice, stream[i]);
MyKernel<<<100, 512, 0, stream[i]>>>
(outputDev + i * size, inputDev + i * size, size);
cudaMemcpyAsync(outputHost + i * size, outputDev + i * size,
size, cudaMemcpyDeviceToHost, stream[i]);
}
cudaEventRecord(stop, 0);
cudaEventSynchronize(stop);
float elapsedTime;
cudaEventElapsedTime(&elapsedTime, start, stop);

3.2.6.8同步调用

调用同步函数时,在设备完成请求的任务之前,控制不会返回给主机线程。 在主机线程执行任何其他 CUDA 调用之前,可以通过调用带有一些特定标志的 cudaSetDeviceFlags() 来指定主机线程是否会产生、阻塞或自旋(有关详细信息,请参阅参考手册)。

3.2.7 多设备系统

3.2.7.1设备枚举

一个主机系统可以有多个设备。 以下代码示例显示了如何枚举这些设备、查询它们的属性并确定启用 CUDA 的设备的数量。

1
2
3
4
5
6
7
8
9
int deviceCount;
cudaGetDeviceCount(&deviceCount);
int device;
for (device = 0; device < deviceCount; ++device) {
cudaDeviceProp deviceProp;
cudaGetDeviceProperties(&deviceProp, device);
printf("Device %d has compute capability %d.%d.\n",
device, deviceProp.major, deviceProp.minor);
}

3.2.7.2 设备选择

主机线程可以通过调用 cudaSetDevice()随时设置它所操作的设备。 设备内存分配和内核启动在当前设置的设备上进行; 流和事件是与当前设置的设备相关联的。 如果未调用 cudaSetDevice(),则当前设备为设备0。

以下代码示例说明了设置当前设备如何影响内存分配和内核执行。

1
2
3
4
5
6
7
8
9
size_t size = 1024 * sizeof(float);
cudaSetDevice(0); // Set device 0 as current
float* p0;
cudaMalloc(&p0, size); // Allocate memory on device 0
MyKernel<<<1000, 128>>>(p0); // Launch kernel on device 0
cudaSetDevice(1); // Set device 1 as current
float* p1;
cudaMalloc(&p1, size); // Allocate memory on device 1
MyKernel<<<1000, 128>>>(p1); // Launch kernel on device 1

3.2.7.3 流和事件行为

如果在与当前设备无关的流上启动内核将失败,如以下代码示例所示。

1
2
3
4
5
6
7
8
9
10
11
cudaSetDevice(0);               // Set device 0 as current
cudaStream_t s0;
cudaStreamCreate(&s0); // Create stream s0 on device 0
MyKernel<<<100, 64, 0, s0>>>(); // Launch kernel on device 0 in s0
cudaSetDevice(1); // Set device 1 as current
cudaStream_t s1;
cudaStreamCreate(&s1); // Create stream s1 on device 1
MyKernel<<<100, 64, 0, s1>>>(); // Launch kernel on device 1 in s1

// This kernel launch will fail:
MyKernel<<<100, 64, 0, s0>>>(); // Launch kernel on device 1 in s0

即使将内存复制运行在与当前设备无关的流,它也会成功。

如果输入事件和输入流关联到不同的设备,cudaEventRecord() 将失败。

如果两个输入事件关联到不同的设备, cudaEventElapsedTime() 将失败。

即使输入事件关联到与当前设备不同的设备,cudaEventSynchronize()cudaEventQuery() 也会成功。

即使输入流和输入事件关联到不同的设备,cudaStreamWaitEvent() 也会成功。 因此,cudaStreamWaitEvent()可用于使多个设备相互同步。

每个设备都有自己的默认流(请参阅默认流),因此向设备的默认流发出的命令可能会乱序执行或与向任何其他设备的默认流发出的命令同时执行。

3.2.7.4 Peer-to-Peer的内存访问

根据系统属性,特别是 PCIe 或 NVLINK 拓扑结构,设备能够相互寻址对方的内存(即,在一个设备上执行的内核可以取消引用指向另一设备内存的指针)。 如果 cudaDeviceCanAccessPeer() 为这两个设备返回 true,则在两个设备之间支持这种对等内存访问功能。

对等内存访问仅在 64 位应用程序中受支持,并且必须通过调用 cudaDeviceEnablePeerAccess() 在两个设备之间启用,如以下代码示例所示。 在未启用 NVSwitch 的系统上,每个设备最多可支持系统范围内的八个对等连接。

两个设备使用统一的地址空间(请参阅统一虚拟地址空间),因此可以使用相同的指针来寻址两个设备的内存,如下面的代码示例所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
cudaSetDevice(0);                   // Set device 0 as current
float* p0;
size_t size = 1024 * sizeof(float);
cudaMalloc(&p0, size); // Allocate memory on device 0
MyKernel<<<1000, 128>>>(p0); // Launch kernel on device 0
cudaSetDevice(1); // Set device 1 as current
cudaDeviceEnablePeerAccess(0, 0); // Enable peer-to-peer access
// with device 0

// Launch kernel on device 1
// This kernel launch can access memory on device 0 at address p0
MyKernel<<<1000, 128>>>(p0);

3.2.7.4.1 Linux上的IOMMU

仅在 Linux 上,CUDA 和显示驱动程序不支持启用 IOMMU 的裸机 PCIe 对等内存复制。 但是,CUDA 和显示驱动程序确实支持通过 VM 传递的 IOMMU。 因此,Linux 上的用户在本机裸机系统上运行时,应禁用 IOMMU。 如启用 IOMMU,将 VFIO 驱动程序用作虚拟机的 PCIe 通道。

在 Windows 上,上述限制不存在。

另请参阅在 64 位平台上分配 DMA 缓冲区

3.2.7.5 Peer-to-Peer内存拷贝

可以在两个不同设备的内存之间执行内存复制。

当两个设备使用统一地址空间时(请参阅统一虚拟地址空间),这是使用设备内存中提到的常规内存复制功能完成的。

否则,这将使用 cudaMemcpyPeer()cudaMemcpyPeerAsync()、cudaMemcpy3DPeer() 或 cudaMemcpy3DPeerAsync() 完成,如以下代码示例所示。

1
2
3
4
5
6
7
8
9
10
11
12
cudaSetDevice(0);                   // Set device 0 as current
float* p0;
size_t size = 1024 * sizeof(float);
cudaMalloc(&p0, size); // Allocate memory on device 0
cudaSetDevice(1); // Set device 1 as current
float* p1;
cudaMalloc(&p1, size); // Allocate memory on device 1
cudaSetDevice(0); // Set device 0 as current
MyKernel<<<1000, 128>>>(p0); // Launch kernel on device 0
cudaSetDevice(1); // Set device 1 as current
cudaMemcpyPeer(p1, 1, p0, 0, size); // Copy p0 to p1
MyKernel<<<1000, 128>>>(p1);

两个不同设备的内存之间的拷贝(在隐式 NULL 流中):

  • 直到之前向任一设备发出的所有命令都完成后才会启动,并且
  • 在复制到任一设备之后发出的任何命令(请参阅异步并发执行)可以开始之前运行完成。

与流的正常行为一致,两个设备的内存之间的异步拷贝可能与另一个流中的拷贝或内核重叠。

请注意,如果通过 cudaDeviceEnablePeerAccess() 在两个设备之间启用Peer-to-Peer访问,如Peer-to-Peer内存访问中所述,这两个设备之间的Peer-to-Peer内存复制不再需要通过主机, 因此速度更快。

统一虚拟地址空间

当应用程序作为 64 位进程运行时,单个地址空间用于主机和计算能力 2.0 及更高版本的所有设备。通过 CUDA API 调用进行的所有主机内存分配以及受支持设备上的所有设备内存分配都在此虚拟地址范围内。作为结果:

  • 通过 CUDA 分配的主机或使用统一地址空间的任何设备上的任何内存的位置都可以使用 cudaPointerGetAttributes() 从指针的值中确定。
  • 当复制到或从任何使用统一地址空间的设备的内存中复制时,可以将 cudaMemcpy*()cudaMemcpyKind 参数设置为 cudaMemcpyDefault 以根据指针确定位置。只要当前设备使用统一寻址,这也适用于未通过 CUDA 分配的主机指针。
  • 通过 cudaHostAlloc() 进行的分配可以在使用统一地址空间的所有设备之间自动移植(请参阅可移植内存),并且 cudaHostAlloc() 返回的指针可以直接在这些设备上运行的内核中使用(即,没有需要通过 cudaHostGetDevicePointer() 获取设备指针,如映射内存中所述。

应用程序可以通过检查 UnifiedAddressing 设备属性(请参阅设备枚举)是否等于 1 来查询统一地址空间是否用于特定设备。

3.2.9 进程间通信

由主机线程创建的任何设备内存指针或事件句柄都可以被同一进程中的任何其他线程直接引用。然而,它在这个进程之外是无效的,因此不能被属于不同进程的线程直接引用。

要跨进程共享设备内存指针和事件,应用程序必须使用进程间通信 API,参考手册中有详细描述。 IPC API 仅支持 Linux 上的 64 位进程以及计算能力 2.0 及更高版本的设备。请注意,cudaMallocManaged 分配不支持 IPC API。

使用此 API,应用程序可以使用 cudaIpcGetMemHandle() 获取给定设备内存指针的 IPC 句柄,使用标准 IPC 机制(例如,进程间共享内存或文件)将其传递给另一个进程,并使用 cudaIpcOpenMemHandle() 检索设备来自 IPC 句柄的指针,该指针是其他进程中的有效指针。可以使用类似的入口点共享事件句柄。

请注意,出于性能原因,由 cudaMalloc() 进行的分配可能会从更大的内存块中进行子分配。在这种情况下,CUDA IPC API 将共享整个底层内存块,这可能导致其他子分配被共享,这可能导致进程之间的信息泄露。为了防止这种行为,建议仅共享具有 2MiB 对齐大小的分配。

使用 IPC API 的一个示例是单个主进程生成一批输入数据,使数据可用于多个辅助进程,而无需重新生成或复制。

使用 CUDA IPC 相互通信的应用程序应使用相同的 CUDA 驱动程序和运行时进行编译、链接和运行。

注意:自 CUDA 11.5 起,L4T 和具有计算能力 7.x 及更高版本的嵌入式 Linux Tegra 设备仅支持事件共享 IPC API。 Tegra 平台仍然不支持内存共享 IPC API。

3.2.10 错误检查

所有运行时函数都返回错误代码,但对于异步函数(请参阅异步并发执行),此错误代码不可能报告任何可能发生在设备上的异步错误,因为函数在设备完成任务之前返回;错误代码仅报告执行任务之前主机上发生的错误,通常与参数验证有关;如果发生异步错误,会被后续一些不相关的运行时函数调用报告。

因此,在某些异步函数调用之后检查异步错误的唯一方法是在调用之后通过调用 cudaDeviceSynchronize()(或使用异步并发执行中描述的任何其他同步机制)并检查 cudaDeviceSynchronize()

运行时为每个初始化为 cudaSuccess 的主机线程维护一个错误变量,并在每次发生错误时被错误代码覆盖(无论是参数验证错误还是异步错误)。 cudaPeekAtLastError() 返回此变量。 cudaGetLastError() 返回此变量并将其重置为 cudaSuccess

内核启动不返回任何错误代码,因此必须在内核启动后立即调用 cudaPeekAtLastError()cudaGetLastError() 以检索任何启动前错误。为了确保 cudaPeekAtLastError()cudaGetLastError() 返回的任何错误不是源自内核启动之前的调用,必须确保在内核启动之前将运行时错误变量设置为 cudaSuccess,例如,通过调用cudaGetLastError() 在内核启动之前。内核启动是异步的,因此要检查异步错误,应用程序必须在内核启动和调用 cudaPeekAtLastError()cudaGetLastError() 之间进行同步。

请注意,cudaStreamQuery()cudaEventQuery() 可能返回的 cudaErrorNotReady 不被视为错误,因此 cudaPeekAtLastError()cudaGetLastError() 不会报告。

3.2.11 调用栈

在计算能力 2.x 及更高版本的设备上,调用堆栈的大小可以使用 cudaDeviceGetLimit() 查询并使用 cudaDeviceSetLimit() 设置。

当调用堆栈溢出时,如果应用程序通过 CUDA 调试器(cuda-gdb、Nsight)运行,内核调用将失败并出现堆栈溢出错误,否则会出现未指定的启动错误。

3.2.12 纹理内存和表面内存(surface memory)

CUDA 支持 GPU 用于图形访问纹理和表面内存的纹理硬件子集。 如设备内存访问中所述,从纹理或表面内存而不是全局内存读取数据可以带来多项性能优势。

有两种不同的 API 可以访问纹理和表面内存:

3.2.12.1纹理内存

使用纹理函数中描述的设备函数从内核读取纹理内存。 调用这些函数之一读取纹理的过程称为纹理提取。 每个纹理提取指定一个参数,称为纹理对象 API 的纹理对象或纹理引用 API 的纹理引用。

纹理对象或纹理引用指定:

  • 纹理,即提取的纹理内存。 纹理对象在运行时创建,并在创建纹理对象时指定纹理,如纹理对象 API 中所述。 纹理引用是在编译时创建的,纹理是在运行时通过 [[[DEPRECATED]] Texture Reference API ](https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#texture-reference-api)中描述的运行时函数将纹理引用绑定到纹理来指定的; 几个不同的纹理引用可能绑定到相同的纹理或内存中重叠的纹理。 纹理可以是线性内存的任何区域或 CUDA 数组(在 CUDA 数组中描述)。

  • 它的维数指定纹理是使用一个纹理坐标的一维数组、使用两个纹理坐标的二维数组还是使用三个纹理坐标的三维数组。数组的元素称为texels,是纹理元素的缩写。纹理的宽度、高度和深度是指数组在每个维度上的大小。表 15 列出了取决于设备计算能力的最大纹理宽度、高度和深度。

  • texels的类型,仅限于基本整数和单精度浮点类型以及从基本向量类型派生的内置向量类型中定义的任何 1、2 和 4 分量向量类型整数和单精度浮点类型。

  • 读取模式,等同于 cudaReadModeNormalizedFloatcudaReadModeElementType。如果是 cudaReadModeNormalizedFloat 并且 texel 的类型是 16 位或 8 位整数类型,则纹理获取返回的值实际上是作为浮点类型返回的,并且整数类型的全范围映射到 [0.0 , 1.0] 表示无符号整数类型,[-1.0, 1.0] 表示有符号整数类型;例如,值为 0xff 的无符号 8 位纹理元素读取为 1。如果是 cudaReadModeElementType,则不执行转换。

  • 纹理坐标是否标准化。默认情况下,使用 [0, N-1] 范围内的浮点坐标(通过 Texture Functions 的函数)引用纹理,其中 N 是与坐标对应的维度中纹理的大小。例如,大小为 64x32 的纹理将分别使用 x 和 y 维度的 [0, 63] 和 [0, 31] 范围内的坐标进行引用。标准化纹理坐标导致坐标被指定在[0.0,1.0-1/N]范围内,而不是[0,N-1],所以相同的64x32纹理将在x和y维度的[0,1 -1/N]范围内被标准化坐标定位。如果纹理坐标独立于纹理大小,则归一化纹理坐标自然适合某些应用程序的要求。

  • 寻址方式。使用超出范围的坐标调用 B.8 节的设备函数是有效的。寻址模式定义了在这种情况下会发生什么。默认寻址模式是将坐标限制在有效范围内:[0, N) 用于非归一化坐标,[0.0, 1.0) 用于归一化坐标。如果指定了边框模式,则纹理坐标超出范围的纹理提取将返回零。对于归一化坐标,还可以使用环绕模式和镜像模式。使用环绕模式时,每个坐标 x 都转换为 frac(x)=x - floor(x),其中 floor(x) 是不大于 x 的最大整数。使用镜像模式时,如果 floor(x) 为偶数,则每个坐标 x 转换为 frac(x),如果 floor(x) 为奇数,则转换为 1-frac(x)。寻址模式被指定为一个大小为 3 的数组,其第一个、第二个和第三个元素分别指定第一个、第二个和第三个纹理坐标的寻址模式;寻址模式为cudaAddressModeBordercudaAddressModeClampcudaAddressModeWrapcudaAddressModeMirrorcudaAddressModeWrapcudaAddressModeMirror 仅支持标准化纹理坐标

  • 过滤模式指定如何根据输入纹理坐标计算获取纹理时返回的值。线性纹理过滤只能对配置为返回浮点数据的纹理进行。它在相邻纹素之间执行低精度插值。启用后,将读取纹理提取位置周围的texels,并根据纹理坐标落在texels之间的位置对纹理提取的返回值进行插值。对一维纹理进行简单线性插值,对二维纹理进行双线性插值,对三维纹理进行三线性插值。 Texture Fetching 提供了有关纹理获取的更多细节。过滤模式等于 cudaFilterModePointcudaFilterModeLinear。如果是cudaFilterModePoint,则返回值是纹理坐标最接近输入纹理坐标的texel。如果是cudaFilterModeLinear,则返回值是纹理坐标最接近的两个(一维纹理)、四个(二维纹理)或八个(三维纹理)texel的线性插值输入纹理坐标。 cudaFilterModeLinear 仅对浮点类型的返回值有效。

纹理对象 API

[[[DEPRECATED]] Texture Reference API](https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#texture-reference-api)

16位浮点纹理解释了如何处理16位浮点纹理。

纹理也可以分层,如分层纹理中所述。

立方体贴图纹理立方体贴图分层纹理描述了一种特殊类型的纹理,立方体贴图纹理。

Texture Gather 描述了一种特殊的纹理获取,纹理收集。

3.2.12.1.1 纹理对象API

使用 cudaCreateTextureObject() 从指定纹理的 struct cudaResourceDesc 类型的资源描述和定义如下的纹理描述创建纹理对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct cudaTextureDesc
{
enum cudaTextureAddressMode addressMode[3];
enum cudaTextureFilterMode filterMode;
enum cudaTextureReadMode readMode;
int sRGB;
int normalizedCoords;
unsigned int maxAnisotropy;
enum cudaTextureFilterMode mipmapFilterMode;
float mipmapLevelBias;
float minMipmapLevelClamp;
float maxMipmapLevelClamp;
};
  • addressMode 指定寻址模式;
  • filterMode 指定过滤模式;
  • readMode 指定读取模式;
  • normalizedCoords 指定纹理坐标是否被归一化;
  • sRGB、maxAnisotropy、mipmapFilterMode、mipmapLevelBias、minMipmapLevelClampmaxMipmapLevelClamp 请参阅的参考手册。

以下代码示例将一些简单的转换内核应用于纹理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
// Simple transformation kernel
__global__ void transformKernel(float* output,
cudaTextureObject_t texObj,
int width, int height,
float theta)
{
// Calculate normalized texture coordinates
unsigned int x = blockIdx.x * blockDim.x + threadIdx.x;
unsigned int y = blockIdx.y * blockDim.y + threadIdx.y;

float u = x / (float)width;
float v = y / (float)height;

// Transform coordinates
u -= 0.5f;
v -= 0.5f;
float tu = u * cosf(theta) - v * sinf(theta) + 0.5f;
float tv = v * cosf(theta) + u * sinf(theta) + 0.5f;

// Read from texture and write to global memory
output[y * width + x] = tex2D<float>(texObj, tu, tv);
}
// Host code
int main()
{
const int height = 1024;
const int width = 1024;
float angle = 0.5;

// Allocate and set some host data
float *h_data = (float *)std::malloc(sizeof(float) * width * height);
for (int i = 0; i < height * width; ++i)
h_data[i] = i;

// Allocate CUDA array in device memory
cudaChannelFormatDesc channelDesc =
cudaCreateChannelDesc(32, 0, 0, 0, cudaChannelFormatKindFloat);
cudaArray_t cuArray;
cudaMallocArray(&cuArray, &channelDesc, width, height);

// Set pitch of the source (the width in memory in bytes of the 2D array pointed
// to by src, including padding), we dont have any padding
const size_t spitch = width * sizeof(float);
// Copy data located at address h_data in host memory to device memory
cudaMemcpy2DToArray(cuArray, 0, 0, h_data, spitch, width * sizeof(float),
height, cudaMemcpyHostToDevice);

// Specify texture
struct cudaResourceDesc resDesc;
memset(&resDesc, 0, sizeof(resDesc));
resDesc.resType = cudaResourceTypeArray;
resDesc.res.array.array = cuArray;

// Specify texture object parameters
struct cudaTextureDesc texDesc;
memset(&texDesc, 0, sizeof(texDesc));
texDesc.addressMode[0] = cudaAddressModeWrap;
texDesc.addressMode[1] = cudaAddressModeWrap;
texDesc.filterMode = cudaFilterModeLinear;
texDesc.readMode = cudaReadModeElementType;
texDesc.normalizedCoords = 1;

// Create texture object
cudaTextureObject_t texObj = 0;
cudaCreateTextureObject(&texObj, &resDesc, &texDesc, NULL);

// Allocate result of transformation in device memory
float *output;
cudaMalloc(&output, width * height * sizeof(float));

// Invoke kernel
dim3 threadsperBlock(16, 16);
dim3 numBlocks((width + threadsperBlock.x - 1) / threadsperBlock.x,
(height + threadsperBlock.y - 1) / threadsperBlock.y);
transformKernel<<<numBlocks, threadsperBlock>>>(output, texObj, width, height,
angle);
// Copy data from device back to host
cudaMemcpy(h_data, output, width * height * sizeof(float),
cudaMemcpyDeviceToHost);

// Destroy texture object
cudaDestroyTextureObject(texObj);

// Free device memory
cudaFreeArray(cuArray);
cudaFree(output);

// Free host memory
free(h_data);

return 0;
}
3.2.12.1.2 [[已弃用]] 纹理引用 API

纹理参考 API 已弃用。

纹理引用的某些属性是不可变的,必须在编译时知道; 它们是在声明纹理引用时指定的。 纹理引用在文件范围内声明为纹理类型的变量:

1
texture<DataType, Type, ReadMode> texRef;
  • DataType 指定纹素的类型;
  • Type 指定纹理参考的类型,等于 cudaTextureType1DcudaTextureType2DcudaTextureType3D,分别用于一维、二维或三维纹理,或 cudaTextureType1DLayeredcudaTextureType2DLayered 用于一维或二维 分别分层纹理; Type 是一个可选参数,默认为 cudaTextureType1D
  • ReadMode 指定读取模式; 它是一个可选参数,默认为 cudaReadModeElementType

纹理引用只能声明为静态全局变量,不能作为参数传递给函数。

纹理引用的其他属性是可变的,并且可以在运行时通过主机运行时进行更改。 如参考手册中所述,运行时 API 具有低级 C 样式接口和高级 C++ 样式接口。 纹理类型在高级 API 中定义为公开派生自低级 API 中定义的 textureReference 类型的结构,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
struct textureReference {
int normalized;
enum cudaTextureFilterMode filterMode;
enum cudaTextureAddressMode addressMode[3];
struct cudaChannelFormatDesc channelDesc;
int sRGB;
unsigned int maxAnisotropy;
enum cudaTextureFilterMode mipmapFilterMode;
float mipmapLevelBias;
float minMipmapLevelClamp;
float maxMipmapLevelClamp;
}
  • normalized 指定纹理坐标是否被归一化;
  • filterMode 指定过滤模式;
  • addressMode 指定寻址模式;
  • channelDesc 描述了texel的格式; 它必须匹配纹理引用声明的 DataType 参数; channelDesc 属于以下类型:
1
2
3
4
5
6
7
8
9
struct cudaChannelFormatDesc {
int x, y, z, w;
enum cudaChannelFormatKind f;
};
其中 x、y、z 和 w 等于返回值的每个分量的位数,f 为:

*cudaChannelFormatKindSigned 如果这些组件是有符号整数类型,
*cudaChannelFormatKindUnsigned 如果它们是无符号整数类型,
*cudaChannelFormatKindFloat 如果它们是浮点类型。
  • sRGB、maxAnisotropy、mipmapFilterMode、mipmapLevelBias、minMipmapLevelClamp 和 maxMipmapLevelClamp 请参阅参考手册

normalizedaddressModefilterMode 可以直接在主机代码中修改。

在纹理内存中读取之前内核可以使用纹理引用,纹理引用必须绑定到纹理,使用 cudaBindTexture()cudaBindTexture2D() 用于线性内存,或 cudaBindTextureToArray() 用于 CUDA 数组。 cudaUnbindTexture() 用于取消绑定纹理引用。 一旦纹理引用被解除绑定,它可以安全地重新绑定到另一个数组,即使使用之前绑定的纹理的内核还没有完成。 建议使用 cudaMallocPitch() 在线性内存中分配二维纹理,并使用 cudaMallocPitch() 返回的间距作为 cudaBindTexture2D() 的输入参数。

以下代码示例将 2D 纹理引用绑定到 devPtr 指向的线性内存:

  • 使用低层次API:
1
2
3
4
5
6
7
8
9
texture<float, cudaTextureType2D,
cudaReadModeElementType> texRef;
textureReference* texRefPtr;
cudaGetTextureReference(&texRefPtr, &texRef);
cudaChannelFormatDesc channelDesc =
cudaCreateChannelDesc<float>();
size_t offset;
cudaBindTexture2D(&offset, texRefPtr, devPtr, &channelDesc,
width, height, pitch);
  • 使用高层次API:
1
2
3
4
5
6
7
texture<float, cudaTextureType2D,
cudaReadModeElementType> texRef;
cudaChannelFormatDesc channelDesc =
cudaCreateChannelDesc<float>();
size_t offset;
cudaBindTexture2D(&offset, texRef, devPtr, channelDesc,
width, height, pitch);

以下代码示例将 2D 纹理引用绑定到 CUDA 数组 cuArray

  • 使用低层次API:
1
2
3
4
5
6
7
texture<float, cudaTextureType2D,
cudaReadModeElementType> texRef;
textureReference* texRefPtr;
cudaGetTextureReference(&texRefPtr, &texRef);
cudaChannelFormatDesc channelDesc;
cudaGetChannelDesc(&channelDesc, cuArray);
cudaBindTextureToArray(texRef, cuArray, &channelDesc);
  • 使用高层次API:
1
2
3
texture<float, cudaTextureType2D,
cudaReadModeElementType> texRef;
cudaBindTextureToArray(texRef, cuArray);

将纹理绑定到纹理引用时指定的格式必须与声明纹理引用时指定的参数匹配; 否则,纹理提取的结果是未定义的。

如表 15 中指定的,可以绑定到内核的纹理数量是有限的。

以下代码示例将一些简单的转换内核应用于纹理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// 2D float texture
texture<float, cudaTextureType2D, cudaReadModeElementType> texRef;

// Simple transformation kernel
__global__ void transformKernel(float* output,
int width, int height,
float theta)
{
// Calculate normalized texture coordinates
unsigned int x = blockIdx.x * blockDim.x + threadIdx.x;
unsigned int y = blockIdx.y * blockDim.y + threadIdx.y;

float u = x / (float)width;
float v = y / (float)height;

// Transform coordinates
u -= 0.5f;
v -= 0.5f;
float tu = u * cosf(theta) - v * sinf(theta) + 0.5f;
float tv = v * cosf(theta) + u * sinf(theta) + 0.5f;


// Read from texture and write to global memory
output[y * width + x] = tex2D(texRef, tu, tv);
}

// Host code
int main()
{
// Allocate CUDA array in device memory
cudaChannelFormatDesc channelDesc =
cudaCreateChannelDesc(32, 0, 0, 0,
cudaChannelFormatKindFloat);
cudaArray* cuArray;
cudaMallocArray(&cuArray, &channelDesc, width, height);

// Copy to device memory some data located at address h_data
// in host memory
cudaMemcpyToArray(cuArray, 0, 0, h_data, size,
cudaMemcpyHostToDevice);

// Set texture reference parameters
texRef.addressMode[0] = cudaAddressModeWrap;
texRef.addressMode[1] = cudaAddressModeWrap;
texRef.filterMode = cudaFilterModeLinear;
texRef.normalized = true;

// Bind the array to the texture reference
cudaBindTextureToArray(texRef, cuArray, channelDesc);

// Allocate result of transformation in device memory
float* output;
cudaMalloc(&output, width * height * sizeof(float));

// Invoke kernel
dim3 dimBlock(16, 16);
dim3 dimGrid((width + dimBlock.x - 1) / dimBlock.x,
(height + dimBlock.y - 1) / dimBlock.y);
transformKernel<<<dimGrid, dimBlock>>>(output, width, height,
angle);

// Free device memory
cudaFreeArray(cuArray);
cudaFree(output);

return 0;
}
3.2.12.1.3 16位浮点类型纹理

CUDA 数组支持的 16 位浮点或 half 格式与 IEEE 754-2008 binary2 格式相同。

CUDA C++ 不支持匹配的数据类型,但提供了通过 unsigned short 类型与 32 位浮点格式相互转换的内在函数:__float2half_rn(float)__half2float(unsigned short)。 这些功能仅在设备代码中受支持。 例如,主机代码的等效函数可以在 OpenEXR 库中找到。

在执行任何过滤之前,在纹理提取期间,16 位浮点组件被提升为 32 位浮点。

可以通过调用 cudaCreateChannelDescHalf*() 函数来创建 16 位浮点格式的通道描述。

3.2.12.1.4 分层纹理

一维或二维分层纹理(在 Direct3D 中也称为纹理数组,在 OpenGL 中也称为数组纹理)是由一系列层组成的纹理,这些层都是具有相同维度、大小和数据类型的常规纹理.

使用整数索引和浮点纹理坐标来寻址一维分层纹理;索引表示序列中的层,坐标表示该层中的texel。使用整数索引和两个浮点纹理坐标来寻址二维分层纹理;索引表示序列中的层,坐标表示该层中的texel

分层纹理只能是一个 CUDA 数组,方法是使用 cudaArrayLayered 标志调用的cudaMalloc3DArray()(一维分层纹理的高度为零)。

使用 tex1DLayered()、tex1DLayered()、tex2DLayered() 和 tex2DLayered() 中描述的设备函数获取分层纹理。纹理过滤(请参阅纹理提取)仅在层内完成,而不是跨层。

分层纹理仅在计算能力 2.0 及更高版本的设备上受支持。

3.2.12.1.5 立方体纹理(Cubemap Textures)

Cubemap Textures是一种特殊类型的二维分层纹理,它有六层代表立方体的面:

  • 层的宽度等于它的高度。
  • 立方体贴图使用三个纹理坐标 x、y 和 z 进行寻址,这些坐标被解释为从立方体中心发出并指向立方体的一个面和对应于该面的层内的texel的方向矢量。 更具体地说,面部是由具有最大量级 m 的坐标选择的,相应的层使用坐标 (s/m+1)/2(t/m+1)/2 来寻址,其中 s 和 t 在表中定义 .
face m s t
|x| > |y| and |x| > |z| x > 0 0 x -z -y
|x| > |y| and |x| > |z| x < 0 1 -x z -y
|y| > |x| and |y| > |z| y > 0 2 y x z
|y| > |x| and |y| > |z| y < 0 3 -y x -z
|z| > |x| and |z| > |y| z > 0 4 z x -y
|z| > |x| and |z| > |y| z < 0 5 -z -x -y

通过使用 cudaArrayCubemap 标志调用 cudaMalloc3DArray(),立方体贴图纹理只能是 CUDA 数组。

立方体贴图纹理是使用 texCubemap()texCubemap() 中描述的设备函数获取的。

Cubemap 纹理仅在计算能力 2.0 及更高版本的设备上受支持。

3.2.12.1.6 分层的立方体纹理内存(Cubemap Layered Textures)

立方体贴图分层纹理是一种分层纹理,其层是相同维度的立方体贴图。

使用整数索引和三个浮点纹理坐标来处理立方体贴图分层纹理; 索引表示序列中的立方体贴图,坐标表示该立方体贴图中的纹理元素。

通过使用 cudaArrayLayeredcudaArrayCubemap 标志调用的 cudaMalloc3DArray(),立方体贴图分层纹理只能是 CUDA 数组。

立方体贴图分层纹理是使用 texCubemapLayered()texCubemapLayered() 中描述的设备函数获取的。 纹理过滤(请参阅纹理提取)仅在层内完成,而不是跨层。

Cubemap 分层纹理仅在计算能力 2.0 及更高版本的设备上受支持。

3.2.12.1.7 纹理收集(Texture Gather)

纹理聚集是一种特殊的纹理提取,仅适用于二维纹理。它由 tex2Dgather() 函数执行,该函数具有与 tex2D() 相同的参数,外加一个等于 0、1、2 或 3 的附加 comp 参数(参见 tex2Dgather()tex2Dgather())。它返回四个 32 位数字,对应于在常规纹理提取期间用于双线性过滤的四个texel中每一个的分量 comp 的值。例如,如果这些纹理像素的值是 (253, 20, 31, 255), (250, 25, 29, 254), (249, 16, 37, 253), (251, 22, 30, 250),并且comp 为 2,tex2Dgather() 返回 (31, 29, 37, 30)。

请注意,纹理坐标仅使用 8 位小数精度计算。因此,对于 tex2D() 将使用 1.0 作为其权重之一(α 或 β,请参阅线性过滤)的情况,tex2Dgather() 可能会返回意外结果。例如,x 纹理坐标为 2.49805:xB=x-0.5=1.99805,但是 xB 的小数部分以 8 位定点格式存储。由于 0.99805 比 255.f/256.f 更接近 256.f/256.f,因此 xB 的值为 2。因此,在这种情况下,tex2Dgather() 将返回 x 中的索引 2 和 3,而不是索引1 和 2。

纹理收集仅支持使用 cudaArrayTextureGather 标志创建的 CUDA 数组,其宽度和高度小于表 15 中为纹理收集指定的最大值,该最大值小于常规纹理提取。

纹理收集仅在计算能力 2.0 及更高版本的设备上受支持。

3.2.12.2 表面内存(Surface Memory)

对于计算能力 2.0 及更高版本的设备,可以使用 Surface Functions 中描述的函数通过表面对象或表面引用来读取和写入使用 cudaArraySurfaceLoadStore 标志创建的 CUDA 数组(在 Cubemap Surfaces 中描述)。

表 15 列出了最大表面宽度、高度和深度,具体取决于设备的计算能力。

3.2.12.2.1 表面内存对象API

使用 cudaCreateSurfaceObject()struct cudaResourceDesc 类型的资源描述中创建表面内存对象。

以下代码示例将一些简单的转换内核应用于纹理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
// Simple copy kernel
__global__ void copyKernel(cudaSurfaceObject_t inputSurfObj,
cudaSurfaceObject_t outputSurfObj,
int width, int height)
{
// Calculate surface coordinates
unsigned int x = blockIdx.x * blockDim.x + threadIdx.x;
unsigned int y = blockIdx.y * blockDim.y + threadIdx.y;
if (x < width && y < height) {
uchar4 data;
// Read from input surface
surf2Dread(&data, inputSurfObj, x * 4, y);
// Write to output surface
surf2Dwrite(data, outputSurfObj, x * 4, y);
}
}

// Host code
int main()
{
const int height = 1024;
const int width = 1024;

// Allocate and set some host data
unsigned char *h_data =
(unsigned char *)std::malloc(sizeof(unsigned char) * width * height * 4);
for (int i = 0; i < height * width * 4; ++i)
h_data[i] = i;

// Allocate CUDA arrays in device memory
cudaChannelFormatDesc channelDesc =
cudaCreateChannelDesc(8, 8, 8, 8, cudaChannelFormatKindUnsigned);
cudaArray_t cuInputArray;
cudaMallocArray(&cuInputArray, &channelDesc, width, height,
cudaArraySurfaceLoadStore);
cudaArray_t cuOutputArray;
cudaMallocArray(&cuOutputArray, &channelDesc, width, height,
cudaArraySurfaceLoadStore);

// Set pitch of the source (the width in memory in bytes of the 2D array
// pointed to by src, including padding), we dont have any padding
const size_t spitch = 4 * width * sizeof(unsigned char);
// Copy data located at address h_data in host memory to device memory
cudaMemcpy2DToArray(cuInputArray, 0, 0, h_data, spitch,
4 * width * sizeof(unsigned char), height,
cudaMemcpyHostToDevice);

// Specify surface
struct cudaResourceDesc resDesc;
memset(&resDesc, 0, sizeof(resDesc));
resDesc.resType = cudaResourceTypeArray;

// Create the surface objects
resDesc.res.array.array = cuInputArray;
cudaSurfaceObject_t inputSurfObj = 0;
cudaCreateSurfaceObject(&inputSurfObj, &resDesc);
resDesc.res.array.array = cuOutputArray;
cudaSurfaceObject_t outputSurfObj = 0;
cudaCreateSurfaceObject(&outputSurfObj, &resDesc);

// Invoke kernel
dim3 threadsperBlock(16, 16);
dim3 numBlocks((width + threadsperBlock.x - 1) / threadsperBlock.x,
(height + threadsperBlock.y - 1) / threadsperBlock.y);
copyKernel<<<numBlocks, threadsperBlock>>>(inputSurfObj, outputSurfObj, width,
height);

// Copy data from device back to host
cudaMemcpy2DFromArray(h_data, spitch, cuOutputArray, 0, 0,
4 * width * sizeof(unsigned char), height,
cudaMemcpyDeviceToHost);

// Destroy surface objects
cudaDestroySurfaceObject(inputSurfObj);
cudaDestroySurfaceObject(outputSurfObj);

// Free device memory
cudaFreeArray(cuInputArray);
cudaFreeArray(cuOutputArray);

// Free host memory
free(h_data);

return 0;
}
3.2.12.2.3 立方体表面内存

使用 surfCubemapread()surfCubemapwrite()surfCubemapreadsurfCubemapwrite)作为二维分层表面来访问立方体贴图表面内存,即,使用表示面的整数索引和寻址对应于该面的层内的纹素的两个浮点纹理坐标 . 面的顺序如表 2所示。

3.2.12.2.4 立方体分层表面内存

使用 surfCubemapLayeredread()surfCubemapLayeredwrite()surfCubemapLayeredread()surfCubemapLayeredwrite())作为二维分层表面来访问立方体贴图分层表面,即,使用表示立方体贴图之一的面和两个浮点纹理的整数索引 坐标寻址对应于该面的层内的纹理元素。 面的顺序如表 2 所示,因此例如 index ((2 * 6) + 3) 会访问第三个立方体贴图的第四个面。

3.2.12.3 CUDA Array

CUDA Array是针对纹理获取优化的不透明内存布局。 它们是一维、二维或三维,由元素组成,每个元素有 1、2 或 4 个分量,可以是有符号或无符号 8 位、16 位或 32 位整数、16 位浮点数、 或 32 位浮点数。 CUDA Array只能由内核通过纹理内存中描述的纹理获取或表面内存中描述的表面读取和写入来访问。

3.2.12.4 读写一致性

纹理和表面内存被缓存(请参阅设备内存访问),并且在同一个内核调用中,缓存在全局内存写入和表面内存写入方面并不保持一致,因此任何纹理获取或表面内存读取到一个地址 ,在同一个内核调用中通过全局写入或表面写入写入会返回未定义的数据。 换句话说,线程可以安全地读取某个纹理或表面内存位置,前提是该内存位置已被先前的内核调用或内存拷贝更新,但如果它先前已由同一个线程或来自同一线程的另一个线程更新,则不能内核调用。

3.2.13图形一致性

来自 OpenGL 和 Direct3D 的一些资源可能会映射到 CUDA 的地址空间中,以使 CUDA 能够读取 OpenGL 或 Direct3D 写入的数据,或者使 CUDA 能够写入数据以供 OpenGL 或 Direct3D 使用。

资源必须先注册到 CUDA,然后才能使用 OpenGL 互操作Direct3D 互操作中提到的函数进行映射。这些函数返回一个指向 struct cudaGraphicsResource 类型的 CUDA 图形资源的指针。注册资源可能会产生高开销,因此通常每个资源只调用一次。使用 cudaGraphicsUnregisterResource() 取消注册 CUDA 图形资源。每个打算使用该资源的 CUDA 上下文都需要单独注册它。

将资源注册到 CUDA 后,可以根据需要使用 cudaGraphicsMapResources()cudaGraphicsUnmapResources() 多次映射和取消映射。可以调用 cudaGraphicsResourceSetMapFlags() 来指定 CUDA 驱动程序可以用来优化资源管理的使用提示(只写、只读)。

内核可以使用 cudaGraphicsResourceGetMappedPointer() 返回的设备内存地址来读取或写入映射的资源,对于缓冲区,使用 cudaGraphicsSubResourceGetMappedArray() 的 CUDA 数组。

在映射时通过 OpenGL、Direct3D 或其他 CUDA 上下文访问资源会产生未定义的结果。 OpenGL 互操作Direct3D 互操作为每个图形 API 和一些代码示例提供了细节。 SLI 互操作给出了系统何时处于 SLI 模式的细节。

3.2.13.1. OpenGL 一致性

可以映射到 CUDA 地址空间的 OpenGL 资源是 OpenGL 缓冲区、纹理和渲染缓冲区对象。

使用 cudaGraphicsGLRegisterBuffer() 注册缓冲区对象。在 CUDA 中,它显示为设备指针,因此可以由内核或通过 cudaMemcpy() 调用读取和写入。

使用 cudaGraphicsGLRegisterImage() 注册纹理或渲染缓冲区对象。在 CUDA 中,它显示为 CUDA 数组。内核可以通过将数组绑定到纹理或表面引用来读取数组。如果资源已使用 cudaGraphicsRegisterFlagsSurfaceLoadStore 标志注册,他们还可以通过表面写入函数对其进行写入。该数组也可以通过 cudaMemcpy2D() 调用来读取和写入。 cudaGraphicsGLRegisterImage() 支持具有 1、2 或 4 个分量和内部浮点类型(例如,GL_RGBA_FLOAT32)、标准化整数(例如,GL_RGBA8、GL_INTENSITY16)和非标准化整数(例如,GL_RGBA8UI)的所有纹理格式(请注意,由于非标准化整数格式需要 OpenGL 3.0,它们只能由着色器编写,而不是固定函数管道)。

正在共享资源的 OpenGL 上下文对于进行任何 OpenGL 互操作性 API 调用的主机线程来说必须是最新的。

请注意:当 OpenGL 纹理设置为无绑定时(例如,通过使用 glGetTextureHandle*/glGetImageHandle* API 请求图像或纹理句柄),它不能在 CUDA 中注册。应用程序需要在请求图像或纹理句柄之前注册纹理以进行互操作。

以下代码示例使用内核动态修改存储在顶点缓冲区对象中的 2D width x height 网格:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
GLuint positionsVBO;
struct cudaGraphicsResource* positionsVBO_CUDA;

int main()
{
// Initialize OpenGL and GLUT for device 0
// and make the OpenGL context current
...
glutDisplayFunc(display);

// Explicitly set device 0
cudaSetDevice(0);

// Create buffer object and register it with CUDA
glGenBuffers(1, &positionsVBO);
glBindBuffer(GL_ARRAY_BUFFER, positionsVBO);
unsigned int size = width * height * 4 * sizeof(float);
glBufferData(GL_ARRAY_BUFFER, size, 0, GL_DYNAMIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0);
cudaGraphicsGLRegisterBuffer(&positionsVBO_CUDA,
positionsVBO,
cudaGraphicsMapFlagsWriteDiscard);

// Launch rendering loop
glutMainLoop();

...
}

void display()
{
// Map buffer object for writing from CUDA
float4* positions;
cudaGraphicsMapResources(1, &positionsVBO_CUDA, 0);
size_t num_bytes;
cudaGraphicsResourceGetMappedPointer((void**)&positions,
&num_bytes,
positionsVBO_CUDA));

// Execute kernel
dim3 dimBlock(16, 16, 1);
dim3 dimGrid(width / dimBlock.x, height / dimBlock.y, 1);
createVertices<<<dimGrid, dimBlock>>>(positions, time,
width, height);

// Unmap buffer object
cudaGraphicsUnmapResources(1, &positionsVBO_CUDA, 0);

// Render from buffer object
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glBindBuffer(GL_ARRAY_BUFFER, positionsVBO);
glVertexPointer(4, GL_FLOAT, 0, 0);
glEnableClientState(GL_VERTEX_ARRAY);
glDrawArrays(GL_POINTS, 0, width * height);
glDisableClientState(GL_VERTEX_ARRAY);

// Swap buffers
glutSwapBuffers();
glutPostRedisplay();
}
void deleteVBO()
{
cudaGraphicsUnregisterResource(positionsVBO_CUDA);
glDeleteBuffers(1, &positionsVBO);
}

__global__ void createVertices(float4* positions, float time,
unsigned int width, unsigned int height)
{
unsigned int x = blockIdx.x * blockDim.x + threadIdx.x;
unsigned int y = blockIdx.y * blockDim.y + threadIdx.y;

// Calculate uv coordinates
float u = x / (float)width;
float v = y / (float)height;
u = u * 2.0f - 1.0f;
v = v * 2.0f - 1.0f;

// calculate simple sine wave pattern
float freq = 4.0f;
float w = sinf(u * freq + time)
* cosf(v * freq + time) * 0.5f;

// Write positions
positions[y * width + x] = make_float4(u, w, v, 1.0f);
}

在 Windows 和 Quadro GPU 上,cudaWGLGetDevice() 可用于检索与 wglEnumGpusNV() 返回的句柄关联的 CUDA 设备。 Quadro GPU 在多 GPU 配置中提供比 GeForce 和 Tesla GPU 更高性能的 OpenGL 互操作性,其中 OpenGL 渲染在 Quadro GPU 上执行,CUDA 计算在系统中的其他 GPU 上执行。

3.2.13.2. Direct3D 一致性

Direct3D 9Ex、Direct3D 10 和 Direct3D 11 支持 Direct3D 互操作性。

CUDA 上下文只能与满足以下条件的 Direct3D 设备互操作: Direct3D 9Ex 设备必须使用设置为 D3DDEVTYPE_HALDeviceType 和使用 D3DCREATE_HARDWARE_VERTEXPROCESSING 标志的 BehaviorFlags 创建; Direct3D 10 和 Direct3D 11 设备必须在 DriverType 设置为 D3D_DRIVER_TYPE_HARDWARE 的情况下创建。

可以映射到 CUDA 地址空间的 Direct3D 资源是 Direct3D 缓冲区、纹理和表面。 这些资源使用 cudaGraphicsD3D9RegisterResource()cudaGraphicsD3D10RegisterResource()cudaGraphicsD3D11RegisterResource() 注册。

以下代码示例使用内核动态修改存储在顶点缓冲区对象中的 2D width x height网格。

Direct3D 9 Version:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
IDirect3D9* D3D;
IDirect3DDevice9* device;
struct CUSTOMVERTEX {
FLOAT x, y, z;
DWORD color;
};
IDirect3DVertexBuffer9* positionsVB;
struct cudaGraphicsResource* positionsVB_CUDA;

int main()
{
int dev;
// Initialize Direct3D
D3D = Direct3DCreate9Ex(D3D_SDK_VERSION);

// Get a CUDA-enabled adapter
unsigned int adapter = 0;
for (; adapter < g_pD3D->GetAdapterCount(); adapter++) {
D3DADAPTER_IDENTIFIER9 adapterId;
g_pD3D->GetAdapterIdentifier(adapter, 0, &adapterId);
if (cudaD3D9GetDevice(&dev, adapterId.DeviceName)
== cudaSuccess)
break;
}

// Create device
...
D3D->CreateDeviceEx(adapter, D3DDEVTYPE_HAL, hWnd,
D3DCREATE_HARDWARE_VERTEXPROCESSING,
&params, NULL, &device);

// Use the same device
cudaSetDevice(dev);

// Create vertex buffer and register it with CUDA
unsigned int size = width * height * sizeof(CUSTOMVERTEX);
device->CreateVertexBuffer(size, 0, D3DFVF_CUSTOMVERTEX,
D3DPOOL_DEFAULT, &positionsVB, 0);
cudaGraphicsD3D9RegisterResource(&positionsVB_CUDA,
positionsVB,
cudaGraphicsRegisterFlagsNone);
cudaGraphicsResourceSetMapFlags(positionsVB_CUDA,
cudaGraphicsMapFlagsWriteDiscard);

// Launch rendering loop
while (...) {
...
Render();
...
}
...
}
void Render()
{
// Map vertex buffer for writing from CUDA
float4* positions;
cudaGraphicsMapResources(1, &positionsVB_CUDA, 0);
size_t num_bytes;
cudaGraphicsResourceGetMappedPointer((void**)&positions,
&num_bytes,
positionsVB_CUDA));

// Execute kernel
dim3 dimBlock(16, 16, 1);
dim3 dimGrid(width / dimBlock.x, height / dimBlock.y, 1);
createVertices<<<dimGrid, dimBlock>>>(positions, time,
width, height);

// Unmap vertex buffer
cudaGraphicsUnmapResources(1, &positionsVB_CUDA, 0);

// Draw and present
...
}

void releaseVB()
{
cudaGraphicsUnregisterResource(positionsVB_CUDA);
positionsVB->Release();
}

__global__ void createVertices(float4* positions, float time,
unsigned int width, unsigned int height)
{
unsigned int x = blockIdx.x * blockDim.x + threadIdx.x;
unsigned int y = blockIdx.y * blockDim.y + threadIdx.y;

// Calculate uv coordinates
float u = x / (float)width;
float v = y / (float)height;
u = u * 2.0f - 1.0f;
v = v * 2.0f - 1.0f;

// Calculate simple sine wave pattern
float freq = 4.0f;
float w = sinf(u * freq + time)
* cosf(v * freq + time) * 0.5f;

// Write positions
positions[y * width + x] =
make_float4(u, w, v, __int_as_float(0xff00ff00));
}

Direct3D 10 Version

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
ID3D10Device* device;
struct CUSTOMVERTEX {
FLOAT x, y, z;
DWORD color;
};
ID3D10Buffer* positionsVB;
struct cudaGraphicsResource* positionsVB_CUDA;

int main()
{
int dev;
// Get a CUDA-enabled adapter
IDXGIFactory* factory;
CreateDXGIFactory(__uuidof(IDXGIFactory), (void**)&factory);
IDXGIAdapter* adapter = 0;
for (unsigned int i = 0; !adapter; ++i) {
if (FAILED(factory->EnumAdapters(i, &adapter))
break;
if (cudaD3D10GetDevice(&dev, adapter) == cudaSuccess)
break;
adapter->Release();
}
factory->Release();

// Create swap chain and device
...
D3D10CreateDeviceAndSwapChain(adapter,
D3D10_DRIVER_TYPE_HARDWARE, 0,
D3D10_CREATE_DEVICE_DEBUG,
D3D10_SDK_VERSION,
&swapChainDesc, &swapChain,
&device);
adapter->Release();

// Use the same device
cudaSetDevice(dev);

// Create vertex buffer and register it with CUDA
unsigned int size = width * height * sizeof(CUSTOMVERTEX);
D3D10_BUFFER_DESC bufferDesc;
bufferDesc.Usage = D3D10_USAGE_DEFAULT;
bufferDesc.ByteWidth = size;
bufferDesc.BindFlags = D3D10_BIND_VERTEX_BUFFER;
bufferDesc.CPUAccessFlags = 0;
bufferDesc.MiscFlags = 0;
device->CreateBuffer(&bufferDesc, 0, &positionsVB);
cudaGraphicsD3D10RegisterResource(&positionsVB_CUDA,
positionsVB,
cudaGraphicsRegisterFlagsNone);
cudaGraphicsResourceSetMapFlags(positionsVB_CUDA,
cudaGraphicsMapFlagsWriteDiscard);

// Launch rendering loop
while (...) {
...
Render();
...
}
...
}
void Render()
{
// Map vertex buffer for writing from CUDA
float4* positions;
cudaGraphicsMapResources(1, &positionsVB_CUDA, 0);
size_t num_bytes;
cudaGraphicsResourceGetMappedPointer((void**)&positions,
&num_bytes,
positionsVB_CUDA));

// Execute kernel
dim3 dimBlock(16, 16, 1);
dim3 dimGrid(width / dimBlock.x, height / dimBlock.y, 1);
createVertices<<<dimGrid, dimBlock>>>(positions, time,
width, height);

// Unmap vertex buffer
cudaGraphicsUnmapResources(1, &positionsVB_CUDA, 0);

// Draw and present
...
}

void releaseVB()
{
cudaGraphicsUnregisterResource(positionsVB_CUDA);
positionsVB->Release();
}

__global__ void createVertices(float4* positions, float time,
unsigned int width, unsigned int height)
{
unsigned int x = blockIdx.x * blockDim.x + threadIdx.x;
unsigned int y = blockIdx.y * blockDim.y + threadIdx.y;

// Calculate uv coordinates
float u = x / (float)width;
float v = y / (float)height;
u = u * 2.0f - 1.0f;
v = v * 2.0f - 1.0f;

// Calculate simple sine wave pattern
float freq = 4.0f;
float w = sinf(u * freq + time)
* cosf(v * freq + time) * 0.5f;

// Write positions
positions[y * width + x] =
make_float4(u, w, v, __int_as_float(0xff00ff00));
}

Direct3D 11 Version

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
ID3D11Device* device;
struct CUSTOMVERTEX {
FLOAT x, y, z;
DWORD color;
};
ID3D11Buffer* positionsVB;
struct cudaGraphicsResource* positionsVB_CUDA;

int main()
{
int dev;
// Get a CUDA-enabled adapter
IDXGIFactory* factory;
CreateDXGIFactory(__uuidof(IDXGIFactory), (void**)&factory);
IDXGIAdapter* adapter = 0;
for (unsigned int i = 0; !adapter; ++i) {
if (FAILED(factory->EnumAdapters(i, &adapter))
break;
if (cudaD3D11GetDevice(&dev, adapter) == cudaSuccess)
break;
adapter->Release();
}
factory->Release();

// Create swap chain and device
...
sFnPtr_D3D11CreateDeviceAndSwapChain(adapter,
D3D11_DRIVER_TYPE_HARDWARE,
0,
D3D11_CREATE_DEVICE_DEBUG,
featureLevels, 3,
D3D11_SDK_VERSION,
&swapChainDesc, &swapChain,
&device,
&featureLevel,
&deviceContext);
adapter->Release();

// Use the same device
cudaSetDevice(dev);

// Create vertex buffer and register it with CUDA
unsigned int size = width * height * sizeof(CUSTOMVERTEX);
D3D11_BUFFER_DESC bufferDesc;
bufferDesc.Usage = D3D11_USAGE_DEFAULT;
bufferDesc.ByteWidth = size;
bufferDesc.BindFlags = D3D11_BIND_VERTEX_BUFFER;
bufferDesc.CPUAccessFlags = 0;
bufferDesc.MiscFlags = 0;
device->CreateBuffer(&bufferDesc, 0, &positionsVB);
cudaGraphicsD3D11RegisterResource(&positionsVB_CUDA,
positionsVB,
cudaGraphicsRegisterFlagsNone);
cudaGraphicsResourceSetMapFlags(positionsVB_CUDA,
cudaGraphicsMapFlagsWriteDiscard);

// Launch rendering loop
while (...) {
...
Render();
...
}
...
}
void Render()
{
// Map vertex buffer for writing from CUDA
float4* positions;
cudaGraphicsMapResources(1, &positionsVB_CUDA, 0);
size_t num_bytes;
cudaGraphicsResourceGetMappedPointer((void**)&positions,
&num_bytes,
positionsVB_CUDA));

// Execute kernel
dim3 dimBlock(16, 16, 1);
dim3 dimGrid(width / dimBlock.x, height / dimBlock.y, 1);
createVertices<<<dimGrid, dimBlock>>>(positions, time,
width, height);

// Unmap vertex buffer
cudaGraphicsUnmapResources(1, &positionsVB_CUDA, 0);

// Draw and present
...
}

void releaseVB()
{
cudaGraphicsUnregisterResource(positionsVB_CUDA);
positionsVB->Release();
}

__global__ void createVertices(float4* positions, float time,
unsigned int width, unsigned int height)
{
unsigned int x = blockIdx.x * blockDim.x + threadIdx.x;
unsigned int y = blockIdx.y * blockDim.y + threadIdx.y;

// Calculate uv coordinates
float u = x / (float)width;
float v = y / (float)height;
u = u * 2.0f - 1.0f;
v = v * 2.0f - 1.0f;

// Calculate simple sine wave pattern
float freq = 4.0f;
float w = sinf(u * freq + time)
* cosf(v * freq + time) * 0.5f;

// Write positions
positions[y * width + x] =
make_float4(u, w, v, __int_as_float(0xff00ff00));
}

3.2.13.3 SLI一致性

在具有多个 GPU 的系统中,所有支持 CUDA 的 GPU 都可以通过 CUDA 驱动程序和运行时作为单独的设备进行访问。然而,当系统处于 SLI 模式时,有如下所述的特殊注意事项。

首先,在一个 GPU 上的一个 CUDA 设备中的分配将消耗其他 GPU 上的内存,这些 GPU 是 Direct3D 或 OpenGL 设备的 SLI 配置的一部分。因此,分配可能会比预期的更早失败。

其次,应用程序应该创建多个 CUDA 上下文,一个用于 SLI 配置中的每个 GPU。虽然这不是严格要求,但它避免了设备之间不必要的数据传输。应用程序可以将 cudaD3D[9|10|11]GetDevices() 用于 Direct3D 和 cudaGLGetDevices() 用于 OpenGL 调用,以识别当前执行渲染的设备的 CUDA 设备句柄和下一帧。鉴于此信息,应用程序通常会选择适当的设备并将 Direct3D 或 OpenGL 资源映射到由 cudaD3D[9|10|11]GetDevices() 或当 deviceList 参数设置为 cudaD3D[9|10 |11]DeviceListCurrentFramecudaGLDeviceListCurrentFrame

请注意,从 cudaGraphicsD9D[9|10|11]RegisterResourcecudaGraphicsGLRegister[Buffer|Image] 返回的资源只能在发生注册的设备上使用。因此,在 SLI 配置中,当在不同的 CUDA 设备上计算不同帧的数据时,有必要分别为每个设备注册资源。

有关 CUDA 运行时如何分别与 Direct3D 和 OpenGL 互操作的详细信息,请参阅 Direct3D 互操作性OpenGL 互操作性

3.2.14 扩展资源一致性

这里待定(实际上是作者不熟悉)

3.2.15 CUDA用户对象

CUDA 用户对象可用于帮助管理 CUDA 中异步工作所使用的资源的生命周期。 特别是,此功能对于 CUDA 图流捕获非常有用。

各种资源管理方案与 CUDA 图不兼容。 例如,考虑基于事件的池或同步创建、异步销毁方案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Library API with pool allocation
void libraryWork(cudaStream_t stream) {
auto &resource = pool.claimTemporaryResource();
resource.waitOnReadyEventInStream(stream);
launchWork(stream, resource);
resource.recordReadyEvent(stream);
}
// Library API with asynchronous resource deletion
void libraryWork(cudaStream_t stream) {
Resource *resource = new Resource(...);
launchWork(stream, resource);
cudaStreamAddCallback(
stream,
[](cudaStream_t, cudaError_t, void *resource) {
delete static_cast<Resource *>(resource);
},
resource,
0);
// Error handling considerations not shown
}

由于需要间接或图更新的资源的非固定指针或句柄,以及每次提交工作时需要同步 CPU 代码,这些方案对于 CUDA 图来说是困难的。如果这些注意事项对库的调用者隐藏,并且由于在捕获期间使用了不允许的 API,它们也不适用于流捕获。存在各种解决方案,例如将资源暴露给调用者。 CUDA 用户对象提供了另一种方法。

CUDA 用户对象将用户指定的析构函数回调与内部引用计数相关联,类似于 C++ shared_ptr。引用可能归 CPU 上的用户代码和 CUDA 图所有。请注意,对于用户拥有的引用,与 C++ 智能指针不同,没有代表引用的对象;用户必须手动跟踪用户拥有的引用。一个典型的用例是在创建用户对象后立即将唯一的用户拥有的引用移动到 CUDA 图。

当引用关联到 CUDA 图时,CUDA 将自动管理图操作。克隆的 cudaGraph_t 保留源 cudaGraph_t 拥有的每个引用的副本,具有相同的多重性。实例化的 cudaGraphExec_t 保留源 cudaGraph_t 中每个引用的副本。当 cudaGraphExec_t 在未同步的情况下被销毁时,引用将保留到执行完成。

这是一个示例用法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
cudaGraph_t graph;  // Preexisting graph

Object *object = new Object; // C++ object with possibly nontrivial destructor
cudaUserObject_t cuObject;
cudaUserObjectCreate(
&cuObject,
object, // Here we use a CUDA-provided template wrapper for this API,
// which supplies a callback to delete the C++ object pointer
1, // Initial refcount
cudaUserObjectNoDestructorSync // Acknowledge that the callback cannot be
// waited on via CUDA
);
cudaGraphRetainUserObject(
graph,
cuObject,
1, // Number of references
cudaGraphUserObjectMove // Transfer a reference owned by the caller (do
// not modify the total reference count)
);
// No more references owned by this thread; no need to call release API
cudaGraphExec_t graphExec;
cudaGraphInstantiate(&graphExec, graph, nullptr, nullptr, 0); // Will retain a
// new reference
cudaGraphDestroy(graph); // graphExec still owns a reference
cudaGraphLaunch(graphExec, 0); // Async launch has access to the user objects
cudaGraphExecDestroy(graphExec); // Launch is not synchronized; the release
// will be deferred if needed
cudaStreamSynchronize(0); // After the launch is synchronized, the remaining
// reference is released and the destructor will
// execute. Note this happens asynchronously.
// If the destructor callback had signaled a synchronization object, it would
// be safe to wait on it at this point.

子图节点中的图所拥有的引用与子图相关联,而不是与父图相关联。如果更新或删除子图,则引用会相应更改。如果使用 cudaGraphExecUpdatecudaGraphExecChildGraphNodeSetParams 更新可执行图或子图,则会克隆新源图中的引用并替换目标图中的引用。在任何一种情况下,如果先前的启动不同步,则将保留任何将被释放的引用,直到启动完成执行。

目前没有通过 CUDA API 等待用户对象析构函数的机制。用户可以从析构代码中手动发出同步对象的信号。另外,从析构函数调用 CUDA API 是不合法的,类似于对 cudaLaunchHostFunc 的限制。这是为了避免阻塞 CUDA 内部共享线程并阻止前进。如果依赖是一种方式并且执行调用的线程不能阻止 CUDA 工作的前进进度,则向另一个线程发出执行 API 调用的信号是合法的。

用户对象是使用 cudaUserObjectCreate 创建的,这是浏览相关 API 的一个很好的起点。

3.3 版本和兼容性

开发人员在开发 CUDA 应用程序时应该关注两个版本号:描述计算设备的一般规范和特性的计算能力(请参阅计算能力)和描述受支持的特性的 CUDA 驱动程序 API 的版本。驱动程序 API 和运行时。

驱动程序 API 的版本在驱动程序头文件中定义为 CUDA_VERSION。它允许开发人员检查他们的应用程序是否需要比当前安装的设备驱动程序更新的设备驱动程序。这很重要,因为驱动 API 是向后兼容的,这意味着针对特定版本的驱动 API 编译的应用程序、插件和库(包括 CUDA 运行时)将继续在后续的设备驱动版本上工作,如下图所示. 驱动 API 不向前兼容,这意味着针对特定版本的驱动 API 编译的应用程序、插件和库(包括 CUDA 运行时)将不适用于以前版本的设备驱动。

需要注意的是,支持的版本的混合和匹配存在限制:

  • 由于系统上一次只能安装一个版本的 CUDA 驱动程序,因此安装的驱动程序必须与必须在已建成的系统其上运行的任何应用程序、插件或库所依据的最大驱动程序 API 版本相同或更高版本 。
  • 应用程序使用的所有插件和库必须使用相同版本的 CUDA 运行时,除非它们静态链接到运行时,在这种情况下,运行时的多个版本可以共存于同一进程空间中。 请注意,如果使用 nvcc 链接应用程序,则默认使用静态版本的 CUDA Runtime 库,并且所有 CUDA Toolkit 库都针对 CUDA Runtime 静态链接。
  • 应用程序使用的所有插件和库必须使用与使用运行时的任何库(例如 cuFFT、cuBLAS…)相同的版本,除非静态链接到这些库。

compatibility-of-cuda-versions.png

对于 Tesla GPU 产品,CUDA 10 为 CUDA 驱动程序的用户模式组件引入了新的向前兼容升级路径。 此功能在 CUDA 兼容性中进行了描述。 此处描述的对 CUDA 驱动程序版本的要求适用于用户模式组件的版本。

3.4 Compute Modes

在运行 Windows Server 2008 及更高版本或 Linux 的 Tesla 解决方案上,可以使用 NVIDIA 的系统管理接口 (nvidia-smi) 将系统中的任何设备设置为以下三种模式之一,这是作为驱动程序一部分分发的工具:

  • 默认计算模式:多个主机线程可以同时使用该设备(通过在此设备上调用 cudaSetDevice(),当使用运行时 API 时,或通过使 current 成为与设备关联的上下文,当使用驱动程序 API 时)。
  • 独占进程计算模式:在设备上只能在系统中的所有进程中创建一个 CUDA 上下文。 在创建该上下文的进程中,该上下文可以是当前任意数量的线程。
  • 禁止的计算模式:不能在设备上创建 CUDA 上下文。

这尤其意味着,如果设备 0 处于禁止模式或独占进程模式并被另一个设备使用,则使用运行时 API 而不显式调用 cudaSetDevice() 的主机线程可能与设备 0 以外的设备相关联过程。 cudaSetValidDevices() 可用于从设备的优先级列表中设置设备。

另请注意,对于采用 Pascal 架构(具有主要修订号 6 及更高版本的计算能力)的设备,存在对计算抢占的支持。这允许计算任务在指令级粒度上被抢占,而不是像以前的 Maxwell 和 Kepler GPU 架构中那样以线程块粒度进行抢占,其好处是可以防止具有长时间运行内核的应用程序垄断系统或超时。但是,将存在与计算抢占相关的上下文切换开销,它会在支持的设备上自动启用。具有属性 cudaDevAttrComputePreemptionSupported 的单个属性查询函数 cudaDeviceGetAttribute() 可用于确定正在使用的设备是否支持计算抢占。希望避免与不同进程相关的上下文切换开销的用户可以通过选择独占进程模式来确保在 GPU 上只有一个进程处于活动状态。

应用程序可以通过检查 computeMode 设备属性来查询设备的计算模式(请参阅设备枚举)。

3.5 模式切换

具有显示输出的 GPU 将一些 DRAM 内存专用于所谓的主画面,用于刷新用户查看其输出的显示设备。当用户通过更改显示器的分辨率或位深度(使用 NVIDIA 控制面板或 Windows 上的显示控制面板)来启动显示器的模式切换时,主表面所需的内存量会发生变化。例如,如果用户将显示分辨率从 1280x1024x32 位更改为 1600x1200x32 位,则系统必须将 7.68 MB 专用于主画面,而不是 5.24 MB。 (在启用抗锯齿的情况下运行的全屏图形应用程序可能需要更多的主画面显示内存。)在 Windows 上,可能会启动显示模式切换的其他事件包括启动全屏 DirectX 应用程序,按 Alt+Tab 来完成任务从全屏 DirectX 应用程序切换,或按 Ctrl+Alt+Del 锁定计算机。

如果模式切换增加了主画面所需的内存量,系统可能不得不蚕食专用于 CUDA 应用程序的内存分配。因此,模式切换会导致对 CUDA 运行时的任何调用失败并返回无效的上下文错误。

3.6 在Windows上的Tesla计算集群

使用 NVIDIA 的系统管理界面 (nvidia-smi),可以将 Windows 设备驱动程序置于 Tesla 和 Quadro 系列设备的 TCC(Tesla Compute Cluster)模式。

TCC 模式不支持任何图形功能。

第四章 硬件实现

NVIDIA GPU 架构围绕可扩展的多线程流式多处理器 (SM: Streaming Multiprocessors) 阵列构建。当主机 CPU 上的 CUDA 程序调用内核网格时,网格的块被枚举并分发到具有可用执行能力的多处理器。一个线程块的线程在一个SM上并发执行,多个线程块可以在一个SM上并发执行。当线程块终止时,新块在空出的SM上启动。

SM旨在同时执行数百个线程。为了管理如此大量的线程,它采用了一种称为 SIMT(Single-Instruction, Multiple-Thread: 单指令,多线程)的独特架构,在 SIMT 架构中进行了描述。这些指令是流水线的,利用单个线程内的指令级并行性,以及通过同时硬件多线程处理的广泛线程级并行性,如硬件多线程中详述。与 CPU 内核不同,它们是按顺序发出的,没有分支预测或推测执行。

SIMT 架构硬件多线程描述了所有设备通用的流式多处理器的架构特性。 Compute Capability 3.x、Compute Capability 5.x、Compute Capability 6.x 和 Compute Capability 7.x 分别为计算能力 3.x、5.x、6.x 和 7.x 的设备提供了详细信息。

NVIDIA GPU 架构使用 little-endian 表示。

4.1 SIMT 架构

多处理器以 32 个并行线程组(称为 warp)的形式创建、管理、调度和执行线程。组成 warp 的各个线程一起从同一个程序地址开始,但它们有自己的指令地址计数器和寄存器状态,因此可以自由地分支和独立执行。warp一词源于编织,这是第一个并行线程技术。半warp是warp的前半部分或后半部分。四分之一经线是warp的第一、第二、第三或第四四分之一。

当一个多处理器被赋予一个或多个线程块来执行时,它将它们划分为warp,并且每个warp都由warp调度程序调度以执行。一个块被分割成warp的方式总是一样的;每个warp包含连续的线程,增加线程ID,第一个warp包含线程0。线程层次结构描述了线程ID如何与块中的线程索引相关。

一个 warp 一次执行一条公共指令,因此当一个 warp 的所有 32 个线程都同意它们的执行路径时,就可以实现完全的效率。如果 warp 的线程通过依赖于数据的条件分支发散,则 warp 执行所采用的每个分支路径,禁用不在该路径上的线程。分支分歧只发生在一个warp内;不同的 warp 独立执行,无论它们是执行公共的还是不相交的代码路径。

SIMT 体系结构类似于 SIMD(单指令多数据)向量组织,其中单指令控制多个处理元素。一个关键区别是 SIMD 矢量组织向软件公开了 SIMD 宽度,而 SIMT 指令指定单个线程的执行和分支行为。与 SIMD 向量机相比,SIMT 使程序员能够为独立的标量线程编写线程级并行代码,以及为协调线程编写数据并行代码。为了正确起见,程序员基本上可以忽略 SIMT 行为;但是,通过代码很少需要warp中的线程发散,可以实现显着的性能改进。在实践中,这类似于传统代码中缓存线的作用:在设计正确性时可以安全地忽略缓存线大小,但在设计峰值性能时必须在代码结构中考虑。另一方面,向量架构需要软件将负载合并到向量中并手动管理分歧。

在 Volta 之前,warp 使用在 warp 中的所有 32 个线程之间共享的单个程序计数器以及指定 warp 的活动线程的活动掩码。结果,来自不同区域或不同执行状态的同一warp的线程无法相互发送信号或交换数据,并且需要细粒度共享由锁或互斥锁保护的数据的算法很容易导致死锁,具体取决于来自哪个warp竞争线程。

从 Volta 架构开始,独立线程调度允许线程之间的完全并发,而不管 warp。使用独立线程调度,GPU 维护每个线程的执行状态,包括程序计数器和调用堆栈,并且可以在每个线程的粒度上产生执行,以便更好地利用执行资源或允许一个线程等待数据由他人生产。调度优化器确定如何将来自同一个 warp 的活动线程组合成 SIMT 单元。这保留了与先前 NVIDIA GPU 一样的 SIMT 执行的高吞吐量,但具有更大的灵活性:线程现在可以在 sub-warp 粒度上发散和重新收敛。

如果开发人员对先前硬件架构的 warp-synchronicity2 做出假设,独立线程调度可能会导致参与执行代码的线程集与预期的完全不同。特别是,应重新访问任何warp同步代码(例如无同步、内部warp减少),以确保与 Volta 及更高版本的兼容性。有关详细信息,请参阅计算能力 7.x

注意:

参与当前指令的 warp 线程称为活动线程,而不在当前指令上的线程是非活动的(禁用)。线程可能由于多种原因而处于非活动状态,包括比其 warp 的其他线程更早退出,采用与 warp 当前执行的分支路径不同的分支路径,或者是线程数不是线程数的块的最后一个线程warp尺寸的倍数。

如果 warp 执行的非原子指令为多个 warp 的线程写入全局或共享内存中的同一位置,则该位置发生的序列化写入次数取决于设备的计算能力(参见 Compute Capability 3.x、Compute Capability 5.x、Compute Capability 6.x 和 Compute Capability 7.x),哪个线程执行最终写入是未定义的。

如果一个由 warp 执行的原子指令读取、修改和写入全局内存中多个线程的同一位置,则对该位置的每次读取/修改/写入都会发生并且它们都被序列化,但是它们发生的顺序是不确定的。

4.2 硬件多线程

多处理器处理的每个 warp 的执行上下文(程序计数器、寄存器等)在 warp 的整个生命周期内都在芯片上维护。因此,从一个执行上下文切换到另一个执行上下文是没有成本的,并且在每个指令发出时,warp 调度程序都会选择一个线程准备好执行其下一条指令(warp 的活动线程)并将指令发布给这些线程.

特别是,每个多处理器都有一组 32 位寄存器,这些寄存器在 warp 之间进行分区,以及在线程块之间进行分区的并行数据缓存或共享内存。

对于给定内核,可以在多处理器上一起驻留和处理的块和warp的数量取决于内核使用的寄存器和共享内存的数量以及多处理器上可用的寄存器和共享内存的数量。每个多处理器也有最大数量的驻留块和驻留warp的最大数量。这些限制以及多处理器上可用的寄存器数量和共享内存是设备计算能力的函数,在附录计算能力中给出。如果每个多处理器没有足够的寄存器或共享内存来处理至少一个块,内核将无法启动。

一个块中的warp总数如下:

number of warps.png

为块分配的寄存器总数和共享内存总量记录在 CUDA 工具包中提供的 CUDA Occupancy Calculator中。

第五章 性能指南

5.1 整体性能优化策略

性能优化围绕四个基本策略:

  • 最大化并行执行以实现最大利用率;
  • 优化内存使用,实现最大内存吞吐量;
  • 优化指令使用,实现最大指令吞吐量;
  • 尽量减少内存抖动。

哪些策略将为应用程序的特定部分产生最佳性能增益取决于该部分的性能限值; 例如,优化主要受内存访问限制的内核的指令使用不会产生任何显着的性能提升。 因此,应该通过测量和监控性能限制来不断地指导优化工作,例如使用 CUDA 分析器。 此外,将特定内核的浮点运算吞吐量或内存吞吐量(以更有意义的为准)与设备的相应峰值理论吞吐量进行比较表明内核还有多少改进空间。

5.2 最大化利用率

为了最大限度地提高利用率,应用程序的结构应该尽可能多地暴露并行性,并有效地将这种并行性映射到系统的各个组件,以使它们大部分时间都处于忙碌状态。

5.2.1 应用程序层次

在高层次上,应用程序应该通过使用异步函数调用和异步并发执行中描述的流来最大化主机、设备和将主机连接到设备的总线之间的并行执行。它应该为每个处理器分配它最擅长的工作类型:主机的串行工作负载;设备的并行工作负载。

对于并行工作负载,在算法中由于某些线程需要同步以相互共享数据而破坏并行性的点,有两种情况: 这些线程属于同一个块,在这种情况下,它们应该使用 __syncthreads () 并在同一个内核调用中通过共享内存共享数据,或者它们属于不同的块,在这种情况下,它们必须使用两个单独的内核调用通过全局内存共享数据,一个用于写入,一个用于从全局内存中读取。第二种情况不太理想,因为它增加了额外内核调用和全局内存流量的开销。因此,应该通过将算法映射到 CUDA 编程模型以使需要线程间通信的计算尽可能在单个线程块内执行,从而最大限度地减少它的发生。

5.2.2 设备层次

在较低级别,应用程序应该最大化设备多处理器之间的并行执行。

多个内核可以在一个设备上并发执行,因此也可以通过使用流来启用足够多的内核来实现最大利用率,如异步并发执行中所述。

5.2.3 多处理器层次

在更低的层次上,应用程序应该最大化多处理器内不同功能单元之间的并行执行。

如硬件多线程中所述,GPU 多处理器主要依靠线程级并行性来最大限度地利用其功能单元。因此,利用率与常驻warp的数量直接相关。在每个指令发出时,warp 调度程序都会选择一条准备好执行的指令。该指令可以是同一warp的另一条独立指令,利用指令级并行性,或者更常见的是另一个warp的指令,利用线程级并行性。如果选择了准备执行指令,则将其发布到 warp 的活动线程。一个warp准备好执行其下一条指令所需的时钟周期数称为延迟,并且当所有warp调度程序在该延迟期间的每个时钟周期总是有一些指令要为某个warp发出一些指令时,就可以实现充分利用,或者换句话说,当延迟完全“隐藏”时。隐藏 L 个时钟周期延迟所​​需的指令数量取决于这些指令各自的吞吐量(有关各种算术指令的吞吐量,请参见算术指令)。如果我们假设指令具有最大吞吐量,它等于:

  • 4L 用于计算能力 5.x、6.1、6.2、7.x 和 8.x 的设备,因为对于这些设备,多处理器在一个时钟周期内为每个 warp 发出一条指令,一次四个 warp,如计算能力中所述。
  • 2L 用于计算能力 6.0 的设备,因为对于这些设备,每个周期发出的两条指令是两条不同warp的一条指令。
  • 8L 用于计算能力 3.x 的设备,因为对于这些设备,每个周期发出的八条指令是四对,用于四个不同的warp,每对都用于相同的warp。

warp 未准备好执行其下一条指令的最常见原因是该指令的输入操作数尚不可用。

如果所有输入操作数都是寄存器,则延迟是由寄存器依赖性引起的,即,一些输入操作数是由一些尚未完成的先前指令写入的。在这种情况下,延迟等于前一条指令的执行时间,warp 调度程序必须在此期间调度其他 warp 的指令。执行时间因指令而异。在计算能力 7.x 的设备上,对于大多数算术指令,它通常是 4 个时钟周期。这意味着每个多处理器需要 16 个活动 warp(4 个周期,4 个 warp 调度程序)来隐藏算术指令延迟(假设 warp 以最大吞吐量执行指令,否则需要更少的 warp)。如果各个warp表现出指令级并行性,即在它们的指令流中有多个独立指令,则需要更少的warp,因为来自单个warp的多个独立指令可以背靠背发出。

如果某些输入操作数驻留在片外存储器中,则延迟要高得多:通常为数百个时钟周期。在如此高的延迟期间保持 warp 调度程序繁忙所需的 warp 数量取决于内核代码及其指令级并行度。一般来说,如果没有片外存储器操作数的指令(即大部分时间是算术指令)与具有片外存储器操作数的指令数量之比较低(这个比例通常是称为程序的算术强度)。

warp 未准备好执行其下一条指令的另一个原因是它正在某个内存栅栏(内存栅栏函数)或同步点(同步函数)处等待。随着越来越多的warp等待同一块中的其他warp在同步点之前完成指令的执行,同步点可以强制多处理器空闲。在这种情况下,每个多处理器拥有多个常驻块有助于减少空闲,因为来自不同块的warp不需要在同步点相互等待。

对于给定的内核调用,驻留在每个多处理器上的块和warp的数量取决于调用的执行配置(执行配置)、多处理器的内存资源以及内核的资源需求,如硬件多线程中所述。使用 --ptxas-options=-v 选项编译时,编译器会报告寄存器和共享内存的使用情况。

一个块所需的共享内存总量等于静态分配的共享内存量和动态分配的共享内存量之和。

内核使用的寄存器数量会对驻留warp的数量产生重大影响。例如,对于计算能力为 6.x 的设备,如果内核使用 64 个寄存器并且每个块有 512 个线程并且需要很少的共享内存,那么两个块(即 32 个 warp)可以驻留在多处理器上,因为它们需要 2x512x64 个寄存器,它与多处理器上可用的寄存器数量完全匹配。但是一旦内核多使用一个寄存器,就只能驻留一个块(即 16 个 warp),因为两个块需要 2x512x65 个寄存器,这比多处理器上可用的寄存器多。因此,编译器会尽量减少寄存器的使用,同时保持寄存器溢出(请参阅设备内存访问)和最少的指令数量。可以使用 maxrregcount 编译器选项或启动边界来控制寄存器的使用,如启动边界中所述。

寄存器文件组织为 32 位寄存器。因此,存储在寄存器中的每个变量都需要至少一个 32 位寄存器,例如双精度变量使用两个 32 位寄存器。

对于给定的内核调用,执行配置对性能的影响通常取决于内核代码。因此建议进行实验。应用程序还可以根据寄存器文件大小和共享内存大小参数化执行配置,这取决于设备的计算能力,以及设备的多处理器数量和内存带宽,所有这些都可以使用运行时查询(参见参考手册)。

每个块的线程数应选择为 warp 大小的倍数,以避免尽可能多地在填充不足的 warp 上浪费计算资源。

5.2.3.1 占用率计算

存在几个 API 函数来帮助程序员根据寄存器和共享内存要求选择线程块大小。

  • 占用计算器 API,cudaOccupancyMaxActiveBlocksPerMultiprocessor,可以根据内核的块大小和共享内存使用情况提供占用预测。此函数根据每个多处理器的并发线程块数报告占用情况。
    • 请注意,此值可以转换为其他指标。乘以每个块的warp数得出每个多处理器的并发warp数;进一步将并发warp除以每个多处理器的最大warp得到占用率作为百分比。
  • 基于占用率的启动配置器 API,cudaOccupancyMaxPotentialBlockSizecudaOccupancyMaxPotentialBlockSizeVariableSMem,启发式地计算实现最大多处理器级占用率的执行配置。

以下代码示例计算 MyKernel 的占用率。然后,它使用并发warp与每个多处理器的最大warp之间的比率报告占用率。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/ Device code
__global__ void MyKernel(int *d, int *a, int *b)
{
int idx = threadIdx.x + blockIdx.x * blockDim.x;
d[idx] = a[idx] * b[idx];
}

// Host code
int main()
{
int numBlocks; // Occupancy in terms of active blocks
int blockSize = 32;

// These variables are used to convert occupancy to warps
int device;
cudaDeviceProp prop;
int activeWarps;
int maxWarps;

cudaGetDevice(&device);
cudaGetDeviceProperties(&prop, device);

cudaOccupancyMaxActiveBlocksPerMultiprocessor(
&numBlocks,
MyKernel,
blockSize,
0);

activeWarps = numBlocks * blockSize / prop.warpSize;
maxWarps = prop.maxThreadsPerMultiProcessor / prop.warpSize;

std::cout << "Occupancy: " << (double)activeWarps / maxWarps * 100 << "%" << std::endl;

return 0;
}

下面的代码示例根据用户输入配置了一个基于占用率的内核启动MyKernel。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// Device code
__global__ void MyKernel(int *array, int arrayCount)
{
int idx = threadIdx.x + blockIdx.x * blockDim.x;
if (idx < arrayCount) {
array[idx] *= array[idx];
}
}

// Host code
int launchMyKernel(int *array, int arrayCount)
{
int blockSize; // The launch configurator returned block size
int minGridSize; // The minimum grid size needed to achieve the
// maximum occupancy for a full device
// launch
int gridSize; // The actual grid size needed, based on input
// size

cudaOccupancyMaxPotentialBlockSize(
&minGridSize,
&blockSize,
(void*)MyKernel,
0,
arrayCount);

// Round up according to array size
gridSize = (arrayCount + blockSize - 1) / blockSize;

MyKernel<<<gridSize, blockSize>>>(array, arrayCount);
cudaDeviceSynchronize();

// If interested, the occupancy can be calculated with
// cudaOccupancyMaxActiveBlocksPerMultiprocessor

return 0;
}

CUDA 工具包还在 <CUDA_Toolkit_Path>/include/cuda_occupancy.h 中为任何不能依赖 CUDA 软件堆栈的用例提供了一个自记录的独立占用计算器和启动配置器实现。 还提供了占用计算器的电子表格版本。 电子表格版本作为一种学习工具特别有用,它可以可视化更改影响占用率的参数(块大小、每个线程的寄存器和每个线程的共享内存)的影响。

5.3 最大化存储吞吐量

最大化应用程序的整体内存吞吐量的第一步是最小化低带宽的数据传输。

这意味着最大限度地减少主机和设备之间的数据传输,如主机和设备之间的数据传输中所述,因为它们的带宽比全局内存和设备之间的数据传输低得多。

这也意味着通过最大化片上内存的使用来最小化全局内存和设备之间的数据传输:共享内存和缓存(即计算能力 2.x 及更高版本的设备上可用的 L1 缓存和 L2 缓存、纹理缓存和常量缓存 适用于所有设备)。

共享内存相当于用户管理的缓存:应用程序显式分配和访问它。 如 CUDA Runtime 所示,典型的编程模式是将来自设备内存的数据暂存到共享内存中; 换句话说,拥有一个块的每个线程:

  • 将数据从设备内存加载到共享内存,
  • 与块的所有其他线程同步,以便每个线程可以安全地读取由不同线程填充的共享内存位置,
    处理共享内存中的数据,
  • 如有必要,再次同步以确保共享内存已使用结果更新,
  • 将结果写回设备内存。

对于某些应用程序(例如,全局内存访问模式依赖于数据),传统的硬件管理缓存更适合利用数据局部性。如 Compute Capability 3.x、Compute Capability 7.x 和 Compute Capability 8.x 中所述,对于计算能力 3.x、7.x 和 8.x 的设备,相同的片上存储器用于 L1 和共享内存,以及有多少专用于 L1 与共享内存,可针对每个内核调用进行配置。

内核访问内存的吞吐量可能会根据每种内存类型的访问模式而变化一个数量级。因此,最大化内存吞吐量的下一步是根据设备内存访问中描述的最佳内存访问模式尽可能优化地组织内存访问。这种优化对于全局内存访问尤为重要,因为与可用的片上带宽和算术指令吞吐量相比,全局内存带宽较低,因此非最佳全局内存访问通常会对性能产生很大影响。

5.3.1 设备与主机之间的数据传输

应用程序应尽量减少主机和设备之间的数据传输。 实现这一点的一种方法是将更多代码从主机移动到设备,即使这意味着运行的内核没有提供足够的并行性以在设备上全效率地执行。 中间数据结构可以在设备内存中创建,由设备操作,并在没有被主机映射或复制到主机内存的情况下销毁。

此外,由于与每次传输相关的开销,将许多小传输批处理为单个大传输总是比单独进行每个传输执行得更好。

在具有前端总线的系统上,主机和设备之间的数据传输的更高性能是通过使用页锁定主机内存来实现的,如页锁定主机内存中所述。

此外,在使用映射页锁定内存(Mapped Memory)时,无需分配任何设备内存,也无需在设备和主机内存之间显式复制数据。 每次内核访问映射内存时都会隐式执行数据传输。 为了获得最佳性能,这些内存访问必须与对全局内存的访问合并(请参阅设备内存访问)。 假设它们映射的内存只被读取或写入一次,使用映射的页面锁定内存而不是设备和主机内存之间的显式副本可以提高性能。

在设备内存和主机内存在物理上相同的集成系统上,主机和设备内存之间的任何拷贝都是多余的,应该使用映射的页面锁定内存。 应用程序可以通过检查集成设备属性(请参阅设备枚举)是否等于 1 来查询设备是否集成。

5.3.2 设备内存访问

访问可寻址内存(即全局、本地、共享、常量或纹理内存)的指令可能需要多次重新发出,具体取决于内存地址在 warp 内线程中的分布。 分布如何以这种方式影响指令吞吐量特定于每种类型的内存,在以下部分中进行描述。 例如,对于全局内存,一般来说,地址越分散,吞吐量就越低。

全局内存

全局内存驻留在设备内存中,设备内存通过 32、64 或 128 字节内存事务访问。这些内存事务必须自然对齐:只有32字节、64字节或128字节的设备内存段按其大小对齐(即,其第一个地址是其大小的倍数)才能被内存事务读取或写入。

当一个 warp 执行一条访问全局内存的指令时,它会将 warp 内的线程的内存访问合并为一个或多个内存事务,具体取决于每个线程访问的大小以及内存地址在整个线程中的分布。线程。一般来说,需要的事务越多,除了线程访问的字之外,传输的未使用字也越多,相应地降低了指令吞吐量。例如,如果为每个线程的 4 字节访问生成一个 32 字节的内存事务,则吞吐量除以 8。

需要多少事务以及最终影响多少吞吐量取决于设备的计算能力。 Compute Capability 3.x、Compute Capability 5.x、Compute Capability 6.x、Compute Capability 7.x 和 Compute Capability 8.x 提供了有关如何为各种计算能力处理全局内存访问的更多详细信息。

为了最大化全局内存吞吐量,因此通过以下方式最大化合并非常重要:

  • 遵循基于 Compute Capability 3.x、Compute Capability 5.x、Compute Capability 6.x、Compute Capability 7.x 和 Compute Capability 8.x 的最佳访问模式
  • 使用满足以下“尺寸和对齐要求”部分中详述的大小和对齐要求的数据类型,
  • 在某些情况下填充数据,例如,在访问二维数组时,如下面的二维数组部分所述。

尺寸和对齐要求

全局内存指令支持读取或写入大小等于 1、2、4、8 或 16 字节的字。 当且仅当数据类型的大小为 1、2、4、8 或 16 字节并且数据为 对齐(即,它的地址是该大小的倍数)。

如果未满足此大小和对齐要求,则访问将编译为具有交错访问模式的多个指令,从而阻止这些指令完全合并。 因此,对于驻留在全局内存中的数据,建议使用满足此要求的类型。

内置矢量类型自动满足对齐要求。

对于结构,大小和对齐要求可以由编译器使用对齐说明符 __align__(8)__align__(16) 强制执行,例如:

1
2
3
4
5
6
7
8
9
10
struct __align__(8) {
float x;
float y;
};

struct __align__(16) {
float x;
float y;
float z;
};

驻留在全局内存中, 或由驱动程序, 或运行时 API 的内存分配例程之一返回的变量的任何地址始终与至少 256 字节对齐。

读取非自然对齐的 8 字节或 16 字节字会产生不正确的结果(相差几个字),因此必须特别注意保持这些类型的任何值或数组值的起始地址对齐。 一个可能容易被忽视的典型情况是使用一些自定义全局内存分配方案时,其中多个数组的分配(多次调用 cudaMalloc()cuMemAlloc())被单个大块内存的分配所取代分区为多个数组,在这种情况下,每个数组的起始地址都与块的起始地址有偏移。

二维数组

一个常见的全局内存访问模式是当索引 (tx,ty) 的每个线程使用以下地址访问一个宽度为 width 的二维数组的一个元素时,位于 type* 类型的地址 BaseAddress (其中 type 满足最大化中描述的使用要求 ):

BaseAddress + width * ty + tx

为了使这些访问完全合并,线程块的宽度和数组的宽度都必须是 warp 大小的倍数。

特别是,这意味着如果一个数组的宽度不是这个大小的倍数,如果它实际上分配了一个宽度向上舍入到这个大小的最接近的倍数并相应地填充它的行,那么访问它的效率会更高。 参考手册中描述的 cudaMallocPitch()cuMemAllocPitch() 函数以及相关的内存复制函数使程序员能够编写不依赖于硬件的代码来分配符合这些约束的数组。

本地内存

本地内存访问仅发生在可变内存空间说明符中提到的某些自动变量上。 编译器可能放置在本地内存中的变量是:

  • 无法确定它们是否以常数索引的数组,
  • 会占用过多寄存器空间的大型结构或数组,
  • 如果内核使用的寄存器多于可用寄存器(这也称为寄存器溢出),则为任何变量。

检查 PTX 汇编代码(通过使用 -ptx-keep 选项进行编译)将判断在第一个编译阶段是否已将变量放置在本地内存中,因为它将使用 .local 助记符声明并使用 ld 访问.localst.local 助记符。即使没有,后续编译阶段可能仍会做出其他决定,但如果他们发现它为目标体系结构消耗了过多的寄存器空间:使用 cuobjdump 检查 cubin 对象将判断是否是这种情况。此外,当使用 --ptxas-options=-v 选项编译时,编译器会报告每个内核 (lmem) 的总本地内存使用量。请注意,某些数学函数具有可能访问本地内存的实现路径。

本地内存空间驻留在设备内存中,因此本地内存访问与全局内存访问具有相同的高延迟和低带宽,并且与设备内存访问中所述的内存合并要求相同。然而,本地存储器的组织方式是通过连续的线程 ID 访问连续的 32 位字。因此,只要一个 warp 中的所有线程访问相同的相对地址(例如,数组变量中的相同索引,结构变量中的相同成员),访问就会完全合并。

在某些计算能力 3.x 的设备上,本地内存访问始终缓存在 L1 和 L2 中,其方式与全局内存访问相同(请参阅计算能力 3.x)。

在计算能力 5.x 和 6.x 的设备上,本地内存访问始终以与全局内存访问相同的方式缓存在 L2 中(请参阅计算能力 5.x 和计算能力 6.x)。

共享内存

因为它是片上的,所以共享内存比本地或全局内存具有更高的带宽和更低的延迟。

为了实现高带宽,共享内存被分成大小相等的内存模块,称为banks,可以同时访问。因此,可以同时处理由落在 n 个不同存储器组中的 n 个地址构成的任何存储器读取或写入请求,从而产生的总带宽是单个模块带宽的 n 倍。

但是,如果一个内存请求的两个地址落在同一个内存 bank 中,就会发生 bank 冲突,访问必须串行化。硬件根据需要将具有bank冲突的内存请求拆分为多个单独的无冲突请求,从而将吞吐量降低等于单独内存请求数量的总数。如果单独的内存请求的数量为 n,则称初始内存请求会导致 n-way bank 冲突。

因此,为了获得最佳性能,重要的是要了解内存地址如何映射到内存组,以便调度内存请求,从而最大限度地减少内存组冲突。这在计算能力 3.x、计算能力 5.x、计算能力 6.x、计算能力 7.x 和计算能力 8.x 中针对计算能力 3.x、5.x、6.x 7.x 和 8.x 的设备分别进行了描述。

常量内存

常量内存空间驻留在设备内存中,并缓存在常量缓存中。

然后,一个请求被拆分为与初始请求中不同的内存地址一样多的单独请求,从而将吞吐量降低等于单独请求数量的总数。

然后在缓存命中的情况下以常量缓存的吞吐量为结果请求提供服务,否则以设备内存的吞吐量提供服务。

纹理和表面记忆

纹理和表面内存空间驻留在设备内存中并缓存在纹理缓存中,因此纹理提取或表面读取仅在缓存未命中时从设备内存读取一次内存,否则只需从纹理缓存读取一次。 纹理缓存针对 2D 空间局部性进行了优化,因此读取 2D 中地址靠近在一起的纹理或表面的同一 warp 的线程将获得最佳性能。 此外,它专为具有恒定延迟的流式提取而设计; 缓存命中会降低 DRAM 带宽需求,但不会降低获取延迟。

通过纹理或表面获取读取设备内存具有一些优势,可以使其成为从全局或常量内存读取设备内存的有利替代方案:

  • 如果内存读取不遵循全局或常量内存读取必须遵循以获得良好性能的访问模式,则可以实现更高的带宽,前提是纹理提取或表面读取中存在局部性;
  • 寻址计算由专用单元在内核外部执行;
  • 打包的数据可以在单个操作中广播到单独的变量;
  • 8 位和 16 位整数输入数据可以选择转换为 [0.0, 1.0] 或 [-1.0, 1.0] 范围内的 32 位浮点值(请参阅纹理内存)。

5.4最大化指令吞吐量

为了最大化指令吞吐量,应用程序应该:

  • 尽量减少使用低吞吐量的算术指令; 这包括在不影响最终结果的情况下用精度换取速度,例如使用内部函数而不是常规函数(内部函数在内部函数中列出),单精度而不是双精度,或者将非规范化数字刷新为零;
  • 最大限度地减少由控制流指令引起的发散warp,如控制流指令中所述
  • 减少指令的数量,例如,尽可能优化同步点(如同步指令中所述)或使用受限指针(如 restrict 中所述)。

在本节中,吞吐量以每个多处理器每个时钟周期的操作数给出。 对于 32 的 warp 大小,一条指令对应于 32 次操作,因此如果 N 是每个时钟周期的操作数,则指令吞吐量为每个时钟周期的 N/32 条指令。

所有吞吐量都是针对一个多处理器的。 它们必须乘以设备中的多处理器数量才能获得整个设备的吞吐量。

5.4.1 算数指令

如下图所示
Throughput.png

其他指令和功能是在本机指令之上实现的。不同计算能力的设备实现可能不同,编译后的native指令的数量可能会随着编译器版本的不同而波动。对于复杂的函数,可以有多个代码路径,具体取决于输入。 cuobjdump 可用于检查 cubin 对象中的特定实现。

一些函数的实现在 CUDA 头文件(math_functions.h、device_functions.h、…)上很容易获得。

通常,使用 -ftz=true 编译的代码(非规范化数字刷新为零)往往比使用 -ftz=false 编译的代码具有更高的性能。类似地,使用 -prec-div=false(不太精确的除法)编译的代码往往比使用 -prec-div=true 编译的代码具有更高的性能,使用 -prec-sqrt=false(不太精确的平方根)编译的代码往往比使用 -prec-sqrt=true 编译的代码具有更高的性能。 nvcc 用户手册更详细地描述了这些编译标志。

Single-Precision Floating-Point Division

__fdividef(x, y)(参见内部函数)提供比除法运算符更快的单精度浮点除法。

Single-Precision Floating-Point Reciprocal Square Root

为了保留 IEEE-754 语义,编译器可以将 1.0/sqrtf() 优化为 rsqrtf(),仅当倒数和平方根都是近似值时(即 -prec-div=false-prec-sqrt=false)。 因此,建议在需要时直接调用 rsqrtf()

Single-Precision Floating-Point Square Root

单精度浮点平方根被实现为倒数平方根后跟倒数,而不是倒数平方根后跟乘法,因此它可以为 0 和无穷大提供正确的结果。

Sine and Cosine

sinf(x)、cosf(x)、tanf(x)、sincosf(x) 和相应的双精度指令更昂贵,如果参数 x 的量级很大,则更是如此。

更准确地说,参数缩减代码(参见实现的数学函数)包括两个代码路径,分别称为快速路径和慢速路径。

快速路径用于大小足够小的参数,并且基本上由几个乘加运算组成。 慢速路径用于量级较大的参数,并且包含在整个参数范围内获得正确结果所需的冗长计算。

目前,三角函数的参数缩减代码为单精度函数选择幅度小于105615.0f,双精度函数小于2147483648.0的参数选择快速路径。

由于慢速路径比快速路径需要更多的寄存器,因此尝试通过在本地内存中存储一些中间变量来降低慢速路径中的寄存器压力,这可能会因为本地内存的高延迟和带宽而影响性能(请参阅设备内存访问)。 目前单精度函数使用28字节的本地内存,双精度函数使用44字节。 但是,确切的数量可能会发生变化。

由于在慢路径中需要进行冗长的计算和使用本地内存,当需要进行慢路径缩减时,与快速路径缩减相比,这些三角函数的吞吐量要低一个数量级。

Integer Arithmetic

整数除法和模运算的成本很高,因为它们最多可编译为 20 条指令。 在某些情况下,它们可以用按位运算代替:如果 n 是 2 的幂,则 (i/n) 等价于 (i>>log2(n)) 并且 (i%n) 等价于 (i&(n- 1)); 如果 n 是字母,编译器将执行这些转换。

__brev__popc 映射到一条指令,而 __brevll__popcll 映射到几条指令。

__[u]mul24 是不再有任何理由使用的遗留内部函数。

Half Precision Arithmetic

为了实现 16 位精度浮点加法、乘法或乘法加法的良好性能,建议将 half2 数据类型用于半精度,将 __nv_bfloat162 用于 __nv_bfloat16 精度。 然后可以使用向量内在函数(例如 __hadd2、__hsub2、__hmul2、__hfma2)在一条指令中执行两个操作。 使用 half2__nv_bfloat162 代替使用 half__nv_bfloat16 的两个调用也可能有助于其他内在函数的性能,例如warp shuffles。

提供了内在的 __halves2half2 以将两个半精度值转换为 half2 数据类型。

提供了内在的 __halves2bfloat162 以将两个 __nv_bfloat 精度值转换为 __nv_bfloat162 数据类型。

Type Conversion

有时,编译器必须插入转换指令,从而引入额外的执行周期。 情况如下:

  • 对 char 或 short 类型的变量进行操作的函数,其操作数通常需要转换为 int,
  • 双精度浮点常量(即那些没有任何类型后缀定义的常量)用作单精度浮点计算的输入(由 C/C++ 标准规定)。

最后一种情况可以通过使用单精度浮点常量来避免,这些常量使用 f 后缀定义,例如 3.141592653589793f、1.0f、0.5f。

5.4.2 控制流指令

任何流控制指令(if、switch、do、for、while)都可以通过导致相同 warp 的线程发散(即遵循不同的执行路径)来显着影响有效指令吞吐量。如果发生这种情况,则必须对不同的执行路径进行序列化,从而增加为此 warp 执行的指令总数。

为了在控制流取决于线程 ID 的情况下获得最佳性能,应编写控制条件以最小化发散warp的数量。这是可能的,因为正如 SIMT 架构中提到的那样,整个块的warp分布是确定性的。一个简单的例子是当控制条件仅取决于 (threadIdx / warpSize) 时,warpSize 是warp大小。在这种情况下,由于控制条件与warp完全对齐,因此没有warp发散。

有时,编译器可能会展开循环,或者它可能会通过使用分支预测来优化短 if 或 switch 块,如下所述。在这些情况下,任何warp都不会发散。程序员还可以使用#pragma unroll 指令控制循环展开(参见#pragma unroll)。

当使用分支预测时,其执行取决于控制条件的任何指令都不会被跳过。相反,它们中的每一个都与基于控制条件设置为真或假的每线程条件代码或预测相关联,尽管这些指令中的每一个都被安排执行,但实际上只有具有真预测的指令被执行。带有错误预测的指令不写入结果,也不评估地址或读取操作数。

5.4.3 同步指令

对于计算能力为 3.x 的设备,__syncthreads() 的吞吐量为每个时钟周期 128 次操作,对于计算能力为 6.0 的设备,每个时钟周期为 32 次操作,对于计算能力为 7.x 和 8.x 的设备,每个时钟周期为 16 次操作。 对于计算能力为 5.x、6.1 和 6.2 的设备,每个时钟周期 64 次操作。

请注意,__syncthreads() 可以通过强制多处理器空闲来影响性能,如设备内存访问中所述。

5.5最小化内存抖动

经常不断地分配和释放内存的应用程序可能会发现分配调用往往会随着时间的推移而变慢,直至达到极限。这通常是由于将内存释放回操作系统供其自己使用的性质而预期的。为了在这方面获得最佳性能,我们建议如下:

  • 尝试根据手头的问题调整分配大小。不要尝试使用 cudaMalloc / cudaMallocHost / cuMemCreate 分配所有可用内存,因为这会强制内存立即驻留并阻止其他应用程序能够使用该内存。这会给操作系统调度程序带来更大的压力,或者只是阻止使用相同 GPU 的其他应用程序完全运行。
  • 尝试在应用程序的早期以适当大小分配内存,并且仅在应用程序没有任何用途时分配内存。减少应用程序中的 cudaMalloc+cudaFree 调用次数,尤其是在性能关键区域。
  • 如果应用程序无法分配足够的设备内存,请考虑使用其他内存类型,例如 cudaMallocHostcudaMallocManaged,它们的性能可能不高,但可以使应用程序取得进展。
  • 对于支持该功能的平台,cudaMallocManaged 允许超额订阅,并且启用正确的 cudaMemAdvise 策略,将允许应用程序保留 cudaMalloc 的大部分(如果不是全部)性能。 cudaMallocManaged 也不会强制分配在需要或预取之前驻留,从而减少操作系统调度程序的整体压力并更好地启用多原则用例。

附录A 支持GPU设备列表

https://developer.nvidia.com/cuda-gpus 列出了所有支持 CUDA 的设备及其计算能力。

可以使用运行时查询计算能力、多处理器数量、时钟频率、设备内存总量和其他属性(参见参考手册)。

附录B 对C++扩展的详细描述

B.1 函数执行空间说明符

函数执行空间说明符表示函数是在主机上执行还是在设备上执行,以及它是可从主机调用还是从设备调用。

B.1.1 __global__

__global__ 执行空间说明符将函数声明为内核。 它的功能是:

  • 在设备上执行,
  • 可从主机调用,
  • 可在计算能力为 3.2 或更高的设备调用(有关更多详细信息,请参阅 CUDA 动态并行性)。
    __global__ 函数必须具有 void 返回类型,并且不能是类的成员。

__global__ 函数的任何调用都必须指定其执行配置,如执行配置中所述。

__global__ 函数的调用是异步的,这意味着它在设备完成执行之前返回。

B.1.2 __device__

__device__ 执行空间说明符声明了一个函数:

  • 在设备上执行,
  • 只能从设备调用。
    __global____device__ 执行空间说明符不能一起使用。

B.1.3 __host__

__host__ 执行空间说明符声明了一个函数:

  • 在主机上执行,
  • 只能从主机调用。
    相当于声明一个函数只带有 __host__ 执行空间说明符,或者声明它没有任何 __host__ 、__device____global__ 执行空间说明符; 在任何一种情况下,该函数都仅为主机编译。

__global____host__ 执行空间说明符不能一起使用。

但是, __device____host__ 执行空间说明符可以一起使用,在这种情况下,该函数是为主机和设备编译的。 Application Compatibility 中引入的 __CUDA_ARCH__ 宏可用于区分主机和设备之间的代码路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
__host__ __device__ func()
{
#if __CUDA_ARCH__ >= 800
// Device code path for compute capability 8.x
#elif __CUDA_ARCH__ >= 700
// Device code path for compute capability 7.x
#elif __CUDA_ARCH__ >= 600
// Device code path for compute capability 6.x
#elif __CUDA_ARCH__ >= 500
// Device code path for compute capability 5.x
#elif __CUDA_ARCH__ >= 300
// Device code path for compute capability 3.x
#elif !defined(__CUDA_ARCH__)
// Host code path
#endif
}

B.1.4 Undefined behavior

在以下情况下,“跨执行空间”调用具有未定义的行为:

  • __CUDA_ARCH__ 定义了, 从 __global____device____host__ __device__ 函数到 __host__ 函数的调用。
  • __CUDA_ARCH__ 未定义,从 __host__ 函数内部调用 __device__ 函数。

B.1.5 __noinline__ and __forceinline__

编译器在认为合适时内联任何 __device__ 函数。

__noinline__ 函数限定符可用作提示编译器尽可能不要内联函数。

__forceinline__ 函数限定符可用于强制编译器内联函数。

__noinline____forceinline__ 函数限定符不能一起使用,并且两个函数限定符都不能应用于内联函数。

B.2 Variable Memory Space Specifiers

变量内存空间说明符表示变量在设备上的内存位置。

在设备代码中声明的没有本节中描述的任何 __device____shared____constant__ 内存空间说明符的自动变量通常驻留在寄存器中。 但是,在某些情况下,编译器可能会选择将其放置在本地内存中,这可能会产生不利的性能后果,如设备内存访问中所述。

B.2.1 __device__

__device__ 内存空间说明符声明了一个驻留在设备上的变量。

在接下来的三个部分中定义的其他内存空间说明符中最多有一个可以与 __device__ 一起使用,以进一步表示变量属于哪个内存空间。 如果它们都不存在,则变量:

  • 驻留在全局内存空间中,
  • 具有创建它的 CUDA 上下文的生命周期,
  • 每个设备都有一个不同的对象,
  • 可从网格内的所有线程和主机通过运行时库 (cudaGetSymbolAddress() / cudaGetSymbolSize() / cudaMemcpyToSymbol() / cudaMemcpyFromSymbol()) 访问。

B.2.2. __constant__

__constant__ 内存空间说明符,可选择与 __device__ 一起使用,声明一个变量:

  • 驻留在常量的内存空间中,
  • 具有创建它的 CUDA 上下文的生命周期,
  • 每个设备都有一个不同的对象,
  • 可从网格内的所有线程和主机通过运行时库 (cudaGetSymbolAddress() / cudaGetSymbolSize() / cudaMemcpyToSymbol() / cudaMemcpyFromSymbol()) 访问。

B.2.3 __shared__

__shared__ 内存空间说明符,可选择与 __device__ 一起使用,声明一个变量:

  • 驻留在线程块的共享内存空间中,
  • 具有块的生命周期,
  • 每个块有一个不同的对象,
  • 只能从块内的所有线程访问,
  • 没有固定地址。

将共享内存中的变量声明为外部数组时,例如:

1
extern __shared__ float shared[];

数组的大小在启动时确定(请参阅执行配置)。 以这种方式声明的所有变量都从内存中的相同地址开始,因此必须通过偏移量显式管理数组中变量的布局。 例如,如果想要在动态分配的共享内存中等价于,

1
2
3
short array0[128];
float array1[64];
int array2[256];

可以通过以下方式声明和初始化数组:

1
2
3
4
5
6
7
extern __shared__ float array[];
__device__ void func() // __device__ or __global__ function
{
short* array0 = (short*)array;
float* array1 = (float*)&array0[128];
int* array2 = (int*)&array1[64];
}

请注意,指针需要与它们指向的类型对齐,因此以下代码不起作用,因为 array1 未对齐到 4 个字节。

1
2
3
4
5
6
extern __shared__ float array[];
__device__ void func() // __device__ or __global__ function
{
short* array0 = (short*)array;
float* array1 = (float*)&array0[127];
}

表 4 列出了内置向量类型的对齐要求。

B.2.4. managed

__managed__ 内存空间说明符,可选择与 __device__ 一起使用,声明一个变量:

  • 可以从设备和主机代码中引用,例如,可以获取其地址,也可以直接从设备或主机功能读取或写入。
  • 具有应用程序的生命周期。
    有关更多详细信息,请参阅 __managed__ 内存空间说明符。

B.2.5. restrict

nvcc 通过 __restrict__ 关键字支持受限指针。

C99中引入了受限指针,以缓解存在于c类型语言中的混叠问题,这种问题抑制了从代码重新排序到公共子表达式消除等各种优化。

下面是一个受混叠问题影响的例子,使用受限指针可以帮助编译器减少指令的数量:

1
2
3
4
5
6
7
8
9
10
11
12
void foo(const float* a,
const float* b,
float* c)
{
c[0] = a[0] * b[0];
c[1] = a[0] * b[0];
c[2] = a[0] * b[0] * a[1];
c[3] = a[0] * a[1];
c[4] = a[0] * b[0];
c[5] = b[0];
...
}

此处的效果是减少了内存访问次数和减少了计算次数。 这通过由于“缓存”负载和常见子表达式而增加的寄存器压力来平衡。

由于寄存器压力在许多 CUDA 代码中是一个关键问题,因此由于占用率降低,使用受限指针会对 CUDA 代码产生负面性能影响。

B.3. Built-in Vector Types

B.3.1. char, short, int, long, longlong, float, double

这些是从基本整数和浮点类型派生的向量类型。 它们是结构,第一个、第二个、第三个和第四个组件可以分别通过字段 x、y、z 和 w 访问。 它们都带有 make_<type name> 形式的构造函数; 例如,

1
int2 make_int2(int x, int y);

它创建了一个带有 value(x, y)int2 类型的向量。
向量类型的对齐要求在下表中有详细说明。

Type Alignment
char1, uchar1 1
char2, uchar2 2
char3, uchar3 1
char4, uchar4 4
short1, ushort1 2
short2, ushort2 4
short3, ushort3 2
short4, ushort4 8
int1, uint1 4
int2, uint2 8
int3, uint3 4
int4, uint4 16
long1, ulong1 4 if sizeof(long) is equal to sizeof(int) 8, otherwise
long2, ulong2 8 if sizeof(long) is equal to sizeof(int), 16, otherwise
long3, ulong3 4 if sizeof(long) is equal to sizeof(int), 8, otherwise
long4, ulong4 16
longlong1, ulonglong1 8
longlong2, ulonglong2 16
longlong3, ulonglong3 8
longlong4, ulonglong4 16
float1 4
float2 8
float3 4
float4 16
double1 8
double2 16
double3 8
double4 16

B.3.2. dim3

此类型是基于 uint3 的整数向量类型,用于指定维度。 定义 dim3 类型的变量时,任何未指定的组件都将初始化为 1。

B.4. Built-in Variables

B.4.1. gridDim

该变量的类型为 dim3(请参阅 dim3)并包含网格的尺寸。

B.4.2. blockIdx

该变量是 uint3 类型(请参见 char、short、int、long、longlong、float、double)并包含网格内的块索引。

B.4.3. blockDim

该变量的类型为 dim3(请参阅 dim3)并包含块的尺寸。

B.4.4. threadIdx

此变量是 uint3 类型(请参见 char、short、int、long、longlong、float、double )并包含块内的线程索引。

B.4.5. warpSize

该变量是 int 类型,包含线程中的 warp 大小(有关 warp 的定义,请参见 SIMT Architecture)。

B.5. Memory Fence Functions

CUDA 编程模型假设设备具有弱序内存模型,即 CUDA 线程将数据写入共享内存、全局内存、页面锁定主机内存或对等设备的内存的顺序不一定是 观察到数据被另一个 CUDA 或主机线程写入的顺序。 两个线程在没有同步的情况下读取或写入同一内存位置是未定义的行为。

在以下示例中,thread 1 执行 writeXY(),而thread 2 执行 readXY()。

1
2
3
4
5
6
7
8
9
10
11
12
13
__device__ int X = 1, Y = 2;

__device__ void writeXY()
{
X = 10;
Y = 20;
}

__device__ void readXY()
{
int B = Y;
int A = X;
}

两个线程同时从相同的内存位置 X 和 Y 读取和写入。 任何数据竞争都是未定义的行为,并且没有定义的语义。 A 和 B 的结果值可以是任何值。

内存栅栏函数可用于强制对内存访问进行一些排序。 内存栅栏功能在强制执行排序的范围上有所不同,但它们独立于访问的内存空间(共享内存、全局内存、页面锁定的主机内存和对等设备的内存)。

1
void __threadfence_block();

请确保:

  • 线程在调用 __threadfence_block() 之前对所有内存的所有写入都被线程的块中的所有线程观察到. 这发生在调用线程在调用 __threadfence_block() 之后对内存的所有写入之前;
  • 线程在调用 __threadfence_block() 之前对所有内存进行的所有读取都排在线程在调用 __threadfence_block() 之后对所有内存的所有读取之前。
1
void __threadfence();

充当调用线程块中所有线程的 __threadfence_block() 并且还确保在调用 __threadfence() 之后调用线程对所有内存的写入不会被设备中的任何线程观察到在任何写入之前发生 调用线程在调用 __threadfence() 之前产生的所有内存。 请注意,要使这种排序保证为真,观察线程必须真正观察内存而不是它的缓存版本; 这可以通过使用 volatile 限定符中详述的 volatile 关键字来确保。

1
void __threadfence_system()

充当调用线程块中所有线程的 __threadfence_block(),并确保设备中的所有线程、主机线程和所有线程在调用 __threadfence_system() 之前对调用线程所做的所有内存的所有写入都被观察到 对等设备中的线程在调用 __threadfence_system() 之后调用线程对所有内存的所有写入之前发生。

__threadfence_system() 仅受计算能力 2.x 及更高版本的设备支持。

在前面的代码示例中,我们可以在代码中插入栅栏,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
__device__ int X = 1, Y = 2;

__device__ void writeXY()
{
X = 10;
__threadfence();
Y = 20;
}

__device__ void readXY()
{
int B = Y;
__threadfence();
int A = X;
}

对于此代码,可以观察到以下结果:

  • A 等于 1,B 等于 2,
  • A 等于 10,B 等于 2,
  • A 等于 10,B 等于 20。

第四种结果是不可能的,因为第一次写入必须在第二次写入之前可见。 如果线程 1 和 2 属于同一个块,使用 __threadfence_block() 就足够了。 如果线程 1 和 2 不属于同一个块,如果它们是来自同一设备的 CUDA 线程,则必须使用 __threadfence(),如果它们是来自两个不同设备的 CUDA 线程,则必须使用 __threadfence_system()。

一个常见的用例是当线程消耗由其他线程产生的一些数据时,如以下内核代码示例所示,该内核在一次调用中计算 N 个数字的数组的总和。 每个块首先对数组的一个子集求和,并将结果存储在全局内存中。 当所有块都完成后,最后一个完成的块从全局内存中读取这些部分和中的每一个,并将它们相加以获得最终结果。 为了确定哪个块最后完成,每个块自动递增一个计数器以表示它已完成计算和存储其部分和(请参阅原子函数关于原子函数)。 最后一个块是接收等于 gridDim.x-1 的计数器值的块。 如果在存储部分和和递增计数器之间没有设置栅栏,则计数器可能会在存储部分和之前递增,因此可能会到达 gridDim.x-1 并让最后一个块在实际更新之前在Global Memory中开始读取部分和 。

作者添加: 开发者指南中原文介绍threadfence的时候,比较长比较绕,可能对于新手开发朋友来说比较难理解.作者觉得,可以简单的理解为一种等待行为.让Warp中线程运行到threadfence这里等一下, 不然可能产生上面的还没写完,下面的就开始读的问题. 这种写后读,可能会读到错误的数据.

内存栅栏函数只影响线程内存操作的顺序; 它们不确保这些内存操作对其他线程可见(就像 __syncthreads() 对块内的线程所做的那样(请参阅同步函数))。 在下面的代码示例中,通过将结果变量声明为volatile 来确保对结果变量的内存操作的可见性(请参阅volatile 限定符)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
__device__ unsigned int count = 0;
__shared__ bool isLastBlockDone;
__global__ void sum(const float* array, unsigned int N,
volatile float* result)
{
// Each block sums a subset of the input array.
float partialSum = calculatePartialSum(array, N);

if (threadIdx.x == 0) {

// Thread 0 of each block stores the partial sum
// to global memory. The compiler will use
// a store operation that bypasses the L1 cache
// since the "result" variable is declared as
// volatile. This ensures that the threads of
// the last block will read the correct partial
// sums computed by all other blocks.
result[blockIdx.x] = partialSum;

// Thread 0 makes sure that the incrementation
// of the "count" variable is only performed after
// the partial sum has been written to global memory.
__threadfence();

// Thread 0 signals that it is done.
unsigned int value = atomicInc(&count, gridDim.x);

// Thread 0 determines if its block is the last
// block to be done.
isLastBlockDone = (value == (gridDim.x - 1));
}

// Synchronize to make sure that each thread reads
// the correct value of isLastBlockDone.
__syncthreads();

if (isLastBlockDone) {

// The last block sums the partial sums
// stored in result[0 .. gridDim.x-1]
float totalSum = calculateTotalSum(result);

if (threadIdx.x == 0) {

// Thread 0 of last block stores the total sum
// to global memory and resets the count
// varialble, so that the next kernel call
// works properly.
result[0] = totalSum;
count = 0;
}
}
}

B.6. Synchronization Functions

1
void __syncthreads();

等待直到线程块中的所有线程都达到这一点,并且这些线程在 __syncthreads() 之前进行的所有全局和共享内存访问对块中的所有线程都是可见的。

__syncthreads() 用于协调同一块的线程之间的通信。 当块中的某些线程访问共享或全局内存中的相同地址时,对于其中一些内存访问,可能存在先读后写、先读后写或先写后写的风险。 通过在这些访问之间同步线程可以避免这些数据危害。

__syncthreads() 允许在条件代码中使用,但前提是条件在整个线程块中的计算结果相同,否则代码执行可能会挂起或产生意外的副作用。

计算能力 2.x 及更高版本的设备支持以下描述的三种 __syncthreads() 变体。

int __syncthreads_count(int predicate)与 __syncthreads() 相同,其附加功能是它为块的所有线程评估predicate并返回predicate评估为非零的线程数。

int __syncthreads_and(int predicate) 与 __syncthreads() 相同,其附加功能是它为块的所有线程计算predicate,并且当且仅当predicate对所有线程的计算结果都为非零时才返回非零。

int __syncthreads_or(int predicate) 与 __syncthreads() 相同,其附加功能是它为块的所有线程评估predicate,并且当且仅当predicate对其中任何一个线程评估为非零时才返回非零。

void __syncwarp(unsigned mask=0xffffffff) 将导致正在执行的线程等待,直到 mask 中命名的所有 warp 通道都执行了 __syncwarp()(具有相同的掩码),然后再恢复执行。 掩码中命名的所有未退出线程必须执行具有相同掩码的相应 __syncwarp(),否则结果未定义。

执行 __syncwarp() 保证参与屏障的线程之间的内存排序。 因此,warp 中希望通过内存进行通信的线程可以存储到内存,执行 __syncwarp(),然后安全地读取 warp 中其他线程存储的值。

注意:对于 .target sm_6x 或更低版本,mask 中的所有线程在收敛时必须执行相同的 __syncwarp(),并且 mask 中所有值的并集必须等于活动掩码。 否则,行为未定义。

B.7. Mathematical Functions

参考手册列出了设备代码支持的所有 C/C++ 标准库数学函数和仅设备代码支持的所有内部函数。

数学函数为其中一些函数提供精度信息。

B.8. Texture Functions

纹理对象在 Texture Object API 中描述

纹理引用在 [[[DEPRECATED]] 纹理引用 API](https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#texture-reference-api) 中描述

纹理提取在纹理提取中进行了描述。

B.8.1. Texture Object API

B.8.1.1. tex1Dfetch()

1
2
template<class T>
T tex1Dfetch(cudaTextureObject_t texObj, int x);

从使用整数纹理坐标 x 的一维纹理对象 texObj 指定的线性内存区域中获取。 tex1Dfetch() 仅适用于非归一化坐标,因此仅支持边界和钳位寻址模式。 它不执行任何纹理过滤。 对于整数类型,它可以选择将整数提升为单精度浮点数。

B.8.1.2。 tex1D()

1
2
template<class T>
T tex1D(cudaTextureObject_t texObj, float x);

从使用纹理坐标 x 的一维纹理对象 texObj 指定的 CUDA 数组中获取。

B.8.1.3。 tex1DLod()

1
2
template<class T>
T tex1DLod(cudaTextureObject_t texObj, float x, float level);

使用细节级别的纹理坐标 x 从一维纹理对象 texObj 指定的 CUDA 数组中获取。

B.8.1.4。 tex1DGrad()

1
2
template<class T>
T tex1DGrad(cudaTextureObject_t texObj, float x, float dx, float dy);

从使用纹理坐标 x 的一维纹理对象 texObj 指定的 CUDA 数组中获取。细节层次来源于 X 梯度 dx 和 Y 梯度 dy。

B.8.1.5。 tex2D()

1
2
template<class T>
T tex2D(cudaTextureObject_t texObj, 浮点 x, 浮点 y);

从 CUDA 数组或由二维纹理对象 texObj 使用纹理坐标 (x,y) 指定的线性内存区域获取。

B.8.1.6。 tex2DLod()

1
2
template<class T>
tex2DLod(cudaTextureObject_t texObj, float x, float y, float level);

从 CUDA 数组或二维纹理对象 texObj 指定的线性内存区域中获取,使用细节级别的纹理坐标 (x,y)。

B.8.1.7。 tex2DGrad()

1
2
3
template<class T>
T tex2DGrad(cudaTextureObject_t texObj, float x, float y,
float2 dx,float2 dy);

使用纹理坐标 (x,y) 从二维纹理对象 texObj 指定的 CUDA 数组中获取。细节层次来源于 dx 和 dy 梯度。

B.8.1.8。 tex3D()

1
2
template<class T>
T tex3D(cudaTextureObject_t texObj, float x, float y, float z);

使用纹理坐标 (x,y,z) 从三维纹理对象 texObj 指定的 CUDA 数组中获取。

B.8.1.9。 tex3DLod()

1
2
template<class T>
T tex3DLod(cudaTextureObject_t texObj, float x, float y, float z, float level);

使用细节级别的纹理坐标 (x,y,z) 从 CUDA 数组或由三维纹理对象 texObj 指定的线性内存区域获取。

B.8.1.10。 tex3DGrad()

1
2
3
template<class T>
T tex3DGrad(cudaTextureObject_t texObj, float x, float y, float z,
float4 dx,float4 dy);

从由三维纹理对象 texObj 指定的 CUDA 数组中获取,使用纹理坐标 (x,y,z) 在从 XY 梯度 dxdy 派生的细节级别。

B.8.1.11。 tex1DLlayered()

1
2
template<class T>
T tex1DLayered(cudaTextureObject_t texObj, float x, int layer);

使用纹理坐标 x 和索layer从一维纹理对象 texObj 指定的 CUDA 数组中获取,如分层纹理中所述

B.8.1.12。 tex1DLlayeredLod()

1
2
template<class T>
T tex1DLayeredLod(cudaTextureObject_t texObj, float x, int layer, float level);

从使用纹理坐标 x 和细节级别级别的图层 layer 的一维分层纹理指定的 CUDA 数组中获取。

B.8.1.13。 tex1DLlayeredGrad()

1
2
3
template<class T>
T tex1DLayeredGrad(cudaTextureObject_t texObj, float x, int layer,
float dx, float dy);

使用纹理坐标 x 和从 dxdy 梯度派生的细节层次从 layer 的一维分层纹理指定的 CUDA 数组中获取。

B.8.1.14。 tex2DLlayered()

1
2
3
template<class T>
T tex2DLayered(cudaTextureObject_t texObj,
float float y,int layer);

使用纹理坐标 (x,y) 和索引层从二维纹理对象 texObj 指定的 CUDA 数组中获取,如分层纹理中所述。

B.8.1.15。 tex2DLlayeredLod()

1
2
3
template<class T>
T tex2DLayeredLod(cudaTextureObject_t texObj, float x, float y, int layer,
float level);

使用纹理坐标 (x,y)layer 的二维分层纹理指定的 CUDA 数组中获取。

B.8.1.16。 tex2DLlayeredGrad()

1
2
3
template<class T>
T tex2DLayeredGrad(cudaTextureObject_t texObj, float x, float y, int layer,
float2 dx,float2 dy);

使用纹理坐标 (x,y) 和从 dxdy 梯度派生的细节层次从 layer 的二维分层纹理指定的 CUDA 数组中获取。

B.8.1.17。 texCubemap()

1
2
template<class T>
T texCubemap(cudaTextureObject_t texObj, float x, float y, float z);

使用纹理坐标 (x,y,z) 获取由立方体纹理对象 texObj 指定的 CUDA 数组,如立方体纹理中所述。

B.8.1.18。 texCubemapLod()

1
2
3
template<class T>
T texCubemapLod(cudaTextureObject_t texObj, float x, float, y, float z,
float level);

使用立方体纹理中描述的纹理坐标 (x,y,z) 从立方体纹理对象 texObj 指定的 CUDA 数组中获取。使用的详细级别由level给出。

B.8.1.19。 texCubemapLayered()

1
2
3
template<class T>
T texCubemapLayered(cudaTextureObject_t texObj,
float x,float y,float z,int layer);

使用纹理坐标 (x,y,z) 和索引层从立方体分层纹理对象 texObj 指定的 CUDA 数组中获取,如立方体分层纹理中所述。

B.8.1.20。 texCubemapLayeredLod()

1
2
3
template<class T>
T texCubemapLayeredLod(cudaTextureObject_t texObj, float x, float y, float z,
int layer,float level);

使用纹理坐标 (x,y,z) 和索引层从立方体分层纹理对象 texObj 指定的 CUDA 数组中获取,如立方体分层纹理中所述,在细节级别级别。

B.8.1.21。 tex2Dgather()

1
2
3
template<class T>
T tex2Dgather(cudaTextureObject_t texObj,
float x,float y,int comp = 0);

从 2D 纹理对象 texObj 指定的 CUDA 数组中获取,使用纹理坐标 x 和 y 以及纹理采集中描述的 comp 参数。

B.8.2. Texture Reference API

B.8.2.1. tex1Dfetch()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
template<class DataType>
Type tex1Dfetch(
texture<DataType, cudaTextureType1D,
cudaReadModeElementType> texRef,
int x);

float tex1Dfetch(
texture<unsigned char, cudaTextureType1D,
cudaReadModeNormalizedFloat> texRef,
int x);

float tex1Dfetch(
texture<signed char, cudaTextureType1D,
cudaReadModeNormalizedFloat> texRef,
int x);

float tex1Dfetch(
texture<unsigned short, cudaTextureType1D,
cudaReadModeNormalizedFloat> texRef,
int x);

float tex1Dfetch(
texture<signed short, cudaTextureType1D,
cudaReadModeNormalizedFloat> texRef,
int x);

使用整数纹理坐标 x 从绑定到一维纹理引用 texRef 的线性内存区域中获取。 tex1Dfetch() 仅适用于非归一化坐标,因此仅支持边界和钳位寻址模式。 它不执行任何纹理过滤。 对于整数类型,它可以选择将整数提升为单精度浮点数。

除了上面显示的功能外,还支持 2 元组和 4 元组; 例如:

1
2
3
4
float4 tex1Dfetch(
texture<uchar4, cudaTextureType1D,
cudaReadModeNormalizedFloat> texRef,
int x);

B.9. Surface Functions

Surface 函数仅受计算能力 2.0 及更高版本的设备支持。

Surface 对象在 Surface Object API 中描述

Surface引用在Surface引用 API 中描述。

在下面的部分中,boundaryMode 指定了边界模式,即如何处理超出范围的表面坐标; 它等于 cudaBoundaryModeClamp,在这种情况下,超出范围的坐标被限制到有效范围,或 cudaBoundaryModeZero,在这种情况下,超出范围的读取返回零并且忽略超出范围的写入,或者 cudaBoundaryModeTrap, 在这种情况下,超出范围的访问会导致内核执行失败。

B.9.1. Surface Object API

B.9.1.1. surf1Dread()

1
2
3
template<class T>
T surf1Dread(cudaSurfaceObject_t surfObj, int x,
boundaryMode = cudaBoundaryModeTrap);

使用坐标 x 读取由一维surface对象 surfObj 指定的 CUDA 数组。

B.9.1.2. surf1Dwrite

1
2
3
4
5
template<class T>
void surf1Dwrite(T data,
cudaSurfaceObject_t surfObj,
int x,
boundaryMode = cudaBoundaryModeTrap);

将数据写入由坐标 x 处的一维surface对象 surfObj 指定的 CUDA 数组。

B.9.1.3. surf2Dread()

1
2
3
4
5
6
7
8
9
template<class T>
T surf2Dread(cudaSurfaceObject_t surfObj,
int x, int y,
boundaryMode = cudaBoundaryModeTrap);
template<class T>
void surf2Dread(T* data,
cudaSurfaceObject_t surfObj,
int x, int y,
boundaryMode = cudaBoundaryModeTrap);

使用坐标 x 和 y 读取二维surface对象 surfObj 指定的 CUDA 数组。

B.9.1.4 surf2Dwrite()

1
2
3
4
5
template<class T>
void surf2Dwrite(T data,
cudaSurfaceObject_t surfObj,
int x, int y,
boundaryMode = cudaBoundaryModeTrap);

将值数据写入由坐标 x 和 y 处的二维surface对象 surfObj 指定的 CUDA 数组。

B.9.1.5. surf3Dread()

1
2
3
4
5
6
7
8
9
template<class T>
T surf3Dread(cudaSurfaceObject_t surfObj,
int x, int y, int z,
boundaryMode = cudaBoundaryModeTrap);
template<class T>
void surf3Dread(T* data,
cudaSurfaceObject_t surfObj,
int x, int y, int z,
boundaryMode = cudaBoundaryModeTrap);

使用坐标 x、y 和 z 读取由三维surface对象 surfObj 指定的 CUDA 数组。

B.9.1.6. surf3Dwrite()

1
2
3
4
5
template<class T>
void surf3Dwrite(T data,
cudaSurfaceObject_t surfObj,
int x, int y, int z,
boundaryMode = cudaBoundaryModeTrap);

将值数据写入由坐标 x、y 和 z 处的三维surface对象 surfObj 指定的 CUDA 数组。

B.9.1.7. surf1DLayeredread()

1
2
3
4
5
6
7
8
9
10
template<class T>
T surf1DLayeredread(
cudaSurfaceObject_t surfObj,
int x, int layer,
boundaryMode = cudaBoundaryModeTrap);
template<class T>
void surf1DLayeredread(T data,
cudaSurfaceObject_t surfObj,
int x, int layer,
boundaryMode = cudaBoundaryModeTrap);

使用坐标 x 和索引层读取一维分层surface对象 surfObj 指定的 CUDA 数组。

B.9.1.8. surf1DLayeredwrite()

1
2
3
4
5
template<class Type>
void surf1DLayeredwrite(T data,
cudaSurfaceObject_t surfObj,
int x, int layer,
boundaryMode = cudaBoundaryModeTrap);

将值数据写入坐标 x 和索引层的二维分层surface对象 surfObj 指定的 CUDA 数组。

B.9.1.9. surf2DLayeredread()

1
2
3
4
5
6
7
8
9
10
template<class T>
T surf2DLayeredread(
cudaSurfaceObject_t surfObj,
int x, int y, int layer,
boundaryMode = cudaBoundaryModeTrap);
template<class T>
void surf2DLayeredread(T data,
cudaSurfaceObject_t surfObj,
int x, int y, int layer,
boundaryMode = cudaBoundaryModeTrap);

使用坐标 x 和 y 以及索引层读取二维分层surface对象 surfObj 指定的 CUDA 数组。

B.9.1.10. surf2DLayeredwrite()

1
2
3
4
5
template<class T>
void surf2DLayeredwrite(T data,
cudaSurfaceObject_t surfObj,
int x, int y, int layer,
boundaryMode = cudaBoundaryModeTrap);

将数据写入由坐标 x 和 y 处的一维分层surface对象 surfObj 和索引层指定的 CUDA 数组。

B.9.1.11. surfCubemapread()

1
2
3
4
5
6
7
8
9
10
template<class T>
T surfCubemapread(
cudaSurfaceObject_t surfObj,
int x, int y, int face,
boundaryMode = cudaBoundaryModeTrap);
template<class T>
void surfCubemapread(T data,
cudaSurfaceObject_t surfObj,
int x, int y, int face,
boundaryMode = cudaBoundaryModeTrap);

使用坐标 x 和 y 以及面索引 face 读取立方体surface对象 surfObj 指定的 CUDA 数组。

B.9.1.12. surfCubemapwrite()

1
2
3
4
5
template<class T>
void surfCubemapwrite(T data,
cudaSurfaceObject_t surfObj,
int x, int y, int face,
boundaryMode = cudaBoundaryModeTrap);

将数据写入由立方体对象 surfObj 在坐标 x 和 y 以及面索引 face 处指定的 CUDA 数组。

B.9.1.13. surfCubemapLayeredread()

1
2
3
4
5
6
7
8
9
10
template<class T>
T surfCubemapLayeredread(
cudaSurfaceObject_t surfObj,
int x, int y, int layerFace,
boundaryMode = cudaBoundaryModeTrap);
template<class T>
void surfCubemapLayeredread(T data,
cudaSurfaceObject_t surfObj,
int x, int y, int layerFace,
boundaryMode = cudaBoundaryModeTrap);

使用坐标 x 和 y 以及索引 layerFace 读取由立方体分层surface对象 surfObj 指定的 CUDA 数组。

B.9.1.14. surfCubemapLayeredwrite()

1
2
3
4
5
template<class T>
void surfCubemapLayeredwrite(T data,
cudaSurfaceObject_t surfObj,
int x, int y, int layerFace,
boundaryMode = cudaBoundaryModeTrap);

将数据写入由立方体分层对象 surfObj 在坐标 x 和 y 以及索引 layerFace 指定的 CUDA 数组。

B.9.2. Surface Reference API

B.9.2.1. surf1Dread()

1
2
3
4
5
6
7
8
9
template<class Type>
Type surf1Dread(surface<void, cudaSurfaceType1D> surfRef,
int x,
boundaryMode = cudaBoundaryModeTrap);
template<class Type>
void surf1Dread(Type data,
surface<void, cudaSurfaceType1D> surfRef,
int x,
boundaryMode = cudaBoundaryModeTrap);

使用坐标 x 读取绑定到一维surface引用 surfRef 的 CUDA 数组。

B.9.2.2. surf1Dwrite

1
2
3
4
5
template<class Type>
void surf1Dwrite(Type data,
surface<void, cudaSurfaceType1D> surfRef,
int x,
boundaryMode = cudaBoundaryModeTrap);

B.9.2.3. surf2Dread()

1
2
3
4
5
6
7
8
9
template<class Type>
Type surf2Dread(surface<void, cudaSurfaceType2D> surfRef,
int x, int y,
boundaryMode = cudaBoundaryModeTrap);
template<class Type>
void surf2Dread(Type* data,
surface<void, cudaSurfaceType2D> surfRef,
int x, int y,
boundaryMode = cudaBoundaryModeTrap);

使用坐标 x 和 y 读取绑定到二维surface引用 surfRef 的 CUDA 数组。

B.9.2.4. surf2Dwrite()

1
2
3
4
5
template<class Type>
void surf3Dwrite(Type data,
surface<void, cudaSurfaceType3D> surfRef,
int x, int y, int z,
boundaryMode = cudaBoundaryModeTrap);

将值数据写入绑定到坐标 x 和 y 处的二维surface引用 surfRef 的 CUDA 数组。

B.9.2.5. surf3Dread()

1
2
3
4
5
6
7
8
9
template<class Type>
Type surf3Dread(surface<void, cudaSurfaceType3D> surfRef,
int x, int y, int z,
boundaryMode = cudaBoundaryModeTrap);
template<class Type>
void surf3Dread(Type* data,
surface<void, cudaSurfaceType3D> surfRef,
int x, int y, int z,
boundaryMode = cudaBoundaryModeTrap);

使用坐标 x、y 和 z 读取绑定到三维surface引用 surfRef 的 CUDA 数组。

B.9.2.6. surf3Dwrite()

1
2
3
4
5
template<class Type>
void surf3Dwrite(Type data,
surface<void, cudaSurfaceType3D> surfRef,
int x, int y, int z,
boundaryMode = cudaBoundaryModeTrap);

将数据写入绑定到坐标 x、y 和 z 处的surface引用 surfRef 的 CUDA 数组。

B.9.2.7. surf1DLayeredread()

1
2
3
4
5
6
7
8
9
10
template<class Type>
Type surf1DLayeredread(
surface<void, cudaSurfaceType1DLayered> surfRef,
int x, int layer,
boundaryMode = cudaBoundaryModeTrap);
template<class Type>
void surf1DLayeredread(Type data,
surface<void, cudaSurfaceType1DLayered> surfRef,
int x, int layer,
boundaryMode = cudaBoundaryModeTrap);

使用坐标 x 和索引层读取绑定到一维分层surface引用 surfRef 的 CUDA 数组。

B.9.2.8. surf1DLayeredwrite()

1
2
3
4
5
template<class Type>
void surf1DLayeredwrite(Type data,
surface<void, cudaSurfaceType1DLayered> surfRef,
int x, int layer,
boundaryMode = cudaBoundaryModeTrap);

将数据写入绑定到坐标 x 和索引层的二维分层surface引用 surfRef 的 CUDA 数组。

B.9.2.9. surf2DLayeredread()

1
2
3
4
5
6
7
8
9
10
template<class Type>
Type surf2DLayeredread(
surface<void, cudaSurfaceType2DLayered> surfRef,
int x, int y, int layer,
boundaryMode = cudaBoundaryModeTrap);
template<class Type>
void surf2DLayeredread(Type data,
surface<void, cudaSurfaceType2DLayered> surfRef,
int x, int y, int layer,
boundaryMode = cudaBoundaryModeTrap);

使用坐标 x 和 y 以及索引层读取绑定到二维分层surface引用 surfRef 的 CUDA 数组。

B.9.2.10. surf2DLayeredwrite()

1
2
3
4
5
template<class Type>
void surf2DLayeredwrite(Type data,
surface<void, cudaSurfaceType2DLayered> surfRef,
int x, int y, int layer,
boundaryMode = cudaBoundaryModeTrap);

将数据写入绑定到坐标 x 和 y 处的一维分层surface引用 surfRef 和索引层的 CUDA 数组。

B.9.2.11. surfCubemapread()

1
2
3
4
5
6
7
8
9
10
template<class Type>
Type surfCubemapread(
surface<void, cudaSurfaceTypeCubemap> surfRef,
int x, int y, int face,
boundaryMode = cudaBoundaryModeTrap);
template<class Type>
void surfCubemapread(Type data,
surface<void, cudaSurfaceTypeCubemap> surfRef,
int x, int y, int face,
boundaryMode = cudaBoundaryModeTrap);

使用坐标 x 和 y 以及面索引 face 读取绑定到立方体surface引用 surfRef 的 CUDA 数组。

B.9.2.12. surfCubemapwrite()

1
2
3
4
5
template<class Type>
void surfCubemapwrite(Type data,
surface<void, cudaSurfaceTypeCubemap> surfRef,
int x, int y, int face,
boundaryMode = cudaBoundaryModeTrap);

将数据写入绑定到位于坐标 x , y 和面索引 face 处的立方体引用 surfRef 的 CUDA 数组。

B.9.2.13. surfCubemapLayeredread()

1
2
3
4
5
6
7
8
9
10
template<class Type>
Type surfCubemapLayeredread(
surface<void, cudaSurfaceTypeCubemapLayered> surfRef,
int x, int y, int layerFace,
boundaryMode = cudaBoundaryModeTrap);
template<class Type>
void surfCubemapLayeredread(Type data,
surface<void, cudaSurfaceTypeCubemapLayered> surfRef,
int x, int y, int layerFace,
boundaryMode = cudaBoundaryModeTrap);

使用坐标 x 和 y 以及索引 layerFace 读取绑定到立方体分层surface引用 surfRef 的 CUDA 数组。

B.9.2.14. surfCubemapLayeredwrite()

1
2
3
4
5
template<class Type>
void surfCubemapLayeredwrite(Type data,
surface<void, cudaSurfaceTypeCubemapLayered> surfRef,
int x, int y, int layerFace,
boundaryMode = cudaBoundaryModeTrap);

将数据写入绑定到位于坐标 x , y 和索引 layerFace处的立方体分层引用 surfRef 的 CUDA 数组。

B.10. Read-Only Data Cache Load Function

只读数据缓存加载功能仅支持计算能力3.5及以上的设备。

1
T __ldg(const T* address);

返回位于地址address的 T 类型数据,其中 T 为 char、signed char、short、int、long、long longunsigned char、unsigned short、unsigned int、unsigned long、unsigned long long、char2、char4、short2、short4、 int2、int4、longlong2uchar2、uchar4、ushort2、ushort4、uint2、uint4、ulonglong2float、float2、float4、doubledouble2. 包含 cuda_fp16.h 头文件,T 可以是 __half__half2。 同样,包含 cuda_bf16.h 头文件后,T 也可以是 __nv_bfloat16__nv_bfloat162。 该操作缓存在只读数据缓存中(请参阅全局内存)。

B.11. Load Functions Using Cache Hints

这些加载功能仅受计算能力 3.5 及更高版本的设备支持。

1
2
3
4
5
T __ldcg(const T* address);
T __ldca(const T* address);
T __ldcs(const T* address);
T __ldlu(const T* address);
T __ldcv(const T* address);

返回位于地址address的 T 类型数据,其中 T 为 char、signed char、short、int、long、long longunsigned char、unsigned short、unsigned int、unsigned long、unsigned long long、char2、char4、short2、short4、 int2、int4、longlong2uchar2、uchar4、ushort2、ushort4、uint2、uint4、ulonglong2float、float2、float4、double 或 double2。 包含 cuda_fp16.h 头文件,T 可以是 __half__half2。 同样,包含 cuda_bf16.h 头文件后,T 也可以是 __nv_bfloat16__nv_bfloat162。 该操作正在使用相应的缓存运算符(请参阅 PTX ISA

B.12. Store Functions Using Cache Hints

这些存储功能仅受计算能力 3.5 及更高版本的设备支持。

1
2
3
4
void __stwb(T* address, T value);
void __stcg(T* address, T value);
void __stcs(T* address, T value);
void __stwt(T* address, T value);

将类型 T 的value参数存储到地址 address 的位置,其中 T 是 char、signed char、short、int、long、long longunsigned char、unsigned short、unsigned int、unsigned long、unsigned long long、char2、char4、short2 、short4、int2、int4、longlong2uchar2、uchar4、ushort2、ushort4、uint2、uint4、ulonglong2float、float2、float4、double 或 double2。 包含 cuda_fp16.h 头文件,T 可以是 __half__half2。 同样,包含 cuda_bf16.h 头文件后,T 也可以是 __nv_bfloat16__nv_bfloat162。 该操作正在使用相应的缓存运算符(请参阅 PTX ISA

B.13. Time Function

1
2
clock_t clock();
long long int clock64();

在设备代码中执行时,返回每个时钟周期递增的每个多处理器计数器的值。 在内核开始和结束时对该计数器进行采样,获取两个样本的差异,并记录每个线程的结果,为每个线程提供设备完全执行线程所花费的时钟周期数的度量, 但不是设备实际执行线程指令所花费的时钟周期数。 前一个数字大于后者,因为线程是时间切片的。

B.14. Atomic Functions

原子函数对驻留在全局或共享内存中的一个 32 位或 64 位字执行读-修改-写原子操作。 例如,atomicAdd() 在全局或共享内存中的某个地址读取一个字,向其中加一个数字,然后将结果写回同一地址。 该操作是原子的,因为它保证在不受其他线程干扰的情况下执行。 换句话说,在操作完成之前,没有其他线程可以访问该地址。 原子函数不充当内存栅栏,也不意味着内存操作的同步或排序约束(有关内存栅栏的更多详细信息,请参阅内存栅栏函数)。 原子函数只能在设备函数中使用。

原子函数仅相对于特定集合的线程执行的其他操作是原子的:

  • 系统范围的原子:当前程序中所有线程的原子操作,包括系统中的其他 CPU 和 GPU。 这些以 _system 为后缀,例如 atomicAdd_system
  • 设备范围的原子:当前程序中所有 CUDA 线程的原子操作,在与当前线程相同的计算设备中执行。 这些没有后缀,只是以操作命名,例如 atomicAdd
  • Block-wide atomics:当前程序中所有 CUDA 线程的原子操作,在与当前线程相同的线程块中执行。 这些以 _block 为后缀,例如 atomicAdd_block

在以下示例中,CPU 和 GPU 都以原子方式更新地址 addr 处的整数值:

1
2
3
4
5
6
7
8
9
10
11
12
__global__ void mykernel(int *addr) {
atomicAdd_system(addr, 10); // only available on devices with compute capability 6.x
}

void foo() {
int *addr;
cudaMallocManaged(&addr, 4);
*addr = 0;

mykernel<<<...>>>(addr);
__sync_fetch_and_add(addr, 10); // CPU atomic operation
}

请注意,任何原子操作都可以基于 atomicCAS()(Compare And Swap)来实现。 例如,用于双精度浮点数的 atomicAdd() 在计算能力低于 6.0 的设备上不可用,但可以按如下方式实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#if __CUDA_ARCH__ < 600
__device__ double atomicAdd(double* address, double val)
{
unsigned long long int* address_as_ull =
(unsigned long long int*)address;
unsigned long long int old = *address_as_ull, assumed;

do {
assumed = old;
old = atomicCAS(address_as_ull, assumed,
__double_as_longlong(val +
__longlong_as_double(assumed)));

// Note: uses integer comparison to avoid hang in case of NaN (since NaN != NaN)
} while (assumed != old);

return __longlong_as_double(old);
}
#endif

以下设备范围的原子 API 有系统范围和块范围的变体,但以下情况除外:

  • 计算能力低于 6.0 的设备只支持设备范围的原子操作,
  • 计算能力低于 7.2 的 Tegra 设备不支持系统范围的原子操作。

B.14.1. Arithmetic Functions

B.14.1.1. atomicAdd()

1
2
3
4
5
6
7
8
9
10
11
int atomicAdd(int* address, int val);
unsigned int atomicAdd(unsigned int* address,
unsigned int val);
unsigned long long int atomicAdd(unsigned long long int* address,
unsigned long long int val);
float atomicAdd(float* address, float val);
double atomicAdd(double* address, double val);
__half2 atomicAdd(__half2 *address, __half2 val);
__half atomicAdd(__half *address, __half val);
__nv_bfloat162 atomicAdd(__nv_bfloat162 *address, __nv_bfloat162 val);
__nv_bfloat16 atomicAdd(__nv_bfloat16 *address, __nv_bfloat16 val);

读取位于全局或共享内存中地址 address 的 16 位、32 位或 64 位字 old,计算 (old + val),并将结果存储回同一地址的内存中。这三个操作在一个原子事务中执行。该函数返回old

atomicAdd() 的 32 位浮点版本仅受计算能力 2.x 及更高版本的设备支持。

atomicAdd() 的 64 位浮点版本仅受计算能力 6.x 及更高版本的设备支持。

atomicAdd() 的 32 位 __half2 浮点版本仅受计算能力 6.x 及更高版本的设备支持。 __half2__nv_bfloat162 加法操作的原子性分别保证两个 __half__nv_bfloat16 元素中的每一个;不保证整个 __half2__nv_bfloat162 作为单个 32 位访问是原子的。

atomicAdd() 的 16 位 __half 浮点版本仅受计算能力 7.x 及更高版本的设备支持。

atomicAdd() 的 16 位 __nv_bfloat16 浮点版本仅受计算能力 8.x 及更高版本的设备支持。

B.14.1.2. atomicSub()

1
2
3
int atomicSub(int* address, int val);
unsigned int atomicSub(unsigned int* address,
unsigned int val);

读取位于全局或共享内存中地址address的 32 位字 old,计算 (old - val),并将结果存储回同一地址的内存中。 这三个操作在一个原子事务中执行。 该函数返回old

B.14.1.3. atomicExch()

1
2
3
4
5
6
int atomicExch(int* address, int val);
unsigned int atomicExch(unsigned int* address,
unsigned int val);
unsigned long long int atomicExch(unsigned long long int* address,
unsigned long long int val);
float atomicExch(float* address, float val);

读取位于全局或共享内存中地址address的 32 位或 64 位字 old 并将 val 存储回同一地址的内存中。 这两个操作在一个原子事务中执行。 该函数返回old

B.14.1.4. atomicMin()

1
2
3
4
5
6
7
int atomicMin(int* address, int val);
unsigned int atomicMin(unsigned int* address,
unsigned int val);
unsigned long long int atomicMin(unsigned long long int* address,
unsigned long long int val);
long long int atomicMin(long long int* address,
long long int val);

读取位于全局或共享内存中地址address的 32 位或 64 位字 old,计算 oldval 的最小值,并将结果存储回同一地址的内存中。 这三个操作在一个原子事务中执行。 该函数返回old

atomicMin() 的 64 位版本仅受计算能力 3.5 及更高版本的设备支持。

B.14.1.5. atomicMax()

1
2
3
4
5
6
7
int atomicMax(int* address, int val);
unsigned int atomicMax(unsigned int* address,
unsigned int val);
unsigned long long int atomicMax(unsigned long long int* address,
unsigned long long int val);
long long int atomicMax(long long int* address,
long long int val);

读取位于全局或共享内存中地址address的 32 位或 64 位字 old,计算 oldval 的最大值,并将结果存储回同一地址的内存中。 这三个操作在一个原子事务中执行。 该函数返回old

atomicMax() 的 64 位版本仅受计算能力 3.5 及更高版本的设备支持。

B.14.1.6. atomicInc()

1
2
unsigned int atomicInc(unsigned int* address,
unsigned int val);

读取位于全局或共享内存中地址address的 32 位字 old,计算 ((old >= val) ? 0 : (old+1)),并将结果存储回同一地址的内存中。 这三个操作在一个原子事务中执行。 该函数返回old

B.14.1.7. atomicDec()

1
2
unsigned int atomicDec(unsigned int* address,
unsigned int val);

读取位于全局或共享内存中地址address的 32 位字 old,计算 (((old == 0) || (old > val)) ? val : (old-1) ),并将结果存储回同一个地址的内存。 这三个操作在一个原子事务中执行。 该函数返回old

B.14.1.8. atomicCAS()

1
2
3
4
5
6
7
8
9
10
int atomicCAS(int* address, int compare, int val);
unsigned int atomicCAS(unsigned int* address,
unsigned int compare,
unsigned int val);
unsigned long long int atomicCAS(unsigned long long int* address,
unsigned long long int compare,
unsigned long long int val);
unsigned short int atomicCAS(unsigned short int *address,
unsigned short int compare,
unsigned short int val);

读取位于全局或共享内存中地址address的 16 位、32 位或 64 位字 old,计算 (old == compare ? val : old) ,并将结果存储回同一地址的内存中。 这三个操作在一个原子事务中执行。 该函数返回old(Compare And Swap)。

B.14.2. Bitwise Functions

B.14.2.1. atomicAnd()

1
2
3
4
5
int atomicAnd(int* address, int val);
unsigned int atomicAnd(unsigned int* address,
unsigned int val);
unsigned long long int atomicAnd(unsigned long long int* address,
unsigned long long int val);

读取位于全局或共享内存中地址address的 32 位或 64 位字 old,计算 (old & val),并将结果存储回同一地址的内存中。 这三个操作在一个原子事务中执行。 该函数返回old

atomicAnd() 的 64 位版本仅受计算能力 3.5 及更高版本的设备支持。

B.14.2.2. atomicOr()

1
2
3
4
5
int atomicOr(int* address, int val);
unsigned int atomicOr(unsigned int* address,
unsigned int val);
unsigned long long int atomicOr(unsigned long long int* address,
unsigned long long int val);

读取位于全局或共享内存中地址address的 32 位或 64 位字 old,计算 (old | val),并将结果存储回同一地址的内存中。 这三个操作在一个原子事务中执行。 该函数返回old

atomicOr() 的 64 位版本仅受计算能力 3.5 及更高版本的设备支持。

B.14.2.3. atomicXor()

1
2
3
4
5
int atomicXor(int* address, int val);
unsigned int atomicXor(unsigned int* address,
unsigned int val);
unsigned long long int atomicXor(unsigned long long int* address,
unsigned long long int val);

读取位于全局或共享内存中地址address的 32 位或 64 位字 old,计算 (old ^ val),并将结果存储回同一地址的内存中。 这三个操作在一个原子事务中执行。 该函数返回old

atomicXor() 的 64 位版本仅受计算能力 3.5 及更高版本的设备支持。

B.15. Address Space Predicate Functions

如果参数是空指针,则本节中描述的函数具有未指定的行为。

B.15.1. __isGlobal()

1
__device__ unsigned int __isGlobal(const void *ptr);

如果 ptr 包含全局内存空间中对象的通用地址,则返回 1,否则返回 0。

B.15.2. __isShared()

1
__device__ unsigned int __isShared(const void *ptr);

如果 ptr 包含共享内存空间中对象的通用地址,则返回 1,否则返回 0。

B.15.3. __isConstant()

1
__device__ unsigned int __isConstant(const void *ptr);

如果 ptr 包含常量内存空间中对象的通用地址,则返回 1,否则返回 0。

B.15.4. __isLocal()

1
__device__ unsigned int __isLocal(const void *ptr);

如果 ptr 包含本地内存空间中对象的通用地址,则返回 1,否则返回 0。

B.16. Address Space Conversion Functions

B.16.1. __cvta_generic_to_global()

1
__device__ size_t __cvta_generic_to_global(const void *ptr);

返回对 ptr 表示的通用地址执行 PTX cvta.to.global 指令的结果。

B.16.2. __cvta_generic_to_shared()

1
__device__ size_t __cvta_generic_to_shared(const void *ptr);

返回对 ptr 表示的通用地址执行 PTX cvta.to.shared 指令的结果。

B.16.3. __cvta_generic_to_constant()

1
__device__ size_t __cvta_generic_to_constant(const void *ptr);

返回对 ptr 表示的通用地址执行 PTX cvta.to.const 指令的结果。

B.16.4. __cvta_generic_to_local()

1
__device__ void * __cvta_global_to_generic(size_t rawbits);

返回通过对 rawbits 提供的值执行 PTX cvta.to.local 指令获得的通用指针。

B.16.5. __cvta_global_to_generic()

1
__device__ void * __cvta_global_to_generic(size_t rawbits);

返回通过对 rawbits 提供的值执行 PTX cvta.global 指令获得的通用指针。

B.16.6. __cvta_shared_to_generic()

1
__device__ void * __cvta_shared_to_generic(size_t rawbits);

返回通过对 rawbits 提供的值执行 PTX cvta.shared 指令获得的通用指针。

B.16.7. __cvta_constant_to_generic()

1
__device__ void * __cvta_constant_to_generic(size_t rawbits);

返回通过对 rawbits 提供的值执行 PTX cvta.const 指令获得的通用指针。

B.16.8. __cvta_local_to_generic()

1
__device__ void * __cvta_local_to_generic(size_t rawbits);

返回通过对 rawbits 提供的值执行 PTX cvta.local 指令获得的通用指针。

B.17. Alloca Function

B.17.1. Synopsis

1
__host__ __device__ void * alloca(size_t size);

B.17.2. Description

alloca() 函数在调用者的堆栈帧(stack frame)中分配 size 个字节的内存。 返回值是一个指向分配内存的指针,当从设备代码调用函数时,内存的开头是 16 字节对齐的。 当 alloca() 的调用者返回时,分配的内存会自动释放。

注意:在 Windows 平台上,在使用 alloca() 之前必须包含 <malloc.h>。 使用 alloca() 可能会导致堆栈溢出,用户需要相应地调整堆栈大小。

它受计算能力 5.2 或更高版本的支持。

B.17.3. Example

1
2
3
4
5
__device__ void foo(unsigned int num) {
int4 *ptr = (int4 *)alloca(num * sizeof(int4));
// use of ptr
...
}

B.18. Compiler Optimization Hint Functions

本节中描述的函数可用于向编译器优化器提供附加信息。

B.18.1. __builtin_assume_aligned()

1
void * __builtin_assume_aligned (const void *exp, size_t align)

允许编译器假定参数指针至少对齐align字节,并返回参数指针。

Example:

1
2
void *res = __builtin_assume_aligned(ptr, 32); // compiler can assume 'res' is
// at least 32-byte aligned

三个参数版本:

1
2
void * __builtin_assume_aligned (const void *exp, size_t align, 
<integral type> offset)

允许编译器假设 (char *)exp - offset 至少对齐align字节,并返回参数指针。

Example:

1
2
3
void *res = __builtin_assume_aligned(ptr, 32, 8); // compiler can assume 
// '(char *)res - 8' is
// at least 32-byte aligned.

B.18.2. __builtin_assume()

1
void __builtin_assume(bool exp)

允许编译器假定布尔参数为真。 如果参数在运行时不为真,则行为未定义。 该参数没有被评估,因此任何副作用都将被丢弃。

Example:

1
2
3
4
 __device__ int get(int *ptr, int idx) {
__builtin_assume(idx <= 2);
return ptr[idx];
}

B.18.3. __assume()

1
void __assume(bool exp)

允许编译器假定布尔参数为真。 如果参数在运行时不为真,则行为未定义。 该参数没有被评估,因此任何副作用都将被丢弃。

Example:

1
2
3
4
 __device__ int get(int *ptr, int idx) {
__assume(idx <= 2);
return ptr[idx];
}

B.18.4. __builtin_expect()

1
long __builtin_expect (long exp, long c)

向编译器指示期望 exp == c,并返回 exp 的值。 通常用于向编译器指示分支预测信息。

1
2
3
4
5
6
7
Example:

// indicate to the compiler that likely "var == 0",
// so the body of the if-block is unlikely to be
// executed at run time.
if (__builtin_expect (var, 0))
doit ();

B.18.5. __builtin_unreachable()

1
void __builtin_unreachable(void)

向编译器指示控制流永远不会到达调用此函数的位置。 如果控制流在运行时确实到达了这一点,则程序具有未定义的行为。

1
2
3
4
5
6
7
8
Example:

// indicates to the compiler that the default case label is never reached.
switch (in) {
case 1: return 4;
case 2: return 10;
default: __builtin_unreachable();
}

B.18.6. Restrictions

__assume() 仅在使用 cl.exe 主机编译器时受支持。 所有平台都支持其他功能,但受以下限制:

  • 如果Host编译器支持该函数,则可以从translation unit中的任何位置调用该函数。
  • 否则,必须从 __device__/__global__ 函数的主体中调用该函数,或者仅在定义 __CUDA_ARCH__ 宏时调用。

B.19. Warp Vote Functions

1
2
3
4
int __all_sync(unsigned mask, int predicate);
int __any_sync(unsigned mask, int predicate);
unsigned __ballot_sync(unsigned mask, int predicate);
unsigned __activemask();

弃用通知:__any、__all 和 __ballot 在 CUDA 9.0 中已针对所有设备弃用。

删除通知:当面向具有 7.x 或更高计算能力的设备时,__any、__all 和 __ballot 不再可用,而应使用它们的同步变体。

warp 投票功能允许给定 warp 的线程执行缩减和广播操作。 这些函数将来自warp中每个线程的int predicate作为输入,并将这些值与零进行比较。 比较的结果通过以下方式之一在 warp 的活动线程中组合(减少),向每个参与线程广播单个返回值:

  • __all_sync(unsigned mask, predicate):
    评估mask中所有未退出线程的predicate,当且仅当predicate对所有线程的评估结果都为非零时,才返回非零值。
  • __any_sync(unsigned mask, predicate):
    评估mask中所有未退出线程的predicate,当且仅当predicate对其中任何一个的评估为非零时才返回非零。
  • __ballot_sync(unsigned mask, predicate):
    当且仅当 predicate 对 warp 的第 N 个线程计算为非零并且第 N 个线程处于活动状态时,为 mask 中所有未退出的线程计算predicate并返回一个其第 N 位被设置的整型。
  • __activemask():
    返回调用 warp 中所有当前活动线程的 32 位整数掩码。如果调用 __activemask() 时,warp 中的第 N 条通道处于活动状态,则设置第 N 位。非活动线程由返回掩码中的 0 位表示。退出程序的线程总是被标记为非活动的。请注意,在 __activemask() 调用中收敛的线程不能保证在后续指令中收敛,除非这些指令正在同步 warp 内置函数。

对于 __all_sync、__any_sync 和 __ballot_sync,必须传递一个掩码(mask)来指定参与调用的线程。 必须为每个参与线程设置一个表示线程通道 ID 的位,以确保它们在硬件执行内部函数之前正确收敛。 掩码中命名的所有活动线程必须使用相同的掩码执行相同的内部线程,否则结果未定义。

B.20. Warp Match Functions

__match_any_sync__match_all_sync 在 warp 中的线程之间执行变量的广播和比较操作。

由计算能力 7.x 或更高版本的设备支持。

B.20.1. Synopsis

1
2
unsigned int __match_any_sync(unsigned mask, T value);
unsigned int __match_all_sync(unsigned mask, T value, int *pred);

T 可以是 int、unsigned int、long、unsigned long、long long、unsigned long long、float 或 double

B.20.2. Description

__match_sync()的intrinsics允许在对mask中命名的线程进行同步之后,在不同的线程之间广播和比较一个值。

__match_any_sync

返回mask中具有相同value的线程掩码

__match_all_sync

如果掩码中的所有线程的value值都相同,则返回mask; 否则返回 0。 如果 mask 中的所有线程具有相同的 value 值,则 pred 设置为 true; 否则predicate设置为假。

新的 *_sync 匹配内在函数采用一个掩码,指示参与调用的线程。 必须为每个参与线程设置一个表示线程通道 ID 的位,以确保它们在硬件执行内部函数之前正确收敛。 掩码中命名的所有非退出线程必须使用相同的掩码执行相同的内在函数,否则结果未定义。

B.21. Warp Reduce Functions

__reduce_sync(unsigned mask, T value) 内在函数在同步 mask 中命名的线程后对 value 中提供的数据执行归约操作。 T 对于 {add, min, max} 可以是无符号的或有符号的,并且仅对于 {and, or, xor} 操作是无符号的。

由计算能力 8.x 或更高版本的设备支持。

B.21.1. Synopsis

1
2
3
4
5
6
7
8
9
10
11
12
// add/min/max
unsigned __reduce_add_sync(unsigned mask, unsigned value);
unsigned __reduce_min_sync(unsigned mask, unsigned value);
unsigned __reduce_max_sync(unsigned mask, unsigned value);
int __reduce_add_sync(unsigned mask, int value);
int __reduce_min_sync(unsigned mask, int value);
int __reduce_max_sync(unsigned mask, int value);

// and/or/xor
unsigned __reduce_and_sync(unsigned mask, unsigned value);
unsigned __reduce_or_sync(unsigned mask, unsigned value);
unsigned __reduce_xor_sync(unsigned mask, unsigned value);

B.21.2. Description

__reduce_add_sync、__reduce_min_sync、__reduce_max_sync

返回对 mask 中命名的每个线程在 value 中提供的值应用算术加法、最小或最大规约操作的结果。

__reduce_and_sync、__reduce_or_sync、__reduce_xor_sync

返回对 mask 中命名的每个线程在 value 中提供的值应用逻辑 AND、OR 或 XOR 规约操作的结果。

B.22. Warp Shuffle Functions

__shfl_sync、__shfl_up_sync、__shfl_down_sync 和 __shfl_xor_syncwarp 内的线程之间交换变量。

由计算能力 3.x 或更高版本的设备支持。

弃用通知:__shfl、__shfl_up、__shfl_down 和 __shfl_xor 在 CUDA 9.0 中已针对所有设备弃用。

删除通知:当面向具有 7.x 或更高计算能力的设备时,__shfl、__shfl_up、__shfl_down 和 __shfl_xor 不再可用,而应使用它们的同步变体。

作者添加:这里可能大家对接下来会提到的threadIndex, warpIdx, laneIndex会比较混淆.那么我用下图来说明.

ID.png

B.22.1. Synopsis

1
2
3
4
T __shfl_sync(unsigned mask, T var, int srcLane, int width=warpSize);
T __shfl_up_sync(unsigned mask, T var, unsigned int delta, int width=warpSize);
T __shfl_down_sync(unsigned mask, T var, unsigned int delta, int width=warpSize);
T __shfl_xor_sync(unsigned mask, T var, int laneMask, int width=warpSize);

T 可以是 int、unsigned int、long、unsigned long、long long、unsigned long long、float 或 double。 包含 cuda_fp16.h 头文件后,T 也可以是 __half 或 __half2。 同样,包含 cuda_bf16.h 头文件后,T 也可以是 __nv_bfloat16 或 __nv_bfloat162

B.22.2. Description

__shfl_sync() 内在函数允许在 warp 内的线程之间交换变量,而无需使用共享内存。 交换同时发生在 warp 中的所有活动线程(并以mask命名),根据类型移动每个线程 4 或 8 个字节的数据。

warp 中的线程称为通道(lanes),并且可能具有介于 0 和 warpSize-1(包括)之间的索引。 支持四种源通道(source-lane)寻址模式:

__shfl_sync()

从索引通道直接复制

__shfl_up_sync()

从相对于调用者 ID 较低的通道复制

__shfl_down_sync()

从相对于调用者具有更高 ID 的通道复制

__shfl_xor_sync()

基于自身通道 ID 的按位异或从通道复制

线程只能从积极参与 __shfl_sync() 命令的另一个线程读取数据。 如果目标线程处于非活动状态,则检索到的值未定义。

所有 __shfl_sync() 内在函数都采用一个可选的宽度参数,该参数会改变内在函数的行为。 width 的值必须是 2 的幂; 如果 width 不是 2 的幂,或者是大于 warpSize 的数字,则结果未定义。

__shfl_sync() 返回由 srcLane 给定 ID 的线程持有的 var 的值。 如果 width 小于 warpSize,则 warp 的每个子部分都表现为一个单独的实体,其起始逻辑通道 ID 为 0。如果 srcLane 超出范围 [0:width-1],则返回的值对应于通过 srcLane srcLane modulo width所持有的 var 的值 (即在同一部分内)。

作者添加:这里原本中说的有点绕,我还是用图来说明比较好.注意下面四个图均由作者制作,如果有问题,仅仅是作者水平问题-_-!.

shfl.png

__shfl_up_sync() 通过从调用者的通道 ID 中减去 delta 来计算源通道 ID。 返回由生成的通道 ID 保存的 var 的值:实际上, var 通过 delta 通道向上移动。 如果宽度小于 warpSize,则warp的每个子部分都表现为一个单独的实体,起始逻辑通道 ID 为 0。源通道索引不会环绕宽度值,因此实际上较低的 delta 通道将保持不变。
shfl_up.png

__shfl_down_sync() 通过将 delta 加调用者的通道 ID 来计算源通道 ID。 返回由生成的通道 ID 保存的 var 的值:这具有将 var 向下移动 delta 通道的效果。 如果 width 小于 warpSize,则 warp 的每个子部分都表现为一个单独的实体,起始逻辑通道 ID 为 0。至于 __shfl_up_sync(),源通道的 ID 号不会环绕宽度值,因此 upper delta lanes将保持不变。
shfl_down.png

__shfl_xor_sync() 通过对调用者的通道 ID 与 laneMask 执行按位异或来计算源通道 ID:返回结果通道 ID 所持有的 var 的值。 如果宽度小于warpSize,那么每组宽度连续的线程都能够访问早期线程组中的元素,但是如果它们尝试访问后面线程组中的元素,则将返回他们自己的var值。 这种模式实现了一种蝶式寻址模式,例如用于树规约和广播。
shufl_xor.png

新的 *_sync shfl 内部函数采用一个掩码,指示参与调用的线程。 必须为每个参与线程设置一个表示线程通道 ID 的位,以确保它们在硬件执行内部函数之前正确收敛。 掩码中命名的所有非退出线程必须使用相同的掩码执行相同的内在函数,否则结果未定义。

B.22.3. Notes

线程只能从积极参与 __shfl_sync() 命令的另一个线程读取数据。 如果目标线程处于非活动状态,则检索到的值未定义。

宽度必须是 2 的幂(即 2、4、8、16 或 32)。 未指定其他值的结果。

B.22.4. Examples

B.22.4.1. Broadcast of a single value across a warp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>

__global__ void bcast(int arg) {
int laneId = threadIdx.x & 0x1f;
int value;
if (laneId == 0) // Note unused variable for
value = arg; // all threads except lane 0
value = __shfl_sync(0xffffffff, value, 0); // Synchronize all threads in warp, and get "value" from lane 0
if (value != arg)
printf("Thread %d failed.\n", threadIdx.x);
}

int main() {
bcast<<< 1, 32 >>>(1234);
cudaDeviceSynchronize();

return 0;
}

B.22.4.2. Inclusive plus-scan across sub-partitions of 8 threads

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <stdio.h>

__global__ void scan4() {
int laneId = threadIdx.x & 0x1f;
// Seed sample starting value (inverse of lane ID)
int value = 31 - laneId;

// Loop to accumulate scan within my partition.
// Scan requires log2(n) == 3 steps for 8 threads
// It works by an accumulated sum up the warp
// by 1, 2, 4, 8 etc. steps.
for (int i=1; i<=4; i*=2) {
// We do the __shfl_sync unconditionally so that we
// can read even from threads which won't do a
// sum, and then conditionally assign the result.
int n = __shfl_up_sync(0xffffffff, value, i, 8);
if ((laneId & 7) >= i)
value += n;
}

printf("Thread %d final value = %d\n", threadIdx.x, value);
}

int main() {
scan4<<< 1, 32 >>>();
cudaDeviceSynchronize();

return 0;
}

B.22.4.3. Reduction across a warp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>

__global__ void warpReduce() {
int laneId = threadIdx.x & 0x1f;
// Seed starting value as inverse lane ID
int value = 31 - laneId;

// Use XOR mode to perform butterfly reduction
for (int i=16; i>=1; i/=2)
value += __shfl_xor_sync(0xffffffff, value, i, 32);

// "value" now contains the sum across all threads
printf("Thread %d final value = %d\n", threadIdx.x, value);
}

int main() {
warpReduce<<< 1, 32 >>>();
cudaDeviceSynchronize();

return 0;
}

B.23. Nanosleep Function

B.23.1. Synopsis

1
T __nanosleep(unsigned ns);

B.23.2. Description

__nanosleep(ns) 将线程挂起大约接近延迟 ns 的睡眠持续时间,以纳秒为单位指定。

它受计算能力 7.0 或更高版本的支持。

B.23.3. Example

以下代码实现了一个具有指数回退的互斥锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
__device__ void mutex_lock(unsigned int *mutex) {
unsigned int ns = 8;
while (atomicCAS(mutex, 0, 1) == 1) {
__nanosleep(ns);
if (ns < 256) {
ns *= 2;
}
}
}

__device__ void mutex_unlock(unsigned int *mutex) {
atomicExch(mutex, 0);
}

B.24. Warp matrix functions

C++ warp矩阵运算利用Tensor Cores来加速 D=A*B+C 形式的矩阵问题。 计算能力 7.0 或更高版本的设备的混合精度浮点数据支持这些操作。 这需要一个warp中所有线程的合作。 此外,仅当条件在整个 warp 中的计算结果相同时,才允许在条件代码中执行这些操作,否则代码执行可能会挂起。

B.24.1. Description

以下所有函数和类型都在命名空间 nvcuda::wmma 中定义。 Sub-byte操作被视为预览版,即它们的数据结构和 API 可能会发生变化,并且可能与未来版本不兼容。 这个额外的功能在 nvcuda::wmma::experimental 命名空间中定义。

1
2
3
4
5
6
7
template<typename Use, int m, int n, int k, typename T, typename Layout=void> class fragment;

void load_matrix_sync(fragment<...> &a, const T* mptr, unsigned ldm);
void load_matrix_sync(fragment<...> &a, const T* mptr, unsigned ldm, layout_t layout);
void store_matrix_sync(T* mptr, const fragment<...> &a, unsigned ldm, layout_t layout);
void fill_fragment(fragment<...> &a, const T& v);
void mma_sync(fragment<...> &d, const fragment<...> &a, const fragment<...> &b, const fragment<...> &c, bool satf=false);

fragment:

包含矩阵的一部分的重载类,分布在warp中的所有线程中。 矩阵元素到fragment内部存储的映射是未指定的,并且在未来的架构中可能会发生变化。

只允许模板参数的某些组合。 第一个模板参数指定片段将如何参与矩阵运算。 可接受的使用值是:

  • matrix_afragment 用作第一个被乘数时,A
  • matrix_bfragment用作第二个被乘数时,B
  • fragment用作源或目标累加器(分别为 C 或 D)时的累加器。

m、n 和 k 大小描述了参与乘法累加操作的warp-wide矩阵块的形状。 每个tile的尺寸取决于它的作用。 对于 matrix_a,图块的尺寸为 m x k; 对于 matrix_b,维度是 k x n,累加器块是 m x n

对于被乘数,数据类型 T 可以是 double、float、__half、__nv_bfloat16、char 或 unsigned char,对于累加器,可以是 double、float、int 或 __half。 如元素类型和矩阵大小中所述,支持累加器和被乘数类型的有限组合。 必须为 matrix_amatrix_b 片段指定 Layout 参数。 row_majorcol_major 分别表示矩阵行或列中的元素在内存中是连续的。 累加器矩阵的 Layout 参数应保留默认值 void。 仅当按如下所述加载或存储累加器时才指定行或列布局。

load_matrix_sync:

等到所有warp通道(lanes)都到达 load_matrix_sync,然后从内存中加载矩阵片段 amptr 必须是一个 256 位对齐的指针,指向内存中矩阵的第一个元素。 ldm 描述连续行(对于行主序)或列(对于列主序)之间的元素跨度,对于 __half 元素类型必须是 8 的倍数,对于浮点元素类型必须是 4 的倍数。 (即,两种情况下都是 16 字节的倍数)。 如果fragment是累加器,则布局参数必须指定为 mem_row_majormem_col_major。 对于 matrix_amatrix_b 片段,Layout是从fragmentLayout参数中推断出来的。 a 的 mptr、ldm、layout 和所有模板参数的值对于 warp 中的所有线程必须相同。 这个函数必须被warp中的所有线程调用,否则结果是未定义的。

store_matrix_sync:

等到所有warp通道都到达 store_matrix_sync,然后将矩阵片段 a 存储到内存中。 mptr 必须是一个 256 位对齐的指针,指向内存中矩阵的第一个元素。 ldm 描述连续行(对于行主序)或列(对于列主序)之间的元素跨度,对于 __half 元素类型必须是 8 的倍数,对于浮点元素类型必须是 4 的倍数。 (即,两种情况下都是 16 字节的倍数)。 输出矩阵的布局必须指定为 mem_row_majormem_col_major。 a 的 mptr、ldm、layout 和所有模板参数的值对于 warp 中的所有线程必须相同。

fill_fragment:

用常量 v 填充矩阵片段。由于未指定矩阵元素到每个片段的映射,因此该函数通常由 warp 中的所有线程调用,并具有共同的 v 值。

mma_sync:

等到所有warp lanes都到达mma_sync,然后执行warp同步的矩阵乘法累加操作D=A*B+C。 还支持原位(in-place)操作,C=A*B+C。 对于 warp 中的所有线程,每个矩阵片段的 satf 和模板参数的值必须相同。 此外,模板参数 m、n 和 k 必须在片段 A、B、C 和 D 之间匹配。该函数必须由 warp 中的所有线程调用,否则结果未定义。

如果 satf(饱和到有限值–saturate to finite value)模式为真,则以下附加数值属性适用于目标累加器:

  • 如果元素结果为+Infinity,则相应的累加器将包含+MAX_NORM
  • 如果元素结果为 -Infinity,则相应的累加器将包含 -MAX_NORM
  • 如果元素结果为 NaN,则对应的累加器将包含 +0

由于未指定矩阵元素到每个线程片段的映射,因此必须在调用 store_matrix_sync 后从内存(共享或全局)访问单个矩阵元素。 在 warp 中的所有线程将对所有片段元素统一应用元素操作的特殊情况下,可以使用以下fragment类成员实现直接元素访问。

1
2
enum fragment<Use, m, n, k, T, Layout>::num_elements;
T fragment<Use, m, n, k, T, Layout>::x[num_elements];

例如,以下代码将累加器矩阵缩小一半。

1
2
3
4
5
wmma::fragment<wmma::accumulator, 16, 16, 16, float> frag;
float alpha = 0.5f; // Same value for all threads in warp
/*...*/
for(int t=0; t<frag.num_elements; t++)
frag.x[t] *= alpha;

B.24.2. Alternate Floating Point

Tensor Core 支持在具有 8.0 及更高计算能力的设备上进行替代类型的浮点运算。

__nv_bfloat16:

此数据格式是另一种 fp16 格式,其范围与 f32 相同,但精度降低(7 位)。 您可以直接将此数据格式与 cuda_bf16.h 中提供的 __nv_bfloat16 类型一起使用。 具有 __nv_bfloat16 数据类型的矩阵片段需要与浮点类型的累加器组合。 支持的形状和操作与 __half 相同。

tf32:

这种数据格式是 Tensor Cores 支持的特殊浮点格式,范围与 f32 相同,但精度降低(>=10 位)。这种格式的内部布局是实现定义的。为了在 WMMA 操作中使用这种浮点格式,输入矩阵必须手动转换为 tf32 精度。

为了便于转换,提供了一个新的内联函数 __float_to_tf32。虽然内联函数的输入和输出参数是浮点类型,但输出将是 tf32。这个新精度仅适用于张量核心,如果与其他浮点类型操作混合使用,结果的精度和范围将是未定义的。

一旦输入矩阵(matrix_amatrix_b)被转换为 tf32 精度,具有precision::tf32 精度的片段和load_matrix_syncfloat 数据类型的组合将利用此新功能。两个累加器片段都必须具有浮点数据类型。唯一支持的矩阵大小是 16x16x8 (m-n-k)

片段的元素表示为浮点数,因此从 element_type<T>storage_element_type<T> 的映射是:

1
precision::tf32 -> float

B.24.3. Double Precision

Tensor Core 支持计算能力 8.0 及更高版本的设备上的双精度浮点运算。 要使用这个新功能,必须使用具有 double 类型的片段。 mma_sync 操作将使用 .rn(四舍五入到最接近的偶数)舍入修饰符执行。

B.24.4. Sub-byte Operations

Sub-byte WMMA 操作提供了一种访问 Tensor Core 的低精度功能的方法。 它们被视为预览功能,即它们的数据结构和 API 可能会发生变化,并且可能与未来版本不兼容。 此功能可通过 nvcuda::wmma::experimental 命名空间获得:

1
2
3
4
5
6
7
8
9
10
11
12
namespace experimental { 
namespace precision {
struct u4; // 4-bit unsigned
struct s4; // 4-bit signed
struct b1; // 1-bit
}
enum bmmaBitOp {
bmmaBitOpXOR = 1, // compute_75 minimum
bmmaBitOpAND = 2 // compute_80 minimum
};
enum bmmaAccumulateOp { bmmaAccumulateOpPOPC = 1 };
}

对于 4 位精度,可用的 API 保持不变,但您必须指定 experimental::precision::u4experimental::precision::s4 作为片段数据类型。 由于片段的元素被打包在一起,num_storage_elements 将小于该片段的 num_elements。 Sub-byte片段的 num_elements 变量,因此返回Sub-byte类型 element_type<T> 的元素数。 对于单位精度也是如此,在这种情况下,从 element_type<T>storage_element_type<T> 的映射如下:

1
2
3
4
experimental::precision::u4 -> unsigned (8 elements in 1 storage element) 
experimental::precision::s4 -> int (8 elements in 1 storage element)
experimental::precision::b1 -> unsigned (32 elements in 1 storage element)
T -> T //all other types

Sub-byte片段的允许布局始终为 matrix_arow_majormatrix_b col_major

对于子字节操作,load_matrix_syncldm 的值对于元素类型 experimental::precision::u4Experimental::precision::s4 应该是 32 的倍数,或者对于元素类型 experimental::precision::b1 应该是 128 的倍数 (即,两种情况下都是 16 字节的倍数)。

bmma_sync:
等到所有warp lane都执行了bmma_sync,然后执行warp同步位矩阵乘法累加运算D = (A op B) + C,其中op由逻辑运算bmmaBitOpbmmaAccumulateOp定义的累加组成。 可用的操作有:

  • bmmaBitOpXORmatrix_a 中的一行与 matrix_b 的 128 位列的 128 位 XOR
  • bmmaBitOpANDmatrix_a 中的一行与 matrix_b 的 128 位列的 128 位 AND,可用于计算能力 8.0 及更高版本的设备。

累积操作始终是 bmmaAccumulateOpPOPC,它计算设置位的数量。

B.24.5. Restrictions

对于每个主要和次要设备架构,tensor cores所需的特殊格式可能不同。 由于线程仅持有整个矩阵的片段(不透明的架构特定的 ABI 数据结构),因此开发人员不允许对如何将各个参数映射到参与矩阵乘法累加的寄存器做出假设,这使情况变得更加复杂。

由于片段是特定于体系结构的,如果函数已针对不同的链接兼容体系结构编译并链接在一起成为相同的设备可执行文件,则将它们从函数 A 传递到函数 B 是不安全的。 在这种情况下,片段的大小和布局将特定于一种架构,而在另一种架构中使用 WMMA API 将导致不正确的结果或潜在的损坏。

片段布局不同的两个链接兼容架构的示例是 sm_70 和 sm_75。

1
2
fragA.cu: void foo() { wmma::fragment<...> mat_a; bar(&mat_a); }
fragB.cu: void bar(wmma::fragment<...> *mat_a) { // operate on mat_a }
1
2
3
4
5
6
// sm_70 fragment layout
$> nvcc -dc -arch=compute_70 -code=sm_70 fragA.cu -o fragA.o
// sm_75 fragment layout
$> nvcc -dc -arch=compute_75 -code=sm_75 fragB.cu -o fragB.o
// Linking the two together
$> nvcc -dlink -arch=sm_75 fragA.o fragB.o -o frag.o

这种未定义的行为在编译时和运行时的工具也可能无法检测到,因此需要格外小心以确保片段的布局是一致的。 当与既为不同的链接兼容架构构建并期望传递 WMMA 片段的遗留库链接时,最有可能出现这种链接危险。

请注意,在弱链接的情况下(例如,CUDA C++ 内联函数),链接器可能会选择任何可用的函数定义,这可能会导致编译单元之间的隐式传递。

为避免此类问题,矩阵应始终存储到内存中以通过外部接口传输(例如 wmma::store_matrix_sync(dst, ...);),然后可以安全地将其作为指针类型传递给 bar() [ 例如 float *dst]。

请注意,由于 sm_70 可以在 sm_75 上运行,因此可以将上述示例 sm_75 代码更改为 sm_70 并在 sm_75 上正确运行。 但是,当与其他 sm_75 单独编译的二进制文件链接时,建议在您的应用程序中包含 sm_75 本机代码。

B.24.6. Element Types & Matrix Sizes

张量核心支持多种元素类型和矩阵大小。 下表显示了支持的 matrix_a、matrix_baccumulator矩阵的各种组合:

Matrix A Matrix B Accumulator Matrix Size (m-n-k)
__half __half float 16x16x16
__half __half float 32x8x16
__half __half float 8x32x16
__half __half __half 16x16x16
__half __half __half 32x8x16
__half __half __half 8x32x16
unsigned char unsigned char int 16x16x16
unsigned char unsigned char int 32x8x16
unsigned char unsigned char int 8x32x16
signed char signed char int 16x16x16
signed char signed char int 32x8x16
signed char signed char int 8x32x16

备用浮点支持:

Matrix A Matrix B Accumulator Matrix Size (m-n-k)
__nv_bfloat16 __nv_bfloat16 float 16x16x16
__nv_bfloat16 __nv_bfloat16 float 32x8x16
__nv_bfloat16 __nv_bfloat16 float 8x32x16
precision::tf32 precision::tf32 float 16x16x8

双精支持:

Matrix A Matrix B Accumulator Matrix Size (m-n-k)
double double double 8x8x4

对sub-byte操作的实验性支持:

Matrix A Matrix B Accumulator Matrix Size (m-n-k)
precision::u4 precision::u4 int 8x8x32
precision::s4 precision::s4 int 8x8x32
precision::b1 precision::b1 int 8x8x128

B.24.7. Example

以下代码在单个warp中实现 16x16x16 矩阵乘法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <mma.h>
using namespace nvcuda;

__global__ void wmma_ker(half *a, half *b, float *c) {
// Declare the fragments
wmma::fragment<wmma::matrix_a, 16, 16, 16, half, wmma::col_major> a_frag;
wmma::fragment<wmma::matrix_b, 16, 16, 16, half, wmma::row_major> b_frag;
wmma::fragment<wmma::accumulator, 16, 16, 16, float> c_frag;

// Initialize the output to zero
wmma::fill_fragment(c_frag, 0.0f);

// Load the inputs
wmma::load_matrix_sync(a_frag, a, 16);
wmma::load_matrix_sync(b_frag, b, 16);

// Perform the matrix multiplication
wmma::mma_sync(c_frag, a_frag, b_frag, c_frag);

// Store the output
wmma::store_matrix_sync(c, c_frag, 16, wmma::mem_row_major);
}

B.25. Asynchronous Barrier

NVIDIA C++ 标准库引入了 std::barrier 的 GPU 实现。 除了 std::barrier 的实现,该库还提供允许用户指定屏障对象范围的扩展。 屏障 API 范围记录在 Thread Scopes 下。 计算能力 8.0 或更高版本的设备为屏障操作和这些屏障与 memcpy_async 功能的集成提供硬件加速。 在计算能力低于 8.0 但从 7.0 开始的设备上,这些障碍在没有硬件加速的情况下可用

nvcuda::experimental::awbarrier被弃用,取而代之的是cuda::barrier

B.25.1. Simple Synchronization Pattern

在没有到达/等待障碍的情况下,使用 __syncthreads()(同步块中的所有线程)或 group.sync() 使用协作组时实现同步。

1
2
3
4
5
6
7
8
9
10
11
#include <cooperative_groups.h>

__global__ void simple_sync(int iteration_count) {
auto block = cooperative_groups::this_thread_block();

for (int i = 0; i < iteration_count; ++i) {
/* code before arrive */
block.sync(); /* wait for all threads to arrive here */
/* code after wait */
}
}

线程在同步点(block.sync())被阻塞,直到所有线程都到达同步点。 此外,同步点之前发生的内存更新保证对同步点之后块中的所有线程可见,即等效于 __threadfence_block() 以及sync

这种模式分为三个阶段:

  • 同步前的代码执行将在同步后读取的内存更新。
  • 同步点
  • 同步点之后的代码,具有同步点之前发生的内存更新的可见性。

B.25.2. Temporal Splitting and Five Stages of Synchronization

使用 std::barrier 的时间分割同步模式如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <cuda/barrier>
#include <cooperative_groups.h>

__device__ void compute(float* data, int curr_iteration);

__global__ void split_arrive_wait(int iteration_count, float *data) {
using barrier = cuda::barrier<cuda::thread_scope_block>;
__shared__ barrier bar;
auto block = cooperative_groups::this_thread_block();

if (block.thread_rank() == 0) {
init(&bar, block.size()); // Initialize the barrier with expected arrival count
}
block.sync();

for (int curr_iter = 0; curr_iter < iteration_count; ++curr_iter) {
/* code before arrive */
barrier::arrival_token token = bar.arrive(); /* this thread arrives. Arrival does not block a thread */
compute(data, curr_iter);
bar.wait(std::move(token)); /* wait for all threads participating in the barrier to complete bar.arrive()*/
/* code after wait */
}
}

在此模式中,同步点 (block.sync()) 分为到达点 (bar.arrive()) 和等待点 (bar.wait(std::move(token)))。 一个线程通过第一次调用 bar.arrive() 开始参与 cuda::barrier。 当一个线程调用 bar.wait(std::move(token)) 时,它将被阻塞,直到参与线程完成 bar.arrive() 的预期次数,该次数由传递给 init() 的预期到达计数参数指定。 在参与线程调用 bar.arrive() 之前发生的内存更新保证在参与线程调用 bar.wait(std::move(token)) 之后对参与线程可见。 请注意,对 bar.arrive() 的调用不会阻塞线程,它可以继续其他不依赖于在其他参与线程调用 bar.arrive() 之前发生的内存更新的工作。

arrive 然后wait 模式有五个阶段,可以反复重复:

  • 到达之前的代码执行将在等待后读取的内存更新。
  • 带有隐式内存栅栏的到达点(即,相当于 __threadfence_block())。
  • 到达和等待之间的代码。
  • 等待点。
  • 等待后的代码,可以看到在到达之前执行的更新。

B.25.3. Bootstrap Initialization, Expected Arrival Count, and Participation

必须在任何线程开始参与 cuda::barrier 之前进行初始化。

1
2
3
4
5
6
7
8
9
10
11
12
#include <cuda/barrier>
#include <cooperative_groups.h>

__global__ void init_barrier() {
__shared__ cuda::barrier<cuda::thread_scope_block> bar;
auto block = cooperative_groups::this_thread_block();

if (block.thread_rank() == 0) {
init(&bar, block.size()); // Single thread initializes the total expected arrival count.
}
block.sync();
}

在任何线程可以参与 cuda::barrier 之前,必须使用带有预期到达计数的 init() 初始化屏障,在本例中为 block.size()。 必须在任何线程调用 bar.arrive() 之前进行初始化。 这带来了一个引导挑战,因为线程必须在参与 cuda::barrier 之前进行同步,但是线程正在创建 cuda::barrier 以进行同步。 在此示例中,将参与的线程是协作组的一部分,并使用 block.sync() 来引导初始化。 在此示例中,整个线程块参与初始化,因此也可以使用 __syncthreads()

init() 的第二个参数是预期到达计数,即参与线程在解除对 bar.wait(std::move(token) 的调用之前将调用 bar.arrive() 的次数 ))。 在前面的示例中,cuda::barrier 使用线程块中的线程数进行初始化,即,cooperative_groups::this_thread_block().size(),并且线程块中的所有线程都参与了屏障。

cuda::barrier 可以灵活地指定线程如何参与(拆分到达/等待)以及哪些线程参与。 相比之下,来自协作组的 this_thread_block.sync()__syncthreads() 适用于整个线程块,而 __syncwarp(mask) 是 warp 的指定子集。 如果用户的意图是同步一个完整的线程块或一个完整的warp,出于性能原因,我们建议分别使用 __syncthreads()__syncwarp(mask)

B.25.4. A Barrier’s Phase: Arrival, Countdown, Completion, and Reset

当参与线程调用 bar.arrive() 时,cuda::barrier 从预期到达计数倒数到零。当倒计时达到零时,当前阶段的 cuda::barrier 就完成了。当最后一次调用 bar.arrive() 导致倒计时归零时,倒计时会自动自动重置。重置将倒计时分配给预期到达计数,并将 cuda::barrier 移动到下一阶段。

token=bar.arrive() 返回的 cuda::barrier::arrival_token 类的token对象与屏障的当前阶段相关联。当 cuda::barrier 处于当前阶段时,对 bar.wait(std::move(token)) 的调用会阻塞调用线程,即,当与token关联的阶段与 cuda::barrier 的阶段匹配时。如果在调用 bar.wait(std::move(token)) 之前阶段提前(因为倒计时达到零),则线程不会阻塞;如果在 bar.wait(std::move(token)) 中线程被阻塞时阶段提前,则线程被解除阻塞。

了解何时可能发生或不可能发生重置至关重要,尤其是在到达/等待同步模式中。

  • 线程对 token=bar.arrive()bar.wait(std::move(token)) 的调用必须按顺序进行,以便 token=bar.arrive()cuda::barrier 的当前阶段发生,并且 bar.wait (std::move(token)) 发生在同一阶段或下一阶段。
  • 当屏障的计数器非零时,线程对 bar.arrive() 的调用必须发生。 在屏障初始化之后,如果线程对 bar.arrive() 的调用导致倒计时达到零,则必须先调用 bar.wait(std::move(token)),然后才能将屏障重新用于对 bar.arrive() 的后续调用。
  • bar.wait() 只能使用当前阶段或前一个阶段的token对象调用。 对于token对象的任何其他值,行为是未定义的。
    对于简单的到达/等待同步模式,遵守这些使用规则很简单。

B.25.5. Spatial Partitioning (also known as Warp Specialization)

线程块可以在空间上进行分区,以便warp专门用于执行独立计算。 空间分区用于生产者或消费者模式,其中一个线程子集产生的数据由另一个(不相交的)线程子集同时使用。

生产者/消费者空间分区模式需要两个单侧同步来管理生产者和消费者之间的数据缓冲区。

Producer Consumer
wait for buffer to be ready to be filled signal buffer is ready to be filled
produce data and fill the buffer
signal buffer is filled wait for buffer to be filled
consume data in filled buffer

生产者线程等待消费者线程发出缓冲区已准备好填充的信号; 但是,消费者线程不会等待此信号。 消费者线程等待生产者线程发出缓冲区已满的信号; 但是,生产者线程不会等待此信号。 对于完整的生产者/消费者并发,此模式具有(至少)双缓冲,其中每个缓冲区需要两个 cuda::barriers

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
 #include <cuda/barrier>
#include <cooperative_groups.h>

using barrier = cuda::barrier<cuda::thread_scope_block>;

__device__ void producer(barrier ready[], barrier filled[], float* buffer, float* in, int N, int buffer_len)
{
for (int i = 0; i < (N/buffer_len); ++i) {
ready[i%2].arrive_and_wait(); /* wait for buffer_(i%2) to be ready to be filled */
/* produce, i.e., fill in, buffer_(i%2) */
barrier::arrival_token token = filled[i%2].arrive(); /* buffer_(i%2) is filled */
}
}

__device__ void consumer(barrier ready[], barrier filled[], float* buffer, float* out, int N, int buffer_len)
{
barrier::arrival_token token1 = ready[0].arrive(); /* buffer_0 is ready for initial fill */
barrier::arrival_token token2 = ready[1].arrive(); /* buffer_1 is ready for initial fill */
for (int i = 0; i < (N/buffer_len); ++i) {
filled[i%2].arrive_and_wait(); /* wait for buffer_(i%2) to be filled */
/* consume buffer_(i%2) */
barrier::arrival_token token = ready[i%2].arrive(); /* buffer_(i%2) is ready to be re-filled */
}
}

//N is the total number of float elements in arrays in and out
__global__ void producer_consumer_pattern(int N, int buffer_len, float* in, float* out) {

// Shared memory buffer declared below is of size 2 * buffer_len
// so that we can alternatively work between two buffers.
// buffer_0 = buffer and buffer_1 = buffer + buffer_len
__shared__ extern float buffer[];

// bar[0] and bar[1] track if buffers buffer_0 and buffer_1 are ready to be filled,
// while bar[2] and bar[3] track if buffers buffer_0 and buffer_1 are filled-in respectively
__shared__ barrier bar[4];


auto block = cooperative_groups::this_thread_block();
if (block.thread_rank() < 4)
init(bar + block.thread_rank(), block.size());
block.sync();

if (block.thread_rank() < warpSize)
producer(bar, bar+2, buffer, in, N, buffer_len);
else
consumer(bar, bar+2, buffer, out, N, buffer_len);
}

在这个例子中,第一个 warp 被专门为生产者,其余的 warp 被专门为消费者。 所有生产者和消费者线程都参与(调用 bar.arrive()bar.arrive_and_wait())四个 cuda::barriers 中的每一个,因此预期到达计数等于 block.size()

生产者线程等待消费者线程发出可以填充共享内存缓冲区的信号。 为了等待 cuda::barrier,生产者线程必须首先到达 ready[i%2].arrive() 以获取token,然后使用该token ready[i%2].wait(token)。 为简单起见,ready[i%2].arrive_and_wait() 结合了这些操作。

1
2
3
bar.arrive_and_wait();
/* is equivalent to */
bar.wait(bar.arrive());

生产者线程计算并填充ready缓冲区,然后它们通过到达填充屏障来表示缓冲区已填充,filled[i%2].arrive()。 生产者线程此时不会等待,而是等待下一次迭代的缓冲区(双缓冲)准备好被填充。

消费者线程首先发出两个缓冲区已准备好填充的信号。 消费者线程此时不等待,而是等待此迭代的缓冲区被填充,filled[i%2].arrive_and_wait()。 在消费者线程消耗完缓冲区后,它们会发出信号表明缓冲区已准备好再次填充,ready[i%2].arrive(),然后等待下一次迭代的缓冲区被填充。

B.25.6. Early Exit (Dropping out of Participation)

当参与同步序列的线程必须提前退出该序列时,该线程必须在退出之前显式退出参与。 其余参与线程可以正常进行后续的 cuda::barrier 到达和等待操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <cuda/barrier>
#include <cooperative_groups.h>

__device__ bool condition_check();

__global__ void early_exit_kernel(int N) {
using barrier = cuda::barrier<cuda::thread_scope_block>;
__shared__ barrier bar;
auto block = cooperative_groups::this_thread_block();

if (block.thread_rank() == 0)
init(&bar , block.size());
block.sync();

for (int i = 0; i < N; ++i) {
if (condition_check()) {
bar.arrive_and_drop();
return;
}
/* other threads can proceed normally */
barrier::arrival_token token = bar.arrive();
/* code between arrive and wait */
bar.wait(std::move(token)); /* wait for all threads to arrive */
/* code after wait */
}
}

此操作到达 cuda::barrier 以履行参与线程到达当前阶段的义务,然后减少下一阶段的预期到达计数,以便不再期望该线程到达屏障。

B.25.7. Memory Barrier Primitives Interface

内存屏障原语是 cuda::barrier 功能的 C类型(C-like) 接口。 这些原语可通过包含 <cuda_awbarrier_primitives.h> 头文件获得。

B.25.7.1. Data Types

1
2
typedef /* implementation defined */ __mbarrier_t;
typedef /* implementation defined */ __mbarrier_token_t;

B.25.7.2. Memory Barrier Primitives API

1
2
uint32_t __mbarrier_maximum_count();
void __mbarrier_init(__mbarrier_t* bar, uint32_t expected_count);
  • bar 必须是指向 __shared__ 内存的指针。
  • expected_count <= __mbarrier_maximum_count()
  • 将当前和下一阶段的 *bar 预期到达计数初始化为 expected_count
1
void __mbarrier_inval(__mbarrier_t* bar); 
  • bar 必须是指向共享内存中的 mbarrier 对象的指针。
  • 在重新使用相应的共享内存之前,需要使 *bar 无效。
1
__mbarrier_token_t __mbarrier_arrive(__mbarrier_t* bar);    
  • *bar 的初始化必须在此调用之前发生。
  • 待处理计数不得为零。
  • 原子地减少屏障当前阶段的挂起计数。
  • 在递减之前返回与屏障状态关联的到达token。
1
__mbarrier_token_t __mbarrier_arrive_and_drop(__mbarrier_t* bar);   
  • *bar 的初始化必须在此调用之前发生。
  • 待处理计数不得为零。
  • 原子地减少当前阶段的未决计数和屏障下一阶段的预期计数。
  • 在递减之前返回与屏障状态关联的到达token。
1
bool __mbarrier_test_wait(__mbarrier_t* bar, __mbarrier_token_t token);  
  • token必须与 *this 的前一个阶段或当前阶段相关联。
  • 如果 token 与 *bar 的前一个阶段相关联,则返回 true,否则返回 false。
1
2
//Note: This API has been deprecated in CUDA 11.1
uint32_t __mbarrier_pending_count(__mbarrier_token_t token);

B.26. Asynchronous Data Copies

CUDA 11 引入了带有 memcpy_async API 的异步数据操作,以允许设备代码显式管理数据的异步复制。 memcpy_async 功能使 CUDA 内核能够将计算与数据传输重叠。

B.26.1. memcpy_async API

memcpy_async API 在 cuda/barrier、cuda/pipelinecooperative_groups/memcpy_async.h 头文件中提供。

cuda::memcpy_async API 与 cuda::barriercuda::pipeline 同步原语一起使用,而cooperative_groups::memcpy_async 使用 coopertive_groups::wait 进行同步。

这些 API 具有非常相似的语义:将对象从 src 复制到 dst,就好像由另一个线程执行一样,在完成复制后,可以通过 cuda::pipeline、cuda::barriercooperative_groups::wait 进行同步。

libcudacxx API 文档和一些示例中提供了 cuda::barriercuda::pipelinecuda::memcpy_async 重载的完整 API 文档。

Cooperation_groups::memcpy_async 的 API 文档在文档的合作组部分中提供。

使用 cuda::barriercuda::pipelinememcpy_async API 需要 7.0 或更高的计算能力。在具有 8.0 或更高计算能力的设备上,从全局内存到共享内存的 memcpy_async 操作可以受益于硬件加速。

B.26.2. Copy and Compute Pattern - Staging Data Through Shared Memory

CUDA 应用程序通常采用一种copy and compute 模式:

  • 从全局内存中获取数据,
  • 将数据存储到共享内存中,
  • 对共享内存数据执行计算,并可能将结果写回全局内存。

以下部分说明了如何在使用和不使用 memcpy_async 功能的情况下表达此模式:

  • 没有 memcpy_async 部分介绍了一个不与数据移动重叠计算并使用中间寄存器复制数据的示例。
  • 使用 memcpy_async 部分改进了前面的示例,引入了cooperation_groups::memcpy_asynccuda::memcpy_async API 直接将数据从全局复制到共享内存,而不使用中间寄存器。
  • 使用 cuda::barrier异步数据拷贝部分显示了带有协作组和屏障的 memcpy
  • 单步异步数据拷贝展示了利用单步cuda::pipeline的memcpy
  • 多步异步数据拷贝展示了使用cuda::pipeline多步memcpy

B.26.3. Without memcpy_async

如果没有 memcpy_async,复制和计算模式的复制阶段表示为 shared[local_idx] = global[global_idx]。 这种全局到共享内存的复制被扩展为从全局内存读取到寄存器,然后从寄存器写入共享内存。

当这种模式出现在迭代算法中时,每个线程块需要在 shared[local_idx] = global[global_idx] 分配之后进行同步,以确保在计算阶段开始之前对共享内存的所有写入都已完成。 线程块还需要在计算阶段之后再次同步,以防止在所有线程完成计算之前覆盖共享内存。 此模式在以下代码片段中进行了说明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <cooperative_groups.h>
__device__ void compute(int* global_out, int const* shared_in) {
// Computes using all values of current batch from shared memory.
// Stores this thread's result back to global memory.
}

__global__ void without_memcpy_async(int* global_out, int const* global_in, size_t size, size_t batch_sz) {
auto grid = cooperative_groups::this_grid();
auto block = cooperative_groups::this_thread_block();
assert(size == batch_sz * grid.size()); // Exposition: input size fits batch_sz * grid_size

extern __shared__ int shared[]; // block.size() * sizeof(int) bytes

size_t local_idx = block.thread_rank();

for (size_t batch = 0; batch < batch_sz; ++batch) {
// Compute the index of the current batch for this block in global memory:
size_t block_batch_idx = block.group_index().x * block.size() + grid.size() * batch;
size_t global_idx = block_batch_idx + threadIdx.x;
shared[local_idx] = global_in[global_idx];

block.sync(); // Wait for all copies to complete

compute(global_out + block_batch_idx, shared); // Compute and write result to global memory

block.sync(); // Wait for compute using shared memory to finish
}
}

B.26.4. With memcpy_async

使用 memcpy_async,从全局内存中分配共享内存

1
shared[local_idx] = global_in[global_idx];

替换为来自合作组的异步复制操作

1
cooperative_groups::memcpy_async(group, shared, global_in + batch_idx, sizeof(int) * block.size());

cooperation_groups::memcpy_async API 将 sizeof(int) * block.size() 字节从 global_in + batch_idx 开始的全局内存复制到共享数据。 这个操作就像由另一个线程执行一样发生,在复制完成后,它与当前线程对cooperative_groups::wait 的调用同步。 在复制操作完成之前,修改全局数据或读取写入共享数据会引入数据竞争。

在具有 8.0 或更高计算能力的设备上,从全局内存到共享内存的 memcpy_async 传输可以受益于硬件加速,从而避免通过中间寄存器传输数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <cooperative_groups.h>
#include <cooperative_groups/memcpy_async.h>

__device__ void compute(int* global_out, int const* shared_in);

__global__ void with_memcpy_async(int* global_out, int const* global_in, size_t size, size_t batch_sz) {
auto grid = cooperative_groups::this_grid();
auto block = cooperative_groups::this_thread_block();
assert(size == batch_sz * grid.size()); // Exposition: input size fits batch_sz * grid_size

extern __shared__ int shared[]; // block.size() * sizeof(int) bytes

for (size_t batch = 0; batch < batch_sz; ++batch) {
size_t block_batch_idx = block.group_index().x * block.size() + grid.size() * batch;
// Whole thread-group cooperatively copies whole batch to shared memory:
cooperative_groups::memcpy_async(block, shared, global_in + block_batch_idx, sizeof(int) * block.size());

cooperative_groups::wait(block); // Joins all threads, waits for all copies to complete

compute(global_out + block_batch_idx, shared);

block.sync();
}
}}

B.26.5. Asynchronous Data Copies using cuda::barrier

cuda::memcpy_asynccuda::barrier 重载允许使用屏障同步异步数据传输。 此重载执行复制操作,就好像由绑定到屏障的另一个线程执行:在创建时增加当前阶段的预期计数,并在完成复制操作时减少它,这样屏障的阶段只会前进, 当所有参与屏障的线程都已到达,并且绑定到屏障当前阶段的所有 memcpy_async 都已完成时。 以下示例使用block范围的屏障,所有块线程都参与其中,并将等待操作与屏障到达和等待交换,同时提供与前一个示例相同的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <cooperative_groups.h>
#include <cuda/barrier>
__device__ void compute(int* global_out, int const* shared_in);

__global__ void with_barrier(int* global_out, int const* global_in, size_t size, size_t batch_sz) {
auto grid = cooperative_groups::this_grid();
auto block = cooperative_groups::this_thread_block();
assert(size == batch_sz * grid.size()); // Assume input size fits batch_sz * grid_size

extern __shared__ int shared[]; // block.size() * sizeof(int) bytes

// Create a synchronization object (C++20 barrier)
__shared__ cuda::barrier<cuda::thread_scope::thread_scope_block> barrier;
if (block.thread_rank() == 0) {
init(&barrier, block.size()); // Friend function initializes barrier
}
block.sync();

for (size_t batch = 0; batch < batch_sz; ++batch) {
size_t block_batch_idx = block.group_index().x * block.size() + grid.size() * batch;
cuda::memcpy_async(block, shared, global_in + block_batch_idx, sizeof(int) * block.size(), barrier);

barrier.arrive_and_wait(); // Waits for all copies to complete

compute(global_out + block_batch_idx, shared);

block.sync();
}
}

B.26.6. Performance Guidance for memcpy_async

对于计算能力 8.x,pipeline机制在同一 CUDA warp中的 CUDA 线程之间共享。 这种共享会导致成批的 memcpy_async 纠缠在warp中,这可能会在某些情况下影响性能。

本节重点介绍 warp-entanglement 对提交、等待和到达操作的影响。 有关各个操作的概述,请参阅pipeline接口pipeline基元接口

B.26.6.1. Alignment

在具有计算能力 8.0 的设备上,cp.async 系列指令允许将数据从全局异步复制到共享内存。 这些指令支持一次复制 4、8 和 16 个字节。 如果提供给 memcpy_async 的大小是 4、8 或 16 的倍数,并且传递给 memcpy_async 的两个指针都对齐到 4、8 或 16 对齐边界,则可以使用专门的异步内存操作来实现 memcpy_async

此外,为了在使用 memcpy_async API 时获得最佳性能,需要为共享内存和全局内存对齐 128 字节。

对于指向对齐要求为 1 或 2 的类型值的指针,通常无法证明指针始终对齐到更高的对齐边界。 确定是否可以使用 cp.async 指令必须延迟到运行时。 执行这样的运行时对齐检查会增加代码大小并增加运行时开销。

cuda::aligned_size_t<size_t Align>(size_t size)Shape 可用于证明传递给 memcpy_async 的两个指针都与 Align 边界对齐,并且大小是 Align 的倍数,方法是将其作为参数传递,其中 memcpy_async API 需要一个 Shape

1
cuda::memcpy_async(group, dst, src, cuda::aligned_size_t<16>(N * block.size()), pipeline);

如果验证不正确,则行为未定义。

B.26.6.2. Trivially copyable

在具有计算能力 8.0 的设备上,cp.async 系列指令允许将数据从全局异步复制到共享内存。 如果传递给 memcpy_async 的指针类型不指向 TriviallyCopyable 类型,则需要调用每个输出元素的复制构造函数,并且这些指令不能用于加速 memcpy_async

B.26.6.3. Warp Entanglement - Commit

memcpy_async 批处理的序列在 warp 中共享。 提交操作被合并,使得对于调用提交操作的所有聚合线程,序列增加一次。 如果warp完全收敛,则序列加1; 如果warp完全发散,则序列增加 32。

  • 设 PB 为 warp-shared pipeline的实际批次序列.

    PB = {BP0, BP1, BP2, …, BPL}

  • 令 TB 为线程感知的批次序列,就好像该序列仅由该线程调用提交操作增加。

    TB = {BT0, BT1, BT2, …, BTL}

    pipeline::producer_commit() 返回值来自线程感知的批处理序列。

  • 线程感知序列中的索引始终与实际warp共享序列中的相等或更大的索引对齐。 仅当从聚合线程调用所有提交操作时,序列才相等。

    BTn ≡ BPm 其中 n <= m

例如,当warp完全发散时:

  • warp共享pipeline的实际顺序是:PB = {0, 1, 2, 3, …, 31} (PL=31)。

  • 该warp的每个线程的感知顺序将是:

    • Thread 0: TB = {0} (TL=0)

    • Thread 1: TB = {0} (TL=0)

    • Thread 31: TB = {0} (TL=0)

B.26.6.4. Warp Entanglement - Wait

CUDA 线程调用 pipeline_consumer_wait_prior<N>()pipeline::consumer_wait() 以等待感知序列 TB 中的批次完成。 注意 pipeline::consumer_wait() 等价于 pipeline_consumer_wait_prior<N>(),其中 N = PL

pipeline_consumer_wait_prior<N>() 函数等待实际序列中的批次,至少达到并包括 PL-N。 由于 TL <= PL,等待批次达到并包括 PL-N 包括等待批次 TL-N。 因此,当 TL < PL 时,线程将无意中等待更多的、更新的批次。

在上面的极端完全发散的warp示例中,每个线程都可以等待所有 32 个批次。

B.26.6.5. Warp Entanglement - Arrive-On

Warp-divergence 影响到达 on(bar) 操作更新障碍的次数。 如果调用 warp 完全收敛,则屏障更新一次。 如果调用 warp 完全发散,则将 32 个单独的更新应用于屏障。

B.26.6.6. Keep Commit and Arrive-On Operations Converged

建议提交和到达调用由聚合线程进行:

  • 通过保持线程的感知批次序列与实际序列对齐,不要过度等待,并且
  • 以最小化对屏障对象的更新。

当这些操作之前的代码分支线程时,应该在调用提交或到达操作之前通过 __syncwarp 重新收敛warp。

B.27. Asynchronous Data Copies using cuda::pipeline

CUDA 提供 cuda::pipeline 同步对象来管理异步数据移动并将其与计算重叠。

cuda::pipeline 的 API 文档在 libcudacxx API 中提供。 流水线对象是一个具有头尾的双端 N 阶段队列,用于按照先进先出 (FIFO) 的顺序处理工作。 管道对象具有以下成员函数来管理管道的各个阶段。

Pipeline Class Member Function Description
producer_acquire Acquires an available stage in the pipeline internal queue.
producer_commit Commits the asynchronous operations issued after the producer_acquire call on the currently acquired stage of the pipeline.
consumer_wait Wait for completion of all asynchronous operations on the oldest stage of the pipeline.
consumer_release Release the oldest stage of the pipeline to the pipeline object for reuse. The released stage can be then acquired by the producer.

B.27.1. Single-Stage Asynchronous Data Copies using cuda::pipeline

在前面的示例中,我们展示了如何使用cooperative_groupscuda::barrier 进行异步数据传输。 在本节中,我们将使用带有单个阶段的 cuda::pipeline API 来调度异步拷贝。 稍后我们将扩展此示例以显示多阶段重叠计算和复制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <cooperative_groups/memcpy_async.h>
#include <cuda/pipeline>

__device__ void compute(int* global_out, int const* shared_in);
__global__ void with_single_stage(int* global_out, int const* global_in, size_t size, size_t batch_sz) {
auto grid = cooperative_groups::this_grid();
auto block = cooperative_groups::this_thread_block();
assert(size == batch_sz * grid.size()); // Assume input size fits batch_sz * grid_size

constexpr size_t stages_count = 1; // Pipeline with one stage
// One batch must fit in shared memory:
extern __shared__ int shared[]; // block.size() * sizeof(int) bytes

// Allocate shared storage for a two-stage cuda::pipeline:
__shared__ cuda::pipeline_shared_state<
cuda::thread_scope::thread_scope_block,
stages_count
> shared_state;
auto pipeline = cuda::make_pipeline(block, &shared_state);

// Each thread processes `batch_sz` elements.
// Compute offset of the batch `batch` of this thread block in global memory:
auto block_batch = [&](size_t batch) -> int {
return block.group_index().x * block.size() + grid.size() * batch;
};

for (size_t batch = 0; batch < batch_sz; ++batch) {
size_t global_idx = block_batch(batch);

// Collectively acquire the pipeline head stage from all producer threads:
pipeline.producer_acquire();

// Submit async copies to the pipeline's head stage to be
// computed in the next loop iteration
cuda::memcpy_async(block, shared, global_in + global_idx, sizeof(int) * block.size(), pipeline);
// Collectively commit (advance) the pipeline's head stage
pipeline.producer_commit();

// Collectively wait for the operations committed to the
// previous `compute` stage to complete:
pipeline.consumer_wait();

// Computation overlapped with the memcpy_async of the "copy" stage:
compute(global_out + global_idx, shared);

// Collectively release the stage resources
pipeline.consumer_release();
}
}

B.27.2. Multi-Stage Asynchronous Data Copies using cuda::pipeline

在前面带有cooperative_groups::waitcuda::barrier 的示例中,内核线程立即等待数据传输到共享内存完成。 这避免了数据从全局内存传输到寄存器,但不会通过重叠计算隐藏 memcpy_async 操作的延迟。

为此,我们在以下示例中使用 CUDA pipeline 功能。 它提供了一种管理 memcpy_async 批处理序列的机制,使 CUDA 内核能够将内存传输与计算重叠。 以下示例实现了一个将数据传输与计算重叠的两级管道。 它:

  • 初始化管道共享状态(更多下文)
  • 通过为第一批调度 memcpy_async 来启动管道。
  • 循环所有批次:它为下一个批次安排 memcpy_async,在完成上一个批次的 memcpy_async 时阻塞所有线程,然后将上一个批次的计算与下一个批次的内存的异步副本重叠。
  • 最后,它通过对最后一批执行计算来排空管道。

请注意,为了与 cuda::pipeline 的互操作性,此处使用来自 cuda/pipeline 头文件的 cuda::memcpy_async

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
#include <cooperative_groups/memcpy_async.h>
#include <cuda/pipeline>

__device__ void compute(int* global_out, int const* shared_in);
__global__ void with_staging(int* global_out, int const* global_in, size_t size, size_t batch_sz) {
auto grid = cooperative_groups::this_grid();
auto block = cooperative_groups::this_thread_block();
assert(size == batch_sz * grid.size()); // Assume input size fits batch_sz * grid_size

constexpr size_t stages_count = 2; // Pipeline with two stages
// Two batches must fit in shared memory:
extern __shared__ int shared[]; // stages_count * block.size() * sizeof(int) bytes
size_t shared_offset[stages_count] = { 0, block.size() }; // Offsets to each batch

// Allocate shared storage for a two-stage cuda::pipeline:
__shared__ cuda::pipeline_shared_state<
cuda::thread_scope::thread_scope_block,
stages_count
> shared_state;
auto pipeline = cuda::make_pipeline(block, &shared_state);

// Each thread processes `batch_sz` elements.
// Compute offset of the batch `batch` of this thread block in global memory:
auto block_batch = [&](size_t batch) -> int {
return block.group_index().x * block.size() + grid.size() * batch;
};

// Initialize first pipeline stage by submitting a `memcpy_async` to fetch a whole batch for the block:
if (batch_sz == 0) return;
pipeline.producer_acquire();
cuda::memcpy_async(block, shared + shared_offset[0], global_in + block_batch(0), sizeof(int) * block.size(), pipeline);
pipeline.producer_commit();

// Pipelined copy/compute:
for (size_t batch = 1; batch < batch_sz; ++batch) {
// Stage indices for the compute and copy stages:
size_t compute_stage_idx = (batch - 1) % 2;
size_t copy_stage_idx = batch % 2;

size_t global_idx = block_batch(batch);

// Collectively acquire the pipeline head stage from all producer threads:
pipeline.producer_acquire();

// Submit async copies to the pipeline's head stage to be
// computed in the next loop iteration
cuda::memcpy_async(block, shared + shared_offset[copy_stage_idx], global_in + global_idx, sizeof(int) * block.size(), pipeline);
// Collectively commit (advance) the pipeline's head stage
pipeline.producer_commit();

// Collectively wait for the operations commited to the
// previous `compute` stage to complete:
pipeline.consumer_wait();

// Computation overlapped with the memcpy_async of the "copy" stage:
compute(global_out + global_idx, shared + shared_offset[compute_stage_idx]);

// Collectively release the stage resources
pipeline.consumer_release();
}

// Compute the data fetch by the last iteration
pipeline.consumer_wait();
compute(global_out + block_batch(batch_sz-1), shared + shared_offset[(batch_sz - 1) % 2]);
pipeline.consumer_release();
}

pipeline 对象是一个带有头尾的双端队列,用于按照先进先出 (FIFO) 的顺序处理工作。 生产者线程将工作提交到管道的头部,而消费者线程从管道的尾部提取工作。 在上面的示例中,所有线程都是生产者和消费者线程。 线程首先提交 memcpy_async 操作以获取下一批,同时等待上一批 memcpy_async 操作完成。

  • 将工作提交到pipeline阶段包括:
    • 使用 pipeline.producer_acquire() 从一组生产者线程中集体获取pipeline头。
    • memcpy_async 操作提交到pipeline头。
    • 使用 pipeline.producer_commit() 共同提交(推进)pipeline头。
  • 使用先前提交的阶段包括:
    • 共同等待阶段完成,例如,使用 pipeline.consumer_wait() 等待尾部(最旧)阶段。
    • 使用 pipeline.consumer_release() 集体释放阶段。

cuda::pipeline_shared_state<scope, count> 封装了允许管道处理多达 count 个并发阶段的有限资源。 如果所有资源都在使用中,则 pipeline.producer_acquire() 会阻塞生产者线程,直到消费者线程释放下一个管道阶段的资源。
通过将循环的 prologepilog 与循环本身合并,可以以更简洁的方式编写此示例,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
template <size_t stages_count = 2 /* Pipeline with stages_count stages */>
__global__ void with_staging_unified(int* global_out, int const* global_in, size_t size, size_t batch_sz) {
auto grid = cooperative_groups::this_grid();
auto block = cooperative_groups::this_thread_block();
assert(size == batch_sz * grid.size()); // Assume input size fits batch_sz * grid_size

extern __shared__ int shared[]; // stages_count * block.size() * sizeof(int) bytes
size_t shared_offset[stages_count];
for (int s = 0; s < stages_count; ++s) shared_offset[s] = s * block.size();

__shared__ cuda::pipeline_shared_state<
cuda::thread_scope::thread_scope_block,
stages_count
> shared_state;
auto pipeline = cuda::make_pipeline(block, &shared_state);

auto block_batch = [&](size_t batch) -> int {
return block.group_index().x * block.size() + grid.size() * batch;
};

// compute_batch: next batch to process
// fetch_batch: next batch to fetch from global memory
for (size_t compute_batch = 0, fetch_batch = 0; compute_batch < batch_sz; ++compute_batch) {
// The outer loop iterates over the computation of the batches
for (; fetch_batch < batch_sz && fetch_batch < (compute_batch + stages_count); ++fetch_batch) {
// This inner loop iterates over the memory transfers, making sure that the pipeline is always full
pipeline.producer_acquire();
size_t shared_idx = fetch_batch % stages_count;
size_t batch_idx = fetch_batch;
size_t block_batch_idx = block_batch(batch_idx);
cuda::memcpy_async(block, shared + shared_offset[shared_idx], global_in + block_batch_idx, sizeof(int) * block.size(), pipeline);
pipeline.producer_commit();
}
pipeline.consumer_wait();
int shared_idx = compute_batch % stages_count;
int batch_idx = compute_batch;
compute(global_out + block_batch(batch_idx), shared + shared_offset[shared_idx]);
pipeline.consumer_release();
}
}

上面使用的 pipeline<thread_scope_block> 原语非常灵活,并且支持我们上面的示例未使用的两个特性:块中的任意线程子集都可以参与管道,并且从参与的线程中,任何子集都可以成为生产者 ,消费者,或两者兼而有之。 在以下示例中,具有“偶数”线程等级的线程是生产者,而其他线程是消费者:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
__device__ void compute(int* global_out, int shared_in); 

template <size_t stages_count = 2>
__global__ void with_specialized_staging_unified(int* global_out, int const* global_in, size_t size, size_t batch_sz) {
auto grid = cooperative_groups::this_grid();
auto block = cooperative_groups::this_thread_block();

// In this example, threads with "even" thread rank are producers, while threads with "odd" thread rank are consumers:
const cuda::pipeline_role thread_role
= block.thread_rank() % 2 == 0? cuda::pipeline_role::producer : cuda::pipeline_role::consumer;

// Each thread block only has half of its threads as producers:
auto producer_threads = block.size() / 2;

// Map adjacent even and odd threads to the same id:
const int thread_idx = block.thread_rank() / 2;

auto elements_per_batch = size / batch_sz;
auto elements_per_batch_per_block = elements_per_batch / grid.group_dim().x;

extern __shared__ int shared[]; // stages_count * elements_per_batch_per_block * sizeof(int) bytes
size_t shared_offset[stages_count];
for (int s = 0; s < stages_count; ++s) shared_offset[s] = s * elements_per_batch_per_block;

__shared__ cuda::pipeline_shared_state<
cuda::thread_scope::thread_scope_block,
stages_count
> shared_state;
cuda::pipeline pipeline = cuda::make_pipeline(block, &shared_state, thread_role);

// Each thread block processes `batch_sz` batches.
// Compute offset of the batch `batch` of this thread block in global memory:
auto block_batch = [&](size_t batch) -> int {
return elements_per_batch * batch + elements_per_batch_per_block * blockIdx.x;
};

for (size_t compute_batch = 0, fetch_batch = 0; compute_batch < batch_sz; ++compute_batch) {
// The outer loop iterates over the computation of the batches
for (; fetch_batch < batch_sz && fetch_batch < (compute_batch + stages_count); ++fetch_batch) {
// This inner loop iterates over the memory transfers, making sure that the pipeline is always full
if (thread_role == cuda::pipeline_role::producer) {
// Only the producer threads schedule asynchronous memcpys:
pipeline.producer_acquire();
size_t shared_idx = fetch_batch % stages_count;
size_t batch_idx = fetch_batch;
size_t global_batch_idx = block_batch(batch_idx) + thread_idx;
size_t shared_batch_idx = shared_offset[shared_idx] + thread_idx;
cuda::memcpy_async(shared + shared_batch_idx, global_in + global_batch_idx, sizeof(int), pipeline);
pipeline.producer_commit();
}
}
if (thread_role == cuda::pipeline_role::consumer) {
// Only the consumer threads compute:
pipeline.consumer_wait();
size_t shared_idx = compute_batch % stages_count;
size_t global_batch_idx = block_batch(compute_batch) + thread_idx;
size_t shared_batch_idx = shared_offset[shared_idx] + thread_idx;
compute(global_out + global_batch_idx, *(shared + shared_batch_idx));
pipeline.consumer_release();
}
}
}

管道执行了一些优化,例如,当所有线程既是生产者又是消费者时,但总的来说,支持所有这些特性的成本不能完全消除。 例如,流水线在共享内存中存储并使用一组屏障进行同步,如果块中的所有线程都参与流水线,这并不是真正必要的。

对于块中的所有线程都参与管道的特殊情况,我们可以通过使用pipeline<thread_scope_thread> 结合 __syncthreads() 做得比pipeline<thread_scope_block> 更好:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
template<size_t stages_count>
__global__ void with_staging_scope_thread(int* global_out, int const* global_in, size_t size, size_t batch_sz) {
auto grid = cooperative_groups::this_grid();
auto block = cooperative_groups::this_thread_block();
auto thread = cooperative_groups::this_thread();
assert(size == batch_sz * grid.size()); // Assume input size fits batch_sz * grid_size

extern __shared__ int shared[]; // stages_count * block.size() * sizeof(int) bytes
size_t shared_offset[stages_count];
for (int s = 0; s < stages_count; ++s) shared_offset[s] = s * block.size();

// No pipeline::shared_state needed
cuda::pipeline<cuda::thread_scope_thread> pipeline = cuda::make_pipeline();

auto block_batch = [&](size_t batch) -> int {
return block.group_index().x * block.size() + grid.size() * batch;
};

for (size_t compute_batch = 0, fetch_batch = 0; compute_batch < batch_sz; ++compute_batch) {
for (; fetch_batch < batch_sz && fetch_batch < (compute_batch + stages_count); ++fetch_batch) {
pipeline.producer_acquire();
size_t shared_idx = fetch_batch % stages_count;
size_t batch_idx = fetch_batch;
// Each thread fetches its own data:
size_t thread_batch_idx = block_batch(batch_idx) + threadIdx.x;
// The copy is performed by a single `thread` and the size of the batch is now that of a single element:
cuda::memcpy_async(thread, shared + shared_offset[shared_idx] + threadIdx.x, global_in + thread_batch_idx, sizeof(int), pipeline);
pipeline.producer_commit();
}
pipeline.consumer_wait();
block.sync(); // __syncthreads: All memcpy_async of all threads in the block for this stage have completed here
int shared_idx = compute_batch % stages_count;
int batch_idx = compute_batch;
compute(global_out + block_batch(batch_idx), shared + shared_offset[shared_idx]);
pipeline.consumer_release();
}
}

如果计算操作只读取与当前线程在同一 warp 中的其他线程写入的共享内存,则 __syncwarp() 就足够了。

B.27.3. Pipeline Interface

libcudacxx API 文档中提供了 cuda::memcpy_async 的完整 API 文档以及一些示例。

pipeline接口需要

  • 至少 CUDA 11.0,
  • 至少与 ISO C++ 2011 兼容,例如,使用 -std=c++11 编译,
  • #include <cuda/pipeline>
    对于类似 C 的接口,在不兼容 ISO C++ 2011 的情况下进行编译时,请参阅 Pipeline Primitives Interface

B.27.4. Pipeline Primitives Interface

pipeline原语是用于 memcpy_async 功能的类 C 接口。 通过包含 <cuda_pipeline.h> 头文件,可以使用pipeline原语接口。 在不兼容 ISO C++ 2011 的情况下进行编译时,请包含 <cuda_pipeline_primitives.h> 头文件。

B.27.4.1. memcpy_async Primitive

1
2
3
4
void __pipeline_memcpy_async(void* __restrict__ dst_shared,
const void* __restrict__ src_global,
size_t size_and_align,
size_t zfill=0);
  • 请求提交以下操作以进行异步评估:
1
2
3
size_t i = 0;
for (; i < size_and_align - zfill; ++i) ((char*)dst_shared)[i] = ((char*)src_shared)[i]; /* copy */
for (; i < size_and_align; ++i) ((char*)dst_shared)[i] = 0; /* zero-fill */
  • 需要:

    • dst_shared 必须是指向 memcpy_async 的共享内存目标的指针。
    • src_global 必须是指向 memcpy_async 的全局内存源的指针。
    • size_and_align 必须为 4、8 或 16。
    • zfill <= size_and_align.
    • size_and_align 必须是 dst_sharedsrc_global 的对齐方式。
  • 任何线程在等待 memcpy_async 操作完成之前修改源内存或观察目标内存都是一种竞争条件。 在提交 memcpy_async 操作和等待其完成之间,以下任何操作都会引入竞争条件:

    • dst_shared 加载。
    • 存储到 dst_sharedsrc_global。
    • dst_sharedsrc_global 应用原子更新。

B.27.4.2. Commit Primitive

1
void __pipeline_commit();
  • 将提交的 memcpy_async 作为当前批次提交到管道。

B.27.4.3. Wait Primitive

1
void __pipeline_wait_prior(size_t N);
  • {0, 1, 2, ..., L} 为与给定线程调用 __pipeline_commit() 相关联的索引序列。
  • 等待批次完成,至少包括 L-N

B.27.4.4. Arrive On Barrier Primitive

1
void __pipeline_arrive_on(__mbarrier_t* bar);
  • bar 指向共享内存中的屏障。
  • 将屏障到达计数加一,当在此调用之前排序的所有 memcpy_async 操作已完成时,到达计数减一,因此对到达计数的净影响为零。 用户有责任确保到达计数的增量不超过 __mbarrier_maximum_count()

B.28. Profiler Counter Function

每个多处理器都有一组 16 个硬件计数器,应用程序可以通过调用 __prof_trigger() 函数用一条指令递增这些计数器。

1
void __prof_trigger(int counter);

索引计数器的每个多处理器硬件计数器每warp增加一。 计数器 8 到 15 是保留的,不应由应用程序使用。

计数器 0, 1, …, 7 的值可以通过 nvprof --events prof_trigger_0x 通过 nvprof 获得,其中 x 为 0, 1, …, 7。所有计数器在每次内核启动之前都会重置(注意,在收集 计数器,内核启动是同步的,如主机和设备之间的并发执行中所述)。

B.29. Assertion

只有计算能力 2.x 及更高版本的设备才支持Assertion。

1
void assert(int expression);

如果表达式等于 0,停止内核执行。 如果程序在调试器中运行,则会触发断点,并且调试器可用于检查设备的当前状态。 否则,表达式等于 0 的每个线程在通过 cudaDeviceSynchronize()cudaStreamSynchronize()cudaEventSynchronize() 与主机同步后向 stderr 打印一条消息。 该消息的格式如下:

1
2
3
4
<filename>:<line number>:<function>:
block: [blockId.x,blockId.x,blockIdx.z],
thread: [threadIdx.x,threadIdx.y,threadIdx.z]
Assertion `<expression>` failed.

对同一设备进行的任何后续主机端同步调用都将返回 cudaErrorAssert。 在调用 cudaDeviceReset() 重新初始化设备之前,不能再向该设备发送命令。

如果expression 不为零,则内核执行不受影响。

例如,源文件 test.cu 中的以下程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <assert.h>

__global__ void testAssert(void)
{
int is_one = 1;
int should_be_one = 0;

// This will have no effect
assert(is_one);

// This will halt kernel execution
assert(should_be_one);
}

int main(int argc, char* argv[])
{
testAssert<<<1,1>>>();
cudaDeviceSynchronize();

return 0;
}

将会输出:

1
test.cu:19: void testAssert(): block: [0,0,0], thread: [0,0,0] Assertion `should_be_one` failed.

Assertion用于调试目的。 它们会影响性能,因此建议在产品代码中禁用它们。 它们可以在编译时通过在包含 assert.h 之前定义 NDEBUG 预处理器宏来禁用。 请注意,表达式不应是具有副作用的表达式(例如 (++i > 0)),否则禁用Assertion将影响代码的功能。

B.30. Trap function

可以通过从任何设备线程调用 __trap() 函数来启动trap操作。

1
void __trap();

内核的执行被中止并在主机程序中引发中断。

B.31. Breakpoint Function

可以通过从任何设备线程调用 __brkpt() 函数来暂停内核函数的执行。

1
void __brkpt();

B.32. Formatted Output

格式化输出仅受计算能力 2.x 及更高版本的设备支持。

1
int printf(const char *format[, arg, ...]);

将来自内核的格式化输出打印到主机端输出流。

内核中的 printf() 函数的行为方式与标准 C 库 printf() 函数类似,用户可以参考主机系统的手册以获取有关 printf() 行为的完整描述。本质上,作为格式传入的字符串输出到主机上的流,在遇到格式说明符的任何地方都会从参数列表中进行替换。下面列出了支持的格式说明符。

printf() 命令作为任何其他设备端函数执行:每个线程,并且在调用线程的上下文中。对于多线程内核,这意味着每个线程都将使用指定的线程数据执行对 printf() 的直接调用。然后,输出字符串的多个版本将出现在主机流中,每个遇到 printf() 的做线程一次。

如果只需要单个输出字符串,则由程序员将输出限制为单个线程(请参阅示例以获取说明性示例)。

与返回打印字符数的 C 标准 printf() 不同,CUDA 的 printf() 返回已解析参数的数量。如果格式字符串后面没有参数,则返回 0。如果格式字符串为 NULL,则返回 -1。如果发生内部错误,则返回 -2。

B.32.1. Format Specifiers

对于标准 printf(),格式说明符采用以下形式:%[flags][width][.precision][size]type

支持以下字段(有关所有行为的完整描述,请参阅广泛可用的文档):

  • Flags: '#' ' ' '0' '+' '-'
  • Width: '*' '0-9'
  • Precision: '0-9'
  • Size: 'h' 'l' 'll'
  • Type: "%cdiouxXpeEfgGaAs"

请注意,CUDA 的 printf() 将接受标志、宽度、精度、大小和类型的任何组合,无论它们总体上是否构成有效的格式说明符。 换句话说,“%hd”将被接受,并且 printf 将接受参数列表中相应位置的双精度变量。

B.32.2. Limitations

printf() 输出的最终格式化发生在主机系统上。 这意味着主机系统的编译器和 C 库必须能够理解格式字符串。 已尽一切努力确保 CUDA 的 printf 函数支持的格式说明符形成来自最常见主机编译器的通用子集,但确切的行为将取决于主机操作系统。

格式说明符中所述, printf() 将接受有效标志和类型的所有组合。 这是因为它无法确定在最终输出被格式化的主机系统上什么是有效的,什么是无效的。 这样做的效果是,如果程序发出包含无效组合的格式字符串,则输出可能未定义。

除了格式字符串之外,printf() 命令最多可以接受 32 个参数。 除此之外的其他参数将被忽略,格式说明符按原样输出。

由于在 64 位 Windows 平台上 long 类型的大小不同(在 64 位 Windows 平台上为 4 个字节,在其他 64 位平台上为 8 个字节),在非 Windows 64 位机器上编译的内核但在 win64 机器上运行将看到包含“%ld”的所有格式字符串的损坏输出。 建议编译平台与执行平台相匹配,以确保安全。

printf() 的输出缓冲区在内核启动之前设置为固定大小(请参阅关联的主机端 API)。 它是循环的,如果在内核执行期间产生的输出多于缓冲区可以容纳的输出,则旧的输出将被覆盖。 仅当执行以下操作之一时才会刷新:

  • 通过 <<<>>>cuLaunchKernel() 启动内核(在启动开始时,如果 CUDA_LAUNCH_BLOCKING 环境变量设置为 1,也在启动结束时),
  • 通过 cudaDeviceSynchronize()cuCtxSynchronize()cudaStreamSynchronize()cuStreamSynchronize()cudaEventSynchronize()cuEventSynchronize() 进行同步,
  • 通过任何阻塞版本的 cudaMemcpy*()cuMemcpy*() 进行内存复制,
  • 通过 cuModuleLoad()cuModuleUnload() 加载/卸载模块,
  • 通过 cudaDeviceReset()cuCtxDestroy() 销毁上下文。
  • 在执行由 cudaStreamAddCallbackcuStreamAddCallback 添加的流回调之前。

请注意,程序退出时缓冲区不会自动刷新。 用户必须显式调用 cudaDeviceReset()cuCtxDestroy(),如下例所示。

printf() 在内部使用共享数据结构,因此调用 printf() 可能会改变线程的执行顺序。 特别是,调用 printf() 的线程可能比不调用 printf() 的线程花费更长的执行路径,并且该路径长度取决于 printf() 的参数。 但是请注意,除了显式 __syncthreads() 障碍外,CUDA 不保证线程执行顺序,因此无法判断执行顺序是否已被 printf() 或硬件中的其他调度行为修改。

B.32.3. Associated Host-Side API

以下 API 函数获取和设置用于将 printf() 参数和内部元数据传输到主机的缓冲区大小(默认为 1 兆字节):

1
2
cudaDeviceGetLimit(size_t* size,cudaLimitPrintfFifoSize)
cudaDeviceSetLimit(cudaLimitPrintfFifoSize, size_t size)

B.32.4. Examples

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>

__global__ void helloCUDA(float f)
{
printf("Hello thread %d, f=%f\n", threadIdx.x, f);
}

int main()
{
helloCUDA<<<1, 5>>>(1.2345f);
cudaDeviceSynchronize();
return 0;
}

上面的代码将会输出:

1
2
3
4
5
Hello thread 2, f=1.2345
Hello thread 1, f=1.2345
Hello thread 4, f=1.2345
Hello thread 0, f=1.2345
Hello thread 3, f=1.2345

注意每个线程如何遇到 printf() 命令,因此输出行数与网格中启动的线程数一样多。 正如预期的那样,全局值(即 float f)在所有线程之间是通用的,而局部值(即 threadIdx.x)在每个线程中是不同的。

下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>

__global__ void helloCUDA(float f)
{
if (threadIdx.x == 0)
printf("Hello thread %d, f=%f\n", threadIdx.x, f) ;
}

int main()
{
helloCUDA<<<1, 5>>>(1.2345f);
cudaDeviceSynchronize();
}

将会输出:

1
Hello thread 0, f=1.2345

不言而喻,if() 语句限制了哪些线程将调用 printf,因此只能看到一行输出。

B.33. Dynamic Global Memory Allocation and Operations

动态全局内存分配和操作仅受计算能力 2.x 及更高版本的设备支持。

1
2
3
__host__ __device__ void* malloc(size_t size);
__device__ void *__nv_aligned_device_malloc(size_t size, size_t align);
__host__ __device__ void free(void* ptr);

从全局内存中的固定大小的堆中动态分配和释放内存。

1
__host__ __device__ void* memcpy(void* dest, const void* src, size_t size);

src 指向的内存位置复制 size 个字节到 dest 指向的内存位置。

1
__host__ __device__ void* memset(void* ptr, int value, size_t size);

ptr 指向的内存块的 size 字节设置为 value(解释为无符号字符)。

CUDA 内核中的 malloc() 函数从设备堆中分配至少 size 个字节,并返回一个指向已分配内存的指针,如果没有足够的内存来满足请求,则返回 NULL。返回的指针保证与 16 字节边界对齐。

内核中的 CUDA __nv_aligned_device_malloc() 函数从设备堆中分配至少 size 个字节,并返回一个指向已分配内存的指针,如果内存不足以满足请求的大小或对齐,则返回 NULL。分配内存的地址将是 align 的倍数。 align 必须是 2 的非零幂。

CUDA 内核中的 free() 函数释放 ptr 指向的内存,该内存必须由先前对 malloc()__nv_aligned_device_malloc() 的调用返回。如果 ptr 为 NULL,则忽略对 free() 的调用。使用相同的 ptr 重复调用 free() 具有未定义的行为。

给定 CUDA 线程通过 malloc()__nv_aligned_device_malloc() 分配的内存在 CUDA 上下文的生命周期内保持分配状态,或者直到通过调用 free() 显式释放。它可以被任何其他 CUDA 线程使用,即使在随后的内核启动时也是如此。任何 CUDA 线程都可以释放由另一个线程分配的内存,但应注意确保不会多次释放同一指针。

B.33.1. Heap Memory Allocation

设备内存堆具有固定大小,必须在任何使用 malloc()、__nv_aligned_device_malloc() 或 free() 的程序加载到上下文之前指定该大小。 如果任何程序在没有明确指定堆大小的情况下使用 malloc() 或 __nv_aligned_device_malloc() ,则会分配 8 MB 的默认堆。

以下 API 函数获取和设置堆大小:

  • cudaDeviceGetLimit(size_t* size, cudaLimitMallocHeapSize)
  • cudaDeviceSetLimit(cudaLimitMallocHeapSize, size_t size)

授予的堆大小至少为 size 个字节。 cuCtxGetLimit() 和 cudaDeviceGetLimit() 返回当前请求的堆大小。

当模块被加载到上下文中时,堆的实际内存分配发生,或者显式地通过 CUDA 驱动程序 API(参见模块),或者隐式地通过 CUDA 运行时 API(参见 CUDA 运行时)。 如果内存分配失败,模块加载会产生 CUDA_ERROR_SHARED_OBJECT_INIT_FAILED 错误。

一旦发生模块加载,堆大小就无法更改,并且不会根据需要动态调整大小。

除了通过主机端 CUDA API 调用(例如 cudaMalloc())分配为设备堆保留的内存之外。

B.33.2. Interoperability with Host Memory API

通过设备 malloc()__nv_aligned_device_malloc() 分配的内存不能使用运行时释放(即,通过从设备内存调用任何空闲内存函数)。

同样,通过运行时分配的内存(即,通过从设备内存调用任何内存分配函数)不能通过 free() 释放。

此外,在设备代码中调用 malloc()__nv_aligned_device_malloc() 分配的内存不能用于任何运行时或驱动程序 API 调用(即 cudaMemcpycudaMemset 等)。

B.33.3. Examples

B.33.3.1. Per Thread Allocation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdlib.h>
#include <stdio.h>

__global__ void mallocTest()
{
size_t size = 123;
char* ptr = (char*)malloc(size);
memset(ptr, 0, size);
printf("Thread %d got pointer: %p\n", threadIdx.x, ptr);
free(ptr);
}

int main()
{
// Set a heap size of 128 megabytes. Note that this must
// be done before any kernel is launched.
cudaDeviceSetLimit(cudaLimitMallocHeapSize, 128*1024*1024);
mallocTest<<<1, 5>>>();
cudaDeviceSynchronize();
return 0;
}

上面的代码将会输出:

1
2
3
4
5
Thread 0 got pointer: 00057020
Thread 1 got pointer: 0005708c
Thread 2 got pointer: 000570f8
Thread 3 got pointer: 00057164
Thread 4 got pointer: 000571d0

注意每个线程如何遇到 malloc()memset() 命令,从而接收和初始化自己的分配。 (确切的指针值会有所不同:这些是说明性的。)

B.33.3.2. Per Thread Block Allocation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <stdlib.h>

__global__ void mallocTest()
{
__shared__ int* data;

// The first thread in the block does the allocation and then
// shares the pointer with all other threads through shared memory,
// so that access can easily be coalesced.
// 64 bytes per thread are allocated.
if (threadIdx.x == 0) {
size_t size = blockDim.x * 64;
data = (int*)malloc(size);
}
__syncthreads();

// Check for failure
if (data == NULL)
return;

// Threads index into the memory, ensuring coalescence
int* ptr = data;
for (int i = 0; i < 64; ++i)
ptr[i * blockDim.x + threadIdx.x] = threadIdx.x;

// Ensure all threads complete before freeing
__syncthreads();

// Only one thread may free the memory!
if (threadIdx.x == 0)
free(data);
}

int main()
{
cudaDeviceSetLimit(cudaLimitMallocHeapSize, 128*1024*1024);
mallocTest<<<10, 128>>>();
cudaDeviceSynchronize();
return 0;
}

B.33.3.3. Allocation Persisting Between Kernel Launches

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#include <stdlib.h>
#include <stdio.h>

#define NUM_BLOCKS 20

__device__ int* dataptr[NUM_BLOCKS]; // Per-block pointer

__global__ void allocmem()
{
// Only the first thread in the block does the allocation
// since we want only one allocation per block.
if (threadIdx.x == 0)
dataptr[blockIdx.x] = (int*)malloc(blockDim.x * 4);
__syncthreads();

// Check for failure
if (dataptr[blockIdx.x] == NULL)
return;

// Zero the data with all threads in parallel
dataptr[blockIdx.x][threadIdx.x] = 0;
}

// Simple example: store thread ID into each element
__global__ void usemem()
{
int* ptr = dataptr[blockIdx.x];
if (ptr != NULL)
ptr[threadIdx.x] += threadIdx.x;
}

// Print the content of the buffer before freeing it
__global__ void freemem()
{
int* ptr = dataptr[blockIdx.x];
if (ptr != NULL)
printf("Block %d, Thread %d: final value = %d\n",
blockIdx.x, threadIdx.x, ptr[threadIdx.x]);

// Only free from one thread!
if (threadIdx.x == 0)
free(ptr);
}

int main()
{
cudaDeviceSetLimit(cudaLimitMallocHeapSize, 128*1024*1024);

// Allocate memory
allocmem<<< NUM_BLOCKS, 10 >>>();

// Use memory
usemem<<< NUM_BLOCKS, 10 >>>();
usemem<<< NUM_BLOCKS, 10 >>>();
usemem<<< NUM_BLOCKS, 10 >>>();

// Free memory
freemem<<< NUM_BLOCKS, 10 >>>();

cudaDeviceSynchronize();

return 0;
}

B.34. Execution Configuration

__global__ 函数的任何调用都必须指定该调用的执行配置。执行配置定义了将用于在设备上执行功能的网格和块的维度,以及关联的流(有关流的描述,请参见 CUDA 运行时)。

通过在函数名称和带括号的参数列表之间插入 <<< Dg, Db, Ns, S >>> 形式的表达式来指定执行配置,其中:

  • Dg 是 dim3 类型(参见 dim3),并指定网格的维度和大小,使得 Dg.x * Dg.y * Dg.z 等于正在启动的块数;
  • Db 是 dim3 类型(参见 dim3),并指定每个块的维度和大小,使得 Db.x * Db.y * Db.z 等于每个块的线程数;
  • Ns 是 size_t 类型,指定除了静态分配的内存之外,每个块动态分配的共享内存中的字节数;这个动态分配的内存被声明为外部数组的任何变量使用,如 __shared__ 中所述; Ns 是一个可选参数,默认为 0;
  • S 是 cudaStream_t 类型并指定关联的流; S 是一个可选参数,默认为 0。

例如,一个函数声明为:

1
__global__ void Func(float* parameter);

必须这样调用:

1
Func<<< Dg, Db, Ns >>>(parameter);

执行配置的参数在实际函数参数之前进行评估。

如果 DgDb 大于 Compute Capabilities 中指定的设备允许的最大数,或者 Ns 大于设备上可用的最大共享内存量减去静态分配所需的共享内存量,则函数调用将失败 。

B.35. Launch Bounds

正如多处理器级别中详细讨论的那样,内核使用的寄存器越少,多处理器上可能驻留的线程和线程块就越多,这可以提高性能。

因此,编译器使用启发式方法来最大限度地减少寄存器使用量,同时将寄存器溢出(请参阅设备内存访问)和指令计数保持在最低限度。 应用程序可以选择性地通过以启动边界的形式向编译器提供附加信息来帮助这些启发式方法,这些信息使用 __global__ 函数定义中的 __launch_bounds__() 限定符指定:

1
2
3
4
5
6
__global__ void
__launch_bounds__(maxThreadsPerBlock, minBlocksPerMultiprocessor)
MyKernel(...)
{
...
}
  • maxThreadsPerBlock 指定应用程序启动 MyKernel() 的每个块的最大线程数; 它编译为 .maxntidPTX 指令;
  • minBlocksPerMultiprocessor 是可选的,指定每个多处理器所需的最小驻留块数; 它编译为 .minnctapersmPTX 指令。

如果指定了启动边界,编译器首先从它们推导出内核应该使用的寄存器数量的上限 L,以确保 maxThreadsPerBlock 线程的 minBlocksPerMultiprocessor 块(或单个块,如果未指定 minBlocksPerMultiprocessor)可以驻留在多处理器上( 有关内核使用的寄存器数量与每个块分配的寄存器数量之间的关系,请参见硬件多线程)。 然后编译器通过以下方式优化寄存器使用:

  • 如果初始寄存器使用量高于 L,编译器会进一步减少它,直到它变得小于或等于 L,通常以更多的本地内存使用或更多的指令为代价;
  • 如果初始寄存器使用率低于 L
    • 如果指定了 maxThreadsPerBlock 而未指定 minBlocksPerMultiprocessor,则编译器使用 maxThreadsPerBlock 来确定 nn+1 个常驻块之间转换的寄存器使用阈值(即,当使用较少的寄存器时,可以为额外的常驻块腾出空间,如 多处理器级别),然后应用与未指定启动边界时类似的启发式方法;
    • 如果同时指定了 minBlocksPerMultiprocessormaxThreadsPerBlock,编译器可能会将寄存器使用率提高到 L 以减少指令数量并更好地隐藏单线程指令延迟。

如果每个块执行的线程数超过其启动限制 maxThreadsPerBlock,则内核将无法启动。

CUDA 内核所需的每个线程资源可能会以不希望的方式限制最大块数量。为了保持对未来硬件和工具包的前向兼容性,并确保至少一个线程块可以在 SM 上运行,开发人员应该包含单个参数 __launch_bounds__(maxThreadsPerBlock),它指定内核将启动的最大块大小。不这样做可能会导致“请求启动的资源过多”错误。在某些情况下,提供 __launch_bounds__(maxThreadsPerBlock,minBlocksPerMultiprocessor) 的两个参数版本可以提高性能。 minBlocksPerMultiprocessor 的正确值应使用详细的每个内核分析来确定。

给定内核的最佳启动范围通常会因主要架构修订版而异。下面的示例代码显示了通常如何使用应用程序兼容性中引入的 __CUDA_ARCH__ 宏在设备代码中处理此问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#define THREADS_PER_BLOCK          256
#if __CUDA_ARCH__ >= 200
#define MY_KERNEL_MAX_THREADS (2 * THREADS_PER_BLOCK)
#define MY_KERNEL_MIN_BLOCKS 3
#else
#define MY_KERNEL_MAX_THREADS THREADS_PER_BLOCK
#define MY_KERNEL_MIN_BLOCKS 2
#endif

// Device code
__global__ void
__launch_bounds__(MY_KERNEL_MAX_THREADS, MY_KERNEL_MIN_BLOCKS)
MyKernel(...)
{
...
}

在使用每个块的最大线程数(指定为 __launch_bounds__() 的第一个参数)调用 MyKernel 的常见情况下,很容易在执行配置中使用 MY_KERNEL_MAX_THREADS 作为每个块的线程数:

1
2
// Host code
MyKernel<<<blocksPerGrid, THREADS_PER_BLOCK>>>(...);

或者在运行时基于计算能力:

1
2
3
4
5
6
// Host code
cudaGetDeviceProperties(&deviceProp, device);
int threadsPerBlock =
(deviceProp.major >= 2 ?
2 * THREADS_PER_BLOCK : THREADS_PER_BLOCK);
MyKernel<<<blocksPerGrid, threadsPerBlock>>>(...);

寄存器使用情况由 --ptxas-options=-v 编译器选项报告。 驻留块的数量可以从 CUDA 分析器报告的占用率中得出(有关占用率的定义,请参阅设备内存访问)。

还可以使用 maxrregcount 编译器选项控制文件中所有 __global__ 函数的寄存器使用。 对于具有启动界限的函数,会忽略 maxrregcount 的值。

B.36. #pragma unroll

默认情况下,编译器展开具有已知行程计数的小循环。 然而,#pragma unroll 指令可用于控制任何给定循环的展开。 它必须放在紧接在循环之前,并且仅适用于该循环。 可选地后跟一个整数常量表达式ICE 。 如果 ICE 不存在,如果其行程计数恒定,则循环将完全展开。 如果 ICE 计算结果为 1,编译器将不会展开循环。 如果 ICE 计算结果为非正整数或大于 int 数据类型可表示的最大值的整数,则该 pragma 将被忽略。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
struct S1_t { static const int value = 4; };
template <int X, typename T2>
__device__ void foo(int *p1, int *p2) {

// no argument specified, loop will be completely unrolled
#pragma unroll
for (int i = 0; i < 12; ++i)
p1[i] += p2[i]*2;

// unroll value = 8
#pragma unroll (X+1)
for (int i = 0; i < 12; ++i)
p1[i] += p2[i]*4;

// unroll value = 1, loop unrolling disabled
#pragma unroll 1
for (int i = 0; i < 12; ++i)
p1[i] += p2[i]*8;

// unroll value = 4
#pragma unroll (T2::value)
for (int i = 0; i < 12; ++i)
p1[i] += p2[i]*16;
}

__global__ void bar(int *p1, int *p2) {
foo<7, S1_t>(p1, p2);
}

B.37. SIMD Video Instructions

PTX ISA 3.0 版包括 SIMD(Single Instruction, Multiple Data)视频指令,可对 一对16 位值和 四个8 位值进行操作。 这些在计算能力 3.0 的设备上可用。

SIMD 视频指令如下:

  • vadd2, vadd4
  • vsub2, vsub4
  • vavrg2, vavrg4
  • vabsdiff2, vabsdiff4
  • vmin2, vmin4
  • vmax2, vmax4
  • vset2, vset4

PTX 指令,例如 SIMD 视频指令,可以通过汇编程序 asm() 语句包含在 CUDA 程序中。

asm() 语句的基本语法是:

1
asm("template-string" : "constraint"(output) : "constraint"(input)"));

使用 vabsdiff4 PTX 指令的示例是:

1
asm("vabsdiff4.u32.u32.u32.add" " %0, %1, %2, %3;": "=r" (result):"r" (A), "r" (B), "r" (C));

这使用 vabsdiff4 指令来计算整数四字节 SIMD 绝对差的和。 以 SIMD 方式为无符号整数 A 和 B 的每个字节计算绝对差值。 可选的累积操作 (.add) 被指定为对这些差值求和。

有关在代码中使用汇编语句的详细信息,请参阅文档“Using Inline PTX Assembly in CUDA”。 有关您正在使用的 PTX 版本的 PTX 指令的详细信息,请参阅 PTX ISA 文档(例如“Parallel Thread Execution ISA Version 3.0”)。

B.38. Diagnostic Pragmas

以下 pragma 可用于控制发出给定诊断消息时使用的错误严重性。

1
2
3
4
5
#pragma nv_diag_suppress
#pragma nv_diag_warning
#pragma nv_diag_error
#pragma nv_diag_default
#pragma nv_diag_once

这些 pragma 的用法具有以下形式:

1
#pragma nv_diag_xxx error_number, error_number ...

使用警告消息中显示的错误号指定受影响的诊断。 任何诊断都可能被覆盖为错误,但只有警告的严重性可能被抑制或在升级为错误后恢复为警告。 nv_diag_default pragma 用于将诊断的严重性返回到在发出任何 pragma 之前有效的严重性(即,由任何命令行选项修改的消息的正常严重性)。 下面的示例禁止在声明 foo 时出现“已声明但从未引用”警告:

1
2
3
4
5
6
7
8
9
10
#pragma nv_diag_suppress 177
void foo()
{
int i=0;
}
#pragma nv_diag_default 177
void bar()
{
int i=0;
}

以下 pragma 可用于保存和恢复当前诊断 pragma 状态:

1
2
#pragma nv_diagnostic push
#pragma nv_diagnostic pop

示例:

1
2
3
4
5
6
7
8
9
10
11
#pragma nv_diagnostic push
#pragma nv_diag_suppress 177
void foo()
{
int i=0;
}
#pragma nv_diagnostic pop
void bar()
{
int i=0;
}

请注意,编译指示仅影响 nvcc CUDA 前端编译器; 它们对主机编译器没有影响。

注意:NVCC 也实现了没有 nv_ 前缀的诊断 pragma,例如 #pragma diag_suppress,但它们已被弃用,并将从未来的版本中删除,使用这些诊断 pragma 将收到如下消息警告:

1
pragma "diag_suppress" is deprecated, use "nv_diag_suppress" instead 

附录C 协作组

C.1. Introduction

Cooperative Groups 是 CUDA 9 中引入的 CUDA 编程模型的扩展,用于组织通信线程组。协作组允许开发人员表达线程通信的粒度,帮助他们表达更丰富、更有效的并行分解。

从历史上看,CUDA 编程模型为同步协作线程提供了一个单一、简单的构造:线程块的所有线程之间的屏障,如使用 __syncthreads() 内部函数实现的那样。但是,程序员希望以其他粒度定义和同步线程组,以“集体”组范围功能接口的形式实现更高的性能、设计灵活性和软件重用。为了表达更广泛的并行交互模式,许多面向性能的程序员已经求助于编写自己的临时和不安全的原语来同步单个 warp 中的线程,或者跨运行在单个 GPU 上的线程块集。虽然实现的性能改进通常很有价值,但这导致了越来越多的脆弱代码集合,随着时间的推移和跨 GPU 架构的不同,这些代码的编写、调整和维护成本很高。合作组通过提供安全且面向未来的机制来启用高性能代码来解决这个问题。

C.2. What’s New in CUDA 11.0

  • 使用网格范围的组不再需要单独编译,并且同步该组的速度现在提高了 30%。此外,我们在最新的 Windows 平台上启用了协作启动,并在 MPS 下运行时增加了对它们的支持。
  • grid_group 现在可以转换为 thread_group
  • 线程块切片和合并组的新集合:reducememcpy_async
  • 线程块切片和合并组的新分区操作:labeled_pa​​rtitionbinary_partition
  • 新的 API,meta_group_rankmeta_group_size,它们提供有关导致创建该组的分区的信息。
  • 线程块tile现在可以在类型中编码其父级,这允许对发出的代码进行更好的编译时优化。
  • 接口更改:grid_group 必须在声明时使用 this_grid() 构造。默认构造函数被删除。

注意:在此版本中,我们正朝着要求 C++11 提供新功能的方向发展。在未来的版本中,所有现有 API 都需要这样做。

C.3. Programming Model Concept

协作组编程模型描述了 CUDA 线程块内和跨线程块的同步模式。 它为应用程序提供了定义它们自己的线程组的方法,以及同步它们的接口。 它还提供了强制执行某些限制的新启动 API,因此可以保证同步正常工作。 这些原语在 CUDA 内启用了新的协作并行模式,包括生产者-消费者并行、机会并行和整个网格的全局同步。

合作组编程模型由以下元素组成:

  • 表示协作线程组的数据类型;
  • 获取由 CUDA 启动 API 定义的隐式组的操作(例如,线程块);
  • 将现有群体划分为新群体的集体;
  • 用于数据移动和操作的集体算法(例如 memcpy_async、reduce、scan);
  • 同步组内所有线程的操作;
  • 检查组属性的操作;
  • 公开低级别、特定于组且通常是硬件加速的操作的集合。

协作组中的主要概念是对象命名作为其中一部分的线程集的对象。 这种将组表示为一等程序对象的方式改进了软件组合,因为集合函数可以接收表示参与线程组的显式对象。 该对象还明确了程序员的意图,从而消除了不合理的架构假设,这些假设会导致代码脆弱、对编译器优化的不良限制以及与新一代 GPU 的更好兼容性。

为了编写高效的代码,最好使用专门的组(通用会失去很多编译时优化),并通过引用打算以某种协作方式使用这些线程的函数来传递这些组对象。

合作组需要 CUDA 9.0 或更高版本。 要使用合作组,请包含头文件:

1
2
3
4
5
6
7
8
// Primary header is compatible with pre-C++11, collective algorithm headers require C++11
#include <cooperative_groups.h>
// Optionally include for memcpy_async() collective
#include <cooperative_groups/memcpy_async.h>
// Optionally include for reduce() collective
#include <cooperative_groups/reduce.h>
// Optionally include for inclusive_scan() and exclusive_scan() collectives
#include <cooperative_groups/scan.h>

并使用合作组命名空间:

1
2
3
using namespace cooperative_groups;
// Alternatively use an alias to avoid polluting the namespace with collective algorithms
namespace cg = cooperative_groups;

可以使用 nvcc 以正常方式编译代码,但是如果您希望使用 memcpy_async、reducescan 功能并且您的主机编译器的默认不是 C++11 或更高版本,那么您必须添加 --std=c++11到命令行。

C.3.1. Composition Example

为了说明组的概念,此示例尝试执行块范围的求和。 以前,编写此代码时对实现存在隐藏的约束:

1
2
3
4
5
6
7
8
9
10
11
__device__ int sum(int *x, int n) {
// ...
__syncthreads();
return total;
}

__global__ void parallel_kernel(float *x) {
// ...
// Entire thread block must call sum
sum(x, n);
}

线程块中的所有线程都必须到达 __syncthreads() 屏障,但是,对于可能想要使用 sum(...) 的开发人员来说,这个约束是隐藏的。 对于合作组,更好的编写方式是:

1
2
3
4
5
6
7
8
9
10
11
12
13
__device__ int sum(const thread_block& g, int *x, int n) {
// ...
g.sync()
return total;
}

__global__ void parallel_kernel(...) {
// ...
// Entire thread block must call sum
thread_block tb = this_thread_block();
sum(tb, x, n);
// ...
}

C.4. Group Types

C.4.1. Implicit Groups

隐式组代表内核的启动配置。不管你的内核是如何编写的,它总是有一定数量的线程、块和块尺寸、单个网格和网格尺寸。另外,如果使用多设备协同启动API,它可以有多个网格(每个设备一个网格)。这些组为分解为更细粒度的组提供了起点,这些组通常是硬件加速的,并且更专门针对开发人员正在解决的问题。

尽管您可以在代码中的任何位置创建隐式组,但这样做很危险。为隐式组创建句柄是一项集体操作——组中的所有线程都必须参与。如果组是在并非所有线程都到达的条件分支中创建的,则可能导致死锁或数据损坏。出于这个原因,建议您预先为隐式组创建一个句柄(尽可能早,在任何分支发生之前)并在整个内核中使用该句柄。出于同样的原因,必须在声明时初始化组句柄(没有默认构造函数),并且不鼓励复制构造它们。

C.4.1.1. Thread Block Group

任何 CUDA 程序员都已经熟悉某一组线程:线程块。 Cooperative Groups 扩展引入了一个新的数据类型 thread_block,以在内核中明确表示这个概念。

1
class thread_block;
1
thread_block g = this_thread_block();

公开成员函数:

static void sync(): Synchronize the threads named in the group
static unsigned int thread_rank(): Rank of the calling thread within [0, num_threads)
static dim3 group_index(): 3-Dimensional index of the block within the launched grid
static dim3 thread_index(): 3-Dimensional index of the thread within the launched block
static dim3 dim_threads(): Dimensions of the launched block in units of threads
static unsigned int num_threads(): Total number of threads in the group

旧版成员函数(别名):

static unsigned int size(): Total number of threads in the group (alias of num_threads())
static dim3 group_dim(): Dimensions of the launched block (alias of dim_threads())

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
/// Loading an integer from global into shared memory
__global__ void kernel(int *globalInput) {
__shared__ int x;
thread_block g = this_thread_block();
// Choose a leader in the thread block
if (g.thread_rank() == 0) {
// load from global into shared for all threads to work with
x = (*globalInput);
}
// After loading data into shared memory, you want to synchronize
// if all threads in your thread block need to see it
g.sync(); // equivalent to __syncthreads();
}

注意:组中的所有线程都必须参与集体操作,否则行为未定义。

相关:thread_block 数据类型派生自更通用的 thread_group 数据类型,可用于表示更广泛的组类。

C.4.1.2. Grid Group

该组对象表示在单个网格中启动的所有线程。 除了 sync() 之外的 API 始终可用,但要能够跨网格同步,您需要使用协作启动 API。

1
2
class grid_group;
grid_group g = this_grid();

公开成员函数:

bool is_valid() const: Returns whether the grid_group can synchronize
void sync() const: Synchronize the threads named in the group
static unsigned long long thread_rank(): Rank of the calling thread within [0, num_threads)
static unsigned long long block_rank(): Rank of the calling block within [0, num_blocks)
static unsigned long long num_threads(): Total number of threads in the group
static unsigned long long num_blocks(): Total number of blocks in the group
static dim3 dim_blocks(): Dimensions of the launched grid in units of blocks
static dim3 block_index(): 3-Dimensional index of the block within the launched grid

旧版成员函数(别名):

static unsigned long long size(): Total number of threads in the group (alias of num_threads())
static dim3 group_dim(): Dimensions of the launched grid (alias of dim_blocks())

C.4.1.3. Multi Grid Group

该组对象表示跨设备协作组启动的所有设备启动的所有线程。 与 grid.group 不同,所有 API 都要求您使用适当的启动 API。

1
class multi_grid_group;

通过一下方式构建:

1
2
// Kernel must be launched with the cooperative multi-device API
multi_grid_group g = this_multi_grid();

公开成员函数:

bool is_valid() const: Returns whether the multi_grid_group can be used
void sync() const: Synchronize the threads named in the group
unsigned long long num_threads() const: Total number of threads in the group
unsigned long long thread_rank() const: Rank of the calling thread within [0, num_threads)
unsigned int grid_rank() const: Rank of the grid within [0,num_grids]
unsigned int num_grids() const: Total number of grids launched

旧版成员函数(别名):

unsigned long long size() const: Total number of threads in the group (alias of num_threads())

C.4.2. Explicit Groups

C.4.2.1. Thread Block Tile

tile组的模板版本,其中模板参数用于指定tile的大小 - 在编译时已知这一点,有可能实现更优化的执行。

1
2
template <unsigned int Size, typename ParentT = void>
class thread_block_tile;

通过以下构建:

1
2
template <unsigned int Size, typename ParentT>
_CG_QUALIFIER thread_block_tile<Size, ParentT> tiled_partition(const ParentT& g)

Size必须是 2 的幂且小于或等于 32。

ParentT 是从其中划分该组的父类型。 它是自动推断的,但是 void 的值会将此信息存储在组句柄中而不是类型中。

公开成员函数:

void sync() const: Synchronize the threads named in the group
unsigned long long num_threads() const: Total number of threads in the group
unsigned long long thread_rank() const: Rank of the calling thread within [0, num_threads)
unsigned long long meta_group_size() const: Returns the number of groups created when the parent group was partitioned.
unsigned long long meta_group_rank() const: Linear rank of the group within the set of tiles partitioned from a parent group (bounded by meta_group_size)
T shfl(T var, unsigned int src_rank) const: Refer to Warp Shuffle Functions
T shfl_up(T var, int delta) const: Refer to Warp Shuffle Functions
T shfl_down(T var, int delta) const: Refer to Warp Shuffle Functions
T shfl_xor(T var, int delta) const: Refer to Warp Shuffle Functions
T any(int predicate) const: Refer to Warp Vote Functions
T all(int predicate) const: Refer to Warp Vote Functions
T ballot(int predicate) const: Refer to Warp Vote Functions
T match_any(T val) const: Refer to Warp Match Functions
T match_all(T val, int &pred) const: Refer to Match Functions

旧版成员函数(别名):

unsigned long long size() const: Total number of threads in the group (alias of num_threads())

注意:

shfl、shfl_up、shfl_down 和 shfl_xor 函数在使用 C++11 或更高版本编译时接受任何类型的对象。 这意味着只要满足以下约束,就可以对非整数类型进行shuffle :

  • 符合普通可复制的条件,即
    is_trivially_copyable<T>::value == true
  • sizeof(T) <= 32

示例:

1
2
3
4
5
/// The following code will create two sets of tiled groups, of size 32 and 4 respectively:
/// The latter has the provenance encoded in the type, while the first stores it in the handle
thread_block block = this_thread_block();
thread_block_tile<32> tile32 = tiled_partition<32>(block);
thread_block_tile<4, thread_block> tile4 = tiled_partition<4>(block);

注意:这里使用的是 thread_block_tile 模板化数据结构,并且组的大小作为模板参数而不是参数传递给 tiled_partition 调用。

C.4.2.1.1. Warp-Synchronous Code Pattern

开发人员可能拥有他们之前对 warp 大小做出隐含假设并围绕该数字进行编码的 warp 同步代码。 现在这需要明确指定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
__global__ void cooperative_kernel(...) {
// obtain default "current thread block" group
thread_block my_block = this_thread_block();

// subdivide into 32-thread, tiled subgroups
// Tiled subgroups evenly partition a parent group into
// adjacent sets of threads - in this case each one warp in size
auto my_tile = tiled_partition<32>(my_block);

// This operation will be performed by only the
// first 32-thread tile of each block
if (my_tile.meta_group_rank() == 0) {
// ...
my_tile.sync();
}
}
C.4.2.1.2. Single thread group

可以从 this_thread 函数中获取代表当前线程的组:

1
thread_block_tile<1> this_thread();

以下 memcpy_async API 使用 thread_groupint 元素从源复制到目标:

1
2
3
4
#include <cooperative_groups.h>
#include <cooperative_groups/memcpy_async.h>

cooperative_groups::memcpy_async(cooperative_groups::this_thread(), dest, src, sizeof(int));

可以在使用 cuda::pipeline 的单阶段异步数据拷贝使用 cuda::pipeline 的多阶段异步数据拷贝部分中找到使用 this_thread 执行异步复制的更详细示例。

C.4.2.1.3. Thread Block Tile of size larger than 32

使用cooperative_groups::experimental 命名空间中的新API 可以获得大小为64、128、256 或512thread_block_tile。 要使用它,_CG_ABI_EXPERIMENTAL 必须在源代码中定义。 在分区之前,必须为 thread_block_tile 保留少量内存。 这可以使用必须驻留在共享或全局内存中的cooperative_groups::experimental::block_tile_memory 结构模板来完成。

1
2
template <unsigned int TileCommunicationSize = 8, unsigned int MaxBlockSize = 1024>
struct block_tile_memory;

TileCommunicationSize 确定为集体操作保留多少内存。 如果对大于指定通信大小的大小类型执行此类操作,则集合可能涉及多次传输并需要更长的时间才能完成。

MaxBlockSize 指定当前线程块中的最大线程数。 此参数可用于最小化仅以较小线程数启动的内核中 block_tile_memory 的共享内存使用量。

然后这个 block_tile_memory 需要被传递到cooperative_groups::experimental::this_thread_block,允许将生成的 thread_block 划分为大小大于 32 的tile。 this_thread_block 接受 block_tile_memory 参数的重载是一个集体操作,必须与所有线程一起调用 线程块。 返回的线程块可以使用experimental::tiled_partition 函数模板进行分区,该模板接受与常规tiled_partition 相同的参数。

1
2
3
4
5
6
7
8
9
10
11
#define _CG_ABI_EXPERIMENTAL // enable experimental API

__global__ void cooperative_kernel(...) {
// reserve shared memory for thread_block_tile usage.
__shared__ experimental::block_tile_memory<4, 256> shared;
thread_block thb = experimental::this_thread_block(shared);

auto tile = experimental::tiled_partition<128>(thb);

// ...
}

公开成员函数:

void sync() const: Synchronize the threads named in the group
unsigned long long num_threads() const: Total number of threads in the group
unsigned long long thread_rank() const: Rank of the calling thread within [0, num_threads)
unsigned long long meta_group_size() const: Returns the number of groups created when the parent group was partitioned.
unsigned long long meta_group_rank() const: Linear rank of the group within the set of tiles partitioned from a parent group (bounded by meta_group_size)
T shfl(T var, unsigned int src_rank) const: Refer to Warp Shuffle Functions, Note: All threads in the group have to specify the same src_rank, otherwise the behavior is undefined.
T any(int predicate) const: Refer to Warp Vote Functions
T all(int predicate) const: Refer to Warp Vote Functions

旧版成员函数(别名):

unsigned long long size() const: Total number of threads in the group (alias of num_threads())

C.4.2.2. Coalesced Groups

在 CUDA 的 SIMT 架构中,在硬件级别,多处理器以 32 个一组的线程执行线程,称为 warp。 如果应用程序代码中存在依赖于数据的条件分支,使得 warp 中的线程发散,那么 warp 会串行执行每个分支,禁用不在该路径上的线程。 在路径上保持活动的线程称为合并。 协作组具有发现和创建包含所有合并线程的组的功能。

通过 coalesced_threads() 构造组句柄是伺机的(opportunistic)。 它在那个时间点返回一组活动线程,并且不保证返回哪些线程(只要它们是活动的)或者它们在整个执行过程中保持合并(它们将被重新组合在一起以执行一个集合,但之后可以再次发散)。

1
class coalesced_group;

通过以下重构:

1
coalesced_group active = coalesced_threads();

公开成员函数:

void sync() const: Synchronize the threads named in the group
unsigned long long num_threads() const: Total number of threads in the group
unsigned long long thread_rank() const: Rank of the calling thread within [0, num_threads)
unsigned long long meta_group_size() const: Returns the number of groups created when the parent group was partitioned. If this group was created by querying the set of active threads, e.g. coalesced_threads() the value of meta_group_size() will be 1.
unsigned long long meta_group_rank() const: Linear rank of the group within the set of tiles partitioned from a parent group (bounded by meta_group_size). If this group was created by querying the set of active threads, e.g. coalesced_threads() the value of meta_group_rank() will always be 0.
T shfl(T var, unsigned int src_rank) const: Refer to Warp Shuffle Functions
T shfl_up(T var, int delta) const: Refer to Warp Shuffle Functions
T shfl_down(T var, int delta) const: Refer to Warp Shuffle Functions
T any(int predicate) const: Refer to Warp Vote Functions
T all(int predicate) const: Refer to Warp Vote Functions
T ballot(int predicate) const: Refer to Warp Vote Functions
T match_any(T val) const: Refer to Warp Match Functions
T match_all(T val, int &pred) const: Refer to Warp Match Functions

旧版成员函数(别名):

unsigned long long size() const: Total number of threads in the group (alias of num_threads())

注意:shfl、shfl_up 和 shfl_down 函数在使用 C++11 或更高版本编译时接受任何类型的对象。 这意味着只要满足以下约束,就可以对非整数类型进行洗牌:

  • 符合普通可复制的条件,即is_trivially_copyable<T>::value == true
  • sizeof(T) <= 32

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
/// Consider a situation whereby there is a branch in the
/// code in which only the 2nd, 4th and 8th threads in each warp are
/// active. The coalesced_threads() call, placed in that branch, will create (for each
/// warp) a group, active, that has three threads (with
/// ranks 0-2 inclusive).
__global__ void kernel(int *globalInput) {
// Lets say globalInput says that threads 2, 4, 8 should handle the data
if (threadIdx.x == *globalInput) {
coalesced_group active = coalesced_threads();
// active contains 0-2 inclusive
active.sync();
}
}

C.4.2.2.1. Discovery Pattern

通常,开发人员需要使用当前活动的线程集。 不对存在的线程做任何假设,而是开发人员使用碰巧存在的线程。 这可以在以下“在warp中跨线程聚合原子增量”示例中看到(使用正确的 CUDA 9.0 内在函数集编写):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
unsigned int writemask = __activemask();
unsigned int total = __popc(writemask);
unsigned int prefix = __popc(writemask & __lanemask_lt());
// Find the lowest-numbered active lane
int elected_lane = __ffs(writemask) - 1;
int base_offset = 0;
if (prefix == 0) {
base_offset = atomicAdd(p, total);
}
base_offset = __shfl_sync(writemask, base_offset, elected_lane);
int thread_offset = prefix + base_offset;
return thread_offset;
}

这可以用Cooperative Groups重写如下:

1
2
3
4
5
6
7
8
9
{
cg::coalesced_group g = cg::coalesced_threads();
int prev;
if (g.thread_rank() == 0) {
prev = atomicAdd(p, g.num_threads());
}
prev = g.thread_rank() + g.shfl(prev, 0);
return prev;
}

C.5. Group Partitioning

C.5.1. tiled_partition

1
2
3
4
template <unsigned int Size, typename ParentT>
thread_block_tile<Size, ParentT> tiled_partition(const ParentT& g);

thread_group tiled_partition(const thread_group& parent, unsigned int tilesz);

tiled_partition 方法是一种集体操作,它将父组划分为一维、行主序的子组平铺。 总共将创建 ((size(parent)/tilesz) 子组,因此父组大小必须能被 Size 整除。允许的父组是 thread_blockthread_block_tile

该实现可能导致调用线程在恢复执行之前等待,直到父组的所有成员都调用了该操作。功能仅限于本地硬件大小,1/2/4/8/16/32cg::size(parent)必须大于size参数。cooperative_groups::experimental命名空间的实验版本支持64/128/256/512大小。

Codegen 要求:计算能力 3.5 最低,C++11 用于大于 32 的size

示例:

1
2
3
/// The following code will create a 32-thread tile
thread_block block = this_thread_block();
thread_block_tile<32> tile32 = tiled_partition<32>(block);

我们可以将这些组中的每一个分成更小的组,每个组的大小为 4 个线程:

1
2
3
auto tile4 = tiled_partition<4>(tile32);
// or using a general group
// thread_group tile4 = tiled_partition(tile32, 4);

例如,如果我们要包含以下代码行:

1
if (tile4.thread_rank()==0) printf(“Hello from tile4 rank 0\n”);

那么该语句将由块中的每四个线程打印:每个 tile4 组中排名为 0 的线程,它们对应于块组中排名为 0、4、8、12.. 的那些线程。

C.5.2. labeled_partition

1
2
3
coalesced_group labeled_partition(const coalesced_group& g, int label);
template <unsigned int Size>
coalesced_group labeled_partition(const thread_block_tile<Size>& g, int label);

labeled_partition 方法是一种集体操作,它将父组划分为一维子组,线程在这些子组中合并。 该实现将评估条件标签并将具有相同标签值的线程分配到同一组中。

该实现可能会导致调用线程在恢复执行之前等待直到父组的所有成员都调用了该操作。

注意:此功能仍在评估中,将来可能会略有变化。

Codegen 要求:计算能力 7.0 最低,C++11

C.5.3. binary_partition

1
2
3
coalesced_group binary_partition(const coalesced_group& g, bool pred);
template <unsigned int Size>
coalesced_group binary_partition(const thread_block_tile<Size>& g, bool pred);

binary_partition() 方法是一种集体操作,它将父组划分为一维子组,线程在其中合并。 该实现将评估predicate并将具有相同值的线程分配到同一组中。 这是labeled_partition() 的一种特殊形式,其中label 只能是0 或1。

该实现可能会导致调用线程在恢复执行之前等待直到父组的所有成员都调用了该操作。

注意:此功能仍在评估中,将来可能会略有变化。

Codegen 要求:计算能力 7.0 最低,C++11

示例:

1
2
3
4
5
6
7
8
9
10
11
12
/// This example divides a 32-sized tile into a group with odd
/// numbers and a group with even numbers
_global__ void oddEven(int *inputArr) {
cg::thread_block cta = cg::this_thread_block();
cg::thread_block_tile<32> tile32 = cg::tiled_partition<32>(cta);

// inputArr contains random integers
int elem = inputArr[cta.thread_rank()];
// after this, tile32 is split into 2 groups,
// a subtile where elem&1 is true and one where its false
auto subtile = cg::binary_partition(tile32, (elem & 1));
}

C.6. Group Collectives

C.6.1. Synchronization

C.6.1.1. sync

1
cooperative_groups::sync(T& group);

sync 同步组中指定的线程。 T 可以是任何现有的组类型,因为它们都支持同步。 如果组是 grid_groupmulti_grid_group,则内核必须已使用适当的协作启动 API 启动。

C.6.2. Data Transfer

C.6.2.1. memcpy_async

memcpy_async 是一个组范围的集体 memcpy,它利用硬件加速支持从全局到共享内存的非阻塞内存事务。给定组中命名的一组线程,memcpy_async 将通过单个管道阶段传输指定数量的字节或输入类型的元素。此外,为了在使用 memcpy_async API 时获得最佳性能,共享内存和全局内存都需要 16 字节对齐。需要注意的是,虽然在一般情况下这是一个 memcpy,但只有当源(source)是全局内存而目标是共享内存并且两者都可以通过 16、8 或 4 字节对齐来寻址时,它才是异步的。异步复制的数据只能在调用 wait wait_prior 之后读取,这表明相应阶段已完成将数据移动到共享内存。

必须等待所有未完成的请求可能会失去一些灵活性(但会变得简单)。为了有效地重叠数据传输和执行,重要的是能够在等待和操作请求 N 时启动 N+1 memcpy_async 请求。为此,请使用 memcpy_async 并使用基于集体阶段的 wait_prior API 等待它.有关详细信息,请参阅 wait 和 wait_prior

用法1:

1
2
3
4
5
6
7
template <typename TyGroup, typename TyElem, typename TyShape>
void memcpy_async(
const TyGroup &group,
TyElem *__restrict__ _dst,
const TyElem *__restrict__ _src,
const TyShape &shape
);

执行shape字节的拷贝

用法2:

1
2
3
4
5
6
7
8
template <typename TyGroup, typename TyElem, typename TyDstLayout, typename TySrcLayout>
void memcpy_async(
const TyGroup &group,
TyElem *__restrict__ dst,
const TyDstLayout &dstLayout,
const TyElem *__restrict__ src,
const TySrcLayout &srcLayout
);

执行 min(dstLayout, srcLayout) 元素的拷贝。 如果布局的类型为 cuda::aligned_size_t<N>,则两者必须指定相同的对齐方式。

勘误表

CUDA 11.1 中引入的具有 src 和 dst 输入布局的 memcpy_async API 期望布局以元素而不是字节形式提供。 元素类型是从 TyElem 推断出来的,大小为 sizeof(TyElem)。 如果使用 cuda::aligned_size_t<N> 类型作为布局,指定的元素个数乘以 sizeof(TyElem) 必须是 N 的倍数,建议使用 std::bytechar 作为元素类型。

如果副本的指定形状或布局是 cuda::aligned_size_t<N> 类型,则将保证至少为 min(16, N)。 在这种情况下,dst 和 src 指针都需要与 N 个字节对齐,并且复制的字节数需要是 N 的倍数。

Codegen 要求:最低计算能力 3.5,异步计算能力 8.0,C++11

需要包含collaborative_groups/memcpy_async.h 头文件。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/// This example streams elementsPerThreadBlock worth of data from global memory
/// into a limited sized shared memory (elementsInShared) block to operate on.
#include <cooperative_groups.h>
#include <cooperative_groups/memcpy_async.h>

namespace cg = cooperative_groups;

__global__ void kernel(int* global_data) {
cg::thread_block tb = cg::this_thread_block();
const size_t elementsPerThreadBlock = 16 * 1024;
const size_t elementsInShared = 128;
__shared__ int local_smem[elementsInShared];

size_t copy_count;
size_t index = 0;
while (index < elementsPerThreadBlock) {
cg::memcpy_async(tb, local_smem, elementsInShared, global_data + index, elementsPerThreadBlock - index);
copy_count = min(elementsInShared, elementsPerThreadBlock - index);
cg::wait(tb);
// Work with local_smem
index += copy_count;
}
}

C.6.2.2. wait and wait_prior

1
2
3
4
5
template <typename TyGroup>
void wait(TyGroup & group);

template <unsigned int NumStages, typename TyGroup>
void wair_prior(TyGroup & group);

waitwait_prior 集合同步指定的线程和线程块,直到所有未完成的 memcpy_async 请求(在等待的情况下)或第一个 NumStages(在 wait_prior 的情况下)完成。

Codegen 要求:最低计算能力 3.5,异步计算能力 8.0,C++11

需要包含collaborative_groups/memcpy_async.h 头文件。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/// This example streams elementsPerThreadBlock worth of data from global memory
/// into a limited sized shared memory (elementsInShared) block to operate on in
/// multiple (two) stages. As stage N is kicked off, we can wait on and operate on stage N-1.
#include <cooperative_groups.h>
#include <cooperative_groups/memcpy_async.h>

namespace cg = cooperative_groups;

__global__ void kernel(int* global_data) {
cg::thread_block tb = cg::this_thread_block();
const size_t elementsPerThreadBlock = 16 * 1024 + 64;
const size_t elementsInShared = 128;
__align__(16) __shared__ int local_smem[2][elementsInShared];
int stage = 0;
// First kick off an extra request
size_t copy_count = elementsInShared;
size_t index = copy_count;
cg::memcpy_async(tb, local_smem[stage], elementsInShared, global_data, elementsPerThreadBlock - index);
while (index < elementsPerThreadBlock) {
// Now we kick off the next request...
cg::memcpy_async(tb, local_smem[stage ^ 1], elementsInShared, global_data + index, elementsPerThreadBlock - index);
// ... but we wait on the one before it
cg::wait_prior<1>(tb);

// Its now available and we can work with local_smem[stage] here
// (...)
//

// Calculate the amount fo data that was actually copied, for the next iteration.
copy_count = min(elementsInShared, elementsPerThreadBlock - index);
index += copy_count;

// A cg::sync(tb) might be needed here depending on whether
// the work done with local_smem[stage] can release threads to race ahead or not
// Wrap to the next stage
stage ^= 1;
}
cg::wait(tb);
// The last local_smem[stage] can be handled here

C.6.3. Data manipulation

C.6.3.1. reduce

1
2
template <typename TyArg, typename TyOp, typename TyGroup>
auto reduce(const TyGroup& group, TyArg&& val, TyOp&& op) -> decltype(op(val, val));

reduce 对传入的组中指定的每个线程提供的数据执行归约操作。这利用硬件加速(在计算 80 及更高的设备上)进行算术加法、最小或最大操作以及逻辑 AND、OR、或 XOR,以及在老一代硬件上提供软件替代支持(fallback)。只有 4B 类型由硬件加速。

group:有效的组类型是 coalesced_groupthread_block_tile

val:满足以下要求的任何类型:

  • 符合普通可复制的条件,即 is_trivially_copyable<TyArg>::value == true
  • sizeof(TyArg) <= 32
  • 对给定的函数对象具有合适的算术或比较运算符。

op:将提供具有整数类型的硬件加速的有效函数对象是 plus()less()greater()bit_and()bit_xor()bit_or()。这些必须构造,因此需要 TyVal 模板参数,即 plus<int>()Reduce 还支持可以使用 operator() 调用的 lambda 和其他函数对象

Codegen 要求:计算能力 3.5 最低,计算能力 8.0 用于硬件加速,C++11。

需要包含collaborative_groups/reduce.h 头文件。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <cooperative_groups.h>
#include <cooperative_groups/reduce.h>
namespace cg=cooperative_groups;

/// The following example accepts input in *A and outputs a result into *sum
/// It spreads the data within the block, one element per thread
#define blocksz 256
__global__ void block_reduce(const int *A, int *sum) {
__shared__ int reduction_s[blocksz];

cg::thread_block cta = cg::this_thread_block();
cg::thread_block_tile<32> tile = cg::tiled_partition<32>(cta);

const int tid = cta.thread_rank();
int beta = A[tid];
// reduce across the tile
// cg::plus<int> allows cg::reduce() to know it can use hardware acceleration for addition
reduction_s[tid] = cg::reduce(tile, beta, cg::plus<int>());
// synchronize the block so all data is ready
cg::sync(cta);
// single leader accumulates the result
if (cta.thread_rank() == 0) {
beta = 0;
for (int i = 0; i < blocksz; i += tile.num_threads()) {
beta += reduction_s[i];
}
sum[blockIdx.x] = beta;
}

C.6.3.2. Reduce Operators

下面是一些可以用reduce完成的基本操作的函数对象的原型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
namespace cooperative_groups {
template <typename Ty>
struct cg::plus;

template <typename Ty>
struct cg::less;

template <typename Ty>
struct cg::greater;

template <typename Ty>
struct cg::bit_and;

template <typename Ty>
struct cg::bit_xor;

template <typename Ty>
struct cg::bit_or;
}

Reduce 仅限于在编译时可用于实现的信息。 因此,为了利用 CC 8.0 中引入的内在函数,cg:: 命名空间公开了几个镜像硬件的功能对象。 这些对象看起来与 C++ STL 中呈现的对象相似,除了 less/greater。 与 STL 有任何差异的原因在于,这些函数对象旨在实际反映硬件内联函数的操作。

功能说明:

  • cg::plus:接受两个值并使用 operator + 返回两者之和。
  • cg::less: 接受两个值并使用 operator < 返回较小的值。 这不同之处在于返回较低的值而不是布尔值。
  • cg::greater:接受两个值并使用 operator < 返回较大的值。 这不同之处在于返回更大的值而不是布尔值。
  • cg::bit_and:接受两个值并返回operator &的结果。
  • cg::bit_xor:接受两个值并返回operator ^的结果。
  • cg::bit_or:接受两个值并返回 operator | 的结果。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
// cg::plus<int> is specialized within cg::reduce and calls __reduce_add_sync(...) on CC 8.0+
cg::reduce(tile, (int)val, cg::plus<int>());

// cg::plus<float> fails to match with an accelerator and instead performs a standard shuffle based reduction
cg::reduce(tile, (float)val, cg::plus<float>());

// While individual components of a vector are supported, reduce will not use hardware intrinsics for the following
// It will also be necessary to define a corresponding operator for vector and any custom types that may be used
int4 vec = {...};
cg::reduce(tile, vec, cg::plus<int4>())

// Finally lambdas and other function objects cannot be inspected for dispatch
// and will instead perform shuffle based reductions using the provided function object.
cg::reduce(tile, (int)val, [](int l, int r) -> int {return l + r;});
}

C.6.3.3. inclusive_scan and exclusive_scan

1
2
3
4
5
6
7
8
9
10
11
template <typename TyGroup, typename TyVal, typename TyFn>
auto inclusive_scan(const TyGroup& group, TyVal&& val, TyFn&& op) -> decltype(op(val, val));

template <typename TyGroup, typename TyVal>
TyVal inclusive_scan(const TyGroup& group, TyVal&& val);

template <typename TyGroup, typename TyVal, typename TyFn>
auto exclusive_scan(const TyGroup& group, TyVal&& val, TyFn&& op) -> decltype(op(val, val));

template <typename TyGroup, typename TyVal>
TyVal exclusive_scan(const TyGroup& group, TyVal&& val);

inclusive_scanexclusive_scan 对传入组中指定的每个线程提供的数据执行扫描操作。在exclusive_scan 的情况下,每个线程的结果是减少thread_rank 低于该线程的线程的数据。 inclusive_scan 的结果还包括调用线程中的归约数据。

group:有效的组类型是 coalesced_groupthread_block_tile

val:满足以下要求的任何类型:

  • 符合普通可复制的条件,即 is_trivially_copyable<TyArg>::value == true
  • sizeof(TyArg) <= 32
  • 对给定的函数对象具有合适的算术或比较运算符。

op:为了方便而定义的函数对象有reduce Operators中描述的plus()less()greater()bit_and()bit_xor()bit_or()。这些必须构造,因此需要 TyVal 模板参数,即 plus<int>()inclusive_scanexclusive_scan 还支持可以使用 operator() 调用的 lambdas 和其他函数对象

Codegen 要求:计算能力 3.5 最低,C++11。

需要包含collaborative_groups/scan.h 头文件。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>
#include <cooperative_groups.h>
#include <cooperative_groups/scan.h>
namespace cg = cooperative_groups;

__global__ void kernel() {
auto thread_block = cg::this_thread_block();
auto tile = cg::tiled_partition<8>(thread_block);
unsigned int val = cg::inclusive_scan(tile, tile.thread_rank());
printf("%u: %u\n", tile.thread_rank(), val);
}

/* prints for each group:
0: 0
1: 1
2: 3
3: 6
4: 10
5: 15
6: 21
7: 28
*/

使用 Exclusive_scan 进行动态缓冲区空间分配的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <cooperative_groups.h>
#include <cooperative_groups/scan.h>
namespace cg = cooperative_groups;

// Buffer partitioning is static to make the example easier to follow,
// but any arbitrary dynamic allocation scheme can be implemented by replacing this function.
__device__ int calculate_buffer_space_needed(cg::thread_block_tile<32>& tile) {
return tile.thread_rank() % 2 + 1;
}

__device__ int my_thread_data(int i) {
return i;
}

__global__ void kernel() {
__shared__ int buffer_used;
extern __shared__ int buffer[];
auto thread_block = cg::this_thread_block();
auto tile = cg::tiled_partition<32>(thread_block);

buffer_used = 0;
thread_block.sync();

// each thread calculates buffer size it needs and its offset within the allocation
int buf_needed = calculate_buffer_space_needed(tile);
int buf_offset = cg::exclusive_scan(tile, buf_needed);

// last thread in the tile allocates buffer space with an atomic operation
int alloc_offset = 0;
if (tile.thread_rank() == tile.num_threads() - 1) {
alloc_offset = atomicAdd(&buffer_used, buf_offset + buf_needed);
}
// that thread shares the allocation start with other threads in the tile
alloc_offset = tile.shfl(alloc_offset, tile.num_threads() - 1);
buf_offset += alloc_offset;

// each thread fill its part of the buffer with thread specific data
for (int i = 0 ; i < buf_needed ; ++i) {
buffer[buf_offset + i] = my_thread_data(i);
}

// buffer is {0, 0, 1, 0, 0, 1 ...};
}

C.7. Grid Synchronization

在引入协作组(Cooperative Groups)之前,CUDA 编程模型只允许在内核完成边界的线程块之间进行同步。内核边界带有隐含的状态失效,以及潜在的性能影响。

例如,在某些用例中,应用程序具有大量小内核,每个内核代表处理pipeline中的一个阶段。当前的 CUDA 编程模型需要这些内核的存在,以确保在一个pipeline阶段上运行的线程块在下一个pipeline阶段上运行的线程块准备好使用数据之前产生数据。在这种情况下,提供全局线程间块同步的能力将允许将应用程序重组为具有持久线程块,当给定阶段完成时,这些线程块能够在设备上同步。

要从内核中跨网格同步,您只需使用 grid.sync() 功能:

1
2
grid_group grid = this_grid();
grid.sync();

并且在启动内核时,有必要使用 cudaLaunchCooperativeKernel CUDA 运行时启动 API 或 CUDA 驱动程序等价物,而不是 <<<…>>> 执行配置语法。

例子:

为了保证线程块在 GPU 上的共同驻留,需要仔细考虑启动的块数。 例如,可以按如下方式启动与 SM 一样多的块:

1
2
3
4
5
int device = 0;
cudaDeviceProp deviceProp;
cudaGetDeviceProperties(&deviceProp, dev);
// initialize, then launch
cudaLaunchCooperativeKernel((void*)my_kernel, deviceProp.multiProcessorCount, numThreads, args);

或者,您可以通过使用占用计算器(occupancy calculator)计算每个 SM 可以同时容纳多少块来最大化暴露的并行度,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
/// This will launch a grid that can maximally fill the GPU, on the default stream with kernel arguments
int numBlocksPerSm = 0;
// Number of threads my_kernel will be launched with
int numThreads = 128;
cudaDeviceProp deviceProp;
cudaGetDeviceProperties(&deviceProp, dev);
cudaOccupancyMaxActiveBlocksPerMultiprocessor(&numBlocksPerSm, my_kernel, numThreads, 0);
// launch
void *kernelArgs[] = { /* add kernel args */ };
dim3 dimBlock(numThreads, 1, 1);
dim3 dimGrid(deviceProp.multiProcessorCount*numBlocksPerSm, 1, 1);
cudaLaunchCooperativeKernel((void*)my_kernel, dimGrid, dimBlock, kernelArgs);

最好先通过查询设备属性 cudaDevAttrCooperativeLaunch 来确保设备支持协作启动:

1
2
3
int dev = 0;
int supportsCoopLaunch = 0;
cudaDeviceGetAttribute(&supportsCoopLaunch, cudaDevAttrCooperativeLaunch, dev);

如果设备 0 支持该属性,则将 supportsCoopLaunch 设置为 1。仅支持计算能力为 6.0 及更高版本的设备。 此外,您需要在以下任何一个上运行:

  • 没有 MPS 的 Linux 平台
  • 具有 MPS 和计算能力 7.0 或更高版本的设备上的 Linux 平台
  • 最新的 Windows 平台

C.8. Multi-Device Synchronization

为了通过协作组启用跨多个设备的同步,需要使用 cudaLaunchCooperativeKernelMultiDevice CUDA API。这与现有的 CUDA API 有很大不同,它将允许单个主机线程跨多个设备启动内核。除了 cudaLaunchCooperativeKernel 做出的约束和保证之外,这个 API 还具有额外的语义:

  • 此 API 将确保启动是原子的,即如果 API 调用成功,则提供的线程块数将在所有指定设备上启动。
  • 通过此 API 启动的功能必须相同。驱动程序在这方面没有进行明确的检查,因为这在很大程度上是不可行的。由应用程序来确保这一点。
  • 提供的 cudaLaunchParams 中没有两个条目可以映射到同一设备。
  • 本次发布所针对的所有设备都必须具有相同的计算能力——主要版本和次要版本。
  • 每个网格的块大小、网格大小和共享内存量在所有设备上必须相同。请注意,这意味着每个设备可以启动的最大块数将受到 SM 数量最少的设备的限制。
  • 拥有正在启动的 CUfunction 的模块中存在的任何用户定义的 deviceconstantmanaged 设备全局变量都在每个设备上独立实例化。用户负责适当地初始化此类设备全局变量。

弃用通知:cudaLaunchCooperativeKernelMultiDevice 已在 CUDA 11.3 中针对所有设备弃用。在多设备共轭梯度样本中可以找到替代方法的示例。

多设备同步的最佳性能是通过 cuCtxEnablePeerAccesscudaDeviceEnablePeerAccess 为所有参与设备启用对等访问来实现的。

启动参数应使用结构数组(每个设备一个)定义,并使用 cudaLaunchCooperativeKernelMultiDevice 启动

Example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
cudaDeviceProp deviceProp;
cudaGetDeviceCount(&numGpus);

// Per device launch parameters
cudaLaunchParams *launchParams = (cudaLaunchParams*)malloc(sizeof(cudaLaunchParams) * numGpus);
cudaStream_t *streams = (cudaStream_t*)malloc(sizeof(cudaStream_t) * numGpus);

// The kernel arguments are copied over during launch
// Its also possible to have individual copies of kernel arguments per device, but
// the signature and name of the function/kernel must be the same.
void *kernelArgs[] = { /* Add kernel arguments */ };

for (int i = 0; i < numGpus; i++) {
cudaSetDevice(i);
// Per device stream, but its also possible to use the default NULL stream of each device
cudaStreamCreate(&streams[i]);
// Loop over other devices and cudaDeviceEnablePeerAccess to get a faster barrier implementation
}
// Since all devices must be of the same compute capability and have the same launch configuration
// it is sufficient to query device 0 here
cudaGetDeviceProperties(&deviceProp[i], 0);
dim3 dimBlock(numThreads, 1, 1);
dim3 dimGrid(deviceProp.multiProcessorCount, 1, 1);
for (int i = 0; i < numGpus; i++) {
launchParamsList[i].func = (void*)my_kernel;
launchParamsList[i].gridDim = dimGrid;
launchParamsList[i].blockDim = dimBlock;
launchParamsList[i].sharedMem = 0;
launchParamsList[i].stream = streams[i];
launchParamsList[i].args = kernelArgs;
}
cudaLaunchCooperativeKernelMultiDevice(launchParams, numGpus);

此外,与网格范围的同步一样,生成的设备代码看起来非常相似:

1
2
multi_grid_group multi_grid = this_multi_grid();
multi_grid.sync();

但是,需要通过将 -rdc=true 传递给 nvcc 来单独编译代码。

最好先通过查询设备属性 cudaDevAttrCooperativeMultiDeviceLaunch 来确保设备支持多设备协作启动:

1
2
3
int dev = 0;
int supportsMdCoopLaunch = 0;
cudaDeviceGetAttribute(&supportsMdCoopLaunch, cudaDevAttrCooperativeMultiDeviceLaunch, dev);

如果设备 0 支持该属性,则将 supportsMdCoopLaunch 设置为 1。仅支持计算能力为 6.0 及更高版本的设备。 此外,您需要在 Linux 平台(无 MPS)或当前版本的 Windows 上运行,并且设备处于 TCC 模式。

有关更多信息,请参阅 cudaLaunchCooperativeKernelMultiDevice API 文档。

附录D-CUDA的动态并行

D.1. Introduction

D.1.1. Overview

Dynamic Parallelism是 CUDA 编程模型的扩展,使 CUDA 内核能够直接在 GPU 上创建新工作并与新工作同步。在程序中需要的任何位置动态创建并行性提供了令人兴奋的新功能。

直接从 GPU 创建工作的能力可以减少在主机和设备之间传输执行控制和数据的需要,因为现在可以通过在设备上执行的线程在运行时做出启动配置决策。此外,可以在运行时在内核内内联生成依赖于数据的并行工作,动态利用 GPU 的硬件调度程序和负载平衡器,并根据数据驱动的决策或工作负载进行调整。以前需要修改以消除递归、不规则循环结构或其他不适合平面、单级并行性的构造的算法和编程模式可以更透明地表达。

本文档描述了支持动态并行的 CUDA 的扩展功能,包括为利用这些功能而对 CUDA 编程模型进行必要的修改和添加,以及利用此附加功能的指南和最佳实践。

警告:与父块的子内核显式同步(即在设备代码中使用 cudaDeviceSynchronize())在 CUDA 11.6 中已弃用,并计划在未来的 CUDA 版本中删除。

只有计算能力为 3.5 或更高的设备支持动态并行。

D.1.2. Glossary

本指南中使用的术语的定义。

  • Grid:网格是线程的集合。网格中的线程执行内核函数并被划分为线程。
  • Thread Block:线程块是在同一多处理器 (SM) 上执行的一组线程。线程块中的线程可以访问共享内存并且可以显式同步。
  • Kernel Function:内核函数是一个隐式并行子程序,它在 CUDA 执行和内存模型下为网格中的每个线程执行。
  • Host:Host 指的是最初调用 CUDA 的执行环境。通常是在系统的 CPU 处理器上运行的线程。
  • Parent:父线程、线程块或网格是已启动新网格、子网格的一种。直到所有启动的子网格也完成后,父节点才被视为完成。
  • Child:子线程、块或网格是由父网格启动的线程、块或网格。子网格必须在父线程、线程块或网格被认为完成之前完成。
  • Thread Block Scope:具有线程块作用域的对象具有单个线程块的生命周期。它们仅在由创建对象的线程块中的线程操作时具有定义的行为,并在创建它们的线程块完成时被销毁。
  • Device Runtime:设备运行时是指可用于使内核函数使用动态并行的运行时系统和 API。

D.2. Execution Environment and Memory Model

D.2.1. Execution Environment

CUDA 执行模型基于线程、线程块和网格的原语,内核函数定义了线程块和网格内的各个线程执行的程序。 当调用内核函数时,网格的属性由执行配置描述,该配置在 CUDA 中具有特殊的语法。 CUDA 中对动态并行性的支持扩展了在新网格上配置、启动和同步到设备上运行的线程的能力。

警告:与父块的子内核显式同步(即在设备代码中使用 cudaDeviceSynchronize() 块)在 CUDA 11.6 中已弃用,并计划在未来的 CUDA 版本中删除。

D.2.1.1. Parent and Child Grids

配置并启动新网格的设备线程属于父网格,调用创建的网格是子网格。

子网格的调用和完成是正确嵌套的,这意味着在其线程创建的所有子网格都完成之前,父网格不会被认为是完整的。 即使调用线程没有在启动的子网格上显式同步,运行时也会保证父子网格之间的隐式同步。

警告:与父块的子内核显式同步(即在设备代码中使用 cudaDeviceSynchronize())在 CUDA 11.6 中已弃用,并计划在未来的 CUDA 版本中删除。

parent-child-launch-nesting.png

D.2.1.2. Scope of CUDA Primitives

在主机和设备上,CUDA 运行时都提供了一个 API,用于启动内核、等待启动的工作完成以及通过流和事件跟踪启动之间的依赖关系。 在主机系统上,启动状态和引用流和事件的 CUDA 原语由进程内的所有线程共享; 但是进程独立执行,可能不共享 CUDA 对象。

设备上存在类似的层次结构:启动的内核和 CUDA 对象对线程块中的所有线程都是可见的,但在线程块之间是独立的。 这意味着例如一个流可以由一个线程创建并由同一线程块中的任何其他线程使用,但不能与任何其他线程块中的线程共享。

D.2.1.3. Synchronization

警告:与父块的子内核显式同步(即在设备代码中使用 cudaDeviceSynchronize())在 CUDA 11.6 中已弃用,并计划在未来的 CUDA 版本中删除。

来自任何线程的 CUDA 运行时操作,包括内核启动,在线程块中都是可见的。 这意味着父网格中的调用线程可以在由该线程启动的网格、线程块中的其他线程或在同一线程块中创建的流上执行同步。 直到块中所有线程的所有启动都完成后,才认为线程块的执行完成。 如果一个块中的所有线程在所有子启动完成之前退出,将自动触发同步操作。

D.2.1.4. Streams and Events

CUDA 流和事件允许控制网格启动之间的依赖关系:启动到同一流中的网格按顺序执行,事件可用于创建流之间的依赖关系。 在设备上创建的流和事件服务于这个完全相同的目的。

在网格中创建的流和事件存在于线程块范围内,但在创建它们的线程块之外使用时具有未定义的行为。 如上所述,线程块启动的所有工作在块退出时都会隐式同步; 启动到流中的工作包含在其中,所有依赖关系都得到了适当的解决。 已在线程块范围之外修改的流上的操作行为未定义。

在主机上创建的流和事件在任何内核中使用时具有未定义的行为,就像在子网格中使用时由父网格创建的流和事件具有未定义的行为一样。

D.2.1.5. Ordering and Concurrency

从设备运行时启动内核的顺序遵循 CUDA Stream 排序语义。在一个线程块内,所有内核启动到同一个流中都是按顺序执行的。当同一个线程块中的多个线程启动到同一个流中时,流内的顺序取决于块内的线程调度,这可以通过 __syncthreads() 等同步原语进行控制。

请注意,由于流由线程块内的所有线程共享,因此隐式 NULL 流也被共享。如果线程块中的多个线程启动到隐式流中,则这些启动将按顺序执行。如果需要并发,则应使用显式命名流。

动态并行使并发在程序中更容易表达;但是,设备运行时不会在 CUDA 执行模型中引入新的并发保证。无法保证设备上任意数量的不同线程块之间的并发执行。

缺乏并发保证延伸到父线程块及其子网格。当父线程块启动子网格时,在父线程块到达显式同步点(例如 cudaDeviceSynchronize())之前,不保证子网格开始执行。

警告:与父块的子内核显式同步(即在设备代码中使用 cudaDeviceSynchronize())在 CUDA 11.6 中已弃用,并计划在未来的 CUDA 版本中删除。

虽然并发通常很容易实现,但它可能会因设备配置、应用程序工作负载和运行时调度而异。因此,依赖不同线程块之间的任何并发性是不安全的。

D.2.1.6. Device Management

设备运行时不支持多 GPU; 设备运行时只能在其当前执行的设备上运行。 但是,允许查询系统中任何支持 CUDA 的设备的属性。

D.2.2. Memory Model

父网格和子网格共享相同的全局和常量内存存储,但具有不同的本地和共享内存。

D.2.2.1. Coherence and Consistency

D.2.2.1.1. Global Memory

父子网格可以连贯地访问全局内存,但子网格和父网格之间的一致性保证很弱。当子网格的内存视图与父线程完全一致时,子网格的执行有两点:当子网格被父线程调用时,以及当子网格线程完成时(由父线程中的同步 API 调用发出信号)。

警告:与父块的子内核显式同步(即在设备代码中使用 cudaDeviceSynchronize())在 CUDA 11.6 中已弃用,并计划在未来的 CUDA 版本中删除。

在子网格调用之前,父线程中的所有全局内存操作对子网格都是可见的。在父网格完成同步后,子网格的所有内存操作对父网格都是可见的。

在下面的示例中,执行 child_launch 的子网格只能保证看到在子网格启动之前对数据所做的修改。由于父线程 0 正在执行启动,子线程将与父线程 0 看到的内存保持一致。由于第一次 __syncthreads() 调用,孩子将看到 data[0]=0, data[1]=1, ..., data[255]=255(没有 __syncthreads() 调用,只有 data[0]将保证被孩子看到)。当子网格返回时,线程 0 保证可以看到其子网格中的线程所做的修改。只有在第二次 __syncthreads() 调用之后,这些修改才可用于父网格的其他线程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
__global__ void child_launch(int *data) {
data[threadIdx.x] = data[threadIdx.x]+1;
}

__global__ void parent_launch(int *data) {
data[threadIdx.x] = threadIdx.x;

__syncthreads();

if (threadIdx.x == 0) {
child_launch<<< 1, 256 >>>(data);
cudaDeviceSynchronize();
}

__syncthreads();
}

void host_launch(int *data) {
parent_launch<<< 1, 256 >>>(data);
}

D.2.2.1.2. Zero Copy Memory

零拷贝系统内存与全局内存具有相同的一致性和一致性保证,并遵循上面详述的语义。 内核可能不会分配或释放零拷贝内存,但可能会使用从主机程序传入的指向零拷贝的指针。

D.2.2.1.3. Constant Memory

常量是不可变的,不能从设备修改,即使在父子启动之间也是如此。 也就是说,所有 __constant__ 变量的值必须在启动之前从主机设置。 所有子内核都从各自的父内核自动继承常量内存。

从内核线程中获取常量内存对象的地址与所有 CUDA 程序具有相同的语义,并且自然支持将该指针从父级传递给子级或从子级传递给父级。

D.2.2.1.4. Shared and Local Memory

共享内存和本地内存分别是线程块或线程私有的,并且在父子之间不可见或不连贯。 当这些位置之一中的对象在其所属范围之外被引用时,行为未定义,并且可能导致错误。

如果 NVIDIA 编译器可以检测到指向本地或共享内存的指针作为参数传递给内核启动,它将尝试发出警告。 在运行时,程序员可以使用 __isGlobal() 内部函数来确定指针是否引用全局内存,因此可以安全地传递给子启动。

请注意,对 cudaMemcpy*Async()cudaMemset*Async() 的调用可能会调用设备上的新子内核以保留流语义。 因此,将共享或本地内存指针传递给这些 API 是非法的,并且会返回错误。

D.2.2.1.5. Local Memory

本地内存是执行线程的私有存储,在该线程之外不可见。 启动子内核时将指向本地内存的指针作为启动参数传递是非法的。 从子级取消引用此类本地内存指针的结果将是未定义的。

例如,如果 child_launch 访问 x_array,则以下内容是非法的,具有未定义的行为:

1
2
int x_array[10];       // Creates x_array in parent's local memory 
child_launch<<< 1, 1 >>>(x_array);

程序员有时很难知道编译器何时将变量放入本地内存。 作为一般规则,传递给子内核的所有存储都应该从全局内存堆中显式分配,或者使用 cudaMalloc()new() 或通过在全局范围内声明 __device__ 存储。 例如:

1
2
3
4
5
6
// Correct - "value" is global storage
__device__ int value;
__device__ void x() {
value = 5;
child<<< 1, 1 >>>(&value);
}
1
2
3
4
5
// Invalid - "value" is local storage
__device__ void y() {
int value = 5;
child<<< 1, 1 >>>(&value);
}

D.2.2.1.6. Texture Memory

对纹理映射的全局内存区域的写入相对于纹理访问是不连贯的。 纹理内存的一致性在子网格的调用和子网格完成时强制执行。 这意味着在子内核启动之前写入内存会反映在子内核的纹理内存访问中。 类似地,子进程对内存的写入将反映在父进程对纹理内存的访问中,但只有在父进程同步子进程完成之后。 父子并发访问可能会导致数据不一致。

警告:与父块的子内核显式同步(即在设备代码中使用 cudaDeviceSynchronize())在 CUDA 11.6 中已弃用,并计划在未来的 CUDA 版本中删除。

D.3. Programming Interface

D.3.1. CUDA C++ Reference

内核可以使用标准 CUDA <<< >>> 语法从设备启动:

1
kernel_name<<< Dg, Db, Ns, S >>>([kernel arguments]);
  • Dgdim3 类型,并指定网格(grid)的尺寸和大小
  • Dbdim3 类型,指定每个线程块(block)的维度和大小
  • Nssize_t 类型,并指定为每个线程块动态分配的共享内存字节数,用于此调用并添加到静态分配的内存中。 Ns 是一个可选参数,默认为 0。
  • ScudaStream_t 类型,并指定与此调用关联的流。 流必须已在进行调用的同一线程块中分配。 S 是一个可选参数,默认为 0。

D.3.1.1.1. Launches are Asynchronous

与主机端启动相同,所有设备端内核启动相对于启动线程都是异步的。 也就是说,<<<>>> 启动命令将立即返回,启动线程将继续执行,直到它命中一个明确的启动同步点,例如 cudaDeviceSynchronize()

警告:与父块的子内核显式同步(即在设备代码中使用 cudaDeviceSynchronize())在 CUDA 11.6 中已弃用,并计划在未来的 CUDA 版本中删除。

网格启动会发布到设备,并将独立于父线程执行。 子网格可以在启动后的任何时间开始执行,但不能保证在启动线程到达显式启动同步点之前开始执行。

D.3.1.1.2. Launch Environment Configuration

所有全局设备配置设置(例如,从 cudaDeviceGetCacheConfig() 返回的共享内存和 L1 缓存大小,以及从 cudaDeviceGetLimit() 返回的设备限制)都将从父级继承。 同样,堆栈大小等设备限制将保持配置不变。

对于主机启动的内核,从主机设置的每个内核配置将优先于全局设置。 这些配置也将在从设备启动内核时使用。 无法从设备重新配置内核环境。

D.3.1.2. Streams

设备运行时提供命名和未命名 (NULL) 流。线程块中的任何线程都可以使用命名流,但流句柄不能传递给其他块或子/父内核。换句话说,流应该被视为创建它的块的私有。流句柄不能保证在块之间是唯一的,因此在未分配它的块中使用流句柄将导致未定义的行为。

与主机端启动类似,启动到单独流中的工作可能会同时运行,但不能保证实际的并发性。 CUDA 编程模型不支持依赖子内核之间的并发性的程序,并且将具有未定义的行为。

设备不支持主机端 NULL 流的跨流屏障语义(详见下文)。为了保持与主机运行时的语义兼容性,必须使用 cudaStreamCreateWithFlags() API 创建所有设备流,并传递 cudaStreamNonBlocking 标志。 cudaStreamCreate() 调用是仅限主机运行时的 API,将无法为设备编译。

由于设备运行时不支持 cudaStreamSynchronize()cudaStreamQuery(),因此当应用程序需要知道流启动的子内核已完成时,应使用 cudaDeviceSynchronize()

警告:与父块的子内核显式同步(即在设备代码中使用 cudaDeviceSynchronize())在 CUDA 11.6 中已弃用,并计划在未来的 CUDA 版本中删除。

D.3.1.2.1. The Implicit (NULL) Stream

在宿主程序中,未命名(NULL)流与其他流具有额外的屏障同步语义(有关详细信息,请参阅默认流)。 设备运行时提供在块中的所有线程之间共享的单个隐式、未命名流,但由于必须使用 cudaStreamNonBlocking 标志创建所有命名流,启动到 NULL 流中的工作不会插入对任何其他流中未决工作的隐式依赖 (包括其他线程块的 NULL 流)。

D.3.1.3. Events

仅支持 CUDA 事件的流间同步功能。 这意味着支持 cudaStreamWaitEvent(),但不支持 cudaEventSynchronize()cudaEventElapsedTime()cudaEventQuery()。 由于不支持 cudaEventElapsedTime()cudaEvents 必须通过 cudaEventCreateWithFlags() 创建,并传递 cudaEventDisableTiming 标志。

对于所有设备运行时对象,事件对象可以在创建它们的线程块内的所有线程之间共享,但对于该块是本地的,并且可能不会传递给其他内核,或者在同一内核内的块之间。 不保证事件句柄在块之间是唯一的,因此在未创建它的块中使用事件句柄将导致未定义的行为。

D.3.1.4. Synchronization

警告:与父块的子内核显式同步(即在设备代码中使用 cudaDeviceSynchronize())在 CUDA 11.6 中已弃用,并计划在未来的 CUDA 版本中删除。

cudaDeviceSynchronize() 函数将同步线程块中任何线程启动的所有工作,直到调用 cudaDeviceSynchronize() 为止。 请注意,可以从不同的代码中调用 cudaDeviceSynchronize()(请参阅块范围同步)。

如果调用线程旨在与从其他线程调用的子网格同步,则由程序执行足够的额外线程间同步,例如通过调用 __syncthreads()

D.3.1.4.1. Block Wide Synchronization

cudaDeviceSynchronize() 函数并不意味着块内同步。 特别是,如果没有通过 __syncthreads() 指令进行显式同步,则调用线程无法对除自身之外的任何线程启动的工作做出任何假设。 例如,如果一个块中的多个线程都在启动工作,并且所有这些工作都需要一次同步(可能是因为基于事件的依赖关系),则由程序来保证在调用之前由所有线程提交这项工作 cudaDeviceSynchronize()

因为允许实现在从块中的任何线程启动时同步,所以很可能多个线程同时调用 cudaDeviceSynchronize() 将耗尽第一次调用中的所有工作,然后对后面的调用没有影响。

D.3.1.5. Device Management

只有运行内核的设备才能从该内核控制。 这意味着设备运行时不支持诸如 cudaSetDevice() 之类的设备 API。 从 GPU 看到的活动设备(从 cudaGetDevice() 返回)将具有与从主机系统看到的相同的设备编号。 cudaDeviceGetAttribute() 调用可能会请求有关另一个设备的信息,因为此 API 允许将设备 ID 指定为调用的参数。 请注意,设备运行时不提供包罗万象的 cudaGetDeviceProperties() API - 必须单独查询属性。

D.3.1.6. Memory Declarations

D.3.1.6.1. Device and Constant Memory

使用 __device____constant__ 内存空间说明符在文件范围内声明的内存在使用设备运行时行为相同。 所有内核都可以读取或写入设备变量,无论内核最初是由主机还是设备运行时启动的。 等效地,所有内核都将具有与在模块范围内声明的 __constant__ 相同的视图。

D.3.1.6.2. Textures & Surfaces

CUDA 支持动态创建的纹理和表面对象,其中纹理引用可以在主机上创建,传递给内核,由该内核使用,然后从主机销毁。 设备运行时不允许从设备代码中创建或销毁纹理或表面对象,但从主机创建的纹理和表面对象可以在设备上自由使用和传递。 不管它们是在哪里创建的,动态创建的纹理对象总是有效的,并且可以从父内核传递给子内核。

注意:设备运行时不支持从设备启动的内核中的遗留模块范围(即费米风格)纹理和表面。 模块范围(遗留)纹理可以从主机创建并在设备代码中用于任何内核,但只能由顶级内核(即从主机启动的内核)使用。

D.3.1.6.3. Shared Memory Variable Declarations

在 CUDA C++ 中,共享内存可以声明为静态大小的文件范围或函数范围的变量,也可以声明为外部变量,其大小由内核调用者在运行时通过启动配置参数确定。 这两种类型的声明在设备运行时都有效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
__global__ void permute(int n, int *data) {
extern __shared__ int smem[];
if (n <= 1)
return;

smem[threadIdx.x] = data[threadIdx.x];
__syncthreads();

permute_data(smem, n);
__syncthreads();

// Write back to GMEM since we can't pass SMEM to children.
data[threadIdx.x] = smem[threadIdx.x];
__syncthreads();

if (threadIdx.x == 0) {
permute<<< 1, 256, n/2*sizeof(int) >>>(n/2, data);
permute<<< 1, 256, n/2*sizeof(int) >>>(n/2, data+n/2);
}
}

void host_launch(int *data) {
permute<<< 1, 256, 256*sizeof(int) >>>(256, data);
}
D.3.1.6.4. Symbol Addresses

设备端符号(即标记为 __device__ 的符号)可以简单地通过 & 运算符从内核中引用,因为所有全局范围的设备变量都在内核的可见地址空间中。 这也适用于 __constant__ 符号,尽管在这种情况下指针将引用只读数据。

鉴于可以直接引用设备端符号,那些引用符号的 CUDA 运行时 API(例如 cudaMemcpyToSymbol()cudaGetSymbolAddress())是多余的,因此设备运行时不支持。 请注意,这意味着常量数据不能在正在运行的内核中更改,即使在子内核启动之前也是如此,因为对 __constant__ 空间的引用是只读的。

D.3.1.7. API Errors and Launch Failures

与 CUDA 运行时一样,任何函数都可能返回错误代码。 最后返回的错误代码被记录下来,并且可以通过 cudaGetLastError() 调用来检索。 每个线程都会记录错误,以便每个线程都可以识别它最近生成的错误。 错误代码的类型为 cudaError_t

与主机端启动类似,设备端启动可能由于多种原因(无效参数等)而失败。 用户必须调用 cudaGetLastError() 来确定启动是否产生错误,但是启动后没有错误并不意味着子内核成功完成。

对于设备端异常,例如,访问无效地址,子网格中的错误将返回给主机,而不是由父调用 cudaDeviceSynchronize() 返回。

D.3.1.7.1. Launch Setup APIs

内核启动是通过设备运行时库公开的系统级机制,因此可通过底层 cudaGetParameterBuffer()cudaLaunchDevice() API 直接从 PTX 获得。 允许 CUDA 应用程序自己调用这些 API,其要求与 PTX 相同。 在这两种情况下,用户都负责根据规范以正确的格式正确填充所有必要的数据结构。 这些数据结构保证了向后兼容性。

与主机端启动一样,设备端操作符 <<<>>> 映射到底层内核启动 API。 这样一来,以 PTX 为目标的用户将能够启动加载,并且编译器前端可以将 <<<>>> 转换为这些调用。

Runtime API Launch Functions Description of Difference From Host Runtime Behaviour (behaviour is identical if no description)
cudaGetParameterBuffer Generated automatically from <<<>>>. Note different API to host equivalent.
cudaLaunchDevice Generated automatically from <<<>>>. Note different API to host equivalent.

这些启动函数的 API 与 CUDA Runtime API 不同,定义如下:

1
2
3
4
5
6
extern   device   cudaError_t cudaGetParameterBuffer(void **params);
extern __device__ cudaError_t cudaLaunchDevice(void *kernel,
void *params, dim3 gridDim,
dim3 blockDim,
unsigned int sharedMemSize = 0,
cudaStream_t stream = 0);

D.3.1.8. API Reference

此处详细介绍了设备运行时支持的 CUDA 运行时 API 部分。 主机和设备运行时 API 具有相同的语法; 语义是相同的,除非另有说明。 下表提供了与主机可用版本相关的 API 概览。

Runtime API Functions Details
cudaDeviceSynchronize Synchronizes on work launched from thread’s own block only.
Warning: Note that calling this API from device code is deprecated in CUDA 11.6, and is slated for removal in a future CUDA release.
cudaDeviceGetCacheConfig
cudaDeviceGetLimit
cudaGetLastError Last error is per-thread state, not per-block state
cudaPeekAtLastError
cudaGetErrorString
cudaGetDeviceCount
cudaDeviceGetAttribute Will return attributes for any device
cudaGetDevice Always returns current device ID as would be seen from host
cudaStreamCreateWithFlags Must pass cudaStreamNonBlocking flag
cudaStreamDestroy
cudaStreamWaitEvent
cudaEventCreateWithFlags Must pass cudaEventDisableTiming flag
cudaEventRecord
cudaEventDestroy
cudaFuncGetAttributes
udaMemsetAsync
cudaMemset2DAsync
cudaMemset3DAsync
cudaRuntimeGetVersion
cudaMalloc May not call cudaFree on the device on a pointer created on the host, and vice-versa
cudaFree
cudaOccupancyMaxActiveBlocksPerMultiprocessor
cudaOccupancyMaxPotentialBlockSize
cudaOccupancyMaxPotentialBlockSizeVariableSMem
cudaMemcpyAsync Notes about all memcpy/memset functions: 1.Only async memcpy/set functions are supported 2.Only device-to-device memcpy is permitted 3.May not pass in local or shared memory pointers
cudaMemcpy2DAsync Notes about all memcpy/memset functions: 1.Only async memcpy/set functions are supported 2.Only device-to-device memcpy is permitted 3.May not pass in local or shared memory pointers
cudaMemcpy3DAsync Notes about all memcpy/memset functions: 1.Only async memcpy/set functions are supported 2.Only device-to-device memcpy is permitted 3.May not pass in local or shared memory pointers

D.3.2. Device-side Launch from PTX

本部分适用于以并行线程执行 (PTX) 为目标并计划在其语言中支持动态并行的编程语言和编译器实现者。 它提供了与在 PTX 级别支持内核启动相关的底层详细信息。

D.3.2.1. Kernel Launch APIs

可以使用可从 PTX 访问的以下两个 API 来实现设备端内核启动:cudaLaunchDevice()cudaGetParameterBuffer()cudaLaunchDevice() 使用通过调用 cudaGetParameterBuffer() 获得的参数缓冲区启动指定的内核,并将参数填充到启动的内核。 参数缓冲区可以为 NULL,即,如果启动的内核不带任何参数,则无需调用 cudaGetParameterBuffer()

D.3.2.1.1. cudaLaunchDevice

在 PTX 级别,cudaLaunchDevice() 需要在使用前以如下所示的两种形式之一声明。

1
2
3
4
5
6
7
8
9
10
11
// PTX-level Declaration of cudaLaunchDevice() when .address_size is 64
.extern .func(.param .b32 func_retval0) cudaLaunchDevice
(
.param .b64 func,
.param .b64 parameterBuffer,
.param .align 4 .b8 gridDimension[12],
.param .align 4 .b8 blockDimension[12],
.param .b32 sharedMemSize,
.param .b64 stream
)
;
1
2
3
4
5
6
7
8
9
10
11
// PTX-level Declaration of cudaLaunchDevice() when .address_size is 32
.extern .func(.param .b32 func_retval0) cudaLaunchDevice
(
.param .b32 func,
.param .b32 parameterBuffer,
.param .align 4 .b8 gridDimension[12],
.param .align 4 .b8 blockDimension[12],
.param .b32 sharedMemSize,
.param .b32 stream
)
;

下面的 CUDA 级声明映射到上述 PTX 级声明之一,可在系统头文件 cuda_device_runtime_api.h 中找到。 该函数在 cudadevrt 系统库中定义,必须与程序链接才能使用设备端内核启动功能。

1
2
3
4
5
6
// CUDA-level declaration of cudaLaunchDevice()
extern "C" __device__
cudaError_t cudaLaunchDevice(void *func, void *parameterBuffer,
dim3 gridDimension, dim3 blockDimension,
unsigned int sharedMemSize,
cudaStream_t stream);

第一个参数是指向要启动的内核的指针,第二个参数是保存已启动内核的实际参数的参数缓冲区。 参数缓冲区的布局在下面的参数缓冲区布局中进行了说明。 其他参数指定启动配置,即网格维度、块维度、共享内存大小以及启动关联的流(启动配置的详细说明请参见执行配置)。

D.3.2.1.2. cudaGetParameterBuffer

cudaGetParameterBuffer() 需要在使用前在 PTX 级别声明。 PTX 级声明必须采用以下两种形式之一,具体取决于地址大小:

1
2
3
4
5
6
7
8
// PTX-level Declaration of cudaGetParameterBuffer() when .address_size is 64
// When .address_size is 64
.extern .func(.param .b64 func_retval0) cudaGetParameterBuffer
(
.param .b64 alignment,
.param .b64 size
)
;
1
2
3
4
5
6
7
 // PTX-level Declaration of cudaGetParameterBuffer() when .address_size is 32
.extern .func(.param .b32 func_retval0) cudaGetParameterBuffer
(
.param .b32 alignment,
.param .b32 size
)
;

cudaGetParameterBuffer() 的以下 CUDA 级声明映射到上述 PTX 级声明:

1
2
3
// CUDA-level Declaration of cudaGetParameterBuffer()
extern "C" __device__
void *cudaGetParameterBuffer(size_t alignment, size_t size);

第一个参数指定参数缓冲区的对齐要求,第二个参数以字节为单位的大小要求。 在当前实现中,cudaGetParameterBuffer() 返回的参数缓冲区始终保证为 64 字节对齐,忽略对齐要求参数。 但是,建议将正确的对齐要求值(即要放置在参数缓冲区中的任何参数的最大对齐)传递给 cudaGetParameterBuffer() 以确保将来的可移植性。

D.3.2.2. Parameter Buffer Layout

禁止参数缓冲区中的参数重新排序,并且要求放置在参数缓冲区中的每个单独的参数对齐。 也就是说,每个参数必须放在参数缓冲区中的第 n 个字节,其中 n 是参数大小的最小倍数,它大于前一个参数占用的最后一个字节的偏移量。 参数缓冲区的最大大小为 4KB。

有关 CUDA 编译器生成的 PTX 代码的更详细说明,请参阅 PTX-3.5 规范。

D.3.3. Toolkit Support for Dynamic Parallelism

D.3.3.1. Including Device Runtime API in CUDA Code

与主机端运行时 API 类似,CUDA 设备运行时 API 的原型会在程序编译期间自动包含在内。 无需明确包含 cuda_device_runtime_api.h

D.3.3.2. Compiling and Linking

当使用带有 nvcc 的动态并行编译和链接 CUDA 程序时,程序将自动链接到静态设备运行时库 libcudadevrt

设备运行时作为静态库(Windows 上的 cudadevrt.lib,Linux 下的 libcudadevrt.a)提供,必须链接使用设备运行时的 GPU 应用程序。设备库的链接可以通过 nvccnvlink 完成。下面显示了两个简单的示例。

如果可以从命令行指定所有必需的源文件,则可以在一个步骤中编译和链接设备运行时程序:

$ nvcc -arch=sm_35 -rdc=true hello_world.cu -o hello -lcudadevrt

也可以先将 CUDA .cu 源文件编译为目标文件,然后在两个阶段的过程中将它们链接在一起:

$ nvcc -arch=sm_35 -dc hello_world.cu -o hello_world.o

$ nvcc -arch=sm_35 -rdc=true hello_world.o -o hello -lcudadevrt

有关详细信息,请参阅 The CUDA Driver Compiler NVCC的使用单独编译部分。

D.4. Programming Guidelines

D.4.1. Basics

设备运行时是主机运行时的功能子集。 API 级别的设备管理、内核启动、设备 memcpy、流管理和事件管理从设备运行时公开。

已经有 CUDA 经验的人应该熟悉设备运行时的编程。 设备运行时语法和语义与主机 API 基本相同,但本文档前面详细介绍了任何例外情况。

警告:与父块的子内核显式同步(即在设备代码中使用 cudaDeviceSynchronize())在 CUDA 11.6 中已弃用,并计划在未来的 CUDA 版本中删除。

以下示例显示了一个包含动态并行性的简单 Hello World 程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <stdio.h> 

__global__ void childKernel()
{
printf("Hello ");
}

__global__ void parentKernel()
{
// launch child
childKernel<<<1,1>>>();
if (cudaSuccess != cudaGetLastError()) {
return;
}

// wait for child to complete
if (cudaSuccess != cudaDeviceSynchronize()) {
return;
}

printf("World!\n");
}

int main(int argc, char *argv[])
{
// launch parent
parentKernel<<<1,1>>>();
if (cudaSuccess != cudaGetLastError()) {
return 1;
}

// wait for parent to complete
if (cudaSuccess != cudaDeviceSynchronize()) {
return 2;
}

return 0;
}

该程序可以从命令行一步构建,如下所示:

$ nvcc -arch=sm_35 -rdc=true hello_world.cu -o hello -lcudadevrt

D.4.2. Performance

D.4.2.1. Synchronization

警告:与父块的子内核显式同步(即在设备代码中使用 cudaDeviceSynchronize())在 CUDA 11.6 中已弃用,并计划在未来的 CUDA 版本中删除。

一个线程的同步可能会影响同一线程块中其他线程的性能,即使这些其他线程自己不调用 cudaDeviceSynchronize() 也是如此。 这种影响将取决于底层实现。 通常,与显式调用 cudaDeviceSynchronize() 相比,在线程块结束时完成子内核的隐式同步更有效。 因此,如果需要在线程块结束之前与子内核同步,建议仅调用 cudaDeviceSynchronize()

D.4.2.2. Dynamic-parallelism-enabled Kernel Overhead

在控制动态启动时处于活动状态的系统软件可能会对当时正在运行的任何内核施加开销,无论它是否调用自己的内核启动。 这种开销来自设备运行时的执行跟踪和管理软件,并且可能导致性能下降,例如,与从主机端相比,从设备进行库调用时。 通常,链接到设备运行时库的应用程序会产生这种开销。

D.4.3. Implementation Restrictions and Limitations

动态并行保证本文档中描述的所有语义,但是,某些硬件和软件资源依赖于实现,并限制了使用设备运行时的程序的规模、性能和其他属性。

D.4.3.1. Runtime

D.4.3.1.1. Memory Footprint

设备运行时系统软件为各种管理目的预留内存,特别是用于在同步期间保存父网格状态的一个预留,以及用于跟踪未决网格启动的第二个预留。 配置控制可用于减少这些预留的大小,以换取某些启动限制。 有关详细信息,请参阅下面的配置选项

大多数保留内存被分配为父内核状态的后备存储,用于在子启动时进行同步。 保守地说,该内存必须支持为设备上可能的最大活动线程数存储状态。 这意味着可调用 cudaDeviceSynchronize() 的每个父代可能需要多达 860MB 的设备内存,具体取决于设备配置,即使它没有全部消耗,也将无法供程序使用。

D.4.3.1.2. Nesting and Synchronization Depth

使用设备运行时,一个内核可能会启动另一个内核,而该内核可能会启动另一个内核,以此类推。每个从属启动都被认为是一个新的嵌套层级,层级总数就是程序的嵌套深度。同步深度定义为程序在子启动时显式同步的最深级别。通常这比程序的嵌套深度小一,但如果程序不需要在所有级别调用 cudaDeviceSynchronize() ,则同步深度可能与嵌套深度有很大不同。

警告:与父块的子内核显式同步(即在设备代码中使用 cudaDeviceSynchronize())在 CUDA 11.6 中已弃用,并计划在未来的 CUDA 版本中删除。

总体最大嵌套深度限制为 24,但实际上,真正的限制将是系统为每个新级别所需的内存量(请参阅上面的内存占用量)。任何会导致内核处于比最大值更深的级别的启动都将失败。请注意,这也可能适用于 cudaMemcpyAsync(),它本身可能会生成内核启动。有关详细信息,请参阅配置选项

默认情况下,为两级同步保留足够的存储空间。这个最大同步深度(以及因此保留的存储)可以通过调用 cudaDeviceSetLimit() 并指定 cudaLimitDevRuntimeSyncDepth 来控制。必须在主机启动顶层内核之前配置要支持的层数,以保证嵌套程序的成功执行。在大于指定最大同步深度的深度调用 cudaDeviceSynchronize() 将返回错误。

在父内核从不调用 cudaDeviceSynchronize() 的情况下,如果系统检测到不需要为父状态保留空间,则允许进行优化。在这种情况下,由于永远不会发生显式父/子同步,因此程序所需的内存占用量将远小于保守的最大值。这样的程序可以指定较浅的最大同步深度,以避免过度分配后备存储。

D.4.3.1.3. Pending Kernel Launches

启动内核时,会跟踪所有关联的配置和参数数据,直到内核完成。 此数据存储在系统管理的启动池中。

启动池分为固定大小的池和性能较低的虚拟化池。 设备运行时系统软件将首先尝试跟踪固定大小池中的启动数据。 当固定大小的池已满时,虚拟化池将用于跟踪新的启动。

固定大小启动池的大小可通过从主机调用 cudaDeviceSetLimit() 并指定 cudaLimitDevRuntimePendingLaunchCount 来配置。

D.4.3.1.4. Configuration Options

设备运行时系统软件的资源分配通过主机程序的 cudaDeviceSetLimit() API 进行控制。 限制必须在任何内核启动之前设置,并且在 GPU 正在运行程序时不得更改。

警告:与父块的子内核显式同步(即在设备代码中使用 cudaDeviceSynchronize())在 CUDA 11.6 中已弃用,并计划在未来的 CUDA 版本中删除。

可以设置以下命名限制:

Limit Behavior
cudaLimitDevRuntimeSyncDepth Sets the maximum depth at which cudaDeviceSynchronize() may be called. Launches may be performed deeper than this, but explicit synchronization deeper than this limit will return the cudaErrorLaunchMaxDepthExceeded. The default maximum sync depth is 2.
cudaLimitDevRuntimePendingLaunchCount Controls the amount of memory set aside for buffering kernel launches which have not yet begun to execute, due either to unresolved dependencies or lack of execution resources. When the buffer is full, the device runtime system software will attempt to track new pending launches in a lower performance virtualized buffer. If the virtualized buffer is also full, i.e. when all available heap space is consumed, launches will not occur, and the thread’s last error will be set to cudaErrorLaunchPendingCountExceeded. The default pending launch count is 2048 launches.
cudaLimitStackSize Controls the stack size in bytes of each GPU thread. The CUDA driver automatically increases the per-thread stack size for each kernel launch as needed. This size isn’t reset back to the original value after each launch. To set the per-thread stack size to a different value, cudaDeviceSetLimit() can be called to set this limit. The stack will be immediately resized, and if necessary, the device will block until all preceding requested tasks are complete. cudaDeviceGetLimit() can be called to get the current per-thread stack size.
D.4.3.1.5. Memory Allocation and Lifetime

cudaMalloc()cudaFree() 在主机和设备环境之间具有不同的语义。 当从主机调用时,cudaMalloc() 从未使用的设备内存中分配一个新区域。 当从设备运行时调用时,这些函数映射到设备端的 malloc()free()。 这意味着在设备环境中,总可分配内存限制为设备 malloc() 堆大小,它可能小于可用的未使用设备内存。 此外,在设备上由 cudaMalloc() 分配的指针上从主机程序调用 cudaFree() 是错误的,反之亦然。

cudaMalloc() on Host cudaMalloc() on Device
cudaFree() on Host Supported Not Supported
cudaFree() on Device Not Supported Supported
Allocation limit Free device memory cudaLimitMallocHeapSize
D.4.3.1.6. SM Id and Warp Id

请注意,在 PTX 中,%smid%warpid 被定义为 volatile 值。 设备运行时可以将线程块重新调度到不同的 SM 上,以便更有效地管理资源。 因此,依赖 %smid%warpid 在线程或线程块的生命周期内保持不变是不安全的。

D.4.3.1.7. ECC Errors

CUDA 内核中的代码没有可用的 ECC 错误通知。 整个启动树完成后,主机端会报告 ECC 错误。 在嵌套程序执行期间出现的任何 ECC 错误都将生成异常或继续执行(取决于错误和配置)。

附录E虚拟内存管理

E.1. Introduction

虚拟内存管理 API 为应用程序提供了一种直接管理统一虚拟地址空间的方法,该空间由 CUDA 提供,用于将物理内存映射到 GPU 可访问的虚拟地址。在 CUDA 10.2 中引入的这些 API 还提供了一种与其他进程和图形 API(如 OpenGL 和 Vulkan)进行互操作的新方法,并提供了用户可以调整以适应其应用程序的更新内存属性。

从历史上看,CUDA 编程模型中的内存分配调用(例如 cudaMalloc)返回了一个指向 GPU 内存的内存地址。这样获得的地址可以与任何 CUDA API 一起使用,也可以在设备内核中使用。但是,分配的内存无法根据用户的内存需求调整大小。为了增加分配的大小,用户必须显式分配更大的缓冲区,从初始分配中复制数据,释放它,然后继续跟踪新分配的地址。这通常会导致应用程序的性能降低和峰值内存利用率更高。本质上,用户有一个类似 malloc 的接口来分配 GPU 内存,但没有相应的 realloc 来补充它。虚拟内存管理 API 将地址和内存的概念解耦,并允许应用程序分别处理它们。 API 允许应用程序在他们认为合适的时候从虚拟地址范围映射和取消映射内存。

在通过 cudaEnablePeerAccess 启用对等设备访问内存分配的情况下,所有过去和未来的用户分配都映射到目标对等设备。这导致用户无意中支付了将所有 cudaMalloc 分配映射到对等设备的运行时成本。然而,在大多数情况下,应用程序通过仅与另一个设备共享少量分配进行通信,并且并非所有分配都需要映射到所有设备。使用虚拟内存管理,应用程序可以专门选择某些分配可从目标设备访问。

CUDA 虚拟内存管理 API 向用户提供细粒度控制,以管理应用程序中的 GPU 内存。它提供的 API 允许用户:

  • 将分配在不同设备上的内存放入一个连续的 VA 范围内。
  • 使用平台特定机制执行内存共享的进程间通信。
  • 在支持它们的设备上选择更新的内存类型。

为了分配内存,虚拟内存管理编程模型公开了以下功能:

  • 分配物理内存。
  • 保留 VA 范围。
  • 将分配的内存映射到 VA 范围。
  • 控制映射范围的访问权限。

请注意,本节中描述的 API 套件需要支持 UVA 的系统。

E.2. Query for support

在尝试使用虚拟内存管理 API 之前,应用程序必须确保他们希望使用的设备支持 CUDA 虚拟内存管理。 以下代码示例显示了查询虚拟内存管理支持:

1
2
3
4
5
6
int deviceSupportsVmm;
CUresult result = cuDeviceGetAttribute(&deviceSupportsVmm, CU_DEVICE_ATTRIBUTE_VIRTUAL_MEMORY_MANAGEMENT_SUPPORTED, device);
if (deviceSupportsVmm != 0) {
// `device` supports Virtual Memory Management
}

E.3. Allocating Physical Memory

通过虚拟内存管理 API 进行内存分配的第一步是创建一个物理内存块,为分配提供支持。 为了分配物理内存,应用程序必须使用 cuMemCreate API。 此函数创建的分配没有任何设备或主机映射。 函数参数 CUmemGenericAllocationHandle 描述了要分配的内存的属性,例如分配的位置、分配是否要共享给另一个进程(或其他图形 API),或者要分配的内存的物理属性。 用户必须确保请求分配的大小必须与适当的粒度对齐。 可以使用 cuMemGetAllocationGranularity 查询有关分配粒度要求的信息。 以下代码片段显示了使用 cuMemCreate 分配物理内存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
CUmemGenericAllocationHandle allocatePhysicalMemory(int device, size_t size) {
CUmemAllocationProp prop = {};
prop.type = CU_MEM_ALLOCATION_TYPE_PINNED;
prop.location.type = CU_MEM_LOCATION_TYPE_DEVICE;
prop.location.id = device;
cuMemGetAllocationGranularity(&granularity, &prop, CU_MEM_ALLOC_GRANULARITY_MINIMUM);

// Ensure size matches granularity requirements for the allocation
size_t padded_size = ROUND_UP(size, granularity);

// Allocate physical memory
CUmemGenericAllocationHandle allocHandle;
cuMemCreate(&allocHandle, padded_size, &prop, 0);

return allocHandle;
}

cuMemCreate 分配的内存由它返回的 CUmemGenericAllocationHandle 引用。 这与 cudaMalloc风格的分配不同,后者返回一个指向 GPU 内存的指针,该指针可由在设备上执行的 CUDA 内核直接访问。 除了使用 cuMemGetAllocationPropertiesFromHandle 查询属性之外,分配的内存不能用于任何操作。 为了使此内存可访问,应用程序必须将此内存映射到由 cuMemAddressReserve 保留的 VA 范围,并为其提供适当的访问权限。 应用程序必须使用 cuMemRelease API 释放分配的内存。

E.3.1. Shareable Memory Allocations

使用 cuMemCreate 用户现在可以在分配时向 CUDA 指示他们已指定特定分配用于进程间通信或图形互操作目的。应用程序可以通过将 CUmemAllocationProp::requestedHandleTypes 设置为平台特定字段来完成此操作。在 Windows 上,当 CUmemAllocationProp::requestedHandleTypes 设置为 CU_MEM_HANDLE_TYPE_WIN32 时,应用程序还必须在 CUmemAllocationProp::win32HandleMetaData 中指定 LPSECURITYATTRIBUTES 属性。该安全属性定义了可以将导出的分配转移到其他进程的范围。

CUDA 虚拟内存管理 API 函数不支持传统的进程间通信函数及其内存。相反,它们公开了一种利用操作系统特定句柄的进程间通信的新机制。应用程序可以使用 cuMemExportToShareableHandle 获取与分配相对应的这些操作系统特定句柄。这样获得的句柄可以通过使用通常的 OS 本地机制进行传输,以进行进程间通信。接收进程应使用 cuMemImportFromShareableHandle 导入分配。

用户必须确保在尝试导出使用 cuMemCreate 分配的内存之前查询是否支持请求的句柄类型。以下代码片段说明了以特定平台方式查询句柄类型支持。

1
2
3
4
5
6
int deviceSupportsIpcHandle;
#if defined(__linux__)
cuDeviceGetAttribute(&deviceSupportsIpcHandle, CU_DEVICE_ATTRIBUTE_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR_SUPPORTED, device));
#else
cuDeviceGetAttribute(&deviceSupportsIpcHandle, CU_DEVICE_ATTRIBUTE_HANDLE_TYPE_WIN32_HANDLE_SUPPORTED, device));
#endif

用户应适当设置 CUmemAllocationProp::requestedHandleTypes,如下所示:

1
2
3
4
5
6
7
#if defined(__linux__)
prop.requestedHandleTypes = CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR;
#else
prop.requestedHandleTypes = CU_MEM_HANDLE_TYPE_WIN32;
prop.win32HandleMetaData = // Windows specific LPSECURITYATTRIBUTES attribute.
#endif

memMapIpcDrv 示例可用作将 IPC 与虚拟内存管理分配一起使用的示例。

E.3.2. Memory Type

在 CUDA 10.2 之前,应用程序没有用户控制的方式来分配某些设备可能支持的任何特殊类型的内存。 使用 cuMemCreate 应用程序还可以使用 CUmemAllocationProp::allocFlags 指定内存类型要求,以选择任何特定的内存功能。 应用程序还必须确保分配设备支持请求的内存类型。

E.3.2.1. Compressible Memory

可压缩内存可用于加速对具有非结构化稀疏性和其他可压缩数据模式的数据的访问。 压缩可以节省 DRAM 带宽、L2 读取带宽和 L2 容量,具体取决于正在操作的数据。 想要在支持计算数据压缩的设备上分配可压缩内存的应用程序可以通过将 CUmemAllocationProp::allocFlags::compressionType 设置为 CU_MEM_ALLOCATION_COMP_GENERIC 来实现。 用户必须通过 CU_DEVICE_ATTRIBUTE_GENERIC_COMPRESSION_SUPPORTED 查询设备是否支持计算数据压缩。 以下代码片段说明了查询可压缩内存支持 cuDeviceGetAttribute

1
2
3
int compressionSupported = 0;
cuDeviceGetAttribute(&compressionSupported, CU_DEVICE_ATTRIBUTE_GENERIC_COMPRESSION_SUPPORTED, device);

在支持计算数据压缩的设备上,用户需要在分配时选择加入,如下所示:

1
2
prop.allocFlags.compressionType = CU_MEM_ALLOCATION_COMP_GENERIC;

由于硬件资源有限等各种原因,分配的内存可能没有压缩属性,用户需要使用cuMemGetAllocationPropertiesFromHandle查询回分配内存的属性并检查压缩属性。

1
2
3
4
5
6
7
8
CUmemAllocationPropPrivate allocationProp = {};
cuMemGetAllocationPropertiesFromHandle(&allocationProp, allocationHandle);

if (allocationProp.allocFlags.compressionType == CU_MEM_ALLOCATION_COMP_GENERIC)
{
// Obtained compressible memory allocation
}

E.4. Reserving a Virtual Address Range

由于使用虚拟内存管理,地址和内存的概念是不同的,因此应用程序必须划出一个地址范围,以容纳由 cuMemCreate 进行的内存分配。保留的地址范围必须至少与用户计划放入其中的所有物理内存分配大小的总和一样大。

应用程序可以通过将适当的参数传递给 cuMemAddressReserve 来保留虚拟地址范围。获得的地址范围不会有任何与之关联的设备或主机物理内存。保留的虚拟地址范围可以映射到属于系统中任何设备的内存块,从而为应用程序提供由属于不同设备的内存支持和映射的连续 VA 范围。应用程序应使用 cuMemAddressFree 将虚拟地址范围返回给 CUDA。用户必须确保在调用 cuMemAddressFree 之前未映射整个 VA 范围。这些函数在概念上类似于 mmap/munmap(在 Linux 上)或 VirtualAlloc/VirtualFree(在 Windows 上)函数。以下代码片段说明了该函数的用法:

1
2
3
4
CUdeviceptr ptr;
// `ptr` holds the returned start of virtual address range reserved.
CUresult result = cuMemAddressReserve(&ptr, size, 0, 0, 0); // alignment = 0 for default alignment

E.5. Virtual Aliasing Support

虚拟内存管理 API 提供了一种创建多个虚拟内存映射或“代理”到相同分配的方法,该方法使用对具有不同虚拟地址的 cuMemMap 的多次调用,即所谓的虚拟别名。 除非在 PTX ISA 中另有说明,否则写入分配的一个代理被认为与同一内存的任何其他代理不一致和不连贯,直到写入设备操作(网格启动、memcpy、memset 等)完成。 在写入设备操作之前出现在 GPU 上但在写入设备操作完成后读取的网格也被认为具有不一致和不连贯的代理。

例如,下面的代码片段被认为是未定义的,假设设备指针 A 和 B 是相同内存分配的虚拟别名:

1
2
3
4
5
__global__ void foo(char *A, char *B) {
*A = 0x1;
printf(“%d\n”, *B); // Undefined behavior! *B can take on either
// the previous value or some value in-between.
}

以下是定义的行为,假设这两个内核是单调排序的(通过流或事件)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
__global__ void foo1(char *A) {
*A = 0x1;
}

__global__ void foo2(char *B) {
printf(“%d\n”, *B); // *B == *A == 0x1 assuming foo2 waits for foo1
// to complete before launching
}

cudaMemcpyAsync(B, input, size, stream1); // Aliases are allowed at
// operation boundaries
foo1<<<1,1,0,stream1>>>(A); // allowing foo1 to access A.
cudaEventRecord(event, stream1);
cudaStreamWaitEvent(stream2, event);
foo2<<<1,1,0,stream2>>>(B);
cudaStreamWaitEvent(stream3, event);
cudaMemcpyAsync(output, B, size, stream3); // Both launches of foo2 and
// cudaMemcpy (which both
// read) wait for foo1 (which writes)
// to complete before proceeding

E.6. Mapping Memory

前两节分配的物理内存和挖出的虚拟地址空间代表了虚拟内存管理 API 引入的内存和地址区别。为了使分配的内存可用,用户必须首先将内存放在地址空间中。从 cuMemAddressReserve 获取的地址范围和从 cuMemCreatecuMemImportFromShareableHandle 获取的物理分配必须通过 cuMemMap 相互关联。

用户可以关联来自多个设备的分配以驻留在连续的虚拟地址范围内,只要他们已经划分出足够的地址空间。为了解耦物理分配和地址范围,用户必须通过 cuMemUnmap 取消映射的地址。用户可以根据需要多次将内存映射和取消映射到同一地址范围,只要他们确保不会尝试在已映射的 VA 范围保留上创建映射。以下代码片段说明了该函数的用法:

1
2
3
4
5
CUdeviceptr ptr;
// `ptr`: address in the address range previously reserved by cuMemAddressReserve.
// `allocHandle`: CUmemGenericAllocationHandle obtained by a previous call to cuMemCreate.
CUresult result = cuMemMap(ptr, size, 0, allocHandle, 0);

E.7. Control Access Rights

虚拟内存管理 API 使应用程序能够通过访问控制机制显式保护其 VA 范围。 使用 cuMemMap 将分配映射到地址范围的区域不会使地址可访问,并且如果被 CUDA 内核访问会导致程序崩溃。 用户必须使用 cuMemSetAccess 函数专门选择访问控制,该函数允许或限制特定设备对映射地址范围的访问。 以下代码片段说明了该函数的用法:

1
2
3
4
5
6
7
8
9
10
void setAccessOnDevice(int device, CUdeviceptr ptr, size_t size) {
CUmemAccessDesc accessDesc = {};
accessDesc.location.type = CU_MEM_LOCATION_TYPE_DEVICE;
accessDesc.location.id = device;
accessDesc.flags = CU_MEM_ACCESS_FLAGS_PROT_READWRITE;

// Make the address accessible
cuMemSetAccess(ptr, size, &accessDesc, 1);
}

使用虚拟内存管理公开的访问控制机制允许用户明确他们希望与系统上的其他对等设备共享哪些分配。 如前所述,cudaEnablePeerAccess 强制将所有先前和将来的 cudaMalloc 分配映射到目标对等设备。 这在许多情况下很方便,因为用户不必担心跟踪每个分配到系统中每个设备的映射状态。 但是对于关心其应用程序性能的用户来说,这种方法具有性能影响。 通过分配粒度的访问控制,虚拟内存管理公开了一种机制,可以以最小的开销进行对等映射。

vectorAddMMAP 示例可用作使用虚拟内存管理 API 的示例。

附录F 流序内存分配

F.1. Introduction

使用 cudaMalloccudaFree 管理内存分配会导致 GPU 在所有正在执行的 CUDA 流之间进行同步。 Stream Order Memory Allocator 使应用程序能够通过启动到 CUDA 流中的其他工作(例如内核启动和异步拷贝)来对内存分配和释放进行排序。这通过利用流排序语义来重用内存分配来改进应用程序内存使用。分配器还允许应用程序控制分配器的内存缓存行为。当设置了适当的释放阈值时,缓存行为允许分配器在应用程序表明它愿意接受更大的内存占用时避免对操作系统进行昂贵的调用。分配器还支持在进程之间轻松安全地共享分配。

对于许多应用程序,Stream Ordered Memory Allocator 减少了对自定义内存管理抽象的需求,并使为需要它的应用程序创建高性能自定义内存管理变得更加容易。对于已经具有自定义内存分配器的应用程序和库,采用 Stream Ordered Memory Allocator 可以使多个库共享由驱动程序管理的公共内存池,从而减少过多的内存消耗。此外,驱动程序可以根据其对分配器和其他流管理 API 的感知执行优化。最后,Nsight Compute 和 Next-Gen CUDA 调试器知道分配器是其 CUDA 11.3 工具包支持的一部分。

F.2. Query for Support

用户可以通过使用设备属性 cudaDevAttrMemoryPoolsSupported 调用 cudaDeviceGetAttribute() 来确定设备是否支持流序内存分配器。

从 CUDA 11.3 开始,可以使用 cudaDevAttrMemoryPoolSupportedHandleTypes 设备属性查询 IPC 内存池支持。 以前的驱动程序将返回 cudaErrorInvalidValue,因为这些驱动程序不知道属性枚举。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int driverVersion = 0;
int deviceSupportsMemoryPools = 0;
int poolSupportedHandleTypes = 0;
cudaDriverGetVersion(&driverVersion);
if (driverVersion >= 11020) {
cudaDeviceGetAttribute(&deviceSupportsMemoryPools,
cudaDevAttrMemoryPoolsSupported, device);
}
if (deviceSupportsMemoryPools != 0) {
// `device` supports the Stream Ordered Memory Allocator
}

if (driverVersion >= 11030) {
cudaDeviceGetAttribute(&poolSupportedHandleTypes,
cudaDevAttrMemoryPoolSupportedHandleTypes, device);
}
if (poolSupportedHandleTypes & cudaMemHandleTypePosixFileDescriptor) {
// Pools on the specified device can be created with posix file descriptor-based IPC
}

在查询之前执行驱动程序版本检查可避免在尚未定义属性的驱动程序上遇到 cudaErrorInvalidValue 错误。 可以使用 cudaGetLastError 来清除错误而不是避免它。

F.3. API Fundamentals (cudaMallocAsync and cudaFreeAsync)

API cudaMallocAsynccudaFreeAsync 构成了分配器的核心。 cudaMallocAsync 返回分配,cudaFreeAsync 释放分配。 两个 API 都接受流参数来定义分配何时变为可用和停止可用。 cudaMallocAsync 返回的指针值是同步确定的,可用于构建未来的工作。 重要的是要注意 cudaMallocAsync 在确定分配的位置时会忽略当前设备/上下文。 相反,cudaMallocAsync 根据指定的内存池或提供的流来确定常驻设备。 最简单的使用模式是分配、使用和释放内存到同一个流中。

1
2
3
4
5
6
7
void *ptr;
size_t size = 512;
cudaMallocAsync(&ptr, size, cudaStreamPerThread);
// do work using the allocation
kernel<<<..., cudaStreamPerThread>>>(ptr, ...);
// An asynchronous free can be specified without synchronizing the CPU and GPU
cudaFreeAsync(ptr, cudaStreamPerThread);

用户可以使用 cudaFreeAsync() 释放使用 cudaMalloc() 分配的内存。 在自由操作开始之前,用户必须对访问完成做出同样的保证。

1
2
3
cudaMalloc(&ptr, size);
kernel<<<..., stream>>>(ptr, ...);
cudaFreeAsync(ptr, stream);

用户可以使用 cudaFree() 释放使用 cudaMallocAsync 分配的内存。 通过 cudaFree() API 释放此类分配时,驱动程序假定对分配的所有访问都已完成,并且不执行进一步的同步。 用户可以使用 cudaStreamQuery / cudaStreamSynchronize / cudaEventQuery / cudaEventSynchronize / cudaDeviceSynchronize 来保证适当的异步工作完成并且GPU不会尝试访问分配。

1
2
3
4
5
6
cudaMallocAsync(&ptr, size,stream);
kernel<<<..., stream>>>(ptr, ...);
// synchronize is needed to avoid prematurely freeing the memory
cudaStreamSynchronize(stream);
cudaFree(ptr);

F.4. Memory Pools and the cudaMemPool_t

内存池封装了虚拟地址和物理内存资源,根据内存池的属性和属性进行分配和管理。内存池的主要方面是它所管理的内存的种类和位置。

所有对 cudaMallocAsync 的调用都使用内存池的资源。在没有指定内存池的情况下,cudaMallocAsync API 使用提供的流设备的当前内存池。设备的当前内存池可以使用 cudaDeviceSetMempool 设置并使用 cudaDeviceGetMempool 查询。默认情况下(在没有 cudaDeviceSetMempool 调用的情况下),当前内存池是设备的默认内存池。 cudaMallocFromPoolAsync 的 API cudaMallocFromPoolAsync 和 c++ 重载允许用户指定要用于分配的池,而无需将其设置为当前池。 API cudaDeviceGetDefaultMempoolcudaMemPoolCreate 为用户提供内存池的句柄。

注意:设备的内存池当前将是该设备的本地。因此,在不指定内存池的情况下进行分配将始终产生流设备本地的分配。

注意:cudaMemPoolSetAttributecudaMemPoolGetAttribute 控制内存池的属性。

F.5. Default/Impicit Pools

可以使用 cudaDeviceGetDefaultMempool API 检索设备的默认内存池。 来自设备默认内存池的分配是位于该设备上的不可迁移设备分配。 这些分配将始终可以从该设备访问。 默认内存池的可访问性可以通过 cudaMemPoolSetAccess 进行修改,并通过 cudaMemPoolGetAccess 进行查询。 由于不需要显式创建默认池,因此有时将它们称为隐式池。 设备默认内存池不支持IPC。

F.6. Explicit Pools

API cudaMemPoolCreate 创建一个显式池。 目前内存池只能分配设备分配。 分配将驻留的设备必须在属性结构中指定。 显式池的主要用例是 IPC 功能。

1
2
3
4
5
6
7
8
// create a pool similar to the implicit pool on device 0
int device = 0;
cudaMemPoolProps poolProps = { };
poolProps.allocType = cudaMemAllocationTypePinned;
poolProps.location.id = device;
poolProps.location.type = cudaMemLocationTypeDevice;

cudaMemPoolCreate(&memPool, &poolProps));

F.7. Physical Page Caching Behavior

默认情况下,分配器尝试最小化池拥有的物理内存。 为了尽量减少分配和释放物理内存的操作系统调用,应用程序必须为每个池配置内存占用。 应用程序可以使用释放阈值属性 (cudaMemPoolAttrReleaseThreshold) 执行此操作。

释放阈值是池在尝试将内存释放回操作系统之前应保留的内存量(以字节为单位)。 当内存池持有超过释放阈值字节的内存时,分配器将尝试在下一次调用流、事件或设备同步时将内存释放回操作系统。 将释放阈值设置为 UINT64_MAX 将防止驱动程序在每次同步后尝试收缩池。

1
2
Cuuint64_t setVal = UINT64_MAX;
cudaMemPoolSetAttribute(memPool, cudaMemPoolAttrReleaseThreshold, &setVal);

cudaMemPoolAttrReleaseThreshold 设置得足够高以有效禁用内存池收缩的应用程序可能希望显式收缩内存池的内存占用。 cudaMemPoolTrimTo 允许此类应用程序这样做。 在修剪内存池的占用空间时,minBytesToKeep 参数允许应用程序保留它预期在后续执行阶段需要的内存量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Cuuint64_t setVal = UINT64_MAX;
cudaMemPoolSetAttribute(memPool, cudaMemPoolAttrReleaseThreshold, &setVal);

// application phase needing a lot of memory from the stream ordered allocator
for (i=0; i<10; i++) {
for (j=0; j<10; j++) {
cudaMallocAsync(&ptrs[j],size[j], stream);
}
kernel<<<...,stream>>>(ptrs,...);
for (j=0; j<10; j++) {
cudaFreeAsync(ptrs[j], stream);
}
}

// Process does not need as much memory for the next phase.
// Synchronize so that the trim operation will know that the allocations are no
// longer in use.
cudaStreamSynchronize(stream);
cudaMemPoolTrimTo(mempool, 0);

// Some other process/allocation mechanism can now use the physical memory
// released by the trimming operation.

F.8. Resource Usage Statistics

在 CUDA 11.3 中,添加了池属性 cudaMemPoolAttrReservedMemCurrent、cudaMemPoolAttrReservedMemHigh、cudaMemPoolAttrUsedMemCurrent 和 cudaMemPoolAttrUsedMemHigh 来查询池的内存使用情况。

查询池的 cudaMemPoolAttrReservedMemCurrent 属性会报告该池当前消耗的总物理 GPU 内存。 查询池的 cudaMemPoolAttrUsedMemCurrent 会返回从池中分配且不可重用的所有内存的总大小。

cudaMemPoolAttr*MemHigh 属性是记录自上次重置以来各个 cudaMemPoolAttr*MemCurrent 属性达到的最大值的水印。 可以使用 cudaMemPoolSetAttribute API 将它们重置为当前值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// sample helper functions for getting the usage statistics in bulk
struct usageStatistics {
cuuint64_t reserved;
cuuint64_t reservedHigh;
cuuint64_t used;
cuuint64_t usedHigh;
};

void getUsageStatistics(cudaMemoryPool_t memPool, struct usageStatistics *statistics)
{
cudaMemPoolGetAttribute(memPool, cudaMemPoolAttrReservedMemCurrent, statistics->reserved);
cudaMemPoolGetAttribute(memPool, cudaMemPoolAttrReservedMemHigh, statistics->reservedHigh);
cudaMemPoolGetAttribute(memPool, cudaMemPoolAttrUsedMemCurrent, statistics->used);
cudaMemPoolGetAttribute(memPool, cudaMemPoolAttrUsedMemHigh, statistics->usedHigh);
}


// resetting the watermarks will make them take on the current value.
void resetStatistics(cudaMemoryPool_t memPool)
{
cuuint64_t value = 0;
cudaMemPoolSetAttribute(memPool, cudaMemPoolAttrReservedMemHigh, &value);
cudaMemPoolSetAttribute(memPool, cudaMemPoolAttrUsedMemHigh, &value);
}

F.9. Memory Reuse Policies

为了服务分配请求,驱动程序在尝试从操作系统分配更多内存之前尝试重用之前通过 cudaFreeAsync() 释放的内存。 例如,流中释放的内存可以立即重新用于同一流中的后续分配请求。 类似地,当一个流与 CPU 同步时,之前在该流中释放的内存可以重新用于任何流中的分配。

流序分配器有一些可控的分配策略。 池属性 cudaMemPoolReuseFollowEventDependencies、cudaMemPoolReuseAllowOpportunistic 和 cudaMemPoolReuseAllowInternalDependencies 控制这些策略。 升级到更新的 CUDA 驱动程序可能会更改、增强、增加或重新排序重用策略。

F.9.1. cudaMemPoolReuseFollowEventDependencies

在分配更多物理 GPU 内存之前,分配器会检查由 CUDA 事件建立的依赖信息,并尝试从另一个流中释放的内存中进行分配。

1
2
3
4
5
6
7
8
9
10
11
cudaMallocAsync(&ptr, size, originalStream);
kernel<<<..., originalStream>>>(ptr, ...);
cudaFreeAsync(ptr, originalStream);
cudaEventRecord(event,originalStream);

// waiting on the event that captures the free in another stream
// allows the allocator to reuse the memory to satisfy
// a new allocation request in the other stream when
// cudaMemPoolReuseFollowEventDependencies is enabled.
cudaStreamWaitEvent(otherStream, event);
cudaMallocAsync(&ptr2, size, otherStream);

F.9.2. cudaMemPoolReuseAllowOpportunistic

根据 cudaMemPoolReuseAllowOpportunistic 策略,分配器检查释放的分配以查看是否满足释放的流序语义(即流已通过释放指示的执行点)。 禁用此功能后,分配器仍将重用在流与 cpu 同步时可用的内存。 禁用此策略不会阻止 cudaMemPoolReuseFollowEventDependencies 应用。

1
2
3
4
5
6
7
8
9
10
11
cudaMallocAsync(&ptr, size, originalStream);
kernel<<<..., originalStream>>>(ptr, ...);
cudaFreeAsync(ptr, originalStream);


// after some time, the kernel finishes running
wait(10);

// When cudaMemPoolReuseAllowOpportunistic is enabled this allocation request
// can be fulfilled with the prior allocation based on the progress of originalStream.
cudaMallocAsync(&ptr2, size, otherStream);

F.9.3. cudaMemPoolReuseAllowInternalDependencies

如果无法从操作系统分配和映射更多物理内存,驱动程序将寻找其可用性取决于另一个流的待处理进度的内存。 如果找到这样的内存,驱动程序会将所需的依赖项插入分配流并重用内存。

1
2
3
4
5
6
7
8
9
10
cudaMallocAsync(&ptr, size, originalStream);
kernel<<<..., originalStream>>>(ptr, ...);
cudaFreeAsync(ptr, originalStream);

// When cudaMemPoolReuseAllowInternalDependencies is enabled
// and the driver fails to allocate more physical memory, the driver may
// effectively perform a cudaStreamWaitEvent in the allocating stream
// to make sure that future work in ‘otherStream’ happens after the work
// in the original stream that would be allowed to access the original allocation.
cudaMallocAsync(&ptr2, size, otherStream);

F.9.4. Disabling Reuse Policies

虽然可控重用策略提高了内存重用,但用户可能希望禁用它们。 允许机会重用(即 cudaMemPoolReuseAllowOpportunistic)基于 CPU 和 GPU 执行的交错引入了运行到运行分配模式的差异。 当用户宁愿在分配失败时显式同步事件或流时,内部依赖插入(即 cudaMemPoolReuseAllowInternalDependencies)可以以意想不到的和潜在的非确定性方式序列化工作。

F.10. Device Accessibility for Multi-GPU Support

就像通过虚拟内存管理 API 控制的分配可访问性一样,内存池分配可访问性不遵循 cudaDeviceEnablePeerAccesscuCtxEnablePeerAccess。相反,API cudaMemPoolSetAccess 修改了哪些设备可以访问池中的分配。默认情况下,可以从分配所在的设备访问分配。无法撤销此访问权限。要启用其他设备的访问,访问设备必须与内存池的设备对等;检查 cudaDeviceCanAccessPeer。如果未检查对等功能,则设置访问可能会失败并显示 cudaErrorInvalidDevice。如果没有从池中进行分配,即使设备不具备对等能力,cudaMemPoolSetAccess 调用也可能成功;在这种情况下,池中的下一次分配将失败。

值得注意的是,cudaMemPoolSetAccess 会影响内存池中的所有分配,而不仅仅是未来的分配。此外,cudaMemPoolGetAccess 报告的可访问性适用于池中的所有分配,而不仅仅是未来的分配。建议不要频繁更改给定 GPU 的池的可访问性设置;一旦池可以从给定的 GPU 访问,它应该在池的整个生命周期内都可以从该 GPU 访问。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// snippet showing usage of cudaMemPoolSetAccess:
cudaError_t setAccessOnDevice(cudaMemPool_t memPool, int residentDevice,
int accessingDevice) {
cudaMemAccessDesc accessDesc = {};
accessDesc.location.type = cudaMemLocationTypeDevice;
accessDesc.location.id = accessingDevice;
accessDesc.flags = cudaMemAccessFlagsProtReadWrite;

int canAccess = 0;
cudaError_t error = cudaDeviceCanAccessPeer(&canAccess, accessingDevice,
residentDevice);
if (error != cudaSuccess) {
return error;
} else if (canAccess == 0) {
return cudaErrorPeerAccessUnsupported;
}

// Make the address accessible
return cudaMemPoolSetAccess(memPool, &accessDesc, 1);
}

F.11. IPC Memory Pools

支持 IPC 的内存池允许在进程之间轻松、高效和安全地共享 GPU 内存。 CUDA 的 IPC 内存池提供与 CUDA 的虚拟内存管理 API 相同的安全优势。

在具有内存池的进程之间共享内存有两个阶段。 进程首先需要共享对池的访问权限,然后共享来自该池的特定分配。 第一阶段建立并实施安全性。 第二阶段协调每个进程中使用的虚拟地址以及映射何时需要在导入过程中有效。

F.11.1. Creating and Sharing IPC Memory Pools

共享对池的访问涉及检索池的 OS 本机句柄(使用 cudaMemPoolExportToShareableHandle() API),使用通常的 OS 本机 IPC 机制将句柄转移到导入进程,并创建导入的内存池(使用 cudaMemPoolImportFromShareableHandle() API)。 要使 cudaMemPoolExportToShareableHandle 成功,必须使用池属性结构中指定的请求句柄类型创建内存池。 请参考示例以了解在进程之间传输操作系统本机句柄的适当 IPC 机制。 该过程的其余部分可以在以下代码片段中找到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// in exporting process
// create an exportable IPC capable pool on device 0
cudaMemPoolProps poolProps = { };
poolProps.allocType = cudaMemAllocationTypePinned;
poolProps.location.id = 0;
poolProps.location.type = cudaMemLocationTypeDevice;

// Setting handleTypes to a non zero value will make the pool exportable (IPC capable)
poolProps.handleTypes = CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR;

cudaMemPoolCreate(&memPool, &poolProps));

// FD based handles are integer types
int fdHandle = 0;


// Retrieve an OS native handle to the pool.
// Note that a pointer to the handle memory is passed in here.
cudaMemPoolExportToShareableHandle(&fdHandle,
memPool,
CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR,
0);

// The handle must be sent to the importing process with the appropriate
// OS specific APIs.
1
2
3
4
5
6
7
8
9
10
// in importing process
int fdHandle;
// The handle needs to be retrieved from the exporting process with the
// appropriate OS specific APIs.
// Create an imported pool from the shareable handle.
// Note that the handle is passed by value here.
cudaMemPoolImportFromShareableHandle(&importedMemPool,
(void*)fdHandle,
CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR,
0);

F.11.2. Set Access in the Importing Process

导入的内存池最初只能从其常驻设备访问。 导入的内存池不继承导出进程设置的任何可访问性。 导入过程需要启用从它计划访问内存的任何 GPU 的访问(使用 cudaMemPoolSetAccess)。

如果导入的内存池在导入过程中属于不可见的设备,则用户必须使用 cudaMemPoolSetAccess API 来启用从将使用分配的 GPU 的访问。

F.11.3. Creating and Sharing Allocations from an Exported Pool

共享池后,在导出进程中使用 cudaMallocAsync() 从池中进行的分配可以与已导入池的其他进程共享。由于池的安全策略是在池级别建立和验证的,操作系统不需要额外的簿记来为特定的池分配提供安全性;换句话说,导入池分配所需的不透明 cudaMemPoolPtrExportData 可以使用任何机制发送到导入进程。

虽然分配可以在不以任何方式与分配流同步的情况下导出甚至导入,但在访问分配时,导入过程必须遵循与导出过程相同的规则。即,对分配的访问必须发生在分配流中分配操作的流排序之后。以下两个代码片段显示 cudaMemPoolExportPointer()cudaMemPoolImportPointer() 与 IPC 事件共享分配,用于保证在分配准备好之前在导入过程中不会访问分配。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// preparing an allocation in the exporting process
cudaMemPoolPtrExportData exportData;
cudaEvent_t readyIpcEvent;
cudaIpcEventHandle_t readyIpcEventHandle;

// IPC event for coordinating between processes
// cudaEventInterprocess flag makes the event an IPC event
// cudaEventDisableTiming is set for performance reasons

cudaEventCreate(
&readyIpcEvent, cudaEventDisableTiming | cudaEventInterprocess)

// allocate from the exporting mem pool
cudaMallocAsync(&ptr, size,exportMemPool, stream);

// event for sharing when the allocation is ready.
cudaEventRecord(readyIpcEvent, stream);
cudaMemPoolExportPointer(&exportData, ptr);
cudaIpcGetEventHandle(&readyIpcEventHandle, readyIpcEvent);

// Share IPC event and pointer export data with the importing process using
// any mechanism. Here we copy the data into shared memory
shmem->ptrData = exportData;
shmem->readyIpcEventHandle = readyIpcEventHandle;
// signal consumers data is ready
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Importing an allocation
cudaMemPoolPtrExportData *importData = &shmem->prtData;
cudaEvent_t readyIpcEvent;
cudaIpcEventHandle_t *readyIpcEventHandle = &shmem->readyIpcEventHandle;

// Need to retrieve the IPC event handle and the export data from the
// exporting process using any mechanism. Here we are using shmem and just
// need synchronization to make sure the shared memory is filled in.

cudaIpcOpenEventHandle(&readyIpcEvent, readyIpcEventHandle);

// import the allocation. The operation does not block on the allocation being ready.
cudaMemPoolImportPointer(&ptr, importedMemPool, importData);

// Wait for the prior stream operations in the allocating stream to complete before
// using the allocation in the importing process.
cudaStreamWaitEvent(stream, readyIpcEvent);
kernel<<<..., stream>>>(ptr, ...);

释放分配时,需要先在导入过程中释放分配,然后在导出过程中释放分配。 以下代码片段演示了使用 CUDA IPC 事件在两个进程中的 cudaFreeAsync 操作之间提供所需的同步。 导入过程中对分配的访问显然受到导入过程侧的自由操作的限制。 值得注意的是,cudaFree 可用于释放两个进程中的分配,并且可以使用其他流同步 API 代替 CUDA IPC 事件。

1
2
3
4
5
6
7
8
// The free must happen in importing process before the exporting process
kernel<<<..., stream>>>(ptr, ...);

// Last access in importing process
cudaFreeAsync(ptr, stream);

// Access not allowed in the importing process after the free
cudaIpcEventRecord(finishedIpcEvent, stream);
1
2
3
4
5
6
7
8
9
// Exporting process
// The exporting process needs to coordinate its free with the stream order
// of the importing process’s free.
cudaStreamWaitEvent(stream, finishedIpcEvent);
kernel<<<..., stream>>>(ptrInExportingProcess, ...);

// The free in the importing process doesn’t stop the exporting process
// from using the allocation.
cudFreeAsync(ptrInExportingProcess,stream);

F.11.4. IPC Export Pool Limitations

IPC 池目前不支持将物理块释放回操作系统。 因此,cudaMemPoolTrimTo API 充当空操作,并且 cudaMemPoolAttrReleaseThreshold 被有效地忽略。 此行为由驱动程序控制,而不是运行时控制,并且可能会在未来的驱动程序更新中发生变化。

F.11.5. IPC Import Pool Limitations

不允许从导入池中分配; 具体来说,导入池不能设置为当前,也不能在 cudaMallocFromPoolAsync API 中使用。 因此,分配重用策略属性对这些池没有意义。

IPC 池目前不支持将物理块释放回操作系统。 因此,cudaMemPoolTrimTo API 充当空操作,并且 cudaMemPoolAttrReleaseThreshold 被有效地忽略。

资源使用统计属性查询仅反映导入进程的分配和相关的物理内存。

F.12. Synchronization API Actions

作为 CUDA 驱动程序一部分的分配器带来的优化之一是与同步 API 的集成。 当用户请求 CUDA 驱动程序同步时,驱动程序等待异步工作完成。 在返回之前,驱动程序将确定什么释放了保证完成的同步。 无论指定的流或禁用的分配策略如何,这些分配都可用于分配。 驱动程序还在这里检查 cudaMemPoolAttrReleaseThreshold 并释放它可以释放的任何多余的物理内存。

F.13. Addendums

F.13.1. cudaMemcpyAsync Current Context/Device Sensitivity

在当前的 CUDA 驱动程序中,任何涉及来自 cudaMallocAsync 的内存的异步 memcpy 都应该使用指定流的上下文作为调用线程的当前上下文来完成。 这对于 cudaMemcpyPeerAsync 不是必需的,因为引用了 API 中指定的设备主上下文而不是当前上下文。

F.13.2. cuPointerGetAttribute Query

在对分配调用 cudaFreeAsync 后在分配上调用 cuPointerGetAttribute 会导致未定义的行为。 具体来说,分配是否仍然可以从给定的流中访问并不重要:行为仍然是未定义的。

F.13.3. cuGraphAddMemsetNode

cuGraphAddMemsetNode 不适用于通过流排序分配器分配的内存。 但是,分配的 memset 可以被流捕获。

F.13.4. Pointer Attributes

cuPointerGetAttributes 查询适用于流有序分配。 由于流排序分配与上下文无关,因此查询 CU_POINTER_ATTRIBUTE_CONTEXT 将成功,但在 *data 中返回 NULL。 属性 CU_POINTER_ATTRIBUTE_DEVICE_ORDINAL 可用于确定分配的位置:这在选择使用 cudaMemcpyPeerAsync 制作 p2h2p 拷贝的上下文时很有用。 CU_POINTER_ATTRIBUTE_MEMPOOL_HANDLE 属性是在 CUDA 11.3 中添加的,可用于调试和在执行 IPC 之前确认分配来自哪个池。

附录G 图内存节点

G.1. Introduction

图内存节点允许图创建和拥有内存分配功能。图内存节点具有 GPU 有序生命周期语义,它指示何时允许在设备上访问内存。这些 GPU 有序生命周期语义支持驱动程序管理的内存重用,并与流序分配 API cudaMallocAsynccudaFreeAsync 相匹配,这可能在创建图形时被捕获。

图分配在图的生命周期内具有固定的地址,包括重复的实例化和启动。这允许图中的其他操作直接引用内存,而无需更新图,即使 CUDA 更改了后备物理内存也是如此。在一个图中,其图有序生命周期不重叠的分配可以使用相同的底层物理内存。

CUDA 可以重用相同的物理内存进行跨多个图的分配,根据 GPU 有序生命周期语义对虚拟地址映射进行别名化。例如,当不同的图被启动到同一个流中时,CUDA 可以虚拟地为相同的物理内存取别名,以满足具有单图生命周期的分配的需求。

G.2. Support and Compatibility

图内存节点需要支持 11.4 的 CUDA 驱动程序并支持 GPU 上的流序分配器。 以下代码段显示了如何检查给定设备上的支持。

1
2
3
4
5
6
7
8
9
int driverVersion = 0;
int deviceSupportsMemoryPools = 0;
int deviceSupportsMemoryNodes = 0;
cudaDriverGetVersion(&driverVersion);
if (driverVersion >= 11020) { // avoid invalid value error in cudaDeviceGetAttribute
cudaDeviceGetAttribute(&deviceSupportsMemoryPools, cudaDevAttrMemoryPoolsSupported, device);
}
deviceSupportsMemoryNodes = (driverVersion >= 11040) && (deviceSupportsMemoryPools != 0);

在驱动程序版本检查中执行属性查询可避免 11.0 和 11.1 驱动程序上的无效值返回代码。 请注意,计算清理程序在检测到 CUDA 返回错误代码时会发出警告,并且在读取属性之前进行版本检查将避免这种情况。 图形内存节点仅在驱动程序版本 11.4 和更高版本上受支持。

G.3. API Fundamentals

图内存节点是表示内存分配或空闲操作的图节点。 简而言之,分配内存的节点称为分配节点。 同样,释放内存的节点称为空闲节点。 分配节点创建的分配称为图分配。 CUDA 在节点创建时为图分配分配虚拟地址。 虽然这些虚拟地址在分配节点的生命周期内是固定的,但分配内容在释放操作之后不会持久,并且可能被引用不同分配的访问覆盖。

每次图运行时,图分配都被视为重新创建。 图分配的生命周期与节点的生命周期不同,从 GPU 执行到达分配图节点时开始,并在发生以下情况之一时结束:

  • GPU 执行到达释放图节点
  • GPU 执行到达释放 cudaFreeAsync() 流调用
  • 立即释放对 cudaFree() 的调用

注意:图销毁不会自动释放任何实时图分配的内存,即使它结束了分配节点的生命周期。 随后必须在另一个图中或使用 cudaFreeAsync()/cudaFree() 释放分配。

就像其他图节点一样,图内存节点在图中按依赖边排序。 程序必须保证访问图内存的操作:

  • 在分配节点之后排序。
  • 在释放内存的操作之前排序

图分配生命周期根据 GPU 执行开始和结束(与 API 调用相反)。 GPU 排序是工作在 GPU 上运行的顺序,而不是工作队列或描述的顺序。 因此,图分配被认为是“GPU 有序”。

G.3.1. Graph Node APIs

可以使用内存节点创建 API、cudaGraphAddMemAllocNodecudaGraphAddMemFreeNode 显式创建图形内存节点。 cudaGraphAddMemAllocNode 分配的地址在传递的 CUDA_MEM_ALLOC_NODE_PARAMS 结构的 dptr 字段中返回给用户。 在分配图中使用图分配的所有操作必须在分配节点之后排序。 类似地,任何空闲节点都必须在图中所有分配的使用之后进行排序。 cudaGraphAddMemFreeNode 创建空闲节点。

在下图中,有一个带有分配和空闲节点的示例图。 内核节点 abc 在分配节点之后和空闲节点之前排序,以便内核可以访问分配。 内核节点 e 没有排在 alloc 节点之后,因此无法安全地访问内存。 内核节点 d 没有排在空闲节点之前,因此它不能安全地访问内存。

以下代码片段建立了该图中的图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Create the graph - it starts out empty
cudaGraphCreate(&graph, 0);

// parameters for a basic allocation
cudaMemAllocNodeParams params = {};
params.poolProps.allocType = cudaMemAllocationTypePinned;
params.poolProps.location.type = cudaMemLocationTypeDevice;
// specify device 0 as the resident device
params.poolProps.location.id = 0;
params.bytesize = size;

cudaGraphAddMemAllocNode(&allocNode, graph, NULL, 0, &params);
nodeParams->kernelParams[0] = params.dptr;
cudaGraphAddKernelNode(&a, graph, &allocNode, 1, &nodeParams);
cudaGraphAddKernelNode(&b, graph, &a, 1, &nodeParams);
cudaGraphAddKernelNode(&c, graph, &a, 1, &nodeParams);
cudaGraphNode_t dependencies[2];
// kernel nodes b and c are using the graph allocation, so the freeing node must depend on them. Since the dependency of node b on node a establishes an indirect dependency, the free node does not need to explicitly depend on node a.
dependencies[0] = b;
dependencies[1] = c;
cudaGraphAddMemFreeNode(&freeNode, graph, dependencies, 2, params.dptr);
// free node does not depend on kernel node d, so it must not access the freed graph allocation.
cudaGraphAddKernelNode(&d, graph, &c, 1, &nodeParams);

// node e does not depend on the allocation node, so it must not access the allocation. This would be true even if the freeNode depended on kernel node e.
cudaGraphAddKernelNode(&e, graph, NULL, 0, &nodeParams);

G.3.2. Stream Capture

可以通过捕获相应的流序分配和免费调用 cudaMallocAsynccudaFreeAsync 来创建图形内存节点。 在这种情况下,捕获的分配 API 返回的虚拟地址可以被图中的其他操作使用。 由于流序的依赖关系将被捕获到图中,流序分配 API 的排序要求保证了图内存节点将根据捕获的流操作正确排序(对于正确编写的流代码)。

忽略内核节点 de,为清楚起见,以下代码片段显示了如何使用流捕获来创建上图中的图形:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
cudaMallocAsync(&dptr, size, stream1);
kernel_A<<< ..., stream1 >>>(dptr, ...);

// Fork into stream2
cudaEventRecord(event1, stream1);
cudaStreamWaitEvent(stream2, event1);

kernel_B<<< ..., stream1 >>>(dptr, ...);
// event dependencies translated into graph dependencies, so the kernel node created by the capture of kernel C will depend on the allocation node created by capturing the cudaMallocAsync call.
kernel_C<<< ..., stream2 >>>(dptr, ...);

// Join stream2 back to origin stream (stream1)
cudaEventRecord(event2, stream2);
cudaStreamWaitEvent(stream1, event2);

// Free depends on all work accessing the memory.
cudaFreeAsync(dptr, stream1);

// End capture in the origin stream
cudaStreamEndCapture(stream1, &graph);

G.3.3. Accessing and Freeing Graph Memory Outside of the Allocating Graph

图分配不必由分配图释放。当图不释放分配时,该分配会在图执行之后持续存在,并且可以通过后续 CUDA 操作访问。这些分配可以在另一个图中访问或直接通过流操作访问,只要访问操作在分配之后通过 CUDA 事件和其他流排序机制进行排序。随后可以通过定期调用 cudaFree、cudaFreeAsync 或通过启动具有相应空闲节点的另一个图,或随后启动分配图(如果它是使用 cudaGraphInstantiateFlagAutoFreeOnLaunch 标志实例化)来释放分配。在内存被释放后访问内存是非法的 - 必须在所有使用图依赖、CUDA 事件和其他流排序机制访问内存的操作之后对释放操作进行排序。

注意:因为图分配可能彼此共享底层物理内存,所以必须考虑与一致性和一致性相关的虚拟混叠支持规则。简单地说,空闲操作必须在完整的设备操作(例如,计算内核/ memcpy)完成后排序。具体来说,带外同步——例如,作为访问图形内存的计算内核的一部分,通过内存进行信号交换——不足以提供对图形内存的写操作和该图形内存的自由操作之间的排序保证。

以下代码片段演示了在分配图之外访问图分配,并通过以下方式正确建立顺序:使用单个流,使用流之间的事件,以及使用嵌入到分配和释放图中的事件。

使用单个流建立的排序:

1
2
3
4
5
6
7
8
9
void *dptr;
cudaGraphAddMemAllocNode(&allocNode, allocGraph, NULL, 0, &params);
dptr = params.dptr;

cudaGraphInstantiate(&allocGraphExec, allocGraph, NULL, NULL, 0);

cudaGraphLaunch(allocGraphExec, stream);
kernel<<< …, stream >>>(dptr, …);
cudaFreeAsync(dptr, stream);

通过记录和等待 CUDA 事件建立的排序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void *dptr;

// Contents of allocating graph
cudaGraphAddMemAllocNode(&allocNode, allocGraph, NULL, 0, &params);
dptr = params.dptr;

// contents of consuming/freeing graph
nodeParams->kernelParams[0] = params.dptr;
cudaGraphAddKernelNode(&a, graph, NULL, 0, &nodeParams);
cudaGraphAddMemFreeNode(&freeNode, freeGraph, &a, 1, dptr);

cudaGraphInstantiate(&allocGraphExec, allocGraph, NULL, NULL, 0);
cudaGraphInstantiate(&freeGraphExec, freeGraph, NULL, NULL, 0);

cudaGraphLaunch(allocGraphExec, allocStream);

// establish the dependency of stream2 on the allocation node
// note: the dependency could also have been established with a stream synchronize operation
cudaEventRecord(allocEvent, allocStream)
cudaStreamWaitEvent(stream2, allocEvent);

kernel<<< …, stream2 >>> (dptr, …);

// establish the dependency between the stream 3 and the allocation use
cudaStreamRecordEvent(streamUseDoneEvent, stream2);
cudaStreamWaitEvent(stream3, streamUseDoneEvent);

// it is now safe to launch the freeing graph, which may also access the memory
cudaGraphLaunch(freeGraphExec, stream3);

使用图外部事件节点建立的排序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
void *dptr;
cudaEvent_t allocEvent; // event indicating when the allocation will be ready for use.
cudaEvent_t streamUseDoneEvent; // event indicating when the stream operations are done with the allocation.

// Contents of allocating graph with event record node
cudaGraphAddMemAllocNode(&allocNode, allocGraph, NULL, 0, &params);
dptr = params.dptr;
// note: this event record node depends on the alloc node
cudaGraphAddEventRecordNode(&recordNode, allocGraph, &allocNode, 1, allocEvent);
cudaGraphInstantiate(&allocGraphExec, allocGraph, NULL, NULL, 0);

// contents of consuming/freeing graph with event wait nodes
cudaGraphAddEventWaitNode(&streamUseDoneEventNode, waitAndFreeGraph, NULL, 0, streamUseDoneEvent);
cudaGraphAddEventWaitNode(&allocReadyEventNode, waitAndFreeGraph, NULL, 0, allocEvent);
nodeParams->kernelParams[0] = params.dptr;

// The allocReadyEventNode provides ordering with the alloc node for use in a consuming graph.
cudaGraphAddKernelNode(&kernelNode, waitAndFreeGraph, &allocReadyEventNode, 1, &nodeParams);

// The free node has to be ordered after both external and internal users.
// Thus the node must depend on both the kernelNode and the
// streamUseDoneEventNode.
dependencies[0] = kernelNode;
dependencies[1] = streamUseDoneEventNode;
cudaGraphAddMemFreeNode(&freeNode, waitAndFreeGraph, &dependencies, 2, dptr);
cudaGraphInstantiate(&waitAndFreeGraphExec, waitAndFreeGraph, NULL, NULL, 0);

cudaGraphLaunch(allocGraphExec, allocStream);

// establish the dependency of stream2 on the event node satisfies the ordering requirement
cudaStreamWaitEvent(stream2, allocEvent);
kernel<<< …, stream2 >>> (dptr, …);
cudaStreamRecordEvent(streamUseDoneEvent, stream2);

// the event wait node in the waitAndFreeGraphExec establishes the dependency on the “readyForFreeEvent” that is needed to prevent the kernel running in stream two from accessing the allocation after the free node in execution order.
cudaGraphLaunch(waitAndFreeGraphExec, stream3);

G.3.4. cudaGraphInstantiateFlagAutoFreeOnLaunch

在正常情况下,如果图有未释放的内存分配,CUDA 将阻止重新启动图,因为同一地址的多个分配会泄漏内存。使用 cudaGraphInstantiateFlagAutoFreeOnLaunch 标志实例化图允许图在其仍有未释放的分配时重新启动。在这种情况下,启动会自动插入一个异步释放的未释放分配。

启动时自动对于单生产者多消费者算法很有用。在每次迭代中,生产者图创建多个分配,并且根据运行时条件,一组不同的消费者访问这些分配。这种类型的变量执行序列意味着消费者无法释放分配,因为后续消费者可能需要访问。启动时自动释放意味着启动循环不需要跟踪生产者的分配 - 相反,该信息与生产者的创建和销毁逻辑保持隔离。通常,启动时自动释放简化了算法,否则该算法需要在每次重新启动之前释放图所拥有的所有分配。

注意: cudaGraphInstantiateFlagAutoFreeOnLaunch 标志不会改变图销毁的行为。应用程序必须显式释放未释放的内存以避免内存泄漏,即使对于使用标志实例化的图也是如此。

以下代码展示了使用 cudaGraphInstantiateFlagAutoFreeOnLaunch 来简化单生产者/多消费者算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// Create producer graph which allocates memory and populates it with data
cudaStreamBeginCapture(cudaStreamPerThread, cudaStreamCaptureModeGlobal);
cudaMallocAsync(&data1, blocks * threads, cudaStreamPerThread);
cudaMallocAsync(&data2, blocks * threads, cudaStreamPerThread);
produce<<<blocks, threads, 0, cudaStreamPerThread>>>(data1, data2);
...
cudaStreamEndCapture(cudaStreamPerThread, &graph);
cudaGraphInstantiateWithFlags(&producer,
graph,
cudaGraphInstantiateFlagAutoFreeOnLaunch);
cudaGraphDestroy(graph);

// Create first consumer graph by capturing an asynchronous library call
cudaStreamBeginCapture(cudaStreamPerThread, cudaStreamCaptureModeGlobal);
consumerFromLibrary(data1, cudaStreamPerThread);
cudaStreamEndCapture(cudaStreamPerThread, &graph);
cudaGraphInstantiateWithFlags(&consumer1, graph, 0); //regular instantiation
cudaGraphDestroy(graph);

// Create second consumer graph
cudaStreamBeginCapture(cudaStreamPerThread, cudaStreamCaptureModeGlobal);
consume2<<<blocks, threads, 0, cudaStreamPerThread>>>(data2);
...
cudaStreamEndCapture(cudaStreamPerThread, &graph);
cudaGraphInstantiateWithFlags(&consumer2, graph, 0);
cudaGraphDestroy(graph);

// Launch in a loop
bool launchConsumer2 = false;
do {
cudaGraphLaunch(producer, myStream);
cudaGraphLaunch(consumer1, myStream);
if (launchConsumer2) {
cudaGraphLaunch(consumer2, myStream);
}
} while (determineAction(&launchConsumer2));

cudaFreeAsync(data1, myStream);
cudaFreeAsync(data2, myStream);

cudaGraphExecDestroy(producer);
cudaGraphExecDestroy(consumer1);
cudaGraphExecDestroy(consumer2);

G.4. Optimized Memory Reuse

CUDA 以两种方式重用内存:

  • 图中的虚拟和物理内存重用基于虚拟地址分配,就像在流序分配器中一样。
  • 图之间的物理内存重用是通过虚拟别名完成的:不同的图可以将相同的物理内存映射到它们唯一的虚拟地址。

G.4.1. Address Reuse within a Graph

CUDA 可以通过将相同的虚拟地址范围分配给生命周期不重叠的不同分配来重用图中的内存。 由于可以重用虚拟地址,因此不能保证指向具有不相交生命周期的不同分配的指针是唯一的。

下图显示了添加一个新的分配节点 (2),它可以重用依赖节点 (1) 释放的地址。

下图显示了添加新的 alloc 节点(3)。 新的分配节点不依赖于空闲节点 (2),因此不能重用来自关联分配节点 (2) 的地址。 如果分配节点 (2) 使用由空闲节点 (1) 释放的地址,则新分配节点 3 将需要一个新地址。

G.4.2. Physical Memory Management and Sharing

CUDA 负责在按 GPU 顺序到达分配节点之前将物理内存映射到虚拟地址。作为内存占用和映射开销的优化,如果多个图不会同时运行,它们可能会使用相同的物理内存进行不同的分配,但是如果它们同时绑定到多个执行图,则物理页面不能被重用,或未释放的图形分配。

CUDA 可以在图形实例化、启动或执行期间随时更新物理内存映射。 CUDA 还可以在未来的图启动之间引入同步,以防止实时图分配引用相同的物理内存。对于任何 allocate-free-allocate 模式,如果程序在分配的生命周期之外访问指针,错误的访问可能会默默地读取或写入另一个分配拥有的实时数据(即使分配的虚拟地址是唯一的)。使用计算清理工具可以捕获此错误。

下图显示了在同一流中按顺序启动的图形。在此示例中,每个图都会释放它分配的所有内存。由于同一流中的图永远不会同时运行,CUDA 可以而且应该使用相同的物理内存来满足所有分配。

G.5. Performance Considerations

当多个图启动到同一个流中时,CUDA 会尝试为它们分配相同的物理内存,因为这些图的执行不能重叠。 在启动之间保留图形的物理映射作为优化以避免重新映射的成本。 如果稍后启动其中一个图,使其执行可能与其他图重叠(例如,如果它启动到不同的流中),则 CUDA 必须执行一些重新映射,因为并发图需要不同的内存以避免数据损坏 .

一般来说,CUDA中图内存的重新映射很可能是由这些操作引起的

  • 更改启动图形的流
  • 图内存池上的修剪操作,显式释放未使用的内存(在物理内存占用中讨论)
  • 当另一个图的未释放分配映射到同一内存时重新启动一个图将导致在重新启动之前重新映射内存

重新映射必须按执行顺序发生,但在该图的任何先前执行完成之后(否则可能会取消映射仍在使用的内存)。 由于这种排序依赖性,以及映射操作是操作系统调用,映射操作可能相对昂贵。 应用程序可以通过将包含分配内存节点的图一致地启动到同一流中来避免这种成本。

G.5.1. First Launch / cudaGraphUpload

在图实例化期间无法分配或映射物理内存,因为图将在其中执行的流是未知的。 映射是在图形启动期间完成的。 调用 cudaGraphUpload 可以通过立即执行该图的所有映射并将该图与上传流相关联,将分配成本与启动分开。 如果图随后启动到同一流中,它将启动而无需任何额外的重新映射。

使用不同的流进行图上传和图启动的行为类似于切换流,可能会导致重新映射操作。 此外,允许无关的内存池管理从空闲流中提取内存,这可能会抵消上传的影响。

G.6. Physical Memory Footprint

异步分配的池管理行为意味着销毁包含内存节点的图(即使它们的分配是空闲的)不会立即将物理内存返回给操作系统以供其他进程使用。要显式将内存释放回操作系统,应用程序应使用 cudaDeviceGraphMemTrim API。

cudaDeviceGraphMemTrim 将取消映射并释放由图形内存节点保留的未主动使用的任何物理内存。尚未释放的分配和计划或运行的图被认为正在积极使用物理内存,不会受到影响。使用修剪 API 将使物理内存可用于其他分配 API 和其他应用程序或进程,但会导致 CUDA 在下次启动修剪图时重新分配和重新映射内存。请注意,cudaDeviceGraphMemTrim 在与 cudaMemPoolTrimTo() 不同的池上运行。图形内存池不会暴露给流序内存分配器。 CUDA 允许应用程序通过 cudaDeviceGetGraphMemAttribute API 查询其图形内存占用量。查询属性 cudaGraphMemAttrReservedMemCurrent 返回驱动程序为当前进程中的图形分配保留的物理内存量。查询 cudaGraphMemAttrUsedMemCurrent 返回至少一个图当前映射的物理内存量。这些属性中的任何一个都可用于跟踪 CUDA 何时为分配图而获取新的物理内存。这两个属性对于检查共享机制节省了多少内存都很有用。

G.7. Peer Access

图分配可以配置为从多个 GPU 访问,在这种情况下,CUDA 将根据需要将分配映射到对等 GPU。 CUDA 允许需要不同映射的图分配重用相同的虚拟地址。 发生这种情况时,地址范围将映射到不同分配所需的所有 GPU。 这意味着分配有时可能允许比其创建期间请求的更多对等访问; 然而,依赖这些额外的映射仍然是一个错误。

G.7.1. Peer Access with Graph Node APIs

cudaGraphAddMemAllocNode API 接受节点参数结构的 accessDescs 数组字段中的映射请求。 poolProps.location 嵌入式结构指定分配的常驻设备。 假设需要来自分配 GPU 的访问,因此应用程序不需要在 accessDescs 数组中为常驻设备指定条目。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
cudaMemAllocNodeParams params = {};
params.poolProps.allocType = cudaMemAllocationTypePinned;
params.poolProps.location.type = cudaMemLocationTypeDevice;
// specify device 1 as the resident device
params.poolProps.location.id = 1;
params.bytesize = size;

// allocate an allocation resident on device 1 accessible from device 1
cudaGraphAddMemAllocNode(&allocNode, graph, NULL, 0, &params);

accessDescs[2];
// boilerplate for the access descs (only ReadWrite and Device access supported by the add node api)
accessDescs[0].flags = cudaMemAccessFlagsProtReadWrite;
accessDescs[0].location.type = cudaMemLocationTypeDevice;
accessDescs[1].flags = cudaMemAccessFlagsProtReadWrite;
accessDescs[1].location.type = cudaMemLocationTypeDevice;

// access being requested for device 0 & 2. Device 1 access requirement left implicit.
accessDescs[0].location.id = 0;
accessDescs[1].location.id = 2;

// access request array has 2 entries.
params.accessDescCount = 2;
params.accessDescs = accessDescs;

// allocate an allocation resident on device 1 accessible from devices 0, 1 and 2. (0 & 2 from the descriptors, 1 from it being the resident device).
cudaGraphAddMemAllocNode(&allocNode, graph, NULL, 0, &params);

G.7.2. Peer Access with Stream Capture

对于流捕获,分配节点在捕获时记录分配池的对等可访问性。 在捕获 cudaMallocFromPoolAsync 调用后更改分配池的对等可访问性不会影响图将为分配进行的映射。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// boilerplate for the access descs (only ReadWrite and Device access supported by the add node api)
accessDesc.flags = cudaMemAccessFlagsProtReadWrite;
accessDesc.location.type = cudaMemLocationTypeDevice;
accessDesc.location.id = 1;

// let memPool be resident and accessible on device 0

cudaStreamBeginCapture(stream);
cudaMallocAsync(&dptr1, size, memPool, stream);
cudaStreamEndCapture(stream, &graph1);

cudaMemPoolSetAccess(memPool, &accessDesc, 1);

cudaStreamBeginCapture(stream);
cudaMallocAsync(&dptr2, size, memPool, stream);
cudaStreamEndCapture(stream, &graph2);

//The graph node allocating dptr1 would only have the device 0 accessibility even though memPool now has device 1 accessibility.
//The graph node allocating dptr2 will have device 0 and device 1 accessibility, since that was the pool accessibility at the time of the cudaMallocAsync call.

附录H 数学方法

参考手册列出了设备代码中支持的 C/C++ 标准库数学函数的所有函数及其描述,以及所有内部函数(仅在设备代码中支持)。

本附录在适用时提供了其中一些功能的准确性信息。它使用 ULP 进行量化。有关最后位置单元 (ULP: Unit in the Last Place, 上面是直译的,这里可以理解为最小精度单元) 定义的更多信息,请参阅 Jean-Michel Muller’s paper On the definition of ulp(x), RR-5504, LIP RR-2005-09, INRIA, LIP. 2005, pp.16 at https://hal.inria.fr/inria-00070503/document

设备代码中支持的数学函数不设置全局 errno 变量,也不报告任何浮点异常来指示错误;因此,如果需要错误诊断机制,用户应该对函数的输入和输出实施额外的筛选。用户负责指针参数的有效性。用户不得将未初始化的参数传递给数学函数,因为这可能导致未定义的行为:函数在用户程序中内联,因此受到编译器优化的影响。

H.1. Standard Functions

本节中的函数可用于主机和设备代码。

本节指定每个函数在设备上执行时的错误范围,以及在主机不提供函数的情况下在主机上执行时的错误范围。

错误界限是从广泛但并非详尽的测试中生成的,因此它们不是保证界限。

Single-Precision Floating-Point Functions

加法和乘法符合 IEEE 标准,因此最大误差为 0.5 ulp。

将单精度浮点操作数舍入为整数的推荐方法是 rintf(),而不是 roundf()。 原因是 roundf() 映射到设备上的 4 条指令序列,而 rintf() 映射到单个指令。 truncf()ceilf()floorf() 也都映射到一条指令。

Table 7. Single-Precision Mathematical Standard Library Functions with Maximum ULP Error. The maximum error is stated as the absolute value of the difference in ulps between a correctly rounded single-precision result and the result returned by the CUDA library function.
Function Maximum ulp error
x+y

0 (IEEE-754 round-to-nearest-even)

x*y

0 (IEEE-754 round-to-nearest-even)

x/y

0 for compute capability ≥ 2 when compiled with -prec-div=true

2 (full range), otherwise

1/x

0 for compute capability ≥ 2 when compiled with -prec-div=true

1 (full range), otherwise

rsqrtf(x)

1/sqrtf(x)

2 (full range)

Applies to 1/sqrtf(x) only when it is converted to rsqrtf(x) by the compiler.

sqrtf(x)

0 when compiled with -prec-sqrt=true

Otherwise 1 for compute capability ≥ 5.2

and 3 for older architectures

cbrtf(x) 1 (full range)
rcbrtf(x) 1 (full range)
hypotf(x,y) 3 (full range)
rhypotf(x,y) 2 (full range)
norm3df(x,y,z) 3 (full range)
rnorm3df(x,y,z) 2 (full range)
norm4df(x,y,z,t) 3 (full range)
rnorm4df(x,y,z,t) 2 (full range)
normf(dim,arr) An error bound can't be provided because a fast algorithm is used with accuracy loss due to round-off
rnormf(dim,arr) An error bound can't be provided because a fast algorithm is used with accuracy loss due to round-off
expf(x) 2 (full range)
exp2f(x) 2 (full range)
exp10f(x) 2 (full range)
expm1f(x) 1 (full range)
logf(x) 1 (full range)
log2f(x) 1 (full range)
log10f(x) 2 (full range)
log1pf(x) 1 (full range)
sinf(x) 2 (full range)
cosf(x) 2 (full range)
tanf(x) 4 (full range)
sincosf(x,sptr,cptr) 2 (full range)
sinpif(x) 2 (full range)
cospif(x) 2 (full range)
sincospif(x,sptr,cptr) 2 (full range)
asinf(x) 4 (full range)
acosf(x) 3 (full range)
atanf(x) 2 (full range)
atan2f(y,x) 3 (full range)
sinhf(x) 3 (full range)
coshf(x) 2 (full range)
tanhf(x) 2 (full range)
asinhf(x) 3 (full range)
acoshf(x) 4 (full range)
atanhf(x) 3 (full range)
powf(x,y) 9 (full range)
erff(x) 2 (full range)
erfcf(x) 4 (full range)
erfinvf(x) 2 (full range)
erfcinvf(x) 4 (full range)
erfcxf(x) 4 (full range)
normcdff(x) 5 (full range)
normcdfinvf(x) 5 (full range)
lgammaf(x) 6 (outside interval -10.001 ... -2.264; larger inside)
tgammaf(x) 11 (full range)
fmaf(x,y,z) 0 (full range)
frexpf(x,exp) 0 (full range)
ldexpf(x,exp) 0 (full range)
scalbnf(x,n) 0 (full range)
scalblnf(x,l) 0 (full range)
logbf(x) 0 (full range)
ilogbf(x) 0 (full range)
j0f(x)

9 for |x| < 8

otherwise, the maximum absolute error is 2.2 x 10-6

j1f(x)

9 for |x| < 8

otherwise, the maximum absolute error is 2.2 x 10-6

jnf(n,x) For n = 128, the maximum absolute error is 2.2 x 10-6
y0f(x)

9 for |x| < 8

otherwise, the maximum absolute error is 2.2 x 10-6

y1f(x)

9 for |x| < 8

otherwise, the maximum absolute error is 2.2 x 10-6

ynf(n,x)

ceil(2 + 2.5n) for |x| < n

otherwise, the maximum absolute error is 2.2 x 10-6

cyl_bessel_i0f(x) 6 (full range)
cyl_bessel_i1f(x) 6 (full range)
fmodf(x,y) 0 (full range)
remainderf(x,y) 0 (full range)
remquof(x,y,iptr) 0 (full range)
modff(x,iptr) 0 (full range)
fdimf(x,y) 0 (full range)
truncf(x) 0 (full range)
roundf(x) 0 (full range)
rintf(x) 0 (full range)
nearbyintf(x) 0 (full range)
ceilf(x) 0 (full range)
floorf(x) 0 (full range)
lrintf(x) 0 (full range)
lroundf(x) 0 (full range)
llrintf(x) 0 (full range)
llroundf(x) 0 (full range)

Double-Precision Floating-Point Functions

将双精度浮点操作数舍入为整数的推荐方法是 rint(),而不是 round()。 原因是 round() 映射到设备上的 5 条指令序列,而 rint() 映射到单个指令。 trunc()、ceil() 和 floor() 也都映射到一条指令。

.
Function Maximum ulp error
x+y

0 (IEEE-754 round-to-nearest-even)

x*y

0 (IEEE-754 round-to-nearest-even)

x/y

0 (IEEE-754 round-to-nearest-even)

1/x

0 (IEEE-754 round-to-nearest-even)

sqrt(x) 0 (IEEE-754 round-to-nearest-even)
rsqrt(x)

1 (full range)

cbrt(x) 1 (full range)
rcbrt(x) 1 (full range)
hypot(x,y) 2 (full range)
rhypot(x,y) 1 (full range)
norm3d(x,y,z) 2 (full range)
rnorm3d(x,y,z) 1 (full range)
norm4d(x,y,z,t) 2 (full range)
rnorm4d(x,y,z,t) 1 (full range)
norm(dim,arr) An error bound can't be provided because a fast algorithm is used with accuracy loss due to round-off
rnorm(dim,arr) An error bound can't be provided because a fast algorithm is used with accuracy loss due to round-off
exp(x) 1 (full range)
exp2(x) 1 (full range)
exp10(x) 1 (full range)
expm1(x) 1 (full range)
log(x) 1 (full range)
log2(x) 1 (full range)
log10(x) 1 (full range)
log1p(x) 1 (full range)
sin(x) 2 (full range)
cos(x) 2 (full range)
tan(x) 2 (full range)
sincos(x,sptr,cptr) 2 (full range)
sinpi(x) 2 (full range)
cospi(x) 2 (full range)
sincospi(x,sptr,cptr) 2 (full range)
asin(x) 2 (full range)
acos(x) 2 (full range)
atan(x) 2 (full range)
atan2(y,x) 2 (full range)
sinh(x) 2 (full range)
cosh(x) 1 (full range)
tanh(x) 1 (full range)
asinh(x) 2 (full range)
acosh(x) 2 (full range)
atanh(x) 2 (full range)
pow(x,y) 2 (full range)
erf(x) 2 (full range)
erfc(x) 5 (full range)
erfinv(x) 5 (full range)
erfcinv(x) 6 (full range)
erfcx(x) 4 (full range)
normcdf(x) 5 (full range)
normcdfinv(x) 8 (full range)
lgamma(x) 4 (outside interval -11.0001 ... -2.2637; larger inside)
tgamma(x) 8 (full range)
fma(x,y,z) 0 (IEEE-754 round-to-nearest-even)
frexp(x,exp) 0 (full range)
ldexp(x,exp) 0 (full range)
scalbn(x,n) 0 (full range)
scalbln(x,l) 0 (full range)
logb(x) 0 (full range)
ilogb(x) 0 (full range)
j0(x)

7 for |x| < 8

otherwise, the maximum absolute error is 5 x 10-12

j1(x)

7 for |x| < 8

otherwise, the maximum absolute error is 5 x 10-12

jn(n,x) For n = 128, the maximum absolute error is 5 x 10-12
y0(x)

7 for |x| < 8

otherwise, the maximum absolute error is 5 x 10-12

y1(x)

7 for |x| < 8

otherwise, the maximum absolute error is 5 x 10-12

yn(n,x)

For |x| > 1.5n, the maximum absolute error is 5 x 10-12

cyl_bessel_i0(x) 6 (full range)
cyl_bessel_i1(x) 6 (full range)
fmod(x,y) 0 (full range)
remainder(x,y) 0 (full range)
remquo(x,y,iptr) 0 (full range)
modf(x,iptr) 0 (full range)
fdim(x,y) 0 (full range)
trunc(x) 0 (full range)
round(x) 0 (full range)
rint(x) 0 (full range)
nearbyint(x) 0 (full range)
ceil(x) 0 (full range)
floor(x) 0 (full range)
lrint(x) 0 (full range)
lround(x) 0 (full range)
llrint(x) 0 (full range)
llround(x) 0 (full range)

H.2. Intrinsic Functions

本节中的函数只能在设备代码中使用。

在这些函数中,有一些标准函数的精度较低但速度更快的版本。它们具有相同的名称,前缀为 __(例如 __sinf(x))。 它们更快,因为它们映射到更少的本机指令。 编译器有一个选项 (-use_fast_math),它强制下表 中的每个函数编译为其内在对应项。 除了降低受影响函数的准确性外,还可能导致特殊情况处理的一些差异。 一种更健壮的方法是通过调用内联函数来选择性地替换数学函数调用,仅在性能增益值得考虑的情况下以及可以容忍更改的属性(例如降低的准确性和不同的特殊情况处理)的情况下。

Table 9. Functions Affected by -use_fast_math
Operator/Function Device Function
x/y

__fdividef(x,y)

sinf(x)

__sinf(x)

cosf(x)

__cosf(x)

tanf(x) __tanf(x)
sincosf(x,sptr,cptr) __sincosf(x,sptr,cptr)
logf(x)

__logf(x)

log2f(x) __log2f(x)
log10f(x) __log10f(x)
expf(x) __expf(x)
exp10f(x) __exp10f(x)
powf(x,y) __powf(x,y)

Single-Precision Floating-Point Functions

__fadd_[rn,rz,ru,rd]()__fmul_[rn,rz,ru,rd]() 映射到编译器从不合并到 FMAD 中的加法和乘法运算。相比之下,由“*”和“+”运算符生成的加法和乘法将经常组合到 FMAD 中。

_rn 为后缀的函数使用舍入到最接近的偶数舍入模式运行。

_rz 为后缀的函数使用向零舍入模式进行舍入操作。

_ru 为后缀的函数使用向上舍入(到正无穷大)舍入模式运行。

_rd 为后缀的函数使用向下舍入(到负无穷大)舍入模式进行操作。

浮点除法的准确性取决于代码是使用 -prec-div=false 还是 -prec-div=true 编译的。使用-prec-div=false编译代码时,正则除法/运算符和__fdividef(x,y)精度相同,但对于2126 < |y| <2128__fdividef(x,y) 提供的结果为零,而 / 运算符提供的正确结果在下表 中规定的精度范围内。此外,对于 2126 < |y| <2128,如果 x 为无穷大,则 __fdividef(x,y) 提供 NaN(作为无穷大乘以零的结果),而 / 运算符返回无穷大。另一方面,当使用 -prec-div=true 或根本没有任何 -prec-div 选项编译代码时, / 运算符符合 IEEE 标准,因为它的默认值为 true。

Function Error bounds
__fadd_[rn,rz,ru,rd](x,y)

IEEE-compliant.

__fsub_[rn,rz,ru,rd](x,y)

IEEE-compliant.

__fmul_[rn,rz,ru,rd](x,y)

IEEE-compliant.

__fmaf_[rn,rz,ru,rd](x,y,z)

IEEE-compliant.

__frcp_[rn,rz,ru,rd](x) IEEE-compliant.
__fsqrt_[rn,rz,ru,rd](x) IEEE-compliant.
__frsqrt_rn(x) IEEE-compliant.
__fdiv_[rn,rz,ru,rd](x,y)

IEEE-compliant.

__fdividef(x,y) For |y| in [2-126, 2126], the maximum ulp error is 2.
__expf(x) The maximum ulp error is 2 + floor(abs(1.16 * x)).
__exp10f(x) The maximum ulp error is 2+ floor(abs(2.95 * x)).
__logf(x) For x in [0.5, 2], the maximum absolute error is 2-21.41, otherwise, the maximum ulp error is 3.
__log2f(x) For x in [0.5, 2], the maximum absolute error is 2-22, otherwise, the maximum ulp error is 2.
__log10f(x) For x in [0.5, 2], the maximum absolute error is 2-24, otherwise, the maximum ulp error is 3.
__sinf(x) For x in [-π,π], the maximum absolute error is 2-21.41, and larger otherwise.
__cosf(x) For x in [-π,π], the maximum absolute error is 2-21.19, and larger otherwise.
__sincosf(x,sptr,cptr) Same as __sinf(x) and __cosf(x).
__tanf(x) Derived from its implementation as __sinf(x) * (1/__cosf(x)).
__powf(x, y) Derived from its implementation as exp2f(y * __log2f(x)).

Double-Precision Floating-Point Functions

__dadd_rn()__dmul_rn() 映射到编译器从不合并到 FMAD 中的加法和乘法运算。 相比之下,由“*”和“+”运算符生成的加法和乘法将经常组合到 FMAD 中。

Table 11. Double-Precision Floating-Point Intrinsic Functions. (Supported by the CUDA Runtime Library with Respective Error Bounds)
Function Error bounds
__dadd_[rn,rz,ru,rd](x,y)

IEEE-compliant.

__dsub_[rn,rz,ru,rd](x,y)

IEEE-compliant.

__dmul_[rn,rz,ru,rd](x,y)

IEEE-compliant.

__fma_[rn,rz,ru,rd](x,y,z)

IEEE-compliant.

__ddiv_[rn,rz,ru,rd](x,y)(x,y)

IEEE-compliant.

Requires compute capability > 2.

__drcp_[rn,rz,ru,rd](x)

IEEE-compliant.

Requires compute capability > 2.

__dsqrt_[rn,rz,ru,rd](x)

IEEE-compliant.

Requires compute capability > 2.

附录I C++ 语言支持

如使用 NVCC 编译中所述,使用 nvcc 编译的 CUDA 源文件可以包含主机代码和设备代码的混合。 CUDA 前端编译器旨在模拟主机编译器对 C++ 输入代码的行为。 输入源代码根据 C++ ISO/IEC 14882:2003、C++ ISO/IEC 14882:2011、C++ ISO/IEC 14882:2014 或 C++ ISO/IEC 14882:2017 规范进行处理,CUDA 前端编译器旨在模拟 任何主机编译器与 ISO 规范的差异。 此外,支持的语言使用本文档 中描述的特定于 CUDA 的结构进行了扩展,并受到下面描述的限制。

C++11 语言特性C++14 语言特性和 C++17 语言特性分别为 C++11、C++14 和 C++17 特性提供支持矩阵。 限制列出了语言限制。 多态函数包装器扩展 Lambda 描述了其他特性。 代码示例提供代码示例。

I.1. C++11 Language Features

下表列出了已被 C++11 标准接受的新语言功能。 “Proposal”列提供了描述该功能的 ISO C++ 委员会提案的链接,而“Available in nvcc (device code)”列表示包含此功能实现的第一个 nvcc 版本(如果已实现) ) 用于设备代码。

Table 12. C++11 Language Features
Language Feature C++11 Proposal Available in nvcc (device code)
Rvalue references N2118 7.0
    Rvalue references for *this N2439 7.0
Initialization of class objects by rvalues N1610 7.0
Non-static data member initializers N2756 7.0
Variadic templates N2242 7.0
    Extending variadic template template parameters N2555 7.0
Initializer lists N2672 7.0
Static assertions N1720 7.0
auto-typed variables N1984 7.0
    Multi-declarator auto N1737 7.0
    Removal of auto as a storage-class specifier N2546 7.0
    New function declarator syntax N2541 7.0
Lambda expressions N2927 7.0
Declared type of an expression N2343 7.0
    Incomplete return types N3276 7.0
Right angle brackets N1757 7.0
Default template arguments for function templates DR226 7.0
Solving the SFINAE problem for expressions DR339 7.0
Alias templates N2258 7.0
Extern templates N1987 7.0
Null pointer constant N2431 7.0
Strongly-typed enums N2347 7.0
Forward declarations for enums N2764

DR1206
7.0
Standardized attribute syntax N2761 7.0
Generalized constant expressions N2235 7.0
Alignment support N2341 7.0
Conditionally-support behavior N1627 7.0
Changing undefined behavior into diagnosable errors N1727 7.0
Delegating constructors N1986 7.0
Inheriting constructors N2540 7.0
Explicit conversion operators N2437 7.0
New character types N2249 7.0
Unicode string literals N2442 7.0
Raw string literals N2442 7.0
Universal character names in literals N2170 7.0
User-defined literals N2765 7.0
Standard Layout Types N2342 7.0
Defaulted functions N2346 7.0
Deleted functions N2346 7.0
Extended friend declarations N1791 7.0
Extending sizeof N2253

DR850
7.0
Inline namespaces N2535 7.0
Unrestricted unions N2544 7.0
Local and unnamed types as template arguments N2657 7.0
Range-based for N2930 7.0
Explicit virtual overrides N2928

N3206

N3272
7.0
Minimal support for garbage collection and reachability-based leak detection N2670 N/A (see Restrictions)
Allowing move constructors to throw [noexcept] N3050 7.0
Defining move special member functions N3053 7.0
Concurrency
Sequence points N2239  
Atomic operations N2427  
Strong Compare and Exchange N2748  
Bidirectional Fences N2752  
Memory model N2429  
Data-dependency ordering: atomics and memory model N2664  
Propagating exceptions N2179  
Allow atomics use in signal handlers N2547  
Thread-local storage N2659  
Dynamic initialization and destruction with concurrency N2660  
C99 Features in C++11
__func__ predefined identifier N2340 7.0
C99 preprocessor N1653 7.0
long long N1811 7.0
Extended integral types N1988  

I.2. C++14 Language Features

下表列出了已被 C++14 标准接受的新语言功能。

Table 13. C++14 Language Features
Language Feature C++14 Proposal Available in nvcc (device code)
Tweak to certain C++ contextual conversions N3323 9.0
Binary literals N3472 9.0
Functions with deduced return type N3638 9.0
Generalized lambda capture (init-capture) N3648 9.0
Generic (polymorphic) lambda expressions N3649 9.0
Variable templates N3651 9.0
Relaxing requirements on constexpr functions N3652 9.0
Member initializers and aggregates N3653 9.0
Clarifying memory allocation N3664  
Sized deallocation N3778  
[[deprecated]] attribute N3760 9.0
Single-quotation-mark as a digit separator N3781 9.0

I.3. C++17 Language Features

nvcc 版本 11.0 及更高版本支持所有 C++17 语言功能,但受此处描述的限制的约束。

I.4. Restrictions

I.4.1. Host Compiler Extensions

设备代码不支持主机编译器特定的语言扩展。

_Complex类型仅在主机代码中受支持。

当与支持它的主机编译器一起编译时,设备代码中支持 __int128 类型。

__float128 类型仅在 64 位 x86 Linux 平台上的主机代码中受支持。 __float128 类型的常量表达式可以由编译器以较低精度的浮点表示形式处理。

I.4.2. Preprocessor Symbols

I.4.2.1. CUDA_ARCH

  1. 以下实体的类型签名不应取决于是否定义了 __CUDA_ARCH__,或者取决于 __CUDA_ARCH__ 的特定值:

    • __global__ 函数和函数模板
    • __device____constant__ 变量
    • 纹理和表面

    例子:

1
2
3
4
5
6
7
8
9
10
11
12
#if !defined(__CUDA_ARCH__)
typedef int mytype;
#else
typedef double mytype;
#endif

__device__ mytype xxx; // error: xxx's type depends on __CUDA_ARCH__
__global__ void foo(mytype in, // error: foo's type depends on __CUDA_ARCH__
mytype *ptr)
{
*ptr = in;
}
  1. 如果 __global__ 函数模板被实例化并从主机启动,则无论是否定义了 __CUDA_ARCH__ 以及无论 __CUDA_ARCH__ 的值如何,都必须使用相同的模板参数实例化该函数模板。

    例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
__device__ int result;
template <typename T>
__global__ void kern(T in)
{
result = in;
}

__host__ __device__ void foo(void)
{
#if !defined(__CUDA_ARCH__)
kern<<<1,1>>>(1); // error: "kern<int>" instantiation only
// when __CUDA_ARCH__ is undefined!
#endif
}

int main(void)
{
foo();
cudaDeviceSynchronize();
return 0;
}

  1. 在单独编译模式下,是否存在具有外部链接的函数或变量的定义不应取决于是否定义了 __CUDA_ARCH____CUDA_ARCH__16 的特定值。

    例子:

1
2
3
4
5
#if !defined(__CUDA_ARCH__)
void foo(void) { } // error: The definition of foo()
// is only present when __CUDA_ARCH__
// is undefined
#endif
  1. 在单独的编译中, __CUDA_ARCH__ 不得在头文件中使用,这样不同的对象可能包含不同的行为。 或者,必须保证所有对象都将针对相同的 compute_arch 进行编译。 如果在头文件中定义了弱函数或模板函数,并且其行为取决于 __CUDA_ARCH__,那么如果为不同的计算架构编译对象,则对象中该函数的实例可能会发生冲突。

    例如,如果 a.h 包含:

1
2
3
4
5
6
7
8
9
10
template<typename T>
__device__ T* getptr(void)
{
#if __CUDA_ARCH__ == 200
return NULL; /* no address */
#else
__shared__ T arr[256];
return arr;
#endif
}

然后,如果 a.cu 和 b.cu 都包含 a.h 并为同一类型实例化 getptr,并且 b.cu 需要一个非 NULL 地址,则编译:

1
2
3
nvcc –arch=compute_20 –dc a.cu
nvcc –arch=compute_30 –dc b.cu
nvcc –arch=sm_30 a.o b.o

在链接时只使用一个版本的 getptr,因此行为将取决于选择哪个版本。 为避免这种情况,必须为相同的计算架构编译 a.cu 和 b.cu,或者 __CUDA_ARCH__ 不应在共享头函数中使用。

编译器不保证将为上述不受支持的 __CUDA_ARCH__ 使用生成诊断。

I.4.3. Qualifiers

I.4.3.1. Device Memory Space Specifiers

__device____shared____managed____constant__ 内存空间说明符不允许用于:

  • 类、结构和联合数据成员,
  • 形式参数,
  • 在主机上执行的函数中的非外部变量声明。

__device____constant____managed__ 内存空间说明符不允许用于在设备上执行的函数中既不是外部也不是静态的变量声明。

__device____constant____managed____shared__ 变量定义不能具有包含非空构造函数或非空析构函数的类的类型。一个类类型的构造函数在翻译单元中的某个点被认为是空的,如果它是一个普通的构造函数或者它满足以下所有条件:

  • 构造函数已定义。
  • 构造函数没有参数,初始化列表是空的,函数体是一个空的复合语句。
  • 它的类没有虚函数,没有虚基类,也没有非静态数据成员初始化器。
  • 其类的所有基类的默认构造函数都可以认为是空的。
  • 对于其类的所有属于类类型(或其数组)的非静态数据成员,默认构造函数可以被认为是空的。

一个类的析构函数在翻译单元中的某个点被认为是空的,如果它是一个普通的析构函数或者它满足以下所有条件:

  • 已定义析构函数。
  • 析构函数体是一个空的复合语句。
  • 它的类没有虚函数,也没有虚基类。
  • 其类的所有基类的析构函数都可以认为是空的。
  • 对于其类的所有属于类类型(或其数组)的非静态数据成员,析构函数可以被认为是空的。

在整个程序编译模式下编译时(有关此模式的说明,请参见 nvcc 用户手册),__device____shared____managed____constant__ 变量不能使用 extern 关键字定义为外部变量。 唯一的例外是动态分配的 __shared__ 变量,如 __shared__ 中所述。

在单独编译模式下编译时(有关此模式的说明,请参阅 nvcc 用户手册),可以使用 extern 关键字将 __device____shared____managed____constant__ 变量定义为外部变量。 当 nvlink 找不到外部变量的定义时(除非它是动态分配的 __shared__ 变量),它会产生错误。

I.4.3.2. __managed__ Memory Space Specifier

__managed__ 内存空间说明符标记的变量(“managed–托管”变量)具有以下限制:

  • 托管变量的地址不是常量表达式。
  • 托管变量不应具有 const 限定类型。
  • 托管变量不应具有引用类型。
  • 当 CUDA 运行时可能不处于有效状态时,不应使用托管变量的地址或值,包括以下情况:
    • 在具有静态或线程本地存储持续时间的对象的静态/动态初始化或销毁中。
    • 在调用 exit() 之后执行的代码中(例如,一个标有 gcc 的“__attribute__((destructor))”的函数)。
    • 在 CUDA 运行时可能未初始化时执行的代码中(例如,标有 gcc 的“__attribute__((constructor))”的函数)。
  • 托管变量不能用作 decltype() 表达式的未加括号的 id 表达式参数。
  • 托管变量具有与为动态分配的托管内存指定的相同的连贯性和一致性行为。
  • 当包含托管变量的 CUDA 程序在具有多个 GPU 的执行平台上运行时,变量仅分配一次,而不是每个 GPU。
  • 在主机上执行的函数中不允许使用没有外部链接的托管变量声明。
  • 在设备上执行的函数中不允许使用没有外部或静态链接的托管变量声明。

以下是托管变量的合法和非法使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
__device__ __managed__ int xxx = 10;         // OK

int *ptr = &xxx; // error: use of managed variable
// (xxx) in static initialization
struct S1_t {
int field;
S1_t(void) : field(xxx) { };
};
struct S2_t {
~S2_t(void) { xxx = 10; }
};

S1_t temp1; // error: use of managed variable
// (xxx) in dynamic initialization

S2_t temp2; // error: use of managed variable
// (xxx) in the destructor of
// object with static storage
// duration

__device__ __managed__ const int yyy = 10; // error: const qualified type

__device__ __managed__ int &zzz = xxx; // error: reference type

template <int *addr> struct S3_t { };
S3_t<&xxx> temp; // error: address of managed
// variable(xxx) not a
// constant expression

__global__ void kern(int *ptr)
{
assert(ptr == &xxx); // OK
xxx = 20; // OK
}
int main(void)
{
int *ptr = &xxx; // OK
kern<<<1,1>>>(ptr);
cudaDeviceSynchronize();
xxx++; // OK
decltype(xxx) qqq; // error: managed variable(xxx) used
// as unparenthized argument to
// decltype

decltype((xxx)) zzz = yyy; // OK
}

I.4.3.3. Volatile Qualifier

编译器可以自由优化对全局或共享内存的读取和写入(例如,通过将全局读取缓存到寄存器或 L1 缓存中),只要它尊重内存围栏函数(Memory Fence Functions)的内存排序语义和内存可见性语义 同步函数(Synchronization Functions)。

可以使用 volatile 关键字禁用这些优化:如果将位于全局或共享内存中的变量声明为 volatile,编译器假定它的值可以随时被另一个线程更改或使用,因此对该变量的任何引用都会编译为 实际的内存读取或写入指令。

I.4.4. Pointers

取消引用指向在主机上执行的代码中的全局或共享内存的指针,或在设备上执行的代码中指向主机内存的指针会导致未定义的行为,最常见的是segmentation fault和应用程序终止。

获取 __device____shared____constant__ 变量的地址获得的地址只能在设备代码中使用。 设备内存中描述的通过 cudaGetSymbolAddress() 获得的 __device____constant__ 变量的地址只能在主机代码中使用。

I.4.5. Operators

I.4.5.1. Assignment Operator

__constant__ 变量只能通过运行时函数(设备内存)从主机代码分配; 它们不能从设备代码中分配。

__shared__ 变量不能将初始化作为其声明的一部分。

不允许为内置变量中定义的任何内置变量赋值。

I.4.5.2. Address Operator

不允许使用内置变量中定义的任何内置变量的地址。

I.4.6. Run Time Type Information (RTTI)

主机代码支持以下与 RTTI 相关的功能,但设备代码不支持。

  • typeid operator
  • std::type_info
  • dynamic_cast operator

I.4.7. Exception Handling

异常处理仅在主机代码中受支持,但在设备代码中不支持。

__global__ 函数不支持异常规范。

I.4.8. Standard Library

除非另有说明,标准库仅在主机代码中受支持,而不在设备代码中受支持。

I.4.9. Functions

I.4.9.1. External Linkage

仅当函数在与设备代码相同的编译单元中定义时,才允许在某些设备代码中调用使用 extern 限定符声明的函数,即单个文件或通过可重定位设备代码和 nvlink 链接在一起的多个文件。

I.4.9.2. Implicitly-declared and explicitly-defaulted functions

令 F 表示一个在其第一个声明中隐式声明或显式默认的函数 或 F 的执行空间说明符 (__host__, __device__) 是调用它的所有函数的执行空间说明符的并集(请注意, __global__ 调用者将被视为 __device__ 调用者进行此分析)。 例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Base {
int x;
public:
__host__ __device__ Base(void) : x(10) {}
};

class Derived : public Base {
int y;
};

class Other: public Base {
int z;
};

__device__ void foo(void)
{
Derived D1;
Other D2;
}

__host__ void bar(void)
{
Other D3;
}

这里,隐式声明的构造函数“Derived::Derived”将被视为 __device__ 函数,因为它仅从 __device__ 函数“foo”调用。 隐式声明的构造函数 “Other::Other“ 将被视为 __host__ __device__ 函数,因为它是从 __device__ 函数 “foo” 和 __host__ 函数 “bar” 调用的。
此外,如果 F 是一个虚拟析构函数,则被 F 覆盖的每个虚拟析构函数 D 的执行空间都被添加到 F 的执行空间集合中,如果 D 不是隐式定义的,或者是显式默认的声明而不是它的声明 第一次声明。

例如:

1
2
3
4
5
6
7
8
9
10
struct Base1 { virtual __host__ __device__ ~Base1() { } };
struct Derived1 : Base1 { }; // implicitly-declared virtual destructor
// ~Derived1 has __host__ __device__
// execution space specifiers

struct Base2 { virtual __device__ ~Base2(); };
__device__ Base2::~Base2() = default;
struct Derived2 : Base2 { }; // implicitly-declared virtual destructor
// ~Derived2 has __device__ execution
// space specifiers

I.4.9.3. Function Parameters

__global__ 函数参数通过常量内存传递给设备,并且限制为 4 KB。

__global__ 函数不能有可变数量的参数。

__global__ 函数参数不能通过引用传递。

在单独编译模式下,如果 __device____global__ 函数在特定翻译单元中被 ODR 使用,则该函数的参数和返回类型在该翻译单元中必须是完整的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//first.cu:
struct S;
__device__ void foo(S); // error: type 'S' is incomplete
__device__ auto *ptr = foo;

int main() { }

//second.cu:
struct S { int x; };
__device__ void foo(S) { }

//compiler invocation
$nvcc -std=c++14 -rdc=true first.cu second.cu -o first
nvlink error : Prototype doesn't match for '_Z3foo1S' in '/tmp/tmpxft_00005c8c_00000000-18_second.o', first defined in '/tmp/tmpxft_00005c8c_00000000-18_second.o'
nvlink fatal : merge_elf failed
I.4.9.3.1. global Function Argument Processing

当从设备代码启动 __global__ 函数时,每个参数都必须是可简单复制和可简单销毁的。

当从主机代码启动 __global__ 函数时,每个参数类型都可以是不可复制或不可销毁的,但对此类类型的处理不遵循标准 C++ 模型,如下所述。 用户代码必须确保此工作流程不会影响程序的正确性。 工作流在两个方面与标准 C++ 不同:

  1. Memcpy instead of copy constructor invocation;  
     从主机代码降低 `__global__` 函数启动时,编译器会生成存根函数,这些函数按值复制参数一次或多次,然后最终使用 `memcpy` 将参数复制到设备上的 `__global__` 函数的参数内存中。 即使参数是不可复制的,也会发生这种情况,因此可能会破坏复制构造函数具有副作用的程序。  
        例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <cassert>
struct S {
int x;
int *ptr;
__host__ __device__ S() { }
__host__ __device__ S(const S &) { ptr = &x; }
};

__global__ void foo(S in) {
// this assert may fail, because the compiler
// generated code will memcpy the contents of "in"
// from host to kernel parameter memory, so the
// "in.ptr" is not initialized to "&in.x" because
// the copy constructor is skipped.
assert(in.ptr == &in.x);
}

int main() {
S tmp;
foo<<<1,1>>>(tmp);
cudaDeviceSynchronize();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <cassert>

__managed__ int counter;
struct S1 {
S1() { }
S1(const S1 &) { ++counter; }
};

__global__ void foo(S1) {

/* this assertion may fail, because
the compiler generates stub
functions on the host for a kernel
launch, and they may copy the
argument by value more than once.
*/
assert(counter == 1);
}

int main() {
S1 V;
foo<<<1,1>>>(V);
cudaDeviceSynchronize();
}
  2. Destructor may be invoked before the __global__ function has finished;          
     内核启动与主机执行是异步的。 因此,如果 `__global__` 函数参数具有非平凡的析构函数,则析构函数甚至可以在 `__global__` 函数完成执行之前在宿主代码中执行。 这可能会破坏析构函数具有副作用的程序。
        示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
struct S {
int *ptr;
S() : ptr(nullptr) { }
S(const S &) { cudaMallocManaged(&ptr, sizeof(int)); }
~S() { cudaFree(ptr); }
};

__global__ void foo(S in) {

//error: This store may write to memory that has already been
// freed (see below).
*(in.ptr) = 4;

}

int main() {
S V;

/* The object 'V' is first copied by value to a compiler-generated
* stub function that does the kernel launch, and the stub function
* bitwise copies the contents of the argument to kernel parameter
* memory.
* However, GPU kernel execution is asynchronous with host
* execution.
* As a result, S::~S() will execute when the stub function returns, releasing allocated memory, even though the kernel may not have finished execution.
*/
foo<<<1,1>>>(V);
cudaDeviceSynchronize();
}

I.4.9.4. Static Variables within Function

在函数 F 的直接或嵌套块范围内,静态变量 V 的声明中允许使用可变内存空间说明符,其中:

  • F 是一个 __global____device__-only 函数。
  • F 是一个 __host__ __device__ 函数,__CUDA_ARCH__ 定义为 17。

如果 V 的声明中没有显式的内存空间说明符,则在设备编译期间假定隐式 __device__ 说明符。

V 具有与在命名空间范围内声明的具有相同内存空间说明符的变量相同的初始化限制,例如 __device__ 变量不能有“非空”构造函数(请参阅设备内存空间说明符)。

函数范围静态变量的合法和非法使用示例如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
struct S1_t {
int x;
};

struct S2_t {
int x;
__device__ S2_t(void) { x = 10; }
};

struct S3_t {
int x;
__device__ S3_t(int p) : x(p) { }
};

__device__ void f1() {
static int i1; // OK, implicit __device__ memory space specifier
static int i2 = 11; // OK, implicit __device__ memory space specifier
static __managed__ int m1; // OK
static __device__ int d1; // OK
static __constant__ int c1; // OK

static S1_t i3; // OK, implicit __device__ memory space specifier
static S1_t i4 = {22}; // OK, implicit __device__ memory space specifier

static __shared__ int i5; // OK

int x = 33;
static int i6 = x; // error: dynamic initialization is not allowed
static S1_t i7 = {x}; // error: dynamic initialization is not allowed

static S2_t i8; // error: dynamic initialization is not allowed
static S3_t i9(44); // error: dynamic initialization is not allowed
}

__host__ __device__ void f2() {
static int i1; // OK, implicit __device__ memory space specifier
// during device compilation.
#ifdef __CUDA_ARCH__
static __device__ int d1; // OK, declaration is only visible during device
// compilation (__CUDA_ARCH__ is defined)
#else
static int d0; // OK, declaration is only visible during host
// compilation (__CUDA_ARCH__ is not defined)
#endif

static __device__ int d2; // error: __device__ variable inside
// a host function during host compilation
// i.e. when __CUDA_ARCH__ is not defined

static __shared__ int i2; // error: __shared__ variable inside
// a host function during host compilation
// i.e. when __CUDA_ARCH__ is not defined
}

I.4.9.5. Function Pointers

在主机代码中获取的 __global__ 函数的地址不能在设备代码中使用(例如,启动内核)。 同样,在设备代码中获取的 __global__ 函数的地址不能在主机代码中使用。

不允许在主机代码中获取 __device__ 函数的地址。

I.4.9.6. Function Recursion

__global__ 函数不支持递归。

I.4.9.7. Friend Functions

__global__ 函数或函数模板不能在友元声明中定义。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct S1_t {
friend __global__
void foo1(void); // OK: not a definition
template<typename T>
friend __global__
void foo2(void); // OK: not a definition

friend __global__
void foo3(void) { } // error: definition in friend declaration

template<typename T>
friend __global__
void foo4(void) { } // error: definition in friend declaration
};

I.4.9.8. Operator Function

运算符函数不能是 __global__ 函数。

I.4.10. Classes

I.4.10.1. 数据成员
不支持静态数据成员,除了那些也是 const 限定的(请参阅 Const 限定变量)。

I.4.10.2. 函数成员

静态成员函数不能是 __global__ 函数。

I.4.10.3. 虚函数

当派生类中的函数覆盖基类中的虚函数时,被覆盖函数和覆盖函数上的执行空间说明符(即 __host__、__device__)必须匹配。

不允许将具有虚函数的类的对象作为参数传递给 __global__ 函数。

如果在主机代码中创建对象,则在设备代码中为该对象调用虚函数具有未定义的行为。

如果在设备代码中创建了一个对象,则在主机代码中为该对象调用虚函数具有未定义的行为。

使用 Microsoft 主机编译器时,请参阅特定于 Windows 的其他限制。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct S1 { virtual __host__ __device__ void foo() { } };

__managed__ S1 *ptr1, *ptr2;

__managed__ __align__(16) char buf1[128];
__global__ void kern() {
ptr1->foo(); // error: virtual function call on a object
// created in host code.
ptr2 = new(buf1) S1();
}

int main(void) {
void *buf;
cudaMallocManaged(&buf, sizeof(S1), cudaMemAttachGlobal);
ptr1 = new (buf) S1();
kern<<<1,1>>>();
cudaDeviceSynchronize();
ptr2->foo(); // error: virtual function call on an object
// created in device code.
}

I.4.10.4. Virtual Base Classes

不允许将派生自虚拟基类的类的对象作为参数传递给 __global__ 函数。

使用 Microsoft 主机编译器时,请参阅特定于 Windows 的其他限制。

I.4.10.5. Anonymous Unions

命名空间范围匿名联合的成员变量不能在 __global____device__ 函数中引用。

I.4.10.6. 特定于 Windows 的

CUDA 编译器遵循 IA64 ABI 进行类布局,而 Microsoft 主机编译器则不遵循。 令 T 表示指向成员类型的指针,或满足以下任一条件的类类型:

  • T has virtual functions.
  • T has a virtual base class.
  • T has multiple inheritance with more than one direct or indirect empty base class.
  • All direct and indirect base classes B of T are empty and the type of the first field F of T uses B in its definition, such that B is laid out at offset 0 in the definition of F.

C 表示 T 或以 T 作为字段类型或基类类型的类类型。 CUDA 编译器计算类布局和大小的方式可能不同于 C 类型的 Microsoft 主机编译器。
只要类型 C 专门用于主机或设备代码,程序就应该可以正常工作。

在主机和设备代码之间传递 C 类型的对象具有未定义的行为,例如,作为 __global__ 函数的参数或通过 cudaMemcpy*() 调用。

如果在主机代码中创建对象,则访问 C 类型的对象或设备代码中的任何子对象,或调用设备代码中的成员函数具有未定义的行为。

如果对象是在设备代码中创建的,则访问 C 类型的对象或主机代码中的任何子对象,或调用主机代码中的成员函数具有未定义的行为。

I.4.11. Templates

类型或模板不能在 __global__ 函数模板实例化或 __device__/__constant__ 变量实例化的类型、非类型或模板模板参数中使用,如果:

  • 类型或模板在 __host____host__ __device__ 中定义。
  • 类型或模板是具有私有或受保护访问的类成员,其父类未在 __device____global__ 函数中定义。
  • 该类型未命名。
    *该类型由上述任何类型复合而成。
    例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
template <typename T>
__global__ void myKernel(void) { }

class myClass {
private:
struct inner_t { };
public:
static void launch(void)
{
// error: inner_t is used in template argument
// but it is private
myKernel<inner_t><<<1,1>>>();
}
};

// C++14 only
template <typename T> __device__ T d1;

template <typename T1, typename T2> __device__ T1 d2;

void fn() {
struct S1_t { };
// error (C++14 only): S1_t is local to the function fn
d1<S1_t> = {};

auto lam1 = [] { };
// error (C++14 only): a closure type cannot be used for
// instantiating a variable template
d2<int, decltype(lam1)> = 10;
}

I.4.12. Trigraphs and Digraphs

任何平台都不支持三元组。 Windows 不支持有向图。

I.4.13. Const-qualified variables

让“V”表示名称空间范围变量或具有 const 限定类型且没有执行空间注释的类静态成员变量(例如,__device____constant____shared__)。 V 被认为是主机代码变量。

V 的值可以直接在设备代码中使用,如果

  • V 在使用点之前已经用常量表达式初始化,
  • V 的类型不是 volatile 限定的,并且
  • 它具有以下类型之一:
    • 内置浮点类型,除非将 Microsoft 编译器用作主机编译器,
    • 内置整型。

设备源代码不能包含对 V 的引用或获取 V 的地址。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const int xxx = 10;
struct S1_t { static const int yyy = 20; };

extern const int zzz;
const float www = 5.0;
__device__ void foo(void) {
int local1[xxx]; // OK
int local2[S1_t::yyy]; // OK

int val1 = xxx; // OK

int val2 = S1_t::yyy; // OK

int val3 = zzz; // error: zzz not initialized with constant
// expression at the point of use.

const int &val3 = xxx; // error: reference to host variable
const int *val4 = &xxx; // error: address of host variable
const float val5 = www; // OK except when the Microsoft compiler is used as
// the host compiler.
}
const int zzz = 20;

I.4.14. Long Double

设备代码不支持使用 long double 类型。

I.4.15. Deprecation Annotation

nvcc 支持在使用 gcc、clang、xlC、iccpgcc 主机编译器时使用 deprecated 属性,以及在使用 cl.exe 主机编译器时使用 deprecated declspec。当启用 C++14 时,它还支持 [[deprecated]] 标准属性。当定义 __CUDA_ARCH__ 时(即在设备编译阶段),CUDA 前端编译器将为从 __device____global____host__ __device__ 函数的主体内对已弃用实体的引用生成弃用诊断。对不推荐使用的实体的其他引用将由主机编译器处理,例如,来自 __host__ 函数中的引用。

CUDA 前端编译器不支持各种主机编译器支持的#pragma gcc 诊断或#pragma 警告机制。因此,CUDA 前端编译器生成的弃用诊断不受这些 pragma 的影响,但主机编译器生成的诊断会受到影响。要抑制设备代码的警告,用户可以使用 NVIDIA 特定的 pragma #pragma nv_diag_suppress。 nvcc 标志 -Wno-deprecated-declarations 可用于禁止所有弃用警告,标志 -Werror=deprecated-declarations 可用于将弃用警告转换为错误。

I.4.16. Noreturn Annotation

nvcc 支持在使用 gcc、clang、xlC、icc 或 pgcc 主机编译器时使用 noreturn 属性,并在使用 cl.exe 主机编译器时使用 noreturn declspec。 当启用 C++11 时,它还支持 [[noreturn]] 标准属性。

属性/declspec 可以在主机和设备代码中使用。

I.4.17. [[likely]] / [[unlikely]] Standard Attributes

所有支持 C++ 标准属性语法的配置都接受这些属性。 这些属性可用于向设备编译器优化器提示与不包含该语句的任何替代路径相比,该语句是否更有可能被执行。

例子:

1
2
3
4
5
6
7
8
9
10
11
__device__ int foo(int x) {

if (i < 10) [[likely]] { // the 'if' block will likely be entered
return 4;
}
if (i < 20) [[unlikely]] { // the 'if' block will not likely be entered
return 1;
}
return 0;
}

如果在 __CUDA_ARCH__ 未定义时在主机代码中使用这些属性,则它们将出现在主机编译器解析的代码中,如果不支持这些属性,则可能会生成警告。 例如,clang11 主机编译器将生成“unknown attribute”警告。

I.4.18. const and pure GNU Attributes

当使用也支持这些属性的语言和主机编译器时,主机和设备功能都支持这些属性,例如 使用 g++ 主机编译器。

对于使用 pure 属性注释的设备函数,设备代码优化器假定该函数不会更改调用者函数(例如内存)可见的任何可变状态。

对于使用 const 属性注释的设备函数,设备代码优化器假定该函数不会访问或更改调用者函数可见的任何可变状态(例如内存)。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
__attribute__((const)) __device__ int get(int in);

__device__ int doit(int in) {
int sum = 0;

//because 'get' is marked with 'const' attribute
//device code optimizer can recognize that the
//second call to get() can be commoned out.
sum = get(in);
sum += get(in);

return sum;
}

I.4.19. Intel Host Compiler Specific

CUDA 前端编译器解析器无法识别英特尔编译器(例如 icc)支持的某些内在函数。 因此,当使用 Intel 编译器作为主机编译器时,nvcc 将在预处理期间启用宏 __INTEL_COMPILER_USE_INTRINSIC_PROTOTYPES。 此宏允许在相关头文件中显式声明英特尔编译器内部函数,从而允许 nvcc 支持在主机代码中使用此类函数

I.4.20. C++11 Features

nvcc 也支持主机编译器默认启用的 C++11 功能,但须遵守本文档中描述的限制。 此外,使用 -std=c++11 标志调用 nvcc 会打开所有 C++11 功能,还会使用相应的 C++11 选项调用主机预处理器、编译器和链接器。

I.4.20.1. Lambda Expressions

与 lambda 表达式关联的闭包类的所有成员函数的执行空间说明符由编译器派生如下。 如 C++11 标准中所述,编译器在包含 lambda 表达式的最小块范围、类范围或命名空间范围内创建闭包类型。 计算封闭闭包类型的最内层函数作用域,并将相应函数的执行空间说明符分配给闭包类成员函数。 如果没有封闭函数范围,则执行空间说明符为 __host__

lambda 表达式和计算的执行空间说明符的示例如下所示(在注释中)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
auto globalVar = [] { return 0; }; // __host__ 

void f1(void) {
auto l1 = [] { return 1; }; // __host__
}

__device__ void f2(void) {
auto l2 = [] { return 2; }; // __device__
}

__host__ __device__ void f3(void) {
auto l3 = [] { return 3; }; // __host__ __device__
}

__device__ void f4(int (*fp)() = [] { return 4; } /* __host__ */) {
}

__global__ void f5(void) {
auto l5 = [] { return 5; }; // __device__
}

__device__ void f6(void) {
struct S1_t {
static void helper(int (*fp)() = [] {return 6; } /* __device__ */) {
}
};
}

lambda 表达式的闭包类型不能用于 __global__ 函数模板实例化的类型或非类型参数,除非 lambda 在 __device____global__ 函数中定义。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <typename T>
__global__ void foo(T in) { };

template <typename T>
struct S1_t { };

void bar(void) {
auto temp1 = [] { };

foo<<<1,1>>>(temp1); // error: lambda closure type used in
// template type argument
foo<<<1,1>>>( S1_t<decltype(temp1)>()); // error: lambda closure type used in
// template type argument
}

I.4.20.2. std::initializer_list

默认情况下,CUDA 编译器将隐式认为 std::initializer_list 的成员函数具有 __host__ __device__ 执行空间说明符,因此可以直接从设备代码调用它们。 nvcc 标志 --no-host-device-initializer-list 将禁用此行为; std::initializer_list 的成员函数将被视为 __host__ 函数,并且不能直接从设备代码调用。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <initializer_list>

__device__ int foo(std::initializer_list<int> in);

__device__ void bar(void)
{
foo({4,5,6}); // (a) initializer list containing only
// constant expressions.

int i = 4;
foo({i,5,6}); // (b) initializer list with at least one
// non-constant element.
// This form may have better performance than (a).
}

I.4.20.3. Rvalue references

默认情况下,CUDA 编译器将隐式认为 std::movestd::forward 函数模板具有 __host__ __device__ 执行空间说明符,因此可以直接从设备代码调用它们。 nvcc 标志 --no-host-device-move-forward 将禁用此行为; std::movestd::forward 将被视为 __host__ 函数,不能直接从设备代码调用。

I.4.20.4. Constexpr functions and function templates

默认情况下,不能从执行空间不兼容的函数中调用 constexpr 函数. 实验性 nvcc 标志 --expt-relaxed-constexpr 消除了此限制. 当指定此标志时,主机代码可以调用 __device__ constexpr 函数和设备 代码可以调用 __host__ constexpr 函数。 当指定了 --expt-relaxed-constexpr 时,nvcc 将定义宏 __CUDACC_RELAXED_CONSTEXPR__。 请注意,即使相应的模板用关键字 constexpr 标记(C++11 标准节 [dcl.constexpr.p6]),函数模板实例化也可能不是 constexpr 函数。

I.4.20.5. Constexpr variables

让“V”表示命名空间范围变量或已标记为 constexpr 且没有执行空间注释的类静态成员变量(例如,__device____constant____shared__)。 V 被认为是主机代码变量。

如果 V 是除 long double 以外的标量类型 并且该类型不是 volatile 限定的,则 V 的值可以直接在设备代码中使用。 此外,如果 V 是非标量类型,则 V 的标量元素可以在 constexpr __device____host__ __device__ 函数中使用,如果对函数的调用是常量表达式. 设备源代码不能包含对 V 的引用 或取 V 的地址。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
constexpr int xxx = 10;
constexpr int yyy = xxx + 4;
struct S1_t { static constexpr int qqq = 100; };

constexpr int host_arr[] = { 1, 2, 3};
constexpr __device__ int get(int idx) { return host_arr[idx]; }

__device__ int foo(int idx) {
int v1 = xxx + yyy + S1_t::qqq; // OK
const int &v2 = xxx; // error: reference to host constexpr
// variable
const int *v3 = &xxx; // error: address of host constexpr
// variable
const int &v4 = S1_t::qqq; // error: reference to host constexpr
// variable
const int *v5 = &S1_t::qqq; // error: address of host constexpr
// variable

v1 += get(2); // OK: 'get(2)' is a constant
// expression.
v1 += get(idx); // error: 'get(idx)' is not a constant
// expression
v1 += host_arr[2]; // error: 'host_arr' does not have
// scalar type.
return v1;
}

I.4.20.6. Inline namespaces

对于输入的CUDA翻译单元,CUDA编译器可以调用主机编译器来编译翻译单元内的主机代码。 在传递给主机编译器的代码中,如果输入的 CUDA 翻译单元包含以下任何实体的定义,CUDA 编译器将注入额外的编译器生成的代码:

  • __global__ 函数或函数模板实例化
  • __device__, __constant__
  • 具有表面或纹理类型的变量

编译器生成的代码包含对已定义实体的引用。 如果实体是在内联命名空间中定义的,而另一个具有相同名称和类型签名的实体在封闭命名空间中定义,则主机编译器可能会认为此引用不明确,主机编译将失败。
可以通过对内联命名空间中定义的此类实体使用唯一名称来避免此限制。

例子:

1
2
3
4
5
6
7
8
__device__ int Gvar;
inline namespace N1 {
__device__ int Gvar;
}

// <-- CUDA compiler inserts a reference to "Gvar" at this point in the
// translation unit. This reference will be considered ambiguous by the
// host compiler and compilation will fail.
1
2
3
4
5
6
7
8
9
10
11
12
13
inline namespace N1 {
namespace N2 {
__device__ int Gvar;
}
}

namespace N2 {
__device__ int Gvar;
}

// <-- CUDA compiler inserts reference to "::N2::Gvar" at this point in
// the translation unit. This reference will be considered ambiguous by
// the host compiler and compilation will fail.
I.4.20.6.1. Inline unnamed namespaces

不能在内联未命名命名空间内的命名空间范围内声明以下实体:

  • __managed____device____shared____constant__ 变量
  • __global__ 函数和函数模板
  • 具有表面或纹理类型的变量

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
inline namespace {
namespace N2 {
template <typename T>
__global__ void foo(void); // error

__global__ void bar(void) { } // error

template <>
__global__ void foo<int>(void) { } // error

__device__ int x1b; // error
__constant__ int x2b; // error
__shared__ int x3b; // error

texture<int> q2; // error
surface<int> s2; // error
}
};

I.4.20.7. thread_local

设备代码中不允许使用 thread_local 存储说明符。

I.4.20.8. global functions and function templates

如果在 __global__ 函数模板实例化的模板参数中使用与 lambda 表达式关联的闭包类型,则 lambda 表达式必须在 __device____global__ 函数的直接或嵌套块范围内定义,或者必须是扩展 lambda。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
template <typename T>
__global__ void kernel(T in) { }

__device__ void foo_device(void)
{
// All kernel instantiations in this function
// are valid, since the lambdas are defined inside
// a __device__ function.

kernel<<<1,1>>>( [] __device__ { } );
kernel<<<1,1>>>( [] __host__ __device__ { } );
kernel<<<1,1>>>( [] { } );
}

auto lam1 = [] { };

auto lam2 = [] __host__ __device__ { };

void foo_host(void)
{
// OK: instantiated with closure type of an extended __device__ lambda
kernel<<<1,1>>>( [] __device__ { } );

// OK: instantiated with closure type of an extended __host__ __device__
// lambda
kernel<<<1,1>>>( [] __host__ __device__ { } );

// error: unsupported: instantiated with closure type of a lambda
// that is not an extended lambda
kernel<<<1,1>>>( [] { } );

// error: unsupported: instantiated with closure type of a lambda
// that is not an extended lambda
kernel<<<1,1>>>( lam1);

// error: unsupported: instantiated with closure type of a lambda
// that is not an extended lambda
kernel<<<1,1>>>( lam2);
}

__global__ 函数或函数模板不能声明为 constexpr

__global__ 函数或函数模板不能有 std::initializer_listva_list 类型的参数。

__global__ 函数不能有右值引用类型的参数。

可变参数 __global__ 函数模板具有以下限制:

  • 只允许一个包参数。
  • pack 参数必须在模板参数列表中最后列出。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
// ok
template <template <typename...> class Wrapper, typename... Pack>
__global__ void foo1(Wrapper<Pack...>);

// error: pack parameter is not last in parameter list
template <typename... Pack, template <typename...> class Wrapper>
__global__ void foo2(Wrapper<Pack...>);

// error: multiple parameter packs
template <typename... Pack1, int...Pack2, template<typename...> class Wrapper1,
template<int...> class Wrapper2>
__global__ void foo3(Wrapper1<Pack1...>, Wrapper2<Pack2...>);

I.4.20.9. managed and shared variables

__managed____shared__ 变量不能用关键字 constexpr 标记。

I.4.20.10. Defaulted functions

CUDA 编译器会忽略在第一个声明中显式默认的函数上的执行空间说明符。 相反,CUDA 编译器将推断执行空间说明符,如隐式声明和显式默认函数中所述。

如果函数是显式默认的,则不会忽略执行空间说明符,但不会在其第一次声明时忽略。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
struct S1 {
// warning: __host__ annotation is ignored on a function that
// is explicitly-defaulted on its first declaration
__host__ S1() = default;
};

__device__ void foo1() {
//note: __device__ execution space is derived for S1::S1
// based on implicit call from within __device__ function
// foo1
S1 s1;
}

struct S2 {
__host__ S2();
};

//note: S2::S2 is not defaulted on its first declaration, and
// its execution space is fixed to __host__ based on its
// first declaration.
S2::S2() = default;

__device__ void foo2() {
// error: call from __device__ function 'foo2' to
// __host__ function 'S2::S2'
S2 s2;
}

I.4.21. C++14 Features

nvcc 也支持主机编译器默认启用的 C++14 功能。 传递 nvcc -std=c++14 标志打开所有 C++14 功能,并使用相应的 C++14 选项 调用主机预处理器、编译器和链接器。本节描述了对受支持的 C++ 14 的限制特点。

I.4.21.1. Functions with deduced return type

__global__ 函数不能有推导的返回类型。

如果 __device__ 函数推导出返回类型,CUDA 前端编译器将在调用主机编译器之前将函数声明更改为具有 void 返回类型。 这可能会导致在主机代码中自省 __device__ 函数的推导返回类型时出现问题。 因此,CUDA 编译器将发出编译时错误,用于在设备函数体之外引用此类推导的返回类型,除非在 __CUDA_ARCH__ 未定义时引用不存在。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
__device__ auto fn1(int x) {
return x;
}

__device__ decltype(auto) fn2(int x) {
return x;
}

__device__ void device_fn1() {
// OK
int (*p1)(int) = fn1;
}

// error: referenced outside device function bodies
decltype(fn1(10)) g1;

void host_fn1() {
// error: referenced outside device function bodies
int (*p1)(int) = fn1;

struct S_local_t {
// error: referenced outside device function bodies
decltype(fn2(10)) m1;

S_local_t() : m1(10) { }
};
}

// error: referenced outside device function bodies
template <typename T = decltype(fn2)>
void host_fn2() { }

template<typename T> struct S1_t { };

// error: referenced outside device function bodies
struct S1_derived_t : S1_t<decltype(fn1)> { };

I.4.21.2. Variable templates

使用 Microsoft 主机编译器时,__device__/__constant__ 变量模板不能具有 const 限定类型。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// error: a __device__ variable template cannot
// have a const qualified type on Windows
template <typename T>
__device__ const T d1(2);

int *const x = nullptr;
// error: a __device__ variable template cannot
// have a const qualified type on Windows
template <typename T>
__device__ T *const d2(x);

// OK
template <typename T>
__device__ const T *d3;

__device__ void fn() {
int t1 = d1<int>;

int *const t2 = d2<int>;

const int *t3 = d3<int>;
}

I.4.22. C++17 Features

nvcc 也支持主机编译器默认启用的 C++17 功能。 传递 nvcc -std=c++17 标志会打开所有 C++17 功能,并使用相应的 C++17 选项调用主机预处理器、编译器和链接器。本节描述对支持的 C++ 17的限制特点。

I.4.22.1. Inline Variable

如果代码在整个程序编译模式下使用 nvcc 编译,则使用 __device____constant____managed__ 内存空间说明符声明的命名空间范围内联变量必须具有内部链接。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
inline __device__ int xxx; //error when compiled with nvcc in
//whole program compilation mode.
//ok when compiled with nvcc in
//separate compilation mode.

inline __shared__ int yyy0; // ok.

static inline __device__ int yyy; // ok: internal linkage
namespace {
inline __device__ int zzz; // ok: internal linkage
}

使用 g++ 主机编译器时,使用 __managed__ 内存空间说明符声明的内联变量可能对调试器不可见。

I.4.22.2. Structured Binding

不能使用可变内存空间说明符声明结构化绑定。

例子:

1
2
3
struct S { int x; int y; };
__device__ auto [a1, b1] = S{4,5}; // error

I.5. Polymorphic Function Wrappers

nvfunctional 头文件中提供了一个多态函数包装类模板 nvstd::function。 此类模板的实例可用于存储、复制和调用任何可调用目标,例如 lambda 表达式。 nvstd::function 可以在主机和设备代码中使用。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <nvfunctional>

__device__ int foo_d() { return 1; }
__host__ __device__ int foo_hd () { return 2; }
__host__ int foo_h() { return 3; }

__global__ void kernel(int *result) {
nvstd::function<int()> fn1 = foo_d;
nvstd::function<int()> fn2 = foo_hd;
nvstd::function<int()> fn3 = []() { return 10; };

*result = fn1() + fn2() + fn3();
}

__host__ __device__ void hostdevice_func(int *result) {
nvstd::function<int()> fn1 = foo_hd;
nvstd::function<int()> fn2 = []() { return 10; };

*result = fn1() + fn2();
}

__host__ void host_func(int *result) {
nvstd::function<int()> fn1 = foo_h;
nvstd::function<int()> fn2 = foo_hd;
nvstd::function<int()> fn3 = []() { return 10; };

*result = fn1() + fn2() + fn3();
}

主机代码中的 nvstd::function 实例不能用 __device__ 函数的地址或 operator()__device__ 函数的函子初始化。 设备代码中的 nvstd::function 实例不能用 __host__ 函数的地址或 operator()__host__ 函数的仿函数初始化。

nvstd::function 实例不能在运行时从主机代码传递到设备代码(反之亦然)。 如果 __global__ 函数是从主机代码启动的,则 nvstd::function 不能用于 __global__ 函数的参数类型。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <nvfunctional>

__device__ int foo_d() { return 1; }
__host__ int foo_h() { return 3; }
auto lam_h = [] { return 0; };

__global__ void k(void) {
// error: initialized with address of __host__ function
nvstd::function<int()> fn1 = foo_h;

// error: initialized with address of functor with
// __host__ operator() function
nvstd::function<int()> fn2 = lam_h;
}

__global__ void kern(nvstd::function<int()> f1) { }

void foo(void) {
// error: initialized with address of __device__ function
nvstd::function<int()> fn1 = foo_d;

auto lam_d = [=] __device__ { return 1; };

// error: initialized with address of functor with
// __device__ operator() function
nvstd::function<int()> fn2 = lam_d;

// error: passing nvstd::function from host to device
kern<<<1,1>>>(fn2);
}

nvstd::functionnvfunctional 头文件中定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
namespace nvstd {
template <class _RetType, class ..._ArgTypes>
class function<_RetType(_ArgTypes...)>
{
public:
// constructors
__device__ __host__ function() noexcept;
__device__ __host__ function(nullptr_t) noexcept;
__device__ __host__ function(const function &);
__device__ __host__ function(function &&);

template<class _F>
__device__ __host__ function(_F);

// destructor
__device__ __host__ ~function();

// assignment operators
__device__ __host__ function& operator=(const function&);
__device__ __host__ function& operator=(function&&);
__device__ __host__ function& operator=(nullptr_t);
__device__ __host__ function& operator=(_F&&);

// swap
__device__ __host__ void swap(function&) noexcept;

// function capacity
__device__ __host__ explicit operator bool() const noexcept;

// function invocation
__device__ _RetType operator()(_ArgTypes...) const;
};

// null pointer comparisons
template <class _R, class... _ArgTypes>
__device__ __host__
bool operator==(const function<_R(_ArgTypes...)>&, nullptr_t) noexcept;

template <class _R, class... _ArgTypes>
__device__ __host__
bool operator==(nullptr_t, const function<_R(_ArgTypes...)>&) noexcept;

template <class _R, class... _ArgTypes>
__device__ __host__
bool operator!=(const function<_R(_ArgTypes...)>&, nullptr_t) noexcept;

template <class _R, class... _ArgTypes>
__device__ __host__
bool operator!=(nullptr_t, const function<_R(_ArgTypes...)>&) noexcept;

// specialized algorithms
template <class _R, class... _ArgTypes>
__device__ __host__
void swap(function<_R(_ArgTypes...)>&, function<_R(_ArgTypes...)>&);
}

I.6. Extended Lambdas

nvcc 标志 ‘--extended-lambda‘ 允许在 lambda 表达式中显式执行空间注释。执行空间注释应该出现在 ‘lambda-introducer‘ 之后和可选的 ‘lambda-declarator‘ 之前。当指定了“--extended-lambda”标志时,nvcc 将定义宏 __CUDACC_EXTENDED_LAMBDA__

extended __device__ lambda‘ 是一个用 ‘__device__‘ 显式注释的 lambda 表达式,并在 __host____host__ __device__ 函数的直接或嵌套块范围内定义。

extended __host__ __device__ lambda‘ 是一个用 ‘__host__‘ 和 ‘__device__‘ 显式注释的 lambda 表达式,并在 __host____host__ __device__ 函数的直接或嵌套块范围内定义。

extended lambda”表示扩展的 __device__ lambda 或扩展的 __host__ __device__ lambda。扩展的 lambda 可用于 __global__ 函数模板实例化的类型参数。

如果未明确指定执行空间注释,则它们是根据包含与 lambda 关联的闭包类的范围计算的,如 C++11 支持部分所述。执行空间注释应用于与 lambda 关联的闭包类的所有方法。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
void foo_host(void) {
// not an extended lambda: no explicit execution space annotations
auto lam1 = [] { };

// extended __device__ lambda
auto lam2 = [] __device__ { };

// extended __host__ __device__ lambda
auto lam3 = [] __host__ __device__ { };

// not an extended lambda: explicitly annotated with only '__host__'
auto lam4 = [] __host__ { };
}

__host__ __device__ void foo_host_device(void) {
// not an extended lambda: no explicit execution space annotations
auto lam1 = [] { };

// extended __device__ lambda
auto lam2 = [] __device__ { };

// extended __host__ __device__ lambda
auto lam3 = [] __host__ __device__ { };

// not an extended lambda: explicitly annotated with only '__host__'
auto lam4 = [] __host__ { };
}

__device__ void foo_device(void) {
// none of the lambdas within this function are extended lambdas,
// because the enclosing function is not a __host__ or __host__ __device__
// function.
auto lam1 = [] { };
auto lam2 = [] __device__ { };
auto lam3 = [] __host__ __device__ { };
auto lam4 = [] __host__ { };
}

// lam1 and lam2 are not extended lambdas because they are not defined
// within a __host__ or __host__ __device__ function.
auto lam1 = [] { };
auto lam2 = [] __host__ __device__ { };

I.6.1. Extended Lambda Type Traits

编译器提供类型特征来在编译时检测扩展 lambda 的闭包类型:

__nv_is_extended_device_lambda_closure_type(type):如果 ‘type’ 是为扩展的 __device__ lambda 创建的闭包类,则 trait 为真,否则为假。

__nv_is_extended_host_device_lambda_closure_type(type):如果 ‘type’ 是为扩展的 __host__ __device__ lambda 创建的闭包类,则 trait 为真,否则为假。

这些特征可以在所有编译模式中使用,无论是启用 lambda 还是扩展 lambda

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#define IS_D_LAMBDA(X) __nv_is_extended_device_lambda_closure_type(X)
#define IS_HD_LAMBDA(X) __nv_is_extended_host_device_lambda_closure_type(X)

auto lam0 = [] __host__ __device__ { };

void foo(void) {
auto lam1 = [] { };
auto lam2 = [] __device__ { };
auto lam3 = [] __host__ __device__ { };

// lam0 is not an extended lambda (since defined outside function scope)
static_assert(!IS_D_LAMBDA(decltype(lam0)), "");
static_assert(!IS_HD_LAMBDA(decltype(lam0)), "");

// lam1 is not an extended lambda (since no execution space annotations)
static_assert(!IS_D_LAMBDA(decltype(lam1)), "");
static_assert(!IS_HD_LAMBDA(decltype(lam1)), "");

// lam2 is an extended __device__ lambda
static_assert(IS_D_LAMBDA(decltype(lam2)), "");
static_assert(!IS_HD_LAMBDA(decltype(lam2)), "");

// lam3 is an extended __host__ __device__ lambda
static_assert(!IS_D_LAMBDA(decltype(lam3)), "");
static_assert(IS_HD_LAMBDA(decltype(lam3)), "");
}

I.6.2. Extended Lambda Restrictions

在调用主机编译器之前,CUDA 编译器将用命名空间范围内定义的占位符类型的实例替换扩展的 lambda 表达式。占位符类型的模板参数需要获取包含原始扩展 lambda 表达式的函数的地址。这是正确执行任何模板参数涉及扩展 lambda 的闭包类型的 __global__ 函数模板所必需的。封闭函数计算如下。

根据定义,扩展 lambda 存在于 __host____host__ __device__ 函数的直接或嵌套块范围内。如果此函数不是 lambda 表达式的 operator(),则将其视为扩展 lambda 的封闭函数。否则,扩展 lambda 定义在一个或多个封闭 lambda 表达式的 operator() 的直接或嵌套块范围内。如果最外层的这种 lambda 表达式定义在函数 F 的直接或嵌套块范围内,则 F 是计算的封闭函数,否则封闭函数不存在。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void foo(void) {
// enclosing function for lam1 is "foo"
auto lam1 = [] __device__ { };

auto lam2 = [] {
auto lam3 = [] {
// enclosing function for lam4 is "foo"
auto lam4 = [] __host__ __device__ { };
};
};
}

auto lam6 = [] {
// enclosing function for lam7 does not exist
auto lam7 = [] __host__ __device__ { };
};

以下是对扩展 lambda 的限制:

扩展 lambda 不能在另一个扩展 lambda 表达式中定义。
例子:

1
2
3
4
5
6
7
void foo(void) {
auto lam1 = [] __host__ __device__ {
// error: extended lambda defined within another extended lambda
auto lam2 = [] __host__ __device__ { };
};
}

不能在通用 lambda 表达式中定义扩展 lambda。
例子:

1
2
3
4
5
6
7
void foo(void) {
auto lam1 = [] (auto) {
// error: extended lambda defined within a generic lambda
auto lam2 = [] __host__ __device__ { };
};
}

如果扩展 lambda 定义在一个或多个嵌套 lambda 表达式的直接或嵌套块范围内,则最外层的此类 lambda 表达式必须定义在函数的直接或嵌套块范围内。
例子:

1
2
3
4
5
auto lam1 = []  {
// error: outer enclosing lambda is not defined within a
// non-lambda-operator() function.
auto lam2 = [] __host__ __device__ { };
};

必须命名扩展 lambda 的封闭函数,并且可以获取其地址。 如果封闭函数是类成员,则必须满足以下条件:

  • 包含成员函数的所有类都必须有一个名称。
  • 成员函数在其父类中不得具有私有或受保护的访问权限。
  • 所有封闭类在其各自的父类中不得具有私有或受保护的访问权限。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
void foo(void) {
// OK
auto lam1 = [] __device__ { return 0; };
{
// OK
auto lam2 = [] __device__ { return 0; };
// OK
auto lam3 = [] __device__ __host__ { return 0; };
}
}

struct S1_t {
S1_t(void) {
// Error: cannot take address of enclosing function
auto lam4 = [] __device__ { return 0; };
}
};

class C0_t {
void foo(void) {
// Error: enclosing function has private access in parent class
auto temp1 = [] __device__ { return 10; };
}
struct S2_t {
void foo(void) {
// Error: enclosing class S2_t has private access in its
// parent class
auto temp1 = [] __device__ { return 10; };
}
};
};

必须可以在定义扩展 lambda 的位置明确地获取封闭例程的地址。 这在某些情况下可能不可行,例如 当类 typedef 隐藏同名的模板类型参数时。
例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
template <typename> struct A {
typedef void Bar;
void test();
};

template<> struct A<void> { };

template <typename Bar>
void A<Bar>::test() {
/* In code sent to host compiler, nvcc will inject an
address expression here, of the form:
(void (A< Bar> ::*)(void))(&A::test))

However, the class typedef 'Bar' (to void) shadows the
template argument 'Bar', causing the address
expression in A<int>::test to actually refer to:
(void (A< void> ::*)(void))(&A::test))

..which doesn't take the address of the enclosing
routine 'A<int>::test' correctly.
*/
auto lam1 = [] __host__ __device__ { return 4; };
}

int main() {
A<int> xxx;
xxx.test();
}

不能在函数本地的类中定义扩展 lambda。
例子:

1
2
3
4
5
6
7
8
9
void foo(void) {
struct S1_t {
void bar(void) {
// Error: bar is member of a class that is local to a function.
auto lam4 = [] __host__ __device__ { return 0; };
}
};
}

扩展 lambda 的封闭函数不能推导出返回类型。
例子:

1
2
3
4
5
auto foo(void) {
// Error: the return type of foo is deduced.
auto lam1 = [] __host__ __device__ { return 0; };
}

__host__ __device__ 扩展 lambda 不能是通用 lambda。
例子:

1
2
3
4
5
6
7
8
9
10
11
12
void foo(void) {
// Error: __host__ __device__ extended lambdas cannot be
// generic lambdas.
auto lam1 = [] __host__ __device__ (auto i) { return i; };

// Error: __host__ __device__ extended lambdas cannot be
// generic lambdas.
auto lam2 = [] __host__ __device__ (auto ...i) {
return sizeof...(i);
};
}

如果封闭函数是函数模板或成员函数模板的实例化,或函数是类模板的成员,则模板必须满足以下约束:

  • 模板最多只能有一个可变参数,并且必须在模板参数列表中最后列出。
  • 模板参数必须命名。
  • 模板实例化参数类型不能涉及函数本地的类型(扩展 lambda 的闭包类型除外),或者是私有或受保护的类成员。

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
template <typename T>
__global__ void kern(T in) { in(); }

template <typename... T>
struct foo {};

template < template <typename...> class T, typename... P1,
typename... P2>
void bar1(const T<P1...>, const T<P2...>) {
// Error: enclosing function has multiple parameter packs
auto lam1 = [] __device__ { return 10; };
}

template < template <typename...> class T, typename... P1,
typename T2>
void bar2(const T<P1...>, T2) {
// Error: for enclosing function, the
// parameter pack is not last in the template parameter list.
auto lam1 = [] __device__ { return 10; };
}

template <typename T, T>
void bar3(void) {
// Error: for enclosing function, the second template
// parameter is not named.
auto lam1 = [] __device__ { return 10; };
}

int main() {
foo<char, int, float> f1;
foo<char, int> f2;
bar1(f1, f2);
bar2(f1, 10);
bar3<int, 10>();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template <typename T>
__global__ void kern(T in) { in(); }

template <typename T>
void bar4(void) {
auto lam1 = [] __device__ { return 10; };
kern<<<1,1>>>(lam1);
}

struct C1_t { struct S1_t { }; friend int main(void); };
int main() {
struct S1_t { };
// Error: enclosing function for device lambda in bar4
// is instantiated with a type local to main.
bar4<S1_t>();

// Error: enclosing function for device lambda in bar4
// is instantiated with a type that is a private member
// of a class.
bar4<C1_t::S1_t>();
}

对于 Visual Studio 主机编译器,封闭函数必须具有外部链接。存在限制是因为此主机编译器不支持使用非外部链接函数的地址作为模板参数,而 CUDA 编译器转换需要它来支持扩展的 lambda。

对于 Visual Studio 主机编译器,不应在“if-constexpr”块的主体内定义扩展 lambda。

扩展的 lambda 对捕获的变量有以下限制:

  • 在发送到宿主编译器的代码中,变量可以通过值传递给一系列辅助函数,然后用于直接初始化用于表示扩展 lambda的闭包类型的类型的字段。
  • 变量只能按值捕获。
  • 如果数组维数大于 7,则无法捕获数组类型的变量。
  • 对于数组类型的变量,在发送到宿主编译器的代码中,首先对闭包类型的数组字段进行默认初始化,然后将数组字段的每个元素从捕获的数组变量的相应元素中复制分配。因此,数组元素类型在宿主代码中必须是默认可构造和可复制分配的。
  • 无法捕获作为可变参数包元素的函数参数。
  • 捕获的变量的类型不能涉及函数本地的类型(扩展 lambda 的闭包类型除外),或者是私有或受保护的类成员。
  • 对于 __host__ __device__ 扩展 lambda,在 lambda 表达式的 operator() 的返回或参数类型中使用的类型不能涉及函数本地的类型(扩展 lambda 的闭包类型除外),或者是私有或受保护的类成员.
  • __host__ __device__ 扩展 lambdas 不支持初始化捕获。 __device__ 扩展 lambda 支持初始化捕获,除非初始化捕获是数组类型或 std::initializer_list 类型。
  • 扩展 lambda 的函数调用运算符不是 constexpr。扩展 lambda 的闭包类型不是文字类型。 constexpr 说明符不能用于扩展 lambda 的声明。
  • 一个变量不能被隐式地捕获在一个词法嵌套在扩展 lambda 内的 if-constexpr 块中,除非它已经在 if-constexpr 块之外早先被隐式捕获或出现在扩展 lambda 的显式捕获列表中(参见下面的示例)。

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
void foo(void) {
// OK: an init-capture is allowed for an
// extended __device__ lambda.
auto lam1 = [x = 1] __device__ () { return x; };

// Error: an init-capture is not allowed for
// an extended __host__ __device__ lambda.
auto lam2 = [x = 1] __host__ __device__ () { return x; };

int a = 1;
// Error: an extended __device__ lambda cannot capture
// variables by reference.
auto lam3 = [&a] __device__ () { return a; };

// Error: by-reference capture is not allowed
// for an extended __device__ lambda.
auto lam4 = [&x = a] __device__ () { return x; };

struct S1_t { };
S1_t s1;
// Error: a type local to a function cannot be used in the type
// of a captured variable.
auto lam6 = [s1] __device__ () { };

// Error: an init-capture cannot be of type std::initializer_list.
auto lam7 = [x = {11}] __device__ () { };

std::initializer_list<int> b = {11,22,33};
// Error: an init-capture cannot be of type std::initializer_list.
auto lam8 = [x = b] __device__ () { };

// Error scenario (lam9) and supported scenarios (lam10, lam11)
// for capture within 'if-constexpr' block
int yyy = 4;
auto lam9 = [=] __device__ {
int result = 0;
if constexpr(false) {
//Error: An extended __device__ lambda cannot first-capture
// 'yyy' in constexpr-if context
result += yyy;
}
return result;
};

auto lam10 = [yyy] __device__ {
int result = 0;
if constexpr(false) {
//OK: 'yyy' already listed in explicit capture list for the extended lambda
result += yyy;
}
return result;
};

auto lam11 = [=] __device__ {
int result = yyy;
if constexpr(false) {
//OK: 'yyy' already implicit captured outside the 'if-constexpr' block
result += yyy;
}
return result;
};
}

解析函数时,CUDA 编译器为该函数中的每个扩展 lambda 分配一个计数器值。 此计数器值用于传递给主机编译器的替代命名类型。 因此,是否在函数中定义扩展 lambda 不应取决于 __CUDA_ARCH__ 的特定值,或 __CUDA_ARCH__ 未定义。

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <typename T>
__global__ void kernel(T in) { in(); }

__host__ __device__ void foo(void) {
// Error: the number and relative declaration
// order of extended lambdas depends on
// __CUDA_ARCH__
#if defined(__CUDA_ARCH__)
auto lam1 = [] __device__ { return 0; };
auto lam1b = [] __host___ __device__ { return 10; };
#endif
auto lam2 = [] __device__ { return 4; };
kernel<<<1,1>>>(lam2);
}

如上所述,CUDA 编译器将主机函数中定义的 __device__ 扩展 lambda 替换为命名空间范围中定义的占位符类型。 此占位符类型未定义与原始 lambda 声明等效的 operator() 函数。 因此,尝试确定 operator() 函数的返回类型或参数类型可能在宿主代码中无法正常工作,因为宿主编译器处理的代码在语义上与 CUDA 编译器处理的输入代码不同。 但是,可以在设备代码中内省 operator() 函数的返回类型或参数类型。 请注意,此限制不适用于 __host__ __device__ 扩展 lambda。

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <type_traits>

void foo(void) {
auto lam1 = [] __device__ { return 10; };

// Error: attempt to extract the return type
// of a __device__ lambda in host code
std::result_of<decltype(lam1)()>::type xx1 = 1;


auto lam2 = [] __host__ __device__ { return 10; };

// OK : lam2 represents a __host__ __device__ extended lambda
std::result_of<decltype(lam2)()>::type xx2 = 1;
}

如果由扩展 lambda 表示的仿函数对象从主机传递到设备代码(例如,作为 __global__ 函数的参数),则 lambda 表达式主体中捕获变量的任何表达式都必须保持不变,无论 __CUDA_ARCH__ 是否 定义宏,以及宏是否具有特定值。 出现这个限制是因为 lambda 的闭包类布局取决于编译器处理 lambda 表达式时遇到捕获的变量的顺序; 如果闭包类布局在设备和主机编译中不同,则程序可能执行不正确。

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
__device__ int result;

template <typename T>
__global__ void kernel(T in) { result = in(); }

void foo(void) {
int x1 = 1;
auto lam1 = [=] __host__ __device__ {
// Error: "x1" is only captured when __CUDA_ARCH__ is defined.
#ifdef __CUDA_ARCH__
return x1 + 1;
#else
return 10;
#endif
};
kernel<<<1,1>>>(lam1);
}

如前所述,CUDA 编译器将扩展的 __device__ lambda 表达式替换为发送到主机编译器的代码中的占位符类型的实例。 此占位符类型未在主机代码中定义指向函数的转换运算符,但在设备代码中提供了转换运算符。 请注意,此限制不适用于 __host__ __device__ 扩展 lambda。

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
template <typename T>
__global__ void kern(T in) {
int (*fp)(double) = in;

// OK: conversion in device code is supported
fp(0);
auto lam1 = [](double) { return 1; };

// OK: conversion in device code is supported
fp = lam1;
fp(0);
}

void foo(void) {
auto lam_d = [] __device__ (double) { return 1; };
auto lam_hd = [] __host__ __device__ (double) { return 1; };
kern<<<1,1>>>(lam_d);
kern<<<1,1>>>(lam_hd);

// OK : conversion for __host__ __device__ lambda is supported
// in host code
int (*fp)(double) = lam_hd;

// Error: conversion for __device__ lambda is not supported in
// host code.
int (*fp2)(double) = lam_d;
}

如前所述,CUDA 编译器将扩展的 __device____host__ __device__ lambda 表达式替换为发送到主机编译器的代码中的占位符类型的实例。 此占位符类型可以定义 C++ 特殊成员函数(例如构造函数、析构函数)。 因此,在 CUDA 前端编译器与主机编译器中,一些标准 C++ 类型特征可能会为扩展 lambda 的闭包类型返回不同的结果。 以下类型特征受到影响:std::is_trivially_copyable、std::is_trivially_constructible、std::is_trivially_copy_constructible、std::is_trivially_move_constructible、std::is_trivially_destructible

必须注意这些类型特征的结果不用于 __global__ 函数模板实例化或 __device__ / __constant__ / __managed__ 变量模板实例化。

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template <bool b>
void __global__ foo() { printf("hi"); }

template <typename T>
void dolaunch() {

// ERROR: this kernel launch may fail, because CUDA frontend compiler
// and host compiler may disagree on the result of
// std::is_trivially_copyable() trait on the closure type of the
// extended lambda
foo<std::is_trivially_copyable<T>::value><<<1,1>>>();
cudaDeviceSynchronize();
}

int main() {
int x = 0;
auto lam1 = [=] __host__ __device__ () { return x; };
dolaunch<decltype(lam1)>();
}

CUDA 编译器将为 1-12 中描述的部分情况生成编译器诊断; 不会为案例 13-17 生成诊断,但主机编译器可能无法编译生成的代码。

I.6.3. Notes on host device lambdas

__device__ lambdas 不同,__host__ __device__ lambdas 可以从主机代码中调用。如前所述,CUDA 编译器将主机代码中定义的扩展 lambda 表达式替换为命名占位符类型的实例。扩展的 __host__ __device__ lambda 的占位符类型通过间接函数调用原始 lambda 的 operator()。

间接函数调用的存在可能会导致主机编译器对扩展的 __host__ __device__ lambda 的优化程度低于仅隐式或显式 __host__ 的 lambda。在后一种情况下,宿主编译器可以轻松地将 lambda 的主体内联到调用上下文中。但是在扩展 __host__ __device__ lambda 的情况下,主机编译器会遇到间接函数调用,并且可能无法轻松内联原始 __host__ __device__ lambda 主体。

I.6.4. *this Capture By Value

当在非静态类成员函数中定义 lambda,并且 lambda 的主体引用类成员变量时,C++11/C++14 规则要求类的 this 指针按值捕获,而不是引用的成员变量。如果 lambda 是在主机函数中定义的扩展 __device____host__ __device__ lambda,并且 lambda 在 GPU 上执行,如果 this 指针指向主机内存,则在 GPU 上访问引用的成员变量将导致运行时错误。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <cstdio>

template <typename T>
__global__ void foo(T in) { printf("\n value = %d", in()); }

struct S1_t {
int xxx;
__host__ __device__ S1_t(void) : xxx(10) { };

void doit(void) {

auto lam1 = [=] __device__ {
// reference to "xxx" causes
// the 'this' pointer (S1_t*) to be captured by value
return xxx + 1;

};

// Kernel launch fails at run time because 'this->xxx'
// is not accessible from the GPU
foo<<<1,1>>>(lam1);
cudaDeviceSynchronize();
}
};

int main(void) {
S1_t s1;
s1.doit();
}

C++17 通过添加新的“*this”捕获模式解决了这个问题。 在这种模式下,编译器复制由“*this”表示的对象,而不是按值捕获指针 this。 此处更详细地描述了“*this”捕获模式:http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2016/p0018r3.html。

当使用 --extended-lambda nvcc 标志时,CUDA 编译器支持 __device____global__ 函数中定义的 lambdas 以及主机代码中定义的扩展 __device__ lambdas 的“*this”捕获模式。

这是修改为使用“*this”捕获模式的上述示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <cstdio>

template <typename T>
__global__ void foo(T in) { printf("\n value = %d", in()); }

struct S1_t {
int xxx;
__host__ __device__ S1_t(void) : xxx(10) { };

void doit(void) {

// note the "*this" capture specification
auto lam1 = [=, *this] __device__ {

// reference to "xxx" causes
// the object denoted by '*this' to be captured by
// value, and the GPU code will access copy_of_star_this->xxx
return xxx + 1;

};

// Kernel launch succeeds
foo<<<1,1>>>(lam1);
cudaDeviceSynchronize();
}
};

int main(void) {
S1_t s1;
s1.doit();
}

主机代码中定义的未注释 lambda 或扩展的 __host__ __device__ lambda 不允许使用“*this”捕获模式。 支持和不支持的用法示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
struct S1_t { 
int xxx;
__host__ __device__ S1_t(void) : xxx(10) { };

void host_func(void) {

// OK: use in an extended __device__ lambda
auto lam1 = [=, *this] __device__ { return xxx; };

// Error: use in an extended __host__ __device__ lambda
auto lam2 = [=, *this] __host__ __device__ { return xxx; };

// Error: use in an unannotated lambda in host function
auto lam3 = [=, *this] { return xxx; };
}

__device__ void device_func(void) {

// OK: use in a lambda defined in a __device__ function
auto lam1 = [=, *this] __device__ { return xxx; };

// OK: use in a lambda defined in a __device__ function
auto lam2 = [=, *this] __host__ __device__ { return xxx; };

// OK: use in a lambda defined in a __device__ function
auto lam3 = [=, *this] { return xxx; };
}

__host__ __device__ void host_device_func(void) {

// OK: use in an extended __device__ lambda
auto lam1 = [=, *this] __device__ { return xxx; };

// Error: use in an extended __host__ __device__ lambda
auto lam2 = [=, *this] __host__ __device__ { return xxx; };

// Error: use in an unannotated lambda in a __host__ __device__ function
auto lam3 = [=, *this] { return xxx; };
}
};

I.6.5. Additional Notes

ADL Lookup:如前所述,CUDA 编译器将在调用宿主编译器之前将扩展的 lambda 表达式替换为占位符类型的实例。 占位符类型的一个模板参数使用包含原始 lambda 表达式的函数的地址。 对于参数类型涉及扩展 lambda 表达式的闭包类型的任何主机函数调用,这可能会导致其他命名空间参与参数相关查找 (ADL)。 这可能会导致主机编译器选择不正确的函数。
例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
namespace N1 {
struct S1_t { };
template <typename T> void foo(T);
};

namespace N2 {
template <typename T> int foo(T);

template <typename T> void doit(T in) { foo(in); }
}

void bar(N1::S1_t in) {
/* extended __device__ lambda. In the code sent to the host compiler, this
is replaced with the placeholder type instantiation expression
' __nv_dl_wrapper_t< __nv_dl_tag<void (*)(N1::S1_t in),(&bar),1> > { }'

As a result, the namespace 'N1' participates in ADL lookup of the
call to "foo" in the body of N2::doit, causing ambiguity.
*/
auto lam1 = [=] __device__ { };
N2::doit(lam1);
}

在上面的示例中,CUDA 编译器将扩展 lambda 替换为涉及 N1 命名空间的占位符类型。 结果,命名空间 N1 参与了对 N2::doit 主体中的 foo(in) 的 ADL 查找,并且主机编译失败,因为找到了多个重载候选 N1::fooN2::foo

I.7. Code Samples

I.7.1. Data Aggregation Class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class PixelRGBA {
public:
__device__ PixelRGBA(): r_(0), g_(0), b_(0), a_(0) { }

__device__ PixelRGBA(unsigned char r, unsigned char g,
unsigned char b, unsigned char a = 255):
r_(r), g_(g), b_(b), a_(a) { }

private:
unsigned char r_, g_, b_, a_;

friend PixelRGBA operator+(const PixelRGBA&, const PixelRGBA&);
};

__device__
PixelRGBA operator+(const PixelRGBA& p1, const PixelRGBA& p2)
{
return PixelRGBA(p1.r_ + p2.r_, p1.g_ + p2.g_,
p1.b_ + p2.b_, p1.a_ + p2.a_);
}

__device__ void func(void)
{
PixelRGBA p1, p2;
// ... // Initialization of p1 and p2 here
PixelRGBA p3 = p1 + p2;
}

I.7.2. Derived Class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
__device__ void* operator new(size_t bytes, MemoryPool& p);
__device__ void operator delete(void*, MemoryPool& p);
class Shape {
public:
__device__ Shape(void) { }
__device__ void putThis(PrintBuffer *p) const;
__device__ virtual void Draw(PrintBuffer *p) const {
p->put("Shapeless");
}
__device__ virtual ~Shape() {}
};
class Point : public Shape {
public:
__device__ Point() : x(0), y(0) {}
__device__ Point(int ix, int iy) : x(ix), y(iy) { }
__device__ void PutCoord(PrintBuffer *p) const;
__device__ void Draw(PrintBuffer *p) const;
__device__ ~Point() {}
private:
int x, y;
};
__device__ Shape* GetPointObj(MemoryPool& pool)
{
Shape* shape = new(pool) Point(rand(-20,10), rand(-100,-20));
return shape;
}

I.7.3. Class Template

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template <class T>
class myValues {
T values[MAX_VALUES];
public:
__device__ myValues(T clear) { ... }
__device__ void setValue(int Idx, T value) { ... }
__device__ void putToMemory(T* valueLocation) { ... }
};

template <class T>
void __global__ useValues(T* memoryBuffer) {
myValues<T> myLocation(0);
...
}

__device__ void* buffer;

int main()
{
...
useValues<int><<<blocks, threads>>>(buffer);
...
}

I.7.4. Function Template

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template <typename T> 
__device__ bool func(T x)
{
...
return (...);
}

template <>
__device__ bool func<int>(T x) // Specialization
{
return true;
}

// Explicit argument specification
bool result = func<double>(0.5);

// Implicit argument deduction
int x = 1;
bool result = func(x);

I.7.5. Functor Class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class Add {
public:
__device__ float operator() (float a, float b) const
{
return a + b;
}
};

class Sub {
public:
__device__ float operator() (float a, float b) const
{
return a - b;
}
};

// Device code
template<class O> __global__
void VectorOperation(const float * A, const float * B, float * C,
unsigned int N, O op)
{
unsigned int iElement = blockDim.x * blockIdx.x + threadIdx.x;
if (iElement < N)
C[iElement] = op(A[iElement], B[iElement]);
}

// Host code
int main()
{
...
VectorOperation<<<blocks, threads>>>(v1, v2, v3, N, Add());
...
}

附录J 纹理获取

本附录给出了用于计算 Texture Functions 的纹理函数返回值的公式,具体取决于纹理引用的各种属性(请参阅纹理和表面内存)。

绑定到纹理引用的纹理表示为一个数组 T

  • 一维纹理的 N 个texels,
  • 二维纹理的 N x M texels,
  • 三维纹理的 N x M x L texels。

它是使用非归一化纹理坐标 x、y 和 z 或归一化纹理坐标 x/N、y/M 和 z/L 获取的,如纹理内存中所述。 在本附录中,假定坐标在有效范围内。 纹理内存解释了如何根据寻址模式将超出范围的坐标重新映射到有效范围。

J.1. Nearest-Point Sampling

在这种过滤模式下,纹理获取返回的值是

  • tex(x)=T[i] 对于一维纹理,
  • tex(x,y)=T[i,j] 对于二维纹理,
  • tex(x,y,z)=T[i,j,k] 对于三维纹理,

其中 i=floor(x),j=floor(y),k=floor(z)。

下图 说明了 N=4 的一维纹理的最近点采样。

对于整数纹理,纹理获取返回的值可以选择重新映射到 [0.0, 1.0](请参阅纹理内存)。

near

J.2. Linear Filtering

在这种仅适用于浮点纹理的过滤模式下,纹理获取返回的值是

  • tex(x)=(1−α)T[i]+αT[i+1] for a one-dimensional texture,

  • tex(x,y)=(1−α)(1−β)T[i,j]+α(1−β)T[i+1,j]+(1−α)βT[i,j+1]+αβT[i+1,j+1] for a two-dimensional texture,

  • tex(x,y,z) =

    (1−α)(1−β)(1−γ)T[i,j,k]+α(1−β)(1−γ)T[i+1,j,k]+

    (1−α)β(1−γ)T[i,j+1,k]+αβ(1−γ)T[i+1,j+1,k]+

    (1−α)(1−β)γT[i,j,k+1]+α(1−β)γT[i+1,j,k+1]+

    (1−α)βγT[i,j+1,k+1]+αβγT[i+1,j+1,k+1]

    for a three-dimensional texture,

其中:

  • i=floor(xB), α=frac(xB), xB=x-0.5,
  • j=floor(yB), β=frac(yB), yB=y-0.5,
  • k=floor(zB), γ=frac(zB), zB= z-0.5,

α、β 和 γ 以 9 位定点格式存储,带有 8 位小数值(因此精确表示 1.0)。

下图 说明了 N=4 的一维纹理的线性过滤。

J.3. Table Lookup

x 跨越区间 [0,R] 的查表 TL(x) 可以实现为 TL(x)=tex((N-1)/R)x+0.5) 以确保 TL(0)= T[0] 和 TL(R)=T[N-1]。

下图 说明了使用纹理过滤从 N=4 的一维纹理中实现 R=4 或 R=1 的表查找。

附录K CUDA计算能力

计算设备的一般规格和功能取决于其计算能力(请参阅计算能力)。

下面的表格中 显示了与当前支持的每种计算能力相关的特性和技术规格。

浮点标准审查是否符合 IEEE 浮点标准。

Compute Capability 3.xCompute Capability 5.xCompute Capability 6.xCompute Capability 7.xCompute Capability 8.x 部分提供了有关计算能力 3.x、5.x、6 的设备架构的更多详细信息 .x、7.x 和 8.x 分别。

K.1. Features and Technical Specifications

Table 14. Feature Support per Compute Capability
Feature Support Compute Capability
(Unlisted features are supported for all compute capabilities) 3.5, 3.7, 5.0, 5.2 5.3 6.x 7.x 8.x
Atomic functions operating on 32-bit integer values in global memory (Atomic Functions) Yes
Atomic functions operating on 32-bit integer values in shared memory (Atomic Functions) Yes
Atomic functions operating on 64-bit integer values in global memory (Atomic Functions) Yes
Atomic functions operating on 64-bit integer values in shared memory (Atomic Functions) Yes
Atomic addition operating on 32-bit floating point values in global and shared memory (atomicAdd()) Yes
Atomic addition operating on 64-bit floating point values in global memory and shared memory (atomicAdd()) No Yes
Warp vote functions (Warp Vote Functions) Yes
Memory fence functions (Memory Fence Functions)
Synchronization functions (Synchronization Functions)
Surface functions (Surface Functions)
Unified Memory Programming (Unified Memory Programming)
Dynamic Parallelism (CUDA Dynamic Parallelism)
Half-precision floating-point operations: addition, subtraction, multiplication, comparison, warp shuffle functions, conversion No Yes
Bfloat16-precision floating-point operations: addition, subtraction, multiplication, comparison, warp shuffle functions, conversion No Yes
Tensor Cores No Yes
Mixed Precision Warp-Matrix Functions (Warp matrix functions) No Yes
Hardware-accelerated memcpy_async (Asynchronous Data Copies using cuda::pipeline) No Yes
Hardware-accelerated Split Arrive/Wait Barrier (Asynchronous Barrier) No Yes
L2 Cache Residency Management (Device Memory L2 Access Management) No Yes

请注意,下表中使用的 KB 和 K 单位分别对应于 1024 字节(即 KiB)和 1024。

Table 15. Technical Specifications per Compute Capability
  Compute Capability
Technical Specifications 3.5 3.7 5.0 5.2 5.3 6.0 6.1 6.2 7.0 7.2 7.5 8.0 8.6 8.7
Maximum number of resident grids per device (Concurrent Kernel Execution) 32 16 128 32 16 128 16 128
Maximum dimensionality of grid of thread blocks 3
Maximum x-dimension of a grid of thread blocks 231-1
Maximum y- or z-dimension of a grid of thread blocks 65535
Maximum dimensionality of a thread block 3
Maximum x- or y-dimension of a block 1024
Maximum z-dimension of a block 64
Maximum number of threads per block 1024
Warp size 32
Maximum number of resident blocks per SM 16 32 16 32 16
Maximum number of resident warps per SM 64 32 64 48
Maximum number of resident threads per SM 2048 1024 2048 1536
Number of 32-bit registers per SM 64 K 128 K 64 K
Maximum number of 32-bit registers per thread block 64 K 32 K 64 K 32 K 64 K
Maximum number of 32-bit registers per thread 255
Maximum amount of shared memory per SM 48 KB 112 KB 64 KB 96 KB 64 KB 96 KB 64 KB 96 KB 64 KB 164 KB 100 KB 164 KB
Maximum amount of shared memory per thread block 33 48 KB 96 KB 96 KB 64 KB 163 KB 99 KB 163 KB
Number of shared memory banks 32
Maximum amount of local memory per thread 512 KB
Constant memory size 64 KB
Cache working set per SM for constant memory 8 KB 4 KB 8 KB
Cache working set per SM for texture memory Between 12 KB and 48 KB Between 24 KB and 48 KB 32 ~ 128 KB 32 or 64 KB 28KB ~ 192 KB 28KB ~ 128 KB 28KB ~ 192 KB
Maximum width for a 1D texture reference bound to a CUDA array 65536 131072
Maximum width for a 1D texture reference bound to linear memory 227 228 227 228 227 228
Maximum width and number of layers for a 1D layered texture reference 16384 x 2048 32768 x 2048
Maximum width and height for a 2D texture reference bound to a CUDA array 65536 x 65536 131072 x 65536
Maximum width and height for a 2D texture reference bound to linear memory 65000 x 65000 65536 x 65536 131072 x 65000
Maximum width and height for a 2D texture reference bound to a CUDA array supporting texture gather 16384 x 16384 32768 x 32768
Maximum width, height, and number of layers for a 2D layered texture reference 16384 x 16384 x 2048 32768 x 32768 x 2048
Maximum width, height, and depth for a 3D texture reference bound to a CUDA array 4096 x 4096 x 4096 16384 x 16384 x 16384
Maximum width (and height) for a cubemap texture reference 16384 32768
Maximum width (and height) and number of layers for a cubemap layered texture reference 16384 x 2046 32768 x 2046
Maximum number of textures that can be bound to a kernel 256
Maximum width for a 1D surface reference bound to a CUDA array 65536 16384 32768
Maximum width and number of layers for a 1D layered surface reference 65536 x 2048 16384 x 2048 32768 x 2048
Maximum width and height for a 2D surface reference bound to a CUDA array 65536 x 32768 65536 x 65536 131072 x 65536
Maximum width, height, and number of layers for a 2D layered surface reference 65536 x 32768 x 2048 16384 x 16384 x 2048 32768 x 32768 x 2048
Maximum width, height, and depth for a 3D surface reference bound to a CUDA array 65536 x 32768 x 2048 4096 x 4096 x 4096 16384 x 16384 x 16384
Maximum width (and height) for a cubemap surface reference bound to a CUDA array 32768 16384 32768
Maximum width (and height) and number of layers for a cubemap layered surface reference 32768 x 2046 16384 x 2046 32768 x 2046
Maximum number of surfaces that can be bound to a kernel 16 32

K.2. Floating-Point Standard

所有计算设备都遵循二进制浮点运算的 IEEE 754-2008 标准,但存在以下偏差:

  • 没有动态可配置的舍入模式;但是,大多数操作支持多种 IEEE 舍入模式,通过设备内在函数公开。
  • 没有检测浮点异常发生的机制,并且所有操作都表现得好像 IEEE-754 异常总是被屏蔽,如果出现异常事件,则传递 IEEE-754 定义的屏蔽响应。出于同样的原因,虽然支持 SNaN 编码,但它们不是发信号的,而是作为静默处理的。
  • 涉及一个或多个输入 NaN 的单精度浮点运算的结果是位模式 0x7fffffff 的安静 NaN。
  • 双精度浮点绝对值和求反在 NaN 方面不符合 IEEE-754;这些通过不变。

必须使用 -ftz=false-prec-div=true-prec-sqrt=true 编译代码以确保符合 IEEE 标准(这是默认设置;有关这些编译标志的说明,请参阅 nvcc 用户手册)。

无论编译器标志 -ftz 的设置如何,

  • 全局内存上的原子单精度浮点加法始终以清零模式运行,即,行为等同于 FADD.F32.FTZ.RN
  • 共享内存上的原子单精度浮点加法始终在非规范支持下运行,即,行为等同于 FADD.F32.RN

根据 IEEE-754R 标准,如果 fminf()fmin()fmaxf()fmax() 的输入参数之一是 NaN,而另一个不是,则结果是non-NaN 参数。

IEEE-754 未定义在浮点值超出整数格式范围的情况下将浮点值转换为整数值。对于计算设备,行为是钳制到支持范围的末尾。这与 x86 架构行为不同。

IEEE-754 未定义整数除以零和整数溢出的行为。对于计算设备,没有机制可以检测是否发生了此类整数运算异常。整数除以零会产生一个未指定的、特定于机器的值。

https://developer.nvidia.com/content/precision-performance-floating-point-and-ieee-754-compliance-nvidia-gpus 包含有关 NVIDIA GPU 的浮点精度和合规性的更多信息。

K.3. Compute Capability 3.x

K.3.1. Architecture

一个 SM 包括:

  • 192 个用于算术运算的 CUDA 内核(请参阅算术指令以了解算术运算的吞吐量),
  • 32个单精度浮点先验函数的特殊函数单元,
  • 4个warp调度器。

当一个 SM 被赋予执行 warp 时,它首先将它们分配给四个调度程序。然后,在每个指令发布时间,每个调度程序都会为其分配的一个已准备好执行的warp(如果有的话)发布两条独立的指令。

一个 SM 有一个只读常量缓存,它被所有功能单元共享,并加快了从驻留在设备内存中的常量内存空间的读取速度。

每个 SM 都有一个 L1 缓存,所有 SM 共享一个 L2 缓存。 L1 缓存用于缓存对本地内存的访问,包括临时寄存器溢出。 L2 缓存用于缓存对本地和全局内存的访问。缓存行为(例如,读取是在 L1 和 L2 中缓存还是仅在 L2 中缓存)可以使用加载或存储指令的修饰符在每次访问的基础上进行部分配置。某些计算能力为 3.5 的设备和计算能力为 3.7 的设备允许通过编译器选项选择在 L1 和 L2 中缓存全局内存。

相同的片上存储器用于 L1 和共享内存:它可以配置为 48 KB 共享内存和 16 KB 一级缓存或 16 KB 共享内存和 48 KB 一级缓存或 32 KB 共享内存和 32 KB 的 L1 缓存,使用 cudaFuncSetCacheConfig()/cuFuncSetCacheConfig()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Device code
__global__ void MyKernel()
{
...
}

// Host code

// Runtime API
// cudaFuncCachePreferShared: shared memory is 48 KB
// cudaFuncCachePreferEqual: shared memory is 32 KB
// cudaFuncCachePreferL1: shared memory is 16 KB
// cudaFuncCachePreferNone: no preference
cudaFuncSetCacheConfig(MyKernel, cudaFuncCachePreferShared)

默认的缓存配置是“prefer none”,意思是“无偏好”。如果内核被配置为没有首选项,那么它将默认为当前线程/上下文的首选项,这是使用 cudaDeviceSetCacheConfig()/cuCtxSetCacheConfig() 设置的(有关详细信息,请参阅参考手册)。如果当前线程/上下文也没有首选项(这又是默认设置),那么任何内核最近使用的缓存配置都将被使用,除非需要不同的缓存配置来启动内核(例如,由于共享内存要求)。初始配置是 48 KB 的共享内存和 16 KB 的 L1 高速缓存。

注意:计算能力为 3.7 的设备为上述每个配置添加了额外的 64 KB 共享内存,每个 SM 分别产生 112 KB、96 KB 和 80 KB 共享内存。但是,每个线程块的最大共享内存仍为 48 KB。
应用程序可以通过检查 l2CacheSize 设备属性来查询 L2 缓存大小(请参阅设备枚举)。最大二级缓存大小为 1.5 MB。

每个 SM 都有一个 48 KB 的只读数据缓存,以加快从设备内存中读取的速度。它直接访问此缓存(对于计算能力为 3.5 或 3.7 的设备),或通过实现纹理和表面内存中提到的各种寻址模式和数据过滤的纹理单元。当通过纹理单元访问时,只读数据缓存也称为纹理缓存。

K.3.2. Global Memory

计算能力 3.x 的设备的全局内存访问缓存在 L2 中,计算能力 3.5 或 3.7 的设备也可以缓存在上一节中描述的只读数据缓存中;它们通常不缓存在 L1 中。某些计算能力为 3.5 的设备和计算能力为 3.7 的设备允许通过 nvcc 的 -Xptxas -dlcm=ca 选项选择缓存 L1 中的全局内存访问。

高速缓存行是 128 字节,并映射到设备内存中 128 字节对齐的段。缓存在 L1 和 L2 中的内存访问使用 128 字节内存事务处理,而仅缓存在 L2 中的内存访问使用 32 字节内存事务处理。因此,仅在 L2 中进行缓存可以减少过度获取,例如,在分散内存访问的情况下。

如果每个线程访问的字的大小超过 4 字节,则 warp 的内存请求首先被拆分为独立发出的单独的 128 字节内存请求:

  • 两个内存请求,每个半warp一个,如果大小为 8 字节,
  • 如果大小为 16 字节,则四个内存请求,每个四分之一warp一个。

然后将每个内存请求分解为独立发出的高速缓存行请求。在缓存命中的情况下,以 L1 或 L2 缓存的吞吐量为缓存行请求提供服务,否则以设备内存的吞吐量提供服务。

请注意,线程可以以任何顺序访问任何字,包括相同的字。

如果 warp 执行的非原子指令为该 warp 的多个线程写入全局内存中的同一位置,则只有一个线程执行写入,并且未定义哪个线程执行写入。

在内核的整个生命周期内只读的数据也可以通过使用 __ldg() 函数读取它来缓存在上一节中描述的只读数据缓存中(请参阅只读数据缓存加载函数)。当编译器检测到某些数据满足只读条件时,它会使用__ldg() 来读取它。编译器可能并不总是能够检测到某些数据满足只读条件。使用 const__restrict__ 限定符标记用于加载此类数据的指针会增加编译器检测到只读条件的可能性。

下图显示了全局内存访问和相应内存事务的一些示例。

K.3.3. Shared Memory

下图中显示了一些跨步访问的示例。

下图显示了一些涉及广播机制的内存读取访问示例。

64 位模式

连续的 64 位字映射到连续的存储区。

对 warp 的共享内存请求不会在访问同一 64 位字中的任何子字的两个线程之间产生bank冲突(即使两个子字的地址位于同一bank中)。在这种情况下,对于读取访问,64 位字被广播到请求线程,对于写入访问,每个子字仅由其中一个线程写入(哪个线程执行写入未定义)。

32 位模式

连续的 32 位字映射到连续的存储区。

对warp 的共享内存请求不会在访问同一32 位字或索引i 和j 在同一64 字对齐段中的两个32 位字内的任何子字的两个线程之间产生bank冲突(即,第一个索引是 64 的倍数的段)并且使得 j=i+32(即使两个子字的地址在同一个库中)。在这种情况下,对于读访问,32 位字被广播到请求线程,对于写访问,每个子字仅由其中一个线程写入(哪个线程执行写入未定义)。

K.4. Compute Capability 5.x

K.4.1. Architecture

一个 SM 包括:

  • 128 个用于算术运算的 CUDA 内核(请参阅算术指令以了解算术运算的吞吐量),
  • 32个单精度浮点先验函数的特殊函数单元,
  • 4个warp调度器。

当一个 SM 被赋予执行 warp 时,它首先将它们分配给四个调度程序。然后,在每个指令发布时间,每个调度程序都会为其分配的经准备好执行的warp之一发布一条指令(如果有的话)。

SM 具有:

  • 由所有功能单元共享的只读常量缓存,可加快从驻留在设备内存中的常量内存空间的读取速度,
  • 一个 24 KB 的统一 L1/纹理缓存,用于缓存来自全局内存的读取,
  • 64 KB 共享内存用于计算能力为 5.0 的设备或 96 KB 共享内存用于计算能力为 5.2 的设备。

纹理单元也使用统一的 L1/纹理缓存,实现纹理和表面内存中提到的各种寻址模式和数据过滤。

还有一个由所有 SM 共享的 L2 缓存,用于缓存对本地或全局内存的访问,包括临时寄存器溢出。应用程序可以通过检查 l2CacheSize 设备属性来查询 L2 缓存大小(请参阅设备枚举)。

缓存行为(例如,读取是否缓存在统一的 L1/纹理缓存和 L2 中或仅在 L2 中)可以使用加载指令的修饰符在每次访问的基础上进行部分配置。

K.4.2. Global Memory

全局内存访问始终缓存在 L2 中,并且 L2 中的缓存行为与计算能力 3.x 的设备相同(请参阅全局内存)。

在内核的整个生命周期内只读的数据也可以通过使用 __ldg() 函数读取它来缓存在上一节中描述的统一 L1/纹理缓存中(请参阅只读数据缓存加载函数)。当编译器检测到某些数据满足只读条件时,它会使用__ldg() 来读取它。编译器可能并不总是能够检测到某些数据满足只读条件。使用 const__restrict__ 限定符标记用于加载此类数据的指针会增加编译器检测到只读条件的可能性。

对于计算能力 5.0 的设备,在内核的整个生命周期内不是只读的数据不能缓存在统一的 L1/纹理缓存中。对于计算能力为 5.2 的设备,默认情况下不缓存在统一的 L1/纹理缓存中,但可以使用以下机制启用缓存:

  • 如 PTX 参考手册中所述,使用带有适当修饰符的内联汇编执行读取;
  • 使用 -Xptxas -dlcm=ca 编译标志进行编译,在这种情况下,所有读取都被缓存,除了使用带有禁用缓存的修饰符的内联汇编执行的读取;
  • 使用 -Xptxas -fscm=ca 编译标志进行编译,在这种情况下,所有读取都被缓存,包括使用内联汇编执行的读取,无论使用何种修饰符。

当使用上面列出的三种机制之一启用缓存时,计算能力 5.2 的设备将为所有内核启动缓存全局内存读取到统一的 L1/纹理缓存中,除了线程块消耗过多 SM 寄存器的内核启动文件。这些异常由分析器报告。

K.4.3. Shared Memory

共享内存有 32 个bank,这些bank被组织成连续的 32 位字映射到连续的bank。 每个bank的带宽为每个时钟周期 32 位。

对 warp 的共享内存请求不会在访问同一 32 位字内的任何地址的两个线程之间产生bank冲突(即使两个地址位于同一存储库中)。 在这种情况下,对于读取访问,该字被广播到请求线程,对于写入访问,每个地址仅由一个线程写入(哪个线程执行写入未定义)。

下显示了一些跨步访问的示例。

左边

步长为一个 32 位字的线性寻址(无bank冲突)。

中间

跨两个 32 位字的线性寻址(双向bank冲突)。

右边

跨度为三个 32 位字的线性寻址(无bank冲突)。

下图显示了一些涉及广播机制的内存读取访问示例。

左边

通过随机排列实现无冲突访问。

中间

由于线程 3、4、6、7 和 9 访问存储区 5 中的同一个字,因此无冲突访问。

右边

无冲突广播访问(线程访问bank内的同一个词)。

K.5. Compute Capability 6.x

K.5.1. Architecture

一个 SM 包括:

  • 64 个(计算能力 6.0)或 128 个(6.1 和 6.2)用于算术运算的 CUDA 内核,
  • 16 个 (6.0) 或 32 个 (6.1 和 6.2) 用于单精度浮点超越函数的特殊函数单元,
  • 2 个(6.0)或 4 个(6.1 和 6.2)warp 调度器。

当一个 SM 被指定执行 warp 时,它首先将它们分配给它的调度程序。然后,在每个指令发布时间,每个调度程序都会为其分配的经准备好执行的warp之一发布一条指令(如果有的话)。

SM 具有:

  • 由所有功能单元共享的只读常量缓存,可加快从驻留在设备内存中的常量内存空间的读取速度,
  • 一个统一的 L1/纹理缓存,用于从大小为 24 KB(6.0 和 6.2)或 48 KB(6.1)的全局内存中读取,
  • 大小为 64 KB(6.0 和 6.2)或 96 KB(6.1)的共享内存。

纹理单元也使用统一的 L1/纹理缓存,实现纹理和表面内存中提到的各种寻址模式和数据过滤。

还有一个由所有 SM 共享的 L2 缓存,用于缓存对本地或全局内存的访问,包括临时寄存器溢出。应用程序可以通过检查 l2CacheSize 设备属性来查询 L2 缓存大小(请参阅设备枚举)。

缓存行为(例如,读取是否缓存在统一的 L1/纹理缓存和 L2 中或仅在 L2 中)可以使用加载指令的修饰符在每次访问的基础上进行部分配置。

K.5.2. Global Memory

全局内存的行为方式与计算能力 5.x 的设备相同(请参阅全局内存)。

K.5.3. Shared Memory

共享内存的行为方式与计算能力 5.x 的设备相同(请参阅共享内存)。

K.6. Compute Capability 7.x

一个 SM 包括:

  • 64 个 FP32 内核,用于单精度算术运算,
  • 32 个用于双精度算术运算的 FP64 内核,
  • 64 个 INT32 内核用于整数数学,
  • 8 个混合精度张量核,用于深度学习矩阵算术
  • 16个单精度浮点超越函数的特殊函数单元,
  • 4个warp调度器。

一个 SM 在它的调度器之间静态地分配它的 warp。然后,在每个指令发布时间,每个调度程序都会为其分配的warp准备好执行的warp之一发布一条指令(如果有的话)。

SM 具有:

  • 由所有功能单元共享的只读常量缓存,可加快从驻留在设备内存中的常量内存空间的读取速度,
  • 一个统一的数据缓存和共享内存,总大小为 128 KB (Volta) 或 96 KB (Turing)。

共享内存从统一的数据缓存中分割出来,并且可以配置为各种大小(请参阅共享内存。)剩余的数据缓存用作 L1 缓存,也由实现上述各种寻址和数据过滤模式的纹理单元使用在纹理和表面内存。

K.6.2. Independent Thread Scheduling

1.Volta 架构在 warp 中的线程之间引入了独立线程调度,启用了以前不可用的内部 warp 同步模式,并在移植 CPU 代码时简化了代码更改。 但是,如果开发人员对先前硬件架构的warp同步性做出假设,这可能会导致参与执行代码的线程集与预期的完全不同。

以下是 Volta 安全代码的关注代码模式和建议的纠正措施。

对于使用 warp 内在函数(__shfl*、__any、__all、__ballot)的应用程序,开发人员有必要将他们的代码移植到具有 *_sync 后缀的新的、安全的同步对应方。 新的warp内在函数采用线程掩码,明确定义哪些通道(warp的线程)必须参与warp内在函数。 有关详细信息,请参阅 Warp Vote 函数和 Warp Shuffle 函数。

由于内在函数可用于 CUDA 9.0+,因此(如有必要)可以使用以下预处理器宏有条件地执行代码:

1
2
3
#if defined(CUDART_VERSION) && CUDART_VERSION >= 9000
// *_sync intrinsic
#endif

这些内在函数可用于所有架构,而不仅仅是 Volta 或 Turing,并且在大多数情况下,单个代码库就足以满足所有架构的需求。 但是请注意,对于 Pascal 和更早的架构,mask 中的所有线程在收敛时必须执行相同的 warp 内在指令,并且 mask 中所有值的并集必须等于 warp 的活动掩码。 以下代码模式在 Volta 上有效,但在 Pascal 或更早的架构上无效。

1
2
3
4
5
6
7
8
9
if (tid % warpSize < 16) {
...
float swapped = __shfl_xor_sync(0xffffffff, val, 16);
...
} else {
...
float swapped = __shfl_xor_sync(0xffffffff, val, 16);
...
}

__ballot(1) 的替代品是 __activemask()。 请注意,即使在单个代码路径中,warp 中的线程也可以发散。 因此,__activemask()__ballot(1) 可能只返回当前代码路径上的线程子集。 以下无效代码示例在 data[i] 大于阈值时将输出的位 i 设置为 1。 __activemask() 用于尝试启用 dataLen 不是 32 的倍数的情况。

1
2
3
4
5
6
7
8
9
10
// Sets bit in output[] to 1 if the correspond element in data[i]
// is greater than ‘threshold’, using 32 threads in a warp.

for (int i = warpLane; i < dataLen; i += warpSize) {
unsigned active = __activemask();
unsigned bitPack = __ballot_sync(active, data[i] > threshold);
if (warpLane == 0) {
output[i / 32] = bitPack;
}
}

此代码无效,因为 CUDA 不保证warp只会在循环条件下发散。 当由于其他原因发生分歧时,将由 warp 中的不同线程子集为相同的 32 位输出元素计算冲突的结果。 正确的代码可能会使用非发散循环条件和 __ballot_sync() 来安全地枚举 warp 中参与阈值计算的线程集,如下所示。

1
2
3
4
5
6
7
8
9
for (int i = warpLane; i - warpLane < dataLen; i += warpSize) {
unsigned active = __ballot_sync(0xFFFFFFFF, i < dataLen);
if (i < dataLen) {
unsigned bitPack = __ballot_sync(active, data[i] > threshold);
if (warpLane == 0) {
output[i / 32] = bitPack;
}
}
}

Discovery Pattern 演示了 __activemask() 的有效用例。

2.如果应用程序有warp同步代码,他们将需要在通过全局或共享内存在线程之间交换数据的任何步骤之间插入新的 __syncwarp() warp范围屏障同步指令。 假设代码以锁步方式执行,或者来自不同线程的读/写在没有同步的情况下在 warp 中可见是无效的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
__shared__ float s_buff[BLOCK_SIZE];
s_buff[tid] = val;
__syncthreads();

// Inter-warp reduction
for (int i = BLOCK_SIZE / 2; i >= 32; i /= 2) {
if (tid < i) {
s_buff[tid] += s_buff[tid+i];
}
__syncthreads();
}

// Intra-warp reduction
// Butterfly reduction simplifies syncwarp mask
if (tid < 32) {
float temp;
temp = s_buff[tid ^ 16]; __syncwarp();
s_buff[tid] += temp; __syncwarp();
temp = s_buff[tid ^ 8]; __syncwarp();
s_buff[tid] += temp; __syncwarp();
temp = s_buff[tid ^ 4]; __syncwarp();
s_buff[tid] += temp; __syncwarp();
temp = s_buff[tid ^ 2]; __syncwarp();
s_buff[tid] += temp; __syncwarp();
}

if (tid == 0) {
*output = s_buff[0] + s_buff[1];
}
__syncthreads();

3.尽管 __syncthreads() 一直被记录为同步线程块中的所有线程,但 Pascal 和以前的体系结构只能在 warp 级别强制同步。 在某些情况下,只要每个 warp 中至少有一些线程到达屏障,这就会允许屏障成功,而不会被每个线程执行。 从 Volta 开始,CUDA 内置的 __syncthreads() 和 PTX 指令 bar.sync(及其派生类)在每个线程中强制执行,因此在块中所有未退出的线程到达之前不会成功。 利用先前行为的代码可能会死锁,必须进行修改以确保所有未退出的线程都到达屏障。

cuda-memcheck 提供的 racechecksynccheck 工具可以帮助定位第 2 点和第 3 点的违规行为。

为了在实现上述纠正措施的同时帮助迁移,开发人员可以选择加入不支持独立线程调度的 Pascal 调度模型。 有关详细信息,请参阅应用程序兼容性

K.6.3. Global Memory

全局内存的行为方式与计算能力 5.x 的设备相同(请参阅全局内存)。

K.6.4. Shared Memory

与 Kepler 架构类似,为共享内存保留的统一数据缓存的数量可以在每个内核的基础上进行配置。对于 Volta 架构(计算能力 7.0),统一数据缓存大小为 128 KB,共享内存容量可设置为 0、8、16、32、64 或 96 KB。对于图灵架构(计算能力 7.5),统一数据缓存大小为 96 KB,共享内存容量可以设置为 32 KB 或 64 KB。与 Kepler 不同,驱动程序自动为每个内核配置共享内存容量以避免共享内存占用瓶颈,同时还允许在可能的情况下与已启动的内核并发执行。在大多数情况下,驱动程序的默认行为应该提供最佳性能。

因为驱动程序并不总是知道全部工作负载,所以有时应用程序提供有关所需共享内存配置的额外提示很有用。例如,很少或没有使用共享内存的内核可能会请求更大的分割,以鼓励与需要更多共享内存的后续内核并发执行。新的 cudaFuncSetAttribute() API 允许应用程序设置首选共享内存容量或分割,作为支持的最大共享内存容量的百分比(Volta 为 96 KB,Turing 为 64 KB)。

与 Kepler 引入的传统 cudaFuncSetCacheConfig() API 相比,cudaFuncSetAttribute() 放宽了首选共享容量的执行。旧版 API 将共享内存容量视为内核启动的硬性要求。结果,具有不同共享内存配置的交错内核将不必要地序列化共享内存重新配置之后的启动。使用新 API,分割被视为提示。如果需要执行功能或避免颠簸,驱动程序可以选择不同的配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Device code
__global__ void MyKernel(...)
{
__shared__ float buffer[BLOCK_DIM];
...
}

// Host code
int carveout = 50; // prefer shared memory capacity 50% of maximum
// Named Carveout Values:
// carveout = cudaSharedmemCarveoutDefault; // (-1)
// carveout = cudaSharedmemCarveoutMaxL1; // (0)
// carveout = cudaSharedmemCarveoutMaxShared; // (100)
cudaFuncSetAttribute(MyKernel, cudaFuncAttributePreferredSharedMemoryCarveout, carveout);
MyKernel <<<gridDim, BLOCK_DIM>>>(...);

除了整数百分比之外,还提供了几个方便的枚举,如上面的代码注释中所列。 如果选择的整数百分比不完全映射到支持的容量(SM 7.0 设备支持 0、8、16、32、64 或 96 KB 的共享容量),则使用下一个更大的容量。 例如,在上面的示例中,最大 96 KB 的 50% 是 48 KB,这不是受支持的共享内存容量。 因此,首选项向上舍入为 64 KB。

计算能力 7.x 设备允许单个线程块来处理共享内存的全部容量:Volta 上为 96 KB,Turing 上为 64 KB。 依赖于每个块超过 48 KB 的共享内存分配的内核是特定于体系结构的,因此它们必须使用动态共享内存(而不是静态大小的数组),并且需要使用 cudaFuncSetAttribute() 显式选择加入,如下所示。

1
2
3
4
5
6
7
8
9
10
// Device code
__global__ void MyKernel(...)
{
...
}

// Host code
int maxbytes = 98304; // 96 KB
cudaFuncSetAttribute(MyKernel, cudaFuncAttributeMaxDynamicSharedMemorySize, maxbytes);
MyKernel <<<gridDim, blockDim>>>(...);

否则,共享内存的行为方式与计算能力 5.x 的设备相同(请参阅共享内存)。

K.7. Compute Capability 8.x

K.7.1. Architecture

流式多处理器 (SM) 包括:

  • 计算能力为 8.0 的设备中用于单精度算术运算的 64 个 FP32 内核和计算能力为 8.6 的设备中的 128 个 FP32 内核,
  • 计算能力 8.0 的设备中用于双精度算术运算的 32 个 FP64 内核和计算能力 8.6 的设备中的 2 个 FP64 内核
  • 64 个 INT32 内核用于整数数学,
  • 4 个混合精度第三代张量核心,支持半精度 (fp16)、__nv_bfloat16、tf32、子字节和双精度 (fp64) 矩阵运算(详见 Warp 矩阵函数),
  • 16个单精度浮点超越函数的特殊函数单元,
  • 4个warp调度器。

一个 SM 在它的调度器之间静态地分配它的 warp。然后,在每个指令发布时间,每个调度程序都会为其分配的warp准备好执行的warp之一发布一条指令(如果有的话)。

SM 具有:

  • 由所有功能单元共享的只读常量缓存,可加快从驻留在设备内存中的常量内存空间的读取速度,
  • 一个统一的数据缓存和共享内存,总大小为 192 KB,用于计算能力 8.0 的设备(1.5 倍 Volta 的 128 KB 容量)和 128 KB,用于计算能力 8.6 的设备。

共享内存从统一数据缓存中分割出来,并且可以配置为各种大小(请参阅共享内存部分)。剩余的数据缓存用作 L1 缓存,也由实现纹理和表面内存中提到的各种寻址和数据过滤模式的纹理单元使用。

K.7.2. Global Memory

全局内存的行为方式与计算能力 5.x 的设备相同(请参阅全局内存)。

K.7.3. Shared Memory

Volta 架构类似,为共享内存保留的统一数据缓存的数量可在每个内核的基础上进行配置。对于 NVIDIA Ampere GPU 架构,计算能力为 8.0 的设备的统一数据缓存大小为 192 KB,计算能力为 8.6 的设备为 128 KB。对于计算能力为 8.0 的设备,共享内存容量可以设置为 0、8、16、32、64、100、132 或 164 KB,对于计算能力的设备,可以设置为 0、8、16、32、64 或 100 KB 8.6.

应用程序可以使用 cudaFuncSetAttribute() 设置carveout,即首选共享内存容量。

cudaFuncSetAttribute(kernel_name, cudaFuncAttributePreferredSharedMemoryCarveout, carveout);

API 可以分别指定计算能力为 8.0 的设备的最大支持共享内存容量 164 KB 和计算能力为 8.6 的设备的 100 KB 的整数百分比,或以下值之一:cudaSharedmemCarveoutDefault, cudaSharedmemCarveoutMaxL1 ,或 cudaSharedmemCarveoutMaxShared。使用百分比时,分拆四舍五入到最接近的受支持共享内存容量。例如,对于计算能力为 8.0 的设备,50% 将映射到 100 KB 的分割,而不是 82 KB 的分割。设置 cudaFuncAttributePreferredSharedMemoryCarveout 被驱动程序视为提示;如果需要,驱动程序可以选择不同的配置。

计算能力 8.0 的设备允许单个线程块寻址多达 163 KB 的共享内存,而计算能力 8.6 的设备允许多达 99 KB 的共享内存。依赖于每个块超过 48 KB 的共享内存分配的内核是特定于体系结构的,并且必须使用动态共享内存而不是静态大小的共享内存数组。这些内核需要通过使用 cudaFuncSetAttribute() 来设置 cudaFuncAttributeMaxDynamicSharedMemorySize 来明确选择加入;请参阅 Volta 架构的共享内存

请注意,每个线程块的最大共享内存量小于每个 SM 可用的最大共享内存分区。未提供给线程块的 1 KB 共享内存保留给系统使用。

附录L CUDA底层驱动API

本附录假定您了解 CUDA 运行时中描述的概念。

驱动程序 API 在 cuda 动态库(cuda.dllcuda.so)中实现,该库在安装设备驱动程序期间复制到系统上。 它的所有入口点都以 cu 为前缀。

它是一个基于句柄的命令式 API:大多数对象都由不透明的句柄引用,这些句柄可以指定给函数来操作对象。

驱动程序 API 中可用的对象汇总在下表中。

Table 16. Objects Available in the CUDA Driver API
Object Handle Description
Device CUdevice CUDA-enabled device
Context CUcontext Roughly equivalent to a CPU process
Module CUmodule Roughly equivalent to a dynamic library
Function CUfunction Kernel
Heap memory CUdeviceptr Pointer to device memory
CUDA array CUarray Opaque container for one-dimensional or two-dimensional data on the device, readable via texture or surface references
Texture reference CUtexref Object that describes how to interpret texture memory data
Surface reference CUsurfref Object that describes how to read or write CUDA arrays
Stream CUstream Object that describes a CUDA stream
Event CUevent Object that describes a CUDA event

在调用驱动程序 API 的任何函数之前,必须使用 cuInit() 初始化驱动程序 API。 然后必须创建一个附加到特定设备的 CUDA 上下文,并使其成为当前调用主机线程,如上下文中所述。

在 CUDA 上下文中,内核作为 PTX 或二进制对象由主机代码显式加载,如模块中所述。 因此,用 C++ 编写的内核必须单独编译成 PTX 或二进制对象。 内核使用 API 入口点启动,如内核执行中所述。

任何想要在未来设备架构上运行的应用程序都必须加载 PTX,而不是二进制代码。 这是因为二进制代码是特定于体系结构的,因此与未来的体系结构不兼容,而 PTX 代码在加载时由设备驱动程序编译为二进制代码。

以下是使用驱动程序 API 编写的内核示例的主机代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
int main()
{
int N = ...;
size_t size = N * sizeof(float);

// Allocate input vectors h_A and h_B in host memory
float* h_A = (float*)malloc(size);
float* h_B = (float*)malloc(size);

// Initialize input vectors
...

// Initialize
cuInit(0);

// Get number of devices supporting CUDA
int deviceCount = 0;
cuDeviceGetCount(&deviceCount);
if (deviceCount == 0) {
printf("There is no device supporting CUDA.\n");
exit (0);
}

// Get handle for device 0
CUdevice cuDevice;
cuDeviceGet(&cuDevice, 0);

// Create context
CUcontext cuContext;
cuCtxCreate(&cuContext, 0, cuDevice);

// Create module from binary file
CUmodule cuModule;
cuModuleLoad(&cuModule, "VecAdd.ptx");

// Allocate vectors in device memory
CUdeviceptr d_A;
cuMemAlloc(&d_A, size);
CUdeviceptr d_B;
cuMemAlloc(&d_B, size);
CUdeviceptr d_C;
cuMemAlloc(&d_C, size);

// Copy vectors from host memory to device memory
cuMemcpyHtoD(d_A, h_A, size);
cuMemcpyHtoD(d_B, h_B, size);

// Get function handle from module
CUfunction vecAdd;
cuModuleGetFunction(&vecAdd, cuModule, "VecAdd");

// Invoke kernel
int threadsPerBlock = 256;
int blocksPerGrid =
(N + threadsPerBlock - 1) / threadsPerBlock;
void* args[] = { &d_A, &d_B, &d_C, &N };
cuLaunchKernel(vecAdd,
blocksPerGrid, 1, 1, threadsPerBlock, 1, 1,
0, 0, args, 0);

...
}

完整的代码可以在 vectorAddDrv CUDA 示例中找到。

L.1. Context

CUDA 上下文类似于 CPU 进程。驱动 API 中执行的所有资源和操作都封装在 CUDA 上下文中,当上下文被销毁时,系统会自动清理这些资源。除了模块和纹理或表面引用等对象外,每个上下文都有自己独特的地址空间。因此,来自不同上下文的 CUdeviceptr 值引用不同的内存位置。

主机线程一次可能只有一个设备上下文当前。当使用 cuCtxCreate() 创建上下文时,它对调用主机线程是当前的。如果有效上下文不是线程当前的,则在上下文中操作的 CUDA 函数(大多数不涉及设备枚举或上下文管理的函数)将返回 CUDA_ERROR_INVALID_CONTEXT

每个主机线程都有一堆当前上下文。 cuCtxCreate() 将新上下文推送到堆栈顶部。可以调用 cuCtxPopCurrent() 将上下文与主机线程分离。然后上下文是“浮动的”,并且可以作为任何主机线程的当前上下文推送。 cuCtxPopCurrent() 还会恢复先前的当前上下文(如果有)。

还为每个上下文维护使用计数。 cuCtxCreate() 创建使用计数为 1 的上下文。cuCtxAttach() 增加使用计数,而 cuCtxDetach() 减少使用计数。当调用 cuCtxDetach()cuCtxDestroy() 时使用计数变为 0,上下文将被销毁。

驱动程序 API 可与运行时互操作,并且可以通过 cuDevicePrimaryCtxRetain() 从驱动程序 API 访问由运行时管理的主上下文(参见初始化)。

使用计数有助于在相同上下文中运行的第三方编写的代码之间的互操作性。例如,如果加载三个库以使用相同的上下文,则每个库将调用 cuCtxAttach() 来增加使用计数,并在库使用上下文完成时调用 cuCtxDetach() 来减少使用计数。对于大多数库,预计应用程序会在加载或初始化库之前创建上下文;这样,应用程序可以使用自己的启发式方法创建上下文,并且库只需对传递给它的上下文进行操作。希望创建自己的上下文的库(可能会或可能没有创建自己的上下文的 API 客户端不知道)将使用 cuCtxPushCurrent()cuCtxPopCurrent(),如下图所示。

L.2. Module

模块是设备代码和数据的动态可加载包,类似于 Windows 中的 DLL,由 nvcc 输出(请参阅使用 NVCC 编译)。 所有符号的名称,包括函数、全局变量和纹理或表面引用,都在模块范围内维护,以便独立第三方编写的模块可以在相同的 CUDA 上下文中互操作。

此代码示例加载一个模块并检索某个内核的句柄:

1
2
3
4
CUmodule cuModule;
cuModuleLoad(&cuModule, "myModule.ptx");
CUfunction myKernel;
cuModuleGetFunction(&myKernel, cuModule, "MyKernel");

此代码示例从 PTX 代码编译和加载新模块并解析编译错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#define BUFFER_SIZE 8192
CUmodule cuModule;
CUjit_option options[3];
void* values[3];
char* PTXCode = "some PTX code";
char error_log[BUFFER_SIZE];
int err;
options[0] = CU_JIT_ERROR_LOG_BUFFER;
values[0] = (void*)error_log;
options[1] = CU_JIT_ERROR_LOG_BUFFER_SIZE_BYTES;
values[1] = (void*)BUFFER_SIZE;
options[2] = CU_JIT_TARGET_FROM_CUCONTEXT;
values[2] = 0;
err = cuModuleLoadDataEx(&cuModule, PTXCode, 3, options, values);
if (err != CUDA_SUCCESS)
printf("Link error:\n%s\n", error_log);

此代码示例从多个 PTX 代码编译、链接和加载新模块,并解析链接和编译错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#define BUFFER_SIZE 8192
CUmodule cuModule;
CUjit_option options[6];
void* values[6];
float walltime;
char error_log[BUFFER_SIZE], info_log[BUFFER_SIZE];
char* PTXCode0 = "some PTX code";
char* PTXCode1 = "some other PTX code";
CUlinkState linkState;
int err;
void* cubin;
size_t cubinSize;
options[0] = CU_JIT_WALL_TIME;
values[0] = (void*)&walltime;
options[1] = CU_JIT_INFO_LOG_BUFFER;
values[1] = (void*)info_log;
options[2] = CU_JIT_INFO_LOG_BUFFER_SIZE_BYTES;
values[2] = (void*)BUFFER_SIZE;
options[3] = CU_JIT_ERROR_LOG_BUFFER;
values[3] = (void*)error_log;
options[4] = CU_JIT_ERROR_LOG_BUFFER_SIZE_BYTES;
values[4] = (void*)BUFFER_SIZE;
options[5] = CU_JIT_LOG_VERBOSE;
values[5] = (void*)1;
cuLinkCreate(6, options, values, &linkState);
err = cuLinkAddData(linkState, CU_JIT_INPUT_PTX,
(void*)PTXCode0, strlen(PTXCode0) + 1, 0, 0, 0, 0);
if (err != CUDA_SUCCESS)
printf("Link error:\n%s\n", error_log);
err = cuLinkAddData(linkState, CU_JIT_INPUT_PTX,
(void*)PTXCode1, strlen(PTXCode1) + 1, 0, 0, 0, 0);
if (err != CUDA_SUCCESS)
printf("Link error:\n%s\n", error_log);
cuLinkComplete(linkState, &cubin, &cubinSize);
printf("Link completed in %fms. Linker Output:\n%s\n", walltime, info_log);
cuModuleLoadData(cuModule, cubin);
cuLinkDestroy(linkState);

完整的代码可以在 ptxjit CUDA 示例中找到。

L.3. Kernel Execution

cuLaunchKernel() 启动具有给定执行配置的内核。

参数可以作为指针数组(在 cuLaunchKernel() 的最后一个参数旁边)传递,其中第 n 个指针对应于第 n 个参数并指向从中复制参数的内存区域,或者作为额外选项之一( cuLaunchKernel()) 的最后一个参数。

当参数作为额外选项(CU_LAUNCH_PARAM_BUFFER_POINTER 选项)传递时,它们作为指向单个缓冲区的指针传递,在该缓冲区中,通过匹配设备代码中每个参数类型的对齐要求,参数被假定为彼此正确偏移。

表 4 列出了内置向量类型的设备代码中的对齐要求。对于所有其他基本类型,设备代码中的对齐要求与主机代码中的对齐要求相匹配,因此可以使用 __alignof() 获得。唯一的例外是当宿主编译器在一个字边界而不是两个字边界上对齐 double 和 long long(在 64 位系统上为 long)(例如,使用 gcc 的编译标志 -mno-align-double ) 因为在设备代码中,这些类型总是在两个字的边界上对齐。

CUdeviceptr是一个整数,但是代表一个指针,所以它的对齐要求是__alignof(void*)

以下代码示例使用宏 (ALIGN_UP()) 调整每个参数的偏移量以满足其对齐要求,并使用另一个宏 (ADD_TO_PARAM_BUFFER()) 将每个参数添加到传递给 CU_LAUNCH_PARAM_BUFFER_POINTER 选项的参数缓冲区。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#define ALIGN_UP(offset, alignment) \
(offset) = ((offset) + (alignment) - 1) & ~((alignment) - 1)

char paramBuffer[1024];
size_t paramBufferSize = 0;

#define ADD_TO_PARAM_BUFFER(value, alignment) \
do { \
paramBufferSize = ALIGN_UP(paramBufferSize, alignment); \
memcpy(paramBuffer + paramBufferSize, \
&(value), sizeof(value)); \
paramBufferSize += sizeof(value); \
} while (0)

int i;
ADD_TO_PARAM_BUFFER(i, __alignof(i));
float4 f4;
ADD_TO_PARAM_BUFFER(f4, 16); // float4's alignment is 16
char c;
ADD_TO_PARAM_BUFFER(c, __alignof(c));
float f;
ADD_TO_PARAM_BUFFER(f, __alignof(f));
CUdeviceptr devPtr;
ADD_TO_PARAM_BUFFER(devPtr, __alignof(devPtr));
float2 f2;
ADD_TO_PARAM_BUFFER(f2, 8); // float2's alignment is 8

void* extra[] = {
CU_LAUNCH_PARAM_BUFFER_POINTER, paramBuffer,
CU_LAUNCH_PARAM_BUFFER_SIZE, &paramBufferSize,
CU_LAUNCH_PARAM_END
};
cuLaunchKernel(cuFunction,
blockWidth, blockHeight, blockDepth,
gridWidth, gridHeight, gridDepth,
0, 0, 0, extra);

结构的对齐要求等于其字段的对齐要求的最大值。 因此,包含内置向量类型 CUdeviceptr 或未对齐的 doublelong long 的结构的对齐要求可能在设备代码和主机代码之间有所不同。 这种结构也可以用不同的方式填充。 例如,以下结构在主机代码中根本不填充,但在设备代码中填充了字段 f 之后的 12 个字节,因为字段 f4 的对齐要求是 16。

1
2
3
4
typedef struct {
float f;
float4 f4;
} myStruct;

L.4. Interoperability between Runtime and Driver APIs

应用程序可以将运行时 API 代码与驱动程序 API 代码混合。

如果通过驱动程序 API 创建上下文并使其成为当前上下文,则后续运行时调用将获取此上下文,而不是创建新上下文。

如果运行时已初始化(如 CUDA 运行时中提到的那样),cuCtxGetCurrent() 可用于检索在初始化期间创建的上下文。 后续驱动程序 API 调用可以使用此上下文。

从运行时隐式创建的上下文称为主上下文(请参阅初始化)。 它可以通过具有主要上下文管理功能的驱动程序 API 进行管理。

可以使用任一 API 分配和释放设备内存。 CUdeviceptr 可以转换为常规指针,反之亦然:

1
2
3
4
5
6
7
8
9
10
CUdeviceptr devPtr;
float* d_data;

// Allocation using driver API
cuMemAlloc(&devPtr, size);
d_data = (float*)devPtr;

// Allocation using runtime API
cudaMalloc(&d_data, size);
devPtr = (CUdeviceptr)d_data;

特别是,这意味着使用驱动程序 API 编写的应用程序可以调用使用运行时 API 编写的库(例如 cuFFT、cuBLAS…)。

参考手册的设备和版本管理部分的所有功能都可以互换使用。

L.5. Driver Entry Point Access

L.5.1. Introduction

驱动程序入口点访问 API 提供了一种检索 CUDA 驱动程序函数地址的方法。 从 CUDA 11.3 开始,用户可以使用从这些 API 获得的函数指针调用可用的 CUDA 驱动程序 API。

这些 API 提供的功能类似于它们的对应物,POSIX 平台上的 dlsym 和 Windows 上的 GetProcAddress。 提供的 API 将允许用户:

  • 使用 CUDA 驱动程序 API 检索驱动程序函数的地址。

  • 使用 CUDA 运行时 API 检索驱动程序函数的地址。

  • 请求 CUDA 驱动程序函数的每线程默认流版本。 有关更多详细信息,请参阅检索每个线程的默认流版本

  • 使用较新的驱动程序访问旧工具包上的新 CUDA 功能。

L.5.2. Driver Function Typedefs

为了帮助检索 CUDA 驱动程序 API 入口点,CUDA 工具包提供对包含所有 CUDA 驱动程序 API 的函数指针定义的头文件的访问。 这些头文件与 CUDA Toolkit 一起安装,并且在工具包的 include/ 目录中可用。 下表总结了包含每个 CUDA API 头文件的 typedef 的头文件。

Table 17. Typedefs header files for CUDA driver APIs
API header file API Typedef header file
cuda.h cudaTypedefs.h
cudaGL.h cudaGLTypedefs.h
cudaProfiler.h cudaProfilerTypedefs.h
cudaVDPAU.h cudaVDPAUTypedefs.h
cudaEGL.h cudaEGLTypedefs.h
cudaD3D9.h cudaD3D9Typedefs.h
cudaD3D10.h cudaD3D10Typedefs.h
cudaD3D11.h cudaD3D11Typedefs.h

上面的头文件本身并没有定义实际的函数指针; 他们为函数指针定义了 typedef。 例如,cudaTypedefs.h 具有驱动 API cuMemAlloc 的以下 typedef

1
2
typedef CUresult (CUDAAPI *PFN_cuMemAlloc_v3020)(CUdeviceptr_v2 *dptr, size_t bytesize);
typedef CUresult (CUDAAPI *PFN_cuMemAlloc_v2000)(CUdeviceptr_v1 *dptr, unsigned int bytesize);

CUDA 驱动程序符号具有基于版本的命名方案,其名称中带有 _v* 扩展名,但第一个版本除外。 当特定 CUDA 驱动程序 API 的签名或语义发生变化时,我们会增加相应驱动程序符号的版本号。 对于 cuMemAlloc 驱动程序 API,第一个驱动程序符号名称是 cuMemAlloc,下一个符号名称是 cuMemAlloc_v2。 CUDA 2.0 (2000) 中引入的第一个版本的 typedefPFN_cuMemAlloc_v2000。 CUDA 3.2 (3020) 中引入的下一个版本的 typedefPFN_cuMemAlloc_v3020

typedef 可用于更轻松地在代码中定义适当类型的函数指针:

1
2
PFN_cuMemAlloc_v3020 pfn_cuMemAlloc_v2;
PFN_cuMemAlloc_v2000 pfn_cuMemAlloc_v1;

如果用户对 API 的特定版本感兴趣,则上述方法更可取。 此外,头文件中包含所有驱动程序符号的最新版本的预定义宏,这些驱动程序符号在安装的 CUDA 工具包发布时可用; 这些 typedef 没有 _v* 后缀。 对于 CUDA 11.3 工具包,cuMemAlloc_v2 是最新版本,所以我们也可以定义它的函数指针如下:

1
PFN_cuMemAlloc pfn_cuMemAlloc;

L.5.3. Driver Function Retrieval

使用驱动程序入口点访问 API 和适当的 typedef,我们可以获得指向任何 CUDA 驱动程序 API 的函数指针。

L.5.3.1. Using the driver API

驱动程序 API 需要 CUDA 版本作为参数来获取请求的驱动程序符号的 ABI 兼容版本。 CUDA 驱动程序 API 有一个以 _v* 扩展名表示的按功能 ABI。 例如,考虑 cudaTypedefs.hcuStreamBeginCapture 的版本及其对应的 typedef

1
2
3
4
5
6
7
// cuda.h
CUresult CUDAAPI cuStreamBeginCapture(CUstream hStream);
CUresult CUDAAPI cuStreamBeginCapture_v2(CUstream hStream, CUstreamCaptureMode mode);

// cudaTypedefs.h
typedef CUresult (CUDAAPI *PFN_cuStreamBeginCapture_v10000)(CUstream hStream);
typedef CUresult (CUDAAPI *PFN_cuStreamBeginCapture_v10010)(CUstream hStream, CUstreamCaptureMode mode);

从上述代码片段中的typedefs,版本后缀_v10000_v10010表示上述API分别在CUDA 10.0CUDA 10.1中引入。

1
2
3
4
5
6
7
8
9
10
#include <cudaTypedefs.h>

// Declare the entry points for cuStreamBeginCapture
PFN_cuStreamBeginCapture_v10000 pfn_cuStreamBeginCapture_v1;
PFN_cuStreamBeginCapture_v10010 pfn_cuStreamBeginCapture_v2;

// Get the function pointer to the cuStreamBeginCapture driver symbol
cuGetProcAddress("cuStreamBeginCapture", &pfn_cuStreamBeginCapture_v1, 10000, CU_GET_PROC_ADDRESS_DEFAULT);
// Get the function pointer to the cuStreamBeginCapture_v2 driver symbol
cuGetProcAddress("cuStreamBeginCapture", &pfn_cuStreamBeginCapture_v2, 10010, CU_GET_PROC_ADDRESS_DEFAULT);

参考上面的代码片段,要检索到驱动程序 API cuStreamBeginCapture 的 _v1 版本的地址,CUDA 版本参数应该正好是 10.0 (10000)。同样,用于检索 _v2 版本 API 的地址的 CUDA 版本应该是 10.1 (10010)。为检索特定版本的驱动程序 API 指定更高的 CUDA 版本可能并不总是可移植的。例如,在此处使用 11030 仍会返回 _v2 符号,但如果在 CUDA 11.3 中发布假设的 _v3 版本,则当与 CUDA 11.3 驱动程序配对时,cuGetProcAddress API 将开始返回较新的 _v3 符号。由于 _v2 _v3 符号的 ABI 和函数签名可能不同,使用用于 _v2 符号的 _v10010 typedef 调用 _v3 函数将表现出未定义的行为。

要检索给定 CUDA 工具包的驱动程序 API 的最新版本,我们还可以指定 CUDA_VERSION 作为版本参数,并使用未版本化的 typedef 来定义函数指针。由于 _v2 是 CUDA 11.3 中驱动程序 API cuStreamBeginCapture 的最新版本,因此下面的代码片段显示了检索它的不同方法。

1
2
3
4
5
6
7
8
9
10
11
// Assuming we are using CUDA 11.3 Toolkit

#include <cudaTypedefs.h>

// Declare the entry point
PFN_cuStreamBeginCapture pfn_cuStreamBeginCapture_latest;

// Intialize the entry point. Specifying CUDA_VERSION will give the function pointer to the
// cuStreamBeginCapture_v2 symbol since it is latest version on CUDA 11.3.
cuGetProcAddress("cuStreamBeginCapture", &pfn_cuStreamBeginCapture_latest, CUDA_VERSION, CU_GET_PROC_ADDRESS_DEFAULT);

请注意,请求具有无效 CUDA 版本的驱动程序 API 将返回错误 CUDA_ERROR_NOT_FOUND。 在上面的代码示例中,传入小于 10000 (CUDA 10.0) 的版本将是无效的。

L.5.3.2. Using the runtime API

运行时 API 使用 CUDA 运行时版本来获取请求的驱动程序符号的 ABI 兼容版本。 在下面的代码片段中,所需的最低 CUDA 运行时版本将是 CUDA 11.2,因为当时引入了 cuMemAllocAsync

1
2
3
4
5
6
7
8
9
10
#include <cudaTypedefs.h>

// Declare the entry point
PFN_cuMemAllocAsync pfn_cuMemAllocAsync;

// Intialize the entry point. Assuming CUDA runtime version >= 11.2
cudaGetDriverEntryPoint("cuMemAllocAsync", &pfn_cuMemAllocAsync, cudaEnableDefault);

// Call the entry point
pfn_cuMemAllocAsync(...);

L.5.3.3. Retrieve per-thread default stream versions

一些 CUDA 驱动程序 API 可以配置为具有默认流或每线程默认流语义。具有每个线程默认流语义的驱动程序 API 在其名称中以 _ptsz_ptds 为后缀。例如,cuLaunchKernel 有一个名为 cuLaunchKernel_ptsz 的每线程默认流变体。使用驱动程序入口点访问 API,用户可以请求驱动程序 API cuLaunchKernel 的每线程默认流版本,而不是默认流版本。为默认流或每线程默认流语义配置 CUDA 驱动程序 API 会影响同步行为。更多详细信息可以在这里找到。

驱动API的默认流或每线程默认流版本可以通过以下方式之一获得:

  • 使用编译标志 --default-stream per-thread 或定义宏 CUDA_API_PER_THREAD_DEFAULT_STREAM 以获取每个线程的默认流行为。
  • 分别使用标志 CU_GET_PROC_ADDRESS_LEGACY_STREAM/cudaEnableLegacyStreamCU_GET_PROC_ADDRESS_PER_THREAD_DEFAULT_STREAM/cudaEnablePerThreadDefaultStream 强制默认流或每个线程的默认流行为。

L.5.3.4. Access new CUDA features

始终建议安装最新的 CUDA 工具包以访问新的 CUDA 驱动程序功能,但如果出于某种原因,用户不想更新或无法访问最新的工具包,则可以使用 API 来访问新的 CUDA 功能 只有更新的 CUDA 驱动程序。 为了讨论,让我们假设用户使用 CUDA 11.3,并希望使用 CUDA 12.0 驱动程序中提供的新驱动程序 API cuFoo。 下面的代码片段说明了这个用例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int main()
{
// Assuming we have CUDA 12.0 driver installed.

// Manually define the prototype as cudaTypedefs.h in CUDA 11.3 does not have the cuFoo typedef
typedef CUresult (CUDAAPI *PFN_cuFoo)(...);
PFN_cuFoo pfn_cuFoo = NULL;

// Get the address for cuFoo API using cuGetProcAddress. Specify CUDA version as
// 12000 since cuFoo was introduced then or get the driver version dynamically
// using cuDriverGetVersion
int driverVersion;
cuDriverGetVersion(&driverVersion);
cuGetProcAddress("cuFoo", &pfn_cuFoo, driverVersion, CU_GET_PROC_ADDRESS_DEFAULT);

if (pfn_cuFoo) {
pfn_cuFoo(...);
}
else {
printf("Cannot retrieve the address to cuFoo. Check if the latest driver for CUDA 12.0 is installed.\n");
assert(0);
}

// rest of code here

}
Table 18. CUDA Environment Variables
Variable Values Description
Device Enumeration and Properties
CUDA_VISIBLE_DEVICES A comma-separated sequence of GPU identifiers

MIG support: MIG-<GPU-UUID>/<GPU instance ID>/<compute instance ID>
GPU identifiers are given as integer indices or as UUID strings. GPU UUID strings should follow the same format as given by nvidia-smi, such as GPU-8932f937-d72c-4106-c12f-20bd9faed9f6. However, for convenience, abbreviated forms are allowed; simply specify enough digits from the beginning of the GPU UUID to uniquely identify that GPU in the target system. For example, CUDA_VISIBLE_DEVICES=GPU-8932f937 may be a valid way to refer to the above GPU UUID, assuming no other GPU in the system shares this prefix.

Only the devices whose index is present in the sequence are visible to CUDA applications and they are enumerated in the order of the sequence. If one of the indices is invalid, only the devices whose index precedes the invalid index are visible to CUDA applications. For example, setting CUDA_VISIBLE_DEVICES to 2,1 causes device 0 to be invisible and device 2 to be enumerated before device 1. Setting CUDA_VISIBLE_DEVICES to 0,2,-1,1 causes devices 0 and 2 to be visible and device 1 to be invisible.

MIG format starts with MIG keyword and GPU UUID should follow the same format as given by nvidia-smi. For example, MIG-GPU-8932f937-d72c-4106-c12f-20bd9faed9f6/1/2. Only single MIG instance enumeration is supported.
CUDA_MANAGED_FORCE_DEVICE_ALLOC 0 or 1 (default is 0) Forces the driver to place all managed allocations in device memory.
CUDA_DEVICE_ORDER FASTEST_FIRST, PCI_BUS_ID, (default is FASTEST_FIRST) FASTEST_FIRST causes CUDA to enumerate the available devices in fastest to slowest order using a simple heuristic. PCI_BUS_ID orders devices by PCI bus ID in ascending order.
Compilation
CUDA_CACHE_DISABLE 0 or 1 (default is 0) Disables caching (when set to 1) or enables caching (when set to 0) for just-in-time-compilation. When disabled, no binary code is added to or retrieved from the cache.
CUDA_CACHE_PATH filepath Specifies the folder where the just-in-time compiler caches binary codes; the default values are:
  • on Windows, %APPDATA%\NVIDIA\ComputeCache
  • on Linux, ~/.nv/ComputeCache
CUDA_CACHE_MAXSIZE integer (default is 268435456 (256 MiB) and maximum is 4294967296 (4 GiB)) Specifies the size in bytes of the cache used by the just-in-time compiler. Binary codes whose size exceeds the cache size are not cached. Older binary codes are evicted from the cache to make room for newer binary codes if needed.
CUDA_FORCE_PTX_JIT 0 or 1 (default is 0) When set to 1, forces the device driver to ignore any binary code embedded in an application (see Application Compatibility) and to just-in-time compile embedded PTX code instead. If a kernel does not have embedded PTX code, it will fail to load. This environment variable can be used to validate that PTX code is embedded in an application and that its just-in-time compilation works as expected to guarantee application forward compatibility with future architectures (see Just-in-Time Compilation).
CUDA_DISABLE_PTX_JIT 0 or 1 (default is 0) When set to 1, disables the just-in-time compilation of embedded PTX code and use the compatible binary code embedded in an application (see Application Compatibility). If a kernel does not have embedded binary code or the embedded binary was compiled for an incompatible architecture, then it will fail to load. This environment variable can be used to validate that an application has the compatible SASS code generated for each kernel.(see Binary Compatibility).
Execution
CUDA_LAUNCH_BLOCKING 0 or 1 (default is 0) Disables (when set to 1) or enables (when set to 0) asynchronous kernel launches.
CUDA_DEVICE_MAX_CONNECTIONS 1 to 32 (default is 8) Sets the number of compute and copy engine concurrent connections (work queues) from the host to each device of compute capability 3.5 and above.
CUDA_AUTO_BOOST 0 or 1 Overrides the autoboost behavior set by the --auto-boost-default option of nvidia-smi. If an application requests via this environment variable a behavior that is different from nvidia-smi's, its request is honored if there is no other application currently running on the same GPU that successfully requested a different behavior, otherwise it is ignored.
cuda-gdb (on Linux platform)
CUDA_DEVICE_WAITS_ON_EXCEPTION 0 or 1 (default is 0) When set to 1, a CUDA application will halt when a device exception occurs, allowing a debugger to be attached for further debugging.
MPS service (on Linux platform)
CUDA_DEVICE_DEFAULT_PERSISTING_L2_CACHE_PERCENTAGE_LIMIT Percentage value (between 0 - 100, default is 0) Devices of compute capability 8.x allow, a portion of L2 cache to be set-aside for persisting data accesses to global memory. When using CUDA MPS service, the set-aside size can only be controlled using this environment variable, before starting CUDA MPS control daemon. I.e., the environment variable should be set before running the command nvidia-cuda-mps-control -d.

附录N CUDA的统一内存

N.1. Unified Memory Introduction

统一内存是 CUDA 编程模型的一个组件,在 CUDA 6.0 中首次引入,它定义了一个托管内存空间,在该空间中所有处理器都可以看到具有公共地址空间的单个连贯内存映像。

注意:处理器是指任何具有专用 MMU 的独立执行单元。这包括任何类型和架构的 CPU 和 GPU。

底层系统管理 CUDA 程序中的数据访问和位置,无需显式内存复制调用。这在两个主要方面有利于 GPU 编程:

  • 通过统一系统中所有 GPU 和 CPU 的内存空间以及为 CUDA 程序员提供更紧密、更直接的语言集成,可以简化 GPU 编程。
  • 通过透明地将数据迁移到使用它的处理器,可以最大限度地提高数据访问速度。

简单来说,统一内存消除了通过 cudaMemcpy*() 例程进行显式数据移动的需要,而不会因将所有数据放入零拷贝内存而导致性能损失。当然,数据移动仍然会发生,因此程序的运行时间通常不会减少;相反,统一内存可以编写更简单、更易于维护的代码。

统一内存提供了一个“单指针数据”模型,在概念上类似于 CUDA 的零拷贝内存。两者之间的一个关键区别在于,在零拷贝分配中,内存的物理位置固定在 CPU 系统内存中,因此程序可以快速或慢速地访问它,具体取决于访问它的位置。另一方面,统一内存将内存和执行空间解耦,以便所有数据访问都很快。

统一内存一词描述了一个为各种程序提供内存管理服务的系统,从针对运行时 API 的程序到使用虚拟 ISA (PTX) 的程序。该系统的一部分定义了选择加入统一内存服务的托管内存空间。

托管内存可与特定于设备的分配互操作和互换,例如使用 cudaMalloc() 例程创建的分配。所有在设备内存上有效的 CUDA 操作在托管内存上也有效;主要区别在于程序的主机部分也能够引用和访问内存。

注意:连接到 Tegra 的离散 GPU 不支持统一内存

N.1.1. System Requirements

统一内存有两个基本要求:

  • 具有 SM 架构 3.0 或更高版本(Kepler 类或更高版本)的 GPU
  • 64 位主机应用程序和非嵌入式操作系统(Linux 或 Windows)
    具有 SM 架构 6.x 或更高版本(Pascal 类或更高版本)的 GPU 提供额外的统一内存功能,例如本文档中概述的按需页面迁移和 GPU 内存超额订阅。 请注意,目前这些功能仅在 Linux 操作系统上受支持。 在 Windows 上运行的应用程序(无论是 TCC 还是 WDDM 模式)将使用基本的统一内存模型,就像在 6.x 之前的架构上一样,即使它们在具有 6.x 或更高计算能力的硬件上运行也是如此。 有关详细信息,请参阅数据迁移和一致性

N.1.2. Simplifying GPU Programming

内存空间的统一意味着主机和设备之间不再需要显式内存传输。在托管内存空间中创建的任何分配都会自动迁移到需要的位置。

程序通过以下两种方式之一分配托管内存: 通过 cudaMallocManaged() 例程,它在语义上类似于 cudaMalloc();或者通过定义一个全局 __managed__ 变量,它在语义上类似于一个 __device__ 变量。在本文档的后面部分可以找到这些的精确定义。
注意:在具有计算能力 6.x 及更高版本的设备的支持平台上,统一内存将使应用程序能够使用默认系统分配器分配和共享数据。这允许 GPU 在不使用特殊分配器的情况下访问整个系统虚拟内存。有关更多详细信息,请参阅系统分配器
以下代码示例说明了托管内存的使用如何改变主机代码的编写方式。首先,一个没有使用统一内存的简单程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
__global__ void AplusB(int *ret, int a, int b) {
ret[threadIdx.x] = a + b + threadIdx.x;
}
int main() {
int *ret;
cudaMalloc(&ret, 1000 * sizeof(int));
AplusB<<< 1, 1000 >>>(ret, 10, 100);
int *host_ret = (int *)malloc(1000 * sizeof(int));
cudaMemcpy(host_ret, ret, 1000 * sizeof(int), cudaMemcpyDefault);
for(int i = 0; i < 1000; i++)
printf("%d: A+B = %d\n", i, host_ret[i]);
free(host_ret);
cudaFree(ret);
return 0;
}

第一个示例在 GPU 上将两个数字与每个线程 ID 组合在一起,并以数组形式返回值。 如果没有托管内存,则返回值的主机端和设备端存储都是必需的(示例中为 host_retret),使用 cudaMemcpy() 在两者之间显式复制也是如此。

将此与程序的统一内存版本进行比较,后者允许从主机直接访问 GPU 数据。 请注意 cudaMallocManaged() 例程,它从主机和设备代码返回一个有效的指针。 这允许在没有单独的 host_ret 副本的情况下使用 ret,大大简化并减小了程序的大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
__global__ void AplusB(int *ret, int a, int b) {
ret[threadIdx.x] = a + b + threadIdx.x;
}
int main() {
int *ret;
cudaMallocManaged(&ret, 1000 * sizeof(int));
AplusB<<< 1, 1000 >>>(ret, 10, 100);
cudaDeviceSynchronize();
for(int i = 0; i < 1000; i++)
printf("%d: A+B = %d\n", i, ret[i]);
cudaFree(ret);
return 0;
}

最后,语言集成允许直接引用 GPU 声明的 __managed__ 变量,并在使用全局变量时进一步简化程序。

1
2
3
4
5
6
7
8
9
10
11
__device__ __managed__ int ret[1000];
__global__ void AplusB(int a, int b) {
ret[threadIdx.x] = a + b + threadIdx.x;
}
int main() {
AplusB<<< 1, 1000 >>>(10, 100);
cudaDeviceSynchronize();
for(int i = 0; i < 1000; i++)
printf("%d: A+B = %d\n", i, ret[i]);
return 0;
}

请注意没有明确的 cudaMemcpy() 命令以及返回数组 ret 在 CPU 和 GPU 上都可见的事实。

值得一提的是主机和设备之间的同步。 请注意在非托管示例中,同步 cudaMemcpy() 例程如何用于同步内核(即等待它完成运行)以及将数据传输到主机。 统一内存示例不调用 cudaMemcpy(),因此需要显式 cudaDeviceSynchronize(),然后主机程序才能安全地使用 GPU 的输出。

N.1.3. Data Migration and Coherency

统一内存尝试通过将数据迁移到正在访问它的设备来优化内存性能(也就是说,如果 CPU 正在访问数据,则将数据移动到主机内存,如果 GPU 将访问它,则将数据移动到设备内存)。数据迁移是统一内存的基础,但对程序是透明的。系统将尝试将数据放置在可以最有效地访问而不违反一致性的位置。

数据的物理位置对程序是不可见的,并且可以随时更改,但对数据的虚拟地址的访问将保持有效并且可以从任何处理器保持一致,无论位置如何。请注意,保持一致性是首要要求,高于性能;在主机操作系统的限制下,系统被允许访问失败或移动数据,以保持处理器之间的全局一致性。

计算能力低于 6.x 的 GPU 架构不支持按需将托管数据细粒度移动到 GPU。每当启动 GPU 内核时,通常必须将所有托管内存转移到 GPU 内存,以避免内存访问出错。计算能力 6.x 引入了一种新的 GPU 页面错误机制,可提供更无缝的统一内存功能。结合系统范围的虚拟地址空间,页面错误提供了几个好处。首先,页面错误意味着 CUDA 系统软件不需要在每次内核启动之前将所有托管内存分配同步到 GPU。如果在 GPU 上运行的内核访问了一个不在其内存中的页面,它就会出错,从而允许该页面按需自动迁移到 GPU 内存。或者,可以将页面映射到 GPU 地址空间,以便通过 PCIe 或 NVLink 互连进行访问(访问映射有时可能比迁移更快)。请注意,统一内存是系统范围的:GPU(和 CPU)可以从 CPU 内存或系统中其他 GPU 的内存中发生故障并迁移内存页面。

N.1.4. GPU Memory Oversubscription

计算能力低于 6.x 的设备分配的托管内存不能超过 GPU 内存的物理大小。

计算能力 6.x 的设备扩展了寻址模式以支持 49 位虚拟寻址。 这足以覆盖现代 CPU 的 48 位虚拟地址空间,以及 GPU 自己的内存。 大的虚拟地址空间和页面错误能力使应用程序可以访问整个系统的虚拟内存,而不受任何一个处理器的物理内存大小的限制。 这意味着应用程序可以超额订阅内存系统:换句话说,它们可以分配、访问和共享大于系统总物理容量的数组,从而实现超大数据集的核外处理。 只要有足够的系统内存可用于分配,cudaMallocManaged 就不会耗尽内存。

N.1.5. Multi-GPU

对于计算能力低于 6.x 的设备,托管内存分配的行为与使用 cudaMalloc() 分配的非托管内存相同:当前活动设备是物理分配的主站,所有其他 GPU 接收到内存的对等映射。这意味着系统中的其他 GPU 将以较低的带宽通过 PCIe 总线访问内存。请注意,如果系统中的 GPU 之间不支持对等映射,则托管内存页面将放置在 CPU 系统内存(“零拷贝”内存)中,并且所有 GPU 都会遇到 PCIe 带宽限制。有关详细信息,请参阅 6.x 之前架构上的多 GPU 程序的托管内存

具有计算能力 6.x 设备的系统上的托管分配对所有 GPU 都是可见的,并且可以按需迁移到任何处理器。统一内存性能提示(请参阅性能调优)允许开发人员探索自定义使用模式,例如跨 GPU 读取重复数据和直接访问对等 GPU 内存而无需迁移。

N.1.6. System Allocator

计算能力 7.0 的设备支持 NVLink 上的地址转换服务 (ATS)。 如果主机 CPU 和操作系统支持,ATS 允许 GPU 直接访问 CPU 的页表。 GPU MMU 中的未命中将导致向 CPU 发送地址转换请求 (ATR)。 CPU 在其页表中查找该地址的虚拟到物理映射并将转换提供回 GPU。 ATS 提供 GPU 对系统内存的完全访问权限,例如使用 malloc 分配的内存、在堆栈上分配的内存、全局变量和文件支持的内存。 应用程序可以通过检查新的 pageableMemoryAccessUsesHostPageTables 属性来查询设备是否支持通过 ATS 一致地访问可分页内存。

这是一个适用于任何满足统一内存基本要求的系统的示例代码(请参阅系统要求):

1
2
3
int *data;
cudaMallocManaged(&data, sizeof(int) * n);
kernel<<<grid, block>>>(data);

具有 pageableMemoryAccess 属性的系统支持这些新的访问模式:

1
2
int *data = (int*)malloc(sizeof(int) * n);
kernel<<<grid, block>>>(data);
1
2
int data[1024];
kernel<<<grid, block>>>(data);
1
2
extern int *data;
kernel<<<grid, block>>>(data);

在上面的示例中,数据可以由第三方 CPU 库初始化,然后由 GPU 内核直接访问。 在具有 pageableMemoryAccess 的系统上,用户还可以使用 cudaMemPrefetchAsync 将可分页内存预取到 GPU。 这可以通过优化数据局部性产生性能优势。

注意:目前仅 IBM Power9 系统支持基于 NVLink 的 ATS

N.1.7. Hardware Coherency

第二代 NVLink 允许从 CPU 直接加载/存储/原子访问每个 GPU 的内存。结合新的 CPU 主控功能,NVLink 支持一致性操作,允许从 GPU 内存读取的数据存储在 CPU 的缓存层次结构中。从 CPU 缓存访问的较低延迟是 CPU 性能的关键。计算能力 6.x 的设备仅支持对等 GPU 原子。计算能力 7.x 的设备可以通过 NVLink 发送 GPU 原子并在目标 CPU 上完成它们,因此第二代 NVLink 增加了对由 GPU 或 CPU 发起的原子的支持。

请注意,CPU 无法访问 cudaMalloc 分配。因此,要利用硬件一致性,用户必须使用统一内存分配器,例如 cudaMallocManaged 或支持 ATS 的系统分配器(请参阅系统分配器)。新属性 directManagedMemAccessFromHost 指示主机是否可以直接访问设备上的托管内存而无需迁移。默认情况下,驻留在 GPU 内存中的 cudaMallocManaged 分配的任何 CPU 访问都会触发页面错误和数据迁移。应用程序可以使用带有 cudaCpuDeviceIdcudaMemAdviseSetAccessedBy 性能提示来启用对受支持系统上 GPU 内存的直接访问。

考虑下面的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
__global__ void write(int *ret, int a, int b) {
ret[threadIdx.x] = a + b + threadIdx.x;
}
__global__ void append(int *ret, int a, int b) {
ret[threadIdx.x] += a + b + threadIdx.x;
}
int main() {
int *ret;
cudaMallocManaged(&ret, 1000 * sizeof(int));
cudaMemAdvise(ret, 1000 * sizeof(int), cudaMemAdviseSetAccessedBy, cudaCpuDeviceId); // set direct access hint

write<<< 1, 1000 >>>(ret, 10, 100); // pages populated in GPU memory
cudaDeviceSynchronize();
for(int i = 0; i < 1000; i++)
printf("%d: A+B = %d\n", i, ret[i]); // directManagedMemAccessFromHost=1: CPU accesses GPU memory directly without migrations
// directManagedMemAccessFromHost=0: CPU faults and triggers device-to-host migrations
append<<< 1, 1000 >>>(ret, 10, 100); // directManagedMemAccessFromHost=1: GPU accesses GPU memory without migrations
cudaDeviceSynchronize(); // directManagedMemAccessFromHost=0: GPU faults and triggers host-to-device migrations
cudaFree(ret);
return 0;
}

写内核完成后,会在GPU内存中创建并初始化ret。 接下来,CPU 将访问 ret,然后再次使用相同的 ret 内存追加内核。 此代码将根据系统架构和硬件一致性支持显示不同的行为:

  • directManagedMemAccessFromHost=1 的系统上:CPU 访问托管缓冲区不会触发任何迁移; 数据将保留在 GPU 内存中,任何后续的 GPU 内核都可以继续直接访问它,而不会造成故障或迁移。
  • directManagedMemAccessFromHost=0 的系统上:CPU 访问托管缓冲区将出现页面错误并启动数据迁移; 任何第一次尝试访问相同数据的 GPU 内核都会出现页面错误并将页面迁移回 GPU 内存。

N.1.8. Access Counters

计算能力 7.0 的设备引入了一个新的访问计数器功能,该功能可以跟踪 GPU 对位于其他处理器上的内存进行的访问频率。 访问计数器有助于确保将内存页面移动到最频繁访问页面的处理器的物理内存中。 访问计数器功能可以指导 CPU 和 GPU 之间以及对等 GPU 之间的迁移。

对于 cudaMallocManaged,访问计数器迁移可以通过使用带有相应设备 ID 的 cudaMemAdviseSetAccessedBy 提示来选择加入。 驱动程序还可以使用访问计数器来实现更有效的抖动缓解或内存超额订阅方案。

注意:访问计数器当前仅在 IBM Power9 系统上启用,并且仅用于 cudaMallocManaged 分配器

N.2. Programming Model

N.2.1. Managed Memory Opt In

大多数平台要求程序通过使用 __managed__ 关键字注释 __device__ 变量(请参阅语言集成部分)或使用新的 cudaMallocManaged() 调用来分配数据来选择自动数据管理。

计算能力低于 6.x 的设备必须始终在堆上分配托管内存,无论是使用分配器还是通过声明全局存储。 无法将先前分配的内存与统一内存相关联,也无法让统一内存系统管理 CPU 或 GPU 堆栈指针。

从 CUDA 8.0 和具有计算能力 6.x 设备的支持系统开始,可以使用相同的指针从 GPU 代码和 CPU 代码访问使用默认 OS 分配器(例如 mallocnew)分配的内存。 在这些系统上,统一内存是默认设置:无需使用特殊分配器或创建专门管理的内存池。

N.2.1.1. Explicit Allocation Using cudaMallocManaged()

统一内存最常使用在语义和语法上类似于标准 CUDA 分配器 cudaMalloc() 的分配函数创建。 功能说明如下:

1
2
3
cudaError_t cudaMallocManaged(void **devPtr,
size_t size,
unsigned int flags=0);

cudaMallocManaged() 函数保留托管内存的 size 字节,并在 devPtr 中返回一个指针。 请注意各种 GPU 架构之间 cudaMallocManaged() 行为的差异。 默认情况下,计算能力低于 6.x 的设备直接在 GPU 上分配托管内存。 但是,计算能力 6.x 及更高版本的设备在调用 cudaMallocManaged() 时不会分配物理内存:在这种情况下,物理内存会在第一次触摸时填充,并且可能驻留在 CPU 或 GPU 上。 托管指针在系统中的所有 GPU 和 CPU 上都有效,尽管程序访问此指针必须遵守统一内存编程模型的并发规则(请参阅一致性和并发性)。 下面是一个简单的例子,展示了 cudaMallocManaged() 的使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
__global__ void printme(char *str) {
printf(str);
}
int main() {
// Allocate 100 bytes of memory, accessible to both Host and Device code
char *s;
cudaMallocManaged(&s, 100);
// Note direct Host-code use of "s"
strncpy(s, "Hello Unified Memory\n", 99);
// Here we pass "s" to a kernel without explicitly copying
printme<<< 1, 1 >>>(s);
cudaDeviceSynchronize();
// Free as for normal CUDA allocations
cudaFree(s);
return 0;
}

cudaMalloc()cudaMallocManaged() 替换时,程序的行为在功能上没有改变; 但是,该程序应该继续消除显式内存拷贝并利用自动迁移。 此外,可以消除双指针(一个指向主机,一个指向设备存储器)。

设备代码无法调用 cudaMallocManaged()。 所有托管内存必须从主机或全局范围内分配(请参阅下一节)。 在内核中使用 malloc() 在设备堆上的分配不会在托管内存空间中创建,因此 CPU 代码将无法访问。

N.2.1.2. Global-Scope Managed Variables Using managed

文件范围和全局范围的 CUDA __device__ 变量也可以通过在声明中添加新的 __managed__ 注释来选择加入统一内存管理。 然后可以直接从主机或设备代码中引用它们,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
__device__ __managed__ int x[2];
__device__ __managed__ int y;
__global__ void kernel() {
x[1] = x[0] + y;
}
int main() {
x[0] = 3;
y = 5;
kernel<<< 1, 1 >>>();
cudaDeviceSynchronize();
printf("result = %d\n", x[1]);
return 0;
}

原始 __device__ 内存空间的所有语义,以及一些额外的统一内存特定约束,都由托管变量继承(请参阅使用 NVCC 编译)。

请注意,标记为 __constant__ 的变量可能不会也标记为 __managed__; 此注释仅用于 __device__ 变量。 常量内存必须在编译时静态设置,或者在 CUDA 中像往常一样使用 cudaMemcpyToSymbol() 设置。

N.2.2. Coherency and Concurrency

在计算能力低于 6.x 的设备上同时访问托管内存是不可能的,因为如果 CPU 在 GPU 内核处于活动状态时访问统一内存分配,则无法保证一致性。 但是,支持操作系统的计算能力 6.x 的设备允许 CPU 和 GPU 通过新的页面错误机制同时访问统一内存分配。 程序可以通过检查新的 concurrentManagedAccess 属性来查询设备是否支持对托管内存的并发访问。 请注意,与任何并行应用程序一样,开发人员需要确保正确同步以避免处理器之间的数据危险。

N.2.2.1. GPU Exclusive Access To Managed Memory

为了确保 6.x 之前的 GPU 架构的一致性,统一内存编程模型在 CPU 和 GPU 同时执行时对数据访问施加了限制。实际上,GPU 在执行任何内核操作时对所有托管数据具有独占访问权,无论特定内核是否正在积极使用数据。当托管数据与 cudaMemcpy*()cudaMemset*() 一起使用时,系统可能会选择从主机或设备访问源或目标,这将限制并发 CPU 访问该数据,而 cudaMemcpy*()cudaMemset*() 正在执行。有关更多详细信息,请参阅使用托管内存的 Memcpy()/Memset() 行为

不允许 CPU 访问任何托管分配或变量,而 GPU 对 concurrentManagedAccess 属性设置为 0 的设备处于活动状态。在这些系统上,并发 CPU/GPU 访问,即使是不同的托管内存分配,也会导致分段错误,因为该页面被认为是 CPU 无法访问的。

1
2
3
4
5
6
7
8
9
10
11
__device__ __managed__ int x, y=2;
__global__ void kernel() {
x = 10;
}
int main() {
kernel<<< 1, 1 >>>();
y = 20; // Error on GPUs not supporting concurrent access

cudaDeviceSynchronize();
return 0;
}

在上面的示例中,当 CPU 接触(这里原文中用的是touch这个词) y 时,GPU 程序内核仍然处于活动状态。 (注意它是如何在 cudaDeviceSynchronize() 之前发生的。)由于 GPU 页面错误功能解除了对同时访问的所有限制,因此代码在计算能力 6.x 的设备上成功运行。 但是,即使 CPU 访问的数据与 GPU 不同,这种内存访问在 6.x 之前的架构上也是无效的。 程序必须在访问 y 之前显式地与 GPU 同步:

1
2
3
4
5
6
7
8
9
10
__device__ __managed__ int x, y=2;
__global__ void kernel() {
x = 10;
}
int main() {
kernel<<< 1, 1 >>>();
cudaDeviceSynchronize();
y = 20; // Success on GPUs not supporing concurrent access
return 0;
}

如本例所示,在具有 6.x 之前的 GPU 架构的系统上,CPU 线程可能不会在执行内核启动和后续同步调用之间访问任何托管数据,无论 GPU 内核是否实际接触相同的数据(或 任何托管数据)。 并发 CPU 和 GPU 访问的潜力足以引发进程级异常。

请注意,如果在 GPU 处于活动状态时使用 cudaMallocManaged()cuMemAllocManaged() 动态分配内存,则在启动其他工作或同步 GPU 之前,内存的行为是未指定的。 在此期间尝试访问 CPU 上的内存可能会也可能不会导致分段错误。 这不适用于使用标志 cudaMemAttachHostCU_MEM_ATTACH_HOST 分配的内存。

N.2.2.2. Explicit Synchronization and Logical GPU Activity

请注意,即使内核快速运行并在上例中的 CPU 接触 y 之前完成,也需要显式同步。统一内存使用逻辑活动来确定 GPU 是否空闲。这与 CUDA 编程模型一致,该模型指定内核可以在启动后的任何时间运行,并且不保证在主机发出同步调用之前完成。

任何在逻辑上保证 GPU 完成其工作的函数调用都是有效的。这包括 cudaDeviceSynchronize(); cudaStreamSynchronize()cudaStreamQuery()(如果它返回 cudaSuccess 而不是 cudaErrorNotReady),其中指定的流是唯一仍在 GPU 上执行的流; cudaEventSynchronize()cudaEventQuery() 在指定事件之后没有任何设备工作的情况下;以及记录为与主机完全同步的 cudaMemcpy()cudaMemset() 的使用。

将遵循流之间创建的依赖关系,通过在流或事件上同步来推断其他流的完成。依赖关系可以通过 cudaStreamWaitEvent() 或在使用默认 (NULL) 流时隐式创建。

CPU 从流回调中访问托管数据是合法的,前提是 GPU 上没有其他可能访问托管数据的流处于活动状态。此外,没有任何设备工作的回调可用于同步:例如,通过从回调内部发出条件变量的信号;否则,CPU 访问仅在回调期间有效。

有几个重要的注意点:

  • 在 GPU 处于活动状态时,始终允许 CPU 访问非托管零拷贝数据。
  • GPU 在运行任何内核时都被认为是活动的,即使该内核不使用托管数据。如果内核可能使用数据,则禁止访问,除非设备属性 concurrentManagedAccess 为 1。
  • 除了适用于非托管内存的多 GPU 访问之外,托管内存的并发 GPU 间访问没有任何限制。
  • 并发 GPU 内核访问托管数据没有任何限制。

请注意最后一点如何允许 GPU 内核之间的竞争,就像当前非托管 GPU 内存的情况一样。如前所述,从 GPU 的角度来看,托管内存的功能与非托管内存相同。以下代码示例说明了这些要点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int main() {
cudaStream_t stream1, stream2;
cudaStreamCreate(&stream1);
cudaStreamCreate(&stream2);
int *non_managed, *managed, *also_managed;
cudaMallocHost(&non_managed, 4); // Non-managed, CPU-accessible memory
cudaMallocManaged(&managed, 4);
cudaMallocManaged(&also_managed, 4);
// Point 1: CPU can access non-managed data.
kernel<<< 1, 1, 0, stream1 >>>(managed);
*non_managed = 1;
// Point 2: CPU cannot access any managed data while GPU is busy,
// unless concurrentManagedAccess = 1
// Note we have not yet synchronized, so "kernel" is still active.
*also_managed = 2; // Will issue segmentation fault
// Point 3: Concurrent GPU kernels can access the same data.
kernel<<< 1, 1, 0, stream2 >>>(managed);
// Point 4: Multi-GPU concurrent access is also permitted.
cudaSetDevice(1);
kernel<<< 1, 1 >>>(managed);
return 0;
}

N.2.2.3. Managing Data Visibility and Concurrent CPU + GPU Access with Streams

到目前为止,假设对于 6.x 之前的 SM 架构:1) 任何活动内核都可以使用任何托管内存,以​​及 2) 在内核处于活动状态时使用来自 CPU 的托管内存是无效的。在这里,我们提出了一个用于对托管内存进行更细粒度控制的系统,该系统旨在在所有支持托管内存的设备上工作,包括 concurrentManagedAccess 等于 0 的旧架构。

CUDA 编程模型提供流作为程序指示内核启动之间的依赖性和独立性的机制。启动到同一流中的内核保证连续执行,而启动到不同流中的内核允许并发执行。流描述了工作项之间的独立性,因此可以通过并发实现更高的效率。

统一内存建立在流独立模型之上,允许 CUDA 程序显式地将托管分配与 CUDA 流相关联。通过这种方式,程序员根据内核是否将数据启动到指定的流中来指示内核对数据的使用。这为基于程序特定数据访问模式的并发提供了机会。控制这种行为的函数是:

1
2
3
4
cudaError_t cudaStreamAttachMemAsync(cudaStream_t stream,
void *ptr,
size_t length=0,
unsigned int flags=0);

cudaStreamAttachMemAsync() 函数将从 ptr 开始的内存长度字节与指定的流相关联。 (目前,length 必须始终为 0 以指示应该附加整个区域。)由于这种关联,只要流中的所有操作都已完成,统一内存系统就允许 CPU 访问该内存区域,而不管其他流是否是活跃的。实际上,这将活动 GPU 对托管内存区域的独占所有权限制为每个流活动而不是整个 GPU 活动。

最重要的是,如果分配与特定流无关,则所有正在运行的内核都可以看到它,而不管它们的流如何。这是 cudaMallocManaged() 分配或 __managed__ 变量的默认可见性;因此,在任何内核运行时 CPU 不得接触数据的简单案例规则。

通过将分配与特定流相关联,程序保证只有启动到该流中的内核才会接触该数据。统一内存系统不执行错误检查:程序员有责任确保兑现保证。

除了允许更大的并发性之外,使用 cudaStreamAttachMemAsync() 可以(并且通常会)启用统一内存系统内的数据传输优化,这可能会影响延迟和其他开销。

N.2.2.4. Stream Association Examples

将数据与流相关联允许对 CPU + GPU 并发进行细粒度控制,但在使用计算能力低于 6.x 的设备时,必须牢记哪些数据对哪些流可见。 查看前面的同步示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
__device__ __managed__ int x, y=2;
__global__ void kernel() {
x = 10;
}
int main() {
cudaStream_t stream1;
cudaStreamCreate(&stream1);
cudaStreamAttachMemAsync(stream1, &y, 0, cudaMemAttachHost);
cudaDeviceSynchronize(); // Wait for Host attachment to occur.
kernel<<< 1, 1, 0, stream1 >>>(); // Note: Launches into stream1.
y = 20; // Success – a kernel is running but “y”
// has been associated with no stream.
return 0;
}

在这里,我们明确地将 y 与主机可访问性相关联,从而始终可以从 CPU 进行访问。 (和以前一样,请注意在访问之前没有 cudaDeviceSynchronize()。)GPU 运行内核对 y 的访问现在将产生未定义的结果。

请注意,将变量与流关联不会更改任何其他变量的关联。 例如。 将 x 与 stream1 关联并不能确保在 stream1 中启动的内核只能访问 x,因此此代码会导致错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
__device__ __managed__ int x, y=2;
__global__ void kernel() {
x = 10;
}
int main() {
cudaStream_t stream1;
cudaStreamCreate(&stream1);
cudaStreamAttachMemAsync(stream1, &x);// Associate “x” with stream1.
cudaDeviceSynchronize(); // Wait for “x” attachment to occur.
kernel<<< 1, 1, 0, stream1 >>>(); // Note: Launches into stream1.
y = 20; // ERROR: “y” is still associated globally
// with all streams by default
return 0;
}

请注意访问 y 将如何导致错误,因为即使 x 已与流相关联,我们也没有告诉系统谁可以看到 y。 因此,系统保守地假设内核可能会访问它并阻止 CPU 这样做。

N.2.2.5. Stream Attach With Multithreaded Host Programs

cudaStreamAttachMemAsync() 的主要用途是使用 CPU 线程启用独立任务并行性。 通常在这样的程序中,CPU 线程为它生成的所有工作创建自己的流,因为使用 CUDA 的 NULL 流会导致线程之间的依赖关系。

托管数据对任何 GPU 流的默认全局可见性使得难以避免多线程程序中 CPU 线程之间的交互。 因此,函数 cudaStreamAttachMemAsync() 用于将线程的托管分配与该线程自己的流相关联,并且该关联通常在线程的生命周期内不会更改。

这样的程序将简单地添加一个对 cudaStreamAttachMemAsync() 的调用,以使用统一内存进行数据访问:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// This function performs some task, in its own private stream.
void run_task(int *in, int *out, int length) {
// Create a stream for us to use.
cudaStream_t stream;
cudaStreamCreate(&stream);
// Allocate some managed data and associate with our stream.
// Note the use of the host-attach flag to cudaMallocManaged();
// we then associate the allocation with our stream so that
// our GPU kernel launches can access it.
int *data;
cudaMallocManaged((void **)&data, length, cudaMemAttachHost);
cudaStreamAttachMemAsync(stream, data);
cudaStreamSynchronize(stream);
// Iterate on the data in some way, using both Host & Device.
for(int i=0; i<N; i++) {
transform<<< 100, 256, 0, stream >>>(in, data, length);
cudaStreamSynchronize(stream);
host_process(data, length); // CPU uses managed data.
convert<<< 100, 256, 0, stream >>>(out, data, length);
}
cudaStreamSynchronize(stream);
cudaStreamDestroy(stream);
cudaFree(data);
}

在这个例子中,分配流关联只建立一次,然后主机和设备都重复使用数据。 结果是比在主机和设备之间显式复制数据时更简单的代码,尽管结果是相同的。

N.2.2.6. Advanced Topic: Modular Programs and Data Access Constraints

在前面的示例中,cudaMallocManaged() 指定了 cudaMemAttachHost 标志,它创建了一个最初对设备端执行不可见的分配。 (默认分配对所有流上的所有 GPU 内核都是可见的。)这可确保在数据分配和为特定流获取数据之间的时间间隔内,不会与另一个线程的执行发生意外交互。

如果没有这个标志,如果另一个线程启动的内核恰好正在运行,则新分配将被视为在 GPU 上使用。这可能会影响线程在能够将其显式附加到私有流之前从 CPU 访问新分配的数据的能力(例如,在基类构造函数中)。因此,为了启用线程之间的安全独立性,应指定此标志进行分配。

注意:另一种方法是在分配附加到流之后在所有线程上放置一个进程范围的屏障。这将确保所有线程在启动任何内核之前完成其数据/流关联,从而避免危险。在销毁流之前需要第二个屏障,因为流销毁会导致分配恢复到其默认可见性。 cudaMemAttachHost 标志的存在既是为了简化此过程,也是因为并非总是可以在需要的地方插入全局屏障。

N.2.2.7. Memcpy()/Memset() Behavior With Managed Memory

由于可以从主机或设备访问托管内存,因此 cudaMemcpy*() 依赖于使用 cudaMemcpyKind 指定的传输类型来确定数据应该作为主机指针还是设备指针访问。

如果指定了 cudaMemcpyHostTo* 并且管理了源数据,那么如果在复制流 (1) 中可以从主机连贯地访问它,那么它将从主机访问;否则将从设备访问。当指定 cudaMemcpy*ToHost 并且目标是托管内存时,类似的规则适用于目标。

如果指定了 cudaMemcpyDeviceTo* 并管理源数据,则将从设备访问它。源必须可以从复制流中的设备连贯地访问 (2);否则,返回错误。当指定 cudaMemcpy*ToDevice 并且目标是托管内存时,类似的规则适用于目标。

如果指定了 cudaMemcpyDefault,则如果无法从复制流中的设备一致地访问托管数据 (2),或者如果数据的首选位置是 cudaCpuDeviceId 并且可以从主机一致地访问,则将从主机访问托管数据在复制流 (1) 中;否则,它将从设备访问。

cudaMemset*() 与托管内存一起使用时,始终从设备访问数据。数据必须可以从用于 cudaMemset() 操作的流中的设备连贯地访问 (2);否则,返回错误。

当通过 cudaMemcpy*cudaMemset* 从设备访问数据时,操作流被视为在 GPU 上处于活动状态。在此期间,如果 GPU 的设备属性 concurrentManagedAccess 为零值,则任何与该流相关联的数据或具有全局可见性的数据的 CPU 访问都将导致分段错误。在从 CPU 访问任何相关数据之前,程序必须适当同步以确保操作已完成。

(1) 要在给定流中从主机连贯地访问托管内存,必须至少满足以下条件之一:

  • 给定流与设备属性 concurrentManagedAccess 具有非零值的设备相关联。
  • 内存既不具有全局可见性,也不与给定流相关联。

(2) 要在给定流中从设备连贯地访问托管内存,必须至少满足以下条件之一:

  • 设备的设备属性 concurrentManagedAccess 具有非零值。
  • 内存要么具有全局可见性,要么与给定的流相关联。

###N.2.3. Language Integration

使用 nvcc 编译主机代码的 CUDA 运行时 API 用户可以访问其他语言集成功能,例如共享符号名称和通过 <<<...>>> 运算符启动内联内核。 统一内存为 CUDA 的语言集成添加了一个附加元素:使用 __managed__ 关键字注释的变量可以直接从主机和设备代码中引用。

下面的例子在前面的 Simplifying GPU Programming 中看到,说明了 __managed__ 全局声明的简单使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Managed variable declaration is an extra annotation with __device__
__device__ __managed__ int x;
__global__ void kernel() {
// Reference "x" directly - it's a normal variable on the GPU.
printf( "GPU sees: x = %d\n" , x);
}
int main() {
// Set "x" from Host code. Note it's just a normal variable on the CPU.
x = 1234;

// Launch a kernel which uses "x" from the GPU.
kernel<<< 1, 1 >>>();
cudaDeviceSynchronize();
return 0;
}

__managed__ 变量的可用功能是该符号在设备代码和主机代码中都可用,而无需取消引用指针,并且数据由所有人共享。这使得在主机和设备程序之间交换数据变得特别容易,而无需显式分配或复制。

从语义上讲,__managed__ 变量的行为与通过 cudaMallocManaged() 分配的存储相同。有关详细说明,请参阅使用 cudaMallocManaged() 进行显式分配。流可见性默认为 cudaMemAttachGlobal,但可以使用 cudaStreamAttachMemAsync() 进行限制。

__managed__ 变量的正确操作需要有效的 CUDA 上下文。如果当前设备的上下文尚未创建,则访问 __managed__ 变量可以触发 CUDA 上下文创建。在上面的示例中,在内核启动之前访问 x 会触发设备 0 上的上下文创建。如果没有该访问,内核启动将触发上下文创建。

声明为 __managed__ 的 C++ 对象受到某些特定约束,尤其是在涉及静态初始化程序的情况下。有关这些约束的列表,请参阅 CUDA C++ 编程指南中的 C++ 语言支持

N.2.3.1. Host Program Errors with managed Variables

__managed__ 变量的使用取决于底层统一内存系统是否正常运行。 例如,如果 CUDA 安装失败或 CUDA 上下文创建不成功,则可能会出现不正确的功能。

当特定于 CUDA 的操作失败时,通常会返回一个错误,指出失败的根源。 使用 __managed__ 变量引入了一种新的故障模式,如果统一内存系统运行不正确,非 CUDA 操作(例如,CPU 访问应该是有效的主机内存地址)可能会失败。 这种无效的内存访问不能轻易地归因于底层的 CUDA 子系统,尽管诸如 cuda-gdb 之类的调试器会指示托管内存地址是故障的根源。

N.2.4. Querying Unified Memory Support

N.2.4.1. Device Properties

统一内存仅在具有 3.0 或更高计算能力的设备上受支持。程序可以通过使用 cudaGetDeviceProperties() 并检查新的 managedMemory 属性来查询 GPU 设备是否支持托管内存。也可以使用具有属性 cudaDevAttrManagedMemory 的单个属性查询函数 cudaDeviceGetAttribute() 来确定能力。

如果在 GPU 和当前操作系统下允许托管内存分配,则任一属性都将设置为 1。请注意,32 位应用程序不支持统一内存(除非在 Android 上),即使 GPU 有足够的能力。

支持平台上计算能力 6.x 的设备无需调用 cudaHostRegister 即可访问可分页内存。应用程序可以通过检查新的 pageableMemoryAccess 属性来查询设备是否支持连贯访问可分页内存。

通过新的缺页机制,统一内存保证了全局数据的一致性。这意味着 CPU 和 GPU 可以同时访问统一内存分配。这在计算能力低于 6.x 的设备上是非法的,因为如果 CPU 在 GPU 内核处于活动状态时访问统一内存分配,则无法保证一致性。程序可以通过检查 concurrentManagedAccess 属性来查询并发访问支持。有关详细信息,请参阅一致性和并发性

N.2.5. Advanced Topics

N.2.5.1. Managed Memory with Multi-GPU Programs on pre-6.x Architectures

在计算能力低于 6.x 的设备的系统上,托管分配通过 GPU 的对等能力自动对系统中的所有 GPU 可见。

在 Linux 上,只要程序正在使用的所有 GPU 都具有点对点支持,托管内存就会在 GPU 内存中分配。如果在任何时候应用程序开始使用不支持对等支持的 GPU 与任何其他对其进行了托管分配的 GPU,则驱动程序会将所有托管分配迁移到系统内存。

在 Windows 上,如果对等映射不可用(例如,在不同架构的 GPU 之间),那么系统将自动回退到使用零拷贝内存,无论两个 GPU 是否都被程序实际使用。如果实际只使用一个 GPU,则需要在启动程序之前设置 CUDA_VISIBLE_DEVICES 环境变量。这限制了哪些 GPU 是可见的,并允许在 GPU 内存中分配托管内存。

或者,在 Windows 上,用户还可以将 CUDA_MANAGED_FORCE_DEVICE_ALLOC 设置为非零值,以强制驱动程序始终使用设备内存进行物理存储。当此环境变量设置为非零值时,该进程中使用的所有支持托管内存的设备必须彼此对等兼容。如果使用支持托管内存的设备并且它与之前在该进程中使用的任何其他托管内存支持设备不兼容,则将返回错误 ::cudaErrorInvalidDevice,即使 ::cudaDeviceReset 具有在这些设备上被调用。这些环境变量在附录 CUDA 环境变量中进行了描述。请注意,从 CUDA 8.0 开始,CUDA_MANAGED_FORCE_DEVICE_ALLOC 对 Linux 操作系统没有影响。

N.2.5.2. Using fork() with Managed Memory

统一内存系统不允许在进程之间共享托管内存指针。 它不会正确管理通过 fork() 操作复制的内存句柄。 如果子级或父级在 fork() 之后访问托管数据,则结果将不确定。

然而,fork() 一个子进程然后通过 exec() 调用立即退出是安全的,因为子进程丢弃了内存句柄并且父进程再次成为唯一的所有者。 父母离开并让孩子接触句柄是不安全的。

N.3. Performance Tuning

为了使用统一内存实现良好的性能,必须满足以下目标:

  • 应避免错误:虽然可重放错误是启用更简单的编程模型的基础,但它们可能严重损害应用程序性能。故障处理可能需要几十微秒,因为它可能涉及 TLB 无效、数据迁移和页表更新。与此同时,应用程序某些部分的执行将停止,从而可能影响整体性能。
  • 数据应该位于访问处理器的本地:如前所述,当数据位于访问它的处理器本地时,内存访问延迟和带宽明显更好。因此,应适当迁移数据以利用较低的延迟和较高的带宽。
  • 应该防止内存抖动:如果数据被多个处理器频繁访问并且必须不断迁移以实现数据局部性,那么迁移的开销可能会超过局部性的好处。应尽可能防止内存抖动。如果无法预防,则必须进行适当的检测和解决。

为了达到与不使用统一内存相同的性能水平,应用程序必须引导统一内存驱动子系统避免上述陷阱。值得注意的是,统一内存驱动子系统可以检测常见的数据访问模式并自动实现其中一些目标,而无需应用程序参与。但是,当数据访问模式不明显时,来自应用程序的明确指导至关重要。 CUDA 8.0 引入了有用的 API,用于为运行时提供内存使用提示 (cudaMemAdvise()) 和显式预取 (cudaMemPrefetchAsync())。这些工具允许与显式内存复制和固定 API 相同的功能,而不会恢复到显式 GPU 内存分配的限制。

注意:Tegra 设备不支持 cudaMemPrefetchAsync()

N.3.1. Data Prefetching

数据预取意味着将数据迁移到处理器的内存中,并在处理器开始访问该数据之前将其映射到该处理器的页表中。 数据预取的目的是在建立数据局部性的同时避免故障。 这对于在任何给定时间主要从单个处理器访问数据的应用程序来说是最有价值的。 由于访问处理器在应用程序的生命周期中发生变化,因此可以相应地预取数据以遵循应用程序的执行流程。 由于工作是在 CUDA 中的流中启动的,因此预计数据预取也是一种流操作,如以下 API 所示:

1
2
3
4
cudaError_t cudaMemPrefetchAsync(const void *devPtr, 
size_t count,
int dstDevice,
cudaStream_t stream);

其中由 devPtr 指针和 count 字节数指定的内存区域,ptr 向下舍入到最近的页面边界,count 向上舍入到最近的页面边界,通过在流中排队迁移操作迁移到 dstDevice。 为 dstDevice 传入 cudaCpuDeviceId 会导致数据迁移到 CPU 内存。
考虑下面的一个简单代码示例:

1
2
3
4
5
6
7
8
9
10
11
void foo(cudaStream_t s) {
char *data;
cudaMallocManaged(&data, N);
init_data(data, N); // execute on CPU
cudaMemPrefetchAsync(data, N, myGpuId, s); // prefetch to GPU
mykernel<<<..., s>>>(data, N, 1, compare); // execute on GPU
cudaMemPrefetchAsync(data, N, cudaCpuDeviceId, s); // prefetch to CPU
cudaStreamSynchronize(s);
use_data(data, N);
cudaFree(data);
}

如果没有性能提示,内核 mykernel 将在首次访问数据时出错,这会产生额外的故障处理开销,并且通常会减慢应用程序的速度。 通过提前预取数据,可以避免页面错误并获得更好的性能。
此 API 遵循流排序语义,即迁移在流中的所有先前操作完成之前不会开始,并且流中的任何后续操作在迁移完成之前不会开始。

N.3.2. Data Usage Hints

当多个处理器需要同时访问相同的数据时,单独的数据预取是不够的。 在这种情况下,应用程序提供有关如何实际使用数据的提示很有用。 以下咨询 API 可用于指定数据使用情况:

1
2
3
4
cudaError_t cudaMemAdvise(const void *devPtr, 
size_t count,
enum cudaMemoryAdvise advice,
int device);

其中,为从 devPtr 地址开始的区域中包含的数据指定的通知和计数字节的长度,四舍五入到最近的页面边界,可以采用以下值:

  • cudaMemAdviseSetReadMostly:这意味着数据大部分将被读取并且只是偶尔写入。 这允许驱动程序在处理器访问数据时在处理器内存中创建数据的只读拷贝。 同样,如果在此区域上调用 cudaMemPrefetchAsync,它将在目标处理器上创建数据的只读拷贝。 当处理器写入此数据时,相应页面的所有副本都将失效,但发生写入的拷贝除外。 此建议忽略设备参数。 该建议允许多个处理器以最大带宽同时访问相同的数据,如以下代码片段所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
char *dataPtr;
size_t dataSize = 4096;
// Allocate memory using malloc or cudaMallocManaged
dataPtr = (char *)malloc(dataSize);
// Set the advice on the memory region
cudaMemAdvise(dataPtr, dataSize, cudaMemAdviseSetReadMostly, 0);
int outerLoopIter = 0;
while (outerLoopIter < maxOuterLoopIter) {
// The data is written to in the outer loop on the CPU
initializeData(dataPtr, dataSize);
// The data is made available to all GPUs by prefetching.
// Prefetching here causes read duplication of data instead
// of data migration
for (int device = 0; device < maxDevices; device++) {
cudaMemPrefetchAsync(dataPtr, dataSize, device, stream);
}
// The kernel only reads this data in the inner loop
int innerLoopIter = 0;
while (innerLoopIter < maxInnerLoopIter) {
kernel<<<32,32>>>((const char *)dataPtr);
innerLoopIter++;
}
outerLoopIter++;
}
  • cudaMemAdviseSetPreferredLocation:此建议将数据的首选位置设置为属于设备的内存。传入设备的 cudaCpuDeviceId 值会将首选位置设置为 CPU 内存。设置首选位置不会导致数据立即迁移到该位置。相反,它会在该内存区域发生故障时指导迁移策略。如果数据已经在它的首选位置并且故障处理器可以建立映射而不需要迁移数据,那么迁移将被避免。另一方面,如果数据不在其首选位置,或者无法建立直接映射,那么它将被迁移到访问它的处理器。请务必注意,设置首选位置不会阻止使用 cudaMemPrefetchAsync 完成数据预取。
  • cudaMemAdviseSetAccessedBy:这个advice意味着数据将被设备访问。这不会导致数据迁移,并且对数据本身的位置没有影响。相反,只要数据的位置允许建立映射,它就会使数据始终映射到指定处理器的页表中。如果数据因任何原因被迁移,映射会相应更新。此advice在数据局部性不重要但避免故障很重要的情况下很有用。例如,考虑一个包含多个启用对等访问的 GPU 的系统,其中位于一个 GPU 上的数据偶尔会被其他 GPU 访问。在这种情况下,将数据迁移到其他 GPU 并不那么重要,因为访问不频繁并且迁移的开销可能太高。但是防止故障仍然有助于提高性能,因此提前设置映射很有用。请注意,在 CPU 访问此数据时,由于 CPU 无法直接访问 GPU 内存,因此数据可能会迁移到 CPU 内存。任何为此数据设置了 cudaMemAdviceSetAccessedBy 标志的 GPU 现在都将更新其映射以指向 CPU 内存中的页面。

每个advice也可以使用以下值之一取消设置:cudaMemAdviseUnsetReadMostlycudaMemAdviseUnsetPreferredLocationcudaMemAdviseUnsetAccessedBy

N.3.3. Querying Usage Attributes

程序可以使用以下 API 查询通过 cudaMemAdvisecudaMemPrefetchAsync 分配的内存范围属性:

1
2
3
4
5
cudaMemRangeGetAttribute(void *data, 
size_t dataSize,
enum cudaMemRangeAttribute attribute,
const void *devPtr,
size_t count);

此函数查询从 devPtr 开始的内存范围的属性,大小为 count 字节。内存范围必须引用通过 cudaMallocManaged 分配或通过 __managed__ 变量声明的托管内存。可以查询以下属性:

  • cudaMemRangeAttributeReadMostly:如果给定内存范围内的所有页面都启用了重复读取,则返回的结果将为 1,否则返回 0。
  • cudaMemRangeAttributePreferredLocation:如果内存范围内的所有页面都将相应的处理器作为首选位置,则返回结果将是 GPU 设备 ID 或 cudaCpuDeviceId,否则将返回 cudaInvalidDeviceId。应用程序可以使用此查询 API 来决定通过 CPU 或 GPU 暂存数据,具体取决于托管指针的首选位置属性。请注意,查询时内存范围内页面的实际位置可能与首选位置不同。
  • cudaMemRangeAttributeAccessedBy: 将返回为该内存范围设置了该建议的设备列表。
  • cudaMemRangeAttributeLastPrefetchLocation:将返回使用 cudaMemPrefetchAsync 显式预取内存范围内所有页面的最后位置。请注意,这只是返回应用程序请求将内存范围预取到的最后一个位置。它没有指示对该位置的预取操作是否已经完成或什至开始。

此外,还可以使用对应的 cudaMemRangeGetAttributes 函数查询多个属性。

在开始之前,我想提一下,这项工作的大部分都是从对cublas Kepler和Maxwell的sgemm实现的详细研究中得出的。我做了一些适度的改进,但大多数难题都由英伟达的优秀工程师和他们对硬件的专业知识解决了。本文档的目标是传播这些知识,供其他人在自己的代码中使用。我还想联系两篇关于sgemm主题的优秀论文:MAGMA原始论文(http://icl.cs.utk.edu/projectsfiles/magma/pubs/fermi_gemm.pdf)和赖俊杰的Kepler sgemm论文(http://hal.inria.fr/docs/00/78/99/58/PDF/112_Lai.pdf)。本文档基本上是Junjie工作的扩展,但具有Maxwell架构和额外的汇编级优化。

Overview

以sgemm为例,本文旨在描述如何最大化Maxwell架构及其他架构的计算能力。拥有数千个计算核心对你没有好处,除非你让它们得到数据。要做到这一点,您需要构建计算结构,以最大限度地重用通过各种内存层次结构提取的数据。在GPU上,这些是:设备内存到二级缓存,二级缓存到纹理缓存,纹理缓存到寄存器,寄存器到共享内存,共享内存到寄存器,从寄存器到指令操作数缓存(Maxwell的新功能),最后从寄存器返回到设备内存。这些数据路径中的每一条都有延迟,我们需要用指令和线程级并行性(ILP&TLP)来隐藏这些延迟。此外,还可能存在bank和联合约束。所提出的sgemm代码能够克服所有这些约束,并在硬件理论错误的2%内运行。

本文档将介绍两种不同的布局:每个块64个线程和每个块256个线程。我将主要讨论64线程版本,因为映射更小更简单。256线程版本或多或少是相同的,只是放大了4倍。这两个版本分别针对小型或大型矩阵进行了优化。较小的64线程版本可以将矩阵拆分为4倍多的块,这在SM稀少的情况下非常有用,但代价是所需的设备内存带宽是256线程版本的两倍。在GM204硬件上,这个额外的带宽实际上超过了可用的带宽,因此只有当有更多的可用块来填充SM超过了成本时,您才想使用它(除非L2可以隐藏它)。虽然,如果您有足够的并行工作,使用流来填充SM是更好的方法。

在这两个版本中,我们将使用双缓冲8寄存器块来加载A和B中的每一个。双缓冲允许我们从共享内存中隐藏加载的大部分延迟:我们可以计算一个寄存器块,同时加载下一个寄存器。我们选择8个寄存器块,因为它与使用四矢量内存指令很好地对齐,并且因为我们可以将总寄存器预算保持在128以下。跨越128个寄存器的障碍将使我们的占用率从每个调度器的4个活动减少到64线程版本的3个,从256线程版本的4个减少到2个。64线程版本不太容易受到下降的影响,我实际上看到了一些矩阵大小的性能提高(减少了L2和纹理缓存稀释,每个SM的块更少),但256线程版本的工作性能稍好,每个调度程序多了1个扭曲,以覆盖延迟。我们在这两种实现中的性能都不会受到占用率下降的巨大影响,这说明了这段代码如何很好地隐藏ILP的延迟。

我们还将对共享内存进行双重缓冲,以便删除其中一个我们通常需要在主循环中进行的bar.syncs,而不是在存储下一批之前等待所有共享加载完成,我们只需开始写入一个新的共享区域,而其他线程可以从上一个区域读取数据。您将在下面看到,这将向主循环添加3个XOR,但这仍然比bar.syncs便宜。至于共享内存的大小,这是由每个线程块加载的内存宽度乘以主循环展开因子来定义的。我们有64个(或256个)线程,每个线程将计算8*8或64个C点。所有这些点将一起排列成正方形,因为我们从a和B均匀拉动。所以这个正方形的宽度就是总点数的平方根。对于我们的两个实现,我们计算:

1
2
64  Threads: sqrt(64  * 8*8) = 64  units wide
256 Threads: sqrt(256 * 8*8) = 128 units wide*

我们的展开因子是我们一次从A和B读取、从共享存储/读取和计算的行数。它将在几个方面受到限制。我们希望能够通过尽可能多的计算工作来隐藏纹理负载的延迟。但是,我们不希望循环的大小超过指令缓存的大小。这样做会增加额外的指令获取延迟,我们需要隐藏这些延迟。在Maxwell上,我测得这个缓存为8KB。因此,这意味着我们不希望循环大小超过1024个8字节指令,其中每4个指令都是一个控制代码。所以768是有用指令的极限。此外,还有指令对齐的注意事项,因此您也希望安全地处于该值之下。简而言之,使用8的循环展开因子可以得到8 x 64=512 ffma指令加上循环所需的额外内存和整数算术指令(约40)。这使我们大大低于768。每个循环8行也与纹理内存负载的维度很好地对齐。最后,512个FFMA应该足以大部分隐藏200+时钟纹理加载延迟。

因此,我们现在知道了共享内存的总大小:(每个循环8行)x(块加载宽度)x(字大小)x(A的2个缓冲区)x(B的2个缓存区)。64个线程为8192字节,256个线程为16384字节。这种大小不会影响占用率,占用率由寄存器计数(我们将保持在128以下)决定。

下面是两种实现共享的基本内存布局。注意,我将X维度与来自A的载荷相等,并沿lda对齐,而Y维度与来自B的载荷相等并沿ldb对齐。这与x和y通常在空间上的定义方式相反,如下图所示。还要注意的是,A和C的图像被布置为转置。回想起来,我可能会把它改成B作为转置,并与A交换,但这就是我最初的计算方法。在下一节中,我将开始详细讨论64线程版本。

image-20221208152310575

64 Thread Implementation

加载A和B,然后存储到共享

为了加载A和B矩阵,我们使用了一种在cuda c或ptx中无法有效实现的技术。我们将线程分成两半,让每一半加载一个矩阵。由于我们有64个线程,这意味着每个warp加载一个矩阵。cuda中的条件加载没有得到很好的优化,因为编译器没有努力确定加载是否在warp上均匀发生。对于纹理加载,这是必要的,因为指令每次只能处理一个纹理。因此,除了纹理加载之外,编译器还会添加一堆warp刷新以及分支和同步指令,以确保强制执行。如果Nvidia提供一种方式来提示条件或谓词是warp一致的(而不仅仅是分支,即bra.uni),那就太好了。

使用此技术的主要优点是,我们只需要一组跟踪寄存器来保存纹理加载索引。在主循环内部,这是一个巨大的胜利,因为它减少了我们需要的整数加法指令的一半。我们利用一切机会提高FFMA指令与非FFMA指令的比率。

我们还维护了4个单独的轨迹变量,以避免在每次纹理加载后使用依赖性屏障将单个轨迹变量增加ldx*2。内存指令发出时不会复制其操作数寄存器。这样做可能会节省晶体管。相反,当内存指令仍在运行时,您可以使用屏障来防止对这些寄存器的写入。在障碍处等待并不一定很糟糕,因为TLP可以启动并覆盖延迟,但减少需要覆盖的延迟总数可以帮助性能,因为这增加了有翘曲覆盖它们的机会。我们没有任何额外的循环IADDS,因为它有4个跟踪变量,只有3个额外的寄存器,这是我们可以轻松负担的。

所以我们将通过纹理单元加载。通过使用显式纹理加载而不是全局加载,无论是否使用非相干缓存,我们都可以获得一些好处。一是这使得代码更加简单,因为我们不需要担心加载超出范围。第二,使用相同的内核代码,我们可以加载8位或16位浮点,从而显著减少带宽和存储需求。有些应用程序不需要完全32位精度,在这种情况下这是一个巨大的胜利。

此外,我们将加载四元向量。这是对cublas代码的更改,在性能方面产生了最大的差异。虽然我可以理解为什么它不在立方体中使用,因为它对输入数据施加了4个字的对齐约束。立方体有一个固定的规范(这并不是说如果检测到四边对齐,它就不能选择不同的代码路径)。因此,通过使用四元向量,我们需要将lda/ldb索引向下折叠4。这有一个额外的好处,即允许我们加载索引大小为31位的矩阵,而不是常规纹理加载27位的限制。四元加载的另一个工件是我们的内存访问模式在每次提取时都会拉入并消耗全部缓存线。这意味着我们只能得到非常有限的纹理缓存使用率(1-2%),而我们的内存缓存性能将由二级缓存控制。

下面是一些伪代码,它只显示了主循环中的纹理加载和共享存储。你可以从地图上看到,这是非常直接的。你会注意到STS。128条指令,我们将遇到存储体冲突,但这些冲突是不可避免的,结果不会影响性能,因为批量加载和存储到向量指令中是一个双赢。此外,我甚至不确定银行冲突期间发生的指令回放是否重要,因为我认为这些指令可能会与FFMA一起发出。事实上,所有的内存操作都是在我们的主循环中发出的,根本不考虑flops计算(除非在寄存器组冲突一节中以一种微妙的方式描述)。

仅从这段代码和我们的主循环中时钟消耗指令的数量,我们就可以粗略估计内核所需的内存带宽上限。对于GM204,以下是数学公式:

  • 每个线程在每个循环中进行4个vec4 4字节的加载,或者每个循环中每个线程进行64个字节的加载。

  • 下面我们将计算每个循环消耗大约520个时钟。

  • 每个SM同时执行128个线程。

  • 有16个SM的时钟频率为1.216 GHz(升压)。

  • 每GB有.931 GiB:

  • 64 x 128 x 16 x 1.216 x.931/520=285 GiB/秒

GM204有224 GiB/sec可用。但这部分设备带宽将不需要,因为二级缓存将为其提供服务。但在设备带宽上有余量总是很好的。您的负载将不会以完全统一的方式执行,并且当它们聚集在一起时,您的净空越小,出现暂停的机会就越大。虽然只有运行接近理论吞吐量的代码才可能注意到这些暂停,但我们的代码恰好会这样做。

因此,您可以看到,64线程的实现对于GM204来说并不理想。然而,对于GM107来说,它是理想的,对于即将推出的具有384位内存总线的GM200来说也是如此。与256线程实现相比,这一实现使用了双倍的带宽,因此功耗更大。因此,当您有足够的数据来提供数据时,通常会首选更大的版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
tid = threadId.x;
bx = blockId.x;
by = blockId.y;

blk = tid >= 32 ? by : bx;
ldx = tid >= 32 ? ldb/4 : lda/4;
tex = tid >= 32 ? texB : texA;
tid2 = (tid >> 4) & 1;
tid15 = tid & 15;

track0 = blk*64/4 + tid15 + (ldx * tid2);
track2 = track0 + ldx*2;
track4 = track0 + ldx*4;
track6 = track0 + ldx*6;

end = track0 + (k-8)*ldx;

writeS = tid15*4*4 + tid2*64*4;
writeS += tid >= 32 ? 2048 : 0;

while (track0 < end)
{
tex.1d.v4.f32.s32 loadX0, [tex, track0];
tex.1d.v4.f32.s32 loadX2, [tex, track2];
tex.1d.v4.f32.s32 loadX4, [tex, track4];
tex.1d.v4.f32.s32 loadX6, [tex, track6];

st.shared.v4.f32 [writeS + 4*0*64], loadX0;
st.shared.v4.f32 [writeS + 4*2*64], loadX2;
st.shared.v4.f32 [writeS + 4*4*64], loadX4;
st.shared.v4.f32 [writeS + 4*6*64], loadX6;

// our loop needs one bar sync after share is loaded
bar.sync 0;

// Increment the track variables and swap shared buffers after the sync.
// We know at this point that these registers are not tied up with any in flight memory op.
track0 += ldx*8;
track2 += ldx*8;
track4 += ldx*8;
track6 += ldx*8;
writeS ^= 4*16*64;

// Additional loop code omitted for clarity.
}

通过四矢量纹理索引加载A和B:

image-20221208153216716

使用四矢量将A和B存储到共享地址空间中:

image-20221208153237832

从共享读取

现在,共享内存已加载,我们从使用一半线程切换到处理A和B中的每一个。我们需要开始组合这些值来计算构成C点的点积(处理每一行后,我们计算块中所有C值的部分积和和)。所以每个线程都将从A的共享行和B的共享行中读取。

除了FFMA之外,共享负载是这个实现的真正工作。我们对它们进行双重缓冲,因此延迟最小。但我们也希望确保我们没有bank冲突,因为我们需要尽快提供这些数据。如何在没有库冲突的情况下使用四元向量从共享加载?好吧,根据文档,只要所有访问都在32个字(128字节)以内,我们就可以了。在sgemm中,这是因为我们可以安排不同的线程同时从同一共享内存位置加载,并使用共享广播机制。然而,事实证明,Maxwell的文档是不完整的,尽管warp中的所有线程都在相同的128字节内,但仍有某些模式会导致库冲突。这样做可能是为了节省芯片。所以我们只需要找到一个可行的模式。

在128字节内,我们可以加载8个16字节的四元组。我们将使其成为从A和B的共享内存加载的模式。我们的共享内存块是4*64=256字节宽,因此为了加载另一半,我们将展开该负载到一个相隔32个单元的额外指令中。我们不必担心bank冲突。每个矩阵的这两个四字负载形成了我们想要的8寄存器块。通过在2D中组合这两种1D模式的负载,我们可以得到下面所示的共享内存映射。该模式还表示每个线程的64个寄存器在C子矩阵中的位置(绿色方块)。

现在我们有了基本信息,我们需要将其分成两个warp,然后映射这些warp中的线程id。直接方法是以简单的扫描模式向下或横向加载。这导致了神秘的bank冲突。但是,如果我们使用由thread号表示的锯齿形图案,它就会起作用。我还没有对所有的负载大小和模式进行详尽的搜索,以了解哪些是有效的,哪些是无效的,但如果Nvidia为Maxwell更新他们的文档来解释这一限制,那就太好了。

至于找出将threadId映射到我们想要的模式(下面的readAs和readBs)所需的逻辑,我有一个简单的技术。我只是打印出每个threadId的二进制表示形式和我希望它映射到的值。当您以这种方式可视化二进制时,很容易确定需要保留、丢弃或移动哪些位以使映射工作(前提是您选择了possble映射)。

我还应该提到我的插图是如何被解读的。黄色方块表示线程(或TLP),绿色方块表示第一个线程的ILP。你应该能够想象得到绿色正方形的图案,并将其移动到每个黄色正方形的顶部(保持绿色与黄色的相对位置)。这应该跨越整个内存空间,这是我们共享映射的目标:a中一条线的每个点都需要与B中一条线上的每个点配对。细黑线表示线程如何被分割成warp。下面的深绿色方块是为了说明我们稍后将要进行的warp同步洗牌中的一个步骤。

另一个值得注意的是,cublas在这里使用了更复杂的readAs/readBs映射,这实现了相同的效果,但需要花费更多的指令。这是我的代码对cublas的一个小改进。如果您提前知道共享加载限制,那么更复杂的模式甚至是有意义的。但似乎愚蠢而直接的方法最终找到了更简单的解决方案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
readAs = ((tid >> 1) & 7) << 4;
readBs = (((tid & 0x30) >> 3) | (tid & 1)) << 4 + 2048;

while (track0 < end)
{
// Process each of our 8 lines from shared
for (j = 0; j < 8; j++)
{
// We fetch one line ahead while calculating the current line.
// Wrap the last line around to the first.
prefetch = (j + 1) % 8;

// Use even/odd rows to implement our double buffer.
if (j & 1)
{
ld.shared.v4.f32 j0Ax00, [readAs + 4*(prefetch*64 + 0)];
ld.shared.v4.f32 j0By00, [readBs + 4*(prefetch*64 + 0)];
ld.shared.v4.f32 j0Ax32, [readAs + 4*(prefetch*64 + 32)];
ld.shared.v4.f32 j0By32, [readBs + 4*(prefetch*64 + 32)];
}
else
{
ld.shared.v4.f32 j1Ax00, [readAs + 4*(prefetch*64 + 0)];
ld.shared.v4.f32 j1By00, [readBs + 4*(prefetch*64 + 0)];
ld.shared.v4.f32 j1Ax32, [readAs + 4*(prefetch*64 + 32)];
ld.shared.v4.f32 j1By32, [readBs + 4*(prefetch*64 + 32)];
}
}
// swap our shared memory buffers after reading out 8 lines
readAs ^= 4*16*64;
readBs ^= 4*16*64;

// Additional loop code omitted for clarity.
}

1D readAs(左侧)和readBs(顶部)在2D中组合以形成该线程块的C结果子矩阵:

image-20221208162657316

计算C:寄存器bank和重用

现在我们为线程填充了8个寄存器A和B,我们可以执行64个FFMA,这些FFMA构成了内核设计的核心工作。为了能够在全速和最低功率下计算这一点,我们需要考虑几个因素。主要是寄存器组和操作数重用。

Maxwell上有4个寄存器组,但与开普勒(也有4个组)不同的是,将bank分配给数字非常简单。Maxwell赋值只是寄存器数模4。在开普勒上,可以安排64条FFMA指令以消除所有存储体冲突。在麦克斯韦身上,这已经不可能了。然而,Maxwell提供了一些弥补这一点的方法,同时提供了显著减少寄存器组流量和总体芯片功耗的能力。这是操作数重用缓存。操作数重用缓存每个源操作数插槽有8个字节的数据。类似FFMA的指令有3个源操作数槽。每次发出指令时,都有一个标志可以用来指定是否要再次使用每个操作数。因此,在同一操作数槽中使用同一寄存器的下一条指令不必去寄存器组获取其值。通过此功能,您可以看到如何避免寄存器bank冲突。

因此,我们要采取的第一步是尽量减少操作数重用时必须隐藏的存储体冲突的数量。为此,我们需要显式选择要使用的寄存器。这是使用maxas作为汇编器的主要优点之一。ptxas在避免存储体冲突方面做得很好,但它并不完美,而且当涉及向量指令时,它做得特别糟糕(本例中的情况非常严重)。因此,我们将选择:

  • 0-63为C寄存器
  • 64-71和80-87是矩阵A的双缓冲块寄存器
  • 72-79和88-95是矩阵B的双缓冲块寄存器

如果我们按照下面所示的8乘8矩阵排列,我们可以用每个寄存器的存储体索引为其着色。对于C寄存器,我们选择与相应的块寄存器不同的颜色。通过这种方式,您可以看到我们可以消除与C寄存器和阻塞寄存器的所有存储体冲突。这使得不可避免的16个存储体与阻塞寄存器本身发生冲突。这些以黑色显示:

image-20221208162949337

如果没有重用缓存,这16个存储体冲突中的每一个都将导致计算中的1个时钟暂停。这将使我们的计算速度降低约20%(在520时钟循环中增加128个时钟)。但是,如果您使用–noruse标志组装sgemm代码,您将看到性能只会下降几百Gflop左右。如果你仔细阅读英伟达关于操作数收集器的专利,特别是如果你搜索涉及bank冲突的部分,这个谜团就迎刃而解了。它描述了一些缓解bank冲突的方法。很难说Maxwell是如何处理的,但这可能涉及到如何利用TLP来隐藏bank冲突延迟。因此,操作数收集器单独屏蔽存储体冲突的能力有限,但可能很快就会被淹没。通过使用持久的缓存而不仅仅是临时操作数缓冲区,硬件能够更有效地避免bank冲突暂停。它只需要汇编器使用重用标志来指导它,这样它就可以提前知道哪些寄存器值得缓存,以及在寄存器被写入时丢弃哪些寄存器。

优化设置重用标志的繁琐任务由maxas为您处理。留给我们的是以这样的方式对指令进行排序,以便最大限度地实现重用。最简单的排序是一个基本的双嵌套“for循环”,它将逐行遍历矩阵。这只有效地利用了重用缓存每个操作数8个字节中的4个字节,并且不会隐藏所有的存储体冲突。相反,如果您的扫描来回进行,则可以隐藏所有冲突并提高寄存器重用率(总体上为39%)。但最有效的模式是,在来回移动时,应用一个漩涡(47%的总重用率)。以下是按C寄存器号列出的FFMA指令顺序:

1
2
3
4
5
6
7
1, 0, 2, 3, 5, 4, 6, 7, 33, 32, 34, 35, 37, 36, 38, 39,

45, 44, 46, 47, 41, 40, 42, 43, 13, 12, 14, 15, 9, 8, 10, 11,

17, 16, 18, 19, 21, 20, 22, 23, 49, 48, 50, 51, 53, 52, 54, 55,

61, 60, 62, 63, 57, 56, 58, 59, 29, 28, 30, 31, 25, 24, 26, 27

您将注意到,所选漩涡尺寸在其中一个方向上的间距为2。此间距具有使C寄存器出现在交替存储体中的效果。我对这样做的原因的最佳猜测是极其微妙的。由于我们的内存指令与FFMA交错,并且这些指令没有其操作数寄存器的副本,因此它们可以在大约20个时钟周期内访问寄存器组。我们的C寄存器经常被弄脏,因此无法重复使用,所以我们总是从寄存器库中取出它们。因此,主要是这些寄存器会与我们的内存加载和存储指令发生延迟存储体冲突。可能不可能完全围绕这些银行冲突进行设计,但您可以减少它们的影响。通过在每条指令上交替使用C寄存器组,我们可以确保组冲突最多只能持续一个时钟。我运行了几个基准测试来检验这个假设,结果似乎是正确的。最后一个注意事项:使用所有四矢量加载和存储的另一个优点(除了效率更高之外)是减少了所需的内存指令数量,从而减少了延迟寄存器组冲突的机会。

鉴于我们知道内存操作数寄存器可能存在延迟存储体冲突,因此为这些操作数选择不同的存储体是值得尝试的。使用maxas,我们可以完全控制寄存器映射,您将在源代码中注意到,我们为track0-3、tex、readAs、readBs和writeS选择了非常特定的库。测试了这些库选择中的每一个,以最大化内核的flops性能。这是一个优化级别,我不确定cublas实现是否实现。我知道它犯的一个错误是,对于第一个FFMA,它选择了具有阻塞寄存器组冲突的C寄存器(3)。这防止了重用缓存隐藏该冲突的能力,因为之前没有将至少一个操作数加载到缓存中的指令。在GM204上,这个错误导致28 Gflops的性能损失。

使用FFMA的最后一个考虑是如何将它们与上述所有内存操作交织。要了解我在这里谈论的内容,请查看源代码的预处理版本。我们希望尽早使用双缓冲共享负载,以覆盖它们的延迟,因此我们将使用第一个FFMA开始双重发布它们。我们将用两条指令来分隔它们,因为内存单元似乎以一半的吞吐量最佳工作。我们将把纹理加载放在两组共享加载的中间。这样做是为了不让指令淹没内存单元。对于64线程实现,我们甚至将四个负载分成两组,并将它们放在不同的FFMA块中。我们将共享存储指令放置在尽可能低的位置,以使纹理加载有机会加载它们的操作数。我们不能将它们放在最后一个FFMA块中,因为这是我们开始为下一个循环迭代加载块寄存器的地方。

所有这些立场决定都经过了严格的测试,证明是最佳的。我应该注意,使用ptxas无法进行这些细粒度的排序和定位选择。事实上,ptxas倾向于优化我们的共享双缓冲加载方案。在选择寄存器组、优化操作数重用的指令排序和将内存指令精确放置在我们想要的位置之间,实现的性能可以达到理论性能的70%,而实现的性能则可以达到98%。

warp同步无序映射

在循环结束时,现在计算线程块的C子矩阵。所以现在是将结果存储回全局存储器的时候了。因为我们使用了来自共享的四矢量加载,所以我们的C值有点聚在一起,对于联合写入全局来说根本不是最佳的。我们可以直接将数据写出来,但我们可以做得更好。通过使用共享内存在同一warp的线程之间移动C寄存器,我们可以重新组织它以进行合并写入。您可能认为warp shuffle指令在这里最有效,但我们需要从不同的线程交换不同的寄存器,因此它不适合于此目的。

我们将把洗牌分成8块。上述共享内存映射上的深绿色线表示第一个块。另外7个将是C寄存器的后续垂直选择。因此,每个线程在共享内存中一次存储8个寄存器,然后立即再读取8个寄存器。但是,这些寄存器的排列方式使得我们的线程ID的重新映射可以以合并模式将数据存储到全局。因此,为了存储到共享,我们需要重新使用原始的共享内存映射,并在其中一个维度中将其从4个跨步单位折叠为一个。读取它的线程id映射将是32个值,步幅为1个单位。

1
2
3
4
5
6
7
8
9
10
11
12
13
tid31 = tid & 31;
tid32 = tid & 32;

// Remove the high bits if present from the last loop's xor.
// Also remove the 2048 added onto readBs.
readAs &= 0x7ff;
readBs &= 0x7ff;

// Write to shared using almost the same shared mapping as before but collapse readBs down to stride one.
writeCs = (readBs / 4) * 64 + readAs;

// Read out with a mapping amenable to coalesced global writes
readCs = ((tid32 << 3) + tid31) << 2;

image-20221208164843431

Warp Shuffling和联合存储到全局

有了上述映射,我们现在可以输出C值。注意,我们不需要bar.sync在写入共享内存之前进行同步,因为这已经在我们的最后一个循环中完成了。还要注意,由于我们不在warp之间共享数据,所以在共享内存洗牌中,我们不需要在写入和读取之间同步。只有在存储到writeC完成后,才会进行从readC的读取。注意,这里增加的共享内存延迟大部分可以用TLP隐藏,而为扭曲同步洗牌增加的净时钟只有十几个左右。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
ldc4 = ldc * 4;

cx = bx*64 + tid31;
cy = by*64 + (tid32 >> 1);

Cy00 = (cy*ldc + cx) * 4 + C;
Cy04 = Cy00 + ldc4 * 4;
Cy08 = Cy00 + ldc4 * 8;
Cy12 = Cy00 + ldc4 * 12;

foreach copy vertical line of 8 registers from C into .v4.f32 cs0 and cs4
{
// Feed the 8 registers through the warp shuffle before storing to global
st.shared.v4.f32 [writeCs + 4*00], cs0;
st.shared.v4.f32 [writeCs + 4*32], cs4;

ld.shared.f32 cs0, [readCs + 4*(0*64 + 00)];
ld.shared.f32 cs1, [readCs + 4*(0*64 + 32)];
ld.shared.f32 cs2, [readCs + 4*(1*64 + 00)];
ld.shared.f32 cs3, [readCs + 4*(1*64 + 32)];
ld.shared.f32 cs4, [readCs + 4*(2*64 + 00)];
ld.shared.f32 cs5, [readCs + 4*(2*64 + 32)];
ld.shared.f32 cs6, [readCs + 4*(3*64 + 00)];
ld.shared.f32 cs7, [readCs + 4*(3*64 + 32)];

st.global.f32 [Cy00 + 4*00], cs0;
st.global.f32 [Cy00 + 4*32], cs1;
st.global.f32 [Cy04 + 4*00], cs2;
st.global.f32 [Cy04 + 4*32], cs3;
st.global.f32 [Cy08 + 4*00], cs4;
st.global.f32 [Cy08 + 4*32], cs5;
st.global.f32 [Cy12 + 4*00], cs6;
st.global.f32 [Cy12 + 4*32], cs7;

Cy00 += ldc4;
Cy04 += ldc4;
Cy08 += ldc4;
Cy12 += ldc4;

// After processing forth set shift over to the stride 32 registers
if (4th iteration)
{
Cy00 += ldc4 * 28;
Cy04 += ldc4 * 28;
Cy08 += ldc4 * 28;
Cy12 += ldc4 * 28;
}
}

在下图中,蓝色方块表示如何从Cy00、Cy04、Cy08和Cy12矩阵C偏移的8个状态构造绿线。它们的垂直放置的不是步幅32的部分是到循环迭代的映射,而不是空间位置。

image-20221208165042717

呃……所以这是一个很高的水平。代码注释中甚至包含了较低级别的细节,特别是关于如何将内存访问与计算同步的细节。注释仅在256线程版本中找到。说到这里,下面我将展示四倍多的线程如何改变映射。

SGEMM - 256 Thread Implementation

Loading A and B

1
2
3
4
5
6
7
8
9
10
11
12
13
tid = threadId.x;
blk = tid >= 128 ? blockId.y : blockId.x;
ldx = tid >= 128 ? ldb/4 : lda/4;
tex = tid >= 128 ? texB : texA;
tid4 = (tid >> 5) & 3
tid31 = tid & 31
tid96 = tid & 96
tid128 = tid & 128

track0 = blk*128/4 + tid31 + (ldx * tid4)
track4 = track0 + ldx*4;

end = track0 + (k-8)*ldx;

Storing to Shared

1
2
writeS  = tid31*4*4 + tid4*128*4;
writeS += tid >= 128 ? 4096 : 0;

image-20221208165147533

Reading from Shared

1
2
readAs = ((tid128 >> 4) | ((tid >> 1) & 7)) << 4;
readBs = (((tid & 0x70) >> 3) | (tid & 1)) << 4 + 4096;

image-20221208165203670

Warp Synchronous Shuffle

1
2
3
4
5
6
readAs &= 0xfff;
readBs &= 0xfff;

writeCs = (readBs / 4) * 128 + readAs;

readCs = ((tid96 << 4) | tid31 | (tid128 >> 2)) << 2;

image-20221208165218383

Storing to Global

1
2
3
4
5
6
7
8
9
ldc4 = ldc * 4;

cx = bx*128 + tid31 | (tid128 >> 2);
cy = by*128 + (tid96 >> 1);

Cy00 = (cy*ldc + cx) * 4 + C;
Cy04 = Cy00 + ldc4*4;
Cy08 = Cy00 + ldc4*8;
Cy12 = Cy00 + ldc4*12;

image-20221208165238932

深入浅出GPU优化系列

前言

首先需要对reduce算法进行介绍。reduce算法本质上就是计算x=x0⊗x1⊗x2⊗x3……⊗xn−1⊗xn 。下面本文将详细说明如何在GPU中实现reduce算法并进行深入地优化。

并行算法设计

在GPU中,reduce采用了一种树形的计算方式。如下图所示。

image-20220910175222544

从上至下,将数据不断地累加,直到得出最后的结果,即25。但由于GPU没有针对global数据的同步操作,只能针对block的数据进行同步。所以,一般而言将reduce分为两个阶段,其示意图如下:

image-20220910175237258

我们仔细来看看这个事,假设给定一个长度为N的数组,需要计算该数组的所有元素之和。首先需要将数组分为m个小份。而后,在第一阶段中,开启m个block计算出m个小份的reduce值。最后,在第二阶段中,使用一个block将m个小份再次进行reduce,得到最终的结果。由于第二阶段本质上是可以调用第一个阶段的kernel,所以不做单独说明,本文只是探索第一阶段的优化技巧。

所以kernel接口为:

1
__global__ void reduce(T *input, T* output)

其中,input代表输入的数组,即一个长度为N的数组,output代表输出数组,即第一阶段的结果,即长度为M的数组。随后要开始激动人心的coding阶段,但在CUDA编程中,我们首先需要设置三个参数:

  1. BlockNum:即开启的block数量,即上面所说的M,代表需要将数组切分为几份。
  2. Thread_per_block:每个block中开启的线程数,一般而言,取128,256,512,1024这几个参数会比较多。
  3. Num_per_block:每个block需要进行reduce操作的长度。

其中,BlockNum* Num_per_block=N

reduce优化

reduce baseline算法介绍

Baseline算法比较简单,分为三个步骤。第一个步骤是将数据load至shared memory中,第二个步骤是在shared memory中对数据进行reduce操作,第三个步骤是将最后的结果写回global memory中。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
__global__ void reduce0(float *d_in,float *d_out){
__shared__ float sdata[THREAD_PER_BLOCK];

//each thread loads one element from global memory to shared mem
unsigned int i=blockIdx.x*blockDim.x+threadIdx.x;
unsigned int tid=threadIdx.x;
sdata[tid]=d_in[i];
__syncthreads();

// do reduction in shared mem
for(unsigned int s=1; s<blockDim.x; s*=2){
if(tid%(2*s) == 0){
sdata[tid]+=sdata[tid+s];
}
__syncthreads();
}

// write result for this block to global mem
if(tid==0)d_out[blockIdx.x]=sdata[tid];
}

在进行优化之前,我们需要再来好好地梳理一下这个baseline代码。优化的本质是通过软件榨干硬件资源,所以必须清楚地了解代码在硬件上的执行过程才能更好地进行优化。

第一个步骤中,我们让Num_per_block与Thread_per_block一致,每个block设定为256个线程,一个block负责256个数据的reduce工作。假设需要处理32M的数据,则有128K个block。tid代表线程号,i代表在原始数组中的索引号。第tid号线程将第i号的数据从global中取出,放到shared memory的第tid元素中。比如在第0号block中,0号线程将0号元素取出,放到shared memory的第0号位置。示意图见:

image-20220910175717727

从硬件角度来分析一下代码。为了执行代码,GPU需要分配两种资源,一个是存储资源,一个是计算资源存储资源包括在global memory中分配的一块32M× sizeof(float)的空间以及在shared memory中分配的256× sizeof(float)的空间。需要注意的是,shared memory存在bank冲突的问题,因而需要格外小心计算资源其实是根据thread数量来确定的,一个block中分配256个thread线程,32个线程为一组,绑定在一个SIMD单元。所以256个线程可以简单地理解为分配了8组SIMD单元。

(但实际的硬件资源分配不是这样,因为一个SM的计算资源有限,不可能真的给每一个block都分配这么多的SIMD单元。)总而言之,在第一个阶段,就是tid号线程将i号数据从global memory中取出,再放进shared memory中,严谨一点的话,中间是走一遍寄存器再到shared memory中的。

到了第二个阶段,block中需要计算的256个元素已经全部被存储在了shared memory中,此时需要对其进行reduce操作。这个过程需要进行多轮迭代,在第一轮迭代中,如果tid%2 ==0, 则第tid号线程将shared memory中第tid号位置的值和第tid+1号的值进行相加,而后放在第tid号位置。

在第二轮迭代中,如果tid%4==0,则第tid号线程将shared memory中第tid号位置的值和第tid+2号的值进行相加,而后放在第tid号位置。不断迭代,则所有元素都将被累加到第0号位置。其示意图如下。其中,红色的线程代表符合if条件的线程,只有它们有任务,需要干活。

image-20220910175947839

第三个阶段中,block负责的256个元素之和都放置在shared memory的0号位置,此时,只需要将0号位置的元素写回即可。

优化技巧1:解决warp divergence

现有问题

目前reduce0存在的最大问题就是warp divergent的问题。对于一个block而言,它所有的thread都是执行同一条指令。如果存在if-else这样的分支情况的话,thread会执行所有的分支。只是不满足条件的分支,所产生的结果不会记录下来。可以在上图中看到,在每一轮迭代中都会产生两个分支,分别是红色和橙色的分支。这严重影响了代码执行的效率。

解决方式

解决的方式也比较明了,就是尽可能地让所有线程走到同一个分支里面。代码示意如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
__global__ void reduce1(float *d_in,float *d_out){
__shared__ float sdata[THREAD_PER_BLOCK];

//each thread loads one element from global memory to shared mem
unsigned int i=blockIdx.x*blockDim.x+threadIdx.x;
unsigned int tid=threadIdx.x;
sdata[tid]=d_in[i];
__syncthreads();

// do reduction in shared mem
for(unsigned int s=1; s<blockDim.x; s*=2){
int index = 2*s*tid;
if(index < blockDim.x){
sdata[index]+=sdata[index+s];
}
__syncthreads();
}

// write result for this block to global mem
if(tid==0)d_out[blockIdx.x]=sdata[tid];
}

虽然代码依旧存在着if语句,但是却与reduce0代码有所不同。我们继续假定block中存在256个thread,即拥有256/32=8个warp。当进行第1次迭代时,0-3号warp的index<blockDim.x, 4-7号warp的index>=blockDim.x。对于每个warp而言,都只是进入到一个分支内,所以并不会存在warp divergence的情况。

当进行第2次迭代时,0、1号两个warp进入计算分支。当进行第3次迭代时,只有0号warp进入计算分支。当进行第4次迭代时,只有0号warp的前16个线程进入分支。此时开始产生warp divergence。通过这种方式,我们消除了前3次迭代的warp divergence。

优化技巧2:解决bank冲突

现有问题

reduce1的最大问题是bank冲突。我们把目光聚焦在这个for循环中。并且只聚焦在0号warp。在第一次迭代中,0号线程需要去load shared memory的0号地址以及1号地址的数,然后写回到0号地址。而此时,这个warp中的16号线程,需要去load shared memory中的32号地址和33号地址。可以发现,0号地址跟32号地址产生了2路的bank冲突

第2次迭代中,0号线程需要去load shared memory中的0号地址和2号地址。这个warp中的8号线程需要load shared memory中的32号地址以及34号地址,16号线程需要load shared memory中的64号地址和68号地址,24号线程需要load shared memory中的96号地址和100号地址。

又因为0、32、64、96号地址对应着同一个bank,所以此时产生了4路的bank冲突。现在,可以继续算下去,8路bank冲突,16路bank冲突。由于bank冲突,所以reduce1性能受限。下图说明了在load第一个数据时所产生的bank冲突。

image-20220910180155032

解决方式

在reduce中,解决bank冲突的方式就是把for循环逆着来。原来stride从0到256,现在stride从128到0。其伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
__global__ void reduce2(float *d_in,float *d_out){
__shared__ float sdata[THREAD_PER_BLOCK];

//each thread loads one element from global memory to shared mem
unsigned int i=blockIdx.x*blockDim.x+threadIdx.x;
unsigned int tid=threadIdx.x;
sdata[tid]=d_in[i];
__syncthreads();

// do reduction in shared mem
for(unsigned int s=blockDim.x/2; s>0; s>>=1){
if(tid < s){
sdata[tid]+=sdata[tid+s];
}
__syncthreads();
}

// write result for this block to global mem
if(tid==0)d_out[blockIdx.x]=sdata[tid];
}

那为什么通过这么一个小小的改变就能消除bank冲突呢,我们继续进行分析。

把目光继续看到这个for循环中,并且只分析0号warp。0号线程需要load shared memory的0号元素以及128号元素。1号线程需要load shared memory中的1号元素和129号元素。这一轮迭代中,在读取第一个数时,warp中的32个线程刚好load 一行shared memory数据。再分析第2轮迭代,0号线程load 0号元素和64号元素,1号线程load 1号元素和65号元素。

咦,也是这样,每次load shared memory的一行。再来分析第3轮迭代,0号线程load 0号元素和32号元素,接下来不写了,总之,一个warp load shared memory的一行。没有bank冲突。到了4轮迭代,0号线程load 0号元素和16号元素。那16号线程呢,16号线程啥也不干,因为s=16,16-31号线程啥也不干,跳过去了。示意图如下:

image-20220910213658483

优化技巧3:解决idle线程

现有问题

reduce2最大的问题就是线程的浪费。可以看到我们启动了256个线程,但是在第1轮迭代时只有128个线程在干活,第2轮迭代只有64个线程在干活,每次干活的线程都会减少一半。第一轮迭代示意图如下,只有前128个线程在load数据。后128个线程啥也不干,光看着。

image-20220910213713143

解决方式

对于HPC从业者而言,我们希望变成GPU的资本家,去尽可能地压榨GPU。但是呢,在这里,每一次迭代有一半的线程不干活。而且,128-255号线程最过分,它娘的,没有任何贡献,啥也不干。想来想去,能不能让它们干点活呢。想来想去,那这样吧,让它好歹做一次加法。除了去global memory中取数外,再做一次加法。当然为了实现这个,block数就得改一改了。Block数量减少,Num_per_block增加一倍。也就是说原来一个block只需要管256个数就行,现在得管512个数了。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
__global__ void reduce3(float *d_in,float *d_out){
__shared__ float sdata[THREAD_PER_BLOCK];

//each thread loads one element from global memory to shared mem
unsigned int i=blockIdx.x*(blockDim.x*2)+threadIdx.x;
unsigned int tid=threadIdx.x;
sdata[tid]=d_in[i] + d_in[i+blockDim.x];
__syncthreads();

// do reduction in shared mem
for(unsigned int s=blockDim.x/2; s>0; s>>=1){
if(tid < s){
sdata[tid]+=sdata[tid+s];
}
__syncthreads();
}

// write result for this block to global mem
if(tid==0)d_out[blockIdx.x]=sdata[tid];
}

通过这种方式,将一些idle的线程给利用起来了。

优化技巧4:展开最后一维减少同步

现有问题

对于reduce3来说,性能已经算是比较好了。但是依旧没有达到我们想要的效果。我们再来仔细地看看还有什么可以改进的地方。我们发现,当进行到最后几轮迭代时,此时的block中只有warp0在干活时,线程还在进行同步操作。这一条语句造成了极大的浪费。

解决方式

由于一个warp中的32个线程其实是在一个SIMD单元上,这32个线程每次都是执行同一条指令,这天然地保持了同步状态,因而当s=32时,即只有一个SIMD单元在工作时,完全可以将__syncthreads()这条同步代码去掉。所以我们将最后一维进行展开以减少同步。伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
__device__ void warpReduce(volatile float* cache,int tid){
cache[tid]+=cache[tid+32];
cache[tid]+=cache[tid+16];
cache[tid]+=cache[tid+8];
cache[tid]+=cache[tid+4];
cache[tid]+=cache[tid+2];
cache[tid]+=cache[tid+1];
}

__global__ void reduce4(float *d_in,float *d_out){
__shared__ float sdata[THREAD_PER_BLOCK];

//each thread loads one element from global memory to shared mem
unsigned int i=blockIdx.x*(blockDim.x*2)+threadIdx.x;
unsigned int tid=threadIdx.x;
sdata[tid]=d_in[i] + d_in[i+blockDim.x];
__syncthreads();

// do reduction in shared mem
for(unsigned int s=blockDim.x/2; s>32; s>>=1){
if(tid < s){
sdata[tid]+=sdata[tid+s];
}
__syncthreads();
}

// write result for this block to global mem
if(tid<32)warpReduce(sdata,tid);
if(tid==0)d_out[blockIdx.x]=sdata[tid];
}

可以通过下面的示意图更好地了解,warp0会被绑定在一个SIMD单元上,上面有thread0-thread31。warp1会被绑在另外一个SIMD单元上,上面有thread32-thread63。由于在一个SIMD单元上,然后不管啥时候thread0和thread7肯定是同一状态,不需要同步。而thread0和thread34就不能保证同步,必须用__syncthreads()来保证同步操作。

优化技巧5:完全展开减少计算

现有问题

其实到了这一步,reduce的效率已经足够高了。再进一步优化其实已经非常困难了。为了探索极致的性能表现,Mharris接下来给出的办法是对for循环进行完全展开。我觉得这里主要是减少for循环的开销。Mharris的实验表明这种方式有着1.41x的加速比。但是用的机器是G80,十几年前的卡。性能数据也比较老了,至于能不能真的有这么好的加速比,我们拭目以待。

解决方法

我们将整个for循环进行展开,非常暴力,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
template <unsigned int blockSize>
__device__ void warpReduce(volatile float* cache,int tid){
if(blockSize >= 64)cache[tid]+=cache[tid+32];
if(blockSize >= 32)cache[tid]+=cache[tid+16];
if(blockSize >= 16)cache[tid]+=cache[tid+8];
if(blockSize >= 8)cache[tid]+=cache[tid+4];
if(blockSize >= 4)cache[tid]+=cache[tid+2];
if(blockSize >= 2)cache[tid]+=cache[tid+1];
}

template <unsigned int blockSize>
__global__ void reduce5(float *d_in,float *d_out){
__shared__ float sdata[THREAD_PER_BLOCK];

//each thread loads one element from global memory to shared mem
unsigned int i=blockIdx.x*(blockDim.x*2)+threadIdx.x;
unsigned int tid=threadIdx.x;
sdata[tid]=d_in[i] + d_in[i+blockDim.x];
__syncthreads();

// do reduction in shared mem
if(blockSize>=512){
if(tid<256){
sdata[tid]+=sdata[tid+256];
}
__syncthreads();
}
if(blockSize>=256){
if(tid<128){
sdata[tid]+=sdata[tid+128];
}
__syncthreads();
}
if(blockSize>=128){
if(tid<64){
sdata[tid]+=sdata[tid+64];
}
__syncthreads();
}

// write result for this block to global mem
if(tid<32)warpReduce<blockSize>(sdata,tid);
if(tid==0)d_out[blockIdx.x]=sdata[tid];
}

优化技巧6:合理设置block数量

现有问题

当走到这一步的时候,能调的东西已经基本上调完了。我们再把眼光放在block和thread的设置上。之前默认了Num_per_block=Thread_per_block。也就是说,一个block开启256个线程时,这个block负责256个元素的reduce操作。那可不可以让一个block多管点数。这样的话,开启的block数量少一些。以此对block设置进行调整,获得最优block取值,这样或许能够带来一些性能收益?

解决方式

这样需要再思考一下block的取值。对于GPU而言,block的取值到底是多更好,还是少更好。如此对CUDA编程熟悉的同学,肯定会毫不犹豫地说:“那肯定是多更好啦。Block数量多,block可以进行快速地切换,去掩盖访存的延时。”这个问题按下不表,我们看看Mharris是怎么说的。

如果一个线程被分配更多的work时,可能会更好地覆盖延时。这一点比较好理解。如果线程有更多的work时,对于编译器而言,就可能有更多的机会对相关指令进行重排,从而去覆盖访存时的巨大延时。虽然这句话并没有很好地说明在某种程度上而言,block少一些会更好。但是,有一点不可否认,block需要进行合理地设置。唠唠叨叨说了很多,现在把代码贴一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
template <unsigned int blockSize>
__global__ void reduce6(float *d_in,float *d_out){
__shared__ float sdata[THREAD_PER_BLOCK];

//each thread loads one element from global memory to shared mem
unsigned int i=blockIdx.x*(blockDim.x*2)+threadIdx.x;
unsigned int tid=threadIdx.x;
unsigned int gridSize = blockSize * 2 * gridDim.x;
sdata[tid] = 0;

while(i<n){
sdata[tid] +=d_in[i]+d_in[i+blockSize];
i+=gridSize;
}
__syncthreads();

// do reduction in shared mem
if(blockSize>=512){
if(tid<256){
sdata[tid]+=sdata[tid+256];
}
__syncthreads();
}
if(blockSize>=256){
if(tid<128){
sdata[tid]+=sdata[tid+128];
}
__syncthreads();
}
if(blockSize>=128){
if(tid<64){
sdata[tid]+=sdata[tid+64];
}
__syncthreads();
}

// write result for this block to global mem
if(tid<32)warpReduce<blockSize>(sdata,tid);
if(tid==0)d_out[blockIdx.x]=sdata[tid];
}

优化技巧7:使用shuffle指令

现有问题

其实,对于Mharris的讲义。reduce优化就到此结束了。但是NV后来出了Shuffle指令,对于reduce优化有着非常好的效果。目前绝大多数访存类算子,像是softmax,batch_norm,reduce等,都是用Shuffle实现。所以,在这里谈一下这么把shuffle指令用在reduce优化上。

Shuffle指令是一组针对warp的指令。Shuffle指令最重要的特性就是warp内的寄存器可以相互访问。在没有shuffle指令的时候,各个线程在进行通信时只能通过shared memory来访问彼此的寄存器。而采用了shuffle指令之后,warp内的线程可以直接对其他线程的寄存器进行访存。通过这种方式可以减少访存的延时。除此之外,带来的最大好处就是可编程性提高了,在某些场景下,就不用shared memory了。毕竟,开发者要自己去控制 shared memory还是挺麻烦的一个事。

伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
template <unsigned int blockSize>
__device__ __forceinline__ float warpReduceSum(float sum){
if(blockSize >= 32)sum += __shfl_down_sync(0xffffffff,sum,16);
if(blockSize >= 16)sum += __shfl_down_sync(0xffffffff,sum,8);
if(blockSize >= 8)sum += __shfl_down_sync(0xffffffff,sum,4);
if(blockSize >= 4)sum += __shfl_down_sync(0xffffffff,sum,2);
if(blockSize >= 2)sum += __shfl_down_sync(0xffffffff,sum,1);
return sum;
}

template <unsigned int blockSize>
__global__ void reduce7(float *d_in,float *d_out, unsigned int n){
float sum = 0;

//each thread loads one element from global memory to shared mem
unsigned int i=blockIdx.x*(blockDim.x*2)+threadIdx.x;
unsigned int tid=threadIdx.x;
unsigned int gridSize = blockSize * 2 * gridDim.x;

while(i<n){
sdata[tid] +=d_in[i]+d_in[i+blockSize];
i+=gridSize;
}

// shared mem for partial sums(one per warp in the block
static __shared__ float warpLevelSums[WARP_SIZE];
const int laneId = threadIdx.x % WARP_SIZE;
const int warpId = threadIdx.x / WARP_SIZE;

sum = warpReduceSum<blockSize>(sum);

if(laneId == 0)warpLevelSums[warpId]=sum;
__syncthreads();

sum = (threadIdx.x < blockDim.x / WARP_SIZE)? warpLevelSums[laneId]:0;
// Final reduce using first warp
if(warpId == 0)sum = warpReduceSum<blockSize/WARP_SIZE>(sum);
// write result for this block to global mem
if(tid==0)d_out[blockIdx.x]=sum;
}

GEMM优化

前言

在高性能领域,对于矩阵乘(GEMM)的优化是一个非常重要的课题。GEMM可以非常广泛地应用于航空航天、流体力学等科学计算领域,这也是之前HPC的主要应用场景。后来深度学习开展地如火如荼,由于对高算力的需要,也成为HPC的主要应用场景之一。这些年涌现了一系列的深度学习模型。模型里面最耗时的东西,包括卷积、全连接层、attention,都可以转换成GEMM操作。所以说,GEMM优化的重要性,怎么突出都不过分。

本篇文章主要介绍GEMM中的数据分块和如何在多级存储进行数据搬运。这也是HPC优化的核心思想,怎么样让数据放在更近的存储上来掩盖计算的延时,从而减少存储墙的影响。文章分为四个方面进行叙述,首先介绍在global memory层面如何进行分块以及数据搬运,随后介绍在shared memory层面如何进行分块以及数据搬运,而后介绍在register层面如何进行分块以及避免bank冲突,最后介绍如何进行prefetch以更好地掩盖访存时延。

从global memory到shared memory

假设有矩阵A,B,需要计算矩阵A和B的乘,即矩阵C。A、B、C三个矩阵的维度分别为,,m∗k,k∗n,m∗n ,且三个矩阵中的数据都是单精度浮点数。对于C中每一个元素,C[i][j],可以看作是A的一行和B的一列进行一次归约操作。采用最naive的GEMM算法,在GPU中,一共开启m∗n 个线程,每个线程需要读取矩阵A的一行与矩阵B的一列,而后将计算结果写回至矩阵C中。因而,完成计算一共需要从global memory中进行2mnk 次读操作和m*n次写操作。大量的访存操作使得GEMM效率难以提高,因而考虑global memory中进行分块,并将矩阵块放置到shared memory中。其示意图如下:

image-20220910214848982

对global memory进行分块的GEMM算法示意图见上图右侧。首先将A、B、C三个矩阵划分为多个维度为,,bm∗bk,bk∗bn,bm∗bn 的小矩阵块。三个矩阵形成M∗K,K∗N,M∗N 的小矩阵网格。其中M=m/bm,N=n/bn,K=k/bk 。随后在GPU中开启M∗N 个block,每个block负责C中一个维度为bm∗bn 的小矩阵块的计算。计算中一共有K次迭代,每一次迭代都需要读取A中一个维度为bm∗bk 的小矩阵块和B中一个维度为bk∗bn 的小矩阵块,并将其放置在shared memory中。因而,完成C中所有元素的计算一共需要从global memory中读取M∗N∗K∗(bm∗bk+bk∗bn) ,即m∗n∗k(1/bm+1/bn) 个单精度浮点数。相比于naive的GEMM算法,访存量减少为原来的1/2∗(1/bm+1/bn) 。通过global memory中分块算法极大地减少了对global memory的访存量。并且,相比于naive算法,对global进行分块可以更充分地利用数据局部性。在naive算法中,每一个线程都需要直接从global memory中取数,其时延非常长,计算性能非常差。而进行分块后,将维度为bm∗bk,bk∗bn 的小矩阵块先存储到shared memory之中。而后计算单元进行计算时可以直接从shared memory中取数,大大减少了访存所需要的时延。

从shared memory到register

随后,我们进一步考虑从shared memory到register的过程。在这里,只分析一个block中的计算。当进行K轮迭代中某一轮迭代时,GPU将维度为bm∗bk,bk∗bn 的小矩阵块存储到shared memory中,而后各个线程将shared memory中的数据存入register中进行计算。

image-20220910215005453

不对shared memory分块时,一个block中含有bm∗bn 个线程,每一个线程负责C中一个元素的计算。则一个block一共需要对shared memory进行2∗bm∗bn∗bk 次读操作。而后考虑对shared memory进行分块,对bm∗bn 的小矩阵进行再一次划分,将其划分为多个维度为rm∗rn 的子矩阵。则一个block需要负责X∗Y 个子矩阵。其中,X=bmrm ,Y=bnrn 。随后,在一个block中开启X∗Y 个线程,每个线程负责一个维度为rm∗rn 的子矩阵的计算。在计算中,一个block一共需要从shared memory读取X∗Y∗(rm+rn)∗bk ,即bm∗bn∗bk∗(1/rm+1/rn) 个单精度浮点数。相比于未分块的算法,对于shared memory中的访存量减少为原来的1/2∗(1/rm+1/rn) 。并且,由于将数据放入register中,可以直接对数据进行运算,减少了从shared memory中取数的时延。

register分块

在这里,我们考虑最后一层,即register中的计算,并且只分析一个thread。在完成以上的过程后,对于一个线程而言,它现在拥有:rm 个A矩阵的寄存器值,rn 个B矩阵的寄存器值,以及rm∗rn 个C矩阵的寄存器值。通过这些寄存器的值,需要计算rm∗rn 个数。这需要rm∗rn 条FFMA指令。

这个时候会涉及到寄存器的bank conflict。在NV的GPU中,每个SM不仅会产生shared memroy之间的bank 冲突,也会产生寄存器之间的bank冲突。这一点对于计算密集型的算子十分重要。像shared memory一样,寄存器的Register File也会被分为几个bank,如果一条指令的的源寄存器有2个以上来自同一bank,就会产生冲突。指令会重发射,浪费一个cycle。

我们假设对这个thread来说,rm=4,rn=4 。并且计算C的寄存器以一种非常naive的情况分配,如下图左侧所示。则需要产生16条FFMA指令,列举如下:

1
2
3
FFMA R0, R16, R20, R0
FFMA R1, R16, R21, R1
……

image-20220910220109982

可以从中看出,这会产生大量的register bank冲突,所以需要对参与计算的寄存器重新进行分配和排布,如上图右侧所示。在有些地方,这种方式也可以叫做register分块。

数据的prefetch

最后,我们来讲讲如何通过对数据进行prefetch来减少访存的latency。我们再来回顾GEMM的过程,并且仔细地看看这个访存的latency到底是怎么导致的。对于一个block而言,需要计算一个bm∗bn 的矩阵块,这个时候需要进行K次迭代,每次迭代都需要先将来自A和B的两个小块送到shared memory中再进行计算。而从global中访存实际上是非常慢的,所以导致了latency。虽然GPU中可以通过block的切换来掩盖这种latency,但是由于分配的shared memory比较多,活跃的block并不太多,这种延时很难被掩盖。对于一个thread,需要计算一个rm∗rn 的小矩阵,但是必须先将数据从shared memory传到寄存器上,才能开始进行计算。所以导致了每进行一次迭代,计算单元就需要停下来等待,计算单元不能被喂饱。

为此,需要进行数据的Prefetch来尽可能地掩盖这种latency。思想也比较简单,需要多开一个buffer,进行读写分离。示意图如下。当block进行第2轮迭代时,需要对A2和B2进行计算,在计算单元进行计算的同时,我们将A3和B3提前放置到shared memory。而后,在进行第3轮迭代时,就可以直接对shared memory中的A3和B3进行计算,而不需要等待从global memory搬运到shared memory的时间。寄存器上的Prefetch也是同理。

image-20220910220136539

GEMM算法概述

这个章节里主要来说一下GEMM的一个计算流程,其实这一点已经在GEMM优化(一)中提及。但上一篇文章主要说得是原理,关于具体计算逻辑,还是不太直观,所以我们在这里再提一下。然后这个具体的计算逻辑分为两个阶段介绍,分别是不采用数据预取和采用数据预取,这主要是考虑到直接说数据预取,有读者可能会看得云里雾里,比较难受,所以先把不采用数据预取这个内容说明白,然后再来讲这个数据预取。

不采用数据预取

首先,我们先明确一下GEMM中的具体参数。取bm=128,bn=128,bk=8,rm=8,rn=8。当这几个参数选定之后先来直观地感受一下这几个参数意义,假定给了三个矩阵,A,B,C,其维度都是2048×2048。要求解C=A×B。那么我们需要开启(2048/128)×(2048/128)=256个block,每个block里面有(128/8)×(128/8)=256个线程,每个线程需要负责计算C矩阵中8×8=64个元素的结果,每个block负责256×64=16384个元素的结果。

明确了上面的参数之后,我们来仔细地观察其中一个block的计算逻辑。对于这个block而言,它需要进行2048/8=256次迭代,我们先把这个迭代称为大迭代,每一次大迭代都需要把A里面128×8=1024个元素和B里面8×128=1024个元素先放到shared memory中。然后这个block中的256个线程把结果计算出来。计算完之后,再进入下一次大迭代。不断重复该过程,直至这个block负责的16384个元素的结果被求解出。大迭代示意图如下:

image-20220910220741253

随后再具体看看每一个大迭代中,block中的线程的计算逻辑。在进行一个大迭代时,shared memory中有128×8=1024个A矩阵元素和8×128=1024个B矩阵元素。随后,每个线程需要进行8次迭代,我们把这个迭代成为小迭代。bk=8,所以有8次小迭代。每一次小迭代中,每个线程需要从shared memory中拿到A矩阵的一小列和B矩阵的一小行,即8个A的元素和8个B的元素。线程将这8+8=16个元素放置在寄存器中。每个线程需要负责8×8=64个元素的计算,一共会产生64条FFMA指令。小迭代示意图如下:

image-20220910220755159

以上就是不采用数据预取的GEMM算法计算逻辑。总的来说,对于一个block而言,有256个大迭代,每个大迭代中又有8个小迭代。这是后续内容的基础,如果还是不太清楚的话,可以再仔细看看,把这个过程完全搞清楚后,我们再继续接下来的内容,即采用数据预取后的GEMM算法计算逻辑。

采用数据预取

采用数据预取的GEMM计算流程稍有差异。这个差异主要是体现在两个方面,第一个是开启的shared memory和寄存器数量,第二个是需要提前将一些数据放置到shared memory和寄存器中。下面来仔细说说这个流程。

为了实现数据预取,需要开启两倍的shared memory和寄存器。当然也可以将原来shared memory切分成两块,也就是将bm×bk和bk×bn的矩阵一分为二。以A中的小矩阵而言,变成了两个bm×bk/2。然后大迭代次数由原来的256变成了512。很多地方把这个技术叫做双缓冲,我感觉跟预取是同一个事情。无非是针对参数bk的大小换不同说法。所以在这里统一叫做数据预取。废话说得有点多。总之,我们还是开启两倍的shared memory和寄存器数据。在一个block中,原来在shared memory中需要存储的数据是bm×bk+bk×bn。现在变成了bm×bk×2+bk×bn×2。在一个thread中,为了存储A和B的数据,原来需要使用rm+rn个寄存器,现在需要使用2×(rm+rn)个寄存器。为了后续方便介绍,我们用read SMwrite SM代表用来读写的两块共享内存,并用read REGwrite REG来表示用来读写的两块寄存器。

把共享内存和寄存器的事情说明白之后,我们来看看具体的计算逻辑。在执行256次大迭代之前,我们需要提前将第0次大迭代的数据存到write SM中,并且将第0次小迭代的数据存到write REG中。在完成这一个预取过程之后,我们再来仔细地看看第0个大迭代。需要注意的是,上一轮大迭代的write SM就是这一轮迭代的read SM。上一轮小迭代的write REG就是这一轮迭代的read REG。所以在进行第0个大迭代时,上面write SM就变成了read SM。然后我们首先需要将下一轮大迭代的数据存到write SM中。由于从global memory中取数的时钟周期非常多。所以在等待数据取回的同时,对read SM中的数据进行计算。也就是我们在等待的同时,需要开启8次小迭代来进行计算。而小迭代中也存在着读写分离,在对read REG进行计算之前,需要先执行write REG的操作,通过这种方式来掩盖访存的latency。所以整体的计算逻辑如下:

1
2
3
4
5
6
for k in 256 big_loop:
prefetch next loop data to write_SM
// compute in read_SM
for iter in 8 small_loop:
prefecth next loop data to write_REG
compute in read_REG

image-20220910220841079

GEMM代码解析

在上一节中已经将GEMM算法的流程再次回顾了一遍,接下来进入到代码解析环节。这里主要是解析采用了数据预取的GEMM。由于将数据从global memroy中搬运到shared memory中还经过了寄存器,所以对prefetch过程进行了细化,这个跟前面的伪代码稍有差异。

参数说明

首先需要说明的是模板参数,这也是后续对GEMM性能进行调参的最主要参数,往往不同的参数选择对最终的GEMM性能影响极大。后面的实验会展示在不同的参数下的性能比较。前三个参数,BLOCK_SIZE_M、BLOCK_SIZE_K、BLOCK_SIZE_N分别代表上文中的bm、bk、bn。中间两个参数,THREAD_SIZE_Y、THREAD_SIZE_X代表上文中的rm、rn。最后的参数ENABLE_DOUBLE_BUFFER代表是否采用双缓冲,即是否采用数据预取,在这里,我们只讨论采用数据预取,即开启双缓冲的情况。

1
2
3
4
5
6
7
8
template <
const int BLOCK_SIZE_M, // height of block of C that each block calculate
const int BLOCK_SIZE_K, // width of block of A that each block load into shared memory
const int BLOCK_SIZE_N, // width of block of C that each block calculate
const int THREAD_SIZE_Y, // height of block of C that each thread calculate
const int THREAD_SIZE_X, // width of block of C that each thread calculate
const bool ENABLE_DOUBLE_BUFFER // whether enable double buffering or not
>

接下来是线程类的参数。整个计算流程需要开启256个block,这256个block按照二维形态排布。而一个block中开启了256个线程,这256个线程按照二维形态进行排布。bx代表横向的block坐标,by代表竖向的block坐标。而tx代表横向的线程坐标,ty代表竖向的线程坐标。这是CUDA的基础内容,看不明白的同学可以找一些博客多理解一下,务必搞清楚。THREAD_X_PER_BLOCK代表在一个block中有多少个横向的线程,在这里等于16。THREAD_Y_PER_BLOCK代表在一个block中有多少个竖向的线程,在这里等于16。THREAD_NUM_PER_BLOCK代表在一个block中有多少个线程,在这里等于256。tid则代表当前线程在这256个线程中的id号。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Block index
int bx = blockIdx.x;
int by = blockIdx.y;

// Thread index
int tx = threadIdx.x;
int ty = threadIdx.y;

// the threads number in Block of X,Y
const int THREAD_X_PER_BLOCK = BLOCK_SIZE_N / THREAD_SIZE_X;
const int THREAD_Y_PER_BLOCK = BLOCK_SIZE_M / THREAD_SIZE_Y;
const int THREAD_NUM_PER_BLOCK = THREAD_X_PER_BLOCK * THREAD_Y_PER_BLOCK;

// thread id in cur Block
const int tid = ty * THREAD_X_PER_BLOCK + tx;

随后说明开启的shared memory和register数量。As代表为了存储A矩阵中的数据所需要开启的shared memory。在一轮迭代中需要使用bm×bk的数据,为了加快后续的访存,所以需要进行一次转置。并且为了预取,开了两倍的大小,一半用来读数据,一半用来写数据。所以一共需要2×BLOCK_SIZE_K×BLOCK_SIZE_M的空间。而Bs同理,但是载入数据时并不需要转置。accum用来临时存储C的计算结果。frag_a用来加载As中的rm个数据,为了预取也开启了双倍的空间。frag_b同理。ldg_num_a稍微有点费解,需要解释一下。为了将global memory的数据块搬运到shared memory中,需要先经过寄存器。也就是说,这个数据搬运过程其实是global memory->register->shared memory。所以为了临时存储A中的数据,需要开启一定量的寄存器。在一次大迭代中,我们总共需要搬运BLOCK_SIZE_M × BLOCK_SIZE_K个float数据,然后一个block中有THREAD_NUM_PER_BLOCK个线程,采用float4进行取数,即一个线程一次取4个数。则一共需要BLOCK_SIZE_M × BLOCK_SIZE_K/(THREAD_NUM_PER_BLOCK×4)次搬运就能把所有的数搬运到寄存器上。这个搬运次数用ldg_num_a表示。为了存储BLOCK_SIZE_M * BLOCK_SIZE_K的数据块,每个线程需要额外开启ldg_a_reg个寄存器进行存储。

1
2
3
4
5
6
7
8
9
10
11
12
13
// shared memory
__shared__ float As[2][BLOCK_SIZE_K][BLOCK_SIZE_M];
__shared__ float Bs[2][BLOCK_SIZE_K][BLOCK_SIZE_N];
// registers for C
float accum[THREAD_SIZE_Y][THREAD_SIZE_X] = {0};
// registers for A and B
float frag_a[2][THREAD_SIZE_Y];
float frag_b[2][THREAD_SIZE_X];
// registers load global memory
const int ldg_num_a = BLOCK_SIZE_M * BLOCK_SIZE_K / (THREAD_NUM_PER_BLOCK * 4);
const int ldg_num_b = BLOCK_SIZE_K * BLOCK_SIZE_N / (THREAD_NUM_PER_BLOCK * 4);
float ldg_a_reg[4*ldg_num_a];
float ldg_b_reg[4*ldg_num_b];

最后需要说明的参数是在global->shared memory阶段用到。我们开启了256个线程,在一次大迭代中需要将128×8个元素搬运到shared memory中。我们用下面的参数说明了这个搬运的逻辑。A_TILE_THREAD_PER_ROW代表把搬运一行数据需要使用多少个线程,为了搬运A的一行,需要使用2个线程。

A_TILE_ROW_START代表在这个维度为bm×bk的数据块中,当前线程需要搬运的数据的竖向坐标,而A_TILE_COL代表需要搬运的数据的横向坐标。对3号线程而言,由于它要搬运(1,1)号数据块中的4个元素。所以,A_TILE_ROW_START是1,A_TILE_COL是4。A_TILE_ROW_STRIDE代表在进行多次搬运时需要跨越的行。假设As是一块256×8的数据块(这个设置跟前面不一样),256个线程进行搬运,一次搬运4个数,所以要搬运两次。对于3号线程而言,分别搬运下图中的绿色数据块。

image-20220910221016698

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// threads number in one row
const int A_TILE_THREAD_PER_ROW = BLOCK_SIZE_K / 4;
const int B_TILE_THREAD_PER_ROW = BLOCK_SIZE_N / 4;

// row number and col number that needs to be loaded by this thread
const int A_TILE_ROW_START = tid / A_TILE_THREAD_PER_ROW;
const int B_TILE_ROW_START = tid / B_TILE_THREAD_PER_ROW;

const int A_TILE_COL = tid % A_TILE_THREAD_PER_ROW * 4;
const int B_TILE_COL = tid % B_TILE_THREAD_PER_ROW * 4;

// row stride that thread uses to load multiple rows of a tile
const int A_TILE_ROW_STRIDE = THREAD_NUM_PER_BLOCK / A_TILE_THREAD_PER_ROW;
const int B_TILE_ROW_STRIDE = THREAD_NUM_PER_BLOCK / B_TILE_THREAD_PER_ROW;

大迭代前预取数据

在介绍完相关参数之后,我们来进入到具体的代码逻辑。为了代码简洁,用float4读取的过程用了两个宏,定义如下:

1
2
#define OFFSET(row, col, ld) ((row) * (ld) + (col))
#define FETCH_FLOAT4(pointer) (reinterpret_cast<float4*>(&(pointer))[0])

迭代前预取数据分为两个部分第一个部分是将第一个大迭代的数据从global 预取到shared memroy中。第二个部分是将shared memory上的数据预取到寄存器中。先来看看第一个部分。这里面分别是将第一个大迭代中需要的A、B数据预取到shared memroy中。对于A矩阵而言,这个for循环代表着block中的线程需要搬运多少次才能将globa中的数据放到shared memory中。由于A需要先进行一次转置,所以先将数据先放置在寄存器中。数据按行取,然后按列存。对于B矩阵而言,数据不用转置,直接按行取,按行存。当然,这个过程中间也要经过寄存器,但是没有写出来的必要了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// load A from global memory to shared memory
#pragma unroll
for ( int i = 0 ; i < BLOCK_SIZE_M ; i += A_TILE_ROW_STRIDE) {
int ldg_index = i / A_TILE_ROW_STRIDE * 4;
FETCH_FLOAT4(ldg_a_reg[ldg_index]) = FETCH_FLOAT4(A[OFFSET(
BLOCK_SIZE_M * by + A_TILE_ROW_START + i, // row
A_TILE_COL, // col
K )]);
As[0][A_TILE_COL][A_TILE_ROW_START + i]=ldg_a_reg[ldg_index];
As[0][A_TILE_COL+1][A_TILE_ROW_START + i]=ldg_a_reg[ldg_index+1];
As[0][A_TILE_COL+2][A_TILE_ROW_START + i]=ldg_a_reg[ldg_index+2];
As[0][A_TILE_COL+3][A_TILE_ROW_START + i]=ldg_a_reg[ldg_index+3];
}
// load B from global memory to shared memory
#pragma unroll
for ( int i = 0 ; i < BLOCK_SIZE_K; i += B_TILE_ROW_STRIDE) {
FETCH_FLOAT4(Bs[0][B_TILE_ROW_START + i][B_TILE_COL]) = FETCH_FLOAT4(B[OFFSET(
B_TILE_ROW_START + i, // row
B_TILE_COL + BLOCK_SIZE_N * bx, // col
N )]);
}
__syncthreads();

然后就是第二个部分。将shared memory中的数据存到寄存器中。一共需要取THREAD_SIZE_Y个数,每次取4个数。这个倒没有什么好说的。

1
2
3
4
5
6
7
8
9
10
// load A from shared memory to register
#pragma unroll
for (int thread_y = 0; thread_y < THREAD_SIZE_Y; thread_y += 4) {
FETCH_FLOAT4(frag_a[0][thread_y]) = FETCH_FLOAT4(As[0][0][THREAD_SIZE_Y * ty + thread_y]);
}
// load B from shared memory to register
#pragma unroll
for (int thread_x = 0; thread_x < THREAD_SIZE_X; thread_x += 4) {
FETCH_FLOAT4(frag_b[0][thread_x]) = FETCH_FLOAT4(Bs[0][0][THREAD_SIZE_X * tx + thread_x]);
}

大迭代逻辑

在完成上一步后,我们要进入到大迭代中,按照前面的参数,我们需要进行256个大迭代。先忽略这个迭代里面的具体代码,看看这个框架,如下所示。首先要说的是write_stage_idx这个参数。之前定义了__shared__ float As[2][BLOCK_SIZE_K][BLOCK_SIZE_M]。为了读写分离,给As开了两块空间。如果write_stage_idx=1,就对As[1]空间进行写操作,对As[0]空间进行读操作。因为我们之前将数据预取到了As[0]这个空间里,所以在第一个大迭代时,对As[0]进行读操作,对As[1]进行写操作,所以write_stage_idx=1。再来看看tile_idx这个参数,这个代表大迭代时,在A矩阵的列号。每一次大迭代要读取BLOCK_SIZE_K列,直到完成大迭代,即tile_idx=K为止。再看看循环里面的load_stage_idx,这个和write_stage_idx对应,两者保持二进制位相反即可。

1
2
3
4
5
6
7
8
9
10
int write_stage_idx = 1;
int tile_idx = 0;
do{
tile_idx += BLOCK_SIZE_K;
int load_stage_idx = write_stage_idx ^ 1;
// compute
if(tile_idx < K){
write_stage_idx ^= 1;
}
}while(tile_idx< K);

大迭代详细解析

我们在这里开始说明具体的大迭代。下面代码描述的是,如果还有下一个迭代,则将下一个迭代的数据块,搬运到寄存器上,这里面的for循环代表可能需要多次搬运。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
tile_idx += BLOCK_SIZE_K;
// load next tile from global mem
if(tile_idx< K){
#pragma unroll
for ( int i = 0 ; i < BLOCK_SIZE_M ; i += A_TILE_ROW_STRIDE) {
int ldg_index = i / A_TILE_ROW_STRIDE * 4;
FETCH_FLOAT4(ldg_a_reg[ldg_index]) = FETCH_FLOAT4(A[OFFSET(
BLOCK_SIZE_M * by + A_TILE_ROW_START + i, // row
A_TILE_COL + tile_idx, // col
K )]);
}
#pragma unroll
for ( int i = 0 ; i < BLOCK_SIZE_K; i += B_TILE_ROW_STRIDE) {
int ldg_index = i / A_TILE_ROW_STRIDE * 4;
FETCH_FLOAT4(ldg_b_reg[ldg_index]) = FETCH_FLOAT4(B[OFFSET(
tile_idx + B_TILE_ROW_START + i, // row
B_TILE_COL + BLOCK_SIZE_N * bx, // col
N )]);
}
}

随后进入到小迭代的计算逻辑之中,load_stage_idx参数代表需要从As的哪个空间进行读数。然后是BLOCK_SIZE_K-1次小迭代。按照前面的参数配置,即需要在这里完成7次小迭代。由于在小迭代中也采用了双缓冲的方式,需要将下一轮小迭代的数据提前写入到寄存器中,这个过程需要对shared memory访存,会稍微慢点。与此同时,线程需要计算更新THREAD_SIZE_X x THREAD_SIZE_Y=8×8=64个C矩阵元素的结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int load_stage_idx = write_stage_idx ^ 1;
#pragma unroll
for(int j=0; j<BLOCK_SIZE_K-1; ++j){
// load next tile from shared mem to register
// load A from shared memory to register
#pragma unroll
for (int thread_y = 0; thread_y < THREAD_SIZE_Y; thread_y += 4) {
FETCH_FLOAT4(frag_a[(j+1)%2][thread_y]) = FETCH_FLOAT4(As[load_stage_idx][j+1][THREAD_SIZE_Y * ty + thread_y]);
}
// load B from shared memory to register
#pragma unroll
for (int thread_x = 0; thread_x < THREAD_SIZE_X; thread_x += 4) {
FETCH_FLOAT4(frag_b[(j+1)%2][thread_x]) = FETCH_FLOAT4(Bs[load_stage_idx][j+1][THREAD_SIZE_X * tx + thread_x]);
}
// compute C THREAD_SIZE_X x THREAD_SIZE_Y
#pragma unroll
for (int thread_y = 0; thread_y < THREAD_SIZE_Y; ++thread_y) {
#pragma unroll
for (int thread_x = 0; thread_x < THREAD_SIZE_X; ++thread_x) {
accum[thread_y][thread_x] += frag_a[j%2][thread_y] * frag_b[j%2][thread_x];
}
}
}

而后需要将存储在临时寄存器的数据搬运到shared memory中。由于A矩阵需要经过一次转置,所以和B矩阵有一点不一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
if(tile_idx < K){
#pragma unroll
for ( int i = 0 ; i < BLOCK_SIZE_M ; i += A_TILE_ROW_STRIDE) {
int ldg_index = i / A_TILE_ROW_STRIDE * 4;
As[write_stage_idx][A_TILE_COL][A_TILE_ROW_START + i]=ldg_a_reg[ldg_index];
As[write_stage_idx][A_TILE_COL+1][A_TILE_ROW_START + i]=ldg_a_reg[ldg_index+1];
As[write_stage_idx][A_TILE_COL+2][A_TILE_ROW_START + i]=ldg_a_reg[ldg_index+2];
As[write_stage_idx][A_TILE_COL+3][A_TILE_ROW_START + i]=ldg_a_reg[ldg_index+3];
}
// load B from global memory to shared memory
#pragma unroll
for ( int i = 0 ; i < BLOCK_SIZE_K; i += B_TILE_ROW_STRIDE) {
int ldg_index = i / A_TILE_ROW_STRIDE * 4;
FETCH_FLOAT4(Bs[write_stage_idx][B_TILE_ROW_START + i][B_TILE_COL]) = FETCH_FLOAT4(ldg_b_reg[ldg_index]);
}
// use double buffer, only need one sync
__syncthreads();
// switch
write_stage_idx ^= 1;
}

最后完成寄存器的预取,并将最后一个小迭代完成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// load A from shared memory to register
#pragma unroll
for (int thread_y = 0; thread_y < THREAD_SIZE_Y; thread_y += 4) {
FETCH_FLOAT4(frag_a[0][thread_y]) = FETCH_FLOAT4(As[load_stage_idx^1][0][THREAD_SIZE_Y * ty + thread_y]);
}
// load B from shared memory to register
#pragma unroll
for (int thread_x = 0; thread_x < THREAD_SIZE_X; thread_x += 4) {
FETCH_FLOAT4(frag_b[0][thread_x]) = FETCH_FLOAT4(Bs[load_stage_idx^1][0][THREAD_SIZE_X * tx + thread_x]);
}
//compute last tile mma THREAD_SIZE_X x THREAD_SIZE_Y
#pragma unroll
for (int thread_y = 0; thread_y < THREAD_SIZE_Y; ++thread_y) {
#pragma unroll
for (int thread_x = 0; thread_x < THREAD_SIZE_X; ++thread_x) {
accum[thread_y][thread_x] += frag_a[1][thread_y] * frag_b[1][thread_x];
}
}

计算结果写回

此时,最后的计算结果已经被存储在accum寄存器中,需要将其写回到global memory中。这个代码比较简单,就没啥好说的了。

1
2
3
4
5
6
7
8
9
10
11
// store back to C
#pragma unroll
for (int thread_y = 0; thread_y < THREAD_SIZE_Y; ++thread_y) {
#pragma unroll
for (int thread_x = 0; thread_x < THREAD_SIZE_X; thread_x+=4) {
FETCH_FLOAT4(C[OFFSET(
BLOCK_SIZE_M * by + ty * THREAD_SIZE_Y + thread_y,
BLOCK_SIZE_N * bx + tx * THREAD_SIZE_X + thread_x,
N)]) = FETCH_FLOAT4(accum[thread_y][thread_x]);
}
}

实验

针对GEMM性能优化,我做了一些实验,主要是想要说明这么两个问题:

  1. 不采用任何汇编的情况下,手写CUDA代码会比cublas差多少?
  2. bm、bn、bk、rm、rn等相关参数对GEMM的性能表现有多大影响?

针对第一个问题,固定了bm、bn、bk、rm、rn的取值为64、8、64、8、8。在V100上测试了不同维度的矩阵(设置M=N=K),并且对比了cublas,其性能结果如下图。横坐标是矩阵维度,纵坐标是GFLOPS。可以在图中看出,在大维度的矩阵下,我们手写的Sgemm大概能达到平均14TFLOPS,性能表现达到cublas的 91%。V100的单精度峰值性能是15.7TFLOPS,在完全不使用汇编,并且有着较好的代码可读性的同时,我们手写的Sgemm大概能达到**90%**的单精度峰值效率。当然,如果不考虑代码可读性的话,这个性能可以进一步提高。在这里可以得出结论,其实也是想消除大家的一个误解。很多人觉得只有写汇编才能写出高性能的代码。其实并不是这样,性能优化中最重要的是并行算法和优化策略,单纯地将代码写成汇编并不会有多少性能提升。

image-20220910221600420

从汇编代码分析程序性能

我们为什么要去看生成的汇编代码?这主要是由于做完优化之后,我们需要有一个东西来判断机器是否能够真正地按照我们设想的模式运行。使用了float4之后,GPU是不是真的使用了向量化指令。采用循环展开之后,GPU是不是真的会进行展开?另外,CUDA C和汇编代码之间还隔着编译器。只有看最底层的汇编码,才能真正地理解我们所做的优化是在哪个地方起了作用,节省了哪个部分的耗时。

NV的GPU提供了ptx和sass两个层面的汇编码。Ptx本质上是一个伪汇编码,事实上机器真正能够识别的是sass码。Ptx还需要使用ptxas工具再转化成sass码才能被GPU识别。然后nv提供了cuobjdump和nvdisasm两个工具,我们可以通过这两个工具来看到最底层的汇编码。

NV每一代机器的指令集都有所不同。此外,NV的指令还有一个特别有意思的东西,那就是control code,后面直接用控制码表示。通过控制码将一些本来应该在硬件实现的逻辑软件化了,从而在同样大小的电路面积上塞下更大的计算单元。

当我们在看汇编代码的时候,我们到底看的是什么东西。这个话题可以分为两部分介绍,分别是访存密集型的kernel和计算密集型的kernel。

对于访存密集型的kernel,正常而言,我们需要关注的是:访问global memory的时候是不是合并访存了,访问shared memory的时候是不是有bank 冲突了。很不幸的是,在汇编代码中,这些东西其实不太能看得出来。我们主要关注的是有没有采用LDG.128的访存指令,以及计算指令的占比是不是太多,#pragma unroll是不是有效展开了。

对于计算密集型的kernel而言,我们重点关注计算指令的占比。这个一般跟并行策略会联系在一起。一般而言,如果并行策略不太行,那么计算指令的占比会很低,这样的话,访存所导致的latency很难被计算指令掩盖,计算效率会非常差。如果并行策略比较好,那么计算指令的占比也会非常地高。也只有当计算指令占比非常高的时候,才有可能地去逼近峰值性能。

对于现有sgemm的代码分析及观察

在分析之前,我们对目前已有的工作先做一个回顾。sgemm是hpc领域的经典问题,目前有大量的论文在针对不同硬件架构,不同矩阵特性进行研究。对于NV的GPU,关于sgemm最著名的工作是scott的maxas。在Maxwell架构上的部分卡上能够达到98%的浮点性能,几乎到达极限。也就是从这个工作以后,针对NV的sgemm优化工作基本上就没法做了,关于针对大矩阵的sgemm优化,也没有太多的研究价值了。当然,针对不同硬件架构的sgemm优化还是层出不出,但基本上是一些follow的工作,然后做一些小修小补。

我们来分析一下scott的工作。在CUDA C层面,不涉及汇编的话,优化技巧主要有3个方面:

技巧1,global->shared memory,采用了texture内存,将线程划分,一半线程只读A,一半线程只读B。

技巧2,shared memory->register,将8×8的读取变成4个4×4的读取,从而避免bank冲突。

image-20220910221941317

技巧3,Store C矩阵的时候,为了合并访存,采用了一种非常奇怪的方式去store。

image-20220910221952729

针对大矩阵的sgemm计算时。如果k维度足够大,global->shared memory以及store C的耗时占比会非常小,所以这两个优化技巧在大矩阵中并不能起到很大的作用。所以相对来说,技巧2会更加具有借鉴意义

紧接着,我们来分析一下sgemm中最耗时的部分,也就是最内层的迭代部分。需要计算8×8×8=512次乘加运算。Scott的sgemm在maxwell产生的汇编代码如下图左,为了比较,我们将GEMM(二)中的代码sgemm_v2最后生成的SASS码放在一起用以比较。

image-20220910222351625

可以从上面看到,512条FFMA和32条LDS指令,最核心的计算指令和访存指令都是一样的。但是GEMM(二)中用编译器产生的汇编码有更多的非计算指令存在。而且如果从上面的链接点进去的话,就会发现,FFMA指令被划到2个代码块中,相对而言,中间会多一个跳转指令。另外一个需要注意的点是scott的代码是针对Maxwell架构,所以将可以用于双发射的指令进行了单独标记。而笔者写的代码是在volta架构上编译运行的,volta架构取消了双发射。但是两个cycle发射一条FFMA指令就可以将所有的fp32 core填满。计算指令和访存指令占据不同的发射端口,计算和访存可以隔一个cycle发射。所以我的猜想是这样的,对于volta架构,t0 cycle的时候发射一条FFMA指令,t1 cycle的时候发射一条LDS指令,而后t2时刻再发射一条FFMA指令。这样的话,FFMA指令隔了2个cycle,中间还发射了一条LDS指令,但fp32的core依旧是被用满的状态。这样的话,即使没有了双发射,理论上也能将fp32 core打满。从volta架构编译出来的控制码中也可以看出一些端倪,如下,FFMA指令stall两个cycle,而LDS指令stall一个cycle。

1
2
3
4
[R---:B------:R-:W-:-:S02]         /*0cd0*/                   FFMA R115, R39.reuse, R14, R115 ;
[----:B------:R-:W-:-:S02] /*0ce0*/ FFMA R114, R39, R15, R114 ;
[----:B------:R-:W1:-:S01] /*0cf0*/ LDS.U.128 R36, [R40+0x2410] ;
[R---:B------:R-:W-:-:S02] /*0d00*/ FFMA R113, R32.reuse, R12, R113 ;

然后总结一下这小节的内容,从CUDA C和SASS代码的角度分析了现有sgemm实现的不足。进一步的优化工作可以从两个方面进行:1、shared memory->register,将8×8的读取变成4个4×4的读取。2、尽可能地减少非必要指令的开销,但是这个在CUDA C层面很难控制,毕竟编译器也没那么听话。

汇编级别代码调整

好了,终于讲到了调汇编的地方。上面小节说了,优化的一个方式是尽可能地减少非必要指令的开销。但是,当我们开始调汇编的时候,还有一个更重要的事情需要做,也是在maxas、KeplerAs等一系列工作的核心,减少FFMA指令所产生的register bank冲突。这里面有两个优化技巧,一个是寄存器的重映射,另外一个是调整FFMA顺序,尽可能地在指令中使用.reuse标识以及提高双发射的效率。

寄存器的重映射

在这里面,由于每代架构中的硬件细节有所不同,所以register的remapping细节也有所不同。首先说一下这里面的硬件细节不同是指,不同的架构中,寄存器到bank的映射方式不同。kepler架构的映射比较奇怪,并不是很规则,如下:

image-20220910222431933

对于Maxwell架构而言,相对来说更加简单一些,bank index即reg_index%4这么一个简单的关系。Pascal架构和Maxwell架构的寄存器bank映射关系一样。而volta架构又有一些不同,在volta之前都是4路的bank,而volta架构变成了2路的bank。

由于架构不一样,针对不同架构的register重映射方式也不一样。对于kepler架构,keplerAs的作者采用的映射方式如下:

image-20220910222443696

对于Maxwell架构,Scott采用的映射方式如下:

image-20220910222453184

上图中间那些带黑框的数字代表不可避免的寄存器冲突,scott随后又使用了指令重排来减缓寄存器的冲突。

而volta架构的话,Dissecting the NVIDIA Volta GPU Architecture via Microbenchmarking作者采用的方式如下,作了一个转置,然后相邻两行进行一个交换。

image-20220910222504575

指令重排

这里的指令重排主要是针对FFMA指令的重排。作用的话,其实有两个。在maxwell架构中,scott重排主要是为了尽可能地解决对角线那些元素的寄存器bank冲突。在这里插一嘴,因为部分读者对于这个重排可能理解不是很到位。举个例子吧,要计算C矩阵中1,2,3,4,5的元素的值,正常的顺序是调用FFMA指令先算1,再算2,再算3,等等。重排的话,就是可能先算2,再算1,再算3。从指令角度的话,就是FFMA指令的排列顺序有所不同,所以叫指令重排,这个是我的个人理解。

重排的目的是为了更好地使用reuse标识,这个地方可以看看旷视写的矩阵乘终极优化指南,当然,基本上也就是scott的sgemm介绍内容。读取指令的操作数的时候,有一个寄存器的reuse cache。在指令中使用这个标识就代表这个数被hold住了,下一条指令可以直接使用。这个地方,大家都是这么说的,NV也没有官方的说明,那就这么理解吧。具体示意代码如下:

1
2
FFMA R2, R64.reuse, R73, R2; ## R64 进入 Reuse Cache
FFMA R3, R64.reuse, R72, R3; ## R64 从 Reuse Cache 中获取,避免与 R72 冲突

为了更好地利用这个reuse特性,scott给了一种非常奇怪的指令排列顺序,如下:

1
2
3
4
 1,  0,  2,  3,  5,  4,  6,  7, 33, 32, 34, 35, 37, 36, 38, 39, 
45, 44, 46, 47, 41, 40, 42, 43, 13, 12, 14, 15, 9, 8, 10, 11,
17, 16, 18, 19, 21, 20, 22, 23, 49, 48, 50, 51, 53, 52, 54, 55,
61, 60, 62, 63, 57, 56, 58, 59, 29, 28, 30, 31, 25, 24, 26, 27

通过CUDA C说的一系列优化手段,以及寄存器的remapping和指令重排,scott的sgemm在Maxwell架构的一些卡上能够达到98%的浮点计算效率,达到了优化的天花板。

扯远了,再说说指令重排,keplerAs的作者张秀霞针对kepler的双发射特性对FFMA指令进行了指令重排来提高性能。这个跟scott的工作又有一些不一样的地方,大家可以对比一下。

实验与总结

最后,我们来做一下实验。实验分成两个部分,第一个部分是CUDA C层面的再次优化,第二个部分是针对SASS代码的调优工作以及中间经历的一些波折。

CUDA C 调优

这个部分的内容主要是介绍一下怎么解决GEMM(二)所存在的shared memory bank冲突。其实scott的文章已经说了这一点,但是吧,实在是太费解了。首先,再来回顾一下这个思路。我们一个block有256个线程,8个warp,8个warp要去取shared memory中的半行元素,也就是128/2=64个元素。warp0和warp4取得是同样的16个元素。而warp里面,线程0、2、4、6、8、10、12、14是取得同样的4个元素。由于取得是同样的元素,同一个bank触发多播的机制,没有冲突。取多少元素说清楚了,就得说一下shared memory的索引了。scott给出的256线程版本索引是:

1
2
readAs = ((tid128 >> 4) | ((tid >> 1) & 7)) << 4;
readBs = (((tid & 0x70) >> 3) | (tid & 1)) << 4 + 4096;

image-20220910222717437

总之,这个索引给我整不会了。作为一个正常的人类,我实在是不太能直观地去理解这个位运算。思量许久,我决定用一种最简单粗暴的索引计算方式。我们本质上是要知道,每一个线程,对应到128个元素中的哪一个元素?这个是我们的核心问题。

我来说一下我的计算方法,以B矩阵对应的shared memory为例,首先,计算warp_id,也就是当前线程属于哪个warp,由tid/32即可得。随后计算lane_id,即当前线程属于这个warp上得哪个线程,由tid%32即可得。随后就是通过warp_id和lane_id来算出,对应128个元素得哪一个元素。先算(warp_id%4)×16,假设是warp2,就是上图左侧的第2个(从0算)warp。前面有2个warp,跳过了2*16=32个元素。然后再看看当前lane_id。0-15在左半边,16-31在右半边。所以lane_id/16,先看是左半边还是右半边。右半边的话,先跳过8个元素。最后再看lane_id的奇偶数,如果奇数的话,就再跳一个四个元素。代码实现如下,这个就是正常人可以看懂的方式了。对A矩阵的映射关系同理。

1
2
3
4
5
//load index of the tile
const int warp_id = tid / 32;
const int lane_id = tid % 32;
const int tile_index_b = (warp_id%4)*16 + (lane_id/16)*8 + (lane_id%2)*4;
const int tile_index_a = (warp_id/4)*32 + ((lane_id%16)/2)*4;

然后shared memory取数的代码更改就是下面这样,以B矩阵块为例:

1
2
3
4
5
6
7
8
// 改变前
#pragma unroll
for (int thread_y = 0; thread_y < THREAD_SIZE_Y; thread_y += 4) {
FETCH_FLOAT4(frag_b[(j+1)%2][thread_y]) = FETCH_FLOAT4(Bs[next_stage_flag][(j+1)%BLOCK_SIZE_K][THREAD_SIZE_Y * ty + thread_y]);
}
// 改变后
FETCH_FLOAT4(frag_b[(j+1)%2][0]) = FETCH_FLOAT4(Bs[next_stage_flag][(j+1)%BLOCK_SIZE_K][tile_index]);
FETCH_FLOAT4(frag_b[(j+1)%2][4]) = FETCH_FLOAT4(Bs[next_stage_flag][(j+1)%BLOCK_SIZE_K][tile_index + 64]);

当然,因为用来寄存C的64个元素对应的位置变化,所以最后的store C的过程也有代码变动。

在进行了这个修改之后,4096(M=N=K)的矩阵大概可以达到96-97%的cublas的性能。单精度峰值浮点效率达93%左右。再往下想要持平或者超越cublas的话,就只能动汇编了。

汇编代码调优

在做寄存器remapping的时候,发现NVCC编译出来的代码是这个样子:

1
2
3
4
FFMA R125, R52, R44, R72 ;
FFMA R122, R53, R44.reuse, R73 ;
FFMA R74, R54, R44.reuse, R74 ;
FFMA R75, R55, R44.reuse, R75 ;

看看第一条指令,做R125=R52×R44+R72,R72的值被拿出来,然后存到了R125上。编译出来的代码有一大堆这样的指令。而我希望所有的指令都满足第3条的样子,R74=R54×R44+R74,从R74取就放回R74才最好。如果不能保证这个形式的话,就意味着,我们不能让固定的寄存器来存储矩阵C中的固定的值。这玩意做remapping的话,就不能简简单单地改寄存器号。毕竟我也不能确定不同的寄存器对应到哪个具体的值了。

当时想了各种方式,调整CUDA C代码来让nvcc编译出我想要的FFMA格式,但是,这个尝试并不能实现。所以接下来,有两个方式,一个是头铁,搞清楚这个100多个寄存器在512条FFMA指令中对应的物理元素,然后做remapping,这个路线中间会遇到可以预想的无数的bug和计算问题。另一个是参考Maxas,把这玩意整合到汇编器上,定义好每个寄存器的对应元素和排列顺序。然后汇编器顺带着处理,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<REGISTER_MAPPING>

// Temporary registers to calculate the state registers. Reuse the C output registers.
// These can be dynamically allocated (~) in the available registger space to elimiate any register bank conflicts.
0-63 ~ blk, ldx, ldx2, ldx4, k, tid1, tid4, tid7, tid31_4, xmad_t0, xmad_end, bxOrig, byOrig, loy

// Aliases for the C registers we use for initializing C (used as vectors)
0-63 : cz<00-63>

// The offset we store our zero value for initializing C. Reuse a register from the second blocking registers
80 : zOffset

// 64 C maxtrix output registers.
// Use special mapping to avoid register bank conflicts between these registers and the blocking registers.
3, 2,11,10,19,18,27,26 : cx00y<00-03|64-67>
7, 6,15,14,23,22,31,30 : cx01y<00-03|64-67>
1, 0, 9, 8,17,16,25,24 : cx02y<00-03|64-67>
5, 4,13,12,21,20,29,28 : cx03y<00-03|64-67>
35,34,43,42,51,50,59,58 : cx64y<00-03|64-67>
39,38,47,46,55,54,63,62 : cx65y<00-03|64-67>
33,32,41,40,49,48,57,56 : cx66y<00-03|64-67>
37,36,45,44,53,52,61,60 : cx67y<00-03|64-67>

// Double buffered register blocking used in vector loads.
// Any bank conflicts that we can't avoid in these registers we can hide with .reuse flags
64-79 : j0Ax<00-03|64-67>, j0By<00-03|64-67>
80-95 : j1Ax<00-03|64-67>, j1By<00-03|64-67>

// Registers to load A or B
96-103 : loadX<0-7>

// Key global state registers for main loop and some we reuse for outputing C.
// Note, tweaking the register banks of track<0|4>, tex, writeS, readBs, readAs impacts performance because of
// delayed bank conflicts between memory operations and ffmas.
// The array index bracket notation can be used to request a bank in a dynamically allocated range.
104-127 ~ track<0|4>[0], tex[2], readAs[2], readBs[3], writeS[3], end, ldx8, tid, bx, by, tid31, tid96, tid128 //, clock, smId, nSMs

// Registers to store the results back to global memory. Reuse any register not needed after the main loop.
// Statically allocate cs0-7 because they're vector registers.
64-71 : cs<0-7>

// dynamically allocated C output registers(~)
72-103 ~ cy<00|04|08|12>, Cy<00|04|08|12>, ldc, ldc1, ldc4, ldc8, ldc60, writeCs, readCs, cx, ci, alpha, xmad_ci //, xmad_D, D, blckDimX, gridDimX

</REGISTER_MAPPING>

然而,我只是想简简单单写个sgemm,我并不想把我有限的周末时间全部投进去,毕竟读者也没给我钱。然后想想指令重排,通过reuse标识也能解决一部分reg的bank冲突,那就整这个吧。

遇到的另一个问题就是指令重排。我把里面所有存在寄存器bank冲突的指令列了出来。再来看看volta架构中的bank冲突,volta架构的寄存器有2路bank,奇数寄存器号代表bank0,偶数寄存器号代表bank1。如果FFMA指令的三个源寄存器的寄存器号都属于奇数或者偶数,那么就发生了bank冲突。

1
2
3
//0    FFMA R74, R36, R62.reuse, R74 ;    
//1 FFMA R78, R34, R62.reuse, R78 ;
//2 FFMA R16, R35, R62, R54 ;

比如上面的代码,0号指令和1号三个源寄存器都是偶数,不考虑reuse标识的话,都有bank冲突,而2号指令就没有bank冲突。调整这3个的位置,变成:

1
2
3
//2    FFMA R16, R35, R62.reuse, R54 ;   
//1 FFMA R78, R34, R62.reuse, R78 ;
/ 0 FFMA R74, R36, R62.reuse, R74 ;

让指令2的R62放入reuse cache中,指令1和指令0继续使用这个数,从而减少bank冲突。更改前后的代码在我的github repo中。但是改完之后,我发现性能提升并不是很明显,大概就是1%左右的性能提升。这可能是在sgemm_v2的基础上改的原因,当时4.1所说的shared memory bank冲突还比较明显。总之,实验大概就是这样子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
// optimize sgemm

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

// CUDA runtime
#include <cuda_runtime.h>
#include <cublas_v2.h>

// cal offset from row col and ld , in row-major matrix, ld is the width of the matrix
#define OFFSET(row, col, ld) ((row) * (ld) + (col))

// transfer float4
#define FETCH_FLOAT4(pointer) (reinterpret_cast<float4*>(&(pointer))[0])

#define checkCudaErrors(func) \
{ \
cudaError_t e = (func); \
if(e != cudaSuccess) \
printf ("%s %d CUDA: %s\n", __FILE__, __LINE__, cudaGetErrorString(e)); \
}

// K: ldA
// N: ldB
template <
const int BLOCK_SIZE_M, // height of block of C that each thread block calculate
const int BLOCK_SIZE_K, // width of block of A that each thread block load into shared memory
const int BLOCK_SIZE_N, // width of block of C that each thread block calculate
const int THREAD_SIZE_Y, // height of block of C that each thread calculate
const int THREAD_SIZE_X, // width of block of C that each thread calculate
const bool ENABLE_DOUBLE_BUFFER // whether enable double buffering or not
>
__global__ void Sgemm(
float * __restrict__ A,
float * __restrict__ B,
float * __restrict__ C,
const int M,
const int N,
const int K) {
// Block index
int bx = blockIdx.x;
int by = blockIdx.y;

// Thread index
int tx = threadIdx.x;
int ty = threadIdx.y;

// the threads number in Block of X,Y
const int THREAD_X_PER_BLOCK = BLOCK_SIZE_N / THREAD_SIZE_X;
const int THREAD_Y_PER_BLOCK = BLOCK_SIZE_M / THREAD_SIZE_Y;
const int THREAD_NUM_PER_BLOCK = THREAD_X_PER_BLOCK * THREAD_Y_PER_BLOCK;

// thread id in cur Block
const int tid = ty * THREAD_X_PER_BLOCK + tx;

// shared memory
__shared__ float As[2][BLOCK_SIZE_K][BLOCK_SIZE_M];
__shared__ float Bs[2][BLOCK_SIZE_K][BLOCK_SIZE_N];
// registers for C
float accum[THREAD_SIZE_Y][THREAD_SIZE_X];
#pragma unroll
for(int i=0; i<THREAD_SIZE_Y; i++){
#pragma unroll
for(int j=0; j<THREAD_SIZE_X; j++){
accum[i][j]=0.0;
}
}
// registers for A and B
float frag_a[2][THREAD_SIZE_Y];
float frag_b[2][THREAD_SIZE_X];
// registers load global memory
const int ldg_num_a = BLOCK_SIZE_M * BLOCK_SIZE_K / (THREAD_NUM_PER_BLOCK * 4);
const int ldg_num_b = BLOCK_SIZE_K * BLOCK_SIZE_N / (THREAD_NUM_PER_BLOCK * 4);
float ldg_a_reg[4*ldg_num_a];
float ldg_b_reg[4*ldg_num_b];

// threads number in one row
const int A_TILE_THREAD_PER_ROW = BLOCK_SIZE_K / 4;
const int B_TILE_THREAD_PER_ROW = BLOCK_SIZE_N / 4;

// row number and col number that needs to be loaded by this thread
const int A_TILE_ROW_START = tid / A_TILE_THREAD_PER_ROW;
const int B_TILE_ROW_START = tid / B_TILE_THREAD_PER_ROW;

const int A_TILE_COL = tid % A_TILE_THREAD_PER_ROW * 4;
const int B_TILE_COL = tid % B_TILE_THREAD_PER_ROW * 4;

// row stride that thread uses to load multiple rows of a tile
const int A_TILE_ROW_STRIDE = THREAD_NUM_PER_BLOCK / A_TILE_THREAD_PER_ROW;
const int B_TILE_ROW_STRIDE = THREAD_NUM_PER_BLOCK / B_TILE_THREAD_PER_ROW;

A = &A[(BLOCK_SIZE_M * by)* K];
B = &B[BLOCK_SIZE_N * bx];

//load index of the tile
const int warp_id = tid / 32;
const int lane_id = tid % 32;
const int a_tile_index = warp_id/2*16 + lane_id/8*4; //warp_id * 8 + (lane_id / 16)*4; // (warp_id/4)*32 + ((lane_id%16)/2)*4;
const int b_tile_index = warp_id%2*32 + lane_id%8*4; //(lane_id % 16) * 4; // (warp_id%4)*16 + (lane_id/16)*8 + (lane_id%2)*4;

//transfer first tile from global mem to shared mem
// load A from global memory to shared memory
#pragma unroll
for ( int i = 0 ; i < BLOCK_SIZE_M ; i += A_TILE_ROW_STRIDE) {
int ldg_index = i / A_TILE_ROW_STRIDE * 4;
FETCH_FLOAT4(ldg_a_reg[ldg_index]) = FETCH_FLOAT4(A[OFFSET(
A_TILE_ROW_START + i, // row
A_TILE_COL, // col
K )]);
As[0][A_TILE_COL][A_TILE_ROW_START + i]=ldg_a_reg[ldg_index];
As[0][A_TILE_COL+1][A_TILE_ROW_START + i]=ldg_a_reg[ldg_index+1];
As[0][A_TILE_COL+2][A_TILE_ROW_START + i]=ldg_a_reg[ldg_index+2];
As[0][A_TILE_COL+3][A_TILE_ROW_START + i]=ldg_a_reg[ldg_index+3];
}
// load B from global memory to shared memory
#pragma unroll
for ( int i = 0 ; i < BLOCK_SIZE_K; i += B_TILE_ROW_STRIDE) {
FETCH_FLOAT4(Bs[0][B_TILE_ROW_START + i][B_TILE_COL]) = FETCH_FLOAT4(B[OFFSET(
B_TILE_ROW_START + i, // row
B_TILE_COL, // col
N )]);
}
__syncthreads();

// load A from shared memory to register
FETCH_FLOAT4(frag_a[0][0]) = FETCH_FLOAT4(As[0][0][a_tile_index]);
FETCH_FLOAT4(frag_a[0][4]) = FETCH_FLOAT4(As[0][0][a_tile_index + 64]);

// load B from shared memory to register
FETCH_FLOAT4(frag_b[0][0]) = FETCH_FLOAT4(Bs[0][0][b_tile_index]);
FETCH_FLOAT4(frag_b[0][4]) = FETCH_FLOAT4(Bs[0][0][b_tile_index + 64]);

int write_stage_idx = 1;
int tile_idx = 0;
do{
// next tile index
tile_idx += BLOCK_SIZE_K;
// load next tile from global mem
if(tile_idx< K){
#pragma unroll
for ( int i = 0 ; i < BLOCK_SIZE_M ; i += A_TILE_ROW_STRIDE) {
int ldg_index = i / A_TILE_ROW_STRIDE * 4;
FETCH_FLOAT4(ldg_a_reg[ldg_index]) = FETCH_FLOAT4(A[OFFSET(
A_TILE_ROW_START + i, // row
A_TILE_COL + tile_idx, // col
K )]);
}
#pragma unroll
for ( int i = 0 ; i < BLOCK_SIZE_K; i += B_TILE_ROW_STRIDE) {
int ldg_index = i / B_TILE_ROW_STRIDE * 4;
FETCH_FLOAT4(ldg_b_reg[ldg_index]) = FETCH_FLOAT4(B[OFFSET(
tile_idx + B_TILE_ROW_START + i, // row
B_TILE_COL, // col
N )]);
}
}

int load_stage_idx = write_stage_idx ^ 1;

#pragma unroll
for(int j=0; j<BLOCK_SIZE_K - 1; ++j){
// load next tile from shared mem to register
// load A from shared memory to register
FETCH_FLOAT4(frag_a[(j+1)%2][0]) = FETCH_FLOAT4(As[load_stage_idx][(j+1)][a_tile_index]);
FETCH_FLOAT4(frag_a[(j+1)%2][4]) = FETCH_FLOAT4(As[load_stage_idx][(j+1)][a_tile_index + 64]);
// load B from shared memory to register
FETCH_FLOAT4(frag_b[(j+1)%2][0]) = FETCH_FLOAT4(Bs[load_stage_idx][(j+1)][b_tile_index]);
FETCH_FLOAT4(frag_b[(j+1)%2][4]) = FETCH_FLOAT4(Bs[load_stage_idx][(j+1)][b_tile_index + 64]);
// compute C THREAD_SIZE_X x THREAD_SIZE_Y
#pragma unroll
for (int thread_y = 0; thread_y < THREAD_SIZE_Y; ++thread_y) {
#pragma unroll
for (int thread_x = 0; thread_x < THREAD_SIZE_X; ++thread_x) {
accum[thread_y][thread_x] += frag_a[j%2][thread_y] * frag_b[j%2][thread_x];
}
}
}

if(tile_idx < K){
// load A from global memory to shared memory
#pragma unroll
for ( int i = 0 ; i < BLOCK_SIZE_M ; i += A_TILE_ROW_STRIDE) {
int ldg_index = i / A_TILE_ROW_STRIDE * 4;
As[write_stage_idx][A_TILE_COL][A_TILE_ROW_START + i]=ldg_a_reg[ldg_index];
As[write_stage_idx][A_TILE_COL+1][A_TILE_ROW_START + i]=ldg_a_reg[ldg_index+1];
As[write_stage_idx][A_TILE_COL+2][A_TILE_ROW_START + i]=ldg_a_reg[ldg_index+2];
As[write_stage_idx][A_TILE_COL+3][A_TILE_ROW_START + i]=ldg_a_reg[ldg_index+3];
}
// load B from global memory to shared memory
#pragma unroll
for ( int i = 0 ; i < BLOCK_SIZE_K; i += B_TILE_ROW_STRIDE) {
int ldg_index = i / B_TILE_ROW_STRIDE * 4;
FETCH_FLOAT4(Bs[write_stage_idx][B_TILE_ROW_START + i][B_TILE_COL]) = FETCH_FLOAT4(ldg_b_reg[ldg_index]);
}
// use double buffer, only need one sync
__syncthreads();
// switch
write_stage_idx ^= 1;
}

// load first tile from shared mem to register of next iter
// load A from shared memory to register
FETCH_FLOAT4(frag_a[0][0]) = FETCH_FLOAT4(As[load_stage_idx^1][0][a_tile_index]);
FETCH_FLOAT4(frag_a[0][4]) = FETCH_FLOAT4(As[load_stage_idx^1][0][a_tile_index + 64]);
// load B from shared memory to register
FETCH_FLOAT4(frag_b[0][0]) = FETCH_FLOAT4(Bs[load_stage_idx^1][0][b_tile_index]);
FETCH_FLOAT4(frag_b[0][4]) = FETCH_FLOAT4(Bs[load_stage_idx^1][0][b_tile_index + 64]);
// compute C THREAD_SIZE_X x THREAD_SIZE_Y
#pragma unroll
for (int thread_y = 0; thread_y < THREAD_SIZE_Y; ++thread_y) {
#pragma unroll
for (int thread_x = 0; thread_x < THREAD_SIZE_X; ++thread_x) {
accum[thread_y][thread_x] += frag_a[1][thread_y] * frag_b[1][thread_x];
}
}
}while(tile_idx< K);

const int c_block_row = a_tile_index;
const int c_block_col = b_tile_index;

//store C00 block
for(int i=0; i<4; i++){
FETCH_FLOAT4(C[OFFSET(
BLOCK_SIZE_M * by + c_block_row + i,
BLOCK_SIZE_N * bx + c_block_col,
N)]) = FETCH_FLOAT4(accum[i][0]);
}
//store C01 block
for(int i=0; i<4; i++){
FETCH_FLOAT4(C[OFFSET(
BLOCK_SIZE_M * by + c_block_row + i,
BLOCK_SIZE_N * bx + c_block_col + 64,
N)]) = FETCH_FLOAT4(accum[i][4]);
}
//store C10 block
for(int i=0; i<4; i++){
FETCH_FLOAT4(C[OFFSET(
BLOCK_SIZE_M * by + c_block_row + 64 + i,
BLOCK_SIZE_N * bx + c_block_col,
N)]) = FETCH_FLOAT4(accum[i+4][0]);
}
//store C11 block
for(int i=0; i<4; i++){
FETCH_FLOAT4(C[OFFSET(
BLOCK_SIZE_M * by + c_block_row + 64 + i,
BLOCK_SIZE_N * bx + c_block_col + 64,
N)]) = FETCH_FLOAT4(accum[i+4][4]);
}
}

int main(int argc, char** argv) {
if (argc != 4) {
printf("usage: ./main [M] [K] [N]\n");
exit(0);
}
size_t M = atoi(argv[1]);
size_t K = atoi(argv[2]);
size_t N = atoi(argv[3]);

assert( M%8 == 0);
assert( N%8 == 0);
assert( K%8 == 0);

size_t bytes_A = sizeof(float) * M * K;
size_t bytes_B = sizeof(float) * K * N;
size_t bytes_C = sizeof(float) * M * N;
float* h_A = (float*)malloc(bytes_A);
float* h_B = (float*)malloc(bytes_B);
float* h_C = (float*)malloc(bytes_C);
float* h_C1 = (float*)malloc(bytes_C);

float* d_A;
float* d_B;
float* d_C;

checkCudaErrors(cudaMalloc(&d_A, bytes_A));
checkCudaErrors(cudaMalloc(&d_B, bytes_B));
checkCudaErrors(cudaMalloc(&d_C, bytes_C));
double msecPerMatrixMul[2] = {0, 0};
double gigaFlops[2] = {0, 0};
double flopsPerMatrixMul = 2.0 * M * N * K;

// don't edit it
const int BLOCK_SIZE_M = 128;
const int BLOCK_SIZE_K = 8;
const int BLOCK_SIZE_N = 128;
const int THREAD_SIZE_X = 8;
const int THREAD_SIZE_Y = 8;
const bool ENABLE_DOUBLE_BUFFER = false;

// 生成A的数据
for( int i = 0; i < M * K; i++ ) {
h_A[i] = i / 13;
}

// 生成B的数据
for( int i = 0; i < K * N; i++ ) {
h_B[i] = i % 13;
}

checkCudaErrors(cudaMemcpy( d_A, h_A, bytes_A, cudaMemcpyHostToDevice));
checkCudaErrors(cudaMemcpy( d_B, h_B, bytes_B, cudaMemcpyHostToDevice));

cudaEvent_t start, stop;
checkCudaErrors(cudaEventCreate(&start));
checkCudaErrors(cudaEventCreate(&stop));
float msecTotal = 0;
int nIter = 1000;

checkCudaErrors(cudaMemcpy( d_C, h_C, bytes_C, cudaMemcpyHostToDevice));
checkCudaErrors(cudaEventRecord(start));
for (int run = 0 ; run < nIter; run ++ ) {
dim3 dimBlock(BLOCK_SIZE_N / THREAD_SIZE_X, BLOCK_SIZE_M / THREAD_SIZE_Y);
dim3 dimGrid(N / BLOCK_SIZE_N, M / BLOCK_SIZE_M);
Sgemm<BLOCK_SIZE_M, BLOCK_SIZE_K, BLOCK_SIZE_N, THREAD_SIZE_Y, THREAD_SIZE_X, ENABLE_DOUBLE_BUFFER>
<<< dimGrid, dimBlock >>>(d_A, d_B, d_C, M, N, K);
}
checkCudaErrors(cudaEventRecord(stop));
checkCudaErrors(cudaEventSynchronize(stop));
checkCudaErrors(cudaEventElapsedTime(&msecTotal, start, stop));
checkCudaErrors(cudaMemcpy( h_C, d_C, bytes_C, cudaMemcpyDeviceToHost));

msecPerMatrixMul[0] = msecTotal / nIter;
gigaFlops[0] = (flopsPerMatrixMul * 1.0e-9f) / (msecPerMatrixMul[0] / 1000.0f);
printf( "My gemm Performance= %.2f GFlop/s, Time= %.3f msec, Size= %.0f Ops,\n",
gigaFlops[0],
msecPerMatrixMul[0],
flopsPerMatrixMul);

// cublas

cublasHandle_t blas_handle;
cublasCreate(&blas_handle);
float alpha = 1.0;
float beta = 0;
checkCudaErrors(cudaMemcpy( d_C, h_C, bytes_C, cudaMemcpyHostToDevice));
checkCudaErrors(cudaEventRecord(start));
for (int run = 0 ; run < nIter; run ++ ) {
cublasSgemm (blas_handle, CUBLAS_OP_T, CUBLAS_OP_T,
M, N, K, &alpha,
d_A, K, d_B, N, &beta, d_C, N
);
}
checkCudaErrors(cudaEventRecord(stop));
checkCudaErrors(cudaEventSynchronize(stop));
checkCudaErrors(cudaEventElapsedTime(&msecTotal, start, stop));

checkCudaErrors(cudaMemcpy( h_C1, d_C, bytes_C, cudaMemcpyDeviceToHost));

msecPerMatrixMul[1] = msecTotal / nIter;
gigaFlops[1] = (flopsPerMatrixMul * 1.0e-9f) / (msecPerMatrixMul[1] / 1000.0f);
printf( "CuBlas Performance= %.2f GFlop/s, Time= %.3f msec, Size= %.0f Ops,\n",
gigaFlops[1],
msecPerMatrixMul[1],
flopsPerMatrixMul);

cublasDestroy(blas_handle);


double eps = 1.e-6; // machine zero
bool correct = true;
for (int i = 0; i < M * N; i++) {
int row = i / N;
int col = i % N;
double abs_err = fabs(h_C[i] - h_C1[col * M + row]);
double dot_length = M;
double abs_val = fabs(h_C[i]);
double rel_err = abs_err / abs_val / dot_length;
if (rel_err > eps) {
printf("Error! Matrix[%d][%d]=%.8f, ref=%.8f error term is > %E\n",
row, col, h_C[i], h_C1[col * M + row], eps);
correct = false;
break;
}
}

printf("%s\n", correct ? "Result= PASS" : "Result= FAIL");
printf("ratio= %f\n", gigaFlops[0] / gigaFlops[1]);

// Free Memory
cudaFree(d_A);
cudaFree(d_B);
cudaFree(d_C);

free(h_A);
free(h_B);
free(h_C);
free(h_C1);
}

SGEMM

在深度学习推理框架或者训练框架中,GEMM 和 Conv 是典型的计算密集型算子,例如在 Bert 和 Conformer 模型的 self-attention 模块中存在大量矩阵运算,因此深度学习框架中 GEMM 算子的底层实现好坏将会直接影响模型的推理或训练延时。

img

图1 conformer 模型中的矩阵运算

介绍如何进行 GEMM 优化的文章很多,即使在知乎上随手搜索 GEMM优化 词条也会有几十个条目,其中也不乏一些内容翔实、条理清楚的好文章。不过,从我个人比较主观的分析来看,大部分文章停留在方法论层面的介绍,没有落实到具体的代码实现上,理论和实践之间还是有不可跨越的鸿沟,作为一个愣头青程序员,没能看到代码总是感觉少了点意思。

另一方面,在 GitHub: How To Optimize GEMM 项目中,作者通过清晰明了的代码和文档向读者介绍内存对齐、向量化、矩阵分块和数据打包等关键技术,此外,作者还给出了每一个步骤的优化点、优化效果对比和分析,实属不可多得的GEMM优化入门读物,强烈推荐!但 GitHub: How To Optimize GEMM 作为一个入门级的项目,旨在粗粒度介绍矩阵乘算法的优化思路,并没有针对某个硬件进行针对性优化,也没有深入优化 micro kernel 的代码实现,因此该项目中的矩阵乘实现仍然存在较大的优化空间。

那么,能不能在介绍矩阵乘优化原理的基础时搭配相应的代码实现,并且最终取得可观的性能表现呢?

Talk is cheap. Show me the code. ― Linus Torvalds

当然,这篇文章就是想做这个事情,本文目标有三点

  1. 介绍如何在x64 CPU 上优化矩阵乘算法的思路;
  2. 实现一份可运行的高性能矩阵乘算法;
  3. 性能数据可复现;

img

图2 矩阵乘运算

矩阵乘运算是大学本科的基础知识,原理十分简单,此处不在赘述其数学公式和讲解。

基础知识

选取一个合适的度量指标是性能优化工作的基础,通常我们使用 GFLOPS 来衡量一个算子的性能。

区分 FLOPS 和 FLOPs

每秒浮点运算次数(floating point operations per second, FLOPS),即每秒所执行的浮点运算次数,是一个衡量硬件性能的指标。下表列举了常见的 FLOPS 换算指标。

缩写 解释
MFLOPS 每秒进行百万次 (10^6) 次浮点运算的次数
GFLOPS 每秒进行十亿次 (10^9) 次浮点运算的次数
TFLOPS 每秒进行万亿次 (10^12)次浮点运算的次数
PFLOPS 每秒进行千万亿次(10^15)次浮点运算的次数
EFLOPS 每秒进行百亿亿次(10^18)次浮点运算的次数

浮点运算量(floating point operations, FLOPs)是指浮点运算的次数,是一个衡量深度学习模型计算量的指标。

此外,从FLOPs延伸出另外一个指标是乘加运算量MACs。

乘加运算量(multiplication and accumulation operations, MACs)是指乘加运算的次数,也是衡量深度模型计算量的指标。在Intel AVX指令中,扩展了对于乘加计算(fused multiply-add, FMA)指令的支持,即在支持AVX指令的CPU上,可以通过FMA计算单元使用一条指令来执行类似 A×B+CA \times B + CA \times B + C 的操作,参考 Intel® C++ Compiler Classic Developer Guide and Reference 中对于 _mm256_fmadd_ps 指令的介绍。一次乘加运算包含了两次浮点运算,一般地可以认为 MACs = 2FLOPs。

计算 CPU 的 FLOPS

从上一小节中得知,FLOPS 是一个衡量硬件性能的指标,那么我们该如何计算 CPU 的FLOPS 呢?

img

图1 使用 lscpu 命令查看系统信息

上图中,红框中几条关键信息

  1. CPU(s), 逻辑核数量;
  2. CPU family, CPU系列标识,用以确定CPU属于哪一代产品。更多关于 Intel CPU Family 信息,可以参考 Intel CPUID
  3. Model, 型号标识可用来确定处理器的制作技术以及属于该系列的第几代设计(或核心),型号与系列通常是相互配合使用的,用于确定计算机所安装的处理器是属于某系列处理器的哪种特定类型。
  4. Model name, CPU型号名称
  5. CPU MHZ: 主频

下面以 “Xeon Platinum 8260Y” 细致地解释下 CPU 型号名称中隐藏的信息。

img

图2 Xeon Platinum 8260Y CPU

  • Xeon Platinum 8260Y**:** Intel 公司推出的至强处理器系列,具备丰富的指令集支持和出色的性能表现,主要针对服务器市场。除至强处理器之外,Intel 公司推出的**酷睿处理器**在桌面市场具备更高的知名度;
  • Xeon Platinum 8260Y**:** Intel 至强系列处理器分为四个级别,性能由高到低依次是铂金级Platinum(8,9)、黄金级Gold(6,7)、白银级Silver(4)和青铜级Bronze(3);
  • Xeon Platinum 8260Y**:** 处理器架构代号,1 代表Skylake ,2 代表 Cascade Lake
  • Xeon Platinum 8260Y: SKU和Extra Options信息可以参考 Cascade Lake 架构介绍

计算CPU FLOPS时需要两点关键信息,下面分别计算下 AVX2 和 AVX512 指令集的GFLOPS。

  1. CPU 主频
  2. FMA 单元数

AVX2

1
2
3
4
单周期双精度浮点计算能力 = 2(FMA数量)* 2(乘加) ∗ 256 (YMM寄存器宽度) / 64(双精度浮点数位数) = 16
单周期双精度浮点计算能力= 2(FMA数量)* 2(乘加) ∗ 256 (YMM寄存器宽度) / 32(双精度浮点数位数) = 32
双精度FLOAPS = 2.5(CPU主频) * 16(单周期双精度浮点计算能力) = 40GFLOPS
单精度FLOAPS = 2.5(CPU主频) * 32(单周期单精度浮点计算能力) = 80GFLOPS

AVX512

1
2
3
4
单周期双精度浮点计算能力 = 2(FMA数量)* 2(乘加) ∗ 512 (YMM寄存器宽度) / 64(双精度浮点数位数) = 32
单周期双精度浮点计算能力= 2(FMA数量)* 2(乘加) ∗ 512 (YMM寄存器宽度) / 32(双精度浮点数位数) = 64
双精度FLOAPS = 2.5(CPU主频) * 16(单周期双精度浮点计算能力) = 80 GFLOPS
单精度FLOAPS = 2.5(CPU主频) * 32(单周期单精度浮点计算能力) = 160 GFLOPS
指令集 精度 理论峰值算力
AVX2 double 40 GFLOPS
AVX2 float 80 GFLOPS
AVX512 double 80 GFLOPS
AVX512 float 160 GFLOPS

至此,我们已经明白了单核心CPU的理论峰值算力,下面开始进入实战环节!

基础矩阵乘实现和优化

本节内容作为正式优化的序章,会介绍两点内容

  1. 如何实现基础的 GEMM 算法并测量其性能数据;
  2. 如何通过一行代码达到十倍的性能提升;

此处约定本文中A,B分别为左、右输入矩阵,C为输出矩阵,并且三者的形状信息如下

A:M×KA: M \times K A: M \times K 的输入矩阵

B:K×NB: K \times NB: K \times N 的输入矩阵

C:M×NC: M \times NC: M \times N 的输出矩阵

基础 GEMM 实现和度量

下面的代码应该都不陌生,矩阵乘算法是编程初学者经典的练习题之一。

1
2
3
4
5
6
7
8
9
10
void naive_row_major_sgemm(const float* A, const float* B, float* C, const int M,
const int N, const int K) {
for (int m = 0; m < M; ++m) {
for (int n = 0; n < N; ++n) {
for (int k = 0; k < K; ++k) {
C[m * N + n] += A[m * K + k] * B[k * N + n];
}
}
}
}

从矩阵乘的原理可知,矩阵乘算法的浮点运算量为 2×M×N×K2 \times M \times N \times K2 \times M \times N \times K,所以

GEMM:GFLOPs=2×M×N×Klatency×10−9GEMM : GFLOPs = \frac{2 \times M \times N \times K}{latency} \times 10^{-9} GEMM : GFLOPs = \frac{2 \times M \times N \times K}{latency} \times 10^{-9}

下面实现一个朴素的GFLOPs 计算函数,相应的代码均会在 GitHub 仓库中提供。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void Benchmark(const std::vector<int64_t>& dims,
std::function<void(void)> func) {
const int warmup_times = 10;
const int infer_times = 20;

// warmup
for (int i = 0; i < warmup_times; ++i) func();

// run
auto dtime = dclock();
for (int i = 0; i < infer_times; ++i) func();

// latency
dtime = dclock() - dtime;

// compute GLOPs
auto flops = 2.0f * product(dims) * 1.0e-09;
flops = flops * infer_times / dtime;

// print
std::cout << std::setw(20) << " GFLOPs: " << flops << std::endl;
}

实测,naive_row_major_sgemm 的性能数据如下

Shape(M, N, K) GFLOPs
(64, 64, 64) 1.97
(128, 128, 128) 1.65
(256, 256, 256) 1.44
(512, 512, 512) 0.95
(1024, 1024, 1024) 0.62

测试数据来看,随着矩阵尺寸的增大,GFLOPs 在不断下降。从上文的分析中可知,单核CPU的理论峰值算力是80 GFLOPS,naive_row_major_sgemm 和理论峰值算力之间的差距非常大,完全没有发挥出CPU的算力。

naive_row_major_sgemm 性能极差的核心原因是在计算时发生了大量的cache miss

img

图3 基础 GEMM 实现示例

一行代码优化十倍性能

在分析清楚 naive_row_major_sgemm 性能极差的主要原因后,我们通过循环重排来优化访存。注意,naive_row_major_sgemm 和 optimize_row_major_sgemm 虽然只有一行代码的差距,但是性能却相差近十倍!

1
2
3
4
5
6
7
8
9
10
void optimize_row_major_sgemm(const float* A, const float* B, float* C, const int M,
const int N, const int K) {
for (int m = 0; m < M; ++m) {
for (int k = 0; k < K; ++k) {
for (int n = 0; n < N; ++n) {
C[m * N + n] += A[m * K + k] * B[k * N + n];
}
}
}
}
Shape(M, N, K) naive GFLOPs optimize GFLOps
(64, 64, 64) 1.97 11.20
(128, 128, 128) 1.65 11.84
(256, 256, 256) 1.44 12.04
(512, 512, 512) 0.95 11.43
(1024, 1024, 1024) 0.62 10.79

根据上表中的数据,可以直接体会到性能优化的魔力。一行代码,十倍加速。

img

图4 优化访存后的 GEMM 实现示例

BLAS 接口简介

截止到目前为止,已经具有 naive_row_major_sgemmoptimize_row_major_sgemm 两份实现,虽然optimize_row_major_sgemm 在性能上有一定的优化,但距离真正的高性能计算库的要求还相差甚远。

即使抛开性能问题不谈,目前 optimize_row_major_sgemm 也很难视为一个合格的库函数,因为该函数在接口定义上太过随意,别人很难直接复用。众所周知,矩阵乘优化已经是非常成熟的课题了,其中自然衍生了许多标准,以方便不同开发者或者研究人员之间工作的交流和复用,其中最基础的便是 BLAS接口规范。

BLAS(basic linear algebra subroutine)是一系列基本线性代数运算函数**1接口(interface)**标准。 这里的线性代数运算是指例如矢量的线性组合,矩阵乘以矢量,矩阵乘以矩阵等。接口在这里指的是诸如哪个函数名实现什么功能,有几个输入和输出变量,分别是什么。

注意 BLAS 是一个接口的标准而不是某种具体实现(implementation)。简单来说,就是不同的作者可以各自写出不同版本的 BLAS 库,实现同样的接口和功能,但每个函数内部的算法可以不同。 这些不同导致了不同版本的 BLAS 在不同机器上运行的速度也不同。

C:=alpha×A×B+beta×CC := alpha \times A \times B + beta \times CC := alpha \times A \times B + beta \times C

  • A, 形状为(M, K)的列主序矩阵
  • B, 形状为(M, K)的列主序矩阵
  • C, 形状为(M, K)的列主序矩阵
1
2
void sgemm(char transa, char transb, int M, int N, int K, float alpha, 
const float* A, int lda, const float* B, int ldb, float beta, float* C, int ldc);
  • transa, 设置矩阵A是否转置的标识位,’N’ 表示不转置, ‘T’ 表示转置;
  • transb, 设置矩阵A是否转置的标识位,’N’ 表示不转置, ‘T’ 表示转置;
  • M, M 维度的值;
  • N, N 维度的值;
  • K, K 维度的值;
  • alpha, 系数;
  • A, A 矩阵指针;
  • lda, A矩阵 leading dimension的值;
  • B, B 矩阵指针;
  • ldb, B矩阵 leading dimension的值;
  • beta, 系数;
  • C, 结果矩阵C矩阵指针;
  • ldc, C矩阵 leading dimension的值;

注: leading dimension,对于一个 MxN 的行优先矩阵,leading dimension 为 N;对于一个 MxN 的列优先矩阵,leading dimension 为 M。

介绍完 BLAS 接口之后,我们以 BLAS 接口的格式编写一份 列优先的矩阵乘实现 作为后续优化工作的比较基准。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void naive_col_major_sgemm(
char transa,
char transb,
int M, int N, int K,
const float alpha,
const float * src_a, int lda,
const float * src_b, int ldb,
const float beta,
float * dst, int ldc)
{
int a_stride_m = transa == 'n' ? 1 : lda;
int a_stride_k = transa == 'n' ? lda : 1;
int b_stride_k = transb == 'n' ? 1 : ldb;
int b_stride_n = transb == 'n' ? ldb : 1;

for(int m=0;m<M;m++) {
for(int n=0;n<N;n++) {
float acc = 0.f;
const float * a_ptr = src_a + m * a_stride_m;
const float * b_ptr = src_b + n * b_stride_n;

for(int k=0;k<K;k++) {
acc += a_ptr[0] * b_ptr[0];
a_ptr += a_stride_k;
b_ptr += b_stride_k;
}

dst[m + n * ldc ] = alpha * acc + beta * dst[m + n * ldc];
}
}
}

深度优化矩阵乘实现

从本节起,开始演示如何优化矩阵乘算法,以达到 80% 以上的硬件性能利用率。

一般而言,矩阵乘优化有以下技巧,在GEMM、GEMV的实现中都可以去套用。

  1. 循环重排;
  2. 数据分块;
  3. 数组打包;
  4. 向量指令集;
  5. 寄存器优化;
  6. 多线程;

基础函数乘实现和优化一节中得知,矩阵乘实现性能差的原因在与数据 cache miss 率很高,因此我们进行的一个优化就是数据打包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
void avx2_col_major_sgemm(char transa, char transb,int M, int N, int K, float alpha, float* A, int lda,
float* B, int ldb, float beta, float* C, int ldc) {
if (alpha == 0) return;

float beta_div_alpha = beta / alpha;

constexpr int Mr = 64;
constexpr int Kr = 256;

constexpr int mr = 16;
constexpr int nr = 6;

// Cache a is 64 x 256
float* pack_a = (float*)_mm_malloc(Mr * Kr * sizeof(float), 32);
// Cache b is 256 x N
float* pack_b = (float*)_mm_malloc(Kr * DivUp(N, nr) * sizeof(float), 32);

float* tmp_pack_a = pack_a;
float* tmp_pack_b = pack_b;

for (int k = 0; k < K; k += Kr) {
float cur_beta = 1.0 / alpha;
if (k == 0) cur_beta = beta_div_alpha;

int cur_k = std::min(K - k, Kr);

// jump to k-th row of matrix B
pack_no_trans(B + k, ldb, tmp_pack_b, Kr, cur_k, N);

for (int i = 0; i < M; i += Mr) {
int cur_m = std::min(M - i, Mr);

pack_trans(A + i + k * lda, lda, tmp_pack_a, Kr, cur_k, cur_m);

for (int j = 0; j < N;) {
int cur_n = std::min(int(N - j), nr);
float* cur_c = C + i + j * ldc;

float* packed_cur_b = tmp_pack_b + DivDown(j, nr) * Kr + j % nr;

sgemm_block_n(cur_m, cur_n, cur_k, alpha, tmp_pack_a, lda, packed_cur_b,
ldb, cur_beta, cur_c, ldc);
j += cur_n;
}
}
}

_mm_free(pack_a);
_mm_free(pack_b);
}

在后文的讲解中,为方便起见,统一设置 M = N = K = 512 为例,来演示矩阵乘优化。

数据打包

从系统信息上看,L1 数据缓存和指令缓存均为 32 K,32K 的 L1d cache 可以容纳 32 * 1024 / 4 = 8192 个单精度浮点数。因此,当 M, N, K 足够大的时候,L1d cache 无法持有三个矩阵所有的数据,便会发生cache miss,这也解释了上文中为什么矩阵越大、性能越差。

1
2
3
4
L1d cache:           32K
L1i cache: 32K
L2 cache: 4096K
L3 cache: 36608K

avx2_col_major_sgemm 的实现代码中,为矩阵A 开辟了 64 x 256 x 4 bytes / 1024 = 64 K 的存储区域,为矩阵B 开辟了 256 x Divp(N=512,6 ) = 256 x 516 x 4 bytes / 1024 = 516 K 的存储区域,目的是防止矩阵A和矩阵B过大,以至于在L2 cache 中发生cache miss 的情况,所以一次只在L2中加载矩阵A和矩阵B的子矩阵,保证不会发生cache miss。

1
2
3
4
5
6
7
8
9
constexpr int Mr = 64;
constexpr int Kr = 256;

...

// Cache a is 64 x 256
float* pack_a = (float*)_mm_malloc(Mr * Kr * sizeof(float), 32);
// Cache b is 256 x N
float* pack_b = (float*)_mm_malloc(Kr * DivUp(N, nr) * sizeof(float), 32);

矩阵乘实现从计算方法上来区分,可以分为 Inner Product 和 Outer Product 两种计算方法,解释如下

  1. Inner Product: 按行切分A矩阵,按列切分B矩阵,使用A矩阵的一个按行切分的子块同B矩阵按列切分的子块做矩阵乘法,即求得结果矩阵C矩阵的一个子矩阵。依次循环,求得最终结果。

img

图5 矩阵分块运算(inner product)

\2. Outer Product: 按列切分A矩阵,按行切分B矩阵,使用A矩阵的一个按列切分的子块同B矩阵按行切分的子块做矩阵乘法,求得一个形状同C矩阵相同的中间结果矩阵。依次循环,对所有的中间结果矩阵求和,可得最终结果。下图中,将A、B矩阵切分为4个子矩阵,然后进行4次矩阵乘,再对 C1、C2、C3 和 C4 进行求和,可以算出最终结果。

img

图6 矩阵分块运算示例(outer product)

avx2_col_major_sgemm 的实现代码中,按照如下方式对矩阵A(512 x 512)、B(512 x 512)进行切分计算,具体步骤如下:

  1. 将矩阵A(512 x 512)切分为 2 x 8 = 16 个形状为 64 x 256 的子矩阵;
  2. 将矩阵B(512 x 512)切分为 2 个形状为 256 x 512 的子矩阵;
  3. 对矩阵B的1号子矩阵进行数据打包, 然后对矩阵A的1号子矩阵进行数据打包,对TMPA1(64x256)和 TMPB1(256 x 512)进行一次矩阵乘运算,求得图中的 c11 (64 x 512) ; 在对矩阵A的2号子矩阵进行数据打包,求得c12;依次循环,直到求得 c18;
  4. 对矩阵B的2号子矩阵进行数据打包, 然后对矩阵A的9号子矩阵进行数据打包,对TMPA1(64x256)和 TMPB1(256 x 512)进行一次矩阵乘运算,求得图中的 c21 (64 x 512) ; 在对矩阵A的10号子矩阵进行数据打包,求得c22;依次循环,直到求得 c28;
  5. 对中间结果矩阵进行求和,可得最终的结果矩阵 C。

img

图7 M=N=K=512 矩阵的切分计算示例

通过上面的介绍,相信读者已经对如何进行矩阵分块有了清晰的认识,其实矩阵分块的思想很简单,就是将原始输入矩阵切分为小矩阵,使得L2 cache可以容纳计算所需的小矩阵。

现在已经粗粒度的讲解了如何对矩阵A和矩阵B进行分块计算,那么矩阵A的子矩阵(64 x 256)和 矩阵B的子矩阵(256 x 512)是如何计算的呢?

  1. 在数据打包时,将子矩阵 a(64 x 256)按行进行切分,分为 4 个形状为 16 x 256 的小矩阵;
  2. 在数据打包时,将子矩阵 b(256 x 512)按列进行切分,分为 86 个形状为 256 x 6 的小矩阵;当子矩阵 b 的列数不是 6 的整数倍时,需在数据打包时,进行 padding。
  3. 使用子矩阵 a 的1号子矩阵(16 x 256)依次和子矩阵 b 的86个子矩阵进行矩阵乘计算,计算结果为 (16 x 256)X (256 x 6)= (16 x 6);最终可得(16 x 6)x 86 个子矩阵;
  4. 依此遍历子矩阵 a 的1、2、3、4号子矩阵进行步骤3中的运算;

img

图8 左矩阵(64x256)和右矩阵(256x512)的计算

上文的描述中,详细介绍如何对矩阵A的子矩阵(64 x 256)和矩阵B的子矩阵(256 x 512)进行计算,后面会结合代码对如何使用SIMD指令进行数据打包的细节演示。

矩阵A的数据打包

img

图9 矩阵A的数据打包

代码实现,暂时没进行深入讲解,比较好理解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
//  pack block_size on leading dimension, t denotes transpose.
// eg. input: A MxN matrix in row major, so the storage-format is (M, N)
// output: B MxN matrix in col major(N-packed), so the storage-format is
// (divUp(N, 16), M, 16)
void pack_trans(float* a, int lda, float* b, int ldb, int m, int n) {
constexpr int block_size = 16;
int i = 0;

for (; i + 64 <= n; i += 64) {
float* cur_a = a + i;
float* cur_b = b + i * ldb;
pack_trans_4x16(cur_a, lda, cur_b, ldb, m, block_size);
}
}

void pack_trans_4x16(float* a, const int lda, float* b, const int ldb, int m, int n) {
const int m4 = m / 4;
const int m1 = m % 1;
const int block_size = 64;
const int ldbx16 = ldb * 16; //(256 * 64)

float* tmpa = a;
// 保存指针 A 的4列元素
float* ar0 = tmpa + 0 * lda;
float* ar1 = tmpa + 1 * lda;
float* ar2 = tmpa + 2 * lda;
float* ar3 = tmpa + 3 * lda;

float* tmpb = b;
float* br0 = tmpb + 0 * ldbx16;
float* br1 = tmpb + 1 * ldbx16;
float* br2 = tmpb + 2 * ldbx16;
float* br3 = tmpb + 3 * ldbx16;

// 循环 256 / 4 = 64 次,每次 pack 4 x 16 = 64 个数据
for (int i = 0; i < m4; ++i) {
{
__m256 v00 = _mm256_loadu_ps(ar0);
__m256 v01 = _mm256_loadu_ps(ar0 + 8);
__m256 v10 = _mm256_loadu_ps(ar1);
__m256 v11 = _mm256_loadu_ps(ar1 + 8);
__m256 v20 = _mm256_loadu_ps(ar2);
__m256 v21 = _mm256_loadu_ps(ar2 + 8);
__m256 v30 = _mm256_loadu_ps(ar3);
__m256 v31 = _mm256_loadu_ps(ar3 + 8);

_mm256_storeu_ps(br0 + 0, v00);
_mm256_storeu_ps(br0 + 8, v01);
_mm256_storeu_ps(br0 + 16, v10);
_mm256_storeu_ps(br0 + 24, v11);
_mm256_storeu_ps(br0 + 32, v20);
_mm256_storeu_ps(br0 + 40, v21);
_mm256_storeu_ps(br0 + 48, v30);
_mm256_storeu_ps(br0 + 56, v31);
}
{
__m256 v00 = _mm256_loadu_ps(ar0 + 16);
__m256 v01 = _mm256_loadu_ps(ar0 + 24);
__m256 v10 = _mm256_loadu_ps(ar1 + 16);
__m256 v11 = _mm256_loadu_ps(ar1 + 24);
__m256 v20 = _mm256_loadu_ps(ar2 + 16);
__m256 v21 = _mm256_loadu_ps(ar2 + 24);
__m256 v30 = _mm256_loadu_ps(ar3 + 16);
__m256 v31 = _mm256_loadu_ps(ar3 + 24);

_mm256_storeu_ps(br1 + 0, v00);
_mm256_storeu_ps(br1 + 8, v01);
_mm256_storeu_ps(br1 + 16, v10);
_mm256_storeu_ps(br1 + 24, v11);
_mm256_storeu_ps(br1 + 32, v20);
_mm256_storeu_ps(br1 + 40, v21);
_mm256_storeu_ps(br1 + 48, v30);
_mm256_storeu_ps(br1 + 56, v31);
}

{
__m256 v00 = _mm256_loadu_ps(ar0 + 32);
__m256 v01 = _mm256_loadu_ps(ar0 + 40);
__m256 v10 = _mm256_loadu_ps(ar1 + 32);
__m256 v11 = _mm256_loadu_ps(ar1 + 40);
__m256 v20 = _mm256_loadu_ps(ar2 + 32);
__m256 v21 = _mm256_loadu_ps(ar2 + 40);
__m256 v30 = _mm256_loadu_ps(ar3 + 32);
__m256 v31 = _mm256_loadu_ps(ar3 + 40);

_mm256_storeu_ps(br2 + 0, v00);
_mm256_storeu_ps(br2 + 8, v01);
_mm256_storeu_ps(br2 + 16, v10);
_mm256_storeu_ps(br2 + 24, v11);
_mm256_storeu_ps(br2 + 32, v20);
_mm256_storeu_ps(br2 + 40, v21);
_mm256_storeu_ps(br2 + 48, v30);
_mm256_storeu_ps(br2 + 56, v31);
}

{
__m256 v00 = _mm256_loadu_ps(ar0 + 48);
__m256 v01 = _mm256_loadu_ps(ar0 + 56);
__m256 v10 = _mm256_loadu_ps(ar1 + 48);
__m256 v11 = _mm256_loadu_ps(ar1 + 56);
__m256 v20 = _mm256_loadu_ps(ar2 + 48);
__m256 v21 = _mm256_loadu_ps(ar2 + 56);
__m256 v30 = _mm256_loadu_ps(ar3 + 48);
__m256 v31 = _mm256_loadu_ps(ar3 + 56);

_mm256_storeu_ps(br3 + 0, v00);
_mm256_storeu_ps(br3 + 8, v01);
_mm256_storeu_ps(br3 + 16, v10);
_mm256_storeu_ps(br3 + 24, v11);
_mm256_storeu_ps(br3 + 32, v20);
_mm256_storeu_ps(br3 + 40, v21);
_mm256_storeu_ps(br3 + 48, v30);
_mm256_storeu_ps(br3 + 56, v31);
}

ar0 += 4 * lda;
ar1 += 4 * lda;
ar2 += 4 * lda;
ar3 += 4 * lda;

br0 += block_size;
br1 += block_size;
br2 += block_size;
br3 += block_size;
}
}

矩阵B的数据打包

img

图10 矩阵B的数据打包

代码实现,暂时没进行深入讲解,比较好理解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
id pack_no_trans_n6(float* a, const int lda, float* b, const int ldb,
const int m, const int n) {
const int m8 = m / 8;
const int m1 = m % 8;
const int block_size = n;

float* tmpa = a;
float* tmpb = b;
float* a0 = tmpa + 0 * lda;
float* a1 = tmpa + 1 * lda;
float* a2 = tmpa + 2 * lda;
float* a3 = tmpa + 3 * lda;
float* a4 = tmpa + 4 * lda;
float* a5 = tmpa + 5 * lda;

for (int i = 0; i < m8; i++) {
__m256 v0 = _mm256_loadu_ps(a0);
__m256 v1 = _mm256_loadu_ps(a1);
__m256 v2 = _mm256_loadu_ps(a2);
__m256 v3 = _mm256_loadu_ps(a3);
__m256 v4 = _mm256_loadu_ps(a4);
__m256 v5 = _mm256_loadu_ps(a5);

__m256 unpack0 = _mm256_unpacklo_ps(v0, v1);
__m256 unpack1 = _mm256_unpackhi_ps(v0, v1);
__m256 unpack2 = _mm256_unpacklo_ps(v2, v3);
__m256 unpack3 = _mm256_unpackhi_ps(v2, v3);
__m256 unpack4 = _mm256_unpacklo_ps(v4, v5);
__m256 unpack5 = _mm256_unpackhi_ps(v4, v5);

__m256 shf0 = _mm256_shuffle_ps(unpack0, unpack2, 0x44);
__m256 shf1 = _mm256_shuffle_ps(unpack4, unpack0, 0xe4);
__m256 shf2 = _mm256_shuffle_ps(unpack2, unpack4, 0xee);
__m256 shf3 = _mm256_shuffle_ps(unpack5, unpack1, 0xe4);
__m256 shf4 = _mm256_shuffle_ps(unpack3, unpack5, 0xee);
__m256 shf5 = _mm256_shuffle_ps(unpack1, unpack3, 0x44);

__m128 low_shf1 = _mm256_castps256_ps128(shf1);
__m256 res0 = _mm256_insertf128_ps(shf0, low_shf1, 0x1);
__m256 res1 = _mm256_permute2f128_ps(shf0, shf1, 0x31);

__m128 low_shf5 = _mm256_castps256_ps128(shf5);
__m256 res2 = _mm256_insertf128_ps(shf2, low_shf5, 0x1);
__m256 res3 = _mm256_permute2f128_ps(shf2, shf5, 0x31);

__m128 low_shf4 = _mm256_castps256_ps128(shf4);
__m256 res4 = _mm256_insertf128_ps(shf3, low_shf4, 0x1);
__m256 res5 = _mm256_permute2f128_ps(shf3, shf4, 0x31);

constexpr int vsize_in_bytes = 8;
_mm256_storeu_ps(tmpb + 0 * vsize_in_bytes, res0);
_mm256_storeu_ps(tmpb + 1 * vsize_in_bytes, res2);
_mm256_storeu_ps(tmpb + 2 * vsize_in_bytes, res4);
_mm256_storeu_ps(tmpb + 3 * vsize_in_bytes, res1);
_mm256_storeu_ps(tmpb + 4 * vsize_in_bytes, res3);
_mm256_storeu_ps(tmpb + 5 * vsize_in_bytes, res5);

tmpb += 6 * vsize_in_bytes;

// jump to another 8 float point values
a0 += vsize_in_bytes;
a1 += vsize_in_bytes;
a2 += vsize_in_bytes;
a3 += vsize_in_bytes;
a4 += vsize_in_bytes;
a5 += vsize_in_bytes;
}
}

寄存器优化(Micro Kernel)

在数据打包的讲解中,有以下描述

使用子矩阵 a 的1号子矩阵(16 x 256)依次和子矩阵 b 的86个子矩阵进行矩阵乘计算,计算结果为 (16 x 256)X (256 x 6)= (16 x 6);最终可得(16 x 6)x 86 个子矩阵;

avx2_col_major_sgemm 的实现中,使用 A(16, 8) * B(8, 6) = C(16, 6) 的Micro Kernel,其计算思路如下,图片和下面的描述均来自一篇很好的文章 《OneDNN GEMM(AVX FP32)算法浅析》

img

图11 micro kernel 寄存器优化

Micro Kernel 的计算步骤如下描述

  1. 在 micro kernel 中,首先使用12个YMM寄存器用以保存结果矩阵 C(shape 为 16x6);
  2. 通过_mm256_loadu_ps指令将A矩阵的第一列移动到两个YMM寄存器中(这里假设为YMM0以及YMM1);
  3. 对于B矩阵第一行的第一个元素,使用_mm256_broadcast_ss指令进行广播并存储到一个YMM寄存器内(这里假设为YMM2),然后使用fma指令_mm256_fmadd_ps将YMM0和YMM1内的元素与YMM2内元素对应相乘,并将结果累加到C矩阵的两个YMM寄存器内,这里假设为YMM4以及YMM5;
  4. 沿着B矩阵第一行进行循环,重复步骤2,B矩阵广播当前行内其它数据时重复使用YMM2寄存器,并将计算结果依次累加到YMM6~YMM15寄存器内;
  5. A矩阵前进一列,B矩阵前进一行,并重复步骤1~3,最终完成整个C(16, 6)矩阵的计算。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
void col_major_micro_kernel_m16n6(const int K, const float alpha,
const float* src_a, const int lda,
const float* src_b, int ldb, const float beta,
float* dst_c, int ldc) {
constexpr int m_block_size = 16;
constexpr int n_block_size = 6;

// Load result matrix c (shape 16x6) into 12 x __m256 vector values
__m256 c00 = _mm256_loadu_ps(dst_c + 0 * ldc);
__m256 c01 = _mm256_loadu_ps(dst_c + 0 * ldc + 8);

__m256 c10 = _mm256_loadu_ps(dst_c + 1 * ldc);
__m256 c11 = _mm256_loadu_ps(dst_c + 1 * ldc + 8);

__m256 c20 = _mm256_loadu_ps(dst_c + 2 * ldc);
__m256 c21 = _mm256_loadu_ps(dst_c + 2 * ldc + 8);

__m256 c30 = _mm256_loadu_ps(dst_c + 3 * ldc);
__m256 c31 = _mm256_loadu_ps(dst_c + 3 * ldc + 8);

__m256 c40 = _mm256_loadu_ps(dst_c + 4 * ldc);
__m256 c41 = _mm256_loadu_ps(dst_c + 4 * ldc + 8);

__m256 c50 = _mm256_loadu_ps(dst_c + 5 * ldc);
__m256 c51 = _mm256_loadu_ps(dst_c + 5 * ldc + 8);

// c = c * beta
__m256 vbeta = _mm256_set1_ps(beta);

c00 = _mm256_mul_ps(c00, vbeta);
c01 = _mm256_mul_ps(c01, vbeta);

c10 = _mm256_mul_ps(c10, vbeta);
c11 = _mm256_mul_ps(c11, vbeta);

c20 = _mm256_mul_ps(c20, vbeta);
c21 = _mm256_mul_ps(c21, vbeta);

c30 = _mm256_mul_ps(c30, vbeta);
c31 = _mm256_mul_ps(c31, vbeta);

c40 = _mm256_mul_ps(c40, vbeta);
c41 = _mm256_mul_ps(c41, vbeta);

c50 = _mm256_mul_ps(c50, vbeta);
c51 = _mm256_mul_ps(c51, vbeta);


for (int k = 0; k < K; ++k) {
__m256 a0 = _mm256_loadu_ps(src_a);
__m256 a1 = _mm256_loadu_ps(src_a + 8);

__m256 vb = _mm256_broadcast_ss(src_b);
c00 = _mm256_fmadd_ps(a0, vb, c00);
c01 = _mm256_fmadd_ps(a1, vb, c01);

vb = _mm256_broadcast_ss(src_b + 1);
c10 = _mm256_fmadd_ps(a0, vb, c10);
c11 = _mm256_fmadd_ps(a1, vb, c11);

vb = _mm256_broadcast_ss(src_b + 2);
c20 = _mm256_fmadd_ps(a0, vb, c20);
c21 = _mm256_fmadd_ps(a1, vb, c21);

vb = _mm256_broadcast_ss(src_b + 3);
c30 = _mm256_fmadd_ps(a0, vb, c30);
c31 = _mm256_fmadd_ps(a1, vb, c31);

vb = _mm256_broadcast_ss(src_b + 4);
c40 = _mm256_fmadd_ps(a0, vb, c40);
c41 = _mm256_fmadd_ps(a1, vb, c41);

vb = _mm256_broadcast_ss(src_b + 5);
c50 = _mm256_fmadd_ps(a0, vb, c50);
c51 = _mm256_fmadd_ps(a1, vb, c51);

src_a += m_block_size;
src_b += n_block_size;
}

__m256 valpha = _mm256_set1_ps(alpha);
c00 = _mm256_mul_ps(c00, valpha);
c01 = _mm256_mul_ps(c01, valpha);

c10 = _mm256_mul_ps(c10, valpha);
c11 = _mm256_mul_ps(c11, valpha);

c20 = _mm256_mul_ps(c20, valpha);
c21 = _mm256_mul_ps(c21, valpha);

c30 = _mm256_mul_ps(c30, valpha);
c31 = _mm256_mul_ps(c31, valpha);

c40 = _mm256_mul_ps(c40, valpha);
c41 = _mm256_mul_ps(c41, valpha);

c50 = _mm256_mul_ps(c50, valpha);
c51 = _mm256_mul_ps(c51, valpha);

_mm256_storeu_ps(dst_c + 0 * ldc, c00);
_mm256_storeu_ps(dst_c + 0 * ldc + 8, c01);

_mm256_storeu_ps(dst_c + 1 * ldc, c10);
_mm256_storeu_ps(dst_c + 1 * ldc + 8, c11);

_mm256_storeu_ps(dst_c + 2 * ldc, c20);
_mm256_storeu_ps(dst_c + 2 * ldc + 8, c21);

_mm256_storeu_ps(dst_c + 3 * ldc, c30);
_mm256_storeu_ps(dst_c + 3 * ldc + 8, c31);

_mm256_storeu_ps(dst_c + 4 * ldc, c40);
_mm256_storeu_ps(dst_c + 4 * ldc + 8, c41);

_mm256_storeu_ps(dst_c + 5 * ldc, c50);
_mm256_storeu_ps(dst_c + 5 * ldc + 8, c51);
}

性能数据

在经历过漫长的讲解之后,那么 avx2_col_major_sgemm 的性能究竟如何呢?且看下表中的数据,表中使用了两个比较基准作为参照,分别是

  1. Naive, 最基础的矩阵乘算法实现,代码文中已经提供;
  2. oneDNN sgemm,oneDNN是英特尔公司大名鼎鼎的多平台支持、高性能计算库,其前身是 mkldnn。oneDNN 在各类硬件上都进行了深度优化,特别是在Intel CPU 上,其性能数据非常具备参考价值。
Shape(M, N, K) Naive GFLOPs oneDNN sgemm GFLOPs avx2_col_major_sgemm
(64, 64, 64) 1.96 32.97 35.42
(128, 128, 128) 1.65 62.69 40.36
(256, 256, 256) 1.44 73.19 65.84
(512, 512, 512) 0.95 70.06 67.65
(1024, 1024, 1024) 0.61 79.73 69.12

从数据上来看,avx2_col_major_sgemm 相较于 Naive 实现已经具备了质的飞跃,并且在多数shapes下可以取得接近 oneDNN 的性能,不过在 (128, 128, 128) 下和oneDNN 存在比较大的性能差异,这也说明 avx2_col_major_sgemm 仍然存在一定的优化空间。这很令人兴奋,不是么?

GPU 硬件与 CUDA 程序开发工具


GPU 硬件

在由 CPU 和 GPU 构成的异构计算平台中,通常将起控制作用的 CPU 称为 主机(host)
将起加速作用的 GPU 称为 设备(device)

主机和设备都有自己的 DRAM,之间一般由 PCIe 总线连接。

GPU 计算能力不等价于计算性能;表征计算性能的一个重要参数是 浮点数运算峰值(FLOPS)
浮点数运算峰值有单精度和双精度之分。对于 Tesla 系列的 GPU,双精度下 FLOPS 一般是单精度下的 1/2;
对于 GeForce 系列的 GPU,双精度下 FLOPS 一般是单精度下的 1/32。

影响计算性能的另一个参数是 GPU 内存带宽(显存)


CUDA 程序开发工具

  1. CUDA;
  2. OpenCL,更为通用的各种异构平台编写并行程序的框架,AMD 的 GPU 程序开发工具;
  3. OpenACC,由多公司共同开发的异构并行编程标准。

CUDA 提供两层 API,即 CUDA 驱动API 和 CUDA 运行时API。
CUDA 开发环境中,程序应用程序是以主机(CPU)为出发点的;应用程序可以调用 CUDA 运行时 API、
CUDA 驱动 API 和一些已有的 CUDA 库。


CUDA 开发环境搭建

linux 操作系统:linux下cuda环境搭建

windows10 操作系统:windows10下cuda环境搭建


nvidia-smi 检查与设置设备

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
nvidia-smi
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 462.30 Driver Version: 462.30 CUDA Version: 11.2 |
|-------------------------------+----------------------+----------------------+
| GPU Name TCC/WDDM | Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap| Memory-Usage | GPU-Util Compute M. |
| | | MIG M. |
|===============================+======================+======================|
| 0 GeForce MX450 WDDM | 00000000:2B:00.0 Off | N/A |
| N/A 39C P8 N/A / N/A | 119MiB / 2048MiB | 0% Default |
| | | N/A |
+-------------------------------+----------------------+----------------------+

+-----------------------------------------------------------------------------+
| Processes: |
| GPU GI CI PID Type Process name GPU Memory |
| ID ID Usage |
|=============================================================================|
| No running processes found |
+-----------------------------------------------------------------------------+
  1. CUDA Version, 11.2;
  2. GPU Name,GeForce MX450,设备号为 0;如果系统中有多个 GPU 且只要使用其中某个特定的 GPU,
    可以通过设置环境变量 CUDA_VISIBLE_DEVICES 的值,从而可以在运行 CUDA 程序前选定 GPU;
  3. TCC/WDDM,WDDM(windows display driver model),其它包括 TCC(Tesla compute cluster);
    可以通过命令行 nvidia-smi -g GPU_ID -dm 0,设置为 WDDM 模式(1 为 TCC 模式);
  4. Compute mode, Default,此时同一个 GPU 中允许存在多个进程;其他模式包括 E.Process,
    指的是独占进程模式,但不适用 WDDM 模式下的 GPU;
    可以通过命令行 nvidia-smi -i GPU_ID -c 0,设置为 Default 模式(1 为 E.Process 模式);
  5. Perf,p8(GPU 性能状态,最大p0~最小p12);

更多关于 nvidia-smi 的资料:nvidia-smi


CUDA 中的线程组织

CUDA 虽然支持 C++ 但支持得并不充分,导致 C++ 代码中有很多 C 代码的风格。

CUDA 采用 nvcc 作为编译器,支持 C++ 代码;nvcc 在编译 CUDA 程序时,
会将纯粹的 c++ 代码交给 c++ 编译器,自己负责编译剩下的 cu 代码。


C++ 的 Hello World 程序

1
2
3
>> g++ hello.cpp -o ./bin/hello.exe
>> ./bin/hello
msvc: hello world!

CUDA 的 Hello World 程序

使用 nvcc 编译纯粹 c++ 代码

1
2
3
>> nvcc -o ./bin/hello_cu.exe hello.cu 
>> ./bin/hello_cu.exe
nvcc: hello world!

在该程序中其实并未使用 GPU。

使用 核函数 的 CUDA 程序

一个利用了 GPU 的 CUDA 程序既有主机代码,又有设备代码(在设备中执行的代码)。
主机对设备的调用是通过 核函数(kernel function) 实现的。

1
2
3
4
5
6
7
8
int main()
{
主机代码
核函数的调用
主机代码

return 0
}

核函数与 c++ 函数的区别:

  1. 必须加 __global__ 限定;
  2. 返回类型必须是空类型 void
1
2
3
4
5
__global__ void hell_from__gpu()
{
// 核函数不支持 c++ 的 iostream。
printf("gpu: hello world!\n");
}

调用核函数的方式:

1
hello_from_gpu<<<1, 1>>>

主机在调用一个核函数时,必须指明在设备中指派多少线程。核函数中的线程常组织为若干线程块:

  1. 三括号中第一个数字是线程块的个数(number of thread block);
  2. 三括号中第二个数字是每个线程块中的线程数(number of thread in per block)。

一个核函数的全部线程块构成一个网格(grid),线程块的个数称为网格大小(grid size)。 每个线程块中含有相同数目的线程,该数目称为线程块大小(block size)。

所以,核函数的总的线程数即网格大小*线程块大小:

1
hello_from_gpu<<<grid size, block size>>>

调用核函数后,调用 CUDA 运行时 API 函数,同步主机和设备:

1
cudaDeviceSynchronize();

核函数中调用输出函数,输出流是先存放在缓冲区的,而缓冲区不会自动刷新。


CUDA 的线程组织

核函数的总线程数必须至少等于计算核心数时才有可能充分利用 GPU 的全部计算资源。

hello_from_gpu<<<2, 4>>>

网格大小是2,线程块大小是4,总线程数即8。核函数中代码的执行方式是 “单指令-多线程”,
即每个线程执行同一串代码。

从开普勒架构开始,最大允许的线程块大小是 2^10 (1024),最大允许的网格大小是 2^31 - 1(一维网格)。

线程总数可以由两个参数确定:

  1. gridDim.x, 即网格大小;
  2. blockDim.x, 即线程块大小;

每个线程的身份可以由两个参数确定:

  1. blockIdx.x, 即一个线程在一个网格中的线程块索引,[0, gridDm.x);
  2. threadIdx.x, 即一个线程在一个线程块中的线程索引,[0, blockDim.x);

网格和线程块都可以拓展为三维结构(各轴默认为 1):

  1. 三维网格 grid_size(gridDim.x, gridDim.y, gridDim.z);
  2. 三维线程块 block_size(blockDim.x, blockDim.y, blockDim.z);

相应的,每个线程的身份参数:

  1. 线程块ID (blockIdx.x, blockIdx.y, blockIdx.z);
  2. 线程ID (threadIdx.x, threadIdx.y, threadIdx.z);

多维网格线程在线程块上的 ID;

tid = threadIdx.z * (blockDim.x * blockDim.y)  // 当前线程块上前面的所有线程数
    + threadIdx.y * (blockDim.x)               // 当前线程块上当前面上前面行的所有线程数
    + threadIdx.x                              // 当前线程块上当前面上当前行的线程数

多维网格线程块在网格上的 ID:

1
2
3
bid = blockIdx.z * (gridDim.x * gridDim.y)
+ blockIdx.y * (gridDim.x)
+ blockIdx.x

一个线程块中的线程还可以细分为不同的 线程束(thread warp),即同一个线程块中
相邻的 warp_size 个线程(一般为 32)。

对于从开普勒架构到图灵架构的 GPU,网格大小在 x, y, z 方向的最大允许值为 (2^31 - 1, 2^16 - 1, 2^16 -1);
线程块大小在 x, y, z 方向的最大允许值为 (1024, 1024, 64),同时要求一个线程块最多有 1024 个线程。


CUDA 的头文件

CUDA 头文件的后缀一般是 “.cuh”;同时,同时可以包含c/cpp 的头文件 “.h”、“.hpp”,采用 nvcc 编译器会自动包含必要的 cuda 头文件,如 <cuda.h>, <cuda_runtime.h>,同时前者也包含了c++头文件 <stdlib.h>。


使用 nvcc 编译 CUDA 程序

nvcc 会先将全部源代码分离为 主机代码 和 设备代码;主机代码完整的支持 c++ 语法,而设备代码只部分支持。

nvcc 会先将设备代码编译为 PTX(parrallel thread execution)伪汇编代码,再将其编译为二进制 cubin目标代码。 在编译为 PTX 代码时,需要选项 -arch=compute_XY 指定一个虚拟架构的计算能力;在编译为 cubin 代码时, 需要选项 -code=sm_ZW 指定一个真实架构的计算能力,以确定可执行文件能够使用的 GPU。

真实架构的计算能力必须大于等于虚拟架构的计算能力,例如:

-arch=compute_35  -code=sm_60  (right)
-arch=compute_60  -code=sm_35  (wrong)

如果希望编译出来的文件能在更多的GPU上运行,则可以同时指定多组计算能力,例如:

-gencode arch=compute_35, code=sm_35
-gencode arch=compute_50, code=sm_50
-gencode arch=compute_60, code=sm_60

此时,编译出来的可执行文件将包含3个二进制版本,称为 胖二进制文件(fatbinary)

同时,nvcc 有一种称为 **实时编译(just-in-time compilation)**机制,可以在运行可执行文件时从其中保留的PTX
代码中临时编译出一个 cubin 目标代码。因此, 需要通过选项 -gencode arch=compute_XY, code=compute_XY
指定所保留 PTX 代码的虚拟架构, 例如:

-gencode arch=compute_35, code=sm_35
-gencode arch=compute_50, code=sm_50
-gencode arch=compute_60, code=sm_60  
-gencode arch=compute_70, code=compute_70

于此同时,nvcc 编译有一个简化的编译选项 -arch=sim_XY,其等价于:

-gencode arch=compute_XY, code=sm_XY  
-gencode arch=compute_XY, code=compute_XY

关于 nvcc 编译器的更多资料: nvcc


简单 CUDA 程序的基本框架

单源文件 CUDA 程序基本框架

对于单源文件的 cuda 程序,基本框架为:

包含头文件

定义常量或宏

声明 c++ 自定义函数和 cuda 核函数的原型

int main()
{
    1. 分配主机和设备内存
    2. 初始化主机中数据
    3. 将某些数据从主机复制到设备
    4. 调用核函数在设备中计算
    5. 将某些数据从设备复制到主机
    6. 释放主机和设备内存
}

c++ 自定义函数和 cuda 核函数的定义

CUDA 核函数的要求:

  1. 返回类型必须是 void,但是函数中可以使用 return(但不可以返回任何值);
  2. 必须使用限定符 __glolbal__,也可以加上 c++ 限定符;
  3. 核函数支持 c++ 的重载机制;
  4. 核函数不支持可变数量的参数列表,即参数个数必须确定;
  5. 一般情况下,传给核函数的数组(指针)必须指向设备内存(“统一内存编程机制”除外);
  6. 核函数不可成为一个类的成员(一般以包装函数调用核函数,将包装函数定义为类成员);
  7. 在计算能力3.5之前,核函数之间不能相互调用;之后,通过“动态并行”机制可以调用;
  8. 无论从主机调用还是从设备调用,核函数都在设备中执行(“<<<,>>>”指定执行配置)。

自定义设备函数

核函数可以调用不带执行配置的自定义函数,即 设备函数

设备函数在设备中执行、在设备中被调用;而核函数在设备中执行、在主机中被调用。

  1. __global__修饰的函数称为核函数,一般由主机调用、在设备中执行;
  2. __device__修饰的函数称为设备函数,只能被核函数或其他设备函数调用、在设备中执行;
  3. __host__修饰主机段的普通 c++ 函数,在主机中被调用、在主机中执行,一般可以省略;
  4. 可以同时用 __host____device__ 修饰函数,从而减少代码冗余,此时编译器将
    分别在主机和设备上编译该函数;
  5. 不能同时用 __global____device__ 修饰函数;
  6. 不能同时用 __global____host__ 修饰函数;
  7. 可以通过 __noinline__ 建议编译器不要将一个设备函数当作内联函数;
  8. 可以通过 __forceinline__ 建议编译器将一个设备函数当作内联函数。

设备函数可以有返回值。

CUDA 程序的错误检测


检测 CUDA 运行时错误的宏函数

定义检查 cuda 运行时 API 返回值 cudaError_t 的宏函数。

#define CHECK(call)                                                     \
do {                                                                    \
    const cudaError_t error_code = call;                                \
    if (error_code != cudaSuccess)                                      \
    {                                                                   \
        printf("CUDA ERROR: \n");                                       \
        printf("    FILE: %s\n", __FILE__);                             \
        printf("    LINE: %d\n", __LINE__);                             \
        printf("    ERROR CODE: %d\n", error_code);                     \
        printf("    ERROR TEXT: %s\n", cudaGetErrorString(error_code)); \
        exit(1);                                                        \
    }                                                                   \
}while(0); 

因为核函数没有返回值,所以无法直接检查核函数错误。间接的方法是,在调用核函数后执行:

CHECK(cudaGetLastError());  // 捕捉同步前的最后一个错误。
CHECK(cudaDeviceSynchronize());  // 同步主机和设备。

核函数的调用是 异步的,即主机调用核函数后不会等待核函数执行完成、而是立刻执行之后的语句。 同步操作较为耗时,一般尽量避免;同时,只要在核函数调用后还有对其他任何能返回错误值的 API 函数进行同步调用,都会触发主机和设备的同步并捕捉到核函数中可能发生的错误。

此外,主机和设备之间的数据拷贝会隐式地同步主机和设备。一般要获得精确的出错位置,还是需要显式地 同步,例如调用 cudaDeviceSynchronize()

或者,通过设置环境变量 CUDA_LAUNCH_BLOCKING 为 1,这样所有核函数的调用都将不再是异步的, 而是同步的。就是说,主机调用一个核函数之后必须等待其执行完,才能向下执行。 一般仅用于程序调试。


CUDA-MEMCHECK 检查内存错误

CUDA 提供了 CUDA-MEMCHECK 的工具集,包括 memcheck, racecheck, initcheck, synccheck.

>> cuda-memcheck --tool memcheck [options] app-name [options]

对于 memcheck 工具,可以简化为:

>> cuda-memcheck [options] app-name [options]

对于本例,可以通过如下方式检测错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
    >> cuda-memcheck ./bin/check.exe
========= CUDA-MEMCHECK

CUDA ERROR:
FILE: check.cu
LINE: 56
ERROR CODE: 9
ERROR TEXT: invalid configuration argument
========= Program hit cudaErrorInvalidConfiguration (error 9) due to "invalid configuration argument" on CUDA API call to cudaLaunchKernel.
========= Saved host backtrace up to driver entry point at error
========= Host Frame:C:\Windows\system32\DriverStore\FileRepository\nvhq.inf_amd64_5550755be1247d27\nvcuda64.dll (cuProfilerStop + 0x97c18) [0x2b8ca8]
========= Host Frame:C:\Windows\system32\DriverStore\FileRepository\nvhq.inf_amd64_5550755be1247d27\nvcuda64.dll (cuProfilerStop + 0x9a2da) [0x2bb36a]
========= Host Frame:C:\Windows\system32\DriverStore\FileRepository\nvhq.inf_amd64_5550755be1247d27\nvcuda64.dll [0x7b52e]
========= Host Frame:C:\Windows\system32\DriverStore\FileRepository\nvhq.inf_amd64_5550755be1247d27\nvcuda64.dll (cuProfilerStop + 0x11ceaa) [0x33df3a]
========= Host Frame:C:\Windows\system32\DriverStore\FileRepository\nvhq.inf_amd64_5550755be1247d27\nvcuda64.dll (cuProfilerStop + 0x137532) [0x3585c2]
========= Host Frame:D:\3_codes\CudaSteps\capter4\bin\check.exe [0x1679]
========= Host Frame:D:\3_codes\CudaSteps\capter4\bin\check.exe [0xd32b]
========= Host Frame:D:\3_codes\CudaSteps\capter4\bin\check.exe [0xd1a8]
========= Host Frame:D:\3_codes\CudaSteps\capter4\bin\check.exe [0xc6a1]
========= Host Frame:D:\3_codes\CudaSteps\capter4\bin\check.exe [0xcbf8]
========= Host Frame:D:\3_codes\CudaSteps\capter4\bin\check.exe [0xd944]
========= Host Frame:C:\Windows\System32\KERNEL32.DLL (BaseThreadInitThunk + 0x14) [0x17034]
========= Host Frame:C:\Windows\SYSTEM32\ntdll.dll (RtlUserThreadStart + 0x21) [0x52651]
=========
========= Program hit cudaErrorInvalidConfiguration (error 9) due to "invalid configuration argument" on CUDA API call to cudaGetLastError.
========= Saved host backtrace up to driver entry point at error
========= Host Frame:C:\Windows\system32\DriverStore\FileRepository\nvhq.inf_amd64_5550755be1247d27\nvcuda64.dll (cuProfilerStop + 0x97c18) [0x2b8ca8]
========= Host Frame:C:\Windows\system32\DriverStore\FileRepository\nvhq.inf_amd64_5550755be1247d27\nvcuda64.dll (cuProfilerStop + 0x9a2da) [0x2bb36a]
========= Host Frame:C:\Windows\system32\DriverStore\FileRepository\nvhq.inf_amd64_5550755be1247d27\nvcuda64.dll [0x7b52e]
========= Host Frame:C:\Windows\system32\DriverStore\FileRepository\nvhq.inf_amd64_5550755be1247d27\nvcuda64.dll (cuProfilerStop + 0x11ceaa) [0x33df3a]
========= Host Frame:C:\Windows\system32\DriverStore\FileRepository\nvhq.inf_amd64_5550755be1247d27\nvcuda64.dll (cuProfilerStop + 0x137532) [0x3585c2]
========= Host Frame:D:\3_codes\CudaSteps\capter4\bin\check.exe [0x1461]
========= Host Frame:D:\3_codes\CudaSteps\capter4\bin\check.exe [0xcbfd]
========= Host Frame:D:\3_codes\CudaSteps\capter4\bin\check.exe [0xd944]
========= Host Frame:C:\Windows\System32\KERNEL32.DLL (BaseThreadInitThunk + 0x14) [0x17034]
========= Host Frame:C:\Windows\SYSTEM32\ntdll.dll (RtlUserThreadStart + 0x21) [0x52651]
=========
========= ERROR SUMMARY: 2 errors

关于 CUDA-MEMCHECK 的更多内容,详见: CUDA-MEMCHECK


获得 GPU 加速的关键


CUDA 事件计时

C++ 的计时方法:

  1. GCC 和 MSVC 都有的 clock()函数;
  2. 原生的 时间库;
  3. GCC 的 gettimeofday()计时;
  4. MSVC 的 QueryPerformanceCounter()QueryPerformanceFrequency() 计时。

CUDA 基于 CUDA 事件的计时方法:

cudaEvent_t start, stop;
CHECK(cudaEventCreate(&start)); // 创建cuda 事件对象。
CHECK(cudaEventCreate(&stop));
CHECK(cudaEventRecord(start));  // 记录代表开始的事件。
cudaEventQuery(start);  // 强制刷新 cuda 执行流。

// run code.

CHECK(cudaEventRecord(stop));
CHECK(cudaEventSynchronize(stop)); // 强制同步,让主机等待cuda事件执行完毕。
float elapsed_time = 0;
CHECK(cudaEventElapsedTime(&curr_time, start, stop)); // 计算 start 和stop间的时间差(ms)。
printf("host memory malloc and copy: %f ms.\n", curr_time - elapsed_time);  

由于 cuda 程序需要在主机和设备间传递数据,所以当计算强度较小时数据传输的性能对程序总耗时影响更大。
因此 cuda 的两种浮点数类型对程序性能的影响就较为明显。考虑提供编译选项,指定版本:

#ifdef USE_DP
    typedef double real;  // 双精度
    const real EPSILON = 1.0e-15;
#else
    typedef float real;   // 单精度
    const real EPSILON = 1.0e-6f;
#endif

在编译时,除了指定 GPU 计算能力 -arch=sm_50,还可以指定 c++ 优化等级 -O3;同时,可以指定其他
编译选项,如 -DUSE_DP 启用双精度版本。

>> nvcc -O3 -arch=sm_50 -DUSE_DP -o ./bin/clock.exe add.cu clock.cu main.cpp
...
>> ./bin/clock
using double precision version
host memory malloc and copy: 2.054112 ms.
device memory malloc: 9.063583 ms.
kernel function : 0.803360 ms.
cuda; no error
copy from device to host: 7.489505 ms.  

>> nvcc -O3 -arch=sm_50 -o ./bin/clock.exe add.cu clock.cu main.cpp
...
>> ./bin/clock     
host memory malloc and copy: 0.950240 ms.
device memory malloc: 5.298208 ms.
kernel function : 0.620512 ms.
cuda; no errors
copy from device to host: 3.034208 ms.

可见双精度版本基本上比单精度版本耗时多一倍。


nvprof 查看程序性能

>> nvprof ./bin/clock

如果没有输出结果,需要将nvprof的目录包含到环境环境变量中(不支持7.5 以上计算能力的显卡)。
推荐采用一代性能分析工具: Nvidia Nsight Systems.


影响 GPU 加速的关键因素

  1. 要获得可观的 GPU 加速,就必须尽量缩减主机和设备间数据传输所花时间的占比。

有些计算即使在 GPU 中速度不高也要尽量放在 GPU 中实现,以避免过多数据经由 PCIe 传递。

  1. 提高算术强度可以显著地提高 GPU 相对于 CPU 的加速比。

算术强度,是指一个计算问题中算术操作的工作量与必要的内存操作的工作量之比。 对设备内存的访问速度取决于 GPU 的显存带宽。

  1. 核函数的并行规模。

并行规模可以用 GPU 中的线程数目来衡量。 一个 GPU 由多个流多处理器SM(streaming multiprocessor)构成,每个 SM 中有若干 CUDA 核心。 每个 SM 是相对独立的,一个 SM 中最多驻留的线程数一般为 2048 或 1024(图灵架构)。

若要 GPU 满负荷工作,则核函数中定义的线程总数要不少于某值,一般与 GPU 能够驻留的线程总数相当。


CUDA 的数学函数库

CUDA 提供的数学函数库提供了多种 数学函数,同时 CUDA 提供了一些高效率、低准确度的 内建函数

CUDA 数学函数库的更多资料,详见:CUDA math.


CUDA 的内存组织

CUDA 中不同类型的内存

CUDA 中的内存类型有:全局内存、常量内存、纹理内存、寄存器、局部内存、共享内存。
CUDA 的内存,即设备内存,主机无法直接访问。


全局内存

全局内存(global memory),即核函数中所有线程都可以访问的内存,可读可写,由主机端分配和释放; 如 cudaMalloc() 的设备内存 d_x, d_y, d_z。

全局内存由于没有放到 GPU 芯片上,所以具有较高的延迟和较低的访问速度,但是容量大(显存)。 全局内存主要为核函数提供数据,并在主机和设备、设备和设备之间传递数据。

全局内存的生命周期由主机端维护,期间不同的核函数可以多次访问全局内存。

除以上动态分配的全局内存变量外,还可以使用 静态全局内存变量,其所占内存数量在编译器确定; 这样的静态全局内存变量必须在 所有主机和设备函数外部定义,例如:

1
2
__device__ real epsilon;  // 单个静态全局内存变量, `__device` 表示是设备中的变量。
__device__ real arr[10]; // 固定长度的静态全局内存数组变量。

对于静态全局内存变量,其访问权限:

  1. 核函数中可以直接访问静态全局内存变量,不必以参数形式传给核函数;
  2. 主机中不可以直接访问静态全局内存变量,可以通过 cudaMemcpyToSymbol()cudaMemcpyFromSymbol() 调用。

常量内存

常量内存(constant memory),仅有 64 kb,可见范围和生命周期与全局内存一样;具有缓存,从而高速;
常量内存仅可读、不可写。

使用常量内存的方法:一是在核函数外定义常量内存变量;二是向核函数传递常量参数,默认存放在常量内存:

  1. 核函数中可以直接访问常量全局内存变量,不必以参数形式传给核函数,但不可更改(只读);
  2. 主机中不可以直接访问常量全局内存变量,可以通过 cudaMemcpyToSymbol()cudaMemcpyFromSymbol() 调用。

纹理内存

纹理内存(texture memory),类似常量内存,也是一种具有缓存的全局内存,具有相同可见范围和生命周期。

可以将某些只读的全局内存数据用 __ldg() 函数通过只读数据缓存(read-only data cache)读取, 既可以达到使用纹理内存的加速效果,又可使代码简洁:

int __ldg(const int* ptr);  // 函数原型。

全局内存的读取在默认情况下就利用了 __ldg() 函数,所以不需要显式地使用。


寄存器

在核函数中定义的、不加任何限定符的变量一般存放在寄存器(register);核函数中不加任何限定符的数组可能放在
寄存器,也可能放在局部内存中。寄存器可读可写。

各种内建变量,如 gridDim、blockDim 等都保存在特殊的寄存器中。

寄存器变量仅被一个线程看见,寄存器的生命周期也和所属线程相同。

寄存器内存在芯片上,是所有内存中访问速度最高的。一个寄存器占 32b(4字节),一个双精度浮点数占 2个寄存器。


局部内存

局部内存(local memory)也是全局内存的一部分,每个线程最多可以使用 512 kb 的局部内存,但过多使用会降低性能。
局部内存的用法类似寄存器。


共享内存

共享内存(shared memory)与寄存器类似,都是位于芯片上,读写速度较快。

共享内存对整个线程块可见,一个线程块上的所有线程都可以访问共享内存上的数据;共享内存的生命周期也与所属线程块一致。

共享内存的主要作用是减少对全局内存的访问,或者改善对全局内存的访问模式。


L1 和 L2 缓存

SM 层次的 L1 缓存(一级缓存)和设备层次 L2 缓存(二级缓存)。它们主要用来缓存全局内存和设备内存的访问。


SM 及其占有率

一个 GPU 由多个 SM(流多处理器)构成,一个 SM 包含如下资源:

  1. 一定数量的寄存器;
  2. 一定数量的共享内存;
  3. 常量内存的缓存;
  4. 纹理内存的缓存;
  5. L1 缓存;
  6. 两个或四个线程束调度器,用于在不同线程上下文间迅速切换,及为准备就绪的线程束发出执行指令;
  7. 执行核心。

一般来说,要尽量让 SM 的占有率不小于某值(如 25%),才有可能获得较高的性能。

  • 一个 SM 中最多拥有的线程块个数 Nb=16(开普勒和图灵架构)或 Nb=32(麦克斯韦、帕斯卡和伏特架构);
  • 一个 SM 中最多拥有的线程格式为 Nt=1028(图灵架构)或 Nt=2048(开普勒到伏特架构)。

在线程块中,每 32 个连续线程为一个 线程束
SM 中线程的执行是以线程束为单位的,所以最好将线程块大小取为线程束大小(32个线程)的整数倍(如 128).


CUDA 运行时 API 函数查询设备

使用 CUDA 运行时 API 函数查询所用GPU 规格。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include "common/error.cuh"
#include <stdlib.h>


int main(int argc, char *argv[])
{
int device_id = 0;
if (argc > 1) device_id = atoi(argv[1]);

CHECK(cudaSetDevice(device_id));

cudaDeviceProp prop;
CHECK(cudaGetDeviceProperties(&prop, device_id));

printf("Device id: %d\n", device_id);
printf("Device name: %s\n", prop.name);
printf("Compute capability: %d.%d\n", prop.major, prop.minor);
printf("Amount of global memory: %g GB\n", prop.totalGlobalMem/(1024.0*1024*1024));
printf("Amount of constant memory: %g KB\n", prop.totalConstMem/1024.0);
printf("Maximum grid size: %d, %d, %d\n", prop.maxGridSize[0], prop.maxGridSize[1], prop.maxGridSize[2]);
printf("Maximum block size: %d, %d, %d\n", prop.maxThreadsDim[0],prop.maxThreadsDim[1],prop.maxThreadsDim[2]);
printf("Number of SMs: %d\n", prop.multiProcessorCount);
printf("Maximum amount of shared memory per block: %g KB\n", prop.sharedMemPerBlock/1024.0);
printf("Maximum amount of shared memory per SM: %g KB\n", prop.sharedMemPerMultiprocessor/1024.0);
printf("Maximum number of registers per block: %d K\n", prop.regsPerBlock/1024);
printf("Maximum number of registers per SM: %d K\n", prop.regsPerMultiprocessor/1024);
printf("Maximum number of threads per block: %d\n", prop.maxThreadsPerBlock);
printf("Maximum number of threads per SM: %d\n", prop.maxThreadsPerMultiProcessor);

return 0;
}

输出:

Device id: 0
Device name: GeForce MX450
Compute capability: 7.5
Amount of global memory: 2 GB
Amount of constant memory: 64 KB
Maximum grid size: 2147483647, 65535, 65535
Maximum block size: 1024, 1024, 64
Number of SMs: 14
Maximum amount of shared memory per block: 48 KB
Maximum amount of shared memory per SM: 64 KB
Maximum number of registers per block: 64 K
Maximum number of registers per SM: 64 K
Maximum number of threads per block: 1024
Maximum number of threads per SM: 1024

全局内存的合理使用

在各种设备内存中,全局内存具有最低的访问速度,往往是一个 CUDA 程序的性能瓶颈。


全局内存的合并与非合并访问

对全局内存的访问将触发内存事务,即数据传输。 在启用了 L1 缓存的情况下,对全局内存的读取将首先尝试经过 L1 缓存;如果未命中, 则尝试经过 L2 缓存;如果再次未命中,则直接从 DRAM 读取。

一次 数据传输处理 的数据量在默认情况下是 32 字节。
一次数据传输中,从全局内存转移到 L2 缓存的一片内存的首地址一定是 32 的整数倍。 也就是说,一次数据传输只能从全局内存读取地址为 0-31 字节、32-63 字节等片段的数据。

合并度,即线程束请求的字节数与由此导致的所有内存事务中所传输的字节数之比。
如果所有数据传输处理的数据都是线程束所需要的,则合并度为 100%,即 合并访问; 否则,即为 非合并访问

以仅使用 L2 缓存的情况为例,一次数据传输指的就是将 32 字节数据从全局内存(DRAM) 通过 32 字节的 L2 缓存片段(cache sector)传输到 SM。 考虑一个线程束访问单精度浮点数类型的全局内存变量的场景, 一个单精度浮点数占有 4 个字节,故一次访问需要 32*4 个字节的数据。在理想情况下, 即合并度为 100% 时,将仅触发 128/32=4 次调用 L2 缓存的数据传输。 如果线程束请求的全局内存地址刚好为 0-127 字节或 128-255 字节,就能与 4 次数据 传输所处理的数据完全吻合,这种情况下就是合并访问。

64 位系统中基本数据类型的内存长度(字节):

int size:               4
short size:             2
float size:             4
double size:            8
char size:              1
bool size:              1
long size:              4
int pointer size:       8
float pointer size:     8
double pointer size:    8
char pointer size:      8

矩阵转置

在核函数中,如果读取操作是非合并访问,则可以采用 只读数据缓存技术,通过加载函数 __ldg() 读取全局内存,从而对数据的读取进行缓存、缓解非合并访问的影响。

从帕斯卡架构开始,编译器会自动判断并调用 __ldg() 函数提升性能;对于开普勒架构、麦克斯韦架构,默认情况下不会使用 __ldg() 函数,需要手动配置。

对于核函数中全局内存的写入,则没有类似函数可用。所以若不能满足读取和写入都是合并的, 一般应该尽量做到合并写入。

核函数中可以直接使用在函数外部由 #defineconst 定义的常量,包括整型和浮点型常量。 但是在windows平台下(MSVC编译器)核函数无法使用外部定义的 const 定义的浮点型常量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134

#include "common/error.cuh"
#include <iostream>

#ifdef USE_DP
typedef double real;
const real EPSILON = 1.0e-15;
#else
typedef float real;
const real EPSILON = 1.0e-6f;
#endif

// using namespace std; // 不能使用std,会导致 `copy()` 不能使用(命名冲突)。


__constant__ int TILE_DIM = 32; // 设备内存中线程块中矩阵维度(线程块大小,最大1024)。

__global__ void copy(const real *src, real *dst, const int N);
__global__ void transpose1(const real *src, real *dst, const int N);
__global__ void transpose2(const real *src, real *dst, const int N);


int main()
{
const int N = 10000;
const int M = N * N * sizeof(real);

int SIZE = 0;
CHECK(cudaMemcpyFromSymbol(&SIZE, TILE_DIM, sizeof(int)));

const int grid_size_x = (N + SIZE - 1)/SIZE; // 获取网格大小。
const int grid_size_y = grid_size_x;

const dim3 block_size(SIZE, SIZE);
const dim3 grid_size(grid_size_x, grid_size_y);

real *h_matrix_org, *h_matrix_res;
h_matrix_org = new real[N*N];
h_matrix_res = new real[N*N];
for (int i = 0; i < N; ++i)
{
for (int j = 0; j < N; ++j)
{
h_matrix_org[j] = i;
}
}

float elapsed_time = 0;
float curr_time = 0;
cudaEvent_t start, stop;
CHECK(cudaEventCreate(&start));
CHECK(cudaEventCreate(&stop));
CHECK(cudaEventRecord(start));
cudaEventQuery(start);

real *d_matrix_org, *d_matrix_res;
CHECK(cudaMalloc(&d_matrix_org, M));
CHECK(cudaMalloc(&d_matrix_res, M));
CHECK(cudaMemcpy(d_matrix_org, h_matrix_org, M, cudaMemcpyDefault));

copy<<<grid_size, block_size>>>(d_matrix_org, d_matrix_res, N);
CHECK(cudaMemcpy(h_matrix_res, d_matrix_res, M, cudaMemcpyDefault));

CHECK(cudaEventRecord(stop));
CHECK(cudaEventSynchronize(stop));
CHECK(cudaEventElapsedTime(&curr_time, start, stop));
printf("matrix copy time cost: %f ms.\n", curr_time - elapsed_time);
elapsed_time = curr_time;

transpose1<<<grid_size, block_size>>>(d_matrix_org, d_matrix_res, N);
CHECK(cudaMemcpy(h_matrix_res, d_matrix_res, M, cudaMemcpyDefault));

CHECK(cudaEventRecord(stop));
CHECK(cudaEventSynchronize(stop));
CHECK(cudaEventElapsedTime(&curr_time, start, stop));
printf("matrix transpose1 time cost: %f ms.\n", curr_time - elapsed_time);
elapsed_time = curr_time;

transpose2<<<grid_size, block_size>>>(d_matrix_org, d_matrix_res, N);
CHECK(cudaMemcpy(h_matrix_res, d_matrix_res, M, cudaMemcpyDefault));

CHECK(cudaEventRecord(stop));
CHECK(cudaEventSynchronize(stop));
CHECK(cudaEventElapsedTime(&curr_time, start, stop));
printf("matrix transpose2 time cost: %f ms.\n", curr_time - elapsed_time);
elapsed_time = curr_time;

delete[] h_matrix_res;
delete[] h_matrix_org;
CHECK(cudaFree(d_matrix_org));
CHECK(cudaFree(d_matrix_res));

return 0;
}


__global__ void copy(const real *src, real *dst, const int N)
{
// TILE_DIM = blockDim.x = blockDim.y
const int nx = blockIdx.x * TILE_DIM + threadIdx.x; // 矩阵列索引。
const int ny = blockIdx.y * TILE_DIM + threadIdx.y; // 矩阵行索引。
const int index = ny * N + nx;

if (nx >= N || ny >= N)
{
return;
}

dst[index] = src[index]; // 全局内存中数组也是线性存放的。
}

__global__ void transpose1(const real *src, real *dst, const int N)
{
const int nx = threadIdx.x + blockIdx.x * TILE_DIM;
const int ny = threadIdx.y + blockIdx.y * TILE_DIM;

if (nx < N && ny < N)
{
// 矩阵转置(合并读取、非合并写入)。
dst[nx*N + ny] = src[ny*N + nx];
}
}

__global__ void transpose2(const real *src, real *dst, const int N)
{
const int nx = threadIdx.x + blockIdx.x * TILE_DIM;
const int ny = threadIdx.y + blockIdx.y * TILE_DIM;

if (nx < N && ny < N)
{
// 矩阵转置(非合并读取、合并写入)。
dst[ny*N + nx] = __ldg(&src[nx*N + ny]); // 显示调用 `__ldg()` 函数缓存全局内存。
}
}

共享内存的合理使用

共享内存是一种可以被程序员直接操作的缓存,主要作用有两个:

  1. 减少核函数中对全局内存的访问次数,实现高效的线程块内部的通信;
  2. 提高全局内存访问的合并度。

数组归约

对于多线程程序,默认情况下不同线程的执行顺序是不固定的(线程间独立)。

采用 折半规约法,通过线程块对数据分片归约,最后再一并求和。

核函数中循环的每一轮都会被拆解、分配到线程块内的所有线程上执行,而不是一个线程连续执行一次完整循环。 核函数中代码是 “单指令多线程” ,代码真正的执行顺序与出现顺序可能不同。所以 线程 0、1、… 127之间实际上并行的。

保证一个线程块中所有线程在执行该语句后面的语句之前,都完全执行了前面的语句:通过 __syncthreads() 实现一个线程块中所有线程按照代码出现的顺序执行指令,但是不同线程块之间依然是独立、异步的。

共享内存变量,可以在核函数中通过限定符 __shared__ 定义一个共享内存变量, 这样就相当于在每一个线程块中有一个该变量的副本。虽然每个副本都是独立的,但核函数中对共享变量的操作 都将 同时 作用在所有副本上。

核函数中可以直接使用函数外部由 #defineconst 定义的常量,但在 MSVC 中限制了核函数使用 const 定义的常量。

利用共享内存进行线程块之间的合作(通信)之前,都要进行同步,以确保共享内存变量中数据对于所有线程块内的 所有线程都是准备好的。

共享内存的生命周期仅在核函数内,所以必须在核函数结束前将共享内存中需要的结果保存到全局内存。 通过共享内存可以避免修改全局内存变量,同时不再要求全局内存数组为 线程块大小的整数倍。

线程块的共享内存根据申请方式分为:静态共享内存变量和动态共享内存变量。 前者在核函数中定义共享内存大小(通过编译期常量),后者在主机调用核函数时指定大小(可以提高可维护性)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
#include "../common/error.cuh"
#include "../common/floats.hpp"
#include <chrono>

using namespace std::chrono;

__constant__ int BLOCK_DIM = 128;


real reduce_cpu(const real *x, const int N)
{
real sum = 0.0;
for (int i = 0; i < N ; ++i)
{
sum += x[i];
}

return sum;
}

__global__ void reduce(real *x, real *y)
{
// 这里执行迭代折半归约计算时,实际上的线程执行过程:
// 1. 线程 0-127,offset = N/2, 迭代第一次;
// 2. 线程 0-127,offset = N/4, 迭代第二次;
// ...
// 即,核函数中循环的每一轮都会被拆解、分配到线程块内的所有线程上执行,而不是一个
// 线程连续执行一次完整循环。
const int tid = threadIdx.x;
real *curr_x = x + blockIdx.x * blockDim.x; // 当前线程块中处理的内存首地址。

for (int offset = blockDim.x >> 1; offset > 0; offset >>=1) // 迭代折半归约。
{
// 由于条件筛选,实际导致每轮有效的线程数量减半,即 “线程束的分化”。
// 要求数组大小为线程块大小的整数倍。
if (tid < offset)
{
// 核函数中代码是 “单指令多线程” ,代码真正的执行顺序与出现顺序可能不同。
// 所以 线程 0、1、... 127之间实际上并行的。
curr_x[tid] += curr_x[tid + offset];
}

// 保证一个线程块中所有线程在执行该语句后面的语句之前,都完全执行了前面的语句。
// 实现一个线程块中所有线程按照代码出现的顺序执行指令,即线程1等待线程0,如此。
// 但是不同线程块之间依然是独立、异步的。
__syncthreads();
}

if (tid == 0)
{
// 通过线程块内同步,线程块 0 中的归约顺序:
// 第一轮,curr_x[0] += curr_x[0+64], ... curr_x[63] += curr_x[63+64];
// 第二轮,curr_x[0] += curr_x[0+32], ... curr_x[31] += curr_x[31+32];
// 第三轮,curr_x[0] += curr_x[0+16], ... curr_x[15] += curr_x[15+16];
// 第四轮,curr_x[0] += curr_x[0+ 8], ... curr_x[7 ] += curr_x[7 + 8];
// 第五轮,curr_x[0] += curr_x[0+ 4], ... curr_x[3 ] += curr_x[3 + 4];
// 第六轮,curr_x[0] += curr_x[0+ 2], curr_x[1 ] += curr_x[1 + 2];
// 第七轮,curr_x[0] += curr_x[0+ 1];
y[blockIdx.x] = curr_x[0];
}
}

__global__ void reduce_shared(const real *x, real *y, const int N)
{
const int tid = threadIdx.x;
const int bid = blockIdx.x;
const int ind = bid * blockDim.x + tid;

__shared__ real s_x[128]; // 定义线程块静态共享内存变量。
s_x[tid] = (ind < N) ? x[ind] : 0.0; // 拷贝全局内存变量到线程块内的共享内存数据副本。
__syncthreads(); // 同步线程块的数据拷贝操作,保证各线程块中数据对于块内线程都准备好。

for (int offset = blockDim.x>>1; offset > 0; offset>>=1)
{
if (ind < offset)
{
s_x[tid] += s_x[tid + offset];
}
__syncthreads(); // 线程块内线程同步。
}

if (tid == 0)
{
y[bid] = s_x[0]; // 保存各个线程块中共享内存的0元素到全局内存。
}
}

__global__ void reduce_shared2(const real *x, real *y, const int N)
{
const int tid = threadIdx.x;
const int bid = blockIdx.x;
const int ind = bid * blockDim.x + tid;

extern __shared__ real s_x[]; // 定义线程块动态共享内存变量,内存大小由主机调用核函数时定义。
s_x[tid] = (ind < N) ? x[ind] : 0.0; // 拷贝全局内存变量到线程块内的共享内存数据副本。
__syncthreads(); // 同步线程块的数据拷贝操作,保证各线程块中数据对于块内线程都准备好。

for (int offset = blockDim.x>>1; offset > 0; offset>>=1)
{
if (ind < offset)
{
s_x[tid] += s_x[tid + offset];
}
__syncthreads(); // 线程块内线程同步。
}

if (tid == 0)
{
y[bid] = s_x[0]; // 保存各个线程块中共享内存的0元素到全局内存。
}
}


int main()
{
int N = 1e8; // 单精度将发生 “大数吃小数” 的现象,导致结果完全错误;双精度没有问题。
int M = N * sizeof(real);

int block_size = 0;
CHECK(cudaMemcpyFromSymbol(&block_size, BLOCK_DIM, sizeof(real)));
int grid_size = (N + block_size - 1)/block_size;

real *h_x = new real[N];
real *h_y = new real[grid_size];
for (int i = 0; i < N; ++i)
{
h_x[i] = 1.23;
}

cout << FLOAT_PREC << endl;

auto t1 = system_clock::now();

// cpu归约,单精度下计算错误,大数吃小数。
cout << "cpu reduce: " << reduce_cpu(h_x, N) << endl;

auto t2 = system_clock::now();
double time = duration<double, std::milli>(t2 - t1).count();
cout << "cpu reduce time cost: " << time << " ms" << endl;

real *d_x, *d_y;
int size = grid_size*sizeof(real);
CHECK(cudaMalloc(&d_x, M));
CHECK(cudaMalloc(&d_y, size)); // 数据分片后个线程块的归约结果数组。
CHECK(cudaMemcpy(d_x, h_x, M, cudaMemcpyDefault));

cudaEvent_t start, stop;
CHECK(cudaEventCreate(&start));
CHECK(cudaEventCreate(&stop));
CHECK(cudaEventRecord(start));
cudaEventQuery(start);

// gpu归约,单精度下也能控制误差,稳健性更强。
reduce<<<grid_size, block_size>>>(d_x, d_y);
CHECK(cudaMemcpy(h_y, d_y, size, cudaMemcpyDefault));
CHECK(cudaGetLastError());

float elap_time=0, curr_time=0;
CHECK(cudaEventRecord(stop));
CHECK(cudaEventSynchronize(stop));
CHECK(cudaEventElapsedTime(&curr_time, start, stop));
cout << "gpu reduce: " << reduce_cpu(h_y, grid_size) << endl;
printf("gpu reduce time cost: %f ms\n", curr_time - elap_time);
elap_time = curr_time;

// gpu归约,采用静态共享内存的加速。
reduce_shared<<<grid_size, block_size>>>(d_x, d_y, N);
CHECK(cudaMemcpy(h_y, d_y, size, cudaMemcpyDefault));
CHECK(cudaGetLastError());

CHECK(cudaEventRecord(stop));
CHECK(cudaEventSynchronize(stop));
CHECK(cudaEventElapsedTime(&curr_time, start, stop));
cout << "gpu shared reduce: " << reduce_cpu(h_y, grid_size) << endl;
printf("gpu shared reduce time cost: %f ms\n", curr_time - elap_time);
elap_time = curr_time;

// gpu归约,采用动态共享内存的加速。
// <<<grid_size, block_size, sharedMemSize>>>,第三个参数指定动态共享内存大小。
int sharedMemSize = block_size * sizeof(real); // 核函数中每个线程块的动态共享内存大小。
reduce_shared2<<<grid_size, block_size, sharedMemSize>>>(d_x, d_y, N);
CHECK(cudaMemcpy(h_y, d_y, size, cudaMemcpyDefault));
CHECK(cudaGetLastError());

CHECK(cudaEventRecord(stop));
CHECK(cudaEventSynchronize(stop));
CHECK(cudaEventElapsedTime(&curr_time, start, stop));
cout << "gpu shared2 reduce: " << reduce_cpu(h_y, grid_size) << endl;
printf("gpu shared2 reduce time cost: %f ms\n", curr_time - elap_time);
elap_time = curr_time;

delete[] h_x;
delete[] h_y;
CHECK(cudaFree(d_x));
CHECK(cudaFree(d_y));

return 0;
}

矩阵转置

由于共享内存访问速度快于全局内存,所以可以通过线程块内的共享内存将全局内存的非合并访问转为合并访问。

注意转置后的数组索引变换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
#include "../common/error.cuh"
#include "../common/floats.hpp"
#include <iomanip>
#include <string>
#include <fstream>

#define TILE_DIM 32

__constant__ int c_TILE_DIM = 32; // 设备内存中线程块中矩阵维度(线程块大小,最大1024)。

void show(const real *matrix, const int N, std::string outfile, std::string title);
__global__ void transpose1(const real *src, real *dst, const int N);
__global__ void transpose2(const real *src, real *dst, const int N);
__global__ void transpose3(const real *src, real *dst, const int N);
__global__ void transpose4(const real *src, real *dst, const int N);



int main()
{
// 由于显存 2 GB,float 为 4 字节,double 为 8 字节,所以在 transpose3, transpose4中:
// float 矩阵维度不能超过 726;
// double 矩阵维度不能超过 512;
const int N = 500;
const int M = N * N * sizeof(real);

int SIZE = 0;
CHECK(cudaMemcpyFromSymbol(&SIZE, c_TILE_DIM, sizeof(int)));

const int grid_size_x = (N + SIZE - 1)/SIZE; // 获取网格大小。
const int grid_size_y = grid_size_x;

const dim3 block_size(SIZE, SIZE);
const dim3 grid_size(grid_size_x, grid_size_y);

real *h_matrix_org, *h_matrix_res;
h_matrix_org = new real[N*N];
h_matrix_res = new real[N*N];
for (int i = 0; i < N; ++i)
{
for (int j = 0; j < N; ++j)
{
h_matrix_org[i * N + j] = i*1.0e-2;
}
}
// show(h_matrix_org, N, "result.txt", "origin matrix");

real *d_matrix_org, *d_matrix_res;
CHECK(cudaMalloc(&d_matrix_org, M));
CHECK(cudaMalloc(&d_matrix_res, M));
CHECK(cudaMemcpy(d_matrix_org, h_matrix_org, M, cudaMemcpyDefault));

float elapsed_time = 0;
float curr_time = 0;
cudaEvent_t start, stop;
CHECK(cudaEventCreate(&start));
CHECK(cudaEventCreate(&stop));
CHECK(cudaEventRecord(start));
cudaEventQuery(start);

// 矩阵转置(全局内存合并读取、非合并写入)。
transpose1<<<grid_size, block_size>>>(d_matrix_org, d_matrix_res, N);
CHECK(cudaMemcpy(h_matrix_res, d_matrix_res, M, cudaMemcpyDefault));
// show(h_matrix_res, N, "result.txt", "transpose1");

CHECK(cudaEventRecord(stop));
CHECK(cudaEventSynchronize(stop));
CHECK(cudaEventElapsedTime(&curr_time, start, stop));
printf("matrix transpose1 time cost: %f ms.\n", curr_time - elapsed_time);
elapsed_time = curr_time;

// 矩阵转置(全局内存非合并读取、合并写入)。
transpose2<<<grid_size, block_size>>>(d_matrix_org, d_matrix_res, N);
CHECK(cudaMemcpy(h_matrix_res, d_matrix_res, M, cudaMemcpyDefault));
// show(h_matrix_res, N, "matrix.txt", "transpose2");

CHECK(cudaEventRecord(stop));
CHECK(cudaEventSynchronize(stop));
CHECK(cudaEventElapsedTime(&curr_time, start, stop));
printf("matrix transpose2 time cost: %f ms.\n", curr_time - elapsed_time);
elapsed_time = curr_time;

// 矩阵转置(通过共享内存全局内存合并读写)。
transpose3<<<grid_size, block_size>>>(d_matrix_org, d_matrix_res, N);
CHECK(cudaMemcpy(h_matrix_res, d_matrix_res, M, cudaMemcpyDefault));
// show(h_matrix_res, N, "result.txt", "transpose3");

CHECK(cudaEventRecord(stop));
CHECK(cudaEventSynchronize(stop));
CHECK(cudaEventElapsedTime(&curr_time, start, stop));
printf("matrix transpose3 time cost: %f ms.\n", curr_time - elapsed_time);
elapsed_time = curr_time;

// 矩阵转置(通过共享内存、bank处理,实现全局内存合并读写)。
transpose4<<<grid_size, block_size>>>(d_matrix_org, d_matrix_res, N);
CHECK(cudaMemcpy(h_matrix_res, d_matrix_res, M, cudaMemcpyDefault));
// show(h_matrix_res, N, "result.txt", "transpose3");

CHECK(cudaEventRecord(stop));
CHECK(cudaEventSynchronize(stop));
CHECK(cudaEventElapsedTime(&curr_time, start, stop));
printf("matrix transpose4 time cost: %f ms.\n", curr_time - elapsed_time);
elapsed_time = curr_time;

delete[] h_matrix_res;
delete[] h_matrix_org;
CHECK(cudaFree(d_matrix_org));
CHECK(cudaFree(d_matrix_res));

return 0;
}


void show(const real *x, const int N, std::string outfile, std::string title)
{
std::fstream out(outfile, std::ios::app);
if (!out.is_open())
{
std::cerr << "invalid output file: " << outfile << endl;
return;
}

out << "\n\n----------------" << title << endl;

for (int i = 0; i < N; ++i)
{
out << endl;
for (int j = 0; j < N; ++j)
{
out << std::setw(6) << x[i * N + j];
}
}
}

__global__ void transpose1(const real *src, real *dst, const int N)
{
const int nx = threadIdx.x + blockIdx.x * c_TILE_DIM;
const int ny = threadIdx.y + blockIdx.y * c_TILE_DIM;

if (nx < N && ny < N)
{
// 矩阵转置(合并读取、非合并写入)。
dst[nx*N + ny] = src[ny*N + nx];
}
}

__global__ void transpose2(const real *src, real *dst, const int N)
{
const int nx = threadIdx.x + blockIdx.x * c_TILE_DIM;
const int ny = threadIdx.y + blockIdx.y * c_TILE_DIM;

if (nx < N && ny < N)
{
// 矩阵转置(非合并读取、合并写入)。
dst[ny*N + nx] = __ldg(&src[nx*N + ny]); // 显示调用 `__ldg()` 函数缓存全局内存。
}
}

__global__ void transpose3(const real *src, real *dst, const int N)
{
// 正常的做法中,全局内存的读写必有一个是非合并访问。
// 现在通过将非合并访问转移到共享内存,利用共享内存的高性能(100倍全局内存),提高计算速度:
// 1. 首先将全局内存拷贝到线程块的共享内存;
// 2. 然后从共享内存非合并访问,读取数据,合并写入全局内存。

__shared__ real s_mat[TILE_DIM][TILE_DIM]; //二维静态共享内存,存储线程块内的一片矩阵。

int bx = blockIdx.x * blockDim.x; // 当前线程块首线程在网格中列索引。
int by = blockIdx.y * blockDim.y; // 当前线程块首线程在网格中行索引。

int tx = threadIdx.x + bx; // 当前线程在网格中列索引。
int ty = threadIdx.y + by; // 当前线程在网格中行索引。

if (tx < N && ty < N)
{
// 全局内存合并访问,共享内存合并访问。
s_mat[threadIdx.y][threadIdx.x] = src[ty * N + tx]; // 全局内存中二维矩阵一维存储。
}
__syncthreads();

// 全局内存合并访问。
if (tx < N && ty < N)
{
// 局部矩阵转置和全局内存合并写入。
int x = by + threadIdx.x;
int y = bx + threadIdx.y;
dst[y * N + x] = s_mat[threadIdx.x][threadIdx.y];
}
}

__global__ void transpose4(const real *src, real *dst, const int N)
{
// 通过修改数组行大小,错开数组元素在共享内存bank中的分布,
// 避免线程束的 32路bank冲突。
__shared__ real s_mat[TILE_DIM][TILE_DIM + 1];

int bx = blockIdx.x * blockDim.x;
int by = blockIdx.y * blockDim.y;

int tx = threadIdx.x + bx;
int ty = threadIdx.y + by;

if (tx < N && ty < N)
{
s_mat[threadIdx.y][threadIdx.x] = src[ty * N + tx];
}
__syncthreads();

if (tx < N && ty < N)
{
int x = by + threadIdx.x;
int y = bx + threadIdx.y;
dst[y * N + x] = s_mat[threadIdx.x][threadIdx.y];
}
}

共享内存的 bank 冲突

共享内存在物理上被分为32个同样宽度(开普勒架构为 8 字节,其他为 4 字节)、能被同时访问的列向内存bank。

1
2
3
4
5
6
7
8
9
10
++++++++++++++++++++++++++++++++++++++

bank0 bank1 ... bank31

++++++++++++++++++++++++++++++++++++++

layer1 layer1 ... layer1
layer2 layer2 ... layer2
...
layer32 layer32 ... layer32

只要同一个线程束内的多个线程不同时访问同一个 bank 中不同层的数据,该线程束对共享内存的访问就只需要 一次内存事务。当同一个线程束内的多个线程试图访问同一个 bank 中不同层的数据时,就会发生冲突。 在同一线程束中的多个线程对同一个 bank 中的 n 层数据访问将导致 n 次内存事务, 称为发生了 n 路 bank 冲突

当线程束内的32个线程同时访问同一个 bank 的32个不同层,这将导致 32 路 bank 冲突。对于非开普勒架构, 每个共享内存的宽带为 4 字节;于是每一层的32个 bank 将对应 32 个 float 数组元素。

使用共享内存来改善全局内存的访问方式不一定会提高核函数的性能;不要过早优化,在优化程序时要对不同的优化方案进行测试和比较。

原子函数的合理使用

cuda 中,一个线程的原子操作可以在不受其他线程的任何操作的影响下完成对某个(全局内存或共享内存)
数据的一套“读-改-写”操作。


完全在 GPU 中进行归约

有两种方法能够在GPU中得到最终结果:

  1. 用另一个核函数将较短的数组进一步归约;
  2. 在核函数末尾利用原子函数进行归约。

在代码实现中:

  1. 原子函数 atomicAdd(·)执行数组的一次完整的读-写操作;
  2. 传给 cudaMemcpy(·) 的主机内存可以是栈内存,也可以是堆内存;
  3. 主机函数可以和设备函数同名,但要遵循重载原则(参数列表不一致)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
#include "../common/error.cuh"
#include "../common/floats.hpp"
#include "../common/clock.cuh"


__global__ void reduce(real *x, real *y, const int N)
{
int tid = threadIdx.x;
int ind = tid + blockIdx.x * blockDim.x;

extern __shared__ real curr_x[];
curr_x[tid] = (ind < N) ? x[ind] : 0.0;

for (int offset = blockDim.x/2 ; offset > 0 ; offset /= 2)
{
if (tid < offset)
{
curr_x[tid] += curr_x[tid + offset];
}
__syncthreads();
}

if (tid == 0)
{
y[blockIdx.x] = curr_x[0];
}
}

__global__ void reduce2(real *x, real *y, const int N)
{
int tid = threadIdx.x;
int ind = tid + blockIdx.x * blockDim.x;

extern __shared__ real curr_x[];
curr_x[tid] = (ind < N) ? x[ind] : 0.0;

for (int offset = blockDim.x/2 ; offset > 0 ; offset /= 2)
{
if (tid < offset)
{
curr_x[tid] += curr_x[tid + offset];
}
__syncthreads();
}

if (tid == 0)
{
// 原子函数 atomicAdd(*address, val).
atomicAdd(y, curr_x[0]);
}
}



int main()
{
int N = 1e8;
int M = N * sizeof(real);

int bSize = 32;
int gSize = (N + bSize - 1)/bSize;

cout << FLOAT_PREC << endl;

real *h_x, *h_y;
h_x = new real[N];
h_y = new real[gSize];
for (int i = 0; i < N; ++i)
{
h_x[i] = 1.23;
}

cudaClockStart

real *d_x, *d_y;
CHECK(cudaMalloc(&d_x, M));
CHECK(cudaMalloc(&d_y, gSize*sizeof(real)));
CHECK(cudaMemcpy(d_x, h_x, M, cudaMemcpyDefault));

cudaClockCurr

reduce<<<gSize, bSize, (bSize+1)*sizeof(real)>>>(d_x, d_y, N);

CHECK(cudaMemcpy(h_y, d_y, gSize*sizeof(real), cudaMemcpyDefault));
real res = 0;
for(int i = 0; i < gSize; ++i)
{
res += h_y[i];
}
cout << "reduce result: " << res << endl;

cudaClockCurr

reduce<<<gSize, bSize, (bSize)*sizeof(real)>>>(d_x, d_y, N);

CHECK(cudaMemcpy(h_y, d_y, gSize*sizeof(real), cudaMemcpyDefault));
res = 0.0;
for(int i = 0; i < gSize; ++i)
{
res += h_y[i];
}
cout << "reduce result: " << res << endl;

cudaClockCurr

real *d_y2, *h_y2;
h_y2 = new real(0.0);
CHECK(cudaMalloc(&d_y2, sizeof(real)));

// 采用原子函数、共享内存的核函数归约,
// 由于减少了主机和设备间的数据传输,效率得以提高。
reduce2<<<gSize, bSize, (bSize)*sizeof(real)>>>(d_x, d_y2, N);

CHECK(cudaMemcpy(h_y2, d_y2, sizeof(real), cudaMemcpyDefault));
cout << "reduce2 result: " << *h_y2 << endl;

cudaClockCurr

delete[] h_x;
delete[] h_y;
delete h_y2;
CHECK(cudaFree(d_x));
CHECK(cudaFree(d_y));
CHECK(cudaFree(d_y2));

return 0;
}

原子函数

原子函数对其第一个参数指向的数据进行一次“读-写-改”的原子操作,是不可分割的操作。 第一个参数可以指向全局内存,也可以指向共享内存。

对所有参与的线程来说,原子操作是一个线程一个线程轮流进行的,没有明确的次序。 原子函数没有同步功能。

原子函数的返回值为所指地址的旧值。

  • 加法: T atomicAdd(T *address, T val)
  • 减法: T atomicSub(T *address, T val)
  • 交换: T atomicExch(T *address, T val);
  • 最小值: T atomicMin(T *address, T val)
  • 最大值: T atomicMax(T *address, T val)
  • 自增: T atomicInc(T *address, T val)
  • 自减: T atomicDec(T *address, T val)
  • 比较交换: T atomicCAS(T *address, T compare, T val)

邻居列表

两个粒子互为邻居的判断:他们的距离不大于一个给定的截断距离 rc。
基本算法: 对每一个给定的粒子,通过比较它与所有其他粒子的距离来判断相应粒子对是否互为邻居。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
#include "../common/error.cuh"
#include "../common/floats.hpp"
#include "../common/clock.cuh"
#include <fstream>
#include <regex>
#include <string>
#include <vector>


void read_data(const std::string &fstr, std::vector<real> &x, std::vector<real> &y);
void write_data(const std::string &fstr, const int *NL, const int N, const int M);
void find_neighbor(int *NN, int *NL, const real *x, const real *y,
const int N, const int M,
const real minDis);
__global__ void find_neighbor_gpu (int *NN, int *NL, const real *x, const real *y,
const int N, const int M,
const real mindDis);
__global__ void find_neighbor_atomic(int *NN, int *NL, const real *x, const real *y,
const int N, const int M,
const real minDis);


int main()
{
cout << FLOAT_PREC << endl;

std::string fstr = "xy.txt";
std::string fout = "result.txt";
std::vector<real> x, y;
read_data(fstr, x, y);

int N = x.size(), M = 10;
real minDis = 1.9*1.9;

int *NN = new int[N];
int *NL = new int[N*M];
for (int i = 0; i < N; ++i)
{
NN[i] = 0;
for (int j = 0; j < M; ++j)
{
NL[i*M + j] = -1;
}
}

int *d_NN, *d_NL;
CHECK(cudaMalloc(&d_NN, N*sizeof(int)));
CHECK(cudaMalloc(&d_NL, N*M*sizeof(int)));
real *d_x, *d_y;
CHECK(cudaMalloc(&d_x, N*sizeof(real)));
CHECK(cudaMalloc(&d_y, N*sizeof(real)));

cppClockStart

find_neighbor(NN, NL, x.data(), y.data(), N, M, minDis);
// write_data(fout, NL, N, M);
cppClockCurr

cudaClockStart

CHECK(cudaMemcpy(d_x, x.data(), N*sizeof(real), cudaMemcpyDefault));
CHECK(cudaMemcpy(d_y, y.data(), N*sizeof(real), cudaMemcpyDefault));

int block_size = 128;
int grid_size = (N + block_size - 1)/block_size;
find_neighbor_atomic<<<grid_size, block_size>>>(d_NN, d_NL, d_x, d_y, N, M, minDis);

CHECK(cudaMemcpy(NN, d_NN, N*sizeof(int), cudaMemcpyDefault));
CHECK(cudaMemcpy(NL, d_NL, N*M*sizeof(int), cudaMemcpyDefault));
// write_data(fout, NL, N, M);

cudaClockCurr

CHECK(cudaMemcpy(d_x, x.data(), N*sizeof(real), cudaMemcpyDefault));
CHECK(cudaMemcpy(d_y, y.data(), N*sizeof(real), cudaMemcpyDefault));
find_neighbor_gpu<<<grid_size, block_size>>>(d_NN, d_NL, d_x, d_y, N, M, minDis);
CHECK(cudaMemcpy(NN, d_NN, N*sizeof(int), cudaMemcpyDefault));
CHECK(cudaMemcpy(NL, d_NL, N*M*sizeof(int), cudaMemcpyDefault));

cudaClockCurr

write_data(fout, NL, N, M);

delete[] NN;
delete[] NL;
CHECK(cudaFree(d_NN));
CHECK(cudaFree(d_NL));
CHECK(cudaFree(d_x));
CHECK(cudaFree(d_y));

return 0;
}


void find_neighbor(int *NN, int *NL, const real *x, const real *y,
const int N, const int M,
const real minDis)
{
for (int i = 0; i < N; ++i)
{
NN[i] = 0;
}

for (int i = 0; i < N; ++i)
{
for (int j = i + 1; j < N; ++j)
{
real dx = x[j] - x[i];
real dy = y[j] - y[i];
real dis = dx * dx + dy * dy;
if (dis < minDis) // 比较平方,减少计算量。
{
NL[i*M + NN[i]] = j; // 一维数组存放二维数据。
NN[i] ++;
NL[j*M + NN[j]] = i; // 省去一般的判断。
NN[j]++;
}
}
}
}

__global__ void find_neighbor_gpu (int *NN, int *NL, const real *x, const real *y,
const int N, const int M,
const real minDis)
{
int i = blockIdx.x * blockDim.x + threadIdx.x;

if (i < N)
{
int count = 0; // 寄存器变量,减少对全局变量NN的访问。
for (int j = 0; j < N; ++j) // 访问次数 N*N,性能降低。
{
real dx = x[j] - x[i];
real dy = y[j] - y[i];
real dis = dx * dx + dy * dy;

if (dis < minDis && i != j) // 距离判断优先,提高“假”的命中率。
{
// 修改了全局内存NL的数据排列方式,实现合并访问(i 与 threadIdx.x的变化步调一致)。
// ???
NL[(count++) * N + i] = j;
}
}

NN[i] = count;
}
}

__global__ void find_neighbor_atomic(int *NN, int *NL, const real *x, const real *y,
const int N, const int M,
const real minDis)
{
// 将 cpu 版本的第一层循环展开,一个线程对应一个原子操作。
int i = blockIdx.x * blockDim.x + threadIdx.x;

if (i < N)
{
NN[i] = 0;

for (int j = i + 1; j < N; ++j)
{
real dx = x[j] - x[i];
real dy = y[j] - y[i];
real dis = dx * dx + dy*dy;
if (dis < minDis)
{
// 原子函数提高的性能,但是在NL中产生了一定的随机性,不便于后期调试。
int old_i_num = atomicAdd(&NN[i], 1); // 返回值为旧值,当前线程对应点的邻居数
NL[i*M + old_i_num] = j; // 当前线程对应点的新邻居
int old_j_num = atomicAdd(&NN[j], 1); // 返回值为旧值,当前邻居点的邻居数
NL[j*M + old_j_num] = i; // 当前邻居点的新邻居
}
}
}
}

void read_data(const std::string &fstr, std::vector<real> &x, std::vector<real> &y)
{
x.clear();
y.clear();

std::fstream reader(fstr, std::ios::in);
if (!reader.is_open())
{
std::cerr << "data file open failed.\n";
return;
}

std::regex re{"[\\s,]+"};
std::string line;
while(std::getline(reader, line))
{
std::vector<std::string> arr{std::sregex_token_iterator(line.begin(), line.end(), re, -1),
std::sregex_token_iterator()};

if (arr.size() < 2 || arr[0].find("#") != std::string::npos)
{
continue;
}

x.push_back(stod(arr[0]));
y.push_back(stod(arr[1]));
}
}

void write_data(const std::string &fstr, const int *NL, const int N, const int M)
{
std::fstream writer(fstr, std::ios::out);
if (!writer.is_open())
{
std::cerr << "result file open failed.\n";
return;
}

for (int i = 0; i < N; ++i)
{
writer << i << "\t";
for (int j = 0; j < M; ++j)
{
int ind = NL[i*M + j];
if (ind >= 0)
{
writer << ind << "\t";
}
}

writer << endl;
}
}

线程束基本函数与协作组

线程束(warp),即一个线程块中连续32个线程。


单指令-多线程模式

一个GPU被分为若干个流多处理器(SM)。核函数中定义的线程块(block)在执行时将被分配到还没有完全占满的 SM。 一个block不会被分配到不同的SM,同时一个 SM 中可以有多个 block。不同的block 之间可以并发也可以顺序执行,一般不能同步。当某些block完成计算任务后,对应的 SM 会部分或完全空闲,然后会有新的block被分配到空闲的SM。

一个 SM 以32个线程(warp)为单位产生、管理、调度、执行线程。
一个 SM 可以处理多个block,一个block可以分为若干个warp。

在同一时刻,一个warp中的线程只能执行一个共同的指令或者闲置,即单指令-多线程执行模型, (single instruction multiple thread, SIMT)。

当一个线程束中线程顺序的执行判断语句中的不同分支时,称为发生了 分支发散(branch divergence)。

if (condition)
{
    A;
}
else
{
    B;
}

首先,满足 condition 的线程或执行语句A,其他的线程会闲置;然后,不满足条件的将会执行语句B,其他线程闲置。 当语句A和B的指令数差不多时,整个warp的执行效率就比没有分支的情况 低一半

一般应当在核函数中尽量避免分支发散,但有时这也是不可避免的。如数组计算中常用的判断语句:

if(n < N)
{
    // do something.
}

该分支判断最多影响最后一个block中的某些warp发生分支发散, 一般不会显著地影响性能。

有时能通过 合并判断语句 的方式减少分支发散;另外,如果两分支中有一个分支 不包含指令,则即使发生分支发散也不会显著影响性能。

注意不同架构中的线程调度机制


线程束内的线程同步函数

__syncwarp(·):当所涉及的线程都在一个线程束内时,可以将线程块同步函数 __syncthreads()
换成一个更加廉价的线程束同步函数__syncwarp(·),简称 束内同步函数

函数参数是一个代表掩码的无符号整型数,默认值是全部32个二进制位都为1,代表
线程束中的所有线程都参与同步。

关于 掩码(mask) 的简介文章:思否小姐姐:“奇怪的知识——位掩码”


更多线程束内的基本函数

线程束表决函数

  • unsigned __ballot_sync(unsigned mask, int predicate),如果线程束内第n个线程参与计算(旧掩码)且predicate值非零,则返回的无符号整型数(新掩码) 的第n个二进制位为1,否则为0。

  • int __all_sync(unsigned mask, int predicate), 线程束内所有参与线程的predicate值均非零,则返回1,否则返回0.

  • int __any_sync(unsigned mask, int predicate), 线程束内所有参与线程的predicate值存在非零,则返回1, 否则返回0.

线程束洗牌函数

  • T __shfl_sync(unsigned mask, T v, int srcLane, int w = warpSize), 参与线程返回标号为 srcLane 的线程中变量 v 的值。 该函数将一个线程中的数据广播到所有线程。

  • T __shfl_up_sync(unsigned mask, T v, unsigned d, int w=warpSize), 标号为t的参与线程返回标号为 t-d 的线程中变量v的值,t-d<0的线程返回t线程的变量v。 该函数是一种将数据向上平移的操作,即将低线程号的值平移到高线程号。 例如当w=8、d=2时,2-7号线程将返回 0-5号线程中变量v的值;0-1号线程返回自己的 v。

  • T __shfl_down_sync(unsigned mask, T v, unsigned d, int w=warpSize), 标号为t的参与线程返回标号为 t+d 的线程中变量v的值,t+d>w的线程返回t线程的变量v。 该函数是一种将数据向下平移的操作,即将高线程号的值平移到低线程号。 例如当w=8、d=2时,0-5号线程将返回2-7号线程中变量v的值,6-7号线程将返回自己的 v。

  • T __shfl__xor_sync(unsigned mask, T v, int laneMask, int w=warpSize), 标号为t的参与线程返回标号为 t^laneMask 的线程中变量 v 的值。 该函数让线程束内的线程两两交换数据。

每个线程束洗牌函数都有一个可选参数 w,默认是线程束大小(32),且只能取2、4,8、16、32。 当 w 小于 32 时,相当于逻辑上的线程束大小是 w,其他规则不变。 此时,可以定义一个 束内索引:(假设使用一维线程块)

int laneId = threadIdx.x % w;  // 线程索引与束内索引的对应关系。

假设线程块大小为16,w 为 8:

线程索引: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 
束内索引: 0 1 2 3 4 5 6 7 0 1 2  3  4  5  6  7

参数中的 mask 称为掩码,是一个无符号整型,具有32位,一般用 十六进制 表示:

const unsigned FULL_MASK = 0xffffffff; // `0x`表示十六进制数;`0b`表示二进制数。

或者

#define FULL_MASK 0xffffffff

以上所有线程束内函数都有 _sync 后缀,表示这些函数都具有 隐式的同步功能


协作组

协作组(cooperative groups),可以看作是线程块和线程束同步机制的推广,
提供包括线程块内部的同步与协作、线程块之间(网格级)的同步与协作、以及
设备与设备之间的同步与协作。

使用协作组需要包含如下头文件:

#include <cooperative_groups.h>
using namespace cooperative_groups;

线程块级别的协作组

协作组编程模型中最基本的类型是线程组 thread_group,其包含如下成员:

  • void sync(),同步组内所有线程;
  • unsigned size(),返回组内总的线程数目,即组的大小;
  • unsigned thread_rank(),返回当前调用该函数的线程在组内的标号(从0计数);
  • bool is_valid(),如果定义的组违反了任何cuda限制,返回 false,否则true;

线程组类型有一个导出类型,线程块thread_block,其中定义了额外的函数:

  • dim3 group_index(),返回当前调用该函数的线程的线程块指标,等价于 blockIdx
  • dim3 thread_index(),返回当前调用该函数的线程的线程指标,等价于 threadIdx

通过 this_thread_block() 初始化一个线程块对象:

thread_block g = this_thread_block();  // 相当于一个线程块类型的常量。

此时,

g.sync() <===> __syncthreads()
g.group_index() <===> blockIdx
g.thread_index() <===> threadIdx

通过 tiled_partition() 可以将一个线程块划分为若干片(tile),每一片构成一个新的线程组。目前,仅支持将片的大小设置为 2 的整数次方且不大于 32。

thread_group g32 = tiled_partition(this_thread_block(), 32); // 将线程块划分为线程束。

可以继续将线程组划分为更细的线程组:

thread_group g4 = tiled_partition(g32, 4);

采用模板、在编译期划分 线程块片(thread block tile)

thread_block_tile<32> g32 = tiled_partition<32>(this_thread_block());
thread_block_tile<32> g4 = tiled_partition<4>(this_thread_block());

线程块片具有额外的函数(类似线程束内函数):

  • unsigned ballot(int predicate);
  • int all(int predicate);
  • int any(int predicate);
  • T shfl(T v, int srcLane);
  • T shfl_up(T v, unsigned d);
  • T shfl_down(T v, unsigned d);
  • T shfl_xor(T v, unsigned d);

与一般的线程束不同,线程组内的所有线程都要参与代码运行计算;同时,线程组内函数不需要指定宽度,因为该宽度就是线程块片的大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
#include "../common/error.cuh"

const unsigned WIDTH = 8;
const unsigned BLOCK_SIZE = 16;
const unsigned FULL_MASK = 0xffffffff;

__global__ void test_warp_primitives(void)
{
int tid = threadIdx.x;
int laneId = tid % WIDTH;

if (tid == 0)
{
printf("threadIdx.x: ");
}
printf("%2d ", tid);
if (tid == 0)
{
printf("\n");
}

if (tid == 0)
{
printf("laneId: ");
}
printf("%2d ", laneId);
if (tid == 0)
{
printf("\n");
}

// 从 FULL_MASK 出发, 计算 mask1(排除 0 号线程)掩码和mask2(仅保留 0 线程)掩码。
unsigned mask1 = __ballot_sync(FULL_MASK, tid>0);
unsigned mask2 = __ballot_sync(FULL_MASK, tid==0);

if (tid == 0)
{
printf("FULL_MASK = %x\n", FULL_MASK);
}
if (tid == 1)
{
printf("mask1 = %x\n", mask1);
}
if (tid == 0)
{
printf("mask2 = %x\n", mask2);
}

// 从 FULL_MASK 出发计算线程束状态。
// 因为不是所有线程的tid 都大于0,所以此处返回 0.
int result = __all_sync(FULL_MASK, tid);
if (tid == 0)
{
printf("all_sync (FULL_MASK): %d\n", result);
}
// 从 mask1 出发计算线程束状态。
// 因为mask1 中关闭了0号线程,所以剩下的所有线程tid > 0,此处返回 1.
result = __all_sync(mask1, tid);
if (tid == 1)
{
printf("all_sync (mask1): %d\n", result);
}

// 从 FULL_MASK 出发计算线程束状态。
// 因为存在线程的tid 都大于0,所以此处返回 1.
result = __any_sync(FULL_MASK, tid);
if (tid == 0)
{
printf("any_sync (FULL_MASK): %d\n", result);
}
// 从 mask2 出发计算线程束状态。
// 因为mask2 中仅激活了 0 号线程,所以此处返回 0.
result = __any_sync(mask2, tid);
if (tid == 0)
{
printf("any_sync (mask2): %d\n", result);
}

// 从 FULL_MASK 出发,将每个线程束中 2号线程的tid广播到线程束内所有函数并作为返回值。
// 所以在第一个线程束中,所有8个线程都将返回 laneId=2 线程(2 号线程)的tid值;
// 在第二个线程束中,所有8个线程都也将返回 landId=2 线程(10 号线程)的tid值。
int value = __shfl_sync(FULL_MASK, tid, 2, WIDTH);
if (tid == 0)
{
printf("shfl: ");
}
printf("%2d ", value);
if (tid == 0)
{
printf("\n");
}

// 从FULL_MASK出发,将每个线程束内 1-7 号线程取 0-6号线程的tid值并作为返回值。
// 所以在第一个线程束中,0号线程返回自己的tid,1号线程返回0号线程的tid,2号线程返回1号线程tid, ...
value = __shfl_up_sync(FULL_MASK, tid, 1, WIDTH);
if (tid == 0)
{
printf("shfl_up: ");
}
printf("%2d ", value);
if (tid == 0)
{
printf("\n");
}

// 从 FULL_MASK 出发,将每个线程束内 0-6号线程取 1-7 号线程的tid值并作为返回值。
// 所以在第一个线程束中,0号线程返回1号线程的tid,2号线程返回3号线程的tid,..., 7号线程返回自己的tid。
value = __shfl_down_sync(FULL_MASK, tid, 1, WIDTH);
if (tid == 0)
{
printf("shfl_down: ");
}
printf("%2d ", value);
if (tid == 0)
{
printf("\n");
}

// 从 FULL_MASK 出发,将线程束中相邻的线程的tid相互传递并作为返回值。
// 所以在第一个线程束中,0号线程返回1号线程的tid、1号线程返回0号线程的tid,2号线程返回3号线程的tid、
// 3号线程返回2号线程的tid,...
value = __shfl_xor_sync(FULL_MASK, tid, 1, WIDTH);
if (tid == 0)
{
printf("shfl_xor: ");
}
printf("%2d ", value);
if (tid == 0)
{
printf("\n");
}
}


int main()
{
test_warp_primitives<<<1, BLOCK_SIZE>>>();
CHECK(cudaDeviceSynchronize());

return 0;
}

数组归约程序的进一步优化

提高线程利用率

在当前的归约程序中,当 offset=64,只用了 1/2 的线程;当 offset=32,只用了 1/4 的线程;…
最终,当 offset=1,只用了 1/128 的线程; 归约过程一共用了 log2(128) = 7 步,平均线程利用率 (1/2 + 1/4 + … + 1/128)/7 => 1/7。

而在归约前的数据拷贝中线程利用率为 100%,可以尽量把计算放在在归约前:让一个线程处理多个数据。

一个线程处理相邻若干个数据会导致全局内存的非合并访问。要保证全局内存的合并访问,这里需要 保证相邻线程处理相邻数据,一个线程访问的数据需要有某种跨度。 该跨度可以是线程块的大小,也可以是网格的大小;对于一维情况,分别是 blockDim.x 和 blockDim.x * gridDim.x。

避免反复分配与释放设备内存

设备内存的分配与释放是比较耗时的。 通过采用静态全局内存替代动态全局内存,实现编译期的设备内存分配可以更加高效。

此外,应当尽量避免在较内存循环反复的分配和释放设备内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
#include "../common/error.cuh"
#include "../common/floats.hpp"
#include "../common/clock.cuh"
#include <cooperative_groups.h>
using namespace cooperative_groups;

__constant__ unsigned FULL_MASK = 0xffffffff;
#define __gSize 10240
__device__ real static_y[__gSize];

__global__ void reduce_syncthreads(real *x, real *y, const int N);
__global__ void reduce_syncwarp(real *x, real *y, const int N);
__global__ void reduce_shfl_down(real *x, real *y, const int N);
__global__ void reduce_cp(real *x, real *y, const int N);
__global__ void reduce_cp_grid(const real *x, real *y, const int N);
real reduce_wrap(const real *x, const int N, const int gSize, const int bSize);
real reduce_wrap_static(const real *x, const int N, const int gSize, const int bSize);



int main()
{
int N = 1e8;
int M = N * sizeof(real);

int bSize = 32;
int gSize = (N + bSize - 1)/bSize;

cout << FLOAT_PREC << endl;

real *h_x, *h_x2, *h_y, *h_y2, *h_res;
h_x = new real[N];
h_x2 = new real[N];
h_y = new real[gSize];
h_y2 = new real[gSize];
h_res = new real(0.0);
for (int i = 0; i < N; ++i)
{
h_x[i] = 1.23;
h_x2[i] = 1.23;
}
real initRes = 0.0;
for (int i = 0; i < gSize ; ++i)
{
h_y2[i] = 0.0;
}

cudaClockStart

real *d_x, *d_y, *d_res;
CHECK(cudaMalloc(&d_x, M));
CHECK(cudaMalloc(&d_y, gSize*sizeof(real)));
CHECK(cudaMalloc(&d_res, sizeof(real)));
CHECK(cudaMemcpy(d_x, h_x, M, cudaMemcpyDefault));

cudaClockCurr

reduce_syncthreads<<<gSize, bSize, (bSize)*sizeof(real)>>>(d_x, d_y, N);

CHECK(cudaMemcpy(h_y, d_y, gSize*sizeof(real), cudaMemcpyDefault));
real res = 0;
for(int i = 0; i < gSize; ++i)
{
res += h_y[i];
}
cout << "reduce_syncthreads result: " << res << endl;
cudaClockCurr

CHECK(cudaMemcpy(d_res, &initRes, sizeof(real), cudaMemcpyDefault));
reduce_syncwarp<<<gSize, bSize, bSize*sizeof(real)>>>(d_x, d_res, N);
CHECK(cudaMemcpy(h_res, d_res, sizeof(real), cudaMemcpyDefault));
cout << "reduce_syncwrap result: " << *h_res << endl;
cudaClockCurr

CHECK(cudaMemcpy(d_res, &initRes, sizeof(real), cudaMemcpyDefault));
reduce_shfl_down<<<gSize, bSize, bSize*sizeof(real)>>>(d_x, d_res, N);
CHECK(cudaMemcpy(h_res, d_res, sizeof(real), cudaMemcpyDefault));
cout << "reduce_shfl_down result: " << *h_res << endl;
cudaClockCurr

CHECK(cudaMemcpy(d_res, &initRes, sizeof(real), cudaMemcpyDefault));
reduce_cp<<<gSize, bSize, bSize*sizeof(real)>>>(d_x, d_res, N);
CHECK(cudaMemcpy(h_res, d_res, sizeof(real), cudaMemcpyDefault));
cout << "reduce_cp result: " << *h_res << endl;
cudaClockCurr

reduce_cp_grid<<<gSize, bSize, bSize*sizeof(real)>>>(d_x, d_y, N);
CHECK(cudaMemcpy(h_y, d_y, gSize*sizeof(real), cudaMemcpyDefault));
res = 0.0;
for(int i = 0; i < gSize; ++i)
{
res += h_y[i];
}
cout << "reduce_cp_grid result: " << res << endl;
cudaClockCurr

res = reduce_wrap(d_x, N, 10240, 128);
cout << "reduce_wrap result: " << res << endl;
cudaClockCurr

res = reduce_wrap_static(d_x, N, 10240, 128);
cout << "reduce_wrap_static result: " << res << endl;
cudaClockCurr

delete[] h_x;
delete[] h_y;
delete h_res;
CHECK(cudaFree(d_x));
CHECK(cudaFree(d_y));
CHECK(cudaFree(d_res));

return 0;
}

__global__ void reduce_syncthreads(real *x, real *y, const int N)
{
int tid = threadIdx.x; // 线程块中线程在x方向的id。
int ind = tid + blockIdx.x * blockDim.x; // 一维线程块中线程在GPU中的id。

extern __shared__ real block_x[]; // 线程块共享内存。
block_x[tid] = (ind < N)? x[ind] : 0;
__syncthreads(); // 同步共享内存的拷贝操作,确保共享内存的数据已准备好。

for(int offset = blockDim.x/2; offset > 0; offset /= 2)
{
if (tid < offset)
{
block_x[tid] += block_x[tid + offset];
}
__syncthreads(); // 同步线程块内线程。

}

if (tid == 0)
{
y[blockIdx.x] = block_x[0];
}

}

__global__ void reduce_syncwarp(real *x, real *y, const int N)
{
const int tid = threadIdx.x;
const int bid = blockIdx.x;
const int ind = bid * blockDim.x + tid;

extern __shared__ real block_arr[];
block_arr[tid] = (ind < N) ? x[ind] : 0.0;
__syncthreads();

// 线程束之间的二分求和。
for (int offset = blockDim.x/2; offset >= 32; offset /=2)
{
if (tid < offset)
{
block_arr[tid] += block_arr[tid + offset];
}
__syncthreads(); // 同步线程块内的线程。
}

// 线程束内的二分求和。
for (int offset = 16; offset > 0; offset /=2)
{
if (tid < offset)
{
block_arr[tid] += block_arr[tid + offset];
}
__syncwarp(); // 同步线程束内的线程。
}

if (tid == 0)
{
atomicAdd(y, block_arr[0]); // 原子函数求和。
}
}

__global__ void reduce_shfl_down(real *x, real *y, const int N)
{
const int tid = threadIdx.x;
const int bid = blockIdx.x;
const int ind = bid * blockDim.x + tid;

extern __shared__ real block_arr[];
block_arr[tid] = (ind < N) ? x[ind] : 0.0;
__syncthreads();

for (int offset = blockDim.x /2 ; offset >= 32; offset /= 2)
{
if (tid < offset)
{
block_arr[tid] += block_arr[tid + offset];
}

__syncthreads();
}

// 在线程寄存器上定义一个变量y。
real curr_y = block_arr[tid];

for (int offset = 16; offset > 0; offset /= 2)
{
// 通过线程束洗牌函数,从FULL_MASK出发,
// 将高线程号(数组索引)中的curr_y值平移到低线程号,通过设置偏移值为 offset,等价实现了线程束内的折半归约。
curr_y += __shfl_down_sync(FULL_MASK, curr_y, offset);
}

if (tid == 0)
{
atomicAdd(y, curr_y);
}
}

__global__ void reduce_cp(real *x, real *y, const int N)
{
const int tid = threadIdx.x;
const int bid = blockIdx.x;
const int ind = bid * blockDim.x + tid;

extern __shared__ real block_arr[];
block_arr[tid] = (ind < N) ? x[ind] : 0.0;
__syncthreads();

for (int offset = blockDim.x /2 ; offset >= 32; offset /= 2)
{
if (tid < offset)
{
block_arr[tid] += block_arr[tid + offset];
}

__syncthreads();
}

real curr_y = block_arr[tid];

// 创建线程块片。
thread_block_tile<32> g32 = tiled_partition<32>(this_thread_block());

for (int offset = 16; offset > 0; offset /= 2)
{
// 线程块片的等价线程束内函数。
curr_y += g32.shfl_down(curr_y, offset);
}

if (tid == 0)
{
atomicAdd(y, curr_y);
}
}

__global__ void reduce_cp_grid(const real *x, real *y, const int N)
{
const int tid = threadIdx.x;
const int bid = blockIdx.x;
extern __shared__ real block_arr[];

real curr_y = 0.0;

// 在归约前处理计算。
// ???
const int stride = blockDim.x * gridDim.x;
for (int n = bid * blockDim.x + tid; n < N; n += stride)
{
curr_y += x[n];
}

block_arr[tid] = curr_y;
__syncthreads();

for (int offset = blockDim.x /2 ; offset >= 32; offset /= 2)
{
if (tid < offset)
{
block_arr[tid] += block_arr[tid + offset];
}

__syncthreads();
}

curr_y = block_arr[tid];
thread_block_tile<32> g32 = tiled_partition<32>(this_thread_block());
for (int offset = 16; offset > 0; offset /= 2)
{
// 线程块片的等价线程束内函数。
curr_y += g32.shfl_down(curr_y, offset);
}

if (tid == 0)
{
y[bid] = curr_y;
}
}


real reduce_wrap(const real *x, const int N, const int gSize, const int bSize)
{
const int ymem = gSize * sizeof(real);
const int smem = bSize * sizeof(real);

real h_y[1] = {0};
real *d_y;
CHECK(cudaMalloc(&d_y, ymem));

// 使用两个核函数时,将数组 d_y 归约到最终结果的计算也是折半归约,
// 这比直接累加(使用原子函数或复制到主机再累加)要稳健(单精度下精度更高)。
// 设备全局内存变量 d_x, d_y 对于每个线程块都是可见的,对于两个核函数是相同的。
reduce_cp_grid<<<gSize, bSize, smem>>>(x, d_y, N);
reduce_cp_grid<<<1, 1024, 1024*sizeof(real)>>>(d_y, d_y, gSize);

CHECK(cudaMemcpy(h_y, d_y, sizeof(real), cudaMemcpyDefault));
CHECK(cudaFree(d_y));

return h_y[0];
}

real reduce_wrap_static(const real *x, const int N, const int gSize, const int bSize)
{
real *d_y;
CHECK(cudaGetSymbolAddress((void**)&d_y, static_y)); // 获取设备静态全局内存或常量内存的地址(指针)。

reduce_cp_grid<<<gSize, bSize, bSize * sizeof(real)>>>(x, d_y, N);
reduce_cp_grid<<<1, 1024, 1024*sizeof(real)>>>(d_y, d_y, gSize);

real h_y[1] = {0};
CHECK(cudaMemcpy(h_y, d_y, sizeof(real), cudaMemcpyDefault));
// CHECK(cudaFree(d_y)); // 全局内存由系统否则释放。

return h_y[0];
}

CUDA 流

一个 CUDA 流一般是指由主机发出的、在设备中执行的cuda操作序列(即和cuda有关的操作, 如主机–设备数据传输和核函数执行)。目前不考虑由设备段发出的流。

任何cuda操作都存在于某个cuda流,要么是 默认流(default stream),也称为 空流; 要么是明确指定的流。非默认的cuda流(非空流)都是在主机端产生与销毁。
一个cuda流由类型为 cudaStream_t 的变量表示,创建与销毁的方式:

1
2
3
4
cudaSteam_t stream;
CHECK(cudaStreamCreate(&stream));
...
CHECK(cudaStreamDestroy(stream));

主机中可以产生多个相互独立的cuda流,并实现cuda流之间的并行。

为了检查一个cuda流中所有操作是否都已在设备中执行完毕:

1
2
cudaError_t cudaStreamSynchronize(cudaStream_t stream);
cudaError_t cudaStreamQuery(cudaStream_t stream);

cudaStreamSynchronize 会强制阻塞主机,直到其中的stream流执行完毕; cudaStreamQuery 不会阻塞主机,只是检查cuda流(stream)是否执行完毕,若是,则返回 cudaSuccess; 否则,返回 cudaErrorNotReady


在默认流中重叠主机和设备计算

同一个cuda流在设备中都是顺序执行的。 在数组相加的例子中:

1
2
3
4
cudaMemcpy(d_x, h_x, M, cudaMemcpyDefault);
cudaMemcpy(d_y, h_y, M, cudaMemcpyDefault);
add<<<gridSize, blockSize>>>(d_x, d_y, d_z, N);
cudaMemcpy(h_z, d_z, M, cudaMemcpyDefault);

从设备的角度,以上4个cuda语句是按代码顺序执行的。

采用 cudaMemcpy 函数在主机与设备间拷贝数据,是具有隐式同步功能的。
所以从主机的角度看,数据传输是同步的或者说阻塞的,即主机在发出命令:

1
cudaMemcpy(d_x, h_x, M, cudaMemcpyDefault);

之后,会等待该命令执行完完毕,再接着往下走;数据传输时,主机是闲置的。
与此不同的是,核函数的启动是异步的或者说非阻塞的,即在主机发出命令:

1
add<<<gridSize, blockSize>>>(d_x, d_y, d_z, N);

之后,不会等待该命令执行完毕,而是立刻得到程序的控制权。紧接着发出:

1
cudaMemcpy(h_z, d_z, M, cudaMemcpyDefault);

然而,该命令不会被立刻执行,因为其与核函数同处默认流,需要顺序执行。

所以,主机在发出核函数调用后会立刻发出下一个命令;如果下一个命令是 主机中的某个计算任务,那么主机就会在设备执行核函数的同时执行计算。 这样就可以实现主机和设备的重叠计算。

当主机和设备的计算量相当时,将主机函数放在设备核函数后可以达到主机函数 与设备函数并发执行的效果,从而有效地隐藏主机函数的执行时间。


非默认 cuda 流重叠多个核函数

要实现多个核函数之间的并行必须使用多个非默认 cuda 流。

使用多个流相对于使用一个流有加速效果;当流的数目超过某个阈值时,加速比就趋于饱和。 制约加速比的因素:

  • GPU 计算资源,当核函数的线程总数超过某一值时,再增加流的数目就不会带来更高性能;
  • GPU 中能够并发执行的核函数的上限。

指定核函数的cuda流的方法:

1
kernal_func<<<grid_size, block_size, 0, stream>>>(params);

在调用核函数时,如果不需要使用共享内存,则该项设为0;同时指定cuda流的id。

计算能力为7,5的GPU能执行的核函数上限值为128。


非默认 cuda 流重叠核函数与数据传递

要实现核函数执行与数据传输的并发(重叠),必须让这两个操作处于不同的非默认流;同时,数据传输需要使用 cudaMemcpy 的异步版本 cudaMemcpyAsync

异步传输由GPU的DMA(direct memory access)实现,不需要主机的参与。

使用异步的数据传输函数时,需要将主机内存定义为不可分页内存或者固定内存,从而防止在程序执行期间物理地址被修改。如果将可分页内存传递给 cudaMemcpyAsync 则会导致同步传输。

主机不可分页内存的分配与释放:

1
2
3
4
5
cudaError_t cudaMallocHost(void **ptr, size_t size);
或者
cudaError_t cudaHostAlloc(void **ptr, size_t size);

cudaError_t cudaFreeHost(void *ptr);

要利用多个流提升性能,一种方法是将数据和相应计算操作分为若干等分, 然后在每个流中发布一个cuda操作序列。

如果核函数执行、主机与设备间的数据传输这3个cuda操作能完全并行执行,理论上最大加速比为 3。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
#include "../common/error.cuh"
#include "../common/floats.hpp"
#include <math.h>
#include <stdio.h>


const int NUM_REPEATS = 10;
const int N1 = 1024;
const int MAX_NUM_STREAMS = 30;
const int N2 = N1 * MAX_NUM_STREAMS;
const int M2 = sizeof(real) * N2;
cudaStream_t streams[MAX_NUM_STREAMS]; // cuda流数组,全局变量由系统负责销毁。


const int N = 100000000;
const int M = sizeof(real) * N;
const int block_size = 128;
const int grid_size = (N - 1) / block_size + 1;


void timing(const real *h_x, const real *h_y, real *h_z,
const real *d_x, const real *d_y, real *d_z,
const int ratio, bool overlap);
void timing(const real *d_x, const real *d_y, real *d_z,
const int num);
void timing(const real *h_x, const real *h_y, real *h_z,
real *d_x, real *d_y, real *d_z,
const int num
);

int main(void)
{
real *h_x = (real*) malloc(M);
real *h_y = (real*) malloc(M);
real *h_z = (real*) malloc(M);
for (int n = 0; n < N; ++n)
{
h_x[n] = 1.23;
h_y[n] = 2.34;
}

real *d_x, *d_y, *d_z;
CHECK(cudaMalloc(&d_x, M));
CHECK(cudaMalloc(&d_y, M));
CHECK(cudaMalloc(&d_z, M));
CHECK(cudaMemcpy(d_x, h_x, M, cudaMemcpyHostToDevice));
CHECK(cudaMemcpy(d_y, h_y, M, cudaMemcpyHostToDevice));


// host and kernal overlap.
printf("Without CPU-GPU overlap (ratio = 10)\n");
timing(h_x, h_y, h_z, d_x, d_y, d_z, 10, false);
printf("With CPU-GPU overlap (ratio = 10)\n");
timing(h_x, h_y, h_z, d_x, d_y, d_z, 10, true);

printf("Without CPU-GPU overlap (ratio = 1)\n");
timing(h_x, h_y, h_z, d_x, d_y, d_z, 1, false);
printf("With CPU-GPU overlap (ratio = 1)\n");
timing(h_x, h_y, h_z, d_x, d_y, d_z, 1, true);

printf("Without CPU-GPU overlap (ratio = 1000)\n");
timing(h_x, h_y, h_z, d_x, d_y, d_z, 1000, false);
printf("With CPU-GPU overlap (ratio = 1000)\n");
timing(h_x, h_y, h_z, d_x, d_y, d_z, 1000, true);


// kernal and kernal overlap.
for (int n = 0 ; n < MAX_NUM_STREAMS; ++n)
{
// 创建cuda流。
CHECK(cudaStreamCreate(&(streams[n])));
}

for (int num = 1; num <= MAX_NUM_STREAMS; ++num)
{
timing(d_x, d_y, d_z, num);
}

for (int n = 0 ; n < MAX_NUM_STREAMS; ++n)
{
// 销毁cuda流。
CHECK(cudaStreamDestroy(streams[n]));
}


// kernal and data transfering overlap.
real *h_x2, *h_y2, *h_z2;
CHECK(cudaMallocHost(&h_x2, M));
CHECK(cudaMallocHost(&h_y2, M));
CHECK(cudaMallocHost(&h_z2, M));
for (int n = 0; n < N; ++n)
{
h_x2[n] = 1.23;
h_y2[n] = 2.34;
}

for (int i = 0; i < MAX_NUM_STREAMS; i++)
{
CHECK(cudaStreamCreate(&(streams[i])));
}

for (int num = 1; num <= MAX_NUM_STREAMS; num *= 2)
{
timing(h_x2, h_y2, h_z2, d_x, d_y, d_z, num);
}

for (int i = 0 ; i < MAX_NUM_STREAMS; i++)
{
CHECK(cudaStreamDestroy(streams[i]));
}

CHECK(cudaFreeHost(h_x2));
CHECK(cudaFreeHost(h_y2));
CHECK(cudaFreeHost(h_z2));

free(h_x);
free(h_y);
free(h_z);
CHECK(cudaFree(d_x));
CHECK(cudaFree(d_y));
CHECK(cudaFree(d_z));

return 0;
}

void cpu_sum(const real *x, const real *y, real *z, const int N_host)
{
for (int n = 0; n < N_host; ++n)
{
z[n] = x[n] + y[n];
}
}

void __global__ gpu_sum(const real *x, const real *y, real *z)
{
const int n = blockDim.x * blockIdx.x + threadIdx.x;
if (n < N)
{
z[n] = x[n] + y[n];
}
}

void timing
(
const real *h_x, const real *h_y, real *h_z,
const real *d_x, const real *d_y, real *d_z,
const int ratio, bool overlap
)
{
float t_sum = 0;
float t2_sum = 0;

for (int repeat = 0; repeat <= NUM_REPEATS; ++repeat)
{
cudaEvent_t start, stop;
CHECK(cudaEventCreate(&start));
CHECK(cudaEventCreate(&stop));
CHECK(cudaEventRecord(start));
cudaEventQuery(start);

if (!overlap)
{
cpu_sum(h_x, h_y, h_z, N / ratio);
}

gpu_sum<<<grid_size, block_size>>>(d_x, d_y, d_z);

if (overlap)
{
// 主机函数与设备核函数重叠。
cpu_sum(h_x, h_y, h_z, N / ratio);
}

CHECK(cudaEventRecord(stop));
CHECK(cudaEventSynchronize(stop));
float elapsed_time;
CHECK(cudaEventElapsedTime(&elapsed_time, start, stop));
printf("Time = %g ms.\n", elapsed_time);

if (repeat > 0)
{
t_sum += elapsed_time;
t2_sum += elapsed_time * elapsed_time;
}

CHECK(cudaEventDestroy(start));
CHECK(cudaEventDestroy(stop));
}

const float t_ave = t_sum / NUM_REPEATS;
const float t_err = sqrt(t2_sum / NUM_REPEATS - t_ave * t_ave);
printf("Time = %g +- %g ms.\n", t_ave, t_err);
}

void __global__ add(const real *d_x, const real *d_y, real *d_z)
{
const int n = blockDim.x * blockIdx.x + threadIdx.x;
if (n < N1)
{
for (int i = 0; i < 100000; ++i)
{
d_z[n] = d_x[n] + d_y[n];
}
}
}

void timing(const real *d_x, const real *d_y, real *d_z, const int num)
{
float t_sum = 0;
float t2_sum = 0;

for (int repeat = 0; repeat <= NUM_REPEATS; ++repeat)
{
cudaEvent_t start, stop;
CHECK(cudaEventCreate(&start));
CHECK(cudaEventCreate(&stop));
CHECK(cudaEventRecord(start));
cudaEventQuery(start);

for (int n = 0; n < num; ++n)
{
int offset = n * N1;

// 指定各个核函数的cuda流,实现核函数的并行。
add<<<grid_size, block_size, 0, streams[n]>>>(d_x + offset, d_y + offset, d_z + offset);
}

CHECK(cudaEventRecord(stop));
CHECK(cudaEventSynchronize(stop));
float elapsed_time;
CHECK(cudaEventElapsedTime(&elapsed_time, start, stop));

if (repeat > 0)
{
t_sum += elapsed_time;
t2_sum += elapsed_time * elapsed_time;
}

CHECK(cudaEventDestroy(start));
CHECK(cudaEventDestroy(stop));
}

const float t_ave = t_sum / NUM_REPEATS;
const float t_err = sqrt(t2_sum / NUM_REPEATS - t_ave * t_ave);
printf("%g\n", t_ave);
}

void __global__ add2(const real *x, const real *y, real *z, int N)
{
const int n = blockDim.x * blockIdx.x + threadIdx.x;
if (n < N)
{
for (int i = 0; i < 40; ++i)
{
z[n] = x[n] + y[n];
}
}
}

void timing
(
const real *h_x, const real *h_y, real *h_z,
real *d_x, real *d_y, real *d_z,
const int num
)
{
int N1 = N / num;
int M1 = M / num;

float t_sum = 0;
float t2_sum = 0;

for (int repeat = 0; repeat <= NUM_REPEATS; ++repeat)
{
cudaEvent_t start, stop;
CHECK(cudaEventCreate(&start));
CHECK(cudaEventCreate(&stop));
CHECK(cudaEventRecord(start));
cudaEventQuery(start);

for (int i = 0; i < num; i++)
{
int offset = i * N1;

// 划分主机不可分页内存,实现异步的数据传输。
// 每个cuda流都有各自的数据传输操作。
CHECK(cudaMemcpyAsync(d_x + offset, h_x + offset, M1,
cudaMemcpyHostToDevice, streams[i]));
CHECK(cudaMemcpyAsync(d_y + offset, h_y + offset, M1,
cudaMemcpyHostToDevice, streams[i]));

int block_size = 128;
int grid_size = (N1 - 1) / block_size + 1;

// 指定核函数的cuda流。
add2<<<grid_size, block_size, 0, streams[i]>>>(d_x + offset, d_y + offset, d_z + offset, N1);

CHECK(cudaMemcpyAsync(h_z + offset, d_z + offset, M1,
cudaMemcpyDeviceToHost, streams[i]));
}

CHECK(cudaEventRecord(stop));
CHECK(cudaEventSynchronize(stop));
float elapsed_time;
CHECK(cudaEventElapsedTime(&elapsed_time, start, stop));

if (repeat > 0)
{
t_sum += elapsed_time;
t2_sum += elapsed_time * elapsed_time;
}

CHECK(cudaEventDestroy(start));
CHECK(cudaEventDestroy(stop));
}

const float t_ave = t_sum / NUM_REPEATS;
const float t_err = sqrt(t2_sum / NUM_REPEATS - t_ave * t_ave);
printf("%d %g\n", num, t_ave);
}

分子动力学模型

  1. 将静态函数放在头文件中,则该函数就有可能被编译为内联函数,从而提高效率。
    适用于于需要被多个编译单元反复调用的函数。 开发cuda程序时,也应该尽量优化对应的c++程序。

  2. 半步长推进。
    粒子在t+dt时刻的坐标仅依赖t时刻的坐标、速度和力;但是t+dt时刻的速度依赖 t时刻的坐标、速度和t+dt时刻的力。 所以首先,以t时刻的状态计算t+dt/2时刻的速度;然后计算t+dt时刻的坐标,同时 更新t时刻的力到t+dt时刻;最后,以t+dt/2时刻的速度和t+dt时刻的力计算t+dt 时刻的速度。

  3. 常量内存比全局内存高速。
    如果数据量在编译期就确定且不大(明显小于4KB),在核函数中仅被读取,而且 一个线程束中的所有线程在某个时刻访问同一个地址, 则该数据适合用传参的方式使用常量内存。

  4. 逐步分析程序的性能瓶颈,逐步优化。
    将一个c++程序用cuda加速时,一般首先确定其中最耗时的部分并将其用cuda加速,从而 快速提高程序性能。 要得到最好的加速效果,需要尽可能多的将程序中可并行的计算用cuda加速。当然, 在大数情况下,我们需要在付出和收获间找到一个平衡点。


cuda 标准库的使用

  • Thrust: 类型 c++ 的标准模板库;
  • cuBLAS:基本线性代数子程序;
  • cuFFT:快速傅里叶变换;
  • cuSPARSE:稀疏矩阵;
  • cuRAND:随机数生成器;
  • cuSolver:稠密矩阵和稀疏矩阵计算库;
  • cuDNN:深度神经网络。

Thrust

Thrust:一个实现了众多基本并行算法的c++模板库,类似c++的标准库stl。

Thrust官方资料

  1. 数据结构

Thrust 中的数据结构主要是矢量容器,类似 stl 中的 std::vector:

  • 存储于主机的容器 thrust::host_vector<typename>;
  • 存储于设备的容器 thrust::device__vector<typename>;

容器的使用也类似于 stl:

1
2
3
4
5
6
// 包含头文件
#include<thrust/host_vector.h>
#include<thrust/device_vector.h>

// 定义并初始化主机内存
thrust::host_vector<double> arr(12, 0.0);

Thrust 函数可以直接调用设备上的矢量容器。

  1. 算法

Thrust 提供5类常用算法:变换,归约,前缀和,排序于搜索,选择性复制、替换、移除、分区等重排操作。

Thrust 函数的参数必须都来自于主机容器,或者都来自于设备容器;thrust::copy 除外。

如果程序中大量使用了 thrust 库,使用设备矢量较为合适;如果只是偶尔使用 Thrust 库,
则使用设备内存指针更为合适。


cuBLAS

cuBLAS,一个基本线性代数子程序,提供三层功能函数:

  • 处理矢量之间的计算,如矢量之间的内积;
  • 处理矩阵和矢量之间的运算,如相乘;
  • 处理矩阵之间的运算,如相乘。

CUBLAS 中矩阵采用 列主序,即矩阵数据按列存储。

cuBLAS官方资料


cuSolver

cuSolver:稠密矩阵和稀疏矩阵计算库。

cuSolver 相比于 cuBLAS,专注于一些比较高级的线性代数计算,并由3个子库组成:

  • cuSolverDN,处理稠密矩阵线性代数计算;
  • cuSolverSP,处理稀疏矩阵线性代数计算;
  • cuSolverRF,处理稀疏矩阵分解。

cuSolver 库函数倾向于使用异步执行。为例保证一个 cuSolver 函数的工作已完成,
可以使用 cudaDeviceSynchronize() 函数进行同步。

cuSolver 中矩阵同样采用 列主序

cuSolver官方资料


cuRAND

cuRAND:随机数生成器。

cuRAND 库中提供两种 API: 主机API 和 设备API。以主机API为例,使用方式:

1
2
3
#include <curand.h>

编译时指定链接选项 `-lcurand`

同时,主机API 分为两种使用方式:

  • 使用设备产生伪随机数并存于设备数组;
  • 使用主机产生伪随机数并存于主机数组。

cuRAND官方资料


从可执行文件到库

本章的主要内容有:

  • 将单个源码文件编译为可执行文件

  • 切换生成器

  • 构建和连接静态库与动态库

  • 用条件语句控制编译

  • 向用户显示选项

  • 指定编译器

  • 切换构建类型

  • 设置编译器选项

  • 为语言设定标准

  • 使用控制流进行构造

本章的示例将指导您完成构建代码所需的基本任务:编译可执行文件、编译库、根据用户输入执行构建操作等等。CMake是一个构建系统生成器,特别适合于独立平台和编译器。除非另有说明,否则所有配置都独立于操作系统,它们可以在GNU/Linux、macOS和Windows的系统下运行。

本书的示例主要为C++项目设计,并使用C++示例进行了演示,但CMake也可以用于其他语言的项目,包括C和Fortran。我们会尝试一些有意思的配置,其中包含了一些C++、C和Fortran语言示例。您可以根据自己喜好,选择性了解。有些示例是定制的,以突出在选择特定语言时需要面临的挑战。

将单个源文件编译为可执行文件

本节示例中,我们将演示如何运行CMake配置和构建一个简单的项目。该项目由单个源文件组成,用于生成可执行文件。我们将用C++讨论这个项目,您在GitHub示例库中可以找到C和Fortran的例子。

准备工作

我们希望将以下源代码编译为单个可执行文件:

1
2
3
4
5
6
7
8
#include <cstdlib>
#include <iostream>
#include <string>
std::string say_hello() { return std::string("Hello, CMake world!"); }
int main() {
std::cout << say_hello() << std::endl;
return EXIT_SUCCESS;
}

具体实施

我们把CMake指令放入一个名为CMakeLists.txt的文件中。文件的名称区分大小写,必须命名为CMakeLists.txt,CMake才能够解析。

用编辑器打开一个文本文件,将这个文件命名为CMakeLists.txt。

第一行,设置CMake所需的最低版本。如果使用的CMake版本低于该版本,则会发出致命错误:

1
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

第二行,声明了项目的名称(recipe-01)和支持的编程语言(CXX代表C++):

1
project(recipe-01 LANGUAGES CXX)

指示CMake创建一个新目标:可执行文件hello-world。这个可执行文件是通过编译和链接源文件hello-world.cpp生成的。CMake将为编译器使用默认设置,并自动选择生成工具:

1
add_executable(hello-world hello-world.cpp)

将该文件与源文件hello-world.cpp放在相同的目录中。记住,它只能被命名为CMakeLists.txt。

现在,可以通过创建build目录,在build目录下来配置项目:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ mkdir -p build
$ cd build
$ cmake ..
-- The CXX compiler identification is GNU 8.1.0
-- Check for working CXX compiler: /usr/bin/c++
-- Check for working CXX compiler: /usr/bin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /home/user/cmake-cookbook/chapter-01/recipe-01/cxx-example/build

如果一切顺利,项目的配置已经在build目录中生成。我们现在可以编译可执行文件:

1
2
3
4
5
$ cmake --build .
Scanning dependencies of target hello-world
[ 50%] Building CXX object CMakeFiles/hello-world.dir/hello-world.cpp.o
[100%] Linking CXX executable hello-world
[100%] Built target hello-world

工作原理

示例中,我们使用了一个简单的CMakeLists.txt来构建“Hello world”可执行文件:

1
2
3
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-01 LANGUAGES CXX)
add_executable(hello-world hello-world.cpp)

NOTE:CMake语言不区分大小写,但是参数区分大小写。

CMake中,C++是默认的编程语言。不过,我们还是建议使用LANGUAGES选项在project命令中显式地声明项目的语言。

要配置项目并生成构建器,我们必须通过命令行界面(CLI)运行CMake。CMake CLI提供了许多选项,cmake -help将输出以显示列出所有可用选项的完整帮助信息,我们将在书中对这些选项进行更多地了解。正如您将从cmake -help的输出中显示的内容,它们中的大多数选项会让你您访问CMake手册,查看详细信息。通过下列命令生成构建器:

1
2
3
$ mkdir -p build
$ cd build
$ cmake ..

这里,我们创建了一个目录build(生成构建器的位置),进入build目录,并通过指定CMakeLists.txt的位置(本例中位于父目录中)来调用CMake。可以使用以下命令行来实现相同的效果:

1
$ cmake -H. -Bbuild

该命令是跨平台的,使用了-H和-B为CLI选项。-H表示当前目录中搜索根CMakeLists.txt文件。-Bbuild告诉CMake在一个名为build的目录中生成所有的文件。

运行cmake命令会输出一系列状态消息,显示配置信息:

1
2
3
4
5
6
7
8
9
10
11
$ cmake ..
-- The CXX compiler identification is GNU 8.1.0
-- Check for working CXX compiler: /usr/bin/c++
-- Check for working CXX compiler: /usr/bin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /home/user/cmake-cookbook/chapter-01/recipe-01/cxx-example/build

NOTE:在与CMakeLists.txt相同的目录中执行cmake .,原则上足以配置一个项目。然而,CMake会将所有生成的文件写到项目的根目录中。这将是一个源代码内构建,通常是不推荐的,因为这会混合源代码和项目的目录树。我们首选的是源外构建。

CMake是一个构建系统生成器。将描述构建系统(如:Unix Makefile、Ninja、Visual Studio等)应当如何操作才能编译代码。然后,CMake为所选的构建系统生成相应的指令。默认情况下,在GNU/Linux和macOS系统上,CMake使用Unix Makefile生成器。Windows上,Visual Studio是默认的生成器。

GNU/Linux上,CMake默认生成Unix Makefile来构建项目:

  • Makefile: make将运行指令来构建项目。
  • CMakefile:包含临时文件的目录,CMake用于检测操作系统、编译器等。此外,根据所选的生成器,它还包含特定的文件。
  • cmake_install.cmake:处理安装规则的CMake脚本,在项目安装时使用。
  • CMakeCache.txt:如文件名所示,CMake缓存。CMake在重新运行配置时使用这个文件。

要构建示例项目,我们运行以下命令:

1
$ cmake --build .

最后,CMake不强制指定构建目录执行名称或位置,我们完全可以把它放在项目路径之外。这样做同样有效:

1
2
3
4
$ mkdir -p /tmp/someplace
$ cd /tmp/someplace
$ cmake /path/to/source
$ cmake --build .

由CMake生成的构建系统,即上面给出的示例中的Makefile,将包含为给定项目构建目标文件、可执行文件和库的目标及规则。hello-world可执行文件是在当前示例中的唯一目标,运行以下命令:

1
2
3
4
5
6
7
8
9
10
11
$ cmake --build . --target help
The following are some of the valid targets for this Makefile:
... all (the default if no target is provided)
... clean
... depend
... rebuild_cache
... hello-world
... edit_cache
... hello-world.o
... hello-world.i
... hello-world.s

CMake生成的目标比构建可执行文件的目标要多。可以使用cmake --build . --target <target-name>语法,实现如下功能:

  • all(或Visual Studio generator中的ALL_BUILD)是默认目标,将在项目中构建所有目标。
  • clean,删除所有生成的文件。
  • rebuild_cache,将调用CMake为源文件生成依赖(如果有的话)。
  • edit_cache,这个目标允许直接编辑缓存。

对于更复杂的项目,通过测试阶段和安装规则,CMake将生成额外的目标:

  • test(或Visual Studio generator中的RUN_TESTS)将在CTest的帮助下运行测试套件。我们将在第4章中详细讨论测试和CTest。
  • install,将执行项目安装规则。我们将在第10章中讨论安装规则。
  • package,此目标将调用CPack为项目生成可分发的包。打包和CPack将在第11章中讨论。

切换生成器

CMake是一个构建系统生成器,可以使用单个CMakeLists.txt为不同平台上的不同工具集配置项目。您可以在CMakeLists.txt中描述构建系统必须运行的操作,以配置并编译代码。基于这些指令,CMake将为所选的构建系统(Unix Makefile、Ninja、Visual Studio等等)生成相应的指令。

准备工作

CMake针对不同平台支持本地构建工具列表。同时支持命令行工具(如Unix Makefile和Ninja)和集成开发环境(IDE)工具。用以下命令,可在平台上找到生成器名单,以及已安装的CMake版本:

1
$ cmake --help

这个命令的输出,将列出CMake命令行界面上所有的选项,您会找到可用生成器的列表。例如,安装了CMake 3.11.2的GNU/Linux机器上的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Generators
The following generators are available on this platform:
Unix Makefiles = Generates standard UNIX makefiles.
Ninja = Generates build.ninja files.
Watcom WMake = Generates Watcom WMake makefiles.
CodeBlocks - Ninja = Generates CodeBlocks project files.
CodeBlocks - Unix Makefiles = Generates CodeBlocks project files.
CodeLite - Ninja = Generates CodeLite project files.
CodeLite - Unix Makefiles = Generates CodeLite project files.
Sublime Text 2 - Ninja = Generates Sublime Text 2 project files.
Sublime Text 2 - Unix Makefiles = Generates Sublime Text 2 project files.
Kate - Ninja = Generates Kate project files.
Kate - Unix Makefiles = Generates Kate project files.
Eclipse CDT4 - Ninja = Generates Eclipse CDT 4.0 project files.
Eclipse CDT4 - Unix Makefiles= Generates Eclipse CDT 4.0 project files.

使用此示例,我们将展示为项目切换生成器是多么EASY。

具体实施

我们将重用前一节示例中的hello-world.cpp和CMakeLists.txt。惟一的区别在使用CMake时,因为现在必须显式地使用命令行方式,用-G切换生成器。

首先,使用以下步骤配置项目:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ mkdir -p build
$ cd build
$ cmake -G Ninja ..
-- The CXX compiler identification is GNU 8.1.0
-- Check for working CXX compiler: /usr/bin/c++
-- Check for working CXX compiler: /usr/bin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /home/user/cmake-cookbook/chapter-01/recipe-02/cxx-exampl

第二步,构建项目:

1
2
$ cmake --build .
[2/2] Linking CXX executable hello-world

如何工作

与前一个配置相比,每一步的输出没什么变化。每个生成器都有自己的文件集,所以编译步骤的输出和构建目录的内容是不同的:

1
2
3
4
5
build.ninja和rules.ninja:包含Ninja的所有的构建语句和构建规则。
CMakeCache.txt:CMake会在这个文件中进行缓存,与生成器无关。
CMakeFiles:包含由CMake在配置期间生成的临时文件。
cmake_install.cmake:CMake脚本处理安装规则,并在安装时使用。
cmake --build .将ninja命令封装在一个跨平台的接口中。

构建和链接静态库和动态库

项目中会有单个源文件构建的多个可执行文件的可能。项目中有多个源文件,通常分布在不同子目录中。这种实践有助于项目的源代码结构,而且支持模块化、代码重用和关注点分离。同时,这种分离可以简化并加速项目的重新编译。本示例中,我们将展示如何将源代码编译到库中,以及如何链接这些库。

准备工作

回看第一个例子,这里并不再为可执行文件提供单个源文件,我们现在将引入一个类,用来包装要打印到屏幕上的消息。更新一下的hello-world.cpp:

1
2
3
4
5
6
7
8
9
10
#include "Message.hpp"
#include <cstdlib>
#include <iostream>
int main() {
Message say_hello("Hello, CMake World!");
std::cout << say_hello << std::endl;
Message say_goodbye("Goodbye, CMake World");
std::cout << say_goodbye << std::endl;
return EXIT_SUCCESS;
}

Message类包装了一个字符串,并提供重载过的<<操作,并且包括两个源码文件:Message.hpp头文件与Message.cpp源文件。Message.hpp中的接口包含以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
#pragma once
#include <iosfwd>
#include <string>
class Message {
public:
Message(const std::string &m) : message_(m) {}
friend std::ostream &operator<<(std::ostream &os, Message &obj) {
return obj.printObject(os);
}
private:
std::string message_;
std::ostream &printObject(std::ostream &os);
};

Message.cpp实现如下:

1
2
3
4
5
6
7
8
#include "Message.hpp"
#include <iostream>
#include <string>
std::ostream &Message::printObject(std::ostream &os) {
os << "This is my very nice message: " << std::endl;
os << message_;
return os;
}

具体实施

这里有两个文件需要编译,所以CMakeLists.txt必须进行修改。本例中,先把它们编译成一个库,而不是直接编译成可执行文件:

创建目标——静态库。库的名称和源码文件名相同,具体代码如下:

1
2
3
4
5
add_library(message
STATIC
Message.hpp
Message.cpp
)

创建hello-world可执行文件的目标部分不需要修改:

1
add_executable(hello-world hello-world.cpp)

最后,将目标库链接到可执行目标:

1
target_link_libraries(hello-world message)

对项目进行配置和构建。库编译完成后,将连接到hello-world可执行文件中:

1
2
3
4
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
1
2
3
4
5
6
7
8
Scanning dependencies of target message
[ 25%] Building CXX object CMakeFiles/message.dir/Message.cpp.o
[ 50%] Linking CXX static library libmessage.a
[ 50%] Built target message
Scanning dependencies of target hello-world
[ 75%] Building CXX object CMakeFiles/hello-world.dir/hello-world.cpp.o
[100%] Linking CXX executable hello-world
[100%] Built target hello-world
1
2
3
4
5
$ ./hello-world
This is my very nice message:
Hello, CMake World!
This is my very nice message:
Goodbye, CMake World

工作原理

本节引入了两个新命令:

add_library(message STATIC Message.hpp Message.cpp):生成必要的构建指令,将指定的源码编译到库中。add_library的第一个参数是目标名。整个CMakeLists.txt中,可使用相同的名称来引用库。生成的库的实际名称将由CMake通过在前面添加前缀lib和适当的扩展名作为后缀来形成。生成库是根据第二个参数(STATIC或SHARED)和操作系统确定的。

target_link_libraries(hello-world message): 将库链接到可执行文件。此命令还确保hello-world可执行文件可以正确地依赖于消息库。因此,在消息库链接到hello-world可执行文件之前,需要完成消息库的构建。

编译成功后,构建目录包含libmessage.a一个静态库(在GNU/Linux上)和hello-world可执行文件。

CMake接受其他值作为add_library的第二个参数的有效值,我们来看下本书会用到的值:

  • STATIC:用于创建静态库,即编译文件的打包存档,以便在链接其他目标时使用,例如:可执行文件。
  • SHARED:用于创建动态库,即可以动态链接,并在运行时加载的库。可以在CMakeLists.txt中使用add_library(message SHARED Message.hpp Message.cpp)从静态库切换到动态共享对象(DSO)。
  • OBJECT:可将给定add_library的列表中的源码编译到目标文件,不将它们归档到静态库中,也不能将它们链接到共享对象中。如果需要一次性创建静态库和动态库,那么使用对象库尤其有用。我们将在本示例中演示。
  • MODULE:又为DSO组。与SHARED库不同,它们不链接到项目中的任何目标,不过可以进行动态加载。该参数可以用于构建运行时插件。

CMake还能够生成特殊类型的库,这不会在构建系统中产生输出,但是对于组织目标之间的依赖关系,和构建需求非常有用:

  • IMPORTED:此类库目标表示位于项目外部的库。此类库的主要用途是,对现有依赖项进行构建。因此,IMPORTED库将被视为不可变的。
  • INTERFACE:与IMPORTED库类似。不过,该类型库可变,没有位置信息。它主要用于项目之外的目标构建使用。
  • ALIAS:顾名思义,这种库为项目中已存在的库目标定义别名。不过,不能为IMPORTED库选择别名。

本例中,我们使用add_library直接集合了源代码。后面的章节中,我们将使用target_sources汇集源码,特别是在第7章。

更多信息

现在展示OBJECT库的使用,修改CMakeLists.txt,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-03 LANGUAGES CXX)
add_library(message-objs
OBJECT
Message.hpp
Message.cpp
)
# this is only needed for older compilers
# but doesn't hurt either to have it
set_target_properties(message-objs
PROPERTIES
POSITION_INDEPENDENT_CODE 1
)
add_library(message-shared
SHARED
$<TARGET_OBJECTS:message-objs>
)
add_library(message-static
STATIC
$<TARGET_OBJECTS:message-objs>
)
add_executable(hello-world hello-world.cpp)
target_link_libraries(hello-world message-static)

首先,add_library改为add_library(Message-objs OBJECT Message.hpp Message.cpp)。此外,需要保证编译的目标文件与生成位置无关。可以通过使用set_target_properties命令,设置message-objs目标的相应属性来实现。

可能在某些平台和/或使用较老的编译器上,需要显式地为目标设置POSITION_INDEPENDENT_CODE属性。

现在,可以使用这个对象库来获取静态库(message-static)和动态库(message-shared)。要注意引用对象库的生成器表达式语法:$<TARGET_OBJECTS:message-objs>。生成器表达式是CMake在生成时(即配置之后)构造,用于生成特定于配置的构建输出。

是否可以让CMake生成同名的两个库?换句话说,它们都可以被称为message,而不是message-static和message-shared吗?我们需要修改这两个目标的属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
add_library(message-shared
SHARED
$<TARGET_OBJECTS:message-objs>
)
set_target_properties(message-shared
PROPERTIES
OUTPUT_NAME "message"
)
add_library(message-static
STATIC
$<TARGET_OBJECTS:message-objs>
)
set_target_properties(message-static
PROPERTIES
OUTPUT_NAME "message"
)

我们可以链接到DSO吗?这取决于操作系统和编译器:

  • GNU/Linux和macOS上,不管选择什么编译器,它都可以工作。
  • Windows上,不能与Visual Studio兼容,但可以与MinGW和MSYS2兼容。

用条件句控制编译

目前为止,看到的示例比较简单,CMake执行流是线性的:从一组源文件到单个可执行文件,也可以生成静态库或动态库。为了确保完全控制构建项目、配置、编译和链接所涉及的所有步骤的执行流,CMake提供了自己的语言。本节中,我们将探索条件结构if-else- else-endif的使用。

具体实施

从与上一个示例的的源代码开始,我们希望能够在不同的两种行为之间进行切换:

  • 将Message.hpp和Message.cpp构建成一个库(静态或动态),然后将生成库链接到hello-world可执行文件中。
  • 将Message.hpp,Message.cpp和hello-world.cpp构建成一个可执行文件,但不生成任何一个库。
    让我们来看看如何使用CMakeLists.txt来实现:

首先,定义最低CMake版本、项目名称和支持的语言:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-04 LANGUAGES CXX)

我们引入了一个新变量USE_LIBRARY,这是一个逻辑变量,值为OFF。我们还打印了它的值:

1
2
set(USE_LIBRARY OFF)
message(STATUS "Compile sources into a library? ${USE_LIBRARY}")

CMake中定义BUILD_SHARED_LIBS全局变量,并设置为OFF。调用add_library并省略第二个参数,将构建一个静态库:

1
set(BUILD_SHARED_LIBS OFF)

然后,引入一个变量_sources,包括Message.hpp和Message.cpp:

1
list(APPEND _sources Message.hpp Message.cpp)

然后,引入一个基于USE_LIBRARY值的if-else语句。如果逻辑为真,则Message.hpp和Message.cpp将打包成一个库:

1
2
3
4
5
6
7
8
9
if(USE_LIBRARY)
# add_library will create a static library
# since BUILD_SHARED_LIBS is OFF
add_library(message ${_sources})
add_executable(hello-world hello-world.cpp)
target_link_libraries(hello-world message)
else()
add_executable(hello-world hello-world.cpp ${_sources})
endif()

我们可以再次使用相同的命令集进行构建。由于USE_LIBRARY为OFF, hello-world可执行文件将使用所有源文件来编译。可以通过在GNU/Linux上,运行objdump -x命令进行验证。

工作原理

我们介绍了两个变量:USE_LIBRARY和BUILD_SHARED_LIBS。这两个变量都设置为OFF。如CMake语言文档中描述,逻辑真或假可以用多种方式表示:

  • 如果将逻辑变量设置为以下任意一种:1、ON、YES、true、Y或非零数,则逻辑变量为true。
  • 如果将逻辑变量设置为以下任意一种:0、OFF、NO、false、N、IGNORE、NOTFOUND、空字符串,或者以-NOTFOUND为后缀,则逻辑变量为false。

USE_LIBRARY变量将在第一个和第二个行为之间切换。BUILD_SHARED_LIBS是CMake的一个全局标志。因为CMake内部要查询BUILD_SHARED_LIBS全局变量,所以add_library命令可以在不传递STATIC/SHARED/OBJECT参数的情况下调用;如果为false或未定义,将生成一个静态库。

这个例子说明,可以引入条件来控制CMake中的执行流。但是,当前的设置不允许从外部切换,不需要手动修改CMakeLists.txt。原则上,我们希望能够向用户开放所有设置,这样就可以在不修改构建代码的情况下调整配置,稍后将展示如何做到这一点。

else()和endif()中的(),可能会让刚开始学习CMake代码的同学感到惊讶。其历史原因是,因为其能够指出指令的作用范围。例如,可以使用if(USE_LIBRARY)…else(USE_LIBRARY)…endif(USE_LIBIRAY)。这个格式并不唯一,可以根据个人喜好来决定使用哪种格式。

TIPS:_sources变量是一个局部变量,不应该在当前范围之外使用,可以在名称前加下划线。

向用户显示选项

前面的配置中,我们引入了条件句:通过硬编码的方式给定逻辑变量值。不过,这会影响用户修改这些变量。CMake代码没有向读者传达,该值可以从外部进行修改。推荐在CMakeLists.txt中使用option()命令,以选项的形式显示逻辑开关,用于外部设置,从而切换构建系统的生成行为。本节的示例将向您展示,如何使用这个命令。

具体实施

看一下前面示例中的静态/动态库示例。与其硬编码USE_LIBRARY为ON或OFF,现在为其设置一个默认值,同时也可以从外部进行更改:

用一个选项替换上一个示例的set(USE_LIBRARY OFF)命令。该选项将修改USE_LIBRARY的值,并设置其默认值为OFF:

1
option(USE_LIBRARY "Compile sources into a library" OFF)

现在,可以通过CMake的-DCLI选项,将信息传递给CMake来切换库的行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ mkdir -p build
$ cd build
$ cmake -D USE_LIBRARY=ON ..
-- ...
-- Compile sources into a library? ON
-- ...
$ cmake --build .
Scanning dependencies of target message
[ 25%] Building CXX object CMakeFiles/message.dir/Message.cpp.o
[ 50%] Linking CXX static library libmessage.a
[ 50%] Built target message
Scanning dependencies of target hello-world
[ 75%] Building CXX object CMakeFiles/hello-world.dir/hello-world.cpp.o
[100%] Linking CXX executable hello-world
[100%] Built target hello-world

-D开关用于为CMake设置任何类型的变量:逻辑变量、路径等等。

工作原理

1
option(<option_variable> "help string" [initial value])

option可接受三个参数:

  • <option_variable>表示该选项的变量的名称。
  • “help string”记录选项的字符串,在CMake的终端或图形用户界面中可见。
  • [initial value]选项的默认值,可以是ON或OFF。

更多信息

有时选项之间会有依赖的情况。示例中,我们提供生成静态库或动态库的选项。但是,如果没有将USE_LIBRARY逻辑设置为ON,则此选项没有任何意义。CMake提供cmake_dependent_option()命令用来定义依赖于其他选项的选项:

1
2
3
4
5
6
7
8
9
10
11
include(CMakeDependentOption)
# second option depends on the value of the first
cmake_dependent_option(
MAKE_STATIC_LIBRARY "Compile sources into a static library" OFF
"USE_LIBRARY" ON
)
# third option depends on the value of the first
cmake_dependent_option(
MAKE_SHARED_LIBRARY "Compile sources into a shared library" ON
"USE_LIBRARY" ON
)

如果USE_LIBRARY为ON,MAKE_STATIC_LIBRARY默认值为OFF,否则MAKE_SHARED_LIBRARY默认值为ON。可以这样运行:

1
$ cmake -D USE_LIBRARY=OFF -D MAKE_SHARED_LIBRARY=ON ..

这仍然不会构建库,因为USE_LIBRARY仍然为OFF。

CMake有适当的机制,通过包含模块来扩展其语法和功能,这些模块要么是CMake自带的,要么是定制的。本例中,包含了一个名为CMakeDependentOption的模块。如果没有include这个模块,cmake_dependent_option()命令将不可用。

手册中的任何模块都可以以命令行的方式使用cmake --help-module <name-of-module>。例如,cmake --help-module CMakeDependentOption将打印刚才讨论的模块的手册页(帮助页面)。

指定编译器

目前为止,我们还没有过多考虑如何选择编译器。CMake可以根据平台和生成器选择编译器,还能将编译器标志设置为默认值。然而,我们通常控制编译器的选择。在后面的示例中,我们还将考虑构建类型的选择,并展示如何控制编译器标志。

具体实施

如何选择一个特定的编译器?例如,如果想使用Intel或Portland Group编译器怎么办?CMake将语言的编译器存储在CMAKE_<LANG>_COMPILER变量中,其中<LANG>是受支持的任何一种语言,对于我们的目的是CXX、C或Fortran。用户可以通过以下两种方式之一设置此变量:

使用CLI中的-D选项,例如:

1
$ cmake -D CMAKE_CXX_COMPILER=clang++ ..

通过导出环境变量CXX(C++编译器)、CC(C编译器)和FC(Fortran编译器)。例如,使用这个命令使用clang++作为C++编译器:

1
$ env CXX=clang++ cmake ..

到目前为止讨论的示例,都可以通过传递适当的选项,配置合适的编译器。

CMake了解运行环境,可以通过其CLI的-D开关或环境变量设置许多选项。前一种机制覆盖后一种机制,但是我们建议使用-D显式设置选项。显式优于隐式,因为环境变量可能被设置为不适合(当前项目)的值。

我们在这里假设,其他编译器在标准路径中可用,CMake在标准路径中执行查找编译器。如果不是这样,用户将需要将完整的编译器可执行文件或包装器路径传递给CMake。

我们建议使用-D CMAKE_<LANG>_COMPILERCLI选项设置编译器,而不是导出CXX、CC和FC。这是确保跨平台并与非POSIX兼容的唯一方法。为了避免变量污染环境,这些变量可能会影响与项目一起构建的外部库环境。

工作原理

配置时,CMake会进行一系列平台测试,以确定哪些编译器可用,以及它们是否适合当前的项目。一个合适的编译器不仅取决于我们所使用的平台,还取决于我们想要使用的生成器。CMake执行的第一个测试基于项目语言的编译器的名称。例如,cc是一个工作的C编译器,那么它将用作C项目的默认编译器。GNU/Linux上,使用Unix Makefile或Ninja时, GCC家族中的编译器很可能是C++、C和Fortran的默认选择。Microsoft Windows上,将选择Visual Studio中的C++和C编译器(前提是Visual Studio是生成器)。如果选择MinGW或MSYS Makefile作为生成器,则默认使用MinGW编译器。

更多信息

我们的平台上的CMake,在哪里可以找到可用的编译器和编译器标志?CMake提供–system-information标志,它将把关于系统的所有信息转储到屏幕或文件中。要查看这个信息,请尝试以下操作:

1
$ cmake --system-information information.txt

文件中(本例中是information.txt)可以看到CMAKE_CXX_COMPILER、CMAKE_C_COMPILER和CMAKE_Fortran_COMPILER的默认值,以及默认标志。我们将在下一个示例中看到相关的标志。

CMake提供了额外的变量来与编译器交互:

  • CMAKE_<LANG>_COMPILER_LOADED:如果为项目启用了语言<LANG>,则将设置为TRUE。
  • CMAKE_<LANG>_COMPILER_ID:编译器标识字符串,编译器供应商所特有。例如,GCC用于GNU编译器集合,AppleClang用于macOS上的Clang, MSVC用于Microsoft Visual Studio编译器。注意,不能保证为所有编译器或语言定义此变量。
  • CMAKE_COMPILER_IS_GNU<LANG>:如果语言<LANG>是GNU编译器集合的一部分,则将此逻辑变量设置为TRUE。注意变量名的<LANG>部分遵循GNU约定:C语言为CC, C++语言为CXX, Fortran语言为G77。
  • CMAKE_<LANG>_COMPILER_VERSION:此变量包含一个字符串,该字符串给定语言的编译器版本。版本信息在major[.minor[.patch[.tweak]]]中给出。但是,对于CMAKE_<LANG>_COMPILER_ID,不能保证所有编译器或语言都定义了此变量。

我们可以尝试使用不同的编译器,配置下面的示例CMakeLists.txt。这个例子中,我们将使用CMake变量来探索已使用的编译器(及版本):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-06 LANGUAGES C CXX)
message(STATUS "Is the C++ compiler loaded? ${CMAKE_CXX_COMPILER_LOADED}")
if(CMAKE_CXX_COMPILER_LOADED)
message(STATUS "The C++ compiler ID is: ${CMAKE_CXX_COMPILER_ID}")
message(STATUS "Is the C++ from GNU? ${CMAKE_COMPILER_IS_GNUCXX}")
message(STATUS "The C++ compiler version is: ${CMAKE_CXX_COMPILER_VERSION}")
endif()
message(STATUS "Is the C compiler loaded? ${CMAKE_C_COMPILER_LOADED}")
if(CMAKE_C_COMPILER_LOADED)
message(STATUS "The C compiler ID is: ${CMAKE_C_COMPILER_ID}")
message(STATUS "Is the C from GNU? ${CMAKE_COMPILER_IS_GNUCC}")
message(STATUS "The C compiler version is: ${CMAKE_C_COMPILER_VERSION}")
endif()

注意,这个例子不包含任何目标,没有要构建的东西,我们只关注配置步骤:

1
2
3
4
5
6
7
8
9
10
11
12
$ mkdir -p build
$ cd build
$ cmake ..
...
-- Is the C++ compiler loaded? 1
-- The C++ compiler ID is: GNU
-- Is the C++ from GNU? 1
-- The C++ compiler version is: 8.1.0
-- Is the C compiler loaded? 1
-- The C compiler ID is: GNU
-- Is the C from GNU? 1
-- The C compiler version is: 8.1.0

当然,输出将取决于可用和已选择的编译器(及版本)。

切换构建类型

CMake可以配置构建类型,例如:Debug、Release等。配置时,可以为Debug或Release构建设置相关的选项或属性,例如:编译器和链接器标志。控制生成构建系统使用的配置变量是CMAKE_BUILD_TYPE。该变量默认为空,CMake识别的值为:

  • Debug:用于在没有优化的情况下,使用带有调试符号构建库或可执行文件。
  • Release:用于构建的优化的库或可执行文件,不包含调试符号。
  • RelWithDebInfo:用于构建较少的优化库或可执行文件,包含调试符号。
  • MinSizeRel:用于不增加目标代码大小的优化方式,来构建库或可执行文件。

具体实施

示例中,我们将展示如何为项目设置构建类型:

首先,定义最低CMake版本、项目名称和支持的语言:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-07 LANGUAGES C CXX)

然后,设置一个默认的构建类型(本例中是Release),并打印一条消息。要注意的是,该变量被设置为缓存变量,可以通过缓存进行编辑:

1
2
3
4
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()
message(STATUS "Build type: ${CMAKE_BUILD_TYPE}")

最后,打印出CMake设置的相应编译标志:

1
2
3
4
5
6
7
8
message(STATUS "C flags, Debug configuration: ${CMAKE_C_FLAGS_DEBUG}")
message(STATUS "C flags, Release configuration: ${CMAKE_C_FLAGS_RELEASE}")
message(STATUS "C flags, Release configuration with Debug info: ${CMAKE_C_FLAGS_RELWITHDEBINFO}")
message(STATUS "C flags, minimal Release configuration: ${CMAKE_C_FLAGS_MINSIZEREL}")
message(STATUS "C++ flags, Debug configuration: ${CMAKE_CXX_FLAGS_DEBUG}")
message(STATUS "C++ flags, Release configuration: ${CMAKE_CXX_FLAGS_RELEASE}")
message(STATUS "C++ flags, Release configuration with Debug info: ${CMAKE_CXX_FLAGS_RELWITHDEBINFO}")
message(STATUS "C++ flags, minimal Release configuration: ${CMAKE_CXX_FLAGS_MINSIZEREL}")

验证配置的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ mkdir -p build
$ cd build
$ cmake ..
...
-- Build type: Release
-- C flags, Debug configuration: -g
-- C flags, Release configuration: -O3 -DNDEBUG
-- C flags, Release configuration with Debug info: -O2 -g -DNDEBUG
-- C flags, minimal Release configuration: -Os -DNDEBUG
-- C++ flags, Debug configuration: -g
-- C++ flags, Release configuration: -O3 -DNDEBUG
-- C++ flags, Release configuration with Debug info: -O2 -g -DNDEBUG
-- C++ flags, minimal Release configuration: -Os -DNDEBUG

切换构建类型:

1
2
3
4
5
6
7
8
9
10
$ cmake -D CMAKE_BUILD_TYPE=Debug ..
-- Build type: Debug
-- C flags, Debug configuration: -g
-- C flags, Release configuration: -O3 -DNDEBUG
-- C flags, Release configuration with Debug info: -O2 -g -DNDEBUG
-- C flags, minimal Release configuration: -Os -DNDEBUG
-- C++ flags, Debug configuration: -g
-- C++ flags, Release configuration: -O3 -DNDEBUG
-- C++ flags, Release configuration with Debug info: -O2 -g -DNDEBUG
-- C++ flags, minimal Release configuration: -Os -DNDEBUG

工作原理

我们演示了如何设置默认构建类型,以及如何(从命令行)覆盖它。这样,就可以控制项目,是使用优化,还是关闭优化启用调试。我们还看到了不同配置使用了哪些标志,这主要取决于选择的编译器。需要在运行CMake时显式地打印标志,也可以仔细阅读运行CMake –system-information的输出,以了解当前平台、默认编译器和语言的默认组合是什么。下一个示例中,我们将讨论如何为不同的编译器和不同的构建类型,扩展或调整编译器标志。

设置编译器选项

前面的示例展示了如何探测CMake,从而获得关于编译器的信息,以及如何切换项目中的编译器。后一个任务是控制项目的编译器标志。CMake为调整或扩展编译器标志提供了很大的灵活性,您可以选择下面两种方法:

  • CMake将编译选项视为目标属性。因此,可以根据每个目标设置编译选项,而不需要覆盖CMake默认值。
  • 可以使用-DCLI标志直接修改CMAKE_<LANG>_FLAGS_<CONFIG>变量。这将影响项目中的所有目标,并覆盖或扩展CMake默认值。

本示例中,我们将展示这两种方法。

准备工作

编写一个示例程序,计算不同几何形状的面积,computer_area.cpp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include "geometry_circle.hpp"
#include "geometry_polygon.hpp"
#include "geometry_rhombus.hpp"
#include "geometry_square.hpp"
#include <cstdlib>
#include <iostream>
int main() {
using namespace geometry;
double radius = 2.5293;
double A_circle = area::circle(radius);
std::cout << "A circle of radius " << radius << " has an area of " << A_circle
<< std::endl;
int nSides = 19;
double side = 1.29312;
double A_polygon = area::polygon(nSides, side);
std::cout << "A regular polygon of " << nSides << " sides of length " << side
<< " has an area of " << A_polygon << std::endl;
double d1 = 5.0;
double d2 = 7.8912;
double A_rhombus = area::rhombus(d1, d2);
std::cout << "A rhombus of major diagonal " << d1 << " and minor diagonal " << d2
<< " has an area of " << A_rhombus << std::endl;
double l = 10.0;
double A_square = area::square(l);
std::cout << "A square of side " << l << " has an area of " << A_square
<< std::endl;
return EXIT_SUCCESS;
}

函数的各种实现分布在不同的文件中,每个几何形状都有一个头文件和源文件。总共有4个头文件和5个源文件要编译:

1
2
3
4
5
6
7
8
9
10
├─ CMakeLists.txt
├─ compute-areas.cpp
├─ geometry_circle.cpp
├─ geometry_circle.hpp
├─ geometry_polygon.cpp
├─ geometry_polygon.hpp
├─ geometry_rhombus.cpp
├─ geometry_rhombus.hpp
├─ geometry_square.cpp
└─ geometry_square.hpp

具体实施

现在已经有了源代码,我们的目标是配置项目,并使用编译器标示进行实验:

设置CMake的最低版本:

1
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

声明项目名称和语言:

1
project(recipe-08 LANGUAGES CXX)

然后,打印当前编译器标志。CMake将对所有C++目标使用这些:

1
message("C++ compiler flags: ${CMAKE_CXX_FLAGS}")

为目标准备了标志列表,其中一些将无法在Windows上使用:

1
2
3
4
list(APPEND flags "-fPIC" "-Wall")
if(NOT WIN32)
list(APPEND flags "-Wextra" "-Wpedantic")
endif()

添加了一个新的目标——geometry库,并列出它的源依赖关系:

1
2
3
4
5
6
7
8
9
10
11
add_library(geometry
STATIC
geometry_circle.cpp
geometry_circle.hpp
geometry_polygon.cpp
geometry_polygon.hpp
geometry_rhombus.cpp
geometry_rhombus.hpp
geometry_square.cpp
geometry_square.hpp
)

为这个库目标设置了编译选项:

1
2
3
4
target_compile_options(geometry
PRIVATE
${flags}
)

然后,将生成compute-areas可执行文件作为一个目标:

1
add_executable(compute-areas compute-areas.cpp)

还为可执行目标设置了编译选项:

1
2
3
4
target_compile_options(compute-areas
PRIVATE
"-fPIC"
)

最后,将可执行文件链接到geometry库:

1
target_link_libraries(compute-areas geometry)

如何工作

本例中,警告标志有-Wall、-Wextra和-Wpedantic,将这些标示添加到geometry目标的编译选项中; compute-areas和 geometry目标都将使用-fPIC标志。编译选项可以添加三个级别的可见性:INTERFACE、PUBLIC和PRIVATE。

可见性的含义如下:

  • PRIVATE,编译选项会应用于给定的目标,不会传递给与目标相关的目标。我们的示例中, 即使compute-areas将链接到geometry库,compute-areas也不会继承geometry目标上设置的编译器选项。
  • INTERFACE,给定的编译选项将只应用于指定目标,并传递给与目标相关的目标。
  • PUBLIC,编译选项将应用于指定目标和使用它的目标。

目标属性的可见性CMake的核心,我们将在本书中经常讨论这个话题。以这种方式添加编译选项,不会影响全局CMake变量CMAKE_<LANG>_FLAGS_<CONFIG>,并能更细粒度控制在哪些目标上使用哪些选项。

我们如何验证,这些标志是否按照我们的意图正确使用呢?或者换句话说,如何确定项目在CMake构建时,实际使用了哪些编译标志?一种方法是,使用CMake将额外的参数传递给本地构建工具。本例中会设置环境变量VERBOSE=1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build . -- VERBOSE=1
... lots of output ...
[ 14%] Building CXX object CMakeFiles/geometry.dir/geometry_circle.cpp.o
/usr/bin/c++ -fPIC -Wall -Wextra -Wpedantic -o CMakeFiles/geometry.dir/geometry_circle.cpp.o -c /home/bast/tmp/cmake-cookbook/chapter-01/recipe-08/cxx-example/geometry_circle.cpp
[ 28%] Building CXX object CMakeFiles/geometry.dir/geometry_polygon.cpp.o
/usr/bin/c++ -fPIC -Wall -Wextra -Wpedantic -o CMakeFiles/geometry.dir/geometry_polygon.cpp.o -c /home/bast/tmp/cmake-cookbook/chapter-01/recipe-08/cxx-example/geometry_polygon.cpp
[ 42%] Building CXX object CMakeFiles/geometry.dir/geometry_rhombus.cpp.o
/usr/bin/c++ -fPIC -Wall -Wextra -Wpedantic -o CMakeFiles/geometry.dir/geometry_rhombus.cpp.o -c /home/bast/tmp/cmake-cookbook/chapter-01/recipe-08/cxx-example/geometry_rhombus.cpp
[ 57%] Building CXX object CMakeFiles/geometry.dir/geometry_square.cpp.o
/usr/bin/c++ -fPIC -Wall -Wextra -Wpedantic -o CMakeFiles/geometry.dir/geometry_square.cpp.o -c /home/bast/tmp/cmake-cookbook/chapter-01/recipe-08/cxx-example/geometry_square.cpp
... more output ...
[ 85%] Building CXX object CMakeFiles/compute-areas.dir/compute-areas.cpp.o
/usr/bin/c++ -fPIC -o CMakeFiles/compute-areas.dir/compute-areas.cpp.o -c /home/bast/tmp/cmake-cookbook/chapter-01/recipe-08/cxx-example/compute-areas.cpp
... more output ...

输出确认编译标志,确认指令设置正确。

控制编译器标志的第二种方法,不用对CMakeLists.txt进行修改。如果想在这个项目中修改geometry和compute-areas目标的编译器选项,可以使用CMake参数进行配置:

1
$ cmake -D CMAKE_CXX_FLAGS="-fno-exceptions -fno-rtti" ..

这个命令将编译项目,禁用异常和运行时类型标识(RTTI)。

也可以使用全局标志,可以使用CMakeLists.txt运行以下命令:

1
$ cmake -D CMAKE_CXX_FLAGS="-fno-exceptions -fno-rtti" ..

这将使用-fno-rtti - fpic - wall - Wextra - wpedantic配置geometry目标,同时使用-fno exception -fno-rtti - fpic配置compute-areas。

更多信息

大多数时候,编译器有特性标示。当前的例子只适用于GCC和Clang;其他供应商的编译器不确定是否会理解(如果不是全部)这些标志。如果项目是真正跨平台,那么这个问题就必须得到解决,有三种方法可以解决这个问题。

最典型的方法是将所需编译器标志列表附加到每个配置类型CMake变量CMAKE_<LANG>_FLAGS_<CONFIG>。标志确定设置为给定编译器有效的标志,因此将包含在if-endif子句中,用于检查CMAKE_<LANG>_COMPILER_ID变量,例如:

1
2
3
4
5
6
7
8
9
10
if(CMAKE_CXX_COMPILER_ID MATCHES GNU)
list(APPEND CMAKE_CXX_FLAGS "-fno-rtti" "-fno-exceptions")
list(APPEND CMAKE_CXX_FLAGS_DEBUG "-Wsuggest-final-types" "-Wsuggest-final-methods" "-Wsuggest-override")
list(APPEND CMAKE_CXX_FLAGS_RELEASE "-O3" "-Wno-unused")
endif()
if(CMAKE_CXX_COMPILER_ID MATCHES Clang)
list(APPEND CMAKE_CXX_FLAGS "-fno-rtti" "-fno-exceptions" "-Qunused-arguments" "-fcolor-diagnostics")
list(APPEND CMAKE_CXX_FLAGS_DEBUG "-Wdocumentation")
list(APPEND CMAKE_CXX_FLAGS_RELEASE "-O3" "-Wno-unused")
endif()

更细粒度的方法是,不修改CMAKE_<LANG>_FLAGS_<CONFIG>变量,而是定义特定的标志列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
set(COMPILER_FLAGS)
set(COMPILER_FLAGS_DEBUG)
set(COMPILER_FLAGS_RELEASE)
if(CMAKE_CXX_COMPILER_ID MATCHES GNU)
list(APPEND CXX_FLAGS "-fno-rtti" "-fno-exceptions")
list(APPEND CXX_FLAGS_DEBUG "-Wsuggest-final-types" "-Wsuggest-final-methods" "-Wsuggest-override")
list(APPEND CXX_FLAGS_RELEASE "-O3" "-Wno-unused")
endif()
if(CMAKE_CXX_COMPILER_ID MATCHES Clang)
list(APPEND CXX_FLAGS "-fno-rtti" "-fno-exceptions" "-Qunused-arguments" "-fcolor-diagnostics")
list(APPEND CXX_FLAGS_DEBUG "-Wdocumentation")
list(APPEND CXX_FLAGS_RELEASE "-O3" "-Wno-unused")
endif()

稍后,使用生成器表达式来设置编译器标志的基础上,为每个配置和每个目标生成构建系统:

1
2
3
4
5
6
target_compile_option(compute-areas
PRIVATE
${CXX_FLAGS}
"$<$<CONFIG:Debug>:${CXX_FLAGS_DEBUG}>"
"$<$<CONFIG:Release>:${CXX_FLAGS_RELEASE}>"
)

当前示例中展示了这两种方法,我们推荐后者(特定于项目的变量和target_compile_options)。

两种方法都有效,并在许多项目中得到广泛应用。不过,每种方式都有缺点。CMAKE_<LANG>_COMPILER_ID不能保证为所有编译器都定义。此外,一些标志可能会被弃用,或者在编译器的较晚版本中引入。与CMAKE_<LANG>_COMPILER_ID类似,CMAKE_<LANG>_COMPILER_VERSION变量不能保证为所有语言和供应商都提供定义。尽管检查这些变量的方式非常流行,但我们认为更健壮的替代方法是检查所需的标志集是否与给定的编译器一起工作,这样项目中实际上只使用有效的标志。结合特定于项目的变量、target_compile_options和生成器表达式,会让解决方案变得非常强大。

为语言设定标准

编程语言有不同的标准,即提供改进的语言版本。启用新标准是通过设置适当的编译器标志来实现的。前面的示例中,我们已经展示了如何为每个目标或全局进行配置。3.1版本中,CMake引入了一个独立于平台和编译器的机制,用于为C++和C设置语言标准:为目标设置<LANG>_STANDARD属性。

准备工作

对于下面的示例,需要一个符合C++14标准或更高版本的C++编译器。此示例代码定义了动物的多态,我们使用std::unique_ptr作为结构中的基类:

1
2
std::unique_ptr<Animal> cat = Cat("Simon");
std::unique_ptr<Animal> dog = Dog("Marlowe);

没有为各种子类型显式地使用构造函数,而是使用工厂方法的实现。工厂方法使用C++11的可变参数模板实现。它包含继承层次结构中每个对象的创建函数映射:

1
typedef std::function<std::unique_ptr<Animal>(const std::string &)> CreateAnimal;

基于预先分配的标签来分派它们,创建对象:

1
2
std::unique_ptr<Animal> simon = farm.create("CAT", "Simon");
std::unique_ptr<Animal> marlowe = farm.create("DOG", "Marlowe");

标签和创建功能在工厂使用前已注册:

1
2
3
Factory<CreateAnimal> farm;
farm.subscribe("CAT", [](const std::string & n) { return std::make_unique<Cat>(n); });
farm.subscribe("DOG", [](const std::string & n) { return std::make_unique<Dog>(n); });

使用C++11 Lambda函数定义创建函数,使用std::make_unique来避免引入裸指针的操作。这个工厂函数是在C++14中引入。

具体实施

将逐步构建CMakeLists.txt,并展示如何设置语言标准(本例中是C++14):

声明最低要求的CMake版本,项目名称和语言:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-09 LANGUAGES CXX)

要求在Windows上导出所有库符号:

1
set(CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS ON)

需要为库添加一个目标,这将编译源代码为一个动态库:

1
2
3
4
5
6
7
8
9
10
add_library(animals
SHARED
Animal.cpp
Animal.hpp
Cat.cpp
Cat.hpp
Dog.cpp
Dog.hpp
Factory.hpp
)

现在,为目标设置了CXX_STANDARD、CXX_EXTENSIONS和CXX_STANDARD_REQUIRED属性。还设置了position_independent ent_code属性,以避免在使用一些编译器构建DSO时出现问题:

1
2
3
4
5
6
7
set_target_properties(animals
PROPERTIES
CXX_STANDARD 14
CXX_EXTENSIONS OFF
CXX_STANDARD_REQUIRED ON
POSITION_INDEPENDENT_CODE 1
)

然后,为”动物农场”的可执行文件添加一个新目标,并设置它的属性:

1
2
3
4
5
6
7
add_executable(animal-farm animal-farm.cpp)
set_target_properties(animal-farm
PROPERTIES
CXX_STANDARD 14
CXX_EXTENSIONS OFF
CXX_STANDARD_REQUIRED ON
)

最后,将可执行文件链接到库:

1
target_link_libraries(animal-farm animals)

现在,来看看猫和狗都说了什么:

1
2
3
4
5
6
7
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./animal-farm
I'm Simon the cat!
I'm Marlowe the dog!

工作原理

步骤4和步骤5中,我们为动物和动物农场目标设置了一些属性:

  • CXX_STANDARD会设置我们想要的标准。
  • CXX_EXTENSIONS告诉CMake,只启用ISO C++标准的编译器标志,而不使用特定编译器的扩展。
  • CXX_STANDARD_REQUIRED指定所选标准的版本。如果这个版本不可用,CMake将停止配置并出现错误。当这个属性被设置为OFF时,CMake将寻找下一个标准的最新版本,直到一个合适的标志。这意味着,首先查找C++14,然后是C++11,然后是C++98。

如果语言标准是所有目标共享的全局属性,那么可以将CMAKE_<LANG>_STANDARDCMAKE_<LANG>_EXTENSIONSCMAKE_<LANG>_STANDARD_REQUIRED变量设置为相应的值。所有目标上的对应属性都将使用这些设置。

更多信息

通过引入编译特性,CMake对语言标准提供了更精细的控制。这些是语言标准引入的特性,比如C++11中的可变参数模板和Lambda表达式,以及C++14中的自动返回类型推断。可以使用target_compile_features()命令要求为特定的目标提供特定的特性,CMake将自动为标准设置正确的编译器标志。也可以让CMake为可选编译器特性,生成兼容头文件。

使用控制流

本章前面的示例中,已经使用过if-else-endif。CMake还提供了创建循环的语言工具:foreach endforeach和while-endwhile。两者都可以与break结合使用,以便尽早从循环中跳出。本示例将展示如何使用foreach,来循环源文件列表。我们将应用这样的循环,在引入新目标的前提下,来为一组源文件进行优化降级。

准备工作

将重用第8节中的几何示例,目标是通过将一些源代码汇集到一个列表中,从而微调编译器的优化。

具体实施

下面是CMakeLists.txt中要的详细步骤:

与示例8中一样,指定了CMake的最低版本、项目名称和语言,并声明了几何库目标:

1
2
3
4
5
6
7
8
9
10
11
12
13
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-10 LANGUAGES CXX)
add_library(geometry
STATIC
geometry_circle.cpp
geometry_circle.hpp
geometry_polygon.cpp
geometry_polygon.hpp
geometry_rhombus.cpp
geometry_rhombus.hpp
geometry_square.cpp
geometry_square.hpp
)

使用-O3编译器优化级别编译库,对目标设置一个私有编译器选项:

1
2
3
4
target_compile_options(geometry
PRIVATE
-O3
)

然后,生成一个源文件列表,以较低的优化选项进行编译:

1
2
3
4
5
list(
APPEND sources_with_lower_optimization
geometry_circle.cpp
geometry_rhombus.cpp
)

循环这些源文件,将它们的优化级别调到-O2。使用它们的源文件属性完成:

1
2
3
4
5
message(STATUS "Setting source properties using IN LISTS syntax:")
foreach(_source IN LISTS sources_with_lower_optimization)
set_source_files_properties(${_source} PROPERTIES COMPILE_FLAGS -O2)
message(STATUS "Appending -O2 flag for ${_source}")
endforeach()

为了确保设置属性,再次循环并在打印每个源文件的COMPILE_FLAGS属性:

1
2
3
4
5
message(STATUS "Querying sources properties using plain syntax:")
foreach(_source ${sources_with_lower_optimization})
get_source_file_property(_flags ${_source} COMPILE_FLAGS)
message(STATUS "Source ${_source} has the following extra COMPILE_FLAGS: ${_flags}")
endforeach()

最后,添加compute-areas可执行目标,并将geometry库连接上去:

1
2
add_executable(compute-areas compute-areas.cpp)
target_link_libraries(compute-areas geometry)

验证在配置步骤中正确设置了标志:

1
2
3
4
5
6
7
8
9
10
$ mkdir -p build
$ cd build
$ cmake ..
...
-- Setting source properties using IN LISTS syntax:
-- Appending -O2 flag for geometry_circle.cpp
-- Appending -O2 flag for geometry_rhombus.cpp
-- Querying sources properties using plain syntax:
-- Source geometry_circle.cpp has the following extra COMPILE_FLAGS: -O2
-- Source geometry_rhombus.cpp has the following extra COMPILE_FLAGS: -O2

最后,还使用VERBOSE=1检查构建步骤。将看到-O2标志添加在-O3标志之后,但是最后一个优化级别标志(在本例中是-O2)不同:

1
$ cmake --build . -- VERBOSE=1

工作原理

foreach-endforeach语法可用于在变量列表上,表示重复特定任务。本示例中,使用它来操作、设置和获取项目中特定文件的编译器标志。CMake代码片段中引入了另外两个新命令:

set_source_files_properties(file PROPERTIES property value),它将属性设置为给定文件的传递值。与目标非常相似,文件在CMake中也有属性,允许对构建系统进行非常细粒度的控制。

get_source_file_property(VAR file property),检索给定文件所需属性的值,并将其存储在CMakeVAR变量中。

CMake中,列表是用分号分隔的字符串组。列表可以由list或set命令创建。例如,set(var a b c d e)和list(APPEND a b c d e)都创建了列表a;b;c;d;e。

为了对一组文件降低优化,将它们收集到一个单独的目标(库)中,并为这个目标显式地设置优化级别,而不是附加一个标志,这样可能会更简洁,不过在本示例中,我们的重点是foreach-endforeach。

更多信息
foreach()的四种使用方式:

  • foreach(loop_var arg1 arg2 ...): 其中提供循环变量和显式项列表。当为sources_with_lower_optimization中的项打印编译器标志集时,使用此表单。注意,如果项目列表位于变量中,则必须显式展开它;也就是说,${sources_with_lower_optimization}必须作为参数传递。
  • 通过指定一个范围,可以对整数进行循环,例如:foreach(loop_var range total)foreach(loop_var range start stop [step])
  • 对列表值变量的循环,例如:foreach(loop_var IN LISTS [list1[...]])。参数解释为列表,其内容就会自动展开。
  • 对变量的循环,例如:foreach(loop_var IN ITEMS [item1 [...]])。参数的内容没有展开。

检测环境

检测操作系统

CMake是一组跨平台工具。不过,了解操作系统(OS)上执行配置或构建步骤也很重要。从而与操作系统相关的CMake代码,会根据操作系统启用条件编译,或者在可用或必要时使用特定于编译器的扩展。

具体实施

我们将用一个非常简单的CMakeLists.txt进行演示:

首先,定义CMake最低版本和项目名称。请注意,语言是NONE:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-01 LANGUAGES NONE)

然后,根据检测到的操作系统信息打印消息:

1
2
3
4
5
6
7
8
9
10
11
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
message(STATUS "Configuring on/for Linux")
elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
message(STATUS "Configuring on/for macOS")
elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows")
message(STATUS "Configuring on/for Windows")
elseif(CMAKE_SYSTEM_NAME STREQUAL "AIX")
message(STATUS "Configuring on/for IBM AIX")
else()
message(STATUS "Configuring on/for ${CMAKE_SYSTEM_NAME}")
endif()

测试之前,检查前面的代码块,并考虑相应系统上的具体行为。

现在,测试配置项目:

1
2
3
$ mkdir -p build
$ cd build
$ cmake ..

关于CMake输出,这里有一行很有趣——在Linux系统上(在其他系统上,输出会不同):

1
-- Configuring on/for Linux

工作原理

CMake为目标操作系统定义了CMAKE_SYSTEM_NAME,因此不需要使用定制命令、工具或脚本来查询此信息。然后,可以使用此变量的值实现特定于操作系统的条件和解决方案。在具有uname命令的系统上,将此变量设置为uname -s的输出。该变量在macOS上设置为“Darwin”。在Linux和Windows上,它分别计算为“Linux”和“Windows”。

处理与平台相关的源代码

理想情况下,应该避免依赖于平台的源代码,但是有时我们没有选择,特别是当要求配置和编译不是自己编写的代码时。本示例中,将演示如何使用CMake根据操作系统编译源代码。

准备工作

修改hello-world.cpp示例代码,将第1章第1节的例子进行修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <cstdlib>
#include <iostream>
#include <string>
std::string say_hello() {
#ifdef IS_WINDOWS
return std::string("Hello from Windows!");
#elif IS_LINUX
return std::string("Hello from Linux!");
#elif IS_MACOS
return std::string("Hello from macOS!");
#else
return std::string("Hello from an unknown system!");
#endif
}
int main() {
std::cout << say_hello() << std::endl;
return EXIT_SUCCESS;
}

具体实施

完成一个CMakeLists.txt实例,使我们能够基于目标操作系统有条件地编译源代码:

首先,设置了CMake最低版本、项目名称和支持的语言:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-02 LANGUAGES CXX)

然后,定义可执行文件及其对应的源文件:

1
add_executable(hello-world hello-world.cpp)

通过定义以下目标编译定义,让预处理器知道系统名称:

1
2
3
4
5
6
7
8
9
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
target_compile_definitions(hello-world PUBLIC "IS_LINUX")
endif()
if(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
target_compile_definitions(hello-world PUBLIC "IS_MACOS")
endif()
if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
target_compile_definitions(hello-world PUBLIC "IS_WINDOWS")
endif()

继续之前,先检查前面的表达式,并考虑在不同系统上有哪些行为。

现在,准备测试它,并配置项目:

1
2
3
4
5
6
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./hello-world
Hello from Linux!

Windows系统上,将看到来自Windows的Hello。其他操作系统将产生不同的输出。

工作原理

hello-world.cpp示例中,有趣的部分是基于预处理器定义IS_WINDOWS、IS_LINUX或IS_MACOS的条件编译:

1
2
3
4
5
6
7
8
9
10
11
std::string say_hello() {
#ifdef IS_WINDOWS
return std::string("Hello from Windows!");
#elif IS_LINUX
return std::string("Hello from Linux!");
#elif IS_MACOS
return std::string("Hello from macOS!");
#else
return std::string("Hello from an unknown system!");
#endif
}

这些定义在CMakeLists.txt中配置时定义,通过使用target_compile_definition在预处理阶段使用。可以不重复if-endif语句,以更紧凑的表达式实现,我们将在下一个示例中演示这种重构方式。也可以把if-endif语句加入到一个if-else-else-endif语句中。这个阶段,可以使用add_definitions(-DIS_LINUX)来设置定义(当然,可以根据平台调整定义),而不是使用target_compile_definition。使用add_definitions的缺点是,会修改编译整个项目的定义,而target_compile_definitions给我们机会,将定义限制于一个特定的目标,以及通过PRIVATE|PUBLIC|INTERFACE限定符,限制这些定义可见性。

  • PRIVATE,编译定义将只应用于给定的目标,而不应用于相关的其他目标。
  • INTERFACE,对给定目标的编译定义将只应用于使用它的目标。
  • PUBLIC,编译定义将应用于给定的目标和使用它的所有其他目标。

处理与编译器相关的源代码

这个方法与前面的方法类似,我们将使用CMake来编译依赖于环境的条件源代码:本例将依赖于编译器。为了可移植性,我们尽量避免去编写新代码,但遇到有依赖的情况我们也要去解决,特别是当使用历史代码或处理编译器依赖工具,如sanitizers。

准备工作

本示例中,我们将从C++中的一个示例开始,稍后我们将演示一个Fortran示例,并尝试重构和简化CMake代码。

看一下hello-world.cpp源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <cstdlib>
#include <iostream>
#include <string>
std::string say_hello() {
#ifdef IS_INTEL_CXX_COMPILER
// only compiled when Intel compiler is selected
// such compiler will not compile the other branches
return std::string("Hello Intel compiler!");
#elif IS_GNU_CXX_COMPILER
// only compiled when GNU compiler is selected
// such compiler will not compile the other branches
return std::string("Hello GNU compiler!");
#elif IS_PGI_CXX_COMPILER
// etc.
return std::string("Hello PGI compiler!");
#elif IS_XL_CXX_COMPILER
return std::string("Hello XL compiler!");
#else
return std::string("Hello unknown compiler - have we met before?");
#endif
}
int main() {
std::cout << say_hello() << std::endl;
std::cout << "compiler name is " COMPILER_NAME << std::endl;
return EXIT_SUCCESS;
}

Fortran示例(hello-world.F90):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
program hello
implicit none
#ifdef IS_Intel_FORTRAN_COMPILER
print *, 'Hello Intel compiler!'
#elif IS_GNU_FORTRAN_COMPILER
print *, 'Hello GNU compiler!'
#elif IS_PGI_FORTRAN_COMPILER
print *, 'Hello PGI compiler!'
#elif IS_XL_FORTRAN_COMPILER
print *, 'Hello XL compiler!'
#else
print *, 'Hello unknown compiler - have we met before?'
#endif
end program

具体实施

我们将从C++的例子开始,然后再看Fortran的例子:

CMakeLists.txt文件中,定义了CMake最低版本、项目名称和支持的语言:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-03 LANGUAGES CXX)

然后,定义可执行目标及其对应的源文件:

1
add_executable(hello-world hello-world.cpp)

通过定义以下目标编译定义,让预处理器了解编译器的名称和供应商:

1
2
3
4
5
6
7
8
9
10
11
12
13
target_compile_definitions(hello-world PUBLIC "COMPILER_NAME=\"${CMAKE_CXX_COMPILER_ID}\"")
if(CMAKE_CXX_COMPILER_ID MATCHES Intel)
target_compile_definitions(hello-world PUBLIC "IS_INTEL_CXX_COMPILER")
endif()
if(CMAKE_CXX_COMPILER_ID MATCHES GNU)
target_compile_definitions(hello-world PUBLIC "IS_GNU_CXX_COMPILER")
endif()
if(CMAKE_CXX_COMPILER_ID MATCHES PGI)
target_compile_definitions(hello-world PUBLIC "IS_PGI_CXX_COMPILER")
endif()
if(CMAKE_CXX_COMPILER_ID MATCHES XL)
target_compile_definitions(hello-world PUBLIC "IS_XL_CXX_COMPILER")
endif()

现在我们已经可以预测结果了:

1
2
3
4
5
6
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./hello-world
Hello GNU compiler!

使用不同的编译器,此示例代码将打印不同的问候语。

前一个示例的CMakeLists.txt文件中的if语句似乎是重复的,我们不喜欢重复的语句。能更简洁地表达吗?当然可以!为此,让我们再来看看Fortran示例。

Fortran例子的CMakeLists.txt文件中,我们需要做以下工作:

需要使Fortran语言:

1
project(recipe-03 LANGUAGES Fortran)

然后,定义可执行文件及其对应的源文件。在本例中,使用大写.F90后缀:

1
add_executable(hello-world hello-world.F90)

我们通过定义下面的目标编译定义,让预处理器非常清楚地了解编译器:

1
2
3
target_compile_definitions(hello-world
PUBLIC "IS_${CMAKE_Fortran_COMPILER_ID}_FORTRAN_COMPILER"
)

其余行为与C++示例相同。

工作原理

CMakeLists.txt会在配置时,进行预处理定义,并传递给预处理器。Fortran示例包含非常紧凑的表达式,我们使用CMAKE_Fortran_COMPILER_ID变量,通过target_compile_definition使用构造预处理器进行预处理定义。为了适应这种情况,我们必须将”Intel”从IS_INTEL_CXX_COMPILER更改为IS_Intel_FORTRAN_COMPILER。通过使用相应的CMAKE_C_COMPILER_IDCMAKE_CXX_COMPILER_ID变量,我们可以在C或C++中实现相同的效果。但是,请注意,CMAKE_<LANG>_COMPILER_ID不能保证为所有编译器或语言都定义。

检测处理器体系结构

准备工作

我们以下面的arch-dependent.cpp代码为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <cstdlib>
#include <iostream>
#include <string>
#define STRINGIFY(x) #x
#define TOSTRING(x) STRINGIFY(x)
std::string say_hello()
{
std::string arch_info(TOSTRING(ARCHITECTURE));
arch_info += std::string(" architecture. ");
#ifdef IS_32_BIT_ARCH
return arch_info + std::string("Compiled on a 32 bit host processor.");
#elif IS_64_BIT_ARCH
return arch_info + std::string("Compiled on a 64 bit host processor.");
#else
return arch_info + std::string("Neither 32 nor 64 bit, puzzling ...");
#endif
}
int main()
{
std::cout << say_hello() << std::endl;
return EXIT_SUCCESS;
}

具体实施

CMakeLists.txt文件中,我们需要以下内容:

首先,定义可执行文件及其源文件依赖关系:

1
2
3
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-04 LANGUAGES CXX)
add_executable(arch-dependent arch-dependent.cpp)

检查空指针类型的大小。CMake的CMAKE_SIZEOF_VOID_P变量会告诉我们CPU是32位还是64位。我们通过状态消息让用户知道检测到的大小,并设置预处理器定义:

1
2
3
4
5
6
7
if(CMAKE_SIZEOF_VOID_P EQUAL 8)
target_compile_definitions(arch-dependent PUBLIC "IS_64_BIT_ARCH")
message(STATUS "Target is 64 bits")
else()
target_compile_definitions(arch-dependent PUBLIC "IS_32_BIT_ARCH")
message(STATUS "Target is 32 bits")
endif()

通过定义以下目标编译定义,让预处理器了解主机处理器架构,同时在配置过程中打印状态消息:

1
2
3
4
5
6
7
8
9
10
11
12
if(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "i386")
message(STATUS "i386 architecture detected")
elseif(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "i686")
message(STATUS "i686 architecture detected")
elseif(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "x86_64")
message(STATUS "x86_64 architecture detected")
else()
message(STATUS "host processor architecture is unknown")
endif()
target_compile_definitions(arch-dependent
PUBLIC "ARCHITECTURE=${CMAKE_HOST_SYSTEM_PROCESSOR}"
)

配置项目,并注意状态消息(打印出的信息可能会发生变化):

1
2
3
4
5
6
7
$ mkdir -p build
$ cd build
$ cmake ..
...
-- Target is 64 bits
-- x86_64 architecture detected
...

最后,构建并执行代码(实际输出将取决于处理器架构):

1
2
3
$ cmake --build .
$ ./arch-dependent
x86_64 architecture. Compiled on a 64 bit host processor.

工作原理

CMake定义了CMAKE_HOST_SYSTEM_PROCESSOR变量,以包含当前运行的处理器的名称。可以设置为“i386”、“i686”、“x86_64”、“AMD64”等等,当然,这取决于当前的CPU。CMAKE_SIZEOF_VOID_P为void指针的大小。我们可以在CMake配置时进行查询,以便修改目标或目标编译定义。可以基于检测到的主机处理器体系结构,使用预处理器定义,确定需要编译的分支源代码。

更多信息

除了CMAKE_HOST_SYSTEM_PROCESSOR, CMake还定义了CMAKE_SYSTEM_PROCESSOR变量。前者包含当前运行的CPU在CMake的名称,而后者将包含当前正在为其构建的CPU的名称。这是一个细微的差别,在交叉编译时起着非常重要的作用。另一种让CMake检测主机处理器体系结构,是使用C或C++中定义的符号,结合CMake的try_run函数,尝试构建执行的源代码分支的预处理符号。这将返回已定义错误码,这些错误可以在CMake端捕获

1
2
3
4
5
#if defined(__i386) || defined(__i386__) || defined(_M_IX86)
#error cmake_arch i386
#elif defined(__x86_64) || defined(__x86_64__) || defined(__amd64) || defined(_M_X64)
#error cmake_arch x86_64
#endif

这种策略也是检测目标处理器体系结构的推荐策略,因为CMake似乎没有提供可移植的内在解决方案。另一种选择,将只使用CMake,完全不使用预处理器,代价是为每种情况设置不同的源文件,然后使用target_source命令将其设置为可执行目标arch-dependent依赖的源文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
add_executable(arch-dependent "")
if(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "i386")
message(STATUS "i386 architecture detected")
target_sources(arch-dependent
PRIVATE
arch-dependent-i386.cpp
)
elseif(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "i686")
message(STATUS "i686 architecture detected")
target_sources(arch-dependent
PRIVATE
arch-dependent-i686.cpp
)
elseif(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "x86_64")
message(STATUS "x86_64 architecture detected")
target_sources(arch-dependent
PRIVATE
arch-dependent-x86_64.cpp
)
else()
message(STATUS "host processor architecture is unknown")
endif()

这种方法,显然需要对现有项目进行更多的工作,因为源文件需要分离。此外,不同源文件之间的代码复制肯定也会成为问题。

检测处理器指令集

本示例中,我们将讨论如何在CMake的帮助下检测主机处理器支持的指令集。这个功能是较新版本添加到CMake中的,需要CMake 3.10或更高版本。检测到的主机系统信息,可用于设置相应的编译器标志,或实现可选的源代码编译,或根据主机系统生成源代码。本示例中,我们的目标是检测主机系统信息,使用预处理器定义将其传递给C++源代码,并将信息打印到输出中。

准备工作

我们是C++源码(processor-info.cpp)如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include "config.h"
#include <cstdlib>
#include <iostream>
int main()
{
std::cout << "Number of logical cores: "
<< NUMBER_OF_LOGICAL_CORES << std::endl;
std::cout << "Number of physical cores: "
<< NUMBER_OF_PHYSICAL_CORES << std::endl;
std::cout << "Total virtual memory in megabytes: "
<< TOTAL_VIRTUAL_MEMORY << std::endl;
std::cout << "Available virtual memory in megabytes: "
<< AVAILABLE_VIRTUAL_MEMORY << std::endl;
std::cout << "Total physical memory in megabytes: "
<< TOTAL_PHYSICAL_MEMORY << std::endl;
std::cout << "Available physical memory in megabytes: "
<< AVAILABLE_PHYSICAL_MEMORY << std::endl;
std::cout << "Processor is 64Bit: "
<< IS_64BIT << std::endl;
std::cout << "Processor has floating point unit: "
<< HAS_FPU << std::endl;
std::cout << "Processor supports MMX instructions: "
<< HAS_MMX << std::endl;
std::cout << "Processor supports Ext. MMX instructions: "
<< HAS_MMX_PLUS << std::endl;
std::cout << "Processor supports SSE instructions: "
<< HAS_SSE << std::endl;
std::cout << "Processor supports SSE2 instructions: "
<< HAS_SSE2 << std::endl;
std::cout << "Processor supports SSE FP instructions: "
<< HAS_SSE_FP << std::endl;
std::cout << "Processor supports SSE MMX instructions: "
<< HAS_SSE_MMX << std::endl;
std::cout << "Processor supports 3DNow instructions: "
<< HAS_AMD_3DNOW << std::endl;
std::cout << "Processor supports 3DNow+ instructions: "
<< HAS_AMD_3DNOW_PLUS << std::endl;
std::cout << "IA64 processor emulating x86 : "
<< HAS_IA64 << std::endl;
std::cout << "OS name: "
<< OS_NAME << std::endl;
std::cout << "OS sub-type: "
<< OS_RELEASE << std::endl;
std::cout << "OS build ID: "
<< OS_VERSION << std::endl;
std::cout << "OS platform: "
<< OS_PLATFORM << std::endl;
return EXIT_SUCCESS;
}

其包含config.h头文件,我们将使用config.h.in生成这个文件。config.h.in如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#pragma once
#define NUMBER_OF_LOGICAL_CORES @_NUMBER_OF_LOGICAL_CORES@
#define NUMBER_OF_PHYSICAL_CORES @_NUMBER_OF_PHYSICAL_CORES@
#define TOTAL_VIRTUAL_MEMORY @_TOTAL_VIRTUAL_MEMORY@
#define AVAILABLE_VIRTUAL_MEMORY @_AVAILABLE_VIRTUAL_MEMORY@
#define TOTAL_PHYSICAL_MEMORY @_TOTAL_PHYSICAL_MEMORY@
#define AVAILABLE_PHYSICAL_MEMORY @_AVAILABLE_PHYSICAL_MEMORY@
#define IS_64BIT @_IS_64BIT@
#define HAS_FPU @_HAS_FPU@
#define HAS_MMX @_HAS_MMX@
#define HAS_MMX_PLUS @_HAS_MMX_PLUS@
#define HAS_SSE @_HAS_SSE@
#define HAS_SSE2 @_HAS_SSE2@
#define HAS_SSE_FP @_HAS_SSE_FP@
#define HAS_SSE_MMX @_HAS_SSE_MMX@
#define HAS_AMD_3DNOW @_HAS_AMD_3DNOW@
#define HAS_AMD_3DNOW_PLUS @_HAS_AMD_3DNOW_PLUS@
#define HAS_IA64 @_HAS_IA64@
#define OS_NAME "@_OS_NAME@"
#define OS_RELEASE "@_OS_RELEASE@"
#define OS_VERSION "@_OS_VERSION@"
#define OS_PLATFORM "@_OS_PLATFORM@"

如何实施

我们将使用CMake为平台填充config.h中的定义,并将示例源文件编译为可执行文件:

首先,我们定义了CMake最低版本、项目名称和项目语言:

1
2
cmake_minimum_required(VERSION 3.10 FATAL_ERROR)
project(recipe-05 CXX)

然后,定义目标可执行文件及其源文件,并包括目录:

1
2
3
4
5
6
7
8
9
add_executable(processor-info "")
target_sources(processor-info
PRIVATE
processor-info.cpp
)
target_include_directories(processor-info
PRIVATE
${PROJECT_BINARY_DIR}
)

继续查询主机系统的信息,获取一些关键字:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
foreach(key
IN ITEMS
NUMBER_OF_LOGICAL_CORES
NUMBER_OF_PHYSICAL_CORES
TOTAL_VIRTUAL_MEMORY
AVAILABLE_VIRTUAL_MEMORY
TOTAL_PHYSICAL_MEMORY
AVAILABLE_PHYSICAL_MEMORY
IS_64BIT
HAS_FPU
HAS_MMX
HAS_MMX_PLUS
HAS_SSE
HAS_SSE2
HAS_SSE_FP
HAS_SSE_MMX
HAS_AMD_3DNOW
HAS_AMD_3DNOW_PLUS
HAS_IA64
OS_NAME
OS_RELEASE
OS_VERSION
OS_PLATFORM
)
cmake_host_system_information(RESULT _${key} QUERY ${key})
endforeach()

定义了相应的变量后,配置config.h:

1
configure_file(config.h.in config.h @ONLY)

现在准备好配置、构建和测试项目:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./processor-info
Number of logical cores: 4
Number of physical cores: 2
Total virtual memory in megabytes: 15258
Available virtual memory in megabytes: 14678
Total physical memory in megabytes: 7858
Available physical memory in megabytes: 4072
Processor is 64Bit: 1
Processor has floating point unit: 1
Processor supports MMX instructions: 1
Processor supports Ext. MMX instructions: 0
Processor supports SSE instructions: 1
Processor supports SSE2 instructions: 1
Processor supports SSE FP instructions: 0
Processor supports SSE MMX instructions: 0
Processor supports 3DNow instructions: 0
Processor supports 3DNow+ instructions: 0
IA64 processor emulating x86 : 0
OS name: Linux
OS sub-type: 4.16.7-1-ARCH
OS build ID: #1 SMP PREEMPT Wed May 2 21:12:36 UTC 2018
OS platform: x86_64

输出会随着处理器的不同而变化。

工作原理

CMakeLists.txt中的foreach循环会查询多个键值,并定义相应的变量。此示例的核心函数是cmake_host_system_information,它查询运行CMake的主机系统的系统信息。本例中,我们对每个键使用了一个函数调用。然后,使用这些变量来配置config.h.in中的占位符,输入并生成config.h。此配置使用configure_file命令完成。最后,config.h包含在processor-info.cpp中。编译后,它将把值打印到屏幕上。

为Eigen库使能向量化

处理器的向量功能,可以提高代码的性能。对于某些类型的运算来说尤为甚之,例如:线性代数。本示例将展示如何使能矢量化,以便使用线性代数的Eigen C++库加速可执行文件。

准备工作

我们用Eigen C++模板库,用来进行线性代数计算,并展示如何设置编译器标志来启用向量化。这个示例的源代码linear-algebra.cpp文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <chrono>
#include <iostream>
#include <Eigen/Dense>
EIGEN_DONT_INLINE
double simple_function(Eigen::VectorXd &va, Eigen::VectorXd &vb)
{
// this simple function computes the dot product of two vectors
// of course it could be expressed more compactly
double d = va.dot(vb);
return d;
}
int main()
{
int len = 1000000;
int num_repetitions = 100;
// generate two random vectors
Eigen::VectorXd va = Eigen::VectorXd::Random(len);
Eigen::VectorXd vb = Eigen::VectorXd::Random(len);
double result;
auto start = std::chrono::system_clock::now();
for (auto i = 0; i < num_repetitions; i++)
{
result = simple_function(va, vb);
}
auto end = std::chrono::system_clock::now();
auto elapsed_seconds = end - start;
std::cout << "result: " << result << std::endl;
std::cout << "elapsed seconds: " << elapsed_seconds.count() << std::endl;
}

我们期望向量化可以加快simple_function中的点积操作。

如何实施

根据Eigen库的文档,设置适当的编译器标志就足以生成向量化的代码。让我们看看CMakeLists.txt:

声明一个C++11项目:

1
2
3
4
5
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-06 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

使用Eigen库,我们需要在系统上找到它的头文件:

1
2
3
4
find_package(Eigen3 3.3 REQUIRED CONFIG)
CheckCXXCompilerFlag.cmake标准模块文件:

include(CheckCXXCompilerFlag)

检查-march=native编译器标志是否工作:

1
check_cxx_compiler_flag("-march=native" _march_native_works)

另一个选项-xHost编译器标志也开启:

1
check_cxx_compiler_flag("-xHost" _xhost_works)

设置了一个空变量_CXX_FLAGS,来保存刚才检查的两个编译器中找到的编译器标志。如果看到_march_native_works,我们将_CXX_FLAGS设置为-march=native。如果看到_xhost_works,我们将_CXX_FLAGS设置为-xHost。如果它们都不起作用,_CXX_FLAGS将为空,并禁用矢量化:

1
2
3
4
5
6
7
8
9
10
set(_CXX_FLAGS)
if(_march_native_works)
message(STATUS "Using processor's vector instructions (-march=native compiler flag set)")
set(_CXX_FLAGS "-march=native")
elseif(_xhost_works)
message(STATUS "Using processor's vector instructions (-xHost compiler flag set)")
set(_CXX_FLAGS "-xHost")
else()
message(STATUS "No suitable compiler flag found for vectorization")
endif()

为了便于比较,我们还为未优化的版本定义了一个可执行目标,不使用优化标志:

1
2
3
4
5
add_executable(linear-algebra-unoptimized linear-algebra.cpp)
target_link_libraries(linear-algebra-unoptimized
PRIVATE
Eigen3::Eigen
)

此外,我们定义了一个优化版本:

1
2
3
4
5
6
7
8
9
add_executable(linear-algebra linear-algebra.cpp)
target_compile_options(linear-algebra
PRIVATE
${_CXX_FLAGS}
)
target_link_libraries(linear-algebra
PRIVATE
Eigen3::Eigen
)

让我们比较一下这两个可执行文件——首先我们配置(在本例中,-march=native_works):

1
2
3
4
5
6
7
8
9
$ mkdir -p build
$ cd build
$ cmake ..
...
-- Performing Test _march_native_works
-- Performing Test _march_native_works - Success
-- Performing Test _xhost_works
-- Performing Test _xhost_works - Failed
-- Using processor's vector instructions (-march=native compiler flag set)

最后,让我们编译可执行文件,并比较运行时间:

1
2
3
4
5
6
7
$ cmake --build .
$ ./linear-algebra-unoptimized
result: -261.505
elapsed seconds: 1.97964
$ ./linear-algebra
result: -261.505
elapsed seconds: 1.05048

工作原理

大多数处理器提供向量指令集,代码可以利用这些特性,获得更高的性能。由于线性代数运算可以从Eigen库中获得很好的加速,所以在使用Eigen库时,就要考虑向量化。我们所要做的就是,指示编译器为我们检查处理器,并为当前体系结构生成本机指令。不同的编译器供应商会使用不同的标志来实现这一点:GNU编译器使用-march=native标志来实现这一点,而Intel编译器使用-xHost标志。使用CheckCXXCompilerFlag.cmake模块提供的check_cxx_compiler_flag函数进行编译器标志的检查:

1
check_cxx_compiler_flag("-march=native" _march_native_works)

这个函数接受两个参数:

  • 第一个是要检查的编译器标志。
  • 第二个是用来存储检查结果(true或false)的变量。如果检查为真,我们将工作标志添加到_CXX_FLAGS变量中,该变量将用于为可执行目标设置编译器标志。

检测外部库和程序

检测Python解释器

我们将介绍find_package命令,这个命令将贯穿本章。

具体实施

我们将逐步建立CMakeLists.txt文件:

首先,定义CMake最低版本和项目名称。注意,这里不需要任何语言支持:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-01 LANGUAGES NONE)

然后,使用find_package命令找到Python解释器:

1
find_package(PythonInterp REQUIRED)

然后,执行Python命令并捕获它的输出和返回值:

1
2
3
4
5
6
7
8
execute_process(
COMMAND
${PYTHON_EXECUTABLE} "-c" "print('Hello, world!')"
RESULT_VARIABLE _status
OUTPUT_VARIABLE _hello_world
ERROR_QUIET
OUTPUT_STRIP_TRAILING_WHITESPACE
)

最后,打印Python命令的返回值和输出:

1
2
message(STATUS "RESULT_VARIABLE is: ${_status}")
message(STATUS "OUTPUT_VARIABLE is: ${_hello_world}")

配置项目:

1
2
3
4
5
6
7
8
9
$ mkdir -p build
$ cd build
$ cmake ..
-- Found PythonInterp: /usr/bin/python (found version "3.6.5")
-- RESULT_VARIABLE is: 0
-- OUTPUT_VARIABLE is: Hello, world!
-- Configuring done
-- Generating done
-- Build files have been written to: /home/user/cmake-cookbook/chapter-03/recipe-01/example/build

工作原理

find_package是用于发现和设置包的CMake模块的命令。这些模块包含CMake命令,用于标识系统标准位置中的包。CMake模块文件称为Find<name>.cmake,当调用find_package(<name>)时,模块中的命令将会运行。

除了在系统上实际查找包模块之外,查找模块还会设置了一些有用的变量,反映实际找到了什么,也可以在自己的CMakeLists.txt中使用这些变量。对于Python解释器,相关模块为FindPythonInterp.cmake附带的设置了一些CMake变量:

  • PYTHONINTERP_FOUND:是否找到解释器
  • PYTHON_EXECUTABLE:Python解释器到可执行文件的路径
  • PYTHON_VERSION_STRING:Python解释器的完整版本信息
  • PYTHON_VERSION_MAJOR:Python解释器的主要版本号
  • PYTHON_VERSION_MINOR :Python解释器的次要版本号
  • PYTHON_VERSION_PATCH:Python解释器的补丁版本号

可以强制CMake,查找特定版本的包。例如,要求Python解释器的版本大于或等于2.7:find_package(PythonInterp 2.7)

可以强制满足依赖关系:

1
find_package(PythonInterp REQUIRED)

如果在查找位置中没有找到适合Python解释器的可执行文件,CMake将中止配置。

软件包没有安装在标准位置时,CMake无法正确定位它们。用户可以使用CLI的-D参数传递相应的选项,告诉CMake查看特定的位置。Python解释器可以使用以下配置:

1
$ cmake -D PYTHON_EXECUTABLE=/custom/location/python ..

这将指定非标准/custom/location/python安装目录中的Python可执行文件。

每个包都是不同的,Find<package>.cmake模块试图提供统一的检测接口。。

除了检测包之外,我们还想提到一个便于打印变量的helper模块。本示例中,我们使用了以下方法:

1
2
message(STATUS "RESULT_VARIABLE is: ${_status}")
message(STATUS "OUTPUT_VARIABLE is: ${_hello_world}")

使用以下工具进行调试:

1
2
include(CMakePrintHelpers)
cmake_print_variables(_status _hello_world)

将产生以下输出:

1
-- _status="0" ; _hello_world="Hello, world!"

检测Python库

可以使用Python工具来分析和操作程序的输出。然而,还有更强大的方法可以将解释语言(如Python)与编译语言(如C或C++)组合在一起使用。一种是扩展Python,通过编译成共享库的C或C++模块在这些类型上提供新类型和新功能,这是第9章的主题。另一种是将Python解释器嵌入到C或C++程序中。两种方法都需要下列条件:

  • Python解释器的工作版本
  • Python头文件Python.h的可用性
  • Python运行时库libpython

三个组件所使用的Python版本必须相同。我们已经演示了如何找到Python解释器;本示例中,我们将展示另外两种方式。

准备工作

我们将一个简单的Python代码,嵌入到C程序中,可以在Python文档页面上找到。源文件称为hello-embedded-python.c:

1
2
3
4
5
6
7
8
9
#include <Python.h>
int main(int argc, char *argv[]) {
Py_SetProgramName(argv[0]); /* optional but recommended */
Py_Initialize();
PyRun_SimpleString("from time import time,ctime\n"
"print 'Today is',ctime(time())\n");
Py_Finalize();
return 0;
}

此代码将在程序中初始化Python解释器的实例,并使用Python的time模块,打印日期。

具体实施

以下是CMakeLists.txt中的步骤:

包含CMake最低版本、项目名称和所需语言:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-02 LANGUAGES C)

使用C99标准,这不严格要求与Python链接,但有时你可能需要对Python进行连接:

1
2
3
set(CMAKE_C_STANDARD 99)
set(CMAKE_C_EXTENSIONS OFF)
set(CMAKE_C_STANDARD_REQUIRED ON)

找到Python解释器。这是一个REQUIRED依赖:

1
find_package(PythonInterp REQUIRED)

找到Python头文件和库的模块,称为FindPythonLibs.cmake:

1
find_package(PythonLibs ${PYTHON_VERSION_MAJOR}.${PYTHON_VERSION_MINOR} EXACT REQUIRED)

使用hello-embedded-python.c源文件,添加一个可执行目标:

1
add_executable(hello-embedded-python hello-embedded-python.c)

可执行文件包含Python.h头文件。因此,这个目标的include目录必须包含Python的include目录,可以通过PYTHON_INCLUDE_DIRS变量进行指定:

1
2
3
4
target_include_directories(hello-embedded-python
PRIVATE
${PYTHON_INCLUDE_DIRS}
)

最后,将可执行文件链接到Python库,通过PYTHON_LIBRARIES变量访问:

1
2
3
4
target_link_libraries(hello-embedded-python
PRIVATE
${PYTHON_LIBRARIES}
)

现在,进行构建:

1
2
3
4
5
6
$ mkdir -p build
$ cd build
$ cmake ..
...
-- Found PythonInterp: /usr/bin/python (found version "3.6.5")
-- Found PythonLibs: /usr/lib/libpython3.6m.so (found suitable exact version "3.6.5")

最后,执行构建,并运行可执行文件:

1
2
3
$ cmake --build .
$ ./hello-embedded-python
Today is Thu Jun 7 22:26:02 2018

工作原理

FindPythonLibs.cmake模块将查找Python头文件和库的标准位置。由于,我们的项目需要这些依赖项,如果没有找到这些依赖项,将停止配置,并报出错误。

注意,我们显式地要求CMake检测安装的Python可执行文件。这是为了确保可执行文件、头文件和库都有一个匹配的版本。这对于不同版本,可能在运行时导致崩溃。我们通过FindPythonInterp.cmake中定义的PYTHON_VERSION_MAJORPYTHON_VERSION_MINOR来实现:

1
2
find_package(PythonInterp REQUIRED)
find_package(PythonLibs ${PYTHON_VERSION_MAJOR}.${PYTHON_VERSION_MINOR} EXACT REQUIRED)

使用EXACT关键字,限制CMake检测特定的版本,在本例中是匹配的相应Python版本的包括文件和库。我们可以使用PYTHON_VERSION_STRING变量,进行更接近的匹配:

1
2
find_package(PythonInterp REQUIRED)
find_package(PythonLibs ${PYTHON_VERSION_STRING} EXACT REQUIRED)

更多信息

当Python不在标准安装目录中,我们如何确定Python头文件和库的位置是正确的?对于Python解释器,可以通过CLI的-D选项传递PYTHON_LIBRARY和PYTHON_INCLUDE_DIR选项来强制CMake查找特定的目录。这些选项指定了以下内容:

  • PYTHON_LIBRARY:指向Python库的路径
  • PYTHON_INCLUDE_DIR:Python.h所在的路径

这样,就能获得所需的Python版本。

有时需要将-D PYTHON_EXECUTABLE-D PYTHON_LIBRARY-D PYTHON_INCLUDE_DIR传递给CMake CLI,以便找到及定位相应的版本的组件。

检测Python模块和包

依赖于Python模块或包的项目中,确定满足对这些Python模块的依赖非常重要。本示例将展示如何探测用户的环境,以找到特定的Python模块和包。

准备工作

我们将尝试在C++程序中嵌入一个稍微复杂一点的例子。这个示例再次引用Python在线文档,并展示了如何通过调用编译后的C++可执行文件,来执行用户定义的Python模块中的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#include <Python.h>
int main(int argc, char* argv[]) {
PyObject* pName, * pModule, * pDict, * pFunc;
PyObject* pArgs, * pValue;
int i;
if (argc < 3) {
fprintf(stderr, "Usage: pure-embedding pythonfile funcname [args]\n");
return 1;
}
Py_Initialize();
PyRun_SimpleString("import sys");
PyRun_SimpleString("sys.path.append(\".\")");
pName = PyUnicode_DecodeFSDefault(argv[1]);
/* Error checking of pName left out */
pModule = PyImport_Import(pName);
Py_DECREF(pName);
if (pModule != NULL) {
pFunc = PyObject_GetAttrString(pModule, argv[2]);
/* pFunc is a new reference */
if (pFunc && PyCallable_Check(pFunc)) {
pArgs = PyTuple_New(argc - 3);
for (i = 0; i < argc - 3; ++i) {
pValue = PyLong_FromLong(atoi(argv[i + 3]));
if (!pValue) {
Py_DECREF(pArgs);
Py_DECREF(pModule);
fprintf(stderr, "Cannot convert argument\n");
return 1;
}
/* pValue reference stolen here: */
PyTuple_SetItem(pArgs, i, pValue);
}
pValue = PyObject_CallObject(pFunc, pArgs);
Py_DECREF(pArgs);
if (pValue != NULL) {
printf("Result of call: %ld\n", PyLong_AsLong(pValue));
Py_DECREF(pValue);
}
else {
Py_DECREF(pFunc);
Py_DECREF(pModule);
PyErr_Print();
fprintf(stderr, "Call failed\n");
return 1;
}
}
else {
if (PyErr_Occurred())
PyErr_Print();
fprintf(stderr, "Cannot find function \"%s\"\n", argv[2]);
}
Py_XDECREF(pFunc);
Py_DECREF(pModule);
}
else {
PyErr_Print();
fprintf(stderr, "Failed to load \"%s\"\n", argv[1]);
return 1;
}
Py_Finalize();
return 0;
}

我们希望嵌入的Python代码(use_numpy.py)使用NumPy设置一个矩阵,所有矩阵元素都为1.0:

1
2
3
4
5
6
7
8
import numpy as np
def print_ones(rows, cols):
A = np.ones(shape=(rows, cols), dtype=float)
print(A)
# we return the number of elements to verify
# that the C++ code is able to receive return values
num_elements = rows*cols
return(num_elements)

具体实施

下面的代码中,我们能够使用CMake检查NumPy是否可用。我们需要确保Python解释器、头文件和库在系统上是可用的。然后,将再来确认NumPy的可用性:

首先,我们定义了最低CMake版本、项目名称、语言和C++标准:

1
2
3
4
5
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-03 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

查找解释器、头文件和库的方法与前面的方法完全相同:

1
2
find_package(PythonInterp REQUIRED)
find_package(PythonLibs ${PYTHON_VERSION_MAJOR}.${PYTHON_VERSION_MINOR} EXACT REQUIRED)

正确打包的Python模块,指定安装位置和版本。可以在CMakeLists.txt中执行Python脚本进行探测:

1
2
3
4
5
6
7
8
execute_process(
COMMAND
${PYTHON_EXECUTABLE} "-c" "import re, numpy; print(re.compile('/__init__.py.*').sub('',numpy.__file__))"
RESULT_VARIABLE _numpy_status
OUTPUT_VARIABLE _numpy_location
ERROR_QUIET
OUTPUT_STRIP_TRAILING_WHITESPACE
)

如果找到NumPy,则_numpy_status变量为整数,否则为错误的字符串,而_numpy_location将包含NumPy模块的路径。如果找到NumPy,则将它的位置保存到一个名为NumPy的新变量中。注意,新变量被缓存,这意味着CMake创建了一个持久性变量,用户稍后可以修改该变量:

1
2
3
if(NOT _numpy_status)
set(NumPy ${_numpy_location} CACHE STRING "Location of NumPy")
endif()

下一步是检查模块的版本。同样,我们在CMakeLists.txt中施加了一些Python魔法,将版本保存到_numpy_version变量中:

1
2
3
4
5
6
7
execute_process(
COMMAND
${PYTHON_EXECUTABLE} "-c" "import numpy; print(numpy.__version__)"
OUTPUT_VARIABLE _numpy_version
ERROR_QUIET
OUTPUT_STRIP_TRAILING_WHITESPACE
)

最后,FindPackageHandleStandardArgs的CMake包以正确的格式设置NumPy_FOUND变量和输出信息:

1
2
3
4
5
6
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(NumPy
FOUND_VAR NumPy_FOUND
REQUIRED_VARS NumPy
VERSION_VAR _numpy_version
)

一旦正确的找到所有依赖项,我们就可以编译可执行文件,并将其链接到Python库:

1
2
3
4
5
6
7
8
9
10
11
12
13
add_executable(pure-embedding "")
target_sources(pure-embedding
PRIVATE
Py${PYTHON_VERSION_MAJOR}-pure-embedding.cpp
)
target_include_directories(pure-embedding
PRIVATE
${PYTHON_INCLUDE_DIRS}
)
target_link_libraries(pure-embedding
PRIVATE
${PYTHON_LIBRARIES}
)

我们还必须保证use_numpy.py在build目录中可用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
add_custom_command(
OUTPUT
${CMAKE_CURRENT_BINARY_DIR}/use_numpy.py
COMMAND
${CMAKE_COMMAND} -E copy_if_different ${CMAKE_CURRENT_SOURCE_DIR}/use_numpy.py
${CMAKE_CURRENT_BINARY_DIR}/use_numpy.py
DEPENDS
${CMAKE_CURRENT_SOURCE_DIR}/use_numpy.py
)
# make sure building pure-embedding triggers the above custom command
target_sources(pure-embedding
PRIVATE
${CMAKE_CURRENT_BINARY_DIR}/use_numpy.py
)

现在,我们可以测试嵌入的代码:

1
2
3
4
5
6
7
8
9
10
11
12
$ mkdir -p build
$ cd build
$ cmake ..
-- ...
-- Found PythonInterp: /usr/bin/python (found version "3.6.5")
-- Found PythonLibs: /usr/lib/libpython3.6m.so (found suitable exact version "3.6.5")
-- Found NumPy: /usr/lib/python3.6/site-packages/numpy (found version "1.14.3")
$ cmake --build .
$ ./pure-embedding use_numpy print_ones 2 3
[[1. 1. 1.]
[1. 1. 1.]]
Result of call: 6

工作原理

例子中有三个新的CMake命令,需要include(FindPackageHandleStandardArgs)

  • execute_process
  • add_custom_command
  • find_package_handle_standard_args

execute_process将作为通过子进程执行一个或多个命令。最后,子进程返回值将保存到变量作为参数,传递给RESULT_VARIABLE,而管道标准输出和标准错误的内容将被保存到变量作为参数传递给OUTPUT_VARIABLE和ERROR_VARIABLE。execute_process可以执行任何操作,并使用它们的结果来推断系统配置。本例中,用它来确保NumPy可用,然后获得模块版本。

find_package_handle_standard_args提供了,用于处理与查找相关程序和库的标准工具。引用此命令时,可以正确的处理与版本相关的选项(REQUIRED和EXACT),而无需更多的CMake代码。稍后将介绍QUIET和COMPONENTS选项。本示例中,使用了以下方法:

1
2
3
4
5
6
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(NumPy
FOUND_VAR NumPy_FOUND
REQUIRED_VARS NumPy
VERSION_VAR _numpy_version
)

所有必需的变量都设置为有效的文件路径(NumPy)后,发送到模块(NumPy_FOUND)。它还将版本保存在可传递的版本变量(_numpy_version)中并打印:

1
-- Found NumPy: /usr/lib/python3.6/site-packages/numpy (found version "1.14.3")

目前的示例中,没有进一步使用这些变量。如果返回NumPy_FOUND为FALSE,则停止配置。

最后,将use_numpy.py复制到build目录,对代码进行注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
add_custom_command(
OUTPUT
${CMAKE_CURRENT_BINARY_DIR}/use_numpy.py
COMMAND
${CMAKE_COMMAND} -E copy_if_different ${CMAKE_CURRENT_SOURCE_DIR}/use_numpy.py
${CMAKE_CURRENT_BINARY_DIR}/use_numpy.py
DEPENDS
${CMAKE_CURRENT_SOURCE_DIR}/use_numpy.py
)
target_sources(pure-embedding
PRIVATE
${CMAKE_CURRENT_BINARY_DIR}/use_numpy.py
)

我们也可以使用file(COPY…)命令来实现复制。这里,我们选择使用add_custom_command,来确保文件在每次更改时都会被复制,而不仅仅是第一次运行配置时。还要注意target_sources命令,它将依赖项添加到${CMAKE_CURRENT_BINARY_DIR}/use_numpy.py;这样做是为了确保构建目标,能够触发之前的命令。

检测BLAS和LAPACK数学库

虽然用于数学库底层实现实际所用的编程语言会随着时间而变化(Fortran、C、Assembly),但是也都是Fortran调用接口。本示例中的任务要链接到这些库,并展示如何用不同语言编写的库。

准备工作

为了展示数学库的检测和连接,我们编译一个C++程序,将矩阵的维数作为命令行输入,生成一个随机的方阵A,一个随机向量b,并计算线性系统方程: Ax = b。另外,将对向量b的进行随机缩放。这里,需要使用的子程序是BLAS中的DSCAL和LAPACK中的DGESV来求线性方程组的解。示例C++代码的清单( linear-algebra.cpp):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include "CxxBLAS.hpp"
#include "CxxLAPACK.hpp"
#include <iostream>
#include <random>
#include <vector>
int main(int argc, char** argv) {
if (argc != 2) {
std::cout << "Usage: ./linear-algebra dim" << std::endl;
return EXIT_FAILURE;
}
// Generate a uniform distribution of real number between -1.0 and 1.0
std::random_device rd;
std::mt19937 mt(rd());
std::uniform_real_distribution<double> dist(-1.0, 1.0);
// Allocate matrices and right-hand side vector
int dim = std::atoi(argv[1]);
std::vector<double> A(dim * dim);
std::vector<double> b(dim);
std::vector<int> ipiv(dim);
// Fill matrix and RHS with random numbers between -1.0 and 1.0
for (int r = 0; r < dim; r++) {
for (int c = 0; c < dim; c++) {
A[r + c * dim] = dist(mt);
}
b[r] = dist(mt);
}
// Scale RHS vector by a random number between -1.0 and 1.0
C_DSCAL(dim, dist(mt), b.data(), 1);
std::cout << "C_DSCAL done" << std::endl;
// Save matrix and RHS
std::vector<double> A1(A);
std::vector<double> b1(b);
int info;
info = C_DGESV(dim, 1, A.data(), dim, ipiv.data(), b.data(), dim);
std::cout << "C_DGESV done" << std::endl;
std::cout << "info is " << info << std::endl;
double eps = 0.0;
for (int i = 0; i < dim; ++i) {
double sum = 0.0;
for (int j = 0; j < dim; ++j)
sum += A1[i + j * dim] * b[j];
eps += std::abs(b1[i] - sum);
}
std::cout << "check is " << eps << std::endl;
return 0;
}

使用C++11的随机库来生成-1.0到1.0之间的随机分布。C_DSCALC_DGESV分别是到BLAS和LAPACK库的接口。为了避免名称混淆,将在下面来进一步讨论CMake模块:

文件CxxBLAS.hpp用extern “C”封装链接BLAS:

1
2
3
4
5
6
7
8
9
10
11
#pragma once
#include "fc_mangle.h"
#include <cstddef>
#ifdef __cplusplus
extern "C" {
#endif
extern void DSCAL(int *n, double *alpha, double *vec, int *inc);
#ifdef __cplusplus
}
#endif
void C_DSCAL(size_t length, double alpha, double *vec, int inc);

对应的实现文件CxxBLAS.cpp:

1
2
3
4
5
6
7
8
9
10
11
12
#include "CxxBLAS.hpp"
#include <climits>
// see http://www.netlib.no/netlib/blas/dscal.f
void C_DSCAL(size_t length, double alpha, double *vec, int inc) {
int big_blocks = (int)(length / INT_MAX);
int small_size = (int)(length % INT_MAX);
for (int block = 0; block <= big_blocks; block++) {
double *vec_s = &vec[block * inc * (size_t)INT_MAX];
signed int length_s = (block == big_blocks) ? small_size : INT_MAX;
::DSCAL(&length_s, &alpha, vec_s, &inc);
}
}

CxxLAPACK.hpp和CxxLAPACK.cpp为LAPACK调用执行相应的转换。

具体实施

对应的CMakeLists.txt包含以下构建块:

我们定义了CMake最低版本,项目名称和支持的语言:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-04 LANGUAGES CXX C Fortran)

使用C++11标准:

1
2
3
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

此外,我们验证Fortran和C/C++编译器是否能协同工作,并生成头文件,这个文件可以处理名称混乱。两个功能都由FortranCInterface模块提供:

1
2
3
4
5
6
7
include(FortranCInterface)
FortranCInterface_VERIFY(CXX)
FortranCInterface_HEADER(
fc_mangle.h
MACRO_NAMESPACE "FC_"
SYMBOLS DSCAL DGESV
)

然后,找到BLAS和LAPACK:

1
2
find_package(BLAS REQUIRED)
find_package(LAPACK REQUIRED)

接下来,添加一个库,其中包含BLAS和LAPACK包装器的源代码,并链接到LAPACK_LIBRARIES,其中也包含BLAS_LIBRARIES:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
add_library(math "")
target_sources(math
PRIVATE
CxxBLAS.cpp
CxxLAPACK.cpp
)
target_include_directories(math
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_BINARY_DIR}
)
target_link_libraries(math
PUBLIC
${LAPACK_LIBRARIES}
)

注意,目标的包含目录和链接库声明为PUBLIC,因此任何依赖于数学库的附加目标也将在其包含目录中。

最后,我们添加一个可执行目标并链接math:

1
2
3
4
5
6
7
8
9
add_executable(linear-algebra "")
target_sources(linear-algebra
PRIVATE
linear-algebra.cpp
)
target_link_libraries(linear-algebra
PRIVATE
math
)

配置时,我们可以关注相关的打印输出:

1
2
3
4
5
6
7
8
9
10
11
12
$ mkdir -p build
$ cd build
$ cmake ..
...
-- Detecting Fortran/C Interface
-- Detecting Fortran/C Interface - Found GLOBAL and MODULE mangling
-- Verifying Fortran/C Compiler Compatibility
-- Verifying Fortran/C Compiler Compatibility - Success
...
-- Found BLAS: /usr/lib/libblas.so
...
-- A library with LAPACK API found.

最后,构建并测试可执行文件:

1
2
3
4
5
6
$ cmake --build .
$ ./linear-algebra 1000
C_DSCAL done
C_DGESV done
info is 0
check is 1.54284e-10

工作原理

FindBLAS.cmake和FindLAPACK.cmake将在标准位置查找BLAS和LAPACK库。对于前者,该模块有SGEMM函数的Fortran实现,一般用于单精度矩阵乘积。对于后者,该模块有CHEEV函数的Fortran实现,用于计算复杂厄米矩阵的特征值和特征向量。查找在CMake内部,通过编译一个小程序来完成,该程序调用这些函数,并尝试链接到候选库。如果失败,则表示相应库不存于系统上。

生成机器码时,每个编译器都会处理符号混淆,不幸的是,这种操作并不通用,而与编译器相关。为了解决这个问题,我们使用FortranCInterface模块验证Fortran和C/C++能否混合编译,然后生成一个Fortran-C接口头文件fc_mangle.h,这个文件用来解决编译器性的问题。然后,必须将生成的fc_mann .h包含在接口头文件CxxBLAS.hpp和CxxLAPACK.hpp中。为了使用FortranCInterface,我们需要在LANGUAGES列表中添加C和Fortran支持。当然,也可以定义自己的预处理器定义,但是可移植性会差很多。

检测OpenMP的并行环境

本示例中,我们将展示如何编译一个包含OpenMP指令的程序(前提是使用一个支持OpenMP的编译器)。有许多支持OpenMP的Fortran、C和C++编译器。对于相对较新的CMake版本,为OpenMP提供了非常好的支持。本示例将展示如何在使用CMake 3.9或更高版本时,使用简单C++和Fortran程序来链接到OpenMP。

准备工作

C和C++程序可以通过包含omp.h头文件和链接到正确的库,来使用OpenMP功能。编译器将在性能关键部分之前添加预处理指令,并生成并行代码。在本示例中,我们将构建以下示例源代码(example.cpp)。这段代码从1到N求和,其中N作为命令行参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>
#include <omp.h>
#include <string>
int main(int argc, char *argv[])
{
std::cout << "number of available processors: " << omp_get_num_procs()
<< std::endl;
std::cout << "number of threads: " << omp_get_max_threads() << std::endl;
auto n = std::stol(argv[1]);
std::cout << "we will form sum of numbers from 1 to " << n << std::endl;
// start timer
auto t0 = omp_get_wtime();
auto s = 0LL;
#pragma omp parallel for reduction(+ : s)
for (auto i = 1; i <= n; i++)
{
s += i;
}
// stop timer
auto t1 = omp_get_wtime();
std::cout << "sum: " << s << std::endl;
std::cout << "elapsed wall clock time: " << t1 - t0 << " seconds" << std::endl;
return 0;
}

在Fortran语言中,需要使用omp_lib模块并链接到库。在性能关键部分之前的代码注释中,可以再次使用并行指令。例如:F90需要包含以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
program example
use omp_lib
implicit none
integer(8) :: i, n, s
character(len=32) :: arg
real(8) :: t0, t1
print *, "number of available processors:", omp_get_num_procs()
print *, "number of threads:", omp_get_max_threads()
call get_command_argument(1, arg)
read(arg , *) n
print *, "we will form sum of numbers from 1 to", n
! start timer
t0 = omp_get_wtime()
s = 0
!$omp parallel do reduction(+:s)
do i = 1, n
s = s + i
end do
! stop timer
t1 = omp_get_wtime()
print *, "sum:", s
print *, "elapsed wall clock time (seconds):", t1 - t0
end program

具体实施

对于C++和Fortran的例子,CMakeLists.txt将遵循一个模板,该模板在这两种语言上很相似:

两者都定义了CMake最低版本、项目名称和语言(CXX或Fortran;我们将展示C++版本):

1
2
cmake_minimum_required(VERSION 3.9 FATAL_ERROR)
project(recipe-05 LANGUAGES CXX)

使用C++11标准:

1
2
3
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

调用find_package来搜索OpenMP:

1
find_package(OpenMP REQUIRED)

最后,我们定义可执行目标,并链接到FindOpenMP模块提供的导入目标(在Fortran的情况下,我们链接到OpenMP::OpenMP_Fortran):

1
2
3
4
5
add_executable(example example.cpp)
target_link_libraries(example
PUBLIC
OpenMP::OpenMP_CXX
)

现在,可以配置和构建代码了:

1
2
3
4
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .

并行测试(在本例中使用了4个内核):

1
2
3
4
5
6
$ ./example 1000000000
number of available processors: 4
number of threads: 4
we will form sum of numbers from 1 to 1000000000
sum: 500000000500000000
elapsed wall clock time: 1.08343 seconds

为了比较,我们可以重新运行这个例子,并将OpenMP线程的数量设置为1:

1
2
3
4
5
6
$ env OMP_NUM_THREADS=1 ./example 1000000000
number of available processors: 4
number of threads: 1
we will form sum of numbers from 1 to 1000000000
sum: 500000000500000000
elapsed wall clock time: 2.96427 seconds

工作原理

我们的示例很简单:编译代码,并运行在多个内核上时,我们会看到加速效果。加速效果并不是OMP_NUM_THREADS的倍数,不过本示例中并不关心,因为我们更关注的是如何使用CMake配置需要使用OpenMP的项目。我们发现链接到OpenMP非常简单,这要感谢FindOpenMP模块:

target_link_libraries(example
PUBLIC
OpenMP::OpenMP_CXX
)
我们不关心编译标志或包含目录——这些设置和依赖项是在OpenMP::OpenMP_CXX中定义的(IMPORTED类型)。如第1章第3节中提到的,IMPORTED库是伪目标,它完全是我们自己项目的外部依赖项。要使用OpenMP,需要设置一些编译器标志,包括目录和链接库。所有这些都包含在OpenMP::OpenMP_CXX的属性上,并通过使用target_link_libraries命令传递给example。这使得在CMake中,使用库变得非常容易。我们可以使用cmake_print_properties命令打印接口的属性,该命令由CMakePrintHelpers.CMake模块提供:

1
2
3
4
5
6
7
8
9
include(CMakePrintHelpers)
cmake_print_properties(
TARGETS
OpenMP::OpenMP_CXX
PROPERTIES
INTERFACE_COMPILE_OPTIONS
INTERFACE_INCLUDE_DIRECTORIES
INTERFACE_LINK_LIBRARIES
)

所有属性都有INTERFACE_前缀,因为这些属性对所需目标,需要以接口形式提供,并且目标以接口的方式使用OpenMP。

对于低于3.9的CMake版本:

1
2
3
4
5
6
7
8
9
add_executable(example example.cpp)
target_compile_options(example
PUBLIC
${OpenMP_CXX_FLAGS}
)
set_target_properties(example
PROPERTIES
LINK_FLAGS ${OpenMP_CXX_FLAGS}
)

检测MPI的并行环境

本示例,将展示如何在系统上找到合适的MPI实现,从而编译一个简单的“Hello, World”MPI例程。

准备工作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>
#include <mpi.h>
int main(int argc, char **argv)
{
// Initialize the MPI environment. The two arguments to MPI Init are not
// currently used by MPI implementations, but are there in case future
// implementations might need the arguments.
MPI_Init(NULL, NULL);
// Get the number of processes
int world_size;
MPI_Comm_size(MPI_COMM_WORLD, &world_size);
// Get the rank of the process
int world_rank;
MPI_Comm_rank(MPI_COMM_WORLD, &world_rank);
// Get the name of the processor
char processor_name[MPI_MAX_PROCESSOR_NAME];
int name_len;
MPI_Get_processor_name(processor_name, &name_len);
// Print off a hello world message
std::cout << "Hello world from processor " << processor_name << ", rank "
<< world_rank << " out of " << world_size << " processors" << std::endl;
// Finalize the MPI environment. No more MPI calls can be made after this
MPI_Finalize();
}

具体实施

这个示例中,我们先查找MPI实现:库、头文件、编译器包装器和启动器。为此,我们将用到FindMPI.cmake标准CMake模块:

首先,定义了CMake最低版本、项目名称、支持的语言和语言标准:

1
2
3
4
5
cmake_minimum_required(VERSION 3.9 FATAL_ERROR)
project(recipe-06 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

然后,调用find_package来定位MPI:

1
find_package(MPI REQUIRED)

与前面的配置类似,定义了可执行文件的的名称和相关源码,并链接到目标:

1
2
3
4
5
add_executable(hello-mpi hello-mpi.cpp)
target_link_libraries(hello-mpi
PUBLIC
MPI::MPI_CXX
)

配置和构建可执行文件:

1
2
3
4
5
6
7
8
$ mkdir -p build
$ cd build
$ cmake ..
-- ...
-- Found MPI_CXX: /usr/lib/openmpi/libmpi_cxx.so (found version "3.1")
-- Found MPI: TRUE (found version "3.1")
-- ...
$ cmake --build .

为了并行执行这个程序,我们使用mpirun启动器(本例中,启动了两个任务):

1
2
3
$ mpirun -np 2 ./hello-mpi
Hello world from processor larry, rank 1 out of 2 processors
Hello world from processor larry, rank 0 out of 2 processors

工作原理

请记住,编译包装器是对MPI库编译器的封装。底层实现中,将会调用相同的编译器,并使用额外的参数(如成功构建并行程序所需的头文件包含路径和库)来扩充它。

编译和链接源文件时,包装器用了哪些标志?我们可以使用–showme选项来查看。要找出编译器的标志,我们可以这样使用:

1
2
$ mpicxx --showme:compile
-pthread

为了找出链接器标志,我们可以这样:

1
2
$ mpicxx --showme:link
-pthread -Wl,-rpath -Wl,/usr/lib/openmpi -Wl,--enable-new-dtags -L/usr/lib/openmpi -lmpi_cxx -lmpi

与之前的OpenMP配置类似,我们发现到MPI的链接非常简单,这要归功于FindMPI模块提供的目标:

正如在前面的配方中所讨论的,对于CMake版本低于3.9,需要更多的工作量:

1
2
3
4
5
6
7
8
9
10
11
12
13
add_executable(hello-mpi hello-mpi.c)
target_compile_options(hello-mpi
PUBLIC
${MPI_CXX_COMPILE_FLAGS}
)
target_include_directories(hello-mpi
PUBLIC
${MPI_CXX_INCLUDE_PATH}
)
target_link_libraries(hello-mpi
PUBLIC
${MPI_CXX_LIBRARIES}
)

检测外部库:Ⅰ. 使用pkg-config

目前为止,我们已经讨论了两种检测外部依赖关系的方法:

使用CMake自带的find-module,但并不是所有的包在CMake的find模块都找得到。
使用<package>Config.cmake, <package>ConfigVersion.cmake<package>Targets.cmake,这些文件由软件包供应商提供,并与软件包一起安装在标准位置的cmake文件夹下。

如果某个依赖项既不提供查找模块,也不提供供应商打包的CMake文件,该怎么办?在这种情况下,我们只有两个选择:

  • 依赖pkg-config程序,来找到系统上的包。这依赖于包供应商在.pc配置文件中,其中有关于发行包的元数据。
  • 为依赖项编写自己的find-package模块。

本示例中,将展示如何利用CMake中的pkg-config来定位ZeroMQ消息库。下一个示例中,将编写一个find模块,展示如何为ZeroMQ编写属于自己find模块。

准备工作

我们构建的代码来自ZeroMQ手册 http://zguide.zeromq.org/page:all 的示例。由两个源文件hwserver.c和hwclient.c组成,这两个源文件将构建为两个独立的可执行文件。执行时,它们将打印“Hello, World”。

具体实施

这是一个C项目,我们将使用C99标准,逐步构建CMakeLists.txt文件:

声明一个C项目,并要求符合C99标准:

1
2
3
4
5
cmake_minimum_required(VERSION 3.6 FATAL_ERROR)
project(recipe-09 LANGUAGES C)
set(CMAKE_C_STANDARD 99)
set(CMAKE_C_EXTENSIONS OFF)
set(CMAKE_C_STANDARD_REQUIRED ON)

使用CMake附带的find-module,查找pkg-config。这里在find_package中传递了QUIET参数。只有在没有找到pkg-config时,CMake才会报错:

1
find_package(PkgConfig REQUIRED QUIET)

找到pkg-config时,我们将使用pkg_search_module函数,以搜索任何附带包配置.pc文件的库或程序。该示例中,我们查找ZeroMQ库:

1
2
3
4
5
6
pkg_search_module(
ZeroMQ
REQUIRED
libzeromq libzmq lib0mq
IMPORTED_TARGET
)

如果找到ZeroMQ库,则打印状态消息:

1
2
3
if(TARGET PkgConfig::ZeroMQ)
message(STATUS "Found ZeroMQ")
endif()

然后,添加两个可执行目标,并链接到ZeroMQ。这将自动设置包括目录和链接库:

1
2
3
4
add_executable(hwserver hwserver.c)
target_link_libraries(hwserver PkgConfig::ZeroMQ)
add_executable(hwclient hwclient.c)
target_link_libraries(hwclient PkgConfig::ZeroMQ)

现在,我们可以配置和构建示例:

1
2
3
4
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .

在终端中,启动服务器,启动时会输出类似于本例的消息:

1
Current 0MQ version is 4.2.2

然后,在另一个终端启动客户端,它将打印如下内容:

1
2
3
4
5
6
Connecting to hello world server…
Sending Hello 0…
Received World 0
Sending Hello 1…
Received World 1
Sending Hello 2…

当找到pkg-config时, CMake需要提供两个函数,来封装这个程序提供的功能:

1
2
pkg_check_modules,查找传递列表中的所有模块(库和/或程序)
pkg_search_module,要在传递的列表中找到第一个工作模块

与find_package一样,这些函数接受REQUIRED和QUIET参数。更详细地说,我们对pkg_search_module的调用如下:

1
2
3
4
5
6
pkg_search_module(
ZeroMQ
REQUIRED
libzeromq libzmq lib0mq
IMPORTED_TARGET
)

这里,第一个参数是前缀,它将用于命名存储搜索ZeroMQ库结果的目标:PkgConfig::ZeroMQ。注意,我们需要为系统上的库名传递不同的选项:libzeromq、libzmq和lib0mq。这是因为不同的操作系统和包管理器,可为同一个包选择不同的名称。

NOTE:pkg_check_modules和pkg_search_module函数添加了IMPORTED_TARGET选项,并在CMake 3.6中定义导入目标的功能。3.6之前的版本,只定义了变量ZeroMQ_INCLUDE_DIRS(用于include目录)和ZeroMQ_LIBRARIES(用于链接库),供后续使用。

创建和运行测试

创建一个简单的单元测试

CTest是CMake的测试工具,本示例中,我们将使用CTest进行单元测试。为了保持对CMake/CTest的关注,我们的测试代码会尽可能的简单。计划是编写和测试能够对整数求和的代码,示例代码只会对整数进行累加,不处理浮点数。

准备工作

代码示例由三个文件组成。实现源文件sum_integs.cpp对整数向量进行求和,并返回累加结果:

1
2
3
4
5
6
7
8
9
#include "sum_integers.hpp"
#include <vector>
int sum_integers(const std::vector<int> integers) {
auto sum = 0;
for (auto i : integers) {
sum += i;
}
return sum;
}

这个示例是否是优雅的实现并不重要,接口以sum_integers的形式导出。接口在sum_integers.hpp文件中声明,详情如下:

1
2
3
#pragma once
#include <vector>
int sum_integers(const std::vector<int> integers);

最后,main函数在main.cpp中定义,从argv[]中收集命令行参数,将它们转换成整数向量,调用sum_integers函数,并将结果打印到输出中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "sum_integers.hpp"
#include <iostream>
#include <string>
#include <vector>
// we assume all arguments are integers and we sum them up
// for simplicity we do not verify the type of arguments
int main(int argc, char *argv[]) {
std::vector<int> integers;
for (auto i = 1; i < argc; i++) {
integers.push_back(std::stoi(argv[i]));
}
auto sum = sum_integers(integers);
std::cout << sum << std::endl;
}

测试这段代码使用C++实现(test.cpp),Bash shell脚本实现(test.sh)和Python脚本实现(test.py),只要实现可以返回一个零或非零值,从而CMake可以解释为成功或失败。

C++例子(test.cpp)中,我们通过调用sum_integers来验证1 + 2 + 3 + 4 + 5 = 15:

1
2
3
4
5
6
7
8
9
10
#include "sum_integers.hpp"
#include <vector>
int main() {
auto integers = {1, 2, 3, 4, 5};
if (sum_integers(integers) == 15) {
return 0;
} else {
return 1;
}
}

Bash shell脚本调用可执行文件:

1
2
3
4
5
6
7
8
9
#!/usr/bin/env bash
EXECUTABLE=$1
OUTPUT=$($EXECUTABLE 1 2 3 4)
if [ "$OUTPUT" = "10" ]
then
exit 0
else
exit 1
fi

此外,Python脚本调用可执行文件(使用–executable命令行参数传递),并使用–short命令行参数执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import subprocess
import argparse
# test script expects the executable as argument
parser = argparse.ArgumentParser()
parser.add_argument('--executable',
help='full path to executable')
parser.add_argument('--short',
default=False,
action='store_true',
help='run a shorter test')
args = parser.parse_args()
def execute_cpp_code(integers):
result = subprocess.check_output([args.executable] + integers)
return int(result)
if args.short:
# we collect [1, 2, ..., 100] as a list of strings
result = execute_cpp_code([str(i) for i in range(1, 101)])
assert result == 5050, 'summing up to 100 failed'
else:
# we collect [1, 2, ..., 1000] as a list of strings
result = execute_cpp_code([str(i) for i in range(1, 1001)])
assert result == 500500, 'summing up to 1000 failed'

具体实施

现在,我们将逐步描述如何为项目设置测试:

对于这个例子,我们需要C++11支持,可用的Python解释器,以及Bash shell:

1
2
3
4
5
6
7
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-01 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(PythonInterp REQUIRED)
find_program(BASH_EXECUTABLE NAMES bash REQUIRED)

然后,定义库及主要可执行文件的依赖关系,以及测试可执行文件:

1
2
3
4
5
6
7
8
# example library
add_library(sum_integers sum_integers.cpp)
# main code
add_executable(sum_up main.cpp)
target_link_libraries(sum_up sum_integers)
# testing binary
add_executable(cpp_test test.cpp)
target_link_libraries(cpp_test sum_integers)

最后,打开测试功能并定义四个测试。最后两个测试, 调用相同的Python脚本,先没有任何命令行参数,再使用–short:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
enable_testing()
add_test(
NAME bash_test
COMMAND ${BASH_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test.sh $<TARGET_FILE:sum_up>
)
add_test(
NAME cpp_test
COMMAND $<TARGET_FILE:cpp_test>
)
add_test(
NAME python_test_long
COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test.py --executable $<TARGET_FILE:sum_up>
)
add_test(
NAME python_test_short
COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test.py --short --executable $<TARGET_FILE:sum_up>
)

现在,我们已经准备好配置和构建代码。先手动进行测试:

1
2
3
4
5
6
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./sum_up 1 2 3 4 5
15

然后,我们可以用ctest运行测试集:

1
2
3
4
5
6
7
8
9
10
11
12
$ ctest
Test project /home/user/cmake-recipes/chapter-04/recipe-01/cxx-example/build
Start 1: bash_test
1/4 Test #1: bash_test ........................ Passed 0.01 sec
Start 2: cpp_test
2/4 Test #2: cpp_test ......................... Passed 0.00 sec
Start 3: python_test_long
3/4 Test #3: python_test_long ................. Passed 0.06 sec
Start 4: python_test_short
4/4 Test #4: python_test_short ................ Passed 0.05 sec
100% tests passed, 0 tests failed out of 4
Total Test time (real) = 0.12 sec

还应该尝试中断实现,以验证测试集是否能捕捉到更改。

工作原理

这里的两个关键命令:

  • enable_testing(),测试这个目录和所有子文件夹(因为我们把它放在主CMakeLists.txt)。
  • add_test(),定义了一个新的测试,并设置测试名称和运行命令。
1
2
3
4
add_test(
NAME cpp_test
COMMAND $<TARGET_FILE:cpp_test>
)

上面的例子中,使用了生成器表达式:$<TARGET_FILE:cpp_test>。生成器表达式,是在生成构建系统生成时的表达式。此时,我们可以声明$<TARGET_FILE:cpp_test>变量,将使用cpp_test可执行目标的完整路径进行替换。

生成器表达式在测试时非常方便,因为不必显式地将可执行程序的位置和名称,可以硬编码到测试中。以一种可移植的方式实现这一点非常麻烦,因为可执行文件和可执行后缀(例如,Windows上是.exe后缀)的位置在不同的操作系统、构建类型和生成器之间可能有所不同。使用生成器表达式,我们不必显式地了解位置和名称。

也可以将参数传递给要运行的test命令,例如:

1
2
3
4
add_test(
NAME python_test_short
COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test.py --short --executable $<TARGET_FILE:sum_up>
)

这个例子中,我们按顺序运行测试,并展示如何缩短总测试时间并行执行测试(第8节),执行测试用例的子集(第9节)。这里,可以自定义测试命令,可以以任何编程语言运行测试集。CTest关心的是,通过命令的返回码测试用例是否通过。CTest遵循的标准约定是,返回零意味着成功,非零返回意味着失败。可以返回零或非零的脚本,都可以做测试用例。

既然知道了如何定义和执行测试,那么了解如何诊断测试失败也很重要。为此,我们可以在代码中引入一个bug,让所有测试都失败:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Start 1: bash_test
1/4 Test #1: bash_test ........................***Failed 0.01 sec
Start 2: cpp_test
2/4 Test #2: cpp_test .........................***Failed 0.00 sec
Start 3: python_test_long
3/4 Test #3: python_test_long .................***Failed 0.06 sec
Start 4: python_test_short
4/4 Test #4: python_test_short ................***Failed 0.06 sec
0% tests passed, 4 tests failed out of 4
Total Test time (real) = 0.13 sec
The following tests FAILED:
1 - bash_test (Failed)
2 - cpp_test (Failed)
3 - python_test_long (Failed)
4 - python_test_short (Failed)
Errors while running CTest

如果我们想了解更多,可以查看文件test/Temporary/lasttestsfailure.log。这个文件包含测试命令的完整输出,并且在分析阶段,要查看的第一个地方。使用以下CLI开关,可以从CTest获得更详细的测试输出:

  • --output-on-failure:将测试程序生成的任何内容打印到屏幕上,以免测试失败。
  • -v:将启用测试的详细输出。
  • -vv:启用更详细的输出。

CTest提供了一个非常方快捷的方式,可以重新运行以前失败的测试;要使用的CLI开关是–rerun-failed,在调试期间非常有用。

更多信息

考虑以下定义:

1
2
3
4
add_test(
NAME python_test_long
COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test.py --executable $<TARGET_FILE:sum_up>
)

前面的定义可以通过显式指定脚本运行的WORKING_DIRECTORY重新表达,如下:

1
2
3
4
5
add_test(
NAME python_test_long
COMMAND ${PYTHON_EXECUTABLE} test.py --executable $<TARGET_FILE:sum_up>
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
)

测试名称可以包含/字符,按名称组织相关测试也很有用,例如:

1
2
3
4
5
add_test(
NAME python/long
COMMAND ${PYTHON_EXECUTABLE} test.py --executable $<TARGET_FILE:sum_up>
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
)

有时候,我们需要为测试脚本设置环境变量。这可以通过set_tests_properties实现:

1
2
3
4
5
6
7
set_tests_properties(python_test
PROPERTIES
ENVIRONMENT
ACCOUNT_MODULE_PATH=${CMAKE_CURRENT_SOURCE_DIR}
ACCOUNT_HEADER_FILE=${CMAKE_CURRENT_SOURCE_DIR}/account/account.h
ACCOUNT_LIBRARY_FILE=$<TARGET_FILE:account>
)

这种方法在不同的平台上并不总可行,CMake提供了解决这个问题的方法。下面的代码片段与上面给出的代码片段相同,在执行实际的Python测试脚本之前,通过CMAKE_COMMAND调用CMake来预先设置环境变量:

1
2
3
4
5
6
7
8
9
10
11
add_test(
NAME
python_test
COMMAND
${CMAKE_COMMAND} -E env
ACCOUNT_MODULE_PATH=${CMAKE_CURRENT_SOURCE_DIR}
ACCOUNT_HEADER_FILE=${CMAKE_CURRENT_SOURCE_DIR}/account/account.h
ACCOUNT_LIBRARY_FILE=$<TARGET_FILE:account>
${PYTHON_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/account/test.py
)

同样,要注意使用生成器表达式$<TARGET_FILE:account>来传递库文件的位置。

我们已经使用ctest命令执行测试,CMake还将为生成器创建目标(Unix Makefile生成器为make test,Ninja工具为ninja test,或者Visual Studio为RUN_TESTS)。这意味着,还有另一种(几乎)可移植的方法来运行测试:

1
$ cmake --build . --target test

使用Google Test库进行单元测试

本示例中,我们将演示如何在CMake的帮助下使用Google Test框架实现单元测试。与前一个配置相比,Google Test框架不仅仅是一个头文件,也是一个库,包含两个需要构建和链接的文件。可以将它们与我们的代码项目放在一起,但是为了使代码项目更加轻量级,我们将选择在配置时,下载一个定义良好的Google Test,然后构建框架并链接它。我们将使用较新的FetchContent模块(从CMake版本3.11开始可用)。

准备工作

main.cpp、sum_integers.cpp和sum_integers.hpp与之前相同,修改test.cpp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include "sum_integers.hpp"
#include "gtest/gtest.h"
#include <vector>
int main(int argc, char **argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
TEST(example, sum_zero) {
auto integers = {1, -1, 2, -2, 3, -3};
auto result = sum_integers(integers);
ASSERT_EQ(result, 0);
}
TEST(example, sum_five) {
auto integers = {1, 2, 3, 4, 5};
auto result = sum_integers(integers);
ASSERT_EQ(result, 15);
}

如上面的代码所示,我们显式地将gtest.h,而不将其他Google Test源放在代码项目存储库中,会在配置时使用FetchContent模块下载它们。

具体实施

下面的步骤描述了如何设置CMakeLists.txt,使用GTest编译可执行文件及其相应的测试:

与前两个示例相比,CMakeLists.txt的开头基本没有变化,CMake 3.11才能使用FetchContent模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# set minimum cmake version
cmake_minimum_required(VERSION 3.11 FATAL_ERROR)
# project name and language
project(recipe-03 LANGUAGES CXX)
# require C++11
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS ON)
# example library
add_library(sum_integers sum_integers.cpp)
# main code
add_executable(sum_up main.cpp)
target_link_libraries(sum_up sum_integers)

然后引入一个if,检查ENABLE_UNIT_TESTS。默认情况下,它为ON,但有时需要设置为OFF,以免在没有网络连接时,也能使用Google Test:

1
2
3
4
5
option(ENABLE_UNIT_TESTS "Enable unit tests" ON)
message(STATUS "Enable testing: ${ENABLE_UNIT_TESTS}")
if(ENABLE_UNIT_TESTS)
# all the remaining CMake code will be placed here
endif()

if内部包含FetchContent模块,声明要获取的新内容,并查询其属性:

1
2
3
4
5
6
7
include(FetchContent)
FetchContent_Declare(
googletest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG release-1.8.0
)
FetchContent_GetProperties(googletest)

如果内容还没有获取到,将尝试获取并配置它。这需要添加几个可以链接的目标。本例中,我们对gtest_main感兴趣。该示例还包含一些变通方法,用于使用在Visual Studio下的编译:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
if(NOT googletest_POPULATED)
FetchContent_Populate(googletest)
# Prevent GoogleTest from overriding our compiler/linker options
# when building with Visual Studio
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
# Prevent GoogleTest from using PThreads
set(gtest_disable_pthreads ON CACHE BOOL "" FORCE)
# adds the targers: gtest, gtest_main, gmock, gmock_main
add_subdirectory(
${googletest_SOURCE_DIR}
${googletest_BINARY_DIR}
)
# Silence std::tr1 warning on MSVC
if(MSVC)
foreach(_tgt gtest gtest_main gmock gmock_main)
target_compile_definitions(${_tgt}
PRIVATE
"_SILENCE_TR1_NAMESPACE_DEPRECATION_WARNING"
)
endforeach()
endif()
endif()

然后,使用target_sources和target_link_libraries命令,定义cpp_test可执行目标并指定它的源文件:

1
2
3
4
5
6
7
8
9
10
add_executable(cpp_test "")
target_sources(cpp_test
PRIVATE
test.cpp
)
target_link_libraries(cpp_test
PRIVATE
sum_integers
gtest_main
)

最后,使用enable_test和add_test命令来定义单元测试:

1
2
3
4
5
enable_testing()
add_test(
NAME google_test
COMMAND $<TARGET_FILE:cpp_test>
)

现在,准备配置、构建和测试项目:

1
2
3
4
5
6
7
8
9
10
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ctest
Test project /home/user/cmake-cookbook/chapter-04/recipe-03/cxx-example/build
Start 1: google_test
1/1 Test #1: google_test ...................... Passed 0.00 sec
100% tests passed, 0 tests failed out of 1
Total Test time (real) = 0.00 sec

可以直接运行cpp_test:

1
2
3
4
5
6
7
8
9
10
11
12
$ ./cpp_test
[==========] Running 2 tests from 1 test case.
[----------] Global test environment set-up.
[----------] 2 tests from example
[ RUN ] example.sum_zero
[ OK ] example.sum_zero (0 ms)
[ RUN ] example.sum_five
[ OK ] example.sum_five (0 ms)
[----------] 2 tests from example (0 ms total)
[----------] Global test environment tear-down
[==========] 2 tests from 1 test case ran. (0 ms total)
[ PASSED ] 2 tests.

工作原理

FetchContent模块支持通过ExternalProject模块,在配置时填充内容,并在其3.11版本中成为CMake的标准部分。而ExternalProject_Add()在构建时(见第8章)进行下载操作,这样FetchContent模块使得构建可以立即进行,这样获取的主要项目和外部项目(在本例中为Google Test)仅在第一次执行CMake时调用,使用add_subdirectory可以嵌套。

为了获取Google Test,首先声明外部内容:

1
2
3
4
5
6
include(FetchContent)
FetchContent_Declare(
googletest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG release-1.8.0
)

本例中,我们获取了一个带有特定标记的Git库(release-1.8.0),但是我们也可以从Subversion、Mercurial或HTTP(S)源获取一个外部项目。有关可用选项,可参考相应的ExternalProject_Add命令的选项,网址是https://cmake.org/cmake/help/v3.11/module/ExternalProject.html

调用FetchContent_Populate()之前,检查是否已经使用FetchContent_GetProperties()命令处理了内容填充;否则,调用FetchContent_Populate()超过一次后,就会抛出错误。

FetchContent_Populate(googletest)用于填充源并定义googletest_SOURCE_DIR和googletest_BINARY_DIR,可以使用它们来处理Google Test项目(使用add_subdirectory(),因为它恰好也是一个CMake项目):

1
2
3
4
add_subdirectory(
${googletest_SOURCE_DIR}
${googletest_BINARY_DIR}
)

前面定义了以下目标:gtest、gtest_main、gmock和gmock_main。这个配置中,作为单元测试示例的库依赖项,我们只对gtest_main目标感兴趣:

1
2
3
4
5
target_link_libraries(cpp_test
PRIVATE
sum_integers
gtest_main
)

构建代码时,可以看到如何正确地对Google Test进行配置和构建。有时,我们希望升级到更新的Google Test版本,这时需要更改的唯一一行就是详细说明GIT_TAG的那一行。

使用动态分析来检测内存缺陷

内存缺陷:写入或读取越界,或者内存泄漏(已分配但从未释放的内存),会产生难以跟踪的bug,最好尽早将它们检查出来。Valgrind( http://valgrind.org )是一个通用的工具,用来检测内存缺陷和内存泄漏。本节中,我们将在使用CMake/CTest测试时使用Valgrind对内存问题进行警告。

准备工作

对于这个配置,需要三个文件。第一个是测试的实现(我们可以调用文件leaky_implementation.cpp):

1
2
3
4
5
6
7
8
9
10
#include "leaky_implementation.hpp"
int do_some_work() {
// we allocate an array
double *my_array = new double[1000];
// do some work
// ...
// we forget to deallocate it
// delete[] my_array;
return 0;
}

还需要相应的头文件(leaky_implementation.hpp):

1
2
#pragma once
int do_some_work();

并且,需要测试文件(test.cpp):

1
2
3
4
5
#include "leaky_implementation.hpp"
int main() {
int return_code = do_some_work();
return return_code;
}

我们希望测试通过,因为return_code硬编码为0。这里我们也期望检测到内存泄漏,因为my_array没有释放。

具体实施

下面展示了如何设置CMakeLists.txt来执行代码动态分析:

我们首先定义CMake最低版本、项目名称、语言、目标和依赖关系:

1
2
3
4
5
6
7
8
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-05 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_library(example_library leaky_implementation.cpp)
add_executable(cpp_test test.cpp)
target_link_libraries(cpp_test example_library)

然后,定义测试目标,还定义了MEMORYCHECK_COMMAND:

1
2
3
4
5
6
7
8
9
find_program(MEMORYCHECK_COMMAND NAMES valgrind)
set(MEMORYCHECK_COMMAND_OPTIONS "--trace-children=yes --leak-check=full")
# add memcheck test action
include(CTest)
enable_testing()
add_test(
NAME cpp_test
COMMAND $<TARGET_FILE:cpp_test>
)

运行测试集,报告测试通过情况,如下所示:

1
2
3
4
5
6
$ ctest
Test project /home/user/cmake-recipes/chapter-04/recipe-05/cxx-example/build
Start 1: cpp_test
1/1 Test #1: cpp_test ......................... Passed 0.00 sec
100% tests passed, 0 tests failed out of 1
Total Test time (real) = 0.00 sec

现在,我们希望检查内存缺陷,可以观察到被检测到的内存泄漏:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ ctest -T memcheck
Site: myhost
Build name: Linux-c++
Create new tag: 20171127-1717 - Experimental
Memory check project /home/user/cmake-recipes/chapter-04/recipe-05/cxx-example/build
Start 1: cpp_test
1/1 MemCheck #1: cpp_test ......................... Passed 0.40 sec
100% tests passed, 0 tests failed out of 1
Total Test time (real) = 0.40 sec
-- Processing memory checking output:
1/1 MemCheck: #1: cpp_test ......................... Defects: 1
MemCheck log files can be found here: ( * corresponds to test number)
/home/user/cmake-recipes/chapter-04/recipe-05/cxx-example/build/Testing/Temporary/MemoryChecker.*.log
Memory checking results:
Memory Leak - 1

最后一步,应该尝试修复内存泄漏,并验证ctest -T memcheck没有报告错误。

工作原理

使用find_program(MEMORYCHECK_COMMAND NAMES valgrind)查找valgrind,并将MEMORYCHECK_COMMAND设置为其绝对路径。我们显式地包含CTest模块来启用memcheck测试操作,可以使用CTest -T memcheck来启用这个操作。此外,使用set(MEMORYCHECK_COMMAND_OPTIONS "--trace-children=yes --leak-check=full"),将相关参数传递给Valgrind。内存检查会创建一个日志文件,该文件可用于详细记录内存缺陷信息。

配置时和构建时的操作

我们将学习如何在配置和构建时,执行自定义操作。先简单回顾一下,与CMake工作流程相关的时序:

  1. CMake时构建时:CMake正在运行,并处理项目中的CMakeLists.txt文件。
  2. 生成时:生成构建工具(如Makefile或Visual Studio项目文件)。
  3. 构建时:由CMake生成相应平台的原生构建脚本,在脚本中调用原生工具构建。此时,将调用编译器在特定的构建目录中构建目标(可执行文件和库)。
  4. CTest时测试时:运行测试套件以检查目标是否按预期执行。
  5. CDash时报告时:当测试结果上传到仪表板上,与其他开发人员共享测试报告。
  6. 安装时:当目标、源文件、可执行程序和库,从构建目录安装到相应位置。
  7. CPack时打包时:将项目打包用以分发时,可以是源码,也可以是二进制。
  8. 包安装时:新生成的包在系统范围内安装。

使用平台无关的文件操作

有些项目构建时,可能需要与平台的文件系统进行交互。也就是检查文件是否存在、创建新文件来存储临时信息、创建或提取打包文件等等。使用CMake不仅能够在不同的平台上生成构建系统,还能够在不复杂的逻辑情况下,进行文件操作,从而独立于操作系统。本示例将展示,如何以可移植的方式下载库文件。

准备工作

我们将展示如何提取Eigen库文件,并使用提取的源文件编译我们的项目。这个示例中,将重用第3章第7节的线性代数例子linear-algebra.cpp,用来检测外部库和程序、检测特征库。这里,假设已经包含Eigen库文件,已在项目构建前下载。

具体实施

项目需要解压缩Eigen打包文件,并相应地为目标设置包含目录:

首先,使能C++11项目:

1
2
3
4
5
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-01 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

我们将自定义目标添加到构建系统中,自定义目标将提取构建目录中的库文件:

1
2
3
4
5
6
7
8
9
10
11
add_custom_target(unpack-eigen
ALL
COMMAND
${CMAKE_COMMAND} -E tar xzf ${CMAKE_CURRENT_SOURCE_DIR}/eigen-eigen-5a0156e40feb.tar.gz
COMMAND
${CMAKE_COMMAND} -E rename eigen-eigen-5a0156e40feb eigen-3.3.4
WORKING_DIRECTORY
${CMAKE_CURRENT_BINARY_DIR}
COMMENT
"Unpacking Eigen3 in ${CMAKE_CURRENT_BINARY_DIR}/eigen-3.3.4"
)

为源文件添加了一个可执行目标:

1
add_executable(linear-algebra linear-algebra.cpp)

由于源文件的编译依赖于Eigen头文件,需要显式地指定可执行目标对自定义目标的依赖关系:

1
add_dependencies(linear-algebra unpack-eigen)

最后,指定包含哪些目录:

1
2
3
4
target_include_directories(linear-algebra
PRIVATE
${CMAKE_CURRENT_BINARY_DIR}/eigen-3.3.4
)

工作原理

细看add_custom_target这个命令:

1
2
3
4
5
6
7
8
9
10
11
add_custom_target(unpack-eigen
ALL
COMMAND
${CMAKE_COMMAND} -E tar xzf ${CMAKE_CURRENT_SOURCE_DIR}/eigen-eigen-5a0156e40feb.tar.gz
COMMAND
${CMAKE_COMMAND} -E rename eigen-eigen-5a0156e40feb eigen-3.3.4
WORKING_DIRECTORY
${CMAKE_CURRENT_BINARY_DIR}
COMMENT
"Unpacking Eigen3 in ${CMAKE_CURRENT_BINARY_DIR}/eigen-3.3.4"
)

构建系统中引入了一个名为unpack-eigen的目标。因为我们传递了ALL参数,目标将始终被执行。COMMAND参数指定要执行哪些命令。本例中,我们希望提取存档并将提取的目录重命名为egan -3.3.4,通过以下两个命令实现:

1
2
3
${CMAKE_COMMAND} -E tar xzf ${CMAKE_CURRENT_SOURCE_DIR}/eigen-eigen-
5a0156e40feb.tar.gz
${CMAKE_COMMAND} -E rename eigen-eigen-5a0156e40feb eigen-3.3.4

注意,使用-E标志调用CMake命令本身来执行实际的工作。对于许多常见操作,CMake实现了一个对所有操作系统都通用的接口,这使得构建系统独立于特定的平台。add_custom_target命令中的下一个参数是工作目录。我们的示例中,它对应于构建目录:CMAKE_CURRENT_BINARY_DIR。最后一个参数COMMENT,用于指定CMake在执行自定义目标时输出什么样的消息。

配置时运行自定义命令

运行CMake生成构建系统,从而指定原生构建工具必须执行哪些命令,以及按照什么顺序执行。我们已经了解了CMake如何在配置时运行许多子任务,以便找到工作的编译器和必要的依赖项。本示例中,我们将讨论如何使用execute_process命令在配置时运行定制化命令。

具体实施

第3章第3节中,我们已经展示了execute_process查找Python模块NumPy时的用法。本例中,我们将使用execute_process命令来确定,是否存在特定的Python模块(本例中为Python CFFI),如果存在,我们在进行版本确定:

对于这个简单的例子,不需要语言支持:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-02 LANGUAGES NONE)

我们要求Python解释器执行一个简短的代码片段,因此,需要使用find_package来查找解释器:

1
find_package(PythonInterp REQUIRED)

然后,调用execute_process来运行一个简短的Python代码段;下一节中,我们将更详细地讨论这个命令:

1
2
3
4
5
6
7
8
9
10
11
# this is set as variable to prepare
# for abstraction using loops or functions
set(_module_name "cffi")
execute_process(
COMMAND
${PYTHON_EXECUTABLE} "-c" "import ${_module_name}; print(${_module_name}.__version__)"
OUTPUT_VARIABLE _stdout
ERROR_VARIABLE _stderr
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_STRIP_TRAILING_WHITESPACE
)

然后,打印结果:

1
2
3
4
5
if(_stderr MATCHES "ModuleNotFoundError")
message(STATUS "Module ${_module_name} not found")
else()
message(STATUS "Found module ${_module_name} v${_stdout}")
endif()

下面是一个配置示例(假设Python CFFI包安装在相应的Python环境中):

1
2
3
4
5
$ mkdir -p build
$ cd build
$ cmake ..
-- Found PythonInterp: /home/user/cmake-cookbook/chapter-05/recipe-02/example/venv/bin/python (found version "3.6.5")
-- Found module cffi v1.11.5

工作原理

execute_process命令将从当前正在执行的CMake进程中派生一个或多个子进程,从而提供了在配置项目时运行任意命令的方法。可以在一次调用execute_process时执行多个命令。但请注意,每个命令的输出将通过管道传输到下一个命令中。该命令接受多个参数:

  • WORKING_DIRECTORY,指定应该在哪个目录中执行命令。
  • RESULT_VARIABLE将包含进程运行的结果。这要么是一个整数,表示执行成功,要么是一个带有错误条件的字符串。
  • OUTPUT_VARIABLE和ERROR_VARIABLE将包含执行命令的标准输出和标准错误。由于命令的输出是通过管道传输的,因此只有最后一个命令的标准输出才会保存到OUTPUT_VARIABLE中。
  • INPUT_FILE指定标准输入重定向的文件名
  • OUTPUT_FILE指定标准输出重定向的文件名
  • ERROR_FILE指定标准错误输出重定向的文件名

设置OUTPUT_QUIET和ERROR_QUIET后,CMake将静默地忽略标准输出和标准错误。

设置OUTPUT_STRIP_TRAILING_WHITESPACE,可以删除运行命令的标准输出中的任何尾随空格

设置ERROR_STRIP_TRAILING_WHITESPACE,可以删除运行命令的错误输出中的任何尾随空格。

有了这些了解这些参数,回到我们的例子当中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
set(_module_name "cffi")
execute_process(
COMMAND
${PYTHON_EXECUTABLE} "-c" "import ${_module_name}; print(${_module_name}.__version__)"
OUTPUT_VARIABLE _stdout
ERROR_VARIABLE _stderr
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_STRIP_TRAILING_WHITESPACE
)
if(_stderr MATCHES "ModuleNotFoundError")
message(STATUS "Module ${_module_name} not found")
else()
message(STATUS "Found module ${_module_name} v${_stdout}")
endif()

该命令检查python -c "import cffi; print(cffi.__version__)"的输出。如果没有找到模块,_stderr将包含ModuleNotFoundError,我们将在if语句中对其进行检查。本例中,我们将打印Module cffi not found。如果导入成功,Python代码将打印模块的版本,该模块通过管道输入_stdout,这样就可以打印如下内容:

1
message(STATUS "Found module ${_module_name} v${_stdout}")

构建时运行自定义命令:Ⅰ. 使用add_custom_command

项目的构建目标取决于命令的结果,这些命令只能在构建系统生成完成后的构建执行。CMake提供了三个选项来在构建时执行自定义命令:

  • 使用add_custom_command编译目标,生成输出文件。
  • add_custom_target的执行没有输出。
  • 构建目标前后,add_custom_command的执行可以没有输出。

这三个选项强制执行特定的语义,并且不可互换。接下来的三个示例将演示具体的用法。

准备工作

我们将重用第3章第4节中的C++示例,以说明如何使用add_custom_command的第一个选项。代码示例中,我们了解了现有的BLAS和LAPACK库,并编译了一个很小的C++包装器库,以调用线性代数的Fortran实现。

我们将把代码分成两部分。linear-algebra.cpp的源文件与第3章、第4章没有区别,并且将包含线性代数包装器库的头文件和针对编译库的链接。源代码将打包到一个压缩的tar存档文件中,该存档文件随示例项目一起提供。存档文件将在构建时提取,并在可执行文件生成之前,编译线性代数的包装器库。

具体实施

CMakeLists.txt必须包含一个自定义命令,来提取线性代数包装器库的源代码:

从CMake最低版本、项目名称和支持语言的定义开始:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-03 LANGUAGES CXX Fortran)

选择C++11标准:

1
2
3
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

然后,在系统上查找BLAS和LAPACK库:

1
2
find_package(BLAS REQUIRED)
find_package(LAPACK REQUIRED)

声明一个变量wrap_BLAS_LAPACK_sources来保存wrap_BLAS_LAPACK.tar.gz压缩包文件的名称:

1
2
3
4
5
6
set(wrap_BLAS_LAPACK_sources
${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxBLAS.hpp
${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxBLAS.cpp
${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxLAPACK.hpp
${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxLAPACK.cpp
)

声明自定义命令来提取wrap_BLAS_LAPACK.tar.gz压缩包,并更新提取文件的时间戳。注意这个wrap_BLAS_LAPACK_sources变量的预期输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
add_custom_command(
OUTPUT
${wrap_BLAS_LAPACK_sources}
COMMAND
${CMAKE_COMMAND} -E tar xzf ${CMAKE_CURRENT_SOURCE_DIR}/wrap_BLAS_LAPACK.tar.gz
COMMAND
${CMAKE_COMMAND} -E touch ${wrap_BLAS_LAPACK_sources}
WORKING_DIRECTORY
${CMAKE_CURRENT_BINARY_DIR}
DEPENDS
${CMAKE_CURRENT_SOURCE_DIR}/wrap_BLAS_LAPACK.tar.gz
COMMENT
"Unpacking C++ wrappers for BLAS/LAPACK"
VERBATIM
)

接下来,添加一个库目标,源文件是新解压出来的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
add_library(math "")
target_sources(math
PRIVATE
${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxBLAS.cpp
${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxLAPACK.cpp
PUBLIC
${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxBLAS.hpp
${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxLAPACK.hpp
)
target_include_directories(math
INTERFACE
${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK
)
target_link_libraries(math
PUBLIC
${LAPACK_LIBRARIES}
)

最后,添加linear-algebra可执行目标。可执行目标链接到库:

1
2
3
4
5
add_executable(linear-algebra linear-algebra.cpp)
target_link_libraries(linear-algebra
PRIVATE
math
)

我们配置、构建和执行示例:

1
2
3
4
5
6
7
8
9
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./linear-algebra 1000
C_DSCAL done
C_DGESV done
info is 0
check is 4.35597e-10

工作原理

让我们来了解一下add_custom_command的使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
add_custom_command(
OUTPUT
${wrap_BLAS_LAPACK_sources}
COMMAND
${CMAKE_COMMAND} -E tar xzf ${CMAKE_CURRENT_SOURCE_DIR}/wrap_BLAS_LAPACK.tar.gz
COMMAND
${CMAKE_COMMAND} -E touch ${wrap_BLAS_LAPACK_sources}
WORKING_DIRECTORY
${CMAKE_CURRENT_BINARY_DIR}
DEPENDS
${CMAKE_CURRENT_SOURCE_DIR}/wrap_BLAS_LAPACK.tar.gz
COMMENT
"Unpacking C++ wrappers for BLAS/LAPACK"
VERBATIM
)

add_custom_command向目标添加规则,并通过执行命令生成输出。add_custom_command中声明的任何目标,即在相同的CMakeLists.txt中声明的任何目标,使用输出的任何文件作为源文件的目标,在构建时会有规则生成这些文件。因此,源文件生成在构建时,目标和自定义命令在构建系统生成时,将自动处理依赖关系。

我们的例子中,输出是压缩tar包,其中包含有源文件。要检测和使用这些文件,必须在构建时提取打包文件。通过使用带有-E标志的CMake命令,以实现平台独立性。下一个命令会更新提取文件的时间戳。这样做是为了确保没有处理陈旧文件。WORKING_DIRECTORY可以指定在何处执行命令。示例中,CMAKE_CURRENT_BINARY_DIR是当前正在处理的构建目录。DEPENDS参数列出了自定义命令的依赖项。例子中,压缩的tar是一个依赖项。CMake使用COMMENT字段在构建时打印状态消息。最后,VERBATIM告诉CMake为生成器和平台生成正确的命令,从而确保完全独立。

我们来仔细看看这用使用方式和打包库的创建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
add_library(math "")
target_sources(math
PRIVATE
${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxBLAS.cpp
${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxLAPACK.cpp
PUBLIC
${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxBLAS.hpp
${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxLAPACK.hpp
)
target_include_directories(math
INTERFACE
${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK
)
target_link_libraries(math
PUBLIC
${LAPACK_LIBRARIES}
)

我们声明一个没有源的库目标,是因为后续使用target_sources填充目标的源。这里实现了一个非常重要的目标,即让依赖于此目标的目标,了解需要哪些目录和头文件,以便成功地使用库。C++源文件的目标是PRIVATE,因此只用于构建库。因为目标及其依赖项都需要使用它们来成功编译,所以头文件是PUBLIC。包含目录使用target_include_categories指定,其中wrap_BLAS_LAPACK声明为INTERFACE,因为只有依赖于math目标的目标需要它。

构建时为特定目标运行自定义命令

本节示例将展示,如何使用add_custom_command的第二个参数,来执行没有输出的自定义操作,这对于构建或链接特定目标之前或之后执行某些操作非常有用。由于自定义命令仅在必须构建目标本身时才执行,因此我们实现了对其执行的目标级控制。我们将通过一个示例来演示,在构建目标之前打印目标的链接,然后在编译后,立即测量编译后,可执行文件的静态分配大小。

准备工作

本示例中,我们将使用Fortran代码(example.f90):

1
2
3
4
5
6
7
8
9
10
11
program example
implicit none
real(8) :: array(20000000)
real(8) :: r
integer :: i
do i = 1, size(array)
call random_number(r)
array(i) = r
end do
print *, sum(array)
end program

虽然我们选择了Fortran,但Fortran代码的对于后面的讨论并不重要,因为有很多遗留的Fortran代码,存在静态分配大小的问题。

这段代码中,我们定义了一个包含20,000,000双精度浮点数的数组,这个数组占用160MB的内存。在这里,我们并不是推荐这样的编程实践。一般来说,这些内存的分配和代码中是否使用这段内存无关。一个更好的方法是只在需要时动态分配数组,随后立即释放。

示例代码用随机数填充数组,并计算它们的和——这样是为了确保数组确实被使用,并且编译器不会优化分配。我们将使用Python脚本(static-size.py)来统计二进制文件静态分配的大小,该脚本用size命令来封装:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import subprocess
import sys
# for simplicity we do not check number of
# arguments and whether the file really exists
file_path = sys.argv[-1]
try:
output = subprocess.check_output(['size', file_path]).decode('utf-8')
except FileNotFoundError:
print('command "size" is not available on this platform')
sys.exit(0)
size = 0.0
for line in output.split('\n'):
if file_path in line:
# we are interested in the 4th number on this line
size = int(line.split()[3])
print('{0:.3f} MB'.format(size/1.0e6))

要打印链接行,我们将使用第二个Python helper脚本(echo-file.py)打印文件的内容:

1
2
3
4
5
6
7
8
9
import sys
# for simplicity we do not verify the number and
# type of arguments
file_path = sys.argv[-1]
try:
with open(file_path, 'r') as f:
print(f.read())
except FileNotFoundError:
print('ERROR: file {0} not found'.format(file_path))

具体实施

来看看CMakeLists.txt:

首先声明一个Fortran项目:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-05 LANGUAGES Fortran)

例子依赖于Python解释器,所以以一种可移植的方式执行helper脚本:

1
find_package(PythonInterp REQUIRED)

本例中,默认为“Release”构建类型,以便CMake添加优化标志:

1
2
3
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()

现在,定义可执行目标:

1
2
3
4
5
add_executable(example "")
target_sources(example
PRIVATE
example.f90
)

然后,定义一个自定义命令,在example目标在已链接之前,打印链接行:

1
2
3
4
5
6
7
8
9
10
11
12
add_custom_command(
TARGET
example
PRE_LINK
COMMAND
${PYTHON_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/echo-file.py
${CMAKE_CURRENT_BINARY_DIR}/CMakeFiles/example.dir/link.txt
COMMENT
"link line:"
VERBATIM
)

测试一下。观察打印的链接行和可执行文件的静态大小:

1
2
3
4
5
6
7
8
9
10
11
12
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
Scanning dependencies of target example
[ 50%] Building Fortran object CMakeFiles/example.dir/example.f90.o
[100%] Linking Fortran executable example
link line:
/usr/bin/f95 -O3 -DNDEBUG -O3 CMakeFiles/example.dir/example.f90.o -o example
static size of executable:
160.003 MB
[100%] Built target example

工作原理

当声明了库或可执行目标,就可以使用add_custom_command将其他命令锁定到目标上。这些命令将在特定的时间执行,与它们所附加的目标的执行相关联。CMake通过以下选项,定制命令执行顺序:

  • PRE_BUILD:在执行与目标相关的任何其他规则之前执行的命令。
  • PRE_LINK:使用此选项,命令在编译目标之后,调用链接器或归档器之前执行。Visual Studio 7或更高版本之外的生成器中使用PRE_BUILD将被解释为PRE_LINK。
  • POST_BUILD:如前所述,这些命令将在执行给定目标的所有规则之后运行。

本例中,将两个自定义命令绑定到可执行目标。PRE_LINK命令将${CMAKE_CURRENT_BINARY_DIR}/CMakeFiles/example.dir/link.txt的内容打印到屏幕上。在我们的例子中,链接行是这样的:

1
/usr/bin/f95 -O3 -DNDEBUG -O3 CMakeFiles/example.dir/example.f90.o -o example

使用Python包装器来实现这一点,它依赖于shell命令。

第二步中,POST_BUILD自定义命令调用Python helper脚本static-size.py,生成器表达式$<target_file:example>作为参数。CMake将在生成时(即生成生成系统时)将生成器表达式扩展到目标文件路径。然后,Python脚本static-size.py使用size命令获取可执行文件的静态分配大小,将其转换为MB,并打印结果。我们的例子中,获得了预期的160 MB:

1
2
static size of executable:
160.003 MB

探究编译和链接命令

生成构建系统期间最常见的操作,是试图评估在哪种系统上构建项目。这意味着要找出哪些功能工作,哪些不工作,并相应地调整项目的编译。使用的方法是查询依赖项是否被满足的信号,或者在代码库中是否启用工作区。接下来的几个示例,将展示如何使用CMake执行这些操作。

准备工作

示例将展示如何使用来自对应的Check<LANG>SourceCompiles.cmake标准模块的check_<lang>_source_compiles函数,以评估给定编译器是否可以将预定义的代码编译成可执行文件。该命令可帮助你确定:

  • 编译器支持所需的特性。
  • 链接器工作正常,并理解特定的标志。
  • 可以使用find_package找到的包含目录和库。

本示例中,我们将展示如何检测OpenMP 4.5标准的循环特性,以便在C++可执行文件中使用。使用一个C++源文件,来探测编译器是否支持这样的特性。CMake提供了一个附加命令try_compile来探究编译。本示例将展示,如何使用这两种方法。

可以使用CMake命令行界面来获取关于特定模块(cmake --help-module <module-name>)和命令(cmake --help-command <command-name>)的文档。示例中,cmake --help-module CheckCXXSourceCompiles将把check_cxx_source_compiles函数的文档输出到屏幕上,而cmake --help-command try_compile将对try_compile命令执行相同的操作。

具体实施

我们将同时使用try_compile和check_cxx_source_compiles,并比较这两个命令的工作方式:

创建一个C++11工程:

1
2
3
4
5
cmake_minimum_required(VERSION 3.9 FATAL_ERROR)
project(recipe-06 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

查找编译器支持的OpenMP:

1
2
3
4
5
6
find_package(OpenMP)
if(OpenMP_FOUND)
# ... <- the steps below will be placed here
else()
message(STATUS "OpenMP not found: no test for taskloop is run")
endif()

如果找到OpenMP,再检查所需的特性是否可用。为此,设置了一个临时目录,try_compile将在这个目录下来生成中间文件。我们把它放在前面步骤中引入的if语句中:

1
set(_scratch_dir ${CMAKE_CURRENT_BINARY_DIR}/omp_try_compile)

调用try_compile生成一个小项目,以尝试编译源文件taskloop.cpp。编译成功或失败的状态,将保存到omp_taskloop_test_1变量中。需要为这个示例编译设置适当的编译器标志、包括目录和链接库。因为使用导入的目标OpenMP::OpenMP_CXX,所以只需将LINK_LIBRARIES选项设置为try_compile即可。如果编译成功,则任务循环特性可用,我们为用户打印一条消息:

1
2
3
4
5
6
7
8
9
try_compile(
omp_taskloop_test_1
${_scratch_dir}
SOURCES
${CMAKE_CURRENT_SOURCE_DIR}/taskloop.cpp
LINK_LIBRARIES
OpenMP::OpenMP_CXX
)
message(STATUS "Result of try_compile: ${omp_taskloop_test_1}")

要使用check_cxx_source_compiles函数,需要包含CheckCXXSourceCompiles.cmake模块文件。其他语言也有类似的模块文件,C(CheckCSourceCompiles.cmake)Fortran(CheckFortranSourceCompiles.cmake):

1
include(CheckCXXSourceCompiles)

我们复制源文件的内容,通过file(READ …)命令读取内容到一个变量中,试图编译和连接这个变量:

1
file(READ ${CMAKE_CURRENT_SOURCE_DIR}/taskloop.cpp _snippet)

我们设置了CMAKE_REQUIRED_LIBRARIES。这对于下一步正确调用编译器是必需的。注意使用导入的OpenMP::OpenMP_CXX目标,它还将设置正确的编译器标志和包含目录:

1
set(CMAKE_REQUIRED_LIBRARIES OpenMP::OpenMP_CXX)

使用代码片段作为参数,调用check_cxx_source_compiles函数。检查结果将保存到omp_taskloop_test_2变量中:

1
check_cxx_source_compiles("${_snippet}" omp_taskloop_test_2)

调用check_cxx_source_compiles并向用户打印消息之前,我们取消了变量的设置:

1
2
unset(CMAKE_REQUIRED_LIBRARIES)
message(STATUS "Result of check_cxx_source_compiles: ${omp_taskloop_test_2}"

最后,进行测试:

1
2
3
4
5
6
7
8
9
10
$ mkdir -p build
$ cd build
$ cmake ..
-- ...
-- Found OpenMP_CXX: -fopenmp (found version "4.5")
-- Found OpenMP: TRUE (found version "4.5")
-- Result of try_compile: TRUE
-- Performing Test omp_taskloop_test_2
-- Performing Test omp_taskloop_test_2 - Success
-- Result of check_cxx_source_compiles: 1

工作原理

try_compile和check_cxx_source_compiles都将编译源文件,并将其链接到可执行文件中。如果这些操作成功,那么输出变量omp_task_loop_test_1(前者)和omp_task_loop_test_2(后者)将被设置为TRUE。然而,这两个命令实现的方式略有不同。check_<lang>_source_compiles命令是try_compile命令的简化包装。

要编译的代码片段必须作为CMake变量传入。大多数情况下,这意味着必须使用file(READ …)来读取文件。然后,代码片段被保存到构建目录的CMakeFiles/CMakeTmp子目录中。

微调编译和链接,必须通过设置以下CMake变量进行:

  • CMAKE_REQUIRED_FLAGS:设置编译器标志。
  • CMAKE_REQUIRED_DEFINITIONS:设置预编译宏。
  • CMAKE_REQUIRED_INCLUDES:设置包含目录列表。
  • CMAKE_REQUIRED_LIBRARIES:设置可执行目标能够连接的库列表。

调用check_<lang>_compiles_function之后,必须手动取消对这些变量的设置,以确保后续使用中,不会保留当前内容。

使用CMake 3.9中可以对于OpenMP目标进行导入,但是目前的配置也可以使用CMake的早期版本,通过手动为check_cxx_source_compiles设置所需的标志和库:set(CMAKE_REQUIRED_FLAGS ${OpenMP_CXX_FLAGS})set(CMAKE_REQUIRED_LIBRARIES ${OpenMP_CXX_LIBRARIES})

生成源码

配置时生成源码

代码生成在配置时发生,例如:CMake可以检测操作系统和可用库;基于这些信息,我们可以定制构建的源代码。本节和下面的章节中,我们将演示如何生成一个简单源文件,该文件定义了一个函数,用于报告构建系统配置。

准备工作

此示例的代码使用Fortran和C语言编写,第9章将讨论混合语言编程。主程序是一个简单的Fortran可执行程序,它调用一个C函数print_info(),该函数将打印配置信息。值得注意的是,在使用Fortran 2003时,编译器将处理命名问题(对于C函数的接口声明),如示例所示。我们将使用的example.f90作为源文件:

1
2
3
4
5
6
7
8
program hello_world
implicit none
interface
subroutine print_info() bind(c, name="print_info")
end subroutine
end interface
call print_info()
end program

C函数print_info()在模板文件print_info.c.in中定义。在配置时,以@开头和结尾的变量将被替换为实际值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stdio.h>
#include <unistd.h>
void print_info(void)
{
printf("\n");
printf("Configuration and build information\n");
printf("-----------------------------------\n");
printf("\n");
printf("Who compiled | %s\n", "@_user_name@");
printf("Compilation hostname | %s\n", "@_host_name@");
printf("Fully qualified domain name | %s\n", "@_fqdn@");
printf("Operating system | %s\n",
"@_os_name@, @_os_release@, @_os_version@");
printf("Platform | %s\n", "@_os_platform@");
printf("Processor info | %s\n",
"@_processor_name@, @_processor_description@");
printf("CMake version | %s\n", "@CMAKE_VERSION@");
printf("CMake generator | %s\n", "@CMAKE_GENERATOR@");
printf("Configuration time | %s\n", "@_configuration_time@");
printf("Fortran compiler | %s\n", "@CMAKE_Fortran_COMPILER@");
printf("C compiler | %s\n", "@CMAKE_C_COMPILER@");
printf("\n");
fflush(stdout);
}

具体实施

在CMakeLists.txt中,我们首先必须对选项进行配置,并用它们的值替换print_info.c.in中相应的占位符。然后,将Fortran和C源代码编译成一个可执行文件:

声明了一个Fortran-C混合项目:

1
2
cmake_minimum_required(VERSION 3.10 FATAL_ERROR)
project(recipe-01 LANGUAGES Fortran C)

使用execute_process为项目获取当且使用者的信息:

1
2
3
4
5
6
7
8
9
execute_process(
COMMAND
whoami
TIMEOUT
1
OUTPUT_VARIABLE
_user_name
OUTPUT_STRIP_TRAILING_WHITESPACE
)

使用cmake_host_system_information()函数(已经在第2章第5节遇到过),可以查询很多系统信息:

1
2
3
4
5
6
7
8
9
10
11
# host name information
cmake_host_system_information(RESULT _host_name QUERY HOSTNAME)
cmake_host_system_information(RESULT _fqdn QUERY FQDN)
# processor information
cmake_host_system_information(RESULT _processor_name QUERY PROCESSOR_NAME)
cmake_host_system_information(RESULT _processor_description QUERY PROCESSOR_DESCRIPTION)
# os information
cmake_host_system_information(RESULT _os_name QUERY OS_NAME)
cmake_host_system_information(RESULT _os_release QUERY OS_RELEASE)
cmake_host_system_information(RESULT _os_version QUERY OS_VERSION)
cmake_host_system_information(RESULT _os_platform QUERY OS_PLATFORM)

捕获配置时的时间戳,并通过使用字符串操作函数:

1
string(TIMESTAMP _configuration_time "%Y-%m-%d %H:%M:%S [UTC]" UTC)

现在,准备好配置模板文件print_info.c.in。通过CMake的configure_file函数生成代码。注意,这里只要求以@开头和结尾的字符串被替换:

1
configure_file(print_info.c.in print_info.c @ONLY)

最后,我们添加一个可执行目标,并定义目标源:

1
2
3
4
5
6
add_executable(example "")
target_sources(example
PRIVATE
example.f90
${CMAKE_CURRENT_BINARY_DIR}/print_info.c
)

下面是一个输出示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./example
Configuration and build information
-----------------------------------
Who compiled | somebody
Compilation hostname | laptop
Fully qualified domain name | laptop
Operating system | Linux, 4.16.13-1-ARCH, #1 SMP PREEMPT Thu May 31 23:29:29 UTC 2018
Platform | x86_64
Processor info | Unknown P6 family, 2 core Intel(R) Core(TM) i5-5200U CPU @ 2.20GHz
CMake version | 3.11.3
CMake generator | Unix Makefiles
Configuration time | 2018-06-25 15:38:03 [UTC]
Fortran compiler | /usr/bin/f95
C compiler | /usr/bin/cc

工作原理

configure_file命令可以复制文件,并用变量值替换它们的内容。示例中,使用configure_file修改模板文件的内容,并将其复制到一个位置,然后将其编译到可执行文件中。如何调用configure_file:

1
configure_file(print_info.c.in print_info.c @ONLY)

第一个参数是模板的名称为print_info.c.in。CMake假设输入文件的目录,与项目的根目录相对;也就是说,在${CMAKE_CURRENT_SOURCE_DIR}/print_info.c.in。我们选择print_info.c,作为第二个参数是配置文件的名称。假设输出文件位于相对于项目构建目录的位置:${CMAKE_CURRENT_BINARY_DIR}/print_info.c

输入和输出文件作为参数时,CMake不仅将配置@VAR@变量,还将配置${VAR}变量。如果${VAR}是语法的一部分,并且不应该修改(例如在shell脚本中),那么就很不方便。为了在引导CMake,应该将选项@ONLY传递给configure_file的调用,如前所述。

记录项目版本信息以便报告

代码版本很重要,不仅是为了可重复性,还为了记录API功能或简化支持请求和bug报告。源代码通常处于某种版本控制之下,例如:可以使用Git标记附加额外版本号(参见https://semver.org )。然而,不仅需要对源代码进行版本控制,而且可执行文件还需要记录项目版本,以便将其打印到代码输出或用户界面上。

本例中,将在CMake源文件中定义版本号。我们的目标是在配置项目时将程序版本记录到头文件中。然后,生成的头文件可以包含在代码的正确位置和时间,以便将代码版本打印到输出文件或屏幕上。

准备工作

将使用以下C文件(example.c)打印版本信息:

1
2
3
4
5
6
7
8
#include "version.h"
#include <stdio.h>
int main() {
printf("This is output from code %s\n", PROJECT_VERSION);
printf("Major version number: %i\n", PROJECT_VERSION_MAJOR);
printf("Minor version number: %i\n", PROJECT_VERSION_MINOR);
printf("Hello CMake world!\n");
}

这里,假设PROJECT_VERSION_MAJORPROJECT_VERSION_MINORPROJECT_VERSION是在version.h中定义的。目标是从以下模板中生成version.h.in:

1
2
3
4
5
#pragma once
#define PROJECT_VERSION_MAJOR @PROJECT_VERSION_MAJOR@
#define PROJECT_VERSION_MINOR @PROJECT_VERSION_MINOR@
#define PROJECT_VERSION_PATCH @PROJECT_VERSION_PATCH@
#define PROJECT_VERSION "v@PROJECT_VERSION@"

这里使用预处理器定义,也可以使用字符串或整数常量来提高类型安全性(稍后我们将对此进行演示)。从CMake的角度来看,这两种方法是相同的。

如何实施

我们将按照以下步骤,在模板头文件中对版本进行注册:

要跟踪代码版本,我们可以在CMakeLists.txt中调用CMake的project时定义项目版本:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-04 VERSION 2.0.1 LANGUAGES C)

然后,基于version.h.in生成version.h:

1
2
3
4
5
configure_file(
version.h.in
generated/version.h
@ONLY
)

最后,我们定义了可执行文件,并提供了目标包含路径:

1
2
3
4
5
add_executable(example example.c)
target_include_directories(example
PRIVATE
${CMAKE_CURRENT_BINARY_DIR}/generated
)

工作原理

当使用版本参数调用CMake的project时,CMake将为项目设置PROJECT_VERSION_MAJORPROJECT_VERSION_MINORPROJECT_VERSION_PATCH。此示例中的关键命令是configure_file,它接受一个输入文件(本例中是version.h.in),通过将@之间的占位符替换成对应的CMake变量,生成一个输出文件(本例中是generate/version.h)。它将@PROJECT_VERSION_MAJOR@替换为2,以此类推。使用关键字@ONLY,我们将configure_file限制为只替换@variables@,而不修改${variables}。后一种形式在version.h.in中没有使用。但是,当使用CMake配置shell脚本时,会经常出现。

生成的头文件可以包含在示例代码中,可以打印版本信息:

1
2
3
4
5
6
7
8
9
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./example
This is output from code v2.0.1
Major version number: 2
Minor version number: 0
Hello CMake world!

NOTE:CMake以x.y.z格式给出的版本号,并将变量PROJECT_VERSION<project-name>_VERSION设置为给定的值。此外,PROJECT_VERSION_MAJOR(<project-name>_VERSION_MAJOR),PROJECT_VERSION_MINOR(<project-name>_VERSION_MINOR) PROJECT_VERSION_PATCH(<project-name>_VERSION_PATCH)和PROJECT_VERSION_TWEAK(<project-name>_VERSION_TWEAK),将分别设置为X, Y, Z和t。

配置时记录Git Hash值

大多数现代源代码存储库都使用Git作为版本控制系统进行跟踪,这可以归功于存储库托管平台GitHub的流行。因此,我们将在本示例中使用Git;然而,实际中会根据具体的动机和实现,可以转化为其他版本控制系统。我们以Git为例,提交的Git Hash决定了源代码的状态。因此,为了标记可执行文件,我们将尝试将Git Hash记录到可执行文件中,方法是将哈希字符串记录在一个头文件中,该头文件可以包含在代码中。

准备工作

我们需要两个源文件,类似于前面的示例。其中一个将配置记录的Hash(version.hpp.in),详情如下:

1
2
3
#pragma once
#include <string>
const std::string GIT_HASH = "@GIT_HASH@";

还需要一个示例源文件(example.cpp),将Hash打印到屏幕上:

1
2
3
4
5
#include "version.hpp"
#include <iostream>
int main() {
std::cout << "This code has been configured from version " << GIT_HASH << std::endl;
}

此示例还假定在Git存储库中至少有一个提交。因此,使用git init初始化这个示例,并使用git add <filename>,然后使用git commit创建提交,以便获得一个有意义的示例。

具体实施

下面演示了从Git记录版本信息的步骤:

定义项目和支持语言:

1
2
3
4
5
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-06 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

定义GIT_HASH变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# in case Git is not available, we default to "unknown"
set(GIT_HASH "unknown")
# find Git and if available set GIT_HASH variable
find_package(Git QUIET)
if(GIT_FOUND)
execute_process(
COMMAND ${GIT_EXECUTABLE} log -1 --pretty=format:%h
OUTPUT_VARIABLE GIT_HASH
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET
WORKING_DIRECTORY
${CMAKE_CURRENT_SOURCE_DIR}
)
endif()
message(STATUS "Git hash is ${GIT_HASH}")

CMakeLists.txt剩余的部分,类似于之前的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
# generate file version.hpp based on version.hpp.in
configure_file(
version.hpp.in
generated/version.hpp
@ONLY
)
# example code
add_executable(example example.cpp)
# needs to find the generated header file
target_include_directories(example
PRIVATE
${CMAKE_CURRENT_BINARY_DIR}/generated
)

验证输出(Hash不同):

1
2
3
4
5
6
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./example
This code has been configured from version d58c64f

工作原理

使用find_package(Git QUIET)来检测系统上是否有可用的Git。如果有(GIT_FOUND为True),运行一个Git命令:${GIT_EXECUTABLE} log -1 --pretty=format:%h。这个命令给出了当前提交Hash的简短版本。当然,这里我们可以灵活地运行Git命令。我们要求execute_process命令将结果放入名为GIT_HASH的变量中,然后删除任何尾随的空格。使用ERROR_QUIET,如果Git命令由于某种原因失败,我们不会停止配置。

由于Git命令可能会失败(源代码已经分发到Git存储库之外),或者Git在系统上不可用,我们希望为这个变量设置一个默认值,如下所示:

1
set(GIT_HASH "unknown")

构建项目

使用函数和宏重用代码

任何编程语言中,函数允许我们抽象(隐藏)细节并避免代码重复,CMake也不例外。本示例中,我们将以宏和函数为例进行讨论,并介绍一个宏,以便方便地定义测试和设置测试的顺序。我们的目标是定义一个宏,能够替换add_test和set_tests_properties,用于定义每组和设置每个测试的预期开销。

准备工作

我们将基于第4章第2节中的例子。main.cpp、sum_integers.cpp和sum_integers.hpp文件不变,用来计算命令行参数提供的整数队列的和。单元测试(test.cpp)的源代码也没有改变。我们还需要Catch 2头文件,catch.hpp。与第4章相反,我们将把源文件放到子目录中,并形成以下文件树:

1
2
3
4
5
6
7
8
9
10
├── CMakeLists.txt
├── src
│ ├── CMakeLists.txt
│ ├── main.cpp
│ ├── sum_integers.cpp
│ └── sum_integers.hpp
└── tests
├── catch.hpp
├── CMakeLists.txt
└── test.cpp

具体实施

定义了CMake最低版本、项目名称和支持的语言,并要求支持C++11标准:

1
2
3
4
5
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-01 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

根据GNU标准定义binary和library路径:

1
2
3
4
5
6
7
include(GNUInstallDirs)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY
${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY
${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_BINDIR})

最后,使用add_subdirectory调用src/CMakeLists.txt和tests/CMakeLists.txt:

1
2
3
add_subdirectory(src)
enable_testing()
add_subdirectory(tests)

src/CMakeLists.txt定义了源码目标:

1
2
3
4
set(CMAKE_INCLUDE_CURRENT_DIR_IN_INTERFACE ON)
add_library(sum_integers sum_integers.cpp)
add_executable(sum_up main.cpp)
target_link_libraries(sum_up sum_integers)

tests/CMakeLists.txt中,构建并链接cpp_test可执行文件:

1
2
add_executable(cpp_test test.cpp)
target_link_libraries(cpp_test sum_integers)

定义一个新宏add_catch_test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
macro(add_catch_test _name _cost)
math(EXPR num_macro_calls "${num_macro_calls} + 1")
message(STATUS "add_catch_test called with ${ARGC} arguments: ${ARGV}")
set(_argn "${ARGN}")
if(_argn)
message(STATUS "oops - macro received argument(s) we did not expect: ${ARGN}")
endif()
add_test(
NAME
${_name}
COMMAND
$<TARGET_FILE:cpp_test>
[${_name}] --success --out
${PROJECT_BINARY_DIR}/tests/${_name}.log --durations yes
WORKING_DIRECTORY
${CMAKE_CURRENT_BINARY_DIR}
)
set_tests_properties(
${_name}
PROPERTIES
COST ${_cost}
)
endmacro()

最后,使用add_catch_test定义了两个测试。此外,还设置和打印了变量的值:

1
2
3
4
set(num_macro_calls 0)
add_catch_test(short 1.5)
add_catch_test(long 2.5 extra_argument)
message(STATUS "in total there were ${num_macro_calls} calls to add_catch_test")

现在,进行测试。配置项目(输出行如下所示):

1
2
3
4
5
6
7
8
9
$ mkdir -p build
$ cd build
$ cmake ..
-- ...
-- add_catch_test called with 2 arguments: short;1.5
-- add_catch_test called with 3 arguments: long;2.5;extra_argument
-- oops - macro received argument(s) we did not expect: extra_argument
-- in total there were 2 calls to add_catch_test
-- ...

最后,构建并运行测试:

1
2
$ cmake --build .
$ ctest

长时间的测试会先开始:

1
2
3
4
5
Start 2: long
1/2 Test #2: long ............................. Passed 0.00 sec
Start 1: short
2/2 Test #1: short ............................ Passed 0.00 sec
100% tests passed, 0 tests failed out of 2

工作原理

这个配置中的新添加了add_catch_test宏。这个宏需要两个参数_name和_cost,可以在宏中使用这些参数来调用add_test和set_tests_properties。参数前面的下划线,是为了向读者表明这些参数只能在宏中访问。另外,宏自动填充了${ARGC}(参数数量)和${ARGV}(参数列表),我们可以在输出中验证了这一点:

  • -- add_catch_test called with 2 arguments: short;1.5
  • -- add_catch_test called with 3 arguments: long;2.5;extra_argument

宏还定义了${ARGN},用于保存最后一个参数之后的参数列表。此外,我们还可以使用${ARGV0}${ARGV1}等来处理参数。我们演示一下,如何捕捉到调用中的额外参数(extra_argument):

1
add_catch_test(long 2.5 extra_argument)

我们使用了以下方法:

1
2
3
4
set(_argn "${ARGN}")
if(_argn)
message(STATUS "oops - macro received argument(s) we did not expect: ${ARGN}")
endif()

这个if语句中,我们引入一个新变量,但不能直接查询ARGN,因为它不是通常意义上的CMake变量。使用这个宏,我们可以通过它们的名称和命令来定义测试,还可以指示预期的开销,这会让耗时长的测试在耗时短测试之前启动,这要归功于COST属性。

我们可以用一个函数来实现它,而不是使用相同语法的宏:

1
2
3
function(add_catch_test _name _cost)
...
endfunction()

宏和函数之间的区别在于它们的变量范围。宏在调用者的范围内执行,而函数有自己的变量范围。换句话说,如果我们使用宏,需要设置或修改对调用者可用的变量。如果不去设置或修改输出变量,最好使用函数。我们注意到,可以在函数中修改父作用域变量,但这必须使用PARENT_SCOPE显式表示:

1
set(variable_visible_outside "some value" PARENT_SCOPE)

为了演示作用域,我们在定义宏之后编写了以下调用:

1
2
3
4
set(num_macro_calls 0)
add_catch_test(short 1.5)
add_catch_test(long 2.5 extra_argument)
message(STATUS "in total there were ${num_macro_calls} calls to add_catch_test")

在宏内部,将num_macro_calls加1:

1
math(EXPR num_macro_calls "${num_macro_calls} + 1")

这时产生的输出:

1
-- in total there were 2 calls to add_catch_test

如果我们将宏更改为函数,测试仍然可以工作,但是num_macro_calls在父范围内的所有调用中始终为0。将CMake宏想象成类似函数是很有用的,这些函数被直接替换到它们被调用的地方(在C语言中内联)。将CMake函数想象成黑盒函数很有必要。黑盒中,除非显式地将其定义为PARENT_SCOPE,否则不会返回任何内容。CMake中的函数没有返回值。

更多信息

可以在宏中嵌套函数调用,也可以在函数中嵌套宏调用,但是这就需要仔细考虑变量的作用范围。如果功能可以使用函数实现,那么这可能比宏更好,因为它对父范围状态提供了更多的默认控制。

我们还应该提到在src/cmakelist.txt中使用CMAKE_INCLUDE_CURRENT_DIR_IN_INTERFACE:

1
set(CMAKE_INCLUDE_CURRENT_DIR_IN_INTERFACE ON)

这个命令会将当前目录,添加到CMakeLists.txt中定义的所有目标的interface_include_directory属性中。换句话说,我们不需要使用target_include_directory来添加cpp_test所需头文件的位置。

将CMake源代码分成模块

项目通常从单个CMakeLists.txt文件开始,随着时间的推移,这个文件会逐渐增长。本示例中,我们将演示一种将CMakeLists.txt分割成更小单元的机制。将CMakeLists.txt拆分为模块有几个动机,这些模块可以包含在主CMakeLists.txt或其他模块中:

  • 主CMakeLists.txt更易于阅读。
  • CMake模块可以在其他项目中重用。
  • 与函数相结合,模块可以帮助我们限制变量的作用范围。

本示例中,我们将演示如何定义和包含一个宏,该宏允许我们获得CMake的彩色输出(用于重要的状态消息或警告)。

准备工作

本例中,我们将使用两个文件,主CMakeLists.txt和cmake/colors.cmake:

1
2
3
├── cmake
│ └── colors.cmake
└── CMakeLists.txt

cmake/colors.cmake文件包含彩色输出的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# colorize CMake output
# code adapted from stackoverflow: http://stackoverflow.com/a/19578320
# from post authored by https://stackoverflow.com/users/2556117/fraser
macro(define_colors)
if(WIN32)
# has no effect on WIN32
set(ColourReset "")
set(ColourBold "")
set(Red "")
set(Green "")
set(Yellow "")
set(Blue "")
set(Magenta "")
set(Cyan "")
set(White "")
set(BoldRed "")
set(BoldGreen "")
set(BoldYellow "")
set(BoldBlue "")
set(BoldMagenta "")
set(BoldCyan "")
set(BoldWhite "")
else()
string(ASCII 27 Esc)
set(ColourReset "${Esc}[m")
set(ColourBold "${Esc}[1m")
set(Red "${Esc}[31m")
set(Green "${Esc}[32m")
set(Yellow "${Esc}[33m")
set(Blue "${Esc}[34m")
set(Magenta "${Esc}[35m")
set(Cyan "${Esc}[36m")
set(White "${Esc}[37m")
set(BoldRed "${Esc}[1;31m")
set(BoldGreen "${Esc}[1;32m")
set(BoldYellow "${Esc}[1;33m")
set(BoldBlue "${Esc}[1;34m")
set(BoldMagenta "${Esc}[1;35m")
set(BoldCyan "${Esc}[1;36m")
set(BoldWhite "${Esc}[1;37m")
endif()
endmacro()

具体实施

来看下我们如何使用颜色定义,来生成彩色状态消息:

从一个熟悉的头部开始:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-02 LANGUAGES NONE)

然后,将cmake子目录添加到CMake模块搜索的路径列表中:

1
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")

包括colors.cmake模块,调用其中定义的宏:

1
2
include(colors)
define_colors()

最后,打印了不同颜色的信息:

1
2
3
4
5
message(STATUS "This is a normal message")
message(STATUS "${Red}This is a red${ColourReset}")
message(STATUS "${BoldRed}This is a bold red${ColourReset}")
message(STATUS "${Green}This is a green${ColourReset}")
message(STATUS "${BoldMagenta}This is bold${ColourReset}")

工作原理

这个例子中,不需要编译代码,也不需要语言支持,我们已经用LANGUAGES NONE明确了这一点:

1
project(recipe-02 LANGUAGES NONE)

我们定义了define_colors宏,并将其放在cmake/colors.cmake。因为还是希望使用调用宏中定义的变量,来更改消息中的颜色,所以我们选择使用宏而不是函数。我们使用以下行包括宏和调用define_colors:

1
2
include(colors)
define_colors()

我们还需要告诉CMake去哪里查找宏:

1
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")

include(colors)命令指示CMake搜索${CMAKE_MODULE_PATH},查找名称为colors.cmake的模块。

例子中,我们没有按以下的方式进行:

1
2
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
include(colors)

而是使用一个显式包含的方式:

1
include(cmake/colors.cmake)

更多信息

推荐的做法是在模块中定义宏或函数,然后调用宏或函数。将包含模块用作函数调用不是很好的方式。除了定义函数和宏以及查找程序、库和路径之外,包含模块不应该做更多的事情。实际的include命令不应该定义或修改变量,其原因是重复的include(可能是偶然的)不应该引入任何不想要的副作用。

编写函数来测试和设置编译器标志

前两个示例中,我们使用了宏。本示例中,将使用一个函数来抽象细节并避免代码重复。我们将实现一个接受编译器标志列表的函数。该函数将尝试用这些标志逐个编译测试代码,并返回编译器理解的第一个标志。这样,我们将了解几个新特性:函数、列表操作、字符串操作,以及检查编译器是否支持相应的标志。

准备工作

按照上一个示例的推荐,我们将在(set_compiler_flag.cmake)模块中定义函数,然后调用函数。该模块包含以下代码,我们将在后面详细讨论:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
include(CheckCCompilerFlag)
include(CheckCXXCompilerFlag)
include(CheckFortranCompilerFlag)
function(set_compiler_flag _result _lang)
# build a list of flags from the arguments
set(_list_of_flags)
# also figure out whether the function
# is required to find a flag
set(_flag_is_required FALSE)
foreach(_arg IN ITEMS ${ARGN})
string(TOUPPER "${_arg}" _arg_uppercase)
if(_arg_uppercase STREQUAL "REQUIRED")
set(_flag_is_required TRUE)
else()
list(APPEND _list_of_flags "${_arg}")
endif()
endforeach()
set(_flag_found FALSE)
# loop over all flags, try to find the first which works
foreach(flag IN ITEMS ${_list_of_flags})
unset(_flag_works CACHE)
if(_lang STREQUAL "C")
check_c_compiler_flag("${flag}" _flag_works)
elseif(_lang STREQUAL "CXX")
check_cxx_compiler_flag("${flag}" _flag_works)
elseif(_lang STREQUAL "Fortran")
check_Fortran_compiler_flag("${flag}" _flag_works)
else()
message(FATAL_ERROR "Unknown language in set_compiler_flag: ${_lang}")
endif()
# if the flag works, use it, and exit
# otherwise try next flag
if(_flag_works)
set(${_result} "${flag}" PARENT_SCOPE)
set(_flag_found TRUE)
break()
endif()
endforeach()
# raise an error if no flag was found
if(_flag_is_required AND NOT _flag_found)
message(FATAL_ERROR "None of the required flags were supported")
endif()
endfunction()

具体实施

展示如何在CMakeLists.txt中使用set_compiler_flag函数:

定义最低CMake版本、项目名称和支持的语言(本例中是C和C++):

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-03 LANGUAGES C CXX)

显示包含set_compiler_flag.cmake:

1
include(set_compiler_flag.cmake)

测试C标志列表:

1
2
3
4
5
6
7
8
9
10
11
set_compiler_flag(
working_compile_flag C REQUIRED
"-foo" # this should fail
"-wrong" # this should fail
"-wrong" # this should fail
"-Wall" # this should work with GNU
"-warn all" # this should work with Intel
"-Minform=inform" # this should work with PGI
"-nope" # this should fail
)
message(STATUS "working C compile flag: ${working_compile_flag}")

测试C++标志列表:

1
2
3
4
5
6
7
set_compiler_flag(
working_compile_flag CXX REQUIRED
"-foo" # this should fail
"-g" # this should work with GNU, Intel, PGI
"/RTCcsu" # this should work with MSVC
)
message(STATUS "working CXX compile flag: ${working_compile_flag}")

现在,我们可以配置项目并验证输出。只显示相关的输出,相应的输出可能会因编译器的不同而有所不同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ mkdir -p build
$ cd build
$ cmake ..
-- ...
-- Performing Test _flag_works
-- Performing Test _flag_works - Failed
-- Performing Test _flag_works
-- Performing Test _flag_works - Failed
-- Performing Test _flag_works
-- Performing Test _flag_works - Failed
-- Performing Test _flag_works
-- Performing Test _flag_works - Success
-- working C compile flag: -Wall
-- Performing Test _flag_works
-- Performing Test _flag_works - Failed
-- Performing Test _flag_works
-- Performing Test _flag_works - Success
-- working CXX compile flag: -g
-- ...

工作原理

这里使用的模式是:

  • 定义一个函数或宏,并将其放入模块中
  • 包含模块
  • 调用函数或宏

从输出中,可以看到代码检查列表中的每个标志。一旦检查成功,它就打印成功的编译标志。看看set_compiler_flag.cmake模块的内部,这个模块又包含三个模块:

1
2
3
include(CheckCCompilerFlag)
include(CheckCXXCompilerFlag)
include(CheckFortranCompilerFlag)

这都是标准的CMake模块,CMake将在${CMAKE_MODULE_PATH}中找到它们。这些模块分别提供check_c_compiler_flagcheck_cxx_compiler_flagcheck_fortran_compiler_flag宏。然后定义函数:

1
2
3
function(set_compiler_flag _result _lang)
...
endfunction()

set_compiler_flag函数需要两个参数,_result(保存成功编译标志或为空字符串)和_lang(指定语言:C、C++或Fortran)。

我们也能这样调用函数:

1
set_compiler_flag(working_compile_flag C REQUIRED "-Wall" "-warn all")

这里有五个调用参数,但是函数头只需要两个参数。这意味着REQUIRED、-Wall和-warn all将放在${ARGN}中。从${ARGN}开始,我们首先使用foreach构建一个标志列表。同时,从标志列表中过滤出REQUIRED,并使用它来设置_flag_is_required:

1
2
3
4
5
6
7
8
9
10
11
12
13
# build a list of flags from the arguments
set(_list_of_flags)
# also figure out whether the function
# is required to find a flag
set(_flag_is_required FALSE)
foreach(_arg IN ITEMS ${ARGN})
string(TOUPPER "${_arg}" _arg_uppercase)
if(_arg_uppercase STREQUAL "REQUIRED")
set(_flag_is_required TRUE)
else()
list(APPEND _list_of_flags "${_arg}")
endif()
endforeach()

现在,我们将循环${_list_of_flags},尝试每个标志,如果_flag_works被设置为TRUE,我们将_flag_found设置为TRUE,并中止进一步的搜索:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
set(_flag_found FALSE)
# loop over all flags, try to find the first which works
foreach(flag IN ITEMS ${_list_of_flags})
unset(_flag_works CACHE)
if(_lang STREQUAL "C")
check_c_compiler_flag("${flag}" _flag_works)
elseif(_lang STREQUAL "CXX")
check_cxx_compiler_flag("${flag}" _flag_works)
elseif(_lang STREQUAL "Fortran")
check_Fortran_compiler_flag("${flag}" _flag_works)
else()
message(FATAL_ERROR "Unknown language in set_compiler_flag: ${_lang}")
endif()
# if the flag works, use it, and exit
# otherwise try next flag
if(_flag_works)
set(${_result} "${flag}" PARENT_SCOPE)
set(_flag_found TRUE)
break()
endif()
endforeach()

unset(_flag_works CACHE)确保check_*_compiler_flag的结果,不会在使用_flag_works result变量时,使用的是缓存结果。

如果找到了标志,并且_flag_works设置为TRUE,我们就将_result映射到的变量:

1
set(${_result} "${flag}" PARENT_SCOPE)

这需要使用PARENT_SCOPE来完成,因为我们正在修改一个变量,希望打印并在函数体外部使用该变量。请注意,如何使用${_result}语法解引用,从父范围传递的变量_result的值。不管函数的名称是什么,这对于确保工作标志被设置非常有必要。如果没有找到任何标志,并且该标志设置了REQUIRED,那我们将使用一条错误消息停止配置:

1
2
3
4
# raise an error if no flag was found
if(_flag_is_required AND NOT _flag_found)
message(FATAL_ERROR "None of the required flags were supported")
endif()

更多信息

我们也可以使用宏来完成这个任务,而使用函数可以对范围有更多的控制。我们知道函数只能可以修改结果变量。

另外,需要在编译和链接时设置一些标志,方法是为check_<lang>_compiler_flag函数设置CMAKE_REQUIRED_FLAGS

用指定参数定义函数或宏

前面的示例中,我们研究了函数和宏,并使用了位置参数。这个示例中,我们将定义一个带有命名参数的函数。我们将复用第1节中的示例,使用函数和宏重用代码,而不是使用以下代码定义测试:add_catch_test(short 1.5)。

我们将这样调用函数:

1
2
3
4
5
6
7
8
9
add_catch_test(
NAME
short
LABELS
short
cpp_test
COST
1.5
)

准备工作

我们使用第1节中的示例,使用函数和宏重用代码,并保持C++源代码不变,文件树保持不变:

1
2
3
4
5
6
7
8
9
10
11
12
├── cmake
│ └── testing.cmake
├── CMakeLists.txt
├── src
│ ├── CMakeLists.txt
│ ├── main.cpp
│ ├── sum_integers.cpp
│ └── sum_integers.hpp
└── tests
├── catch.hpp
├── CMakeLists.txt
└── test.cpp

具体实施

我们对CMake代码进行一些修改,如下所示:

CMakeLists.txt顶部中只增加了一行,因为我们将包括位于cmake下面的模块:

1
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")

保持src/CMakeLists.txt。

tests/CMakeLists.txt中,将add_catch_test函数定义移动到cmake/testing.cmake,并且定义两个测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
add_executable(cpp_test test.cpp)
target_link_libraries(cpp_test sum_integers)
include(testing)
add_catch_test(
NAME
short
LABELS
short
cpp_test
COST
1.5
)
add_catch_test(
NAME
long
LABELS
long
cpp_test
COST
2.5
)

add_catch_test在cmake/testing.cmake中定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
function(add_catch_test)
set(options)
set(oneValueArgs NAME COST)
set(multiValueArgs LABELS DEPENDS REFERENCE_FILES)
cmake_parse_arguments(add_catch_test
"${options}"
"${oneValueArgs}"
"${multiValueArgs}"
${ARGN}
)
message(STATUS "defining a test ...")
message(STATUS " NAME: ${add_catch_test_NAME}")
message(STATUS " LABELS: ${add_catch_test_LABELS}")
message(STATUS " COST: ${add_catch_test_COST}")
message(STATUS " REFERENCE_FILES: ${add_catch_test_REFERENCE_FILES}")
add_test(
NAME
${add_catch_test_NAME}
COMMAND
$<TARGET_FILE:cpp_test>
[${add_catch_test_NAME}] --success --out
${PROJECT_BINARY_DIR}/tests/${add_catch_test_NAME}.log --durations yes
WORKING_DIRECTORY
${CMAKE_CURRENT_BINARY_DIR}
)
set_tests_properties(${add_catch_test_NAME}
PROPERTIES
LABELS "${add_catch_test_LABELS}"
)
if(add_catch_test_COST)
set_tests_properties(${add_catch_test_NAME}
PROPERTIES
COST ${add_catch_test_COST}
)
endif()
if(add_catch_test_DEPENDS)
set_tests_properties(${add_catch_test_NAME}
PROPERTIES
DEPENDS ${add_catch_test_DEPENDS}
)
endif()
if(add_catch_test_REFERENCE_FILES)
file(
COPY
${add_catch_test_REFERENCE_FILES}
DESTINATION
${CMAKE_CURRENT_BINARY_DIR}
)
endif()
endfunction()

测试输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ mkdir -p build
$ cd build
$ cmake ..
-- ...
-- defining a test ...
-- NAME: short
-- LABELS: short;cpp_test
-- COST: 1.5
-- REFERENCE_FILES:
-- defining a test ...
-- NAME: long
-- LABELS: long;cpp_test
-- COST: 2.5
-- REFERENCE_FILES:
-- ...

最后,编译并测试:

1
2
$ cmake --build .
$ ctest

工作原理

示例的特点是其命名参数,因此我们可以将重点放在cmake/testing.cmake模块上。CMake提供cmake_parse_arguments命令,我们使用函数名(add_catch_test)选项(我们的例子中是none)、单值参数(NAME和COST)和多值参数(LABELS、DEPENDS和REFERENCE_FILES)调用该命令:

1
2
3
4
5
6
7
8
9
10
11
12
function(add_catch_test)
set(options)
set(oneValueArgs NAME COST)
set(multiValueArgs LABELS DEPENDS REFERENCE_FILES)
cmake_parse_arguments(add_catch_test
"${options}"
"${oneValueArgs}"
"${multiValueArgs}"
${ARGN}
)
...
endfunction()

cmake_parse_arguments命令解析选项和参数,并在例子中定义如下:

1
2
3
4
5
add_catch_test_NAME
add_catch_test_COST
add_catch_test_LABELS
add_catch_test_DEPENDS
add_catch_test_REFERENCE_FILES

可以查询,并在函数中使用这些变量。这种方法使我们有机会用更健壮的接口和更具有可读的函数/宏调用,来实现函数和宏。

更多信息

选项关键字(本例中我们没有使用)由cmake_parse_arguments定义为TRUE或FALSE。add_catch_test函数,还提供test命令作为一个命名参数,为了更简洁的演示,我们省略了这个参数。

TIPS:cmake_parse_arguments命令在cmake 3.5的版本前中的CMakeParseArguments.cmake定义。因此,可以在CMake/test.cmake顶部的使用include(CMakeParseArguments)命令使此示例能与CMake早期版本一起工作。

重新定义函数和宏

我们已经提到模块包含不应该用作函数调用,因为模块可能被包含多次。本示例中,我们将编写我们自己的“包含保护”机制,如果多次包含一个模块,将触发警告。内置的include_guard命令从3.10版开始可以使用,对于C/C++头文件,它的行为就像#pragma一样。对于当前版本的CMake,我们将演示如何重新定义函数和宏,并且展示如何检查CMake版本,对于低于3.10的版本,我们将使用定制的“包含保护”机制。

准备工作

这个例子中,我们将使用三个文件:

1
2
3
4
5
.
├── cmake
│ ├── custom.cmake
│ └── include_guard.cmake
└── CMakeLists.txt

custom.cmake模块包含以下代码:

1
2
include_guard(GLOBAL)
message(STATUS "custom.cmake is included and processed")

我们稍后会对cmake/include_guard.cmake进行讨论。

具体实施

我们对三个CMake文件的逐步分解:

示例中,我们不会编译任何代码,因此我们的语言要求是NONE:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-05 LANGUAGES NONE)

定义一个include_guard宏,将其放在一个单独的模块中:

1
2
# (re)defines include_guard
include(cmake/include_guard.cmake)

cmake/include_guard.cmake文件包含以下内容(稍后将详细讨论):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
macro(include_guard)
if (CMAKE_VERSION VERSION_LESS "3.10")
# for CMake below 3.10 we define our
# own include_guard(GLOBAL)
message(STATUS "calling our custom include_guard")
# if this macro is called the first time
# we start with an empty list
if(NOT DEFINED included_modules)
set(included_modules)
endif()
if ("${CMAKE_CURRENT_LIST_FILE}" IN_LIST included_modules)
message(WARNING "module ${CMAKE_CURRENT_LIST_FILE} processed more than once")
endif()
list(APPEND included_modules ${CMAKE_CURRENT_LIST_FILE})
else()
# for CMake 3.10 or higher we augment
# the built-in include_guard
message(STATUS "calling the built-in include_guard")
_include_guard(${ARGV})
endif()
endmacro()

主CMakeLists.txt中,我们模拟了两次包含自定义模块的情况:

1
2
include(cmake/custom.cmake)
include(cmake/custom.cmake)

最后,使用以下命令进行配置:

1
2
3
$ mkdir -p build
$ cd build
$ cmake ..

使用CMake 3.10及更高版本的结果如下:

1
2
3
-- calling the built-in include_guard
-- custom.cmake is included and processed
-- calling the built-in include_guard

使用CMake得到3.10以下的结果如下:

1
2
3
4
5
6
7
8
9
10
- calling our custom include_guard
-- custom.cmake is included and processed
-- calling our custom include_guard
CMake Warning at cmake/include_guard.cmake:7 (message):
module
/home/user/example/cmake/custom.cmake
processed more than once
Call Stack (most recent call first):
cmake/custom.cmake:1 (include_guard)
CMakeLists.txt:12 (include)

工作原理

include_guard宏包含两个分支,一个用于CMake低于3.10,另一个用于CMake高于3.10:

1
2
3
4
5
6
7
macro(include_guard)
if (CMAKE_VERSION VERSION_LESS "3.10")
# ...
else()
# ...
endif()
endmacro()

如果CMake版本低于3.10,进入第一个分支,并且内置的include_guard不可用,所以我们自定义了一个:

1
2
3
4
5
6
7
8
9
10
message(STATUS "calling our custom include_guard")
# if this macro is called the first time
# we start with an empty list
if(NOT DEFINED included_modules)
set(included_modules)
endif()
if ("${CMAKE_CURRENT_LIST_FILE}" IN_LIST included_modules)
message(WARNING "module ${CMAKE_CURRENT_LIST_FILE} processed more than once")
endif()
list(APPEND included_modules ${CMAKE_CURRENT_LIST_FILE})

如果第一次调用宏,则included_modules变量没有定义,因此我们将其设置为空列表。然后检查${CMAKE_CURRENT_LIST_FILE}是否是included_modules列表中的元素。如果是,则会发出警告;如果没有,我们将${CMAKE_CURRENT_LIST_FILE}追加到这个列表。CMake输出中,我们可以验证自定义模块的第二个包含确实会导致警告。

CMake 3.10及更高版本的情况有所不同;在这种情况下,存在一个内置的include_guard,我们用自己的宏接收到参数并调用它:

1
2
3
4
5
6
7
8
macro(include_guard)
if (CMAKE_VERSION VERSION_LESS "3.10")
# ...
else()
message(STATUS "calling the built-in include_guard")
_include_guard(${ARGV})
endif()
endmacro()

这里,_include_guard(${ARGV})指向内置的include_guard。本例中,使用自定义消息(“调用内置的include_guard”)进行了扩展。这种模式为我们提供了一种机制,来重新定义自己的或内置的函数和宏,这对于调试或记录日志来说非常有用。

NOTE:这种模式可能很有用,但是应该谨慎使用,因为CMake不会对重新定义的宏或函数进行警告。

使用废弃函数、宏和变量

“废弃”是在不断发展的项目开发过程中一种重要机制,它向开发人员发出信号,表明将来某个函数、宏或变量将被删除或替换。在一段时间内,函数、宏或变量将继续可访问,但会发出警告,最终可能会上升为错误。

准备工作

我们将从以下CMake项目开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-06 LANGUAGES NONE)
macro(custom_include_guard)
if(NOT DEFINED included_modules)
set(included_modules)
endif()
if ("${CMAKE_CURRENT_LIST_FILE}" IN_LIST included_modules)
message(WARNING "module ${CMAKE_CURRENT_LIST_FILE} processed more than once")
endif()
list(APPEND included_modules ${CMAKE_CURRENT_LIST_FILE})
endmacro()
include(cmake/custom.cmake)
message(STATUS "list of all included modules: ${included_modules}")

这段代码定义了一个自定义的”包含保护”机制,包括一个自定义模块(与前一个示例中的模块相同),并打印所有包含模块的列表。对于CMake 3.10或更高版本有内置的include_guard。但是,不能简单地删除custom_include_guard${included_modules},而是使用一个“废弃”警告来弃用宏和变量。某个时候,可以将该警告转换为FATAL_ERROR,使代码停止配置,并迫使开发人员对代码进行修改,切换到内置命令。

具体实施

“废弃”函数、宏和变量的方法如下:

首先,定义一个函数,我们将使用它来弃用一个变量:

1
2
3
4
5
function(deprecate_variable _variable _access)
if(_access STREQUAL "READ_ACCESS")
message(DEPRECATION "variable ${_variable} is deprecated")
endif()
endfunction()

然后,如果CMake的版本大于3.9,我们重新定义custom_include_guard并将variable_watch附加到included_modules中:

1
2
3
4
5
6
7
8
9
if (CMAKE_VERSION VERSION_GREATER "3.9")
# deprecate custom_include_guard
macro(custom_include_guard)
message(DEPRECATION "custom_include_guard is deprecated - use built-in include_guard instead")
_custom_include_guard(${ARGV})
endmacro()
# deprecate variable included_modules
variable_watch(included_modules deprecate_variable)
endif()

CMake3.10以下版本的项目会产生以下结果:

1
2
3
4
5
$ mkdir -p build
$ cd build
$ cmake ..
-- custom.cmake is included and processed
-- list of all included modules: /home/user/example/cmake/custom.cmake

CMake 3.10及以上将产生预期的“废弃”警告:

1
2
3
4
5
6
7
8
9
10
11
12
CMake Deprecation Warning at CMakeLists.txt:26 (message):
custom_include_guard is deprecated - use built-in include_guard instead
Call Stack (most recent call first):
cmake/custom.cmake:1 (custom_include_guard)
CMakeLists.txt:34 (include)
-- custom.cmake is included and processed
CMake Deprecation Warning at CMakeLists.txt:19 (message):
variable included_modules is deprecated
Call Stack (most recent call first):
CMakeLists.txt:9999 (deprecate_variable)
CMakeLists.txt:36 (message)
-- list of all included modules: /home/user/example/cmake/custom.cmake

工作原理

弃用函数或宏相当于重新定义它,如前面的示例所示,并使用DEPRECATION打印消息:

1
2
3
4
macro(somemacro)
message(DEPRECATION "somemacro is deprecated")
_somemacro(${ARGV})
endmacro()

可以通过定义以下变量来实现对变量的弃用:

1
2
3
4
5
function(deprecate_variable _variable _access)
if(_access STREQUAL "READ_ACCESS")
message(DEPRECATION "variable ${_variable} is deprecated")
endif()
endfunction()

然后,这个函数被添加到将要“废弃”的变量上:

1
variable_watch(somevariable deprecate_variable)

如果在本例中${included_modules}是读取 (READ_ACCESS),那么deprecate_variable函数将发出带有DEPRECATION的消息。

语言混合项目

使用C/C++库构建Fortran项目

本示例将展示如何用C系统库和自定义C代码来对接Fortran代码。

准备工作

第7章中,我们把项目结构列为一个树。每个子目录都有一个CMakeLists.txt文件,其中包含与该目录相关的指令。这使我们可以对子目录进行限制中,如这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
.
├── CMakeLists.txt
└── src
├── bt-randomgen-example.f90
├── CMakeLists.txt
├── interfaces
│ ├── CMakeLists.txt
│ ├── interface_backtrace.f90
│ ├── interface_randomgen.f90
│ └── randomgen.c
└── utils
├── CMakeLists.txt
└── util_strings.f90

我们的例子中,src子目录中包括bt-randomgen-example.f90,会将源码编译成可执行文件。另外两个子目录interface和utils包含更多的源代码,这些源代码将被编译成库。

interfaces子目录中的源代码展示了如何包装向后追踪的C系统库。例如,interface_backtrace.f90:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
module interface_backtrace
implicit none
interface
function backtrace(buffer, size) result(bt) bind(C, name="backtrace")
use, intrinsic :: iso_c_binding, only: c_int, c_ptr
type(c_ptr) :: buffer
integer(c_int), value :: size
integer(c_int) :: bt
end function
subroutine backtrace_symbols_fd(buffer, size, fd) bind(C, name="backtrace_symbols_fd")
use, intrinsic :: iso_c_binding, only: c_int, c_ptr
type(c_ptr) :: buffer
integer(c_int), value :: size, fd
end subroutine
end interface
end module

上面的例子演示了:

  • 内置iso_c_binding模块,确保Fortran和C类型和函数的互操作性。
  • interface声明,将函数在单独库中绑定到相应的符号上。
  • bind(C)属性,为声明的函数进行命名修饰。

这个子目录还包含两个源文件:

  • randomgen.c:这是一个C源文件,它对外公开了一个函数,使用C标准rand函数在一个区间内生成随机整数。
  • interface_randomgen.f90:它将C函数封装在Fortran可执行文件中使用。

具体实施

我们有4个CMakeLists.txt实例要查看——根目录下1个,子目录下3个。让我们从根目录的CMakeLists.txt开始:

声明一个Fortran和C的混合语言项目:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-01 LANGUAGES Fortran C)

CMake将静态库和动态库保存在build目录下的lib目录中。可执行文件保存在bin目录下,Fortran编译模块文件保存在modules目录下:

1
2
3
4
5
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/lib)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/lib)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/bin)
set(CMAKE_Fortran_MODULE_DIRECTORY
${CMAKE_CURRENT_BINARY_DIR}/modules)

接下来,我们进入第一个子CMakeLists.txt,添加src子目录:

1
add_subdirectory(src)

src/CMakeLists.txt文件添加了两个子目录:

1
2
add_subdirectory(interfaces)
add_subdirectory(utils)

在interfaces子目录中,我们将执行以下操作:

包括FortranCInterface.cmak模块,并验证C和Fortran编译器可以正确地交互:

1
2
include(FortranCInterface)
FortranCInterface_VERIFY()

接下来,我们找到Backtrace系统库,因为我们想在Fortran代码中使用它:

1
find_package(Backtrace REQUIRED)

然后,创建一个共享库目标,其中包含Backtrace包装器、随机数生成器,以及Fortran包装器的源文件:

1
2
3
4
5
6
7
add_library(bt-randomgen-wrap SHARED "")
target_sources(bt-randomgen-wrap
PRIVATE
interface_backtrace.f90
interface_randomgen.f90
randomgen.c
)

我们还为新生成的库目标设置了链接库。使用PUBLIC属性,以便连接到其他目标时,能正确地看到依赖关系:

1
2
3
4
target_link_libraries(bt-randomgen-wrap
PUBLIC
${Backtrace_LIBRARIES}
)

utils子目录中,还有一个CMakeLists.txt,其只有一单行程序:我们创建一个新的库目标,子目录中的源文件将被编译到这个目标库中。并与这个目标没有依赖关系:

1
add_library(utils SHARED util_strings.f90)

回到src/CMakeLists.txt:

使用bt-randomgen-example.f90添加一个可执行目标:

1
add_executable(bt-randomgen-example bt-randomgen-example.f90)

最后,将在子CMakeLists.txt中生成的库目标,并链接到可执行目标:

1
2
3
4
5
target_link_libraries(bt-randomgen-example
PRIVATE
bt-randomgen-wrap
utils
)

工作原理

确定链接了正确库之后,需要保证程序能够正确调用函数。每个编译器在生成机器码时都会执行命名检查。不过,这种操作的约定不是通用的,而是与编译器相关的。FortranCInterface,我们已经在第3章第4节时,检查所选C编译器与Fortran编译器的兼容性。对于当前的目的,命名检查并不是一个真正的问题。Fortran 2003标准提供了可选name参数的函数和子例程定义了bind属性。如果提供了这个参数,编译器将使用程序员指定的名称为这些子例程和函数生成符号。例如,backtrace函数可以从C语言中暴露给Fortran,并保留其命名:

1
function backtrace(buffer, size) result(bt) bind(C, name="backtrace")

更多信息

interface/CMakeLists.txt中的CMake代码还表明,可以使用不同语言的源文件创建库。CMake能够做到以下几点:

  • 列出的源文件中获取目标文件,并识别要使用哪个编译器。
  • 选择适当的链接器,以便构建库(或可执行文件)。

CMake如何决定使用哪个编译器?在project命令时使用参数LANGUAGES指定,这样CMake会检查系统上给定语言编译器。当使用源文件列表添加目标时,CMake将根据文件扩展名选择适当地编译器。因此,以.c结尾的文件使用C编译器编译,而以.f90结尾的文件(如果需要预处理,可以使用.F90)将使用Fortran编译器编译。类似地,对于C++, .cpp或.cxx扩展将触发C++编译器。我们只列出了C/C++和Fortran语言的一些可能的、有效的文件扩展名,但是CMake可以识别更多的扩展名。如果您的项目中的文件扩展名,由于某种原因不在可识别的扩展名之列,该怎么办?源文件属性可以用来告诉CMake在特定的源文件上使用哪个编译器,就像这样:

1
2
3
4
set_source_files_properties(my_source_file.axx
PROPERTIES
LANGUAGE CXX
)

那链接器呢?CMake如何确定目标的链接器语言?对于不混合编程语言的目标很简单:通过生成目标文件的编译器命令调用链接器即可。如果目标混合了多个语言,就像示例中一样,则根据在语言混合中,优先级最高的语言来选择链接器语言。比如,我们的示例中混合了Fortran和C,因此Fortran语言比C语言具有更高的优先级,因此使用Fortran用作链接器语言。当混合使用Fortran和C++时,后者具有更高的优先级,因此C++被用作链接器语言。就像编译器语言一样,我们可以通过目标相应的LINKER_LANGUAGE属性,强制CMake为我们的目标使用特定的链接器语言:

1
2
3
4
set_target_properties(my_target
PROPERTIES
LINKER_LANGUAGE Fortran
)

使用Fortran库构建C/C++项目

第3章第4节,展示了如何检测Fortran编写的BLAS和LAPACK线性代数库,以及如何在C++代码中使用它们。这里,将重新讨论这个方式,但这次的角度有所不同:较少地关注检测外部库,会更深入地讨论混合C++和Fortran的方面,以及名称混乱的问题。

准备工作

本示例中,我们将重用第3章第4节源代码。虽然,我们不会修改源码或头文件,但我们会按照第7章“结构化项目”中,讨论的建议修改项目树结构,并得到以下源代码结构:

1
2
3
4
5
6
7
8
9
10
11
12
.
├── CMakeLists.txt
├── README.md
└── src
├── CMakeLists.txt
├── linear-algebra.cpp
└── math
├── CMakeLists.txt
├── CxxBLAS.cpp
├── CxxBLAS.hpp
├── CxxLAPACK.cpp
└── CxxLAPACK.hpp

这里,收集了BLAS和LAPACK的所有包装器,它们提供了src/math下的数学库了,主要程序为linear-algebra.cpp。因此,所有源都在src子目录下。我们还将CMake代码分割为三个CMakeLists.txt文件,现在来讨论这些文件。

具体实施

这个项目混合了C++(作为该示例的主程序语言)和C(封装Fortran子例程所需的语言)。在根目录下的CMakeLists.txt文件中,我们需要做以下操作:

声明一个混合语言项目,并选择C++标准:

1
2
3
4
5
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-02 LANGUAGES CXX C Fortran)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

使用GNUInstallDirs模块来设置CMake将静态和动态库,以及可执行文件保存的标准目录。我们还指示CMake将Fortran编译的模块文件放在modules目录下:

1
2
3
4
5
6
7
8
include(GNUInstallDirs)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY
${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY
${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_BINDIR})
set(CMAKE_Fortran_MODULE_DIRECTORY ${PROJECT_BINARY_DIR}/modules)

然后,进入下一个子目录:

1
add_subdirectory(src)

子文件src/CMakeLists.txt添加了另一个目录math,其中包含线性代数包装器。在src/math/CMakeLists.txt中,我们需要以下操作:

调用find_package来获取BLAS和LAPACK库的位置:

1
2
find_package(BLAS REQUIRED)
find_package(LAPACK REQUIRED)

包含FortranCInterface.cmake模块,并验证Fortran、C和C++编译器是否兼容:

1
2
include(FortranCInterface)
FortranCInterface_VERIFY(CXX)

我们还需要生成预处理器宏来处理BLAS和LAPACK子例程的名称问题。同样,FortranCInterface通过在当前构建目录中生成一个名为fc_mangl.h的头文件来提供协助:

1
2
3
4
5
FortranCInterface_HEADER(
fc_mangle.h
MACRO_NAMESPACE "FC_"
SYMBOLS DSCAL DGESV
)

接下来,添加了一个库,其中包含BLAS和LAPACK包装器的源代码。我们还指定要找到头文件和库的目录。注意PUBLIC属性,它允许其他依赖于math的目标正确地获得它们的依赖关系:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
add_library(math "")
target_sources(math
PRIVATE
CxxBLAS.cpp
CxxLAPACK.cpp
)
target_include_directories(math
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_BINARY_DIR}
)
target_link_libraries(math
PUBLIC
${LAPACK_LIBRARIES}
)

回到src/CMakeLists.txt,我们最终添加了一个可执行目标,并将其链接到BLAS/LAPACK包装器的数学库:

1
2
3
4
5
6
7
8
9
add_executable(linear-algebra "")
target_sources(linear-algebra
PRIVATE
linear-algebra.cpp
)
target_link_libraries(linear- algebra
PRIVATE
math
)

工作原理

使用find_package确定了要链接到的库。方法和之前一样,需要确保程序能够正确地调用它们定义的函数。第3章第4节中,我们面临的问题是编译器的名称符号混乱。我们使用FortranCInterface模块来检查所选的C和C++编译器与Fortran编译器的兼容性。我们还使用FortranCInterface_HEADER函数生成带有宏的头文件,以处理Fortran子例程的名称混乱。并通过以下代码实现:

1
2
3
4
5
FortranCInterface_HEADER(
fc_mangle.h
MACRO_NAMESPACE "FC_"
SYMBOLS DSCAL DGESV
)

这个命令将生成fc_mangl.h头文件,其中包含从Fortran编译器推断的名称混乱宏,并将其保存到当前二进制目录CMAKE_CURRENT_BINARY_DIR中。我们小心地将CMAKE_CURRENT_BINARY_DIR设置为数学目标的包含路径。生成的fc_mangle.h如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#ifndef FC_HEADER_INCLUDED
#define FC_HEADER_INCLUDED
/* Mangling for Fortran global symbols without underscores. */
#define FC_GLOBAL(name,NAME) name##_
/* Mangling for Fortran global symbols with underscores. */
#define FC_GLOBAL_(name,NAME) name##_
/* Mangling for Fortran module symbols without underscores. */
#define FC_MODULE(mod_name,name, mod_NAME,NAME) __##mod_name##_MOD_##name
/* Mangling for Fortran module symbols with underscores. */
#define FC_MODULE_(mod_name,name, mod_NAME,NAME) __##mod_name##_MOD_##name
/* Mangle some symbols automatically. */
#define DSCAL FC_GLOBAL(dscal, DSCAL)
#define DGESV FC_GLOBAL(dgesv, DGESV)
#endif

本例中的编译器使用下划线进行错误处理。由于Fortran不区分大小写,子例程可能以小写或大写出现,这就说明将这两种情况传递给宏的必要性。注意,CMake还将为隐藏在Fortran模块后面的符号生成宏。

NOTE:现在,BLAS和LAPACK的许多实现都在Fortran子例程附带了一个C的包装层。这些包装器已经标准化,分别称为CBLAS和LAPACKE。

由于已经将源组织成库目标和可执行目标,所以我们应该对目标的PUBLIC、INTERFACE和PRIVATE可见性属性的使用进行评论。与源文件一样,包括目录、编译定义和选项,当与target_link_libraries一起使用时,这些属性的含义是相同的:

  • 使用PRIVATE属性,库将只链接到当前目标,而不链接到使用它的任何其他目标。
  • 使用INTERFACE属性,库将只链接到使用当前目标作为依赖项的目标。
  • 使用PUBLIC属性,库将被链接到当前目标,以及将其作为依赖项使用的任何其他目标。

编写安装程序

安装项目

第一个示例中,将介绍我们的小项目和一些基本概念,这些概念也将在后面的示例中使用。安装文件、库和可执行文件是一项非常基础的任务,但是也可能会带来一些缺陷。我们将带您了解这些问题,并展示如何使用CMake有效地避开这些缺陷。

准备工作

第1章第3节的示例,几乎复用:只添加对UUID库的依赖。这个依赖是有条件的,如果没有找到UUID库,我们将通过预处理程序排除使用UUID库的代码。项目布局如下:

1
2
3
4
5
6
7
8
9
.
├── CMakeLists.txt
├── src
│ ├── CMakeLists.txt
│ ├── hello-world.cpp
│ ├── Message.cpp
│ └── Message.hpp
└── tests
└── CMakeLists.txt

我们已经看到,有三个CMakeLists.txt,一个是主CMakeLists.txt,另一个是位于src目录下的,还有一个是位于test目录下的。

Message.hpp头文件包含以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#pragma once
#include <iosfwd>
#include <string>
class Message
{
public:
Message(const std::string &m) : message_(m) {}
friend std::ostream &operator<<(std::ostream &os, Message &obj)
{
return obj.printObject(os);
}
private:
std::string message_;
std::ostream &printObject(std::ostream &os);
};
std::string getUUID();

Message.cpp中有相应的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include "Message.hpp"
#include <iostream>
#include <string>
#ifdef HAVE_UUID
#include <uuid/uuid.h>
#endif
std::ostream &Message::printObject(std::ostream &os)
{
os << "This is my very nice message: " << std::endl;
os << message_ << std::endl;
os << "...and here is its UUID: " << getUUID();
return os;
}
#ifdef HAVE_UUID
std::string getUUID()
{
uuid_t uuid;
uuid_generate(uuid);
char uuid_str[37];
uuid_unparse_lower(uuid, uuid_str);
uuid_clear(uuid);
std::string uuid_cxx(uuid_str);
return uuid_cxx;
}
#else
std::string getUUID()
{
return "Ooooops, no UUID for you!";
}
#endif
最后,示例hello-world.cpp内容如下:

#include <cstdlib>
#include <iostream>
#include "Message.hpp"
int main()
{
Message say_hello("Hello, CMake World!");
std::cout << say_hello << std::endl;
Message say_goodbye("Goodbye, CMake World");
std::cout << say_goodbye << std::endl;
return EXIT_SUCCESS;
}

具体实施

我们先来看一下主CMakeLists.txt:

声明CMake最低版本,并定义一个C++11项目。请注意,我们已经为我们的项目设置了一个版本,在project中使用VERSION进行指定:

1
2
3
4
5
6
7
8
9
10
11
# CMake 3.6 needed for IMPORTED_TARGET option
# to pkg_search_module
cmake_minimum_required(VERSION 3.6 FATAL_ERROR)
project(recipe-01
LANGUAGES CXX
VERSION 1.0.0
)
# <<< General set up >>>
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

用户可以通过CMAKE_INSTALL_PREFIX变量定义安装目录。CMake会给这个变量设置一个默认值:Windows上的C:\Program Files和Unix上的/usr/local。我们将会打印安装目录的信息:

1
message(STATUS "Project will be installed to ${CMAKE_INSTALL_PREFIX}")

默认情况下,我们更喜欢以Release的方式配置项目。用户可以通过CMAKE_BUILD_TYPE设置此变量,从而改变配置类型,我们将检查是否存在这种情况。如果没有,将设置为默认值:

1
2
3
4
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()
message(STATUS "Build type set to ${CMAKE_BUILD_TYPE}")

接下来,告诉CMake在何处构建可执行、静态和动态库目标。便于在用户不打算安装项目的情况下,访问这些构建目标。这里使用标准CMake的GNUInstallDirs.cmake模块。这将确保的项目布局的合理性和可移植性:

1
2
3
4
5
6
7
include(GNUInstallDirs)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY
${PROJECT_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
${PROJECT_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY
${PROJECT_BINARY_DIR}/${CMAKE_INSTALL_BINDIR})

虽然,前面的命令配置了构建目录中输出的位置,但是需要下面的命令来配置可执行程序、库以及安装前缀中包含的文件的位置。它们大致遵循相同的布局,但是我们定义了新的INSTALL_LIBDIRINSTALL_BINDIRINSTALL_INCLUDEDIRINSTALL_CMAKEDIR变量。当然,也可以覆盖这些变量:

1
2
3
4
5
6
7
8
9
10
# Offer the user the choice of overriding the installation directories
set(INSTALL_LIBDIR ${CMAKE_INSTALL_LIBDIR} CACHE PATH "Installation directory for libraries")
set(INSTALL_BINDIR ${CMAKE_INSTALL_BINDIR} CACHE PATH "Installation directory for executables")
set(INSTALL_INCLUDEDIR ${CMAKE_INSTALL_INCLUDEDIR} CACHE PATH "Installation directory for header files")
if(WIN32 AND NOT CYGWIN)
set(DEF_INSTALL_CMAKEDIR CMake)
else()
set(DEF_INSTALL_CMAKEDIR share/cmake/${PROJECT_NAME})
endif()
set(INSTALL_CMAKEDIR ${DEF_INSTALL_CMAKEDIR} CACHE PATH "Installation directory for CMake files")

报告组件安装的路径:

1
2
3
4
5
6
# Report to user
foreach(p LIB BIN INCLUDE CMAKE)
file(TO_NATIVE_PATH ${CMAKE_INSTALL_PREFIX}/${INSTALL_${p}DIR} _path )
message(STATUS "Installing ${p} components to ${_path}")
unset(_path)
endforeach()

主CMakeLists.txt文件中的最后一个指令添加src子目录,启用测试,并添加tests子目录:

1
2
3
add_subdirectory(src)
enable_testing()
add_subdirectory(tests)

现在我们继续分析src/CMakeLists.txt,其定义了构建的实际目标:

我们的项目依赖于UUID库:

1
2
3
4
5
6
7
8
9
# Search for pkg-config and UUID
find_package(PkgConfig QUIET)
if(PKG_CONFIG_FOUND)
pkg_search_module(UUID uuid IMPORTED_TARGET)
if(TARGET PkgConfig::UUID)
message(STATUS "Found libuuid")
set(UUID_FOUND TRUE)
endif()
endif()

我们希望建立一个动态库,将该目标声明为message-shared:

1
add_library(message-shared SHARED "")

这个目标由target_sources命令指定:

1
2
3
4
target_sources(message-shared
PRIVATE
${CMAKE_CURRENT_LIST_DIR}/Message.cpp
)

我们为目标声明编译时定义和链接库。请注意,所有这些都是PUBLIC,以确保所有依赖的目标将正确继承它们:

1
2
3
4
5
6
7
8
  target_compile_definitions(message-shared
PUBLIC
$<$<BOOL:${UUID_FOUND}>:HAVE_UUID>
)
target_link_libraries(message-shared
PUBLIC
$<$<BOOL:${UUID_FOUND}>:PkgConfig::UUID>
)

然后设置目标的附加属性:

1
2
3
4
5
6
7
8
9
10
set_target_properties(message-shared
PROPERTIES
POSITION_INDEPENDENT_CODE 1
SOVERSION ${PROJECT_VERSION_MAJOR}
OUTPUT_NAME "message"
DEBUG_POSTFIX "_d"
PUBLIC_HEADER "Message.hpp"
MACOSX_RPATH ON
WINDOWS_EXPORT_ALL_SYMBOLS ON
)

最后,为“Hello, world”程序添加可执行目标:

1
add_executable(hello-world_wDSO hello-world.cpp)

hello-world_wDSO可执行目标,会链接到动态库:

1
2
3
4
target_link_libraries(hello-world_wDSO
PUBLIC
message-shared
)

src/CMakeLists.txt文件中,还包含安装指令。考虑这些之前,我们需要设置可执行文件的RPATH:

使用CMake路径操作,我们可以设置message_RPATH变量。这将为GNU/Linux和macOS设置适当的RPATH:

1
2
3
4
5
6
7
8
RPATH
file(RELATIVE_PATH _rel ${CMAKE_INSTALL_PREFIX}/${INSTALL_BINDIR} ${CMAKE_INSTALL_PREFIX})
if(APPLE)
set(_rpath "@loader_path/${_rel}")
else()
set(_rpath "\$ORIGIN/${_rel}")
endif()
file(TO_NATIVE_PATH "${_rpath}/${INSTALL_LIBDIR}" message_RPATH)

现在,可以使用这个变量来设置可执行目标hello-world_wDSO的RPATH(通过目标属性实现)。我们也可以设置额外的属性,稍后会对此进行更多的讨论:

1
2
3
4
5
6
7
8
set_target_properties(hello-world_wDSO
PROPERTIES
MACOSX_RPATH ON
SKIP_BUILD_RPATH OFF
BUILD_WITH_INSTALL_RPATH OFF
INSTALL_RPATH "${message_RPATH}"
INSTALL_RPATH_USE_LINK_PATH ON
)

终于可以安装库、头文件和可执行文件了!使用CMake提供的install命令来指定安装位置。注意,路径是相对的,我们将在后续进一步讨论这一点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
install(
TARGETS
message-shared
hello-world_wDSO
ARCHIVE
DESTINATION ${INSTALL_LIBDIR}
COMPONENT lib
RUNTIME
DESTINATION ${INSTALL_BINDIR}
COMPONENT bin
LIBRARY
DESTINATION ${INSTALL_LIBDIR}
COMPONENT lib
PUBLIC_HEADER
DESTINATION ${INSTALL_INCLUDEDIR}/message
COMPONENT dev
)

tests目录中的CMakeLists.txt文件包含简单的指令,以确保“Hello, World”可执行文件能够正确运行:

1
2
3
4
add_test(
NAME test_shared
COMMAND $<TARGET_FILE:hello-world_wDSO>
)

现在让我们配置、构建和安装项目,并查看结果。添加安装指令时,CMake就会生成一个名为install的新目标,该目标将运行安装规则:

1
2
3
4
$ mkdir -p build
$ cd build
$ cmake -G"Unix Makefiles" -DCMAKE_INSTALL_PREFIX=$HOME/Software/recipe-01
$ cmake --build . --target install

GNU/Linux构建目录的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
build
├── bin
│ └── hello-world_wDSO
├── CMakeCache.txt
├── CMakeFiles
├── cmake_install.cmake
├── CTestTestfile.cmake
├── install_manifest.txt
├── lib64
│ ├── libmessage.so -> libmessage.so.1
│ └── libmessage.so.1
├── Makefile
├── src
├── Testing
└── tests

另一方面,在安装位置,可以找到如下的目录结构:

1
2
3
4
5
6
7
8
9
$HOME/Software/recipe-01/
├── bin
│ └── hello-world_wDSO
├── include
│ └── message
│ └── Message.hpp
└── lib64
├── libmessage.so -> libmessage.so.1
└── libmessage.so.1

这意味着安装指令中给出的位置,是相对于用户给定的CMAKE_INSTALL_PREFIX路径。

工作原理

这个示例有三个要点我们需要更详细地讨论:

  • 使用GNUInstallDirs.cmake定义目标安装的标准位置
  • 在动态库和可执行目标上设置的属性,特别是RPATH的处理
  • 安装指令

GNUInstallDirs.cmake模块所做的就是定义这样一组变量,这些变量是安装不同类型文件的子目录的名称。在例子中,使用了以下内容:

  • *CMAKE_INSTALL_BINDIR:这将用于定义用户可执行文件所在的子目录,即所选安装目录下的bin目录。
  • CMAKE_INSTALL_LIBDIR:这将扩展到目标代码库(即静态库和动态库)所在的子目录。在64位系统上,它是lib64,而在32位系统上,它只是lib。
  • CMAKE_INSTALL_INCLUDEDIR:最后,我们使用这个变量为C头文件获取正确的子目录,该变量为include。

然而,用户可能希望覆盖这些选项。我们允许在主CMakeLists.txt文件中使用以下方式覆盖选项:

1
2
3
4
5
6
7
8
# Offer the user the choice
of overriding the installation directories
set(INSTALL_LIBDIR ${CMAKE_INSTALL_LIBDIR} CACHE PATH
"Installation directory for libraries")
set(INSTALL_BINDIR ${CMAKE_INSTALL_BINDIR} CACHE PATH
"Installation directory for executables")
set(INSTALL_INCLUDEDIR ${CMAKE_INSTALL_INCLUDEDIR} CACHE
PATH "Installation directory for header files")

这重新定义了在项目中使用的INSTALL_BINDIRINSTALL_LIBDIRINSTALL_INCLUDEDIR变量。我们还定义了INSTALL_CMAKEDIR变量,但它所扮演的角色将在接下来的几个示例中详细讨论。

在动态库目标上设置的属性,需要设置以下内容:

  • POSITION_INDEPENDENT_CODE 1:设置生成位置无关代码所需的编译器标志。
  • SOVERSION ${PROJECT_VERSION_MAJOR} : 这是动态库提供的应用程序编程接口(API)版本。在设置语义版本之后,将其设置为与项目的主版本一致。CMake目标也有一个版本属性,可以用来指定目标的构建版本。注意,SOVERSION和VERSION有所不同:随着时间的推移,提供相同API的多个构建版本。本例中,我们不关心这种的粒度控制:仅使用SOVERSION属性设置API版本就足够了,CMake将为我们将VERSION设置为相同的值。
  • OUTPUT_NAME "message":这告诉CMake库的名称message,而不是目标message-shared的名称,libmessage.so.1将在构建时生成。从前面给出的构建目录和安装目录的也可以看出,libmessage.so的符号链接也将生成。
  • DEBUG_POSTFIX "_d":这告诉CMake,如果我们以Debug配置构建项目,则将_d后缀添加到生成的动态库。
  • PUBLIC_HEADER "Message.hpp":我们使用这个属性来设置头文件列表(本例中只有一个头文件),声明提供的API函数。这主要用于macOS上的动态库目标,也可以用于其他操作系统和目标。
  • MACOSX_RPATH ON:这将动态库的install_name部分(目录)设置为macOS上的@rpath。
  • WINDOWS_EXPORT_ALL_SYMBOLS ON:这将强制在Windows上编译以导出所有符号。注意,这通常不是一个好的方式,我们将在第2节中展示如何生成导出头文件,以及如何在不同的平台上保证符号的可见性。

现在讨论一下RPATH。我们将hello-world_wDSO可执行文件链接到libmessage.so.1,这意味着在执行时,将加载动态库。因此,有关库位置的信息需要在某个地方进行编码,以便加载程序能够成功地完成其工作。

GNU/Linux上,库的定位需要将路径附加到LD_LIBRARY_PATH环境变量中。注意,这很可能会污染系统中所有应用程序的链接器路径,并可能导致符号冲突。

设置动态对象的RPATH时,应该选择哪个路径?我们需要确保可执行文件总是找到正确的动态库,不管它是在构建树中运行还是在安装树中运行。这需要通过设置hello-world_wDSO目标的RPATH相关属性来实现的,通过$ORIGIN(在GNU/Linux上)变量来查找与可执行文件本身位置相关的路径:

1
2
3
4
5
6
7
8
# Prepare RPATH
file(RELATIVE_PATH _rel ${CMAKE_INSTALL_PREFIX}/${INSTALL_BINDIR} ${CMAKE_INSTALL_PREFIX})
if(APPLE)
set(_rpath "@loader_path/${_rel}")
else()
set(_rpath "\$ORIGIN/${_rel}")
endif()
file(TO_NATIVE_PATH "${_rpath}/${INSTALL_LIBDIR}" message_RPATH)

当设置了message_RPATH变量,目标属性将完成剩下的工作:

1
2
3
4
5
6
7
8
set_target_properties(hello-world_wDSO
PROPERTIES
MACOSX_RPATH ON
SKIP_BUILD_RPATH OFF
BUILD_WITH_INSTALL_RPATH OFF
INSTALL_RPATH "${message_RPATH}"
INSTALL_RPATH_USE_LINK_PATH ON
)

让我们详细研究一下这个命令:

  • SKIP_BUILD_RPATH OFF:告诉CMake生成适当的RPATH,以便能够在构建树中运行可执行文件。
  • UILD_WITH_INSTALL_RPATH OFF:关闭生成可执行目标,使其RPATH调整为与安装树的RPATH相同。在构建树中不运行可执行文件。
  • INSTALL_RPATH "${message_RPATH}":将已安装的可执行目标的RPATH设置为先前的路径。
  • INSTALL_RPATH_USE_LINK_PATH ON:告诉CMake将链接器搜索路径附加到可执行文件的RPATH中。

最后,看一下安装指令。我们需要安装一个可执行文件、一个库和一个头文件。可执行文件和库是构建目标,因此我们使用安装命令的TARGETS选项。可以同时设置多个目标的安装规则:CMake知道它们是什么类型的目标,无论其是可执行程序库、动态库,还是静态库:

1
2
3
4
install(
TARGETS
message-shared
hello-world_wDSO

可执行文件将安装在RUNTIME DESTINATION,将其设置为${INSTALL_BINDIR}。动态库安装到LIBRARY_DESTINATION,将其设置为${INSTALL_LIBDIR}。静态库将安装到ARCHIVE DESTINATION,将其设置为${INSTALL_LIBDIR}:

1
2
3
4
5
6
7
8
9
ARCHIVE
DESTINATION ${INSTALL_LIBDIR}
COMPONENT lib
RUNTIME
DESTINATION ${INSTALL_BINDIR}
COMPONENT bin
LIBRARY
DESTINATION ${INSTALL_LIBDIR}
COMPONENT lib

注意,这里不仅指定了DESTINATION,还指定了COMPONENT。使用cmake –build . –target install安装命令,所有组件会按预期安装完毕。然而,有时只安装其中一些可用的。这就是COMPONENT关键字帮助我们做的事情。例如,当只要求安装库,我们可以执行以下步骤:

1
$ cmake -D COMPONENT=lib -P cmake_install.cmake

自从Message.hpp头文件设置为项目的公共头文件,我们可以使用PUBLIC_HEADER关键字将其与其他目标安装到选择的目的地:${INSTALL_INCLUDEDIR}/message。库用户现在可以包含头文件:#include <message/Message.hpp>,这需要在编译时,使用-I选项将正确的头文件查找路径位置传递给编译器。

安装指令中的各种目标地址会被解释为相对路径,除非使用绝对路径。但是相对于哪里呢?根据不同的安装工具而不同,而CMake可以去计算目标地址的绝对路径。当使用cmake --build . --target install,路径将相对于CMAKE_INSTALL_PREFIX计算。但当使用CPack时,绝对路径将相对于CPACK_PACKAGING_INSTALL_PREFIX计算。CPack的用法将在第11章中介绍。

生成输出头文件

设想一下,当我们的小型库非常受欢迎时,许多人都在使用它。然而,一些客户希望在安装时使用静态库,而另一些客户也注意到所有符号在动态库中都是可见的。最佳方式是规定动态库只公开最小的符号,从而限制代码中定义的对象和函数对外的可见性。我们希望在默认情况下,动态库定义的所有符号都对外隐藏。这将使得项目的贡献者,能够清楚地划分库和外部代码之间的接口,因为他们必须显式地标记所有要在项目外部使用的符号。因此,我们需要完成以下工作:

  • 使用同一组源文件构建动态库和静态库
  • 确保正确分隔动态库中符号的可见性

准备工作

我们仍将使用与前一个示例中基本相同的代码,但是我们需要修改src/CMakeLists.txt和Message.hpp头文件。后者将包括新的、自动生成的头文件messageExport.h:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#pragma once
#include
#include
#include "messageExport.h"
class message_EXPORT Message
{
public:
Message(const std::string &m) : message_(m) {}
friend std::ostream &operator<<(std::ostream &os, Message &obj)
{
return obj.printObject(os);
}
private:
std::string message_;
std::ostream &printObject(std::ostream &os);
};
std::string getUUID();

Message类的声明中引入了message_EXPORT预处理器指令,这个指令将让编译器生成对库的用户可见的符号。

具体实施

除了项目的名称外,主CMakeLists.txt文件没有改变。首先,看看src子目录中的CMakeLists.txt文件,所有工作实际上都在这里进行。我们将重点展示对之前示例的修改之处:

为消息传递库声明SHARED库目标及其源。注意,编译定义和链接库没有改变:

1
2
3
4
5
6
7
8
9
10
11
12
13
add_library(message-shared SHARED "")
target_sources(message-shared
PRIVATE
${CMAKE_CURRENT_LIST_DIR}/Message.cpp
)
target_compile_definitions(message-shared
PUBLIC
$<$<BOOL:${UUID_FOUND}>:HAVE_UUID>
)
target_link_libraries(message-shared
PUBLIC
$<$<BOOL:${UUID_FOUND}>:PkgConfig::UUID>
)

设置目标属性。将${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}/messageExport.h头文件添加到公共头列表中,作为PUBLIC_HEADER目标属性的参数。CXX_VISIBILITY_PRESET置和VISIBILITY_INLINES_HIDDEN属性将在下一节中讨论:

1
2
3
4
5
6
7
8
9
10
11
set_target_properties(message-shared
PROPERTIES
POSITION_INDEPENDENT_CODE 1
CXX_VISIBILITY_PRESET hidden
VISIBILITY_INLINES_HIDDEN 1
SOVERSION ${PROJECT_VERSION_MAJOR}
OUTPUT_NAME "message"
DEBUG_POSTFIX "_d"
PUBLIC_HEADER "Message.hpp;${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}/messageExport.h"
MACOSX_RPATH ON
)

包含GenerateExportHeader.cmake模块并调用generate_export_header函数,这将在构建目录的子目录中生成messageExport.h头文件。我们将稍后会详细讨论这个函数和生成的头文件:

1
2
3
4
5
6
7
8
9
10
11
include(GenerateExportHeader)
generate_export_header(message-shared
BASE_NAME "message"
EXPORT_MACRO_NAME "message_EXPORT"
EXPORT_FILE_NAME "${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}/messageExport.h"
DEPRECATED_MACRO_NAME "message_DEPRECATED"
NO_EXPORT_MACRO_NAME "message_NO_EXPORT"
STATIC_DEFINE "message_STATIC_DEFINE"
NO_DEPRECATED_MACRO_NAME "message_NO_DEPRECATED"
DEFINE_NO_DEPRECATED
)

当要更改符号的可见性(从其默认值-隐藏值)时,都应该包含导出头文件。我们已经在Message.hpp头文件例这样做了,因为想在库中公开一些符号。现在将${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}目录作为message-shared目标的PUBLIC包含目录列出:

1
2
3
4
target_include_directories(message-shared
PUBLIC
${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}
)

现在,可以将注意力转向静态库的生成:

添加一个库目标来生成静态库。将编译与静态库相同的源文件,以获得此动态库目标:

1
2
3
4
5
add_library(message-static STATIC "")
target_sources(message-static
PRIVATE
${CMAKE_CURRENT_LIST_DIR}/Message.cpp
)

设置编译器定义,包含目录和链接库,就像我们为动态库目标所做的一样。但请注意,我们添加了message_STATIC_DEFINE编译时宏定义,为了确保我们的符号可以适当地暴露:

1
2
3
4
5
6
7
8
9
10
11
12
13
target_compile_definitions(message-static
PUBLIC
message_STATIC_DEFINE
$<$<BOOL:${UUID_FOUND}>:HAVE_UUID>
)
target_include_directories(message-static
PUBLIC
${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}
)
target_link_libraries(message-static
PUBLIC
$<$<BOOL:${UUID_FOUND}>:PkgConfig::UUID>
)

还设置了message-static目标的属性:

1
2
3
4
5
6
7
8
set_target_properties(message-static
PROPERTIES
POSITION_INDEPENDENT_CODE 1
ARCHIVE_OUTPUT_NAME "message"
DEBUG_POSTFIX "_sd"
RELEASE_POSTFIX "_s"
PUBLIC_HEADER "Message.hpp;${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}/messageExport.h"
)

除了链接到消息动态库目标的hello-world_wDSO可执行目标之外,还定义了另一个可执行目标hello-world_wAR,这个链接指向静态库:

1
2
3
4
5
add_executable(hello-world_wAR hello-world.cpp)
target_link_libraries(hello-world_wAR
PUBLIC
message-static
)

安装指令现在多了message-static和hello-world_wAR目标,其他没有改变:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
install(
TARGETS
message-shared
message-static
hello-world_wDSO
hello-world_wAR
ARCHIVE
DESTINATION ${INSTALL_LIBDIR}
COMPONENT lib
RUNTIME
DESTINATION ${INSTALL_BINDIR}
COMPONENT bin
LIBRARY
DESTINATION ${INSTALL_LIBDIR}
COMPONENT lib
PUBLIC_HEADER
DESTINATION ${INSTALL_INCLUDEDIR}/message
COMPONENT dev
)

工作原理

此示例演示了,如何设置动态库的符号可见性。最好的方式是在默认情况下隐藏所有符号,显式地只公开那些需要使用的符号。这需要分为两步实现。首先,需要指示编译器隐藏符号。当然,不同的编译器将有不同的可用选项,并且直接在CMakeLists.txt中设置这些选项并不是是跨平台的。CMake通过在动态库目标上设置两个属性,提供了一种健壮的跨平台方法来设置符号的可见性:

  • CXX_VISIBILITY_PRESET hidden:这将隐藏所有符号,除非显式地标记了其他符号。当使用GNU编译器时,这将为目标添加-fvisibility=hidden标志。
  • VISIBILITY_INLINES_HIDDEN 1:这将隐藏内联函数的符号。如果使用GNU编译器,这对应于-fvisibility-inlines-hidden

Windows上,这都是默认行为。实际上,我们需要在前面的示例中通过设置WINDOWS_EXPORT_ALL_SYMBOLS属性为ON来覆盖它。

如何标记可见的符号?这由预处理器决定,因此需要提供相应的预处理宏,这些宏可以扩展到所选平台上,以便编译器能够理解可见性属性。CMake中有现成的GenerateExportHeader.cmake模块。这个模块定义了generate_export_header函数,我们调用它的过程如下:

1
2
3
4
5
6
7
8
9
10
11
include(GenerateExportHeader)
generate_export_header(message-shared
BASE_NAME "message"
EXPORT_MACRO_NAME "message_EXPORT"
EXPORT_FILE_NAME "${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}/messageExport.h"
DEPRECATED_MACRO_NAME "message_DEPRECATED"
NO_EXPORT_MACRO_NAME "message_NO_EXPORT"
STATIC_DEFINE "message_STATIC_DEFINE"
NO_DEPRECATED_MACRO_NAME "message_NO_DEPRECATED"
DEFINE_NO_DEPRECATED
)

该函数生成messageExport.h头文件,其中包含预处理器所需的宏。根据EXPORT_FILE_NAME选项的请求,在目录${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}中生成该文件。如果该选项为空,则头文件将在当前二进制目录中生成。这个函数的第一个参数是现有的目标(示例中是message- shared),函数的基本调用只需要传递现有目标的名称即可。可选参数,用于细粒度的控制所有生成宏,也可以传递:

  • BASE_NAME:设置生成的头文件和宏的名称。
  • EXPORT_MACRO_NAME:设置导出宏的名称。
  • EXPORT_FILE_NAME:设置导出头文件的名称。
  • DEPRECATED_MACRO_NAME:设置弃用宏的名称。这是用来标记将要废弃的代码,如果客户使用该宏定义,编译器将发出一个将要废弃的警告。
  • NO_EXPORT_MACRO_NAME:设置不导出宏的名字。
  • STATIC_DEFINE:用于定义宏的名称,以便使用相同源编译静态库时使用。
  • NO_DEPRECATED_MACRO_NAME:设置宏的名称,在编译时将“将要废弃”的代码排除在外。
  • DEFINE_NO_DEPRECATED:指示CMake生成预处理器代码,以从编译中排除“将要废弃”的代码。

GNU/Linux上,使用GNU编译器,CMake将生成以下messageExport.h头文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#ifndef message_EXPORT_H
#define message_EXPORT_H
#ifdef message_STATIC_DEFINE
# define message_EXPORT
# define message_NO_EXPORT
#else
# ifndef message_EXPORT
# ifdef message_shared_EXPORTS
/* We are building this library */
# define message_EXPORT __attribute__((visibility("default")))
# else
/* We are using this library */
# define message_EXPORT __attribute__((visibility("default")))
# endif
# endif
# ifndef message_NO_EXPORT
# define message_NO_EXPORT __attribute__((visibility("hidden")))
# endif
#endif
#ifndef message_DEPRECATED
# define message_DEPRECATED __attribute__ ((__deprecated__))
#endif
#ifndef message_DEPRECATED_EXPORT
# define message_DEPRECATED_EXPORT message_EXPORT message_DEPRECATED
#endif
#ifndef message_DEPRECATED_NO_EXPORT
# define message_DEPRECATED_NO_EXPORT message_NO_EXPORT message_DEPRECATED
#endif
#if 1 /* DEFINE_NO_DEPRECATED */
# ifndef message_NO_DEPRECATED
# define message_NO_DEPRECATED
# endif
#endif
#endif

我们可以使用message_EXPORT宏,预先处理用户公开类和函数。弃用可以通过在前面加上message_DEPRECATED宏来实现。

从messageExport.h头文件的内容可以看出,所有符号都应该在静态库中可见,这就是message_STATIC_DEFINE宏起了作用。当声明了目标,我们就将其设置为编译时定义。静态库的其他目标属性如下:

  • ARCHIVE_OUTPUT_NAME “message”:这将确保库文件的名称是message,而不是message-static。
  • DEBUG_POSTFIX “_sd”:这将把给定的后缀附加到库名称中。当目标构建类型为Release时,为静态库添加”_sd”后缀。
  • RELEASE_POSTFIX “_s”:这与前面的属性类似,当目标构建类型为Release时,为静态库添加后缀“_s”。

输出目标

可以假设,消息库在开源社区取得了巨大的成功。人们非常喜欢它,并在自己的项目中使用它将消息打印到屏幕上。用户特别喜欢每个打印的消息都有惟一的标识符。但用户也希望,当他们编译并安装了库,库就能更容易找到。这个示例将展示CMake如何让我们导出目标,以便其他使用CMake的项目可以轻松地获取它们。

准备工作

源代码与之前的示例一致,项目结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.
├── cmake
│ └── messageConfig.cmake.in
├── CMakeLists.txt
├── src
│ ├── CMakeLists.txt
│ ├── hello- world.cpp
│ ├── Message.cpp
│ └── Message.hpp
└── tests
├── CMakeLists.txt
└── use_target
├── CMakeLists.txt
└── use_message.cpp

注意,cmake子目录中添加了一个messageConfig.cmake.in。这个文件将包含导出的目标,还添加了一个测试来检查项目的安装和导出是否按预期工作。

具体实施

同样,主CMakeLists.txt文件相对于前一个示例来说没有变化。移动到包含我们的源代码的子目录src中:

需要找到UUID库,可以重用之前示例中的代码:

1
2
3
4
5
6
7
8
9
# Search for pkg-config and UUID
find_package(PkgConfig QUIET)
if(PKG_CONFIG_FOUND)
pkg_search_module(UUID uuid IMPORTED_TARGET)
if(TARGET PkgConfig::UUID)
message(STATUS "Found libuuid")
set(UUID_FOUND TRUE)
endif()
endif()

接下来,设置动态库目标并生成导出头文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
add_library(message-shared SHARED "")
include(GenerateExportHeader)
generate_export_header(message-shared
BASE_NAME "message"
EXPORT_MACRO_NAME "message_EXPORT"
EXPORT_FILE_NAME "${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}/messageExport.h"
DEPRECATED_MACRO_NAME "message_DEPRECATED"
NO_EXPORT_MACRO_NAME "message_NO_EXPORT"
STATIC_DEFINE "message_STATIC_DEFINE"
NO_DEPRECATED_MACRO_NAME "message_NO_DEPRECATED"
DEFINE_NO_DEPRECATED
)
target_sources(message-shared
PRIVATE
${CMAKE_CURRENT_LIST_DIR}/Message.cpp
)

为目标设置了PUBLIC和INTERFACE编译定义。注意$<INSTALL_INTERFACE:…>生成器表达式的使用:

1
2
3
4
5
6
target_compile_definitions(message-shared
PUBLIC
$<$<BOOL:${UUID_FOUND}>:HAVE_UUID>
INTERFACE
$<INSTALL_INTERFACE:USING_message>
)

链接库和目标属性与前一个示例一样:

1
2
3
4
5
6
7
8
9
10
11
12
target_link_libraries(message-static
PUBLIC
$<$<BOOL:${UUID_FOUND}>:PkgConfig::UUID>
)
set_target_properties(message-static
PROPERTIES
POSITION_INDEPENDENT_CODE 1
ARCHIVE_OUTPUT_NAME "message"
DEBUG_POSTFIX "_sd"
RELEASE_POSTFIX "_s"
PUBLIC_HEADER "Message.hpp;${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}/messageExport.h"
)

可执行文件的生成,与前一个示例中使用的命令完全相同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
add_executable(hello-world_wDSO hello-world.cpp)
target_link_libraries(hello-world_wDSO
PUBLIC
message-shared
)
# Prepare RPATH
file(RELATIVE_PATH _rel ${CMAKE_INSTALL_PREFIX}/${INSTALL_BINDIR} ${CMAKE_INSTALL_PREFIX})
if(APPLE)
set(_rpath "@loader_path/${_rel}")
else()
set(_rpath "\$ORIGIN/${_rel}")
endif()
file(TO_NATIVE_PATH "${_rpath}/${INSTALL_LIBDIR}" message_RPATH)
set_target_properties(hello-world_wDSO
PROPERTIES
MACOSX_RPATH ON
SKIP_BUILD_RPATH OFF
BUILD_WITH_INSTALL_RPATH OFF
INSTALL_RPATH "${message_RPATH}"
INSTALL_RPATH_USE_LINK_PATH ON
)
add_executable(hello-world_wAR hello-world.cpp)
target_link_libraries(hello-world_wAR
PUBLIC
message-static
)

现在,来看看安装规则:

因为CMake可以正确地将每个目标放在正确的地方,所以把目标的安装规则都列在一起。这次,添加了EXPORT关键字,这样CMake将为目标生成一个导出的目标文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
install(
TARGETS
message-shared
message-static
hello-world_wDSO
hello-world_wAR
EXPORT
messageTargets
ARCHIVE
DESTINATION ${INSTALL_LIBDIR}
COMPONENT lib
RUNTIME
DESTINATION ${INSTALL_BINDIR}
COMPONENT bin
LIBRARY
DESTINATION ${INSTALL_LIBDIR}
COMPONENT lib
PUBLIC_HEADER
DESTINATION ${INSTALL_INCLUDEDIR}/message
COMPONENT dev
)

自动生成的导出目标文件称为messageTargets.cmake,需要显式地指定它的安装规则。这个文件的目标是INSTALL_CMAKEDIR,在主CMakeLists.txt文件中定义:

1
2
3
4
5
6
7
8
9
10
install(
EXPORT
messageTargets
NAMESPACE
"message::"
DESTINATION
${INSTALL_CMAKEDIR}
COMPONENT
dev
)

最后,需要生成正确的CMake配置文件。这些将确保下游项目能够找到消息库导出的目标。为此,首先包括CMakePackageConfigHelpers.cmake标准模块:

1
include(CMakePackageConfigHelpers)

让CMake为我们的库,生成一个包含版本信息的文件:

1
2
3
4
5
write_basic_package_version_file(
${CMAKE_CURRENT_BINARY_DIR}/messageConfigVersion.cmake
VERSION ${PROJECT_VERSION}
COMPATIBILITY SameMajorVersion
)

使用configure_package_config_file函数,我们生成了实际的CMake配置文件。这是基于模板cmake/messageConfig.cmake.in文件:

1
2
3
4
5
configure_package_config_file(
${PROJECT_SOURCE_DIR}/cmake/messageConfig.cmake.in
${CMAKE_CURRENT_BINARY_DIR}/messageConfig.cmake
INSTALL_DESTINATION ${INSTALL_CMAKEDIR}
)

最后,为这两个自动生成的配置文件设置了安装规则:

1
2
3
4
5
6
7
install(
FILES
${CMAKE_CURRENT_BINARY_DIR}/messageConfig.cmake
${CMAKE_CURRENT_BINARY_DIR}/messageConfigVersion.cmake
DESTINATION
${INSTALL_CMAKEDIR}
)

cmake/messageConfig.cmake的内容是什么?该文件的顶部有相关的说明,可以作为用户文档供使用者查看。让我们看看实际的CMake命令:

占位符将使用configure_package_config_file命令进行替换:

1
@PACKAGE_INIT@

包括为目标自动生成的导出文件:

1
include("${CMAKE_CURRENT_LIST_DIR}/messageTargets.cmake")

检查静态库和动态库,以及两个“Hello, World”可执行文件是否带有CMake提供的check_required_components函数:

1
2
3
4
5
6
check_required_components(
"message-shared"
"message-static"
"message-hello-world_wDSO"
"message-hello-world_wAR"
)

检查目标PkgConfig::UUID是否存在。如果没有,我们再次搜索UUID库(只在非Windows操作系统下有效):

1
2
3
4
5
6
if(NOT WIN32)
if(NOT TARGET PkgConfig::UUID)
find_package(PkgConfig REQUIRED QUIET)
pkg_search_module(UUID REQUIRED uuid IMPORTED_TARGET)
endif()
endif()

测试一下:

1
2
3
4
$ mkdir -p build
$ cd build
$ cmake -DCMAKE_INSTALL_PREFIX=$HOME/Software/recipe-03 ..
$ cmake --build . --target install

安装树应该如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$HOME/Software/recipe-03/
├── bin
│ ├── hello-world_wAR
│ └── hello-world_wDSO
├── include
│ └── message
│ ├── messageExport.h
│ └── Message.hpp
├── lib64
│ ├── libmessage_s.a
│ ├── libmessage.so -> libmessage.so.1
│ └── libmessage.so.1
└── share
└── cmake
└── recipe-03
├── messageConfig.cmake
├── messageConfigVersion.cmake
├── messageTargets.cmake
└── messageTargets-release.cmake

出现了一个share子目录,其中包含我们要求CMake自动生成的所有文件。现在开始,消息库的用户可以在他们自己的CMakeLists.txt文件中找到消息库,只要他们设置message_DIR的CMake变量,指向安装树中的share/cmake/message目录:

1
find_package(message 1 CONFIG REQUIRED)

工作原理

这个示例涵盖了很多领域。对于构建系统将要执行的操作,CMake目标是一个非常有用的抽象概念。使用PRIVATE、PUBLIC和INTERFACE关键字,我们可以设置项目中的目标进行交互。在实践中,这允许我们定义目标A的依赖关系,将如何影响目标B(依赖于A)。如果库维护人员提供了适当的CMake配置文件,那么只需很少的CMake命令就可以轻松地解决所有依赖关系。

这个问题可以通过遵循message-static、message-shared、hello-world_wDSO和hello-world_wAR目标概述的模式来解决。我们将单独分析message-shared目标的CMake命令,这里只是进行一般性讨论:

生成目标在项目构建中列出其依赖项。对UUID库的链接是 message-shared的PUBLIC需求,因为它将用于在项目中构建目标和在下游项目中构建目标。编译时宏定义和包含目录需要在PUBLIC级或INTERFACE级目标上进行设置。它们实际上是在项目中构建目标时所需要的,其他的只与下游项目相关。此外,其中一些只有在项目安装之后才会相关联。这里使用了$<BUILD_INTERFACE:…>和$<INSTALL_INTERFACE:…>生成器表达式。只有消息库外部的下游目标才需要这些,也就是说,只有在安装了目标之后,它们才会变得可见。我们的例子中,应用如下:

只有在项目中使用了message-shared库,那么$<BUILD_INTERFACE:${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}>才会扩展成${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}

只有在message-shared库在另一个构建树中,作为一个已导出目标,那么$<INSTALL_INTERFACE:${INSTALL_INCLUDEDIR}>将会扩展成${INSTALL_INCLUDEDIR}

描述目标的安装规则,包括生成文件的名称。

描述CMake生成的导出文件的安装规则messageTargets.cmake文件将安装到INSTALL_CMAKEDIR。目标导出文件的安装规则的名称空间选项,将把给定字符串前置到目标的名称中,这有助于避免来自不同项目的目标之间的名称冲突。INSTALL_CMAKEDIR变量是在主CMakeLists.txt文件中设置的:

1
2
3
4
5
6
if(WIN32 AND NOT CYGWIN)
set(DEF_INSTALL_CMAKEDIR CMake)
else()
set(DEF_INSTALL_CMAKEDIR share/cmake/${PROJECT_NAME})
endif()
set(INSTALL_CMAKEDIR ${DEF_INSTALL_CMAKEDIR} CACHE PATH "Installation directory for CMake files")

CMakeLists.txt的最后一部分生成配置文件。包括CMakePackageConfigHelpers.cmake模块,分三步完成:

调用write_basic_package_version_file函数生成一个版本文件包。宏的第一个参数是版本控制文件的路径:messageConfigVersion.cmake。版本格式为Major.Minor.Patch,并使用PROJECT_VERSION指定版本,还可以指定与库的新版本的兼容性。例子中,当库具有相同的主版本时,为了保证兼容性,使用了相同的SameMajorVersion参数。

接下来,配置模板文件messageConfig.cmake.in,该文件位于cmake子目录中。

最后,为新生成的文件设置安装规则。两者都将安装在INSTALL_CMAKEDIR下。

安装超级构建

我们的消息库取得了巨大的成功,许多其他程序员都使用它,并且非常满意。也希望在自己的项目中使用它,但是不确定如何正确地管理依赖关系。可以用自己的代码附带消息库的源代码,但是如果该库已经安装在系统上了应该怎么做呢?第8章,展示了超级构建的场景,但是不确定如何安装这样的项目。本示例将带您了解安装超级构建的安装细节。

准备工作

此示例将针对消息库,构建一个简单的可执行链接。项目布局如下:

1
2
3
4
5
6
7
8
9
10
11
12
├── cmake
│ ├── install_hook.cmake.in
│ └── print_rpath.py
├── CMakeLists.txt
├── external
│ └── upstream
│ ├── CMakeLists.txt
│ └── message
│ └── CMakeLists.txt
└── src
├── CMakeLists.txt
└── use_message.cpp

主CMakeLists.txt文件配合超级构建,external子目录包含处理依赖项的CMake指令。cmake子目录包含一个Python脚本和一个模板CMake脚本。这些将用于安装方面的微调,CMake脚本首先进行配置,然后调用Python脚本打印use_message可执行文件的RPATH:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import shlex
import subprocess
import sys
def main():
patcher = sys.argv[1]
elfobj = sys.argv[2]
tools = {'patchelf': '--print-rpath', 'chrpath': '--list', 'otool': '-L'}
if patcher not in tools.keys():
raise RuntimeError('Unknown tool {}'.format(patcher))
cmd = shlex.split('{:s} {:s} {:s}'.format(patcher, tools[patcher], elfobj))
rpath = subprocess.run(
cmd,
bufsize=1,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True)
print(rpath.stdout)
if __name__ == "__main__":
main()

使用平台原生工具可以轻松地打印RPATH,稍后我们将在本示例中讨论这些工具。

最后,src子目录包含项目的CMakeLists.txt和源文件。use_message.cpp源文件包含以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <cstdlib>
#include <iostream>
#ifdef USING_message
#include <message/Message.hpp>
void messaging()
{
Message say_hello("Hello, World! From a client of yours!");
std::cout << say_hello << std::endl;
Message say_goodbye("Goodbye, World! From a client of yours!");
std::cout << say_goodbye << std::endl;
}
#else
void messaging()
{
std::cout << "Hello, World! From a client of yours!" << std::endl;
std::cout << "Goodbye, World! From a client of yours!" << std::endl;
}
#endif
int main()
{
messaging();
return EXIT_SUCCESS;
}

具体实施

我们将从主CMakeLists.txt文件开始,它用来协调超级构建:

与之前的示例相同。首先声明一个C++11项目,设置了默认安装路径、构建类型、目标的输出目录,以及安装树中组件的布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
cmake_minimum_required(VERSION 3.6 FATAL_ERROR)
project(recipe-04
LANGUAGES CXX
VERSION 1.0.0
)
# <<< General set up >>>
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()
message(STATUS "Build type set to ${CMAKE_BUILD_TYPE}")
message(STATUS "Project will be installed to ${CMAKE_INSTALL_PREFIX}")
include(GNUInstallDirs)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY
${PROJECT_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
${PROJECT_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY
${PROJECT_BINARY_DIR}/${CMAKE_INSTALL_BINDIR})
# Offer the user the choice of overriding the installation directories
set(INSTALL_LIBDIR ${CMAKE_INSTALL_LIBDIR} CACHE PATH "Installation directory for libraries")
set(INSTALL_BINDIR ${CMAKE_INSTALL_BINDIR} CACHE PATH "Installation directory for executables")
set(INSTALL_INCLUDEDIR ${CMAKE_INSTALL_INCLUDEDIR} CACHE PATH "Installation directory for header files")
if(WIN32 AND NOT CYGWIN)
set(DEF_INSTALL_CMAKEDIR CMake)
else()
set(DEF_INSTALL_CMAKEDIR share/cmake/${PROJECT_NAME})
endif()
set(INSTALL_CMAKEDIR ${DEF_INSTALL_CMAKEDIR} CACHE PATH "Installation directory for CMake files")
# Report to user
foreach(p LIB BIN INCLUDE CMAKE)
file(TO_NATIVE_PATH ${CMAKE_INSTALL_PREFIX}/${INSTALL_${p}DIR} _path )
message(STATUS "Installing ${p} components to ${_path}")
unset(_path)
endforeach()

设置了EP_BASE目录属性,这将为超构建中的子项目设置布局。所有子项目都将在CMAKE_BINARY_DIR的子项目文件夹下生成:

1
set_property(DIRECTORY PROPERTY EP_BASE ${CMAKE_BINARY_DIR}/subprojects)

然后,声明STAGED_INSTALL_PREFIX变量。这个变量指向构建目录下的stage子目录,项目将在构建期间安装在这里。这是一种沙箱安装过程,让我们有机会检查整个超级构建的布局:

1
2
set(STAGED_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/stage)
message(STATUS "${PROJECT_NAME} staged install: ${STAGED_INSTALL_PREFIX}")

添加external/upstream子目录。其中包括使用CMake指令来管理我们的上游依赖关系,在我们的例子中,就是消息库:

1
add_subdirectory(external/upstream)

然后,包含ExternalProject.cmake标准模块:

1
include(ExternalProject)

将自己的项目作为外部项目添加,调用ExternalProject_Add命令。SOURCE_DIR用于指定源位于src子目录中。我们会选择适当的CMake参数来配置我们的项目。这里,使用STAGED_INSTALL_PREFIX作为子项目的安装目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
ExternalProject_Add(${PROJECT_NAME}_core
DEPENDS
message_external
SOURCE_DIR
${CMAKE_CURRENT_SOURCE_DIR}/src
CMAKE_ARGS
-DCMAKE_INSTALL_PREFIX=${STAGED_INSTALL_PREFIX}
-DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE}
-DCMAKE_CXX_COMPILER=${CMAKE_CXX_COMPILER}
-DCMAKE_CXX_FLAGS=${CMAKE_CXX_FLAGS}
-DCMAKE_CXX_STANDARD=${CMAKE_CXX_STANDARD}
-DCMAKE_CXX_EXTENSIONS=${CMAKE_CXX_EXTENSIONS}
-DCMAKE_CXX_STANDARD_REQUIRED=${CMAKE_CXX_STANDARD_REQUIRED}
-Dmessage_DIR=${message_DIR}
CMAKE_CACHE_ARGS
-DCMAKE_PREFIX_PATH:PATH=${CMAKE_PREFIX_PATH}
BUILD_ALWAYS
1
)

现在,为use_message添加一个测试,并由recipe-04_core构建。这将运行use_message可执行文件的安装,即位于构建树中的安装:

1
2
3
4
5
6
7
enable_testing()
add_test(
NAME
check_use_message
COMMAND
${STAGED_INSTALL_PREFIX}/${INSTALL_BINDIR}/use_message
)

最后,可以声明安装规则。因为所需要的东西都已经安装在暂存区域中,我们只要将暂存区域的内容复制到安装目录即可:

1
2
3
4
5
6
7
install(
DIRECTORY
${STAGED_INSTALL_PREFIX}/
DESTINATION
.
USE_SOURCE_PERMISSIONS
)

使用SCRIPT参数声明一个附加的安装规则。CMake脚本的install_hook.cmake将被执行,但只在GNU/Linux和macOS上执行。这个脚本将打印已安装的可执行文件的RPATH,并运行它。我们将在下一节详细地讨论这个问题:

1
2
3
4
5
6
7
8
if(UNIX)
set(PRINT_SCRIPT "${CMAKE_CURRENT_LIST_DIR}/cmake/print_rpath.py")
configure_file(cmake/install_hook.cmake.in install_hook.cmake @ONLY)
install(
SCRIPT
${CMAKE_CURRENT_BINARY_DIR}/install_hook.cmake
)
endif()

-Dmessage_DIR=${message_DIR}已作为CMake参数传递给项目,这将正确设置消息库依赖项的位置。message_DIR的值在external/upstream/message目录下的CMakeLists.txt文件中定义。这个文件处理依赖于消息库,让我们看看是如何处理的:

首先,搜索并找到包。用户可能已经在系统的某个地方安装了,并在配置时传递了message_DIR:

1
find_package(message 1 CONFIG QUIET)

如果找到了消息库,我们将向用户报告目标的位置和版本,并添加一个虚拟的message_external目标。这里,需要虚拟目标来正确处理超构建的依赖关系:

1
2
3
4
if(message_FOUND)
get_property(_loc TARGET message::message-shared PROPERTY LOCATION)
message(STATUS "Found message: ${_loc} (found version ${message_VERSION})")
add_library(message_external INTERFACE) # dummy

如果没有找到这个库,我们将把它添加为一个外部项目,从在线Git存储库下载它,然后编译它。安装路径、构建类型和安装目录布局都是由主CMakeLists.txt文件设置,C++编译器和标志也是如此。项目将安装到STAGED_INSTALL_PREFIX下,然后进行测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
else()
include(ExternalProject)
message(STATUS "Suitable message could not be located, Building message instead.")
ExternalProject_Add(message_external
GIT_REPOSITORY
https://github.com/dev-cafe/message.git
GIT_TAG
master
UPDATE_COMMAND
""
CMAKE_ARGS
-DCMAKE_INSTALL_PREFIX=${STAGED_INSTALL_PREFIX}
-DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE}
-DCMAKE_CXX_COMPILER=${CMAKE_CXX_COMPILER}
CMAKE_CACHE_ARGS
-DCMAKE_CXX_FLAGS:STRING=${CMAKE_CXX_FLAGS}
TEST_AFTER_INSTALL
1
DOWNLOAD_NO_PROGRESS
1
LOG_CONFIGURE
1
LOG_BUILD
1
LOG_INSTALL
1
)

最后,将message_DIR目录进行设置,为指向新构建的messageConfig.cmake文件指明安装路径。注意,这些路径被保存到CMakeCache中:

1
2
3
4
5
6
7
8
9
  if(WIN32 AND NOT CYGWIN)
set(DEF_message_DIR ${STAGED_INSTALL_PREFIX}/CMake)
else()
set(DEF_message_DIR ${STAGED_INSTALL_PREFIX}/share/cmake/message)
endif()
file(TO_NATIVE_PATH "${DEF_message_DIR}" DEF_message_DIR)
set(message_DIR ${DEF_message_DIR}
CACHE PATH "Path to internally built messageConfig.cmake" FORCE)
endif()

我们终于准备好编译我们自己的项目,并成功地将其链接到消息库(无论是系统上已有的消息库,还是新构建的消息库)。由于这是一个超级构建,src子目录下的代码是一个完全独立的CMake项目:

声明一个C++11项目:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
cmake_minimum_required(VERSION 3.6 FATAL_ERROR)
project(recipe-04_core
LANGUAGES CXX
)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
include(GNUInstallDirs)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY
${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY
${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_BINDIR})

尝试找到消息库。超级构建中,正确设置message_DIR:

1
2
3
find_package(message 1 CONFIG REQUIRED)
get_property(_loc TARGET message::message-shared PROPERTY LOCATION)
message(STATUS "Found message: ${_loc} (found version ${message_VERSION})")

添加可执行目标use_message,该目标由use_message.cpp源文件创建,并连接到message::message-shared目标:

1
2
3
4
5
add_executable(use_message use_message.cpp)
target_link_libraries(use_message
PUBLIC
message::message-shared
)

为use_message设置目标属性。再次对RPATH进行设置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Prepare RPATH
file(RELATIVE_PATH _rel ${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_BINDIR} ${CMAKE_INSTALL_PREFIX})
if(APPLE)
set(_rpath "@loader_path/${_rel}")
else()
set(_rpath "\$ORIGIN/${_rel}")
endif()
file(TO_NATIVE_PATH "${_rpath}/${CMAKE_INSTALL_LIBDIR}" use_message_RPATH)
set_target_properties(use_message
PROPERTIES
MACOSX_RPATH ON
SKIP_BUILD_RPATH OFF
BUILD_WITH_INSTALL_RPATH OFF
INSTALL_RPATH "${use_message_RPATH}"
INSTALL_RPATH_USE_LINK_PATH ON
)

最后,为use_message目标设置了安装规则:

1
2
3
4
5
6
7
install(
TARGETS
use_message
RUNTIME
DESTINATION ${CMAKE_INSTALL_BINDIR}
COMPONENT bin
)

现在瞧瞧CMake脚本模板install_hook.cmake.in的内容:

CMake脚本在我们的主项目范围之外执行,因此没有定义变量或目标的概念。因此,需要设置变量来保存已安装的use_message可执行文件的完整路径。注意使用@INSTALL_BINDIR@,它将由configure_file解析:

1
set(_executable ${CMAKE_INSTALL_PREFIX}/@INSTALL_BINDIR@/use_message)

需要找到平台本机可执行工具,使用该工具打印已安装的可执行文件的RPATH。我们将搜索chrpath、patchelf和otool。当找到已安装的程序时,向用户提供有用的状态信息,并且退出搜索:

1
2
3
4
5
6
7
8
9
10
11
12
13
set(_patcher)
list(APPEND _patchers chrpath patchelf otool)
foreach(p IN LISTS _patchers)
find_program(${p}_FOUND
NAMES
${p}
)
if(${p}_FOUND)
set(_patcher ${p})
message(STATUS "ELF patching tool ${_patcher} FOUND")
break()
endif()
endforeach()

检查_patcher变量是否为空,这意味着PatchELF工具是否可用。当为空时,我们要进行的操作将会失败,所以会发出一个致命错误,提醒用户需要安装PatchELF工具:

1
2
if(NOT _patcher)
message(FATAL_ERROR "ELF patching tool NOT FOUND!\nPlease install one of chrpath, patchelf or otool")

当PatchELF工具找到了,则继续。我们调用Python脚本print_rpath.py,将_executable变量作为参数传递给execute_process:

1
2
3
4
5
6
7
8
9
10
find_package(PythonInterp REQUIRED QUIET)
execute_process(
COMMAND
${PYTHON_EXECUTABLE} @PRINT_SCRIPT@ "${_patcher}"
"${_executable}"
RESULT_VARIABLE _res
OUTPUT_VARIABLE _out
ERROR_VARIABLE _err
OUTPUT_STRIP_TRAILING_WHITESPACE
)

检查_res变量的返回代码。如果执行成功,将打印_out变量中捕获的标准输出流。否则,打印退出前捕获的标准输出和错误流:

1
2
3
4
5
6
7
8
9
  if(_res EQUAL 0)
message(STATUS "RPATH for ${_executable} is ${_out}")
else()
message(STATUS "Something went wrong!")
message(STATUS "Standard output from print_rpath.py: ${_out}")
message(STATUS "Standard error from print_rpath.py: ${_err}")
message(FATAL_ERROR "${_patcher} could NOT obtain RPATH for ${_executable}")
endif()
endif()

再使用execute_process来运行已安装的use_message可执行目标:

1
2
3
4
5
6
7
execute_process(
COMMAND ${_executable}
RESULT_VARIABLE _res
OUTPUT_VARIABLE _out
ERROR_VARIABLE _err
OUTPUT_STRIP_TRAILING_WHITESPACE
)

最后,向用户报告execute_process的结果:

1
2
3
4
5
6
7
8
if(_res EQUAL 0)
message(STATUS "Running ${_executable}:\n ${_out}")
else()
message(STATUS "Something went wrong!")
message(STATUS "Standard output from running ${_executable}:\n ${_out}")
message(STATUS "Standard error from running ${_executable}:\n ${_err}")
message(FATAL_ERROR "Something went wrong with ${_executable}")
endif()

工作原理

CMake工具箱中,超级构建是非常有用的模式。它通过将复杂的项目划分为更小、更容易管理的子项目来管理它们。此外,可以使用CMake作为构建项目的包管理器。CMake可以搜索依赖项,如果在系统上找不到依赖项,则重新构建它们。这里需要三个CMakeLists.txt文件:

  • 主CMakeLists.txt文件包含项目和依赖项共享的设置,还包括我们自己的项目(作为外部项目)。本例中,我们选择的名称为${PROJECT_NAME}_core;也就是recipe-04_core,因为项目名称recipe-04用于超级构建。
  • 外部CMakeLists.txt文件将尝试查找上游依赖项,并在导入目标和构建目标之间进行切换,这取决于是否找到了依赖项。对于每个依赖项,最好有单独的子目录,其中包含一个CMakeLists.txt文件。
  • 最后,我们项目的CMakeLists.txt文件,可以构建一个独立的CMake项目。在原则上,我们可以自己配置和构建它,而不需要超级构建提供的依赖关系管理工具。

当对消息库的依赖关系未得到满足时,将首先考虑超级构建:

1
2
3
$ mkdir -p build
$ cd build
$ cmake -DCMAKE_INSTALL_PREFIX=$HOME/Software/recipe-04 ..

让CMake查找库,这是我们得到的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
-- The CXX compiler identification is GNU 7.3.0
-- Check for working CXX compiler: /nix/store/gqg2vrcq7krqi9rrl6pphvsg81sb8pjw-gcc-wrapper-7.3.0/bin/g++
-- Check for working CXX compiler: /nix/store/gqg2vrcq7krqi9rrl6pphvsg81sb8pjw-gcc-wrapper-7.3.0/bin/g++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Project will be installed to /home/roberto/Software/recipe-04
-- Build type set to Release
-- Installing LIB components to /home/roberto/Software/recipe-04/lib64
-- Installing BIN components to /home/roberto/Software/recipe-04/bin
-- Installing INCLUDE components to /home/roberto/Software/recipe-04/include
-- Installing CMAKE components to /home/roberto/Software/recipe-04/share/cmake/recipe-04
-- recipe-04 staged install: /home/roberto/Workspace/robertodr/cmake-cookbook/chapter-10/recipe-04/cxx-example/build/stage
-- Suitable message could not be located, Building message instead.
-- Configuring done
-- Generating done
-- Build files have been written to: /home/roberto/Workspace/robertodr/cmake-cookbook/chapter-10/recipe-04/cxx-example/build

根据指令,CMake报告如下:

安装将分阶段进入构建树。分阶段安装是对实际安装过程进行沙箱化的一种方法。作为开发人员,这对于在运行安装命令之前检查所有库、可执行程序和文件是否安装在正确的位置非常有用。对于用户来说,可在构建目录中给出了相同的结构。这样,即使没有运行正确的安装,我们的项目也可以立即使用。

系统上没有找到合适的消息库。然后,CMake将运行在构建项目之前构建库所提供的命令,以满足这种依赖性。

如果库已经位于系统的已知位置,我们可以将-Dmessage_DIR选项传递给CMake:

1
$ cmake -DCMAKE_INSTALL_PREFIX=$HOME/Software/use_message -Dmessage_DIR=$HOME/Software/message/share/cmake/message ..

事实上,这个库已经找到并导入。我们对自己的项目进行建造操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- The CXX compiler identification is GNU 7.3.0
-- Check for working CXX compiler: /nix/store/gqg2vrcq7krqi9rrl6pphvsg81sb8pjw-gcc-wrapper-7.3.0/bin/g++
-- Check for working CXX compiler: /nix/store/gqg2vrcq7krqi9rrl6pphvsg81sb8pjw-gcc-wrapper-7.3.0/bin/g++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Project will be installed to /home/roberto/Software/recipe-04
-- Build type set to Release
-- Installing LIB components to /home/roberto/Software/recipe-04/lib64
-- Installing BIN components to /home/roberto/Software/recipe-04/bin
-- Installing INCLUDE components to /home/roberto/Software/recipe-04/include
-- Installing CMAKE components to /home/roberto/Software/recipe-04/share/cmake/recipe-04
-- recipe-04 staged install: /home/roberto/Workspace/robertodr/cmake-cookbook/chapter-10/recipe-04/cxx-example/build/stage
-- Checking for one of the modules 'uuid'
-- Found message: /home/roberto/Software/message/lib64/libmessage.so.1 (found version 1.0.0)
-- Configuring done
-- Generating done
-- Build files have been written to: /home/roberto/Workspace/robertodr/cmake-cookbook/chapter-10/recipe-04/cxx-example/build

项目的最终安装规则是,将安装文件复制到CMAKE_INSTALL_PREFIX:

1
2
3
4
5
6
7
install(
DIRECTORY
${STAGED_INSTALL_PREFIX}/
DESTINATION
.
USE_SOURCE_PERMISSIONS
)

注意使用.而不是绝对路径${CMAKE_INSTALL_PREFIX},这样CPack工具就可以正确理解该规则。

recipe-04_core项目构建一个简单的可执行目标,该目标链接到消息动态库。正如本章前几节所讨论,为了让可执行文件正确运行,需要正确设置RPATH。本章的第1节展示了,如何在CMake的帮助下实现这一点,同样的模式在CMakeLists.txt中被重用,用于创建use_message的可执行目标:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
file(RELATIVE_PATH _rel ${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_BINDIR} ${CMAKE_INSTALL_PREFIX})
if(APPLE)
set(_rpath "@loader_path/${_rel}")
else()
set(_rpath "\$ORIGIN/${_rel}")
endif()
file(TO_NATIVE_PATH "${_rpath}/${CMAKE_INSTALL_LIBDIR}" use_message_RPATH)
set_target_properties(use_message
PROPERTIES
MACOSX_RPATH ON
SKIP_BUILD_RPATH OFF
BUILD_WITH_INSTALL_RPATH OFF
INSTALL_RPATH "${use_message_RPATH}"
INSTALL_RPATH_USE_LINK_PATH ON
)

为了检查这是否合适,可以使用本机工具打印已安装的可执行文件的RPATH。我们将对该工具的调用,封装到Python脚本中,并将其进一步封装到CMake脚本中。最后,使用SCRIPT关键字将CMake脚本作为安装规则调用:

1
2
3
4
5
6
7
8
if(UNIX)
set(PRINT_SCRIPT "${CMAKE_CURRENT_LIST_DIR}/cmake/print_rpath.py")
configure_file(cmake/install_hook.cmake.in install_hook.cmake @ONLY)
install(
SCRIPT
${CMAKE_CURRENT_BINARY_DIR}/install_hook.cmake
)
endif()

脚本是在安装最后进行执行:

1
$ cmake --build build --target install

GNU/Linux系统上,我们将看到以下输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Install the project...
-- Install configuration: "Release"
-- Installing: /home/roberto/Software/recipe-04/.
-- Installing: /home/roberto/Software/recipe-04/./lib64
-- Installing: /home/roberto/Software/recipe-04/./lib64/libmessage.so
-- Installing: /home/roberto/Software/recipe-04/./lib64/libmessage_s.a
-- Installing: /home/roberto/Software/recipe-04/./lib64/libmessage.so.1
-- Installing: /home/roberto/Software/recipe-04/./include
-- Installing: /home/roberto/Software/recipe-04/./include/message
-- Installing: /home/roberto/Software/recipe-04/./include/message/Message.hpp
-- Installing: /home/roberto/Software/recipe-04/./include/message/messageExport.h
-- Installing: /home/roberto/Software/recipe-04/./share
-- Installing: /home/roberto/Software/recipe-04/./share/cmake
-- Installing: /home/roberto/Software/recipe-04/./share/cmake/message
-- Installing: /home/roberto/Software/recipe-04/./share/cmake/message/messageTargets-release.cmake
-- Installing: /home/roberto/Software/recipe-04/./share/cmake/message/messageConfigVersion.cmake
-- Installing: /home/roberto/Software/recipe-04/./share/cmake/message/messageConfig.cmake
-- Installing: /home/roberto/Software/recipe-04/./share/cmake/message/messageTargets.cmake
-- Installing: /home/roberto/Software/recipe-04/./bin
-- Installing: /home/roberto/Software/recipe-04/./bin/hello-world_wAR
-- Installing: /home/roberto/Software/recipe-04/./bin/use_message
-- Installing: /home/roberto/Software/recipe-04/./bin/hello-world_wDSO
-- ELF patching tool chrpath FOUND
-- RPATH for /home/roberto/Software/recipe-04/bin/use_message is /home/roberto/Software/recipe-04/bin/use_message: RUNPATH=$ORIGIN/../lib64:/home/roberto/Workspace/robertodr/cmake-cookbook/chapter-10/recipe-04/cxx-example/build/stage/lib64:/nix/store/di389pfcw2krnmh8nmkn55d1rnzmba37-CMake-Cookbook/lib64:/nix/store/di389pfcw2krnmh8nmkn55d1rnzmba37-CMake-Cookbook/lib:/nix/store/mjs2b8mmid86lvbzibzdlz8w5yrjgcnf-util-linux-2.31.1/lib:/nix/store/2kcrj1ksd2a14bm5sky182fv2xwfhfap-glibc-2.26-131/lib:/nix/store/4zd34747fz0ggzzasy4icgn3lmy89pra-gcc-7.3.0-lib/lib
-- Running /home/roberto/Software/recipe-04/bin/use_message:
This is my very nice message:
Hello, World! From a client of yours!
...and here is its UUID: a8014bf7-5dfa-45e2-8408-12e9a5941825
This is my very nice message:
Goodbye, World! From a client of yours!
...and here is its UUID: ac971ef4-7606-460f-9144-1ad96f713647

C++和标准库速成

基础知识

类型

枚举类型只是一个整数值,如果试图对枚举类型进行算术操作,编译器会给出警告或错误信息。如果没有给出一个枚举成员的整型值,编译器会将上一个枚举成员的值递增1,再赋予当前的枚举成员。如果没有给第一个枚举成员赋值,编译器就给它赋值0。

强类型枚举

上面给出的枚举并不是强类型的,这意味着其并非类型安全的。它们总被解释为整型数据,因此可以比较完全不同的枚举类型中的枚举值。强类型的enum class枚举解决了这些问题,例如,下面定义前述PieceType枚举的类型安全版本:

1
2
3
4
5
6
enum class PieceType 
King = 1,
Queen,
Rook = 10,
Pawn
};

对于enumclass,枚举值名不会自动超出封闭的作用域,这表示总要使用作用域解析操作符:

1
PieceType piece = PieceType::King;

这也意味着给枚举值指定了更简短的名称,例如,用King替代PieceTypeKing。另外,枚举值不会自动转换为整数。因此,下面的代码是不合法的:

1
if (PieceType: :Queen == 2) {...}

默认情况下,枚举值的基本类型是整型,但可采用以下方式加以改变:

1
2
3
4
5
6
enum Class PieceType : unsigned long
King = 1,
Queen,
Rook = 10,
Pawn
};

if/else

C++17允许在if中包含一个初始化器:

1
if (<initializer>; <conditional_expression>) {<body>}

switch

一旦找到与switch条件匹配的case表达式,就执行其后的所有语句,直至遇到break语句为止。即使遇到另一个case表达式,执行也会继续,这称为fallthrough。下例有一组语句,会为不同的case执行:

1
2
3
4
5
6
7
8
9
switch (backgroundColor) {
case Color::DarkBlue:
case Color::Black:
// Code to execute for both a dark blue or black background color
break;
case Color::Red:
// Code to execute for a red background color
break;
}

如果你无意间忘掉了break语句,fllthrough 将成为bug的来源。因此,如果在switch语句中检测到fallthrough,编译器将生成警告信息,除非像上例那样case 为空。从C++17开始,你可以使用allthrough]特性,告诉编译器某个fallthrough 是有意为之,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
switch (backgroundColor) {
case Color::DarkBlue:
doSomethingForDarkBlue () ;
[[fallthrough]] ;
case Color::Black:
// Code is executed for both a dark blue or black background color
doSome thingForBlackOrDarkBlue() ;
break;
case Color::Red:
case Color::Green:
// Code to execute for a red or green background color
break;
}

逻辑符

C++对表达式求值时会采用短路逻辑。这意味着一旦最终结果可确定,就不对表达式的剩余部分求值。例如,当执行如下所示的多个布尔表达式的逻辑或操作时,如果发现其中一个表达式的值为true,立刻可判定其结果为true,就不再检测剩余部分。

1
bool result = bool1 || bool2 || (i > 7) || (27 / 13 % i + 1)<2;

在此例中,如果bool1的值是true,整个表达式的值必然为true,因此不会对其他部分求值。这种方法可阻止代码执行多余操作。然而,如果后面的表达式以某种方式影响程序的状态,就会带来难以发现的bug。

短路做法对性能有好处。在使用逻辑短路时,可将代价更低的测试放在前面,以避免执行代价更高的测试。在指针上下文中,它也可避免指针无效时执行表达式的一部分的情况。本章后面将讨论指针以及包含短路的指针。

函数

函数返回类型的推断

C++14允许要求编译器自动推断出函数的返回类型。要使用这个功能,需要把auto指定为返回类型:

1
2
auto addNumbers (int number1, int number2)
return number1 + number2;

编译器根据return语句使用的表达式推断返回类型。函数中可有多个return语句,但它们应解析为相同的类型。这种函数甚至可包含递归调用(调用自身),但函数中的第一个return语句必须是非递归调用。

当前函数的名称

每个函数都有一个预定义的局部变量__func__, 其中包含当前函数的名称。这个变量的一个用途是用于日志记录:

1
2
3
4
int addNumbers(int number1, int number2) {
std::cout << "Entering function " << func << std::endl;
return numberl + number2 ;
}

std::array

上一节讨论的数组来自C,仍能在C++中使用。但C++有一种大小固定的特殊容器std:array,这种容器在<array>头文件中定义。它基本上是对C风格的数组进行了简单包装。用std:array替代C风格的数组会带来很多好处。它总是知道自身大小,不会自动转换为指针,从而避免了某些类型的bug;具有迭代器,可方便地遍历元素。

下例演示了array 容器的用法,必须在尖括号中指定两个参数。第一个参数表示数组中元素的类型,第二个参数表示数组的大小。

1
2
3
array<int, 3> arr = {987};
cout << "Array size = " << arr.size() << endl;
cout << "2nd element = " << arr[1] << endl ;

C风格的数组和std:array都具有固定的大小,在编译时必须知道这一点。在运行时数组不会增大或缩小。

std::vector

标准库提供了多个不同的非固定大小容器,可用于存储信息。std:vector就是此类容器的一个示例,它在<vector>中声明,用一种更灵活和安全的机制取代C中数组的概念。用户不需要担心内存的管理,因为vector将自动分配足够的内存来存放其元素。vector 是动态的,意味着可在运行时添加和删除元素。下面的示例演示了vector的基本功能。

1
2
3
4
5
// Create a vector of integers
vector<int> myVector = { 11, 22 };
// Add some more integers to the vector using push_ back()
myVector.push_back(33);
myVector.push_back(44);

深入研究C++

C++中的字符串

在C++中使用字符串有三种方法。一种是C风格,将字符串看成字符数组;一种是C++风格,将字符串封装到一种易于使用的string类型中;还有一种是非标准的普通类。

与I/0流一样,string类型位于std名称空间中。下面的示例说明了string 如何像字符数组那样使用:

1
2
3
string myString = "He1lo, world";
cout << "The value of myString is" << myString << endl;
cout << "The second letter is " << myString[1] << endl;

指针和动态内存

动态内存允许所创建的程序具有在编译时大小可变的数据,大多数复杂程序都会以某种方式使用动态内存。

堆栈和堆

C++程序中的内存分为两个部分——堆栈和堆。当前函数中声明的所有变量将占用顶部堆栈帧的内存。如果当前函数调用了另一个函数bar()bar()就会拥有自己的堆栈帧供其运行。任何从foo()传递给bar()的参数都会从foo()堆栈帧复制到bar()堆栈帧。

堆栈帧很好,因为它为每个函数提供了独立的内存空间。如果在foo()堆栈帧中声明了一个变量,那么除非专门要求,否则调用bar()函数不会更改该变量。此外,foo()函数执行完毕时,堆栈帧就会消失,该函数中声明的所有变量都不会再占用内存。在堆栈上分配内存的变量不需要由程序员释放内存,这个过程是自动完成的。

堆是与当前函数或堆栈帧完全没有关系的内存区域。如果想在函数调用结束之后仍然保存其中声明的变量,可以将变量放到堆中。程序可在任何时候向堆中添加新位或修改堆中已有的位。必须确保释放在堆上分配的任何内存,这个过程不会自动完成。

动态分配的数组

堆也可以用于动态分配数组。使用new[]操作符可给数组分配内存:

1
2
int arraySize = 8;
int* myVariableSizedArray = new int[arraySize] ;

这条语句分配的内存用于存储8个整数,内存的大小与arraySize变量对应。图1-3显示了执行这条语句后堆栈和堆的情况。可以看到,指针变量仍在堆栈中,但动态创建的数组在堆中。

现在已经分配了内存,可将myVariableSizedArray当作基于堆栈的普通数组使用

在C++中,每次调用new时,都必须相应地调用delete;每次调用new[]时,都必须相应地调用delete[],以避免内存泄漏。如果未调用deletedelete[],或调用不匹配,会导致内存泄漏。

空指针常量

在C++11之前,常量NULL用于表示空指针。将NULL定义为常量0会导致一些问题。分析下面的例子:

1
2
3
4
5
6
void func(char* str) {cout << "char* version" << end1; }
void func(int i) {cout << "int version" << endl; }
int main() {
func (NULL);
return 0;
}

main()函数通过参数NULL调用func(), NULL是一个空指针常量。换言之,该例要用空指针作为实参,调用func()char*版本。但是,NULL不是指针,而等价于整数0,所以实际调用的是func()的整数版本。

可引入真正的空指针常量nullptr解决这个问题。

统一初始化

C++11之前,Struct变量和Class变量的初始化是不同的:

1
2
CircleStruct myCircle1 = {10, 10, 2.5};
CircleClass myCircle2(10, 10, 2.5);

对于结构版本,可使用{…}语法。然而,对于类版本,需要使用函数符号(..)调用构造函数。自C++11以后,允许使用{…}语法初始化类型,如下所示:

1
2
CircleStruct myCircle3 = {10102.5};
CircleClass myCircle4 = {10102.5};

定义myCircle4时将自动调用CircleClass的构造函数。甚至等号也是可选的,因此下面的代码与前面的代码等价:

1
2
CircleStruct myCircle5{10102.5};
CircleClass myCircle6{1010, 2.5};

统一初始化并不局限于结构和类,它还可用于初始化C++中的任何内容。例如,下面的代码把所有4个变量都初始化为3:

1
2
3
4
int a = 3;
int b(3) ;
int c = {3}; // Uniform initial ization
int d{3};

统一初始化还可用于将变量初始化为0;使用默认构造函数构造对象,将基本整数类型(如char和int等)初始化为0,将浮点类型初始化为0.0,将指针类型初始化为nullptr。例如:

1
2
int e{};
// Uniform initialization, e will be 0

使用统一初始化还可以阻止窄化。C++隐式地执行窄化,例如:

1
2
3
4
5
void func(int i) { /*... */ }
int main() {
int x = 3.14;
func(3.14) ;
}

这两种情况下,C++在对x赋值或调用func()之前,会自动将3.14截断为3。注意有些编译器会针对窄化给出警告信息,而另一些编译器则不会。使用统一初始化,如果编译器完全支持C++11标准,x的赋值和func()的调用都会生成编译错误:

1
2
3
4
5
6
7
void func(int i) { /* ... */ }
int main() {
int x = {3.14};
// Error because narrowing
func({3.14});
// Error because narrowing
}

统一初始化还可用来初始化动态分配的数组:

1
int* pArray = new int[4]{0, 1, 23};

统一初始化还可在构造函数初始化器中初始化类成员数组:

1
2
3
4
5
6
7
class MyClass
{
public:
MyClass() : mArray{0, 1, 2, 3} ()
private:
int mArray[4];
};

直接列表初始化与复制列表初始化

有两种初始化类型使用包含在大括号中的初始化列表:

  • 复制列表初始化:T obj = {argl, arg2, ...};
  • 直接列表初始化:T obj {argl, arg2, ...};

在C++17中,与auto类型推断相结合,直接列表初始化与复制列表初始化存在重要区别。从C++17开始,可得到以下结果:

1
2
3
4
5
6
7
// Copy list initialization
auto a = {11}; // initializer_list<int>
auto b = {11, 22}; // initializer_list<int>

// Direct list initialization
auto c {11}; // int
auto d {11, 22}; // Error, too many elements.

注意,对于复制列表初始化,放在大括号中的初始化器的所有元素都必须使用相同的类型。例如,以下代码无法编译:

1
auto b = {11, 22.33}; // Compilation error

在早期标准版本(C++11/14)中,复制列表初始化和直接列表初始化会推导出initializer_ list<>

1
2
3
4
5
6
7
// Copy list initialization
auto a = {11}; // initializer_list<int>
auto b = {11, 22}; // initializer_list<int>

// Direct list initialization
auto c {11}; // initializer_list<int>
auto d {11, 22}; // initializer_list<int>

使用string和string_view

动态字符串

C语言中并没有真正好用的string数据类型,只有固定的字节数组。“字符串库”只不过是一组非常原始的函数,甚至没有边界检查的功能。C++提供了string 类型作为数据类型。

C风格的字符串

在C语言中,字符串表示为字符的数组。字符串中的最后-一个字符是null字符(‘\0’),目前,程序员使用C字符串时最常犯的错误是忘记为’\0’字符分配空间。

C++包含一些来自C语言的字符串操作函数,它们在<string>头文件中定义。为字符串分配内存的正确方式是在实际字符所需的空间加1。所以在使用C风格的字符串时要记住这一点。正确的实现代码如下:

1
2
3
4
5
char* copyString (const char* str) {
char* result = new char [strlen(str) + 1] ;
strcpy(result, str);
return result;
}

C和C++中的sizeof()操作符可用于获得给定数据类型或变量的大小。例如,sizeof(char)返回1,因为字符的大小是1字节。但在C风格的字符串中,sizeof()strlen()是不同的。绝对不要通过sizeof()获得字符串的大小。它根据C风格的字符串的存储方式来返回不同大小。如果C风格的字符串存储为char[],则sizeof()返回字符串使用的实际内存,包括’\0’字符。例如:

1
2
3
char text1[] = "abcdef";
size_t s1 = sizeof(text1); // is 7
size_t s2 = strlen(text1); // is 6

但是,如果C风格的字符串存储为char*sizeof()就返回指针的大小。

1
2
3
const char* text2 = "abcdef";
size_t s3 = sizeof(text2); // is platform-dependent
size_t s4 = strlen(text2); // is 6

在32位模式下编译时,s3的值为4;而在64位模式下编译时,s3的值为8,因为这返回的是指针const char*的大小。

字符串字面量

与字符串字面量关联的真正内存位于内存的只读部分。通过这种方式,编译器可重用等价字符串字面量的引用,从而优化内存的使用。也就是说,即使一个程序使用了500次”hello”字符串字面量,编译器也只在内存中创建一个 hello 实例。这种技术称为字面量池(literal pooling)。

字符串字面量可赋值给变量,但因为字符串字面量位于内存的只读部分,且使用了字面量池,所以这样做会产生风险。C++标准正式指出:字符串字面量的类型为“n个const char 的数组”,然而为了向后兼容较老的不支持const的代码,大部分编译器不会强制程序将字符串字面量赋值给const char*类型的变量。这些编译器允许将字符串字面量赋值给不带有const的char*,而且整个程序可正常运行,除非试图修改字符串。一般情况下,试图修改字符串字面量的行为是没有定义的。可能会导致程序崩溃;可能使程序继续执行,看起来却有莫名其妙的副作用:可能不加通告地忽略修改行为;可能修改行为是有效的,这完全取决于编译器。

还可将字符串字面量用作字符数组(char[])的初始值。这种情况下,编译器会创建一个足以放下这个字符串的数组,然后将字符串复制到这个数组。因此,编译器不会将字面量放在只读的内存中,也不会进行字面量的
池操作。

原始字符串字面量(raw string literal)是可横跨多行代码的字符串字面量,不需要转义嵌入的双引号,像\t和\n这种转义序列不按照转义序列的方式处理,而是按照普通文本的方式处理。

1
const char* str = R"(Hello "World"!)";

C++ std::string类

在C++的string 类中,operator==operator!=operator<等运算符都被重载了,这些运算符可以操作真正的字符串字符。单独的字符可通过运算符operator[]访问。如下面的代码所示,当string操作需要扩展string时,string 类能够自动处理内存需求,因此不会再出现内存溢出的情况了:

1
2
3
4
5
6
7
8
string myString = "hello";
myString += ", there";
string myOtherString = myString;

if (myString == myOtherString)
myOtherString[0] = 'H';
cout << myString << endl ;
cout << myOtherString << endl;

在这个例子中有几点需要注意。一是要注意即使字符串被分配和调整大小,也不会出现内存泄漏的情况。所有这些string对象都创建为堆栈变量。尽管string类肯定需要完成大量分配内存和调整大小的工作,但是string
类的析构函数会在string对象离开作用域时清理内存。另外需要注意的是,运算符以预期的方式工作。例如,=运算符复制字符串,这是最有可能预期的操作。

为达到兼容的目的,还可应用string类的c_str()方法获得一个表示C风格字符串的const字符指针。不过,一旦string执行任何内存重分配或string对象被销毁了,返回的这个const指针就失效了。应该在使用结果之前调用这个方法,以便它准确反映string当前的内容。永远不要从函数中返回在基于堆栈的string上调用c_str()的结果。

还有一个data()方法,在C++14及更早的版本中,始终与c_str()一样返回const char*。 从C++17开始,在非const字符上调用时,data()返回char*

std:string字面量

源代码中的字符串字面量通常解释为const char*。 使用用户定义的标准字面量s可以把字符串字面量解释为std:string。例如:

1
2
3
4
auto string1 = "Hello World";
// string1 is a const char*
auto string2 = "Hello World"s;
// string2 is an std::string

用户定义的标准字面量s需要using namespace std:string_literals;using namespace std;

高级数值转换

std名称空间包含很多辅助函数,以便完成数值和字符串之间的转换。下面的函数可用于将数值转换为字符串。所有这些函数都负责内存分配,它们会创建一个新的string对象并返回。

  • string to_string(int val);
  • string to_string(unsigned val);
  • string to_string(long val);
  • string to_string(unsigned long val);
  • string to_string(long long val);
  • string to string(unsigned long long val);
  • string to_string(float val);
  • string to_string(double val);
  • string to_string(long double val);

通过下面这组也在std名称空间中定义的函数将字符串转换为数值。在这些函数原型中,str表示要转换的字符串,idx是一个指针,这个指针将接收第一个未转换的字符的索引,base表示转换过程中使用的进制。idx指针可以是空指针,如果是空指针,则被忽略。如果不能执行任何转换,这些函数会抛出invalid_argument异常,如果转换的值超出返回类型的范围,则抛出out_of_range异常。

  • int stoi(const string& str, size_t *idx=0, int base= 10);
  • long stol(const string& str, size_t *idx=0, int base=10);
  • unsigned long stoul(const string& str, size_t *idx=0, int base=10);
  • long long stol(const string& str, size_t *idx=0, int base=10);
  • unsigned long long stoul(const string& str, size_t *idx=0, int base= 10);
  • float stof(const string& str, size_t *idx=0);
  • double stod(const string& str, size_t *idx=0);
  • long double stold(const string& str, size_t *idx=0);

std::string_view类

在C++17中,引入std:string_view类解决了开销和易用性的问题,std:string_view类是std:basic_string_view类模板的实例化,在<string_view>头文件中定义。string_view基本上就是const string&的简单替代品,但不会产生开销。它从不复制字符串,string_view支持与std:string类似的接口。一个例外是缺少c_str(),但data()是可用的。另外,string_view确实添加了remove_prefix(size_t)remove sufix(size_t)方法;前者将起始指针前移给定的偏移量来收缩字符串,后者则将结尾指针倒退给定的偏移量来收缩字符串。

注意,无法连接一个string和一个string_view。下面的代码将无法编译:

1
2
3
string str = "Hello";
string_view sv = "world";
auto result = str + sv;

为进行编译,必须将最后一行替代为:

1
auto result = str + sv.data() ;

内存管理

使用动态内存

这个例子展示了指针既可在堆栈中,也可在堆中。

1
2
3
int** handle = nullptr;
handle = new int*;
*handle = new int;

上面的代码首先声明一个指向整数指针的指针变量handle。然后,动态分配足够的内存来保存一个指向整数的指针,并将指向这个新内存的指针保存在handle中。接下来,将另一块足以保存整数的动态内存的指针保存在*handle的内存位置。一个指针保存在堆栈中(handle),另一个指针保存在堆中(*handle)。

分配和释放

要为变量创建空间,可使用new关键字。要释放这个空间给程序中的其他部分使用,可使用delete关键字。

使用new和delete

要分配一块内存,可调用new,并提供需要空间的变量的类型。new 返回指向那个内存的指针,但程序员应将这个指针保存在变量中。如果忽略了new的返回值,或这个指针变量离开了作用域,那么这块内存就被孤立了,因为无法再访问这块内存。这也称为内存泄漏

除非计算机能提供无限制的高速内存,否则就需要告诉编译器,对象关联的内存什么时候可以释放,用作他用。为释放堆中的内存,只需要使用delete关键字,并提供指向那块内存的指针,如下所示:

1
2
3
int* ptr = new int;
delete ptr
ptr = nullptr;

建议在释放指针的内存后,将指针重新设置为nullptr. 这样就不会在无意中使用一个指向已释放内存的指针。

在C++中不应该使用malloc()free()函数。只使用new和delete运算符。malloc()free()函数不会调用构造函数和析构函数。

在C++中有一个继承自C语言的函数realloc()。不要使用它!在C中,reallo()用 于改变数组的大小,采取的方法是分配新大小的新内存块,然后将所有旧数据复制到新位置,再删除旧内存块。在C++中这种做法是极其危险的,因为用户定义的对象不能很好地适应按位复制。

当内存分配失败时

默认情况下,如果new失败了,程序会终止。当new因为没有足以满足请求的内存而抛出异常失败时,程序退出。也有不抛出异常的new版本。相反,它会返回nullptr,这类似于C语言中malloc()的行为。使用这个版本的语法如下所示:

1
int* ptr = new (nothrow) int;

数组

对象的数组

对象的数组和简单类型的数组没有区别。通过new[N]分配N个对象的数组时,实际上分配了N个连续的内存块,每一块足以容纳单个对象。使用new[]时,每个对象的无参构造函数=default会自动调用。这样,通过new[]分配对象数组时,会返回一个指向数组的指针,这个数组中的所有对象都被初始化了。

1
2
3
4
5
class Simple {
public:
Simple() { cout << "Simple constructor called!" << endl; }
~Simple() { cout << "Simple destructor called!" << endl; }
};

如果要分配包含4个Simple对象的数组,那么Simple构造函数会被调用4次。

1
Simple* mySimpleArray = new Simple[4];

删除数组

如前所述,通过数组版本的new(new[])分配内存时,必须通过数组版本的delete(delete[])释放相应的内存。这个版本的delete会自动析构数组中的对象,并释放这些对象的内存。

1
2
3
4
Simple* mySimpleArray = new Simple[4];
// Use mySimpleArray
delete [] mySimpleArray;
mySimpleArray = nullptr;

如果不使用数组版本的delete, 程序就可能出现异常行为。在一些编译器中,可能只会调用数组中第1个元素的析构函数,因为编译器只知道要删除指向一个对象的指针,而数组中的其他所有元素都变成了孤立对象。在其他编译器中,可能出现内存崩溃的情况,因为newnew[]可能采用完全不同的内存分配方案。

数组-指针的对偶性

在堆上分配的数组通过指向该数组中第一个元素的指针来引用。基于堆栈的数组通过数组语法([])和普通的变量声明来引用。

数组就是指针

通过指针不仅能指向基于堆的数组,也可以通过指针语法来访问基于堆栈的数组的元素。数组的地址就是第1个元素(索引0)的地址。编译器知道,通过变量名引用整个数组时,实际上引用的是第1个元素的地址。从这个角度看,指针用起来就像基于堆的数组。下面的代码创建了一个堆栈上的数组,数组元素初始化为0,但通过一个指针来访问这个数组:

1
2
3
4
int myIntArray[10] = {};
int* myIntPtr = myIntArray;

myIntPtr[4] = 5;

向函数传递数组时,通过指针引用基于堆栈的数组的能力非常有用。下面的函数以指针的方式接收一个整数数组。请注意,调用者需要显式地传入数组的大小,因为指针没有包含任何与大小有关的信息。事实上,任何形式的C++数组,不论是不是指针,都没有内含大小信息。这是应使用现代容器(例如标准库提供的容器)的另一个原因。

1
2
3
4
5
void doubleInts (int* theArray, size_ _t size) {
for (size_t i=0; i < size; i++){
theArray[i] *= 2;
}
}

这个函数的调用者可以传入基于堆栈或堆的数组。在传入基于堆的数组时,指针已经存在了,且按值传入函数。在传入基于堆栈的数组时,调用者可以传入一个数组变量,编译器会自动把这个数组变量当作指向数组的指针处理,还可以显式地传入第一个元素的地址。这里展示了所有三种形式:

1
2
3
4
5
6
7
8
9
10
size_t arrsize = 4;
int* heapArray = new int[arrSize]{ 1, 5, 3, 4 };
doubleInts(heapArray, arrSize) ;
delete [] heapArray;
heapArray = nullptr;

int stackArray[] = { 5, 79, 11 };
arrSize = std::size(stackArray);
doubleInts(stackArray, arrSize) ;
doubleInts(&stackArray[0],arrSize) ;

数组参数传递的语义和指针参数传递的语义十分相似,因为当把数组传递给函数时,编译器将数组视为指针。函数如果接收数组作为参数,并修改数组中元素的值,实际上修改的是原始数组而不是副本。与指针一样,传递数组实际上模仿的是按引用传递的功能,因为真正传入函数的是原始数组的地址而不是副本。

为什么在函数定义中使用数组语法时编译器不复制数组?这样做是为了提高效率——复制数组中的元素需要时间,而且数组可能占用大量的内存。总是传递指针,编译器就不需要包括复制数组的代码。

可“按引用”给函数传递长度已知的基于堆栈的数组,但其语法并不明显。它不适用于基于堆的数组。例如,下面的doubleIntsStack0仅接收大小为4的基于堆栈的数组:

1
void doubleIntsStack(int (&theArray) [4]);

低级内存操作

如果代码使用了对象,只需要确保每个类都妥善管理自己的内存。通过构造和析构,编译器可提示什么时候管理内存。将内存管理隐藏在类中可以极大地改变可用性。

指针运算

C++编译器通过声明的指针类型允许执行指针运算。如果声明一个指向int的指针,然后将这个指针递增1,那么这个指针在内存中向前移动1个int 的大小,而不是1个字节。此类操作对数组最有用,因为数组在内存中包含同构的数据序列。例如,假设在堆中声明一个整数数组:

1
int* myArray = new int[8];

下面的语法给该数组中位置2的元素设置值:

1
myArray[2] = 33;

使用指针运算可等价地使用下面的语法,这个语法获得myArray数组中“向前2个int”位置的内存地址,然后解除引用来设置值:

1
*(myArray + 2) = 33;

作为访问单个元素的替代语法,指针运算似乎没有太大吸引力。其真正的作用在于以下事实:像myArray+2这样的表达式仍是一个指向int的指针,因而可以表示一个更小的整数数组。

自定义内存管理

在99%的情况下,C++中内置的内存分配设施是足够使用的。new和delete在后台完成了所有相关工作:分配正确大小的内存块、管理可用的内存区域列表以及释放内存时将内存块释放回可用内存列表。

自行管理内存可能减少开销。当使用new分配内存时,程序还需要预留少量的空间来记录分配了多少内存。这样,当调用delete时,可以释放正确数量的内存。对于大多数对象,这个开销比实际分配的内存小得多,所以差别不大。然而,对于很小的对象或分配了大量对象的程序来说,这个开销的影响可能会很大。

当自行管理内存时,可事先了解每个对象的大小,因此可避免每个对象的开销。

垃圾回收

内存清理的另一个方面是垃圾回收。在支持垃圾回收的环境中,程序员几乎不必显式地释放与对象关联的内存。运行时库会在某时刻自动清理没有任何引用的对象。在现代C++中,使用智能指针管理内存,在旧代码中,则在对象层次通过new和delete管理内存。

标记(mark)和清扫(sweep)是一种垃圾回收的方法。使用这种方法的垃圾回收器定期检查程序中的每个指针,并将指针引用的内存标记为仍在使用。在每一轮周期结束时,未标记的内存视为没有在使用,因而被释放。

如果愿意执行以下操作,那么可以在C++中实现标记和清扫算法:

  1. 在垃圾回收器中注册所有指针,这样垃圾回收器可轻松遍历所有指针。
  2. 让所有对象都从一个混入类中派生,这个混入类可能是GartbageCollectible,允许垃圾回收器将对象标记为正在使用中。
  3. 确保在垃圾回收器运行时不能修改指针,从而保护对象的并发访问。

垃圾回收存在以下缺点:

  • 当垃圾回收器正在运行时,程序可能停止响应。
  • 使用垃圾回收器时,析构函数具有不确定性。由于对象在被垃圾回收之前不会销毁,因此对象离开作用域时不会立即执行析构函数。这意味着,由析构函数完成的资源清理操作要在将来某个不确定的时刻进行。

智能指针

智能指针可帮助管理动态分配的内存,这是避免内存泄漏建议采用的技术。这样,智能指针可保存动态分配的资源,如内存。当堆栈变量离开作用域或被重置时,会自动释放所占用的资源。智能指针可用于管理在函数作用域内(或作为类的数据成员)动态分配的资源。也可通过函数实参来传递动态分配的资源的所有权。

C++提供的一些语言特性使智能指针具有吸引力。首先,可通过模板为任何指针类型编写类型安全的智能指针类。其次,可使用运算符重载为智能指针对象提供一个接口,使智能指针对象的使用和普通指针一样。确切地讲,可重载*->运算符,使客户代码解除对智能指针对象的引用的方式和解除对普通指针的引用相同。

智能指针有多种类型。最简单的智能指针类型对资源有唯一的所有权, 当智能指针离开作用域或被重置时,会释放所引用的内存。标准库提供了std::unique_ptr,这是一个具有“唯一所有权” 语义的智能指针。

然而,指针的管理不仅是在指针离开作用域时释放它们。有时,多个对象或代码段包含同一个指针的多个副本。这个问题称为别名。为正确释放所有内存,使用这个资源的最后一个代码块应该释放该指针指向的资源,一种更成熟的智能指针类型实现了“引用计数”来跟踪指针的所有者。每次复制这个“引用计数”智能指针时,都会创建一个指向同一资源的新实例,将引用计数增加1。当这样的一个智能指针实例离开作用域或被重置时,引用计数会减1。当引用计数降为0时,则资源不再有所有者,因此智能指针释放资源。标准库提供了stl:shared_ptr,这是一个使用引用计数且具有“共享所有权”语义的智能指针。标准的shared_ptr是线程安全的,但这不意味着所指向的资源是线程安全的。

unique_ _ptr

作为经验法则,总将动态分配的对象保存在堆栈的unique_ptr实例中。

创建unique_ptrs

考虑下面的函数,这个函数在堆上分配了一个Simple对象,但是不释放这个对象,故意产生内存泄漏。

1
2
3
4
void leaky() {
Simple* mySimplePtr = new Simple(); // BUG! Memory is never released!
mySimplePtr->go() ;
}

实例unique_ptr离开作用域时(在函数的末尾,或者因为抛出了异常),就会在其析构函数中自动释放Simple对象:

1
2
3
4
void notLeaky() {
auto mySimpleSmartPtr = make_unique<Simple>() ;
mySimpleSmartPtr->go();
}

这段代码使用C++14中的make_unique()和auto关键字,所以只需要指定指针的类型,本例中是Simple。如果Simple构造函数需要参数,就把它们放在make_unique()调用的圆括号中。

如果编译器不支持make_unique(), 可创建自己的unique_ptr,如下所示,注意Simple必须写两次:

1
unique_ptr<Simple> mySimpleSmartPtr (new Simple());

在C++17之前,必须使用make_unique(),一是因为只能将类型指定一次, 二是出于安全考虑!考虑下面对foo()函数的调用:

1
foo(unique_ptr<simple> (new Simple()), unique_ptr<Bar>(new Bar (data())));

如果Simple、Bar 或data()函数的构造函数抛出异常(具体取决于编译器的优化设置),很可能是Simple 或Bar对象出现了内存泄漏。而使用make_unique(),则不会发生内存泄漏:

1
foo(make_unique<Simple>(), make_unique<Bar> (data()));

使用unique_ptrs

这个标准智能指针最大的一个亮点是:用户不需要学习大量的新语法,就可以获得巨大好处。与标准指针一样,也可将其写作:

1
(*mySimpleSmartPtr).go();

get()方法可用于直接访问底层指针。这可将指针传递给需要普通指针的函数

1
2
auto mySimpleSmartPtr = make_unique<Simple>() ;
processData(mySimpleSmartPtr.get());

可释放unique_ptr的底层指针,并使用reset()根据需要将其改成另一个指针。例如:

1
2
3
4
mySimpleSmartPtr.reset();
// Free resource and set to nullptr
mySimpleSmartPtr.reset (new Simple()); // Free resource and set to a new
// Simple instance

可使用release()断开unique_ptr与底层指针的连接。release()方法返回资源的底层指针,然后将智能指针设置为nullptr。实际上,智能指针失去对资源的所有权,负责在你用完资源时释放资源。例如:

1
2
3
4
Simple* simple = mySimpleSmartPtr.release(); // Release ownership
// Use the simple pointer...
delete simple;
simple = nullptr;

由于unique_ptr代表唯一拥有权,因此无法复制它!使用std:move()实用工具,可使用移动语义将一个unique_ptr移到另一个。这用于显式移动所有权,如下所示:

1
2
3
4
5
6
7
8
9
10
class Foo
{
public:
Foo (unique_ ptr<int> data) : mData (move (data)) { }
private:
unique_ptr<int> mData;
};

auto myIntSmartPtr = make_unique<int>(42);
Foo f(move (myIntSmartPtr));

unique_ptr和C风格数组

unique_ptr适用于存储动态分配的旧式C风格数组。下例创建了一个unique_ptr来保存动态分配的、包含10个整数的C风格数组:

1
auto myVariableSizedArray = make_unique<int[]>(10) ;

即使可使用unique_ptr存储动态分配的C风格数组,也建议改用标准库容器,例如std:arraystd:vector等。

自定义deleter

默认情况下,unique_ ptr使用标准的new和delete运算符来分配和释放内存。可将此行为改成:

1
2
3
4
5
6
7
8
9
10
int* malloc_int(int value) {
int* p = (int*)malloc(sizeof(int));
*p = value;
return p;
}

int main() {
unique_ptr<int, decltype(free)*> myIntSmartPtr(malloc_int(42), free);
return 0;
}

这段代码使用malloc_int()给整数分配内存。unique_ptr调用标准的free()函数来释放内存。如前所述,在C++中不应该使用malloc(),而应改用new。然而,unique_ ptr的这项特性是很有用的,因为还可管理其他类型的资源而不仅是内存。例如,当unique_ptr离开作用域时,可自动关闭文件或网络套接字以及其他任何资源。

但是,unique_ptr的自定义deleter的语法有些费解。需要将自定义deleter的类型指定为模板类型参数。在本例中,dcltype(free)用于返回free()类型。 模板类型参数应当是函数指针的类型,因此另外附加一个*,如decltype(free)*

shared_ptr

shared_ptr的用法与unique_ptr类似。要创建shared_ptr,可使用make_shared(),它比直接创建shared_ptr更高效。例如:

1
auto mySimpleSmartPtr = make_shared<Simple>();

从C++17开始,shared_ptr可用于存储动态分配的旧式C风格数组的指针。这在C++17之前是无法实现的。但是,尽管这在C++17中是可能的,仍建议使用标准库容器而非C风格数组。与unique_ptr一样,shared_ptr也支持get()和reset()方法。

unique_ptr类似,shared_ptr默认情况下使用标准的new和delete运算符来分配和释放内存:在C++17中存储C风格数组时,使用new[]delete[]。可更改此行为,如下所示:

1
2
// Implementation of malloc_ int() as before.
shared_ptr<int> myIntSmartPtr (malloc_int(42), free) ;

可以看到,不必将自定义deleter的类型指定为模板类型参数,这比unique_ptr的自定义deleter更简便。

强制转换shared_ptr

可用于强制转换shared_ptrs的函数是const_pointer_cast()dynamic_pointer_cast()static_pointer_cast()。C++17又添加了reinterpret_pointer_cast()。它们的行为和工作方式类似于非智能指针转换函数const_cast()dynamic_cast()static_cast()reinterpret_cast()

引用计数的必要性

作为一般概念, 引用计数(reference counting)用于跟踪正在使用的某个类的实例或特定对象的个数。引用计数的智能指针跟踪为引用一个真实指针(或某个对象)而建立的智能指针的数目。通过这种方式,智能指针可以避免双重删除。

别名

shared_ptr支持所谓的别名。这允许一个shared_ptr与另一个shared_ptr共享一个指针(拥有的指针), 但指向不同的对象(存储的指针)。例如,这可用于使用一个shared_ptr指向一个对象的成员,同时拥有该对象本身,例如:

1
2
3
4
5
6
7
8
class Foo {
public:
Foo(int value) : mData (value) { }
int mData;
};

auto foo = make_shared<Foo> (42) ;
auto aliasing = shared_ptr<int>(foo,&foo->mData) ;

仅当两个shared_ptrs(foo和aliasing)都销毁时,才销毁Foo对象。

“拥有的指针”用于引用计数;当对指针解引用或调用它的get()时,将返回“存储的指针”。存储的指针用于大多数操作,如比较运算符。可以使用owner_before()方法或std:owner_less类,基于拥有的指针执行比较。

在某些情况下(例如在std::set中存储shared_ptrs),这很有用。

weak_ptr

在C++中还有一个类与shared_ptr模板有关,那就是weak_ptrweak_ptr可包含由shared_ptr管理的资源的引用。weak_ptr不拥有这个资源,所以不能阻止shared_ptr释放资源。weak_ptr 销毁时(例如离开作用域时)不会销毁它指向的资源:然而,它可用于判断资源是否已经被关联的shared_ptr释放了。weak_ptr的构造函数要求将一个shared_ptr或另一个weak_ptr作为参数。为了访问weak_ptr中保存的指针,需要将weak_ptr转换为shared_ptr。这有两种方法:

  • 使用weak_ptr实例的lock()方法, 这个方法返回一个shared_ptr。如果同时释放了与weak_ptr关联的shared_ptr, 返回的shared_ptr是nullptr。
  • 创建一个新的shared_ptr实例,将weak_ptr 作为shared_ptr构造函数的参数。如果释放了与weak_ptr关联的shared_ptr,将抛出std::bad_weak_ptr异常。

下例演示了weak_ptr的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void useResource (weak_ptr<Simple>& weakSimple) {
auto resource = weakSimple.lock();
if (resource)
cout << "Resource still alive." << endl;
else
cout << "Resource has been freed!" << endl;
}
int main() {
auto sharedSimple = make_shared<Simple>() ;
weak_ptr<Simple> weakSimple(sharedSimple) ;
// Try to use the `weak_ptr`.
useResource(weakSimple) ;
// Reset the shared_ptr.
// Since there is only 1 `shared_ptr`to the Simple resource, this will
// free the resource, even though there is still a `weak_ptr` alive.
sharedSimple.reset();
// Try to use the `weak_ptr` a second time.
useResource (weakSimple);
return 0;
}

上述代码的输出如下:

1
2
3
4
Simple constructor called!
Resource still alive.
Simple destructor called!
Resource has been freed!

从C++17开始,shared_ptr支持C风格的数组;与此类似,weak_ptr 也支持C风格的数组。

常见的内存陷阱

分配不足的字符串

大多数情况下,都是因为程序员没有分配尾部的\0终止字符。当程序员假设某个固定的最大大小时,也会发生字符串分配不足的情况。基本的内置C风格字符串函数不会针对固定的大小操作——而是有 多少写多少,如果超出字符串的末尾,就写入未分配的内存。

有三种方法用于解决可能的分配不足问题。按照优先级降序排列,这三种方法为:

  1. 使用C++风格的字符串,它可自动处理与连接字符串关联的内存。
  2. 不要将缓冲区分配为全局变量或分配在堆栈上,而是分配在堆上。当剩余空间不足时,分配一个新缓冲区,它大到至少能保存当前内容加上新内存块的内容,将原来缓冲区的内容复制到新缓冲区,将新内容追加到后面,然后删除原来的缓冲区。
  3. 创建另一个版本的getMoreData(),这个版本接收一个最大计数值(包括\0字符),返回的字符数不多于这个值;然后跟踪剩余的空间数以及缓冲区中当前的位置。

访问内存越界

本章前面提到,指针只不过是一个内存地址,因此指针可能指向内存中的任何一个位置。这种情况很容易出现。例如,考虑一个C风格的字符串,它不小心丢失了’\0’终止字符。下面这个函数试图将字符串填满m字符,但实际上可能会继续在字符串后面填充m:

1
2
3
4
5
6
void fillWithM(char* inStr) {
int i= 0;
while (inStr[i] != '\0') {
inStr [i] = 'm';
}
}

如果把不正确的终止字符串传入这个函数,那么内存的重要部分被改写而导致程序崩溃只是时间问题。许多内存检测工具也能检测缓冲区溢出。使用像C++ string 和vector这样的高级结构有助于避免产生一些和C风格字符串和数组相关的bug。

内存泄漏

随着程序的运行,吞掉的内存越来越多。这是因为程序有内存泄漏。通过智能指针避免内存泄漏是解决这个问题的首选方法。分配了内存,但没有释放,就会发生内存泄漏。

双重删除和无效指针

通过delete 释放某个指针关联的内存时,这个内存就可以由程序的其他部分使用了。然而,无法禁止再次使用这个指针,这个指针成为悬挂指针(dangling pointer)。双重删除也是一个问题。如果第二次在同一个指针上执行delete操作,程序可能会释放重新分配给另一个对象的内存。双重删除和使用已释放的内存都是很难追查的问题,因为症状可能不会立即显现。

如果双重删除在较短的时间内发生,程序可能产生未定义的行为,因为关联的内存可能不会那么快重用。同样,如果删除的对象在删除后立即使用,这个对象很有可能仍然完好无缺。当然,无法保证这种行为会继续出现。一旦删除对象,内存分配器就没有义务保存任何对象。

熟悉类和对象

编写类

类中每个成员和方法都可用三种访问说明符之一来说明:public、protected或private。类的默认访问说明符是private:在第一个访问说明符之前声明的所有成员的访问都是私有的。类似的C++中的struct也可以拥有方法,不过struct默认的访问说明符是public。

::称为作用域解析运算符。每个普通的方法调用都会传递一个指向对象的指针,这是称为“隐藏”参数的this指针。使用这个参数可以访问数据成员或调用方法,也可将其传递给其他方法或函数。

在堆中创建对象时,通过“->”访问其成员,如同必须释放堆中的其他内存一样,也必须在对象上调用delete,释放堆中为对象分配的内存。为了避免发生内存错误,建议使用智能指针。使用智能指针不需要手动释放内存,内存会自动释放。

1
2
auto myCellp = make_unique<SpreadsheetCell>();
myCellp->setValue(3.7);

对象的生命周期

创建对象

声明对象或使用new显式分配空间时,就会创建对象。当创建对象时,会同时创建内嵌的对象。声明并编写一个构造函数可以初始化对象。从语法上讲,构造函数是与类同名的方法。构造函数没有返回类型,可以有也可以没有参数,没有参数的构造函数称为默认构造函数。可以是无参构造函数,也可以让所有参数都使用默认值。许多情况下,都必须提供默认构造函数,如果不提供,就会导致编译器错误。

构造函数用来创建对象并初始化其值。在基于堆栈和堆进行分配时可以使用构造函数。在堆栈中分配SpreadsheetCell对象时,可这样使用构造函数:

1
2
3
SpreadsheetCell myCe11(5), anotherCe1l(4);
cout << "cell 1:"<< myCell.getValue() << endl;
cout << "cell 2:"<< anotherCell.getValue() << endl;

当动态分配SpreadsheetCell对象时,可这样使用构造函数:

1
2
3
4
5
6
7
8
9
10
11
auto smartCellp = make_unique<SpreadsheetCell>(4);
//... do something with the cell, no need to delete the smart pointer
// Or with raw pointers, without smart pointers (not recommended)
SpreadsheetCell* myCellp = new SpreadsheetCell(5);
SpreadsheetCe11* anotherCellp = nullptr;
anotherCellp = new SpreadsheetCell (4) ;
// ... do something with the cells
delete myCellp;
myCellp = nullptr;
delete anotherCellp;
anotherCellp = nullptr;

注意可以声明一个指向SpreadsheetCell对象的指针,而不立即调用构造函数。堆栈中的对象在声明时会调用构造函数。

无论在堆栈中(在函数中)还是在类中(作为类的数据成员)声明指针,如果没有立即初始化指针,都应该像前面声明anotherCellp那样将指针初始化为nullptr。

在一个类中可提供多个构造函数。所有构造函数的名称相同(类名),但不同的构造函数具有不同数量的参数或者不同的参数类型。当具有多个构造函数时,在一个构造函数中执行另一个构造函数的想法很诱人。例如,以下面的方式让string构造函数调用double构造函数:

1
2
3
SpreadsheetCell::SpreadsheetCell (string_view initialValue) {
SpreadsheetCell(stringToDouble(initialValue));
}

显式调用SpreadsheetCell构造函数实际上新建了一个SpreadsheetCell类型的临时未命名对象,而并不是像预期的那样调用构造函数以初始化对象。然而,C++支持委托构造函数(delegating constructors), 允许构造函数初始化器调用同一个类的其他构造函数。

默认构造函数没有参数,也称为无参构造函数。使用默认构造函数可以在客户不指定值的情况下初始化数据成员。C++没有提供任何语法,让创建数组的代码直接调用不同的构造函数。如果想创建某个类的对象数组,最好还是定义类的默认构造函数。如果没有定义自己的构造函数,编译器会自动创建默认构造函数。如果想在标准库容器(例如stl::vector)中存储类,也需要默认构造函数。

与基于堆栈的对象的其他构造函数不同,调用默认构造函数不需要使用函数调用的语法。根据其他构造函数的语法,用户或许会试着这样调用默认构造函数:

1
2
3
4
SpreadsheetCell myCell(); // WRONG, but will compile.
myCell.setValue (6);
// However, this line will not compile.
cout << "cell 1:"<< myCell.getValue() << endl;

试图调用默认构造函数的行可以编译,但是后面的行无法编译。问题在于常说的most vexing parse,编译器实际上将第一行当作函数声明,函数名为myCell,没有参数,返回值为SpreadsheetCell对象。当编译第二行时,编译器认为用户将函数名用作对象!

对于堆中的对象,可以这样使用默认构造函数:

1
2
3
4
auto smartcellp = make_unique<SpreadsheetCell>();
// Or with a raw pointer (not recommended)
SpreadsheetCell* myCellp = new SpreadsheetCell ();
// SpreadsheetCell* myCellp = new SpreadsheetCell;

编译器生成的默认构造函数

本章的第一个SpreadsheetCell类定义如下所示:

1
2
3
4
5
6
7
class SpreadsheetCell {
public:
void setvalue(double invalue);
double getValue() const;
private:
double mValue;
};

这个类定义没有声明任何默认构造函数,但以下代码仍然可以正常运行:

1
2
SpreadsheetCell myCell;
myCell.setValue(6) ;

下面的定义与前面的定义相同,只是添加了一个显式的构造函数,用一个double值作为参数。这个定义仍然没有显式声明默认构造函数:

1
2
3
4
class SpreadsheetCell {
public:
SpreadsheetCell (double initialValue); // No default constructor
};

使用这个定义,下面的代码将无法编译:

1
2
SpreadsheetCell myCell;
myCell.setValue(6);

原因在于如果没有指定任何构造函数,编译器将自动生成无参构造函数。类所有的对象成员都可以调用编译器生成的默认构造函数,但不会初始化语言的原始类型,例如int 和double。 尽管如此,也可用它来创建类的对象。然而,如果声明了默认构造函数或其他构造函数,编译器就不会再自动生成默认构造函数。

默认构造函数与无参构造函数是一回事。术语“默认构造函数”并不仅仅是说如果没有声明任何构造函数,就会自动生成一个构造函数;而且指如果没有参数,构造函数就采用默认值。

显式的默认构造函数

在C++03或更早版本中,必须显式地编写空的默认构造函数,为了避免手动编写空的默认构造函数,C++现在支持显式的默认构造函数(explicitly defaulted constnuctor)。可按如下方法编写类的定义,而不需要在实现文件中实现默认构造函数:

1
2
3
4
5
6
7
class SpreadsheetCell {
public:
SpreadsheetCell() = default;
SpreadsheetCell (double initialValue) ;
SpreadsheetCell (std::string_view initialValue) ;
};
// Remainder of the class definition omitted for brevity

SpreadsheetCell定义了两个定制的构造函数。然而,由于使用了default 关键字,编译器仍然会生成一个标准的由编译器生成的默认构造函数。

C++还支持显式删除构造函数(explicitly deleted constructors)。例如,可定义一个只有静态方法的类,这个类没有任何构造函数,也不想让编译器生成默认构造函数。在此情况下可以显式删除默认构造函数:

1
2
3
class MyClass {
public:
MyClass() = delete;

构造函数初始化器

本章到现在为止,都是在构造函数体内初始化数据成员,例如:

1
2
3
SpreadsheetCell::SpreadsheetCell (double initialValue) {
setValue (initialValue);
}

C++提供了另一种在构造函数中初始化数据成员的方法,叫作构造函数初始化器或ctor-initializer。 下面的代码使用ctor-initializer语法重写了没有参数的SpreadsheetCell构造函数:

1
SpreadsheetCell::SpreadsheetCell (double initialValue) : mValue (initialValue) {}

可以看出,ctor-initializer 出现在构造函数参数列表和构造函数体的左大括号之间。这个列表以冒号开始,由逗号分隔。列表中的每个元素都使用函数符号、统一的初始化语法、调用基类构造函数,或者调用委托构造函数以初始化某个数据成员。

使用ctor-initializer初始化数据成员与在构造函数体内初始化数据成员不同。当C++创建某个对象时,必须在调用构造函数前创建对象的所有数据成员。如果数据成员本身就是对象,那么在创建这些数据成员时,必须为其调用构造函数。在构造函数体内给某个对象赋值时,并没有真正创建这个对象,而只是改变对象的值。

ctor-initializer允许在创建数据成员时赋初值,这样做比在后面赋值效率高。对于类型,它少了一次调用构造函数的过程,而在函数体中赋值则会多一次调用。而对于内置数据类型则没有差别。编译器会一一操作初始化列表,以适当的顺序在构造函数之内安插初始化操作,并且在任何显示用户代码之前;list中的项目顺序是由类中的成员声明顺序决定的,不是由初始化列表的顺序决定的;

如果类的数据成员是具有默认构造函数的类的对象,则不必在ctor-initializer中显式初始化对象。例如,如果有一个std::string数据成员,其默认构造函数将字符串初始化为空字符串,那么在ctor initializer中将其初始化为””是多余的。

而如果类的数据成员是没有默认构造函数的类的对象,则必须在ctor-initializer 中显式初始化对象。例如,考虑下面的SpreadsheetCell类:

1
2
3
4
class SpreadsheetCell {
public:
SpreadsheetCell (double d);
};

这个类只有一个采用double 值作为参数的显式构造函数,而没有默认构造函数。可在另一个类中将这个类用作数据成员,如下所示:

1
2
3
4
5
6
class SomeClass {
public:
SomeClass();
private:
SpreadsheetCell mCell;
};

在ctor-initializer中初始化mCell数据成员,如下所示:

1
SomeClass::SomeClass() : mCell(1.0) { }

赋值初始化,通过在函数体内进行赋值初始化;列表初始化,在冒号后使用初始化列表进行初始化。这两种方式的主要区别在于:

  • 对于在函数体中初始化,是在所有的数据成员被分配内存空间后才进行的。
  • 列表初始化是给数据成员分配内存空间时就进行初始化,就是说分配一个数据成员只要冒号后有此数据成员的赋值表达式(此表达式必须是括号赋值表达式),那么分配了内存空间后在进入函数体之前给数据成员赋值,就是说初始化这个数据成员此时函数体还未执行。
  1. 一个派生类构造函数的执行顺序如下:
  • 虚拟基类的构造函数(多个虚拟基类则按照继承的顺序执行构造函数)。
  • 基类的构造函数(多个普通基类也按照继承的顺序执行构造函数)。
  • 类类型的成员对象的构造函数(按照初始化顺序)
  • 派生类自己的构造函数。

必须使用成员初始化的四种情况

  • 当初始化一个引用成员时;
  • 当初始化一个常量成员时;
  • 当调用一个基类的构造函数,而它拥有一组参数时;
  • 当调用一个成员类的构造函数,而它拥有一组参数时;

复制构造函数

复制构造函数(copy constructor)允许所创建的对象是另一个对象的精确副本。如果没有编写复制构造函数,C++会自动生成一个,用源对象中相应数据成员的值初始化新对象的每个数据成员。如果数据成员是对象,初始化意味着调用它们的复制构造函数。下面是SpreadsheetCell类中复制构造函数的声明:

1
2
3
4
5
class SpreadsheetCell {
public:
SpreadsheetCell (const SpreadsheetCell& src) ;
// Remainder of the class definition omitted for brevity
};

复制构造函数采用源对象的const引用作为参数。与其他构造函数类似,它也没有返回值。在复制构造函数内部,应该复制源对象的所有数据成员。当然,从技术角度看,可在复制构造函数内完成任何操作,但最好按照预期的行为将新对象初始化为已有对象的副本。下面是SpreadsheetCell复制构造函数的示例实现,注意ctor-initializer的用法。

1
SpreadsheetCell::SpreadsheetCell (const SpreadsheetCell& src) : mValue (src.mValue)

假定有一组成员变量,名为m1、m2、…. mn,编译器生成的复制构造函数为:

1
classname::classname (const classname& src) : m1(src.m1), m2(src.m2), ... mn(src.mn) { }

因此多数情况下,不需要亲自编写复制构造函数!

C++中传递函数参数的默认方式是值传递,这意味着函数或方法接收某个值或对象的副本。因此,无论什么时候给函数或方法传递一个对象,编译器都会调用新对象的复制构造函数进行初始化

当调用setString()并传递一个string参数时,这个string参数会调用复制构造函数进行初始化。为初始化printString()中的inString对象,会调用string复制构造函数,其参数为name:

1
2
string name = "heading one";
printString(name) ; // Copies name

printString()方法结束时,inString 被销毁,因为它只是name的一个副本, 所以name完好无缺。当然,可通过将参数作为const引用来传递,从而避免复制构造函数的开销。

显式调用复制构造函数

也可显式地使用复制构造函数,从而将某个对象作为另一个对象的精确副本。例如,可这样创建SpreadsheetCell对象的副本:

1
2
SpreadsheetCell myCelll (4) ;
SpreadsheetCell myCe112 (myCe111); // myCe112 has the same values as myCe111

按引用传递对象

向函数或方法传递对象时,为避免复制对象,可让函数或方法采用对象的引用作为参数。按引用传递对象通常比按值传递对象的效率更高,因为只需要复制对象的地址,而不需要复制对象的全部内容。此外,按引用传递可避免对象动态内存分配的问题。

按引用传递某个对象时,使用对象引用的函数或方法可修改原始对象。如果只是为了提高效率才按引用传递,可将对象声明为const以排除这种可能。这称为按const引用传递对象。

为了提高性能,最好按const引用而不是按值传递对象。但是诸如int和double等基本类型应当按值传递。按const引用传递这些类型什么也得不到。

初始化列表构造函数

初始化列表构造函数(nitializer-list constructors)将std:initializer_list<T>作为第一个参数, 并且没有任何其他参数。下面的类演示了这种用法。该类只接收initializer_list<T>,元素个数应为偶数,否则将抛出异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class EvenSequence {
public:
EvenSequence (initializer_ list<double> args) {
if (args.size() % 2 != 0)
throw invalid_ argument ("initializer_ list should contain even number of elements.");
mSequence. reserve(args.size());
for (const auto& value : args)
mSequence.push_back (value);
}
void dump() const {
for (const auto& value : mSequence)
cout << value << ", ";
cout << endl;
private:
vector<double> mSequence;
};

在初始化列表构造函数的内部,可使用基于区间的for循环来访问初始化列表的元素。使用size()方法可获取初始化列表中元素的数目。

EvenSequence初始化列表构造函数使用基于区间的for循环来复制给定initializer_list 中的元素。也可以使用vector 的assign()方法。

标准库完全支持初始化列表构造函数。例如,可使用初始化列表初始化stl::vector容器。

1
std::vector<std::string> myVec = {"String 1", "String 2", "String 3"};

如果不使用初始化列表构造函数,可通过一些push_back()调用来初始化vector:

1
2
3
4
std::vector<std::string> myVec;
myVec.push_back("String 1");
myVec.push_back("String 2");
myVec.push_back("String 3");

初始化列表并不限于构造函数,还可以用于普通函数。

委托构造函数

委托构造函数(delegating constructors)允许构造函数调用同一个类的其他构造函数。然而,这个调用不能放在构造函数体内,而必须放在构造函数初始化器中,且必须是列表中唯一的成员初始化器。下面给出了一个示例:

1
2
3
SpreadsheetCell::SpreadsheetCell (string_view initialvalue)
: SpreadsheetCell (str ingToDouble (ini tialValue) )
{ }

当调用这个string_view构造函数(委托构造函数)时,首先将调用委托给目标构造函数,也就是double构造函数。当目标构造函数返回时,再执行委托构造函数。当使用委托构造函数时,要注意避免出现构造函数的递归。例如:

1
2
3
4
class MyClass {
MyClass (char c) : MyClass(1.2) { }
MyClass (double d) : MyClass('m') { }
};

第一个构造函数委托第二个构造函数,第二个构造函数又委托第一个构造函数。C++标准没有定义此类代码的行为,这取决于编译器。

总结编译器生成的构造函数

编译器为每个类自动生成没有参数的构造函数和复制构造函数。然而,编译器自动生成的构造函数取决于你自己定义的构造函数,对应的规则如表8-3所示。

销毁对象

当销毁对象时,会发生两件事:调用对象的析构函数,释放对象占用的内存。在析构函数中可以执行对象的清理,例如释放动态分配的内存或者关闭文件句柄。如果没有声明析构函数,编译器将自动生成一个,析构函数会逐一销毁成员,然后删除对象。

当堆栈中的对象超出作用域时,意味着当前的函数、方法或其他执行代码块结束,对象会被销毁。换句话说,当代码遇到结束大括号时,这个大括号中所有创建在堆栈中的对象都会被销毁。下面的程序显示了这一行为:

1
2
3
4
5
6
7
8
int main() {
SpreadsheetCell myCell (5) ;
if (myCell .getValue() == 5)
SpreadsheetCell anotherCell(6) ;
} // anotherCell is destroyed as this block ends.
cout << "myCell: " << myCell.getValue() << endl;
return 0;
} // myCell is destroyed as this block ends .

堆栈中对象的销毁顺序与声明顺序(和构建顺序)相反。如果某个对象是其他对象的数据成员,这顺序也适用。数据成员的初始化顺序是它们在类中声明的顺序。因此,按对象的销毁顺序与创建顺序相反这一规则, 数据成员对象的销毁顺序与其在类中声明的顺序相反。

没有智能指针的帮助,在堆中分配的对象不会自动销毁。必须使用delete 删除对象指针,从而调用析构函数并释放内存。下面的程序显示了这一行为:

1
2
3
4
5
6
7
8
int main()
SpreadsheetCell* cellPtrl = new SpreadsheetCell (5) ;
SpreadsheetCell* cellPtr2 = new SpreadsheetCell (6) ;
cout << "cellPtr1: "<< cellPtr1->getvalue() << endl;
delete cellPtrl; // Destroys cellPtrl
cel1Ptrl = nullptr;
return 0;
} // cellPtr2 is NOT destroyed because delete was not called on it.

对象赋值

就像可将一个int变量的值赋给另一个int变量一样, 在C++中也可将一个对象的值赋给另一个对象。例如,下面的代码将myCell的值赋给anotherCell:

1
2
SpreadsheetCell myCell(5), anotherCell;
anotherCell = myCell;

在C++中,“复制”只在初始化对象时发生。如果一个已经具有值的对象被改写,更精确的术语是“赋值”。注意C++提供的复制工具是复制构造函数。因为这是一个构造函数,所以只能用在创建对象时,而不能用于对象的赋值。

因此,C++为所有的类提供了执行赋值的方法。这个方法叫作赋值运算符(assignment operator), 名称是operator=,因为实际上是为类重载了=运算符。在上例中,调用了anotherCell的赋值运算符,参数为myCell。

如果没有编写自己的赋值运算符,C++将自动生成一个,从而允许将对象赋给另一个对象。默认的C++赋值行为几乎与默认的复制行为相同:以递归方式用源对象的每个数据成员并赋值给目标对象。

声明赋值运算符

下面是SpreadsheetCell类的赋值运算符:

1
2
3
4
5
class SpreadsheetCell {
public:
SpreadsheetCell& operator= (const SpreadsheetCell& rhs) ;
// Remainder of the class definition omitted for brevity
}

赋值运算符与复制构造函数类似,采用了源对象的const引用。在此情况下,将源对象称为rths,代表等号的“右边”(可为其指定其他任何名称),调用赋值运算符的对象在等号的左边。与复制构造函数不同的是,赋值运算符返回SpreadsheetCell对象的引用。原因是赋值可以链接在一起。

定义赋值运算符

赋值运算符的实现与复制构造函数类似,但存在一些重要的区别。首先,复制构造函数只有在初始化时才调用,此时目标对象还没有有效的值。赋值运算符可以改写对象的当前值。其次,在C++中允许将对象的值赋给自身。例如,下面的代码可以编译并运行:

1
2
SpreadsheetCell cell(4);
cell = cell;

赋值运算符不应该阻止自赋值。在SpreadsheetCell类中,这并不重要,因为它的唯一数据成员是基本类型double。但当类具有动态分配的内存或其他资源时,必须将自赋值考虑在内,为阻止此类情况下的问题发生,赋值运算符通常在方法开始时检测自赋值,如果发现自赋值,则立刻返回。下面是SpreadsheetCell类的赋值运算符的定义:

1
2
SpreadsheetCell& SpreadsheetCell::operator= (const SpreadsheetCell& rhs) {
if (this == &rhs) {

第一行检测自赋值,但有一个神秘之处。当等号的左边和右边相同时,就是自赋值。判断两个对象是否相同的方法之一是检查它们在内存中的位置是否相同,更明确地说,是检查指向它们的指针是否相等。由于返回类型是SpreadsheeCell&,因此必须返回一个正确的值。所有赋值运算符都返回*this,自赋值情况也不例外:

1
2
    return *this;
}

this指针指向执行方法的对象,因此*this就是对象本身。编译器将返回一个对象的引用,从而与声明的返回值匹配。如果不是自赋值,就必须对每个成员赋值:

1
2
mValue = rhs.mValue;
return *this;

这个方法在这里复制了值。最后返回*this

显式地默认或删除赋值运算符

可显式地默认或删除编译器生成的赋值运算符,如下所示:

1
SpreadsheetCell& operator= (const SpreadsheetCell& rhs) = default;

或者

1
SpreadsheetCell& operator= (const SpreadsheetCell& rhs) = delete;

编译器生成的复制构造函数和复制赋值运算符

在C++11中,如果类具有用户声明的复制赋值构造函数或析构函数,那么已经不赞成生成复制构造函数(可能是编译器觉得需要特殊处理,不能简单地直接复制么)。如果在此类情况下仍然需要编译器生成的复制构造函数,可以显式指定default:

1
MyClass (const MyClass& src) = default;

同样,在C++11中,如果类具有用户声明的复制赋值构造函数或析构函数,也不赞成生成复制赋值运算符。如果在此类情况下仍然需要编译器生成的复制赋值运算符,可以显式指定default:

1
MyClass& operator= (const MyClass& rhs) = default;

复制和赋值的区别

有时很难区分对象什么时候用复制构造函数初始化,什么时候用赋值运算符赋值。基本上,声明时会使用复制构造函数,赋值语句会使用赋值运算符。考虑下面的代码:

1
2
SpreadsheetCell myCe11(5);
SpreadsheetCell anotherCell (myCell);

AnotherCell由复制构造函数创建。

1
SpreadsheetCell aThirdCell = myCell;

aThirdCell也是由复制构造函数创建的,因为这条语句是一个声明。这行代码不会调用operator=。不过,考虑以下代码:

1
anotherCell = myCell; // Calls operator= for anotherCell

此处,anotherCell 已经构建,因此编译器会调用operator=。

按值返回对象

当函数或方法返回对象时,有时很难看出究竟执行了什么样的复制和赋值。例如,SpreadseetCel:etString0的实现如下所示:

1
2
3
string SpreadsheetCell::getString() const {
return doubleToString (mValue);
}

现在考虑下面的代码:

1
2
3
SpreadsheetCell myCe112(5);
string s1;
s1 = myCell2.getString();

getString()返回mString时,编译器实际上调用string复制构造函数,创建一个未命名的临时字符串对象。将结果赋给s1时,会调用s1的赋值运算符,将这个临时字符串作为参数。然后,这个临时的字符串对象被销毁。因此,这行简单的代码调用了复制构造函数和赋值运算符(针对两个不同的对象)。然而,编译器可实现(有时需要实现)返回值优化(Returm Value Optimization, RVO),在返回值时优化掉成本高昂的复制构造函数。RVO
也称为复制省略(copy elision)。

了解到上面的内容后,考虑下面的代码:

1
2
SpreadsheetCell myCell3(5) ;
string s2 = myCell3.getString() ;

在此情况下,getString()返回时创建了一个临时的未命名字符串对象。但现在s2调用的是复制构造函数,而不是赋值运算符。通过移动语义(move semantics),编译器可使用移动构造函数而不是复制构造函数,从getString()返回该字符串,这样做效率更高。

复制构造函数和对象成员

还应注意构造函数中赋值和调用复制构造函数的不同之处。如果某个对象包含其他对象,编译器生成的复制构造函数会递归调用每个被包含对象的复制构造函数。当编写自己的复制构造函数时,可使用前面所示的ctor initializer提供相同的语义。如果在ctor initializer中省略某个数据成员,在执行构造函数体内的代码之前,编译器将对该成员执行默认的初始化(为对象调用默认构造函数)。这样,在执行构造函数体时,所有数据成员都已经初始化。

例如,可这样编写复制构造函数:

1
2
SpreadsheetCell::SpreadsheetCell (const SpreadsheetCell& src)
mValue = src.mValue;

然而,在复制构造函数的函数体内对数据成员赋值时,使用的是赋值运算符而不是复制构造函数,因为它们已经初始化。

精通类与对象

友元

C++运行某个类将其他类、其他类的成员函数或非成员函数声明为友元,友元可访问类的protected、private数据成员和方法。可将Bar类或或其中的一个方法、独立函数设置为Foo类的友元;

1
2
3
4
5
class Foo {
friend void Bar::processFoo(const Foo& foo);
friend class Bar;
friend void dumpFoo(const Foo& foo);
}

对象的动态分配

使用移动语义处理移动

对象的移动语义(movesemantics)需要实现移动构造函数(move constructor)和移动赋值运算符(move assignment operator)。如果源对象是操作结束后被销毁的临时对象,编译器就会使用这两个方法。移动构造函数和移动赋值运算符将数据成员从源对象移动到新对象,然后使源对象处于有效但不确定的状态。通常会将源代码的数据成员重置为空值。这样做实际上将内存和其他资源的所有权从一个对象移动到另一个对象。这两个方法基本上只对成员变量进行表层复制(shallow copy),然后转换已分配内存和其他资源的所有权,从而阻止悬挂指针和内存泄漏。

在实现移动语义前,你需要学习右值(rvalue)和右值引用(rvalue reference)。

右值引用

在C++中,左值(value)是可获取其地址的一个量,例如一个有名称的变量。由于经常出现在赋值语句的左边,因此将其称作左值。另外,所有不是左值的量都是右值(rvalue),例如字面量、临时对象或临时值。通常右值位于赋值运算符的右边。例如,考虑下面的语句:

1
int a=4*2;

在这条语句中,a是左值,它具有名称,它的地址为&a。右侧表达式4 * 2的结果是右值。它是一个临时值,将在语句执行完毕时销毁。在本例中,将这个临时副本存储在变量a中。右值引用是一个对右值(rvalue)的引用。特别地,这是一个当右值是临时对象时才适用的概念。右值引用的目的是在涉及临时对象时提供可选用的特定函数。由于知道临时对象会被销毁,通过右值引用,某些涉及复制大量值的操作可通过简单地复制指向这些值的指针来实现。

函数可将&&作为参数说明的一部分(例如type &&name),以指定右值引用参数。通常,临时对象被当作const type&,但当函数重载使用了右值引用时,可以解析临时对象,用于该函数重载。下面的示例说明了这一点。代码首先定义了两个handleMessage()函数,一个接收左值引用,另一个接收右值引用:

1
2
3
4
5
6
7
8
// lvalue reference parameter
void handleMessage(std::string& message) {
cout << "handleMessage with lvalue reference: " << message << endl;
}
// rvalue reference parameter
void handleMessage (std::string&& message) {
cout << "handleMessage with rvalue reference: " << message << endl;
}

可使用具有名称的变量作为参数调用handleMessage()函数:

1
2
3
4
std::string a = "Hello ";
std::string b = "World";
handleMessage(a);
// Calls handleMessage (string& value)

由于a是一个命名变量,调用handleMessage()函数时,该函数接收一个左值引用。handleMessage()函数通过其引用参数所执行的任何更改来更改a的值。还可用表达式作为参数来调用handleMessage()函数:

1
2
handleMessage(a + b);
// Calls handleMessage (string&& value)

此时无法使用接收左值引用作为参数的handleMessage()函数,因为表达式a+b的结果是临时的,这不是一个左值。在此情况下,会调用右值引用版本。由于参数是一个临时值,handleMessage()函数调用结束后,会丢失通过引用参数所做的任何更改。

字面量也可作为handleMessage()调用的参数,此时同样会调用右值引用版本,因为字面量不能作为左值(但字面量可作为const引用形参的对应实参传递)。

1
handleMessage("Hello World"); // Calls handldMessage (string&& value)

如果删除接收左值引用的handleMessage()函数,使用有名称的变量调用handleMessage(),会导致编译错误,因为右值引用参数(string&& message)永远不会与左值(b)绑定。如下所示,可使用std:move()将左值转换为右值,强迫编译器调用handleMessage()函数的右值引用版本:

1
handleMessage (std::move(b)); // Calls handleMessage (string&& value)

重申一次,有名称的变量是左值。因此,在handleMessage()函数中,右值引用参数message本身是一个左值,原因是它具有名称!如果希望将这个左值引用参数,作为右值传递给另一个函数,则需要使用std:move(),将左值转换为右值。例如,假设要添加以下函数,使用右值引用参数:

1
void helper (std::string&& message)

如果按如下方式调用,则无法编译:

1
2
3
void handleMessage (std::string&& message) {
helper (message);
}

helper()函数需要右值引用,而handleMessage()函数传递messagemessage具有名称,因此是左值,导致编译错误。正确的方式是使用std:move()

1
2
3
void handleMessage (std::string&& message) {
helper (std::move(message));
}

有名称的右值引用,如右值引用参数,本身就是左值,因为它具有名称!

右值引用并不局限于函数的参数。可以声明右值引用类型的变量,并对其赋值,尽管这一用法并不常见。

考虑下面的代码,在C++中这是不合法的:

1
2
3
int& i = 2;// Invalid: reference to a constant
int a = 2, b = 3;
int& j = a + b; // Invalid: reference to a temporary

使用右值引用后,下面的代码完全合法:

1
2
3
int&& i = 2;
int a = 2, b = 3;
int&& j = a + b;

前面示例中单独使用右值引用的情况很少见。

实现移动语义

移动语义是通过右值引用实现的。为了对类增加移动语义,需要实现移动构造函数和移动赋值运算符。移动构造函数和移动赋值运算符应使用noexcept限定符标记,这告诉编译器,它们不会抛出任何异常。这对于与标准库兼容非常重要,因为如果实现了移动语义,与标准库的完全兼容只会移动存储的对象,且确保不抛出异常。

下面的Spreadsheet类定义包含一个移动构造函数和一个移动赋值运算符。也引入了两个辅助方法cleanup()moveFrom()。前者在析构函数和移动赋值运算符中调用。后者用于把成员变量从源对象移动到目标对象,接着重置源对象。

1
2
3
4
5
6
7
8
9
10
class Spreadsheet {
public:
Spreadsheet(Spreadsheet&& src) noexcept; // Move constructor
Spreadsheet& operator= (Spreadsheet&& rhs) noexcept; // Move assign
// Remaining code omitted for brevity
private:
void cleanup() noexcept;
void moveFrom (Spreadsheet& src) noexcept;
// Remaining code omitted for brevity
};

实现代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
void Spreadsheet::cleanup() noexcept {
for(size_t i=0; i < mWidth; i ++)
delete[] mCells[i];
delete[] mCells;
mCells = nullptr;
mWidth = 0;
mHeight = 0;
}

void Spreadsheet::moveFrom (Spreadsheet& src) noexcept {
// Shallow copy of data
mWidth = src.mWidth; .
mHeight = src.mHeight;
mCells = src.mCells;
// Reset the source object, because ownership has been moved !
src.mWidth = 0;
src.mHeight = 0;
src.mCells = nullptr;
}

// Move constructor
Spreadsheet::Spreadsheet (Spreadsheet&& src) noexcept {
moveFrom(src);
}
// Move assignment operator
Spreadsheet& Spreadsheet::operator= (Spreadsheet&& rhs) noexcept {
// check for self-assignment
if (this = &rhs)
return *this;
// free the old memory
cleanup() ;
moveFrom(rhs);
return *this;
}

移动构造函数和移动赋值运算符都将mCells的内存所有权从源对象移动到新对象,这两个方法将源对象的mCells指针设置为空指针,以防源对象的析构函数释放这块内存,因为新的对象现在拥有了这块内存。很明显,只有你知道将销毁源对象时,移动语义才有用。例如,就像普通的构造函数或复制赋值运算符一样,可显式将移动构造函数和/或移动赋值运算符设置为默认或将其删除。

仅当类没有用户声明的复制构造函数、复制赋值运算符、移动赋值运算符或析构函数时,编译器才会为类自动生成默认的移动构造函数。仅当类没有用户声明的复制构造函数、移动构造函数、复制赋值运算符或析构函数时,才会为类生成默认的移动赋值运算符。

移动对象数据成员

moveFrom()方法对三个数据成员直接赋值,因为这些成员都是基本类型。如果对象还将其他对象作为数据成员,则应当使用std:move()移动这些对象。假设Spreadsheet类有一个名为mName的std::string数据成员。接着采用以下方式实现moveFrom()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
void Spreadsheet::moveFrom (Spreadsheet& src) noexcept {
// Move object data members
mName = std::move(src.mName) ;
// Move primitives :
// Shallow copy of data
mwidth = src.mWidth;
mHeight = src.mHeight;
mCells = src.mCells;
// Reset the source object, because ownership has been moved!
src.mWidth = 0;
src.mHeight = 0
src.mCells = nullptr;
}

前面的移动构造函数和移动赋值运算符的实现都使用了moveFrom()辅助方法,该辅助方法通过执行浅表复制来移动所有数据成员。

零规则

前面的讨论解释如何编写以下5个特殊的成员函数:析构函数、复制构造函数、移动构造函数、复制赋值运算符和移动赋值运算符。但在现代C++中,你需要接受零规则(rule of zero)。

“零规则”指出,在设计类时,应当使其不需要上述5个特殊成员函数。如何做到这一点?基本上,应当避免拥有任何旧式的、动态分配的内存。而改用现代结构,如标准库容器。例如,在Spreadsheet 类中,用vector<vector<SpreadsheetCell>>替代SpreadsheetCell**数据成员。该vector自动处理内存,因此不需要上述5个特殊成员函数。

与方法有关的更多内容

静态方法

与数据成员类似,方法有时会应用于全部类对象而不是单个对象,此时可以像静态数据成员那样编写静态方法。以第8章的SpreadsheetCell类为例,这个类有两个辅助方法:stringToDouble()doubleToString()。这两个方法没有访问特定对象的信息,因此可以是静态的。下面的类定义将这些方法设置为静态的:

1
2
3
4
5
6
class SpreadsheetCell {
// Omitted for brevity
private:
static std::string doubleToString (double inValue) ;
static double stringToDouble(std::string_view inString);
};

这两个方法的实现与前面的实现相同,在方法定义前不需要重复static关键字。然而,注意静态方法不属于特定对象,因此没有this指针,当用某个特定对象调用静态方法时,静态方法不会访问这个对象的非静态数据成员。实际上,静态方法就像普通函数,唯一区别在于静态方法可以访问类的private和protected静态数据成员。如果同一类型的其他对象对于静态方法可见,那么静态方法也可访问其他对象的private和protected非静态数据成员。

类中的任何方法都可像调用普通函数那样调用静态方法,因此SpreadsheetCell类中所有方法的实现都没有改变。如果要在类的外面调用静态方法,需要用类名和作用域解析运算符来限定方法的名称(就像静态数据成员那样),静态方法的访问控制与普通方法一样。

stringToDouble()doubleTostring()设置为public,这样类外面的代码也可以使用它们。此时,可在任意位置这样调用这两个方法:

1
string str = SpreadsheetCell::doubleToString(5.0);

const 方法

const(常量)对象的值不能改变。如果使用常量对象、常量对象的引用和指向常量对象的指针,编译器将不允许调用对象的任何方法,除非这些方法承诺不改变任何数据成员。为了保证方法不改变数据成员,可以用const关键字标记方法本身。下面的SpreadsheetCell类包含了用const标记的不改变任何数据成员的方法。

1
2
3
4
5
6
7
class SpreadsheetCell {
public:
// Omitted for brevity
double getValue() const ;
std::string getstring() const;
// Omitted for brevity
};

const规范是方法原型的一部分,必须放在方法的定义中:

1
2
3
4
5
6
double SpreadsheetCell::getValue() const {
return mValue;
}
std::string SpreadsheetCell::getString() const {
return doubleToString (mValue);
}

将方法标记为const,就是与客户代码立下了契约,承诺不会在方法内改变对象内部的值。如果将实际上修改了数据成员的方法声明为const,编译器将会报错。不能将静态方法声明为const,因为这是多余的。静态方法没有类的实例,因此不可能改变内部的值。

const 的工作原理是将方法内用到的数据成员都标记为const引用,因此如果试图修改数据成员,编译器会报错。非const对象可调用const方法和非const方法。然而,const 对象只能调用const方法,下面是一些示例:

1
2
3
4
5
6
7
8
9
SpreadsheetCell myCell (5) ;
cout. << myCell.getValue() << endl;
// OK
myCell.setString("6");
// OK
const SpreadsheetCell& myCellConstRef = myCell;
cout << myCellConstRef.getValue() << endl; // OK
myCellConstRef.setString("6");
// Compilation Error!

应该养成习惯,将不修改对象的所有方法声明为const,这样就可在程序中引用const对象。注意const对象也会被销毁,它们的析构函数也会被调用,因此不应该将析构函数标记为const。

mutable数据成员

有时编写的方法“逻辑上”是const方法,但是碰巧改变了对象的数据成员。这个改动对于用户可见的数据没有任何影响,但在技术上确实做了改动,因此编译器不允许将这个方法声明为const。解决方法是将变量设置为mutable,告诉编译器在const方法中允许改变这个值。

方法重载

注意,在类中可编写多个构造函数,所有这些构造函数的名称都相同。这些构造函数只是参数数量或类型不同。在C++中,可对任何方法或函数做同样的事情。具体来讲,可重载函数或方法,具体做法是将函数或方法的名称用于多个函数,但是参数的类型或数目不同。例如在SpreadsheetCell类中,可将setString()setValue()全部重命名为set()。类定义如下所示:

1
2
3
4
5
6
7
class SpreadsheetCell {
public:
// Omitted for brevity
void set(double inValue) ;
void set(std::string_view inString) ;
// Omitted for brevity
};

set()方法的实现保持不变。当编写调用set()方法的代码时,编译器根据传递的参数判断调用哪个实例,这称为重载解析。

基于const的重载

还要注意,可根据const来重载方法。也就是说,可以编写两个名称相同、参数也相同的方法,其中一个是const,另一个不是。如果是const对象,就调用const方法;如果是非const对象,就调用非const方法。

通常情况下,const版本和非const版本的实现是一样的。为避免代码重复,可使用const_cast()模式。你可像往常一样实现const版本,此后通过适当转换,传递对const版本的调用,以实现非const版本。基本上,你使用std:as_const()(在<utility>中定义)将*this转换为const Spreadsheet&,调用getCellAt()的const版本,然后使用const_cast(),从结果中删除const:

1
2
3
4
5
6
7
8
const SpreadsheetCell& Spreadsheet::getCellAt(size_t x,size_t y) const {
verifyCoordinate(x,y);
return mCel1s[x][y];
}

SpreadsheetCell& Spreadsheet::getCe1lAt(size_t x, size_t y) {
return const_cast<SpreadsheetCell&> (std::as_const(*this).getCellAt(x, y));
}

自C++17起,st::as_const()函数可供使用。如果你的编译器还不支持该函数,可改用以下static_cast()

1
return const_cast<SpreadsheetCell&> ( static_cast<const Spreadsheet&>(*this).getCellAt(x, y));

有了这两个重载的getCellAt(),现在可在const和非const的Spreadsheet对象上调用getCellAt()

1
2
3
4
5
Spreadsheet sheet1 (56).
SpreadsheetCell& cel11 = sheet1.getCel1At(1, 1);

const Spreadsheet sheet2(56);
const SpreadsheetCell& cell2 = sheet2.getCellAt(1, 1);

这里,getCellAt()的const版本做的事情不多,因此使用const_cast()模式的优势不明显。

显式删除重载

重载方法可被显式删除,可以用这种方法禁止调用具有特定参数的成员函数。例如,考虑下面的类:

1
2
3
4
class MyClass {
public:
void foo(int i);
};

可以用下面的方式调用foo()方法:

1
2
3
MyClass c;
c.foo(123);
c.foo(1.23);

在第三行,编译器将double值(1.23)转换为整型值(1),然后调用foo(int i)。 编译器可能会给出警告,但是仍然会执行这一隐式转换。显式删除foo()的double实例,可以禁止编译器执行这一转换:

1
2
3
4
5
class MyClass {
public:
void foo(int i);
void foo(double d) = delete;
};

通过这一改动, 以double为参数调用foo()时,编译器会给出错误提示,而不是将其转换为整数。

内联方法

编译器可以将方法体或函数体直接插入到调用方法或函数的位置。这个过程称为内联(inline)。内联比使用#define安全。inline 关键字只是提示编译器。如果编译器认为这会降低性能,就会忽略该关键字。

如果编写了内联函数或内联方法,应该将定义与原型一起放在头文件中。

高级C++编译器不要求把内联方法的定义放在头文件中。例如,Microsoft Visual C++支持连接时代码生成(LTCG),会自动将较小的函数内联,哪怕这些函数没有声明为内联的或者没有在头文件中定义,也同样如此。

C++提供了另一种声明内联方法的语法,这种语法根本不使用inline关键字,而是直接将方法定义放在类定义中。下面是使用了这种语法的SpreadsheetCell类定义:

1
2
3
4
5
6
7
8
9
class SpreadsheetCell {
public:
double getValue() const { mNumAccesses++; return mValue; }
std::string getString() const {
mNumAccesses++;
return doubleToString (mValue) ;
// Omitted for brevity
}
};

编译器只会内联最简单的方法和函数,如果将编译器不想内联的方法定义为内联方法,编译器会自动忽略这个指令。现代编译器在内联方法或函数之前,会考虑代码膨胀等指标,因此不会内联任何没有效益的方法。

默认参数

C++中,默认参数(default arguments)与方法重载类似。在原型中可为函数或方法的参数指定默认值。如果用户指定了这些参数,默认值会被忽略;如果用户忽略了这些参数,将会使用默认值。但是存在一个限制:能从最右边的参数开始提供连续的默认参数列表,否则编译器将无法用默认参数匹配缺失的参数。默认参数可用于函数、方法和构造函数。例如,可在Spreadsheet构造函数中设置宽度和高度的默认值:

1
2
3
4
5
class Spreadsheet {
public:
Spreadsheet(size_t width = 100size_t height = 100);
// Omitted for brevity
};

现在可以用0个、1个或2个参数调用Spreadsheet构造函数,尽管只有一个非复制构造函数:

1
2
3
Spreadsheet s1;
Spreadsheet s2(5);
Spreadsheet s3(5, 6);

所有参数都有默认值的构造函数等同于默认构造函数。也就是说,可构建类的对象而不指定任何参数。如果试图同时声明默认构造函数,以及具有多个参数并且所有参数都有默认值的构造函数,编译器会报错。因为如果不指定任何参数,编译器不知道该调用哪个构造函数。

不同的数据成员类型

C++为数据成员提供了多种选择。除了在类中简单地声明数据成员外,还可创建静态数据成员(类的所有对象共享)、静态常量数据成员、引用数据成员、常量引用数据成员和其他成员。

静态数据成员

静态数据成员属于类但不是对象的数据成员,可将静态数据成员当作类的全局变量。下面是Spreadsheet类的定义,其中包含了新的静态数据成员sCounter:

1
2
3
4
5
class Spreadsheet {
// Omitted for brevity
private:
static size_t sCounter;
};

不仅要在类定义中列出static类成员,还需要在源文件中为其分配内存,通常是定义类方法的那个源文件。在此还可初始化静态成员,但注意与普通的变量和数据成员不同,默认情况下它们会初始化为0。static 指针会初始化为nullptr。

内联变量

从C++17开始,可将静态数据成员声明为inline。这样做的好处是不必在源文件中为它们分配空间。下面是一个实例:

1
2
3
4
5
class Spreadsheet {
// Omitted for brevity
private:
static inline size_t sCounter = 0;
};

注意其中的inline关键字。有了这个类定义,可从源文件中删除下面的代码行:

1
size_t Spreadsheet::sCounter; 

在类方法内访问静态数据成员

在类方法内部,可以像使用普通数据成员那样使用静态数据成员。例如,为Spreadsheet类创建一个mId成员,并在Spreadsheet构造函数中用sCounter成员初始化它。下面是包含了mId成员的Spreadsheet 类定义:

1
2
3
4
5
6
7
class Spreadsheet {
public:
size_t getId() const;
private:
static size_t sCounter;
size_t mId = 0;
};

下面是Spreadsheet构造函数的实现,在此赋予初始ID:

1
2
3
4
5
6
7
Spreadsheet::Spreadsheet (size_t width, size_t height) 
: mId (sCounter++), mwidth (width), mHeight (height) {
mCells = new SpreadsheetCell* [mWidth] ;
for (size_t i = 0; i < mwidth; i++) {
mCells[i] = new SpreadsheetCell [mHeight] ;
}
}

可以看出,构造函数可访问sCounter,就像这是一个普通成员。在复制构造函数中,也要指定新的ID。由于Spreadsheet复制构造函数委托给非复制构造函数(会自动创建新的ID),因此这可以自动进行处理。在赋值运算符中不应该复制ID。一旦给某个对象指定ID,就不应该再改变。建议把mId设置为const数据成员。

在方法外访问静态数据成员

访问控制限定符适用于静态数据成员:sCounter 是私有的,因此不能在类方法之外访问。如果sCounter是公有的,就可在类方法外访问,具体方法是用::作用域解析运算符指出这个变量是Spreadsheet类的一部分:

1
int c = Spreadsheet::sCounter;

静态常量数据成员

类中的数据成员可声明为const,意味着在创建并初始化后,数据成员的值不能再改变。如果某个常量只适用于类,应该使用静态常量(static const或const static)数据成员,而不是全局常量。可在类定义中定义和初始化整型和枚举类型的静态常量数据成员,而不需要将其指定为内联变量。Spreadsheet类的static const成员:

1
2
3
4
5
class Spreadsheet {
public:
static const size_t kMaxHeight = 100;
static const size_t kMaxWidth = 100;
};

非静态数据成员也可声明为const。例如,mId数据成员就可声明为const。因为不能给const数据成员赋值,所以需要在类内初始化器或ctor initializer中初始化它们。这意味着根据使用情形,可能无法为具有非静态常量数据成员的类提供赋值运算符。如果属于这种情况,通常将赋值运算符标记为deleted。

kMaxHeight和kMaxWidth是公有的,因此可在程序的任何位置访问它们,就像它们是全局变量一样:

1
cout << "Maximum height is: " << Spreadsheet::kMaxHeight << endl;

引用数据成员

Spreadsheets和SpreadsheetCells可一起放入SpreadsheetApplication类。Spreadsheet类必须知道SpreadsheetApplication 类,SpreadsheetApplication类也必须知道Spreadsheet类。这是一个循环引用问题,无法用普通的#include解决。解决方案是在其中一个头文件中使用前置声明。下面是新的使用了前置声明的Spreadsheet类定义,用来通知编译器关于SpreadsheetApplication类的信息。

1
2
3
4
5
6
7
class SpreadsheetApplication; // forward declaration
class Spreadsheet {
public:
Spreadsheet(size_t width, size_t height, SpreadsheetApplication& theApp);
private:
SpreadsheetApplicatoin& mTheApp;
};

这个定义将一个SpreadsheetApplication引用作为数据成员添加进来。在此情况下建议使用引用而不是指针,因为Spreadsheet总要引用一个 SpreadsheetApplication,而指针则无法保证这一点。

常量引用数据成员

就像普通引用可引用常量对象一样,引用成员也可引用常量对象。例如,为让Spreadsheet只包含应用程序对象的常量引用,只需要在类定义中将mTheApp声明为常量引用:

1
2
3
4
5
6
class Spreadsheet {
public:
Spreadsheet(size_t width, size_t height, const SpreadsheetApplication& theApp);
private:
const SpreadsheetApplication& mTheApp;
};

常量引用和非常量引用之间存在一个重要差别。常量引用SpreadsheetApplication 数据成员只能用于调用SpreadsheetApplication 对象上的常量方法。如果试图通过常量引用调用非常量方法,编译器会报错。还可创建静态引用成员或静态常量引用成员,但一般不需要这么做。

嵌套类

类定义不仅可包含成员函数和数据成员,还可编写嵌套类和嵌套结构、声明typedef或者创建枚举类型。类中声明的一切内容都具有类作用域。如果声明的内容是公有的,那么可在类外使用ClassName::作用域解析语法访问。

可在类的定义中提供另一个类定义。例如,假定SpreadsheetCell类实际上是Spreadsheet类的一部分,因此不妨将SpreadsheetCell重命名为Cell。可将二者定义为:

1
2
3
4
5
6
7
8
9
10
class Spreadsheet {
public:
class Cell{
public:
Cell() = default;
Cell(double initialValue);
};

Spreadsheet(size_t width, size_t height, const SpreadsheetApplication& theApp);
};

现在Cell类定义位于Spreadsheet类内部,因此在Spreasheet类外引用Cell必须用Spreadsheet::作用域限定名称,即使在方法定义时也是如此。例如,Cell的double构造函数应如下所示:

1
Spreadsheet::Cell::Cell (double initialValue) : mValue (initialValue) { }

甚至在Spreadsheet类中方法的返回类型(不是参数)也必须使用这一语法:

1
2
3
4
Spreadsheet::Cell& Spreadsheet::getCellAt(size_t x, size_t y) {
verifyCoordinate(x, y)
return mCells[x][y];
}

如果在Spreadsheet类中直接完整定义嵌套的Cell类,将使Spreadsheet类的定义略显臃肿。为缓解这一点,只需要在Spreadsheet中为Cell添加前置声明,然后独立地定义Cell类,如下所示:

1
2
3
4
5
6
7
8
9
10
class Spreadsheet
public:
class Cell ;
};

class Spreadsheet::Cell {
public:
Cell() = default;
Cell (double initialValue);
};

普通的访问控制也适用于嵌套类定义。如果声明了一个private或protected嵌套类,这个类只能在外围类(outer class,即包含它的类)中使用。嵌套的类有权访问外围类中的所有private或protected成员;而外围类却只能访问嵌套类中的public成员。

类内的枚举类型

如果想在类内定义许多常量,应该使用枚举类型而不是#define。例如,可在SpreadsheetCell类中支持单元格颜色,如下所示:

1
2
3
4
5
6
7
8
class SpreadsheetCell {
public:
enum class Color { Red = 1; Green, Blue, Yel1ow };
void setColor (Color color);
Color getColor() const;
private:
Color mColor = Color::Red;
};

setColor()getColor()方法的实现简单明了:

1
2
void SpreadsheetCell::setColor (Color color) { mColor = color; }
SpreadsheetCell::Color SpreadsheetCell::getColor() const { return mColor; }

运算符重载

C++允许编写自己的加号版本,为此可编写一个名为operator+的方法:

1
2
3
4
5
6
class SpreadsheetCell {
public:
SpreadsheetCell operator+(const SpreadsheetCell& cell) const {
return SpreadsheetCell(getValue() + cell.getValue());
}
}

当C++编译器分析一个程序,遇到运算符(例如,+、-、=或<<)时,就会试着查找名为operator+operator-operator=operator<<,且具有适当参数的函数或方法。例如,当编译器看到下面这行时,就会试着查找SpreadsheetCell类中名为operator+并将另一个SpreadsheetCell对象作为参数的方法,或者查找用两个SpreadsheetCell对象作为参数、名为operator+的全局函数:

1
SpreadsheetCell aThirdCell = myCell + anotherCell;

如果SpreadsheetCell类包含operator+方法,上述代码就 会转换为:

1
SpreadsheetCell aThirdCell = myCell.operator+ (anotherCell);

注意,用作operator+参数的对象类型并不一定要与编写operator+的类相同。

此外还要注意,可任意指定operator+的返回值类型。运算符重载是函数重载的一种形式,函数重载对函数的返回类型并没有要求。

隐式转换

令人惊讶的是,一旦编写前面所示的operator+,不仅可将两个单元格相加,还可将单元格与string_view、double或int 值相加。

1
2
3
4
SpreadsheetCell myCell(4), aThirdCell;
string str = "hello";
aThirdCell = myCell + string_view(str);
aThirdCell = myCell + 5.6;

上面的代码之所以可运行,是因为编译器会试着查找合适的operator+,而不是只查找指定类型的那个operator+。为找到operator+,编译器还试图查找合适的类型转换,构造函数会对有问题的类型进行适当的转换。

隐式转换通常会带来便利。但在上例中,将SpreadsheetCell与string_view相加并没有意义。可使用explicit关键字标记构造函数,禁止将string_view隐式地转换为SpreadsheetCell:

1
2
3
4
5
6
class SpreadsheetCell { 
public:
SpreadsheetCell() = default;
SpreadsheetCell (double initialValue);
explicit SpreadsheetCe1l (std::string_view initialvalue);
};

explicit关键字只在类定义内使用,只适用于只有一个参数的构造函数,例如单参构造函数或为参数提供默认值的多参构造函数。由于必须创建临时对象,隐式使用构造函数的效率不高。为避免与double值相加时隐式地使用构造函数,可编写第二个operator+,如下所示:

1
2
3
SpreadsheetCell SpreadsheetCell::operator+(double rhs) const {
return SpreadsheetCell(getValue() + rhs);
}

第三次尝试:全局operator+

隐式转换允许使用operator+方法将SpreadsheetCell对象与int和double值相加。然而,这个运算符不具有互换性,如下所示:

1
2
aThirdcell = myCell + 4; // Works fine.
aThirdCell = 4 + myCell; // FAILS TO COMPILE!

当SpreadsheetCell对象在运算符的左边时,隐式转换正常运行,但在右边时无法运行。加法是可互换的,因此这里存在错误。问题在于必须在SpreadsheetCell对象上调用operator+ 方法,对象必须在operator+的左边。这是C++语言定义的方式,因此使用operator+方法无法让上面的代码运行。

然而,如果用不局限于某个特定对象的全局operator+函数替换类内的operator+方法,上面的代码就可以运行,需要在头文件中声明运算符:

1
2
3
4
class SpreadsheetCell {
};

SpreadsheetCell operator+ (const SpreadsheetCell& lhs, const SpreadsheetCell& rhs);

那么,如果编写以下代码,会发生什么情况呢?

1
aThirdCell = 4.5 + 5.5;

这段代码可编译并运行,但并没有调用前面编写的operator+。这段代码将普通的double型数值4.5和5.5相加,得到了下面所示的中间语句:

1
aThirdCell = 10;

为了让赋值操作继续,运算符右边应该是SpreadsheetCell对象。编译器找到并非显式由用户定义的用double值作为参数的构造函数,然后用这个构造函数隐式地将double值转换为一个临时SpreadsheeCell对象,最后调用赋值运算符。

在C++中,不能更改运算符的优先级。例如,*和/始终在+和一之前计算。对于用户定义的运算符,唯一能做的只是在确定运算的优先级后指定实现。C++也不允许发明新的运算符号,不允许更改运算符的实参个数。

重载算术运算符

必须显式地重载简写算术运算符(Arithmetic Shorthand Operators)。这些运算符与基本算术运算符不同,它们会改变运算符左边的对象,而不是创建一个新对象。此外还有一个微妙差别,它们生成的结果是对被修改对象的引用,这一点与赋值运算符类似。简写算术运算符的左边总要有一个对象,因此应该将其作为方法而不是全局函数。下面是SpreadsheetCell类的声明:

1
2
3
4
5
6
7
class SpreadsheetCell {
public:
SpreadsheetCell& operator+= (const SpreadsheetCell& rhs);
SpreadsheetCell& operator-= (const SpreadsheetCe1l& rhs);
SpreadsheetCell& operator*= (const SpreadsheetCell& rhs);
SpreadsheetCell& operator/= (const SpreadsheetCell& rhs);
};

下面是operator+=的实现,其他的与此类似。

1
2
3
4
SpreadsheetCell& SpreadsheetCell::operator+= (const SpreadsheetCell& rhs) {
set(getValue() + rhs.getValue());
return *this;
}

简写算术运算符是对基本算术运算符和赋值运算符的结合。根据上面的定义,可编写如下代码:

1
2
3
SpreadsheetCell myCell(4), aThirdCell(2);
aThirdCell -= myCell;
aThirdCell += 5.4;

然而不能编写这样的代码

1
5.4 += aThirdCell;

如果既有某个运算符的普通版本,又有简写版本,建议你基于简写版本实现普通版本,以避免代码重复。

1
2
3
4
5
SpreadsheetCell operator+ (const SpreadsheetCell& lhs, const SpreadsheetCell& rhs) {
auto result(lhs); // Local copy
result += rhs;
return result;
}

重载比较运算符

与基本的算术运算符类似,它们也应该是全局函数,这样就可在运算符的左边和右边使用隐式转换。所有比较运算符的返回值都是布尔值。当然,可改变返回类型,但并不建议这么做。下面是比较运算符的声明;

1
bool operator <op> (const SpreadsheetCell& lhs,const SpreadsheetCell& rhs) ;

下面是operator==的定义,其他的与此类似:

1
2
3
bool operator== (const SpreadsheetCell& lhs, const SpreadsheetCell& rhs) {
return (lhs.getValue() == rhs.getValue());
}

当类中的数据成员较多时,比较每个数据成员可能比较痛苦。然而,当实现了==和<之后,可以根据这两个运算符编写其他比较运算符。例如,下面的operator>=定义使用了operator<

1
2
3
bool operator>= (const SpreadsheetCell& lhs, const SpreadsheetCell& rhs) {
return !(lhs < rhs);
}

可使用这些运算符将某个SpreadsheetCell与其他SpreadsheetCell进行比较,也可与double和int值进行比较:

1
2
3
if (myCell > aThirdCell || myCell < 10) {
cout << myCell.getValue() << endl;
}

揭秘继承技术

使用继承构建类

扩展类

当使用C++编写类定义时,可以告诉编译器,该类继承(或扩展)了一个已有的类。通过这种方式,该类将自动包含原始类的数据成员和方法;原始类称为父类(parent class)、基类或超类(superclass)。扩展已有类可以使该类(现在称为派生类或子类)只描述与父类不同的那部分内容。

在C++中,为扩展一个类,可在定义类时指定要扩展的类。为说明继承的语法,此处使用了名为Base和Derived的类。首先考虑Base类的定义:

1
2
3
4
5
6
7
8
class Base {
public:
void someMethod();
protected:
int mProtectedInt;
private:
int mPrivateInt;
};

如果要构建一个从Base类继承的新类Derived,应该使用下面的语法告诉编译器:Derived类派生自Base类:

1
2
3
4
class Derived : public Base {
public:
void someOtherMethod();
};

Derived本身就是一个完整的类,这个类只是刚好共享了Base类的特性而已。Derived不一定是Base唯一的派生类。其他类也可是Base的派生类,这些类是Derived的同级类(sibling)。

客户对继承的看法

对于客户或代码的其他部分而言,Derived类型的对象仍然是Base对象,因为Derived类从Base类继承。这意味着Base类的所有public方法和数据成员,以及Derived类的所有public方法和数据成员都是可供使用的。

在调用某个方法时,使用派生类的代码不需要知道是继承链中的哪个类定义了这个方法。例如,下面的代码调用了Derived对象的两个方法,而其中一个方法是在Base类中定义的:

1
2
3
Derived myDerived;
myDerived.someMethod() ;
myDerived.someOtherMethod() ;

指向某个对象的指针或引用可以指向声明类的对象,也可以指向其任意派生类的对象。此时需要理解的概念是,指向Base对象的指针可以指向Derived对象,对于引用也是如此。客户仍然只能访问Base类的方法和数据成员,但是通过这种机制,任何操作Base对象的代码都可以操作Derived对象。

例如,下面的代码可以正常编译并运行,尽管看上去好像类型并不匹配:

1
Base* base = new Derived() ; // Create Derived, store it in Base pointer .

然而,不能通过Base指针调用Derived类的方法。下面的代码无法运行:

1
base->someOtherMethod();

从派生类的角度分析继承

派生类可访问基类中声明的public、 protected 方法和数据成员,就好像这些方法和数据成员是派生类自己的,因为从技术上讲,它们属于派生类。例如,Derived 类中someOtherMethod()的实现可以使用在Base类中声明的数据成员mProtectedInt。下面的代码显示了这一实现,访问基类的数据成员和方法与访问派生类中的数据成员和方法并无不同之处。

1
2
3
4
void Derived::someOtherMethod() {
cout << "I can access base class data member mProtectedInt." << endl;
cout << "Its value is "<< mProtectedInt << endl;
}

如果类将数据成员和方法声明为protected,派生类就可以访问它们;如果声明为private,派生类就不能访问。

private访问说明符可控制派生类与基类的交互方式。建议将所有数据成员都默认声明为private,如果希望任何代码都可以访问这些数据成员,就可以提供public的获取器和设置器。如果仅希望派生类访问它们,就可以提供受保护的获取器和设置器。把数据成员默认设置为private的原因是,这会提供最高级别的封装,这意味着可改变数据的表示方式,而public或protected接口保持不变。不直接访问数据成员,也可在public或protected设置其中方便地添加对数据的检查。方法也应默认设置为private,只有需要公开的方法才设置为public,只有派生类需要访问的方法才设置为protected。

禁用继承

C++允许将类标记为final,这意味着继承这个类会导致编译错误。将类标记为final的方法是直接在类名的后面使用final关键字。例如,下面的Base类被标记为final:

1
class Base final { };

下面的Derived类试图从Base类继承,但是这会导致编译错误,因为Base类被标记为final。

1
class Derived : public Base { };

重写方法

从某个类继承的主要原因是为了添加或替换功能。Derived类定义在父类的基础上添加了功能。在许多情况下,可能需要替换或重写某个方法来修改类的行为。

将所有方法都设置为virtual,以防万一

在C++中,重写(verride)方法有一点别扭,因为必须使用关键字vitual。只有在基类中声明为virtual的方法才能被派生类正确地重写。virtual关键字出现在方法声明的开头,下面显示了Base类的修改版本:

1
2
3
4
5
6
7
8
class Base {
public:
virtual void someMethod() ;
protected:
int mProtectedInt;
private:
int mPrivateInt;
};

virtual关键字有些微妙之处,常被当作语言的设计不当部分。经验表明,最好将所有方法都设置为virtual。即使Derived类不大可能扩展,也最好还是将这个类的方法设置为virtual。

1
2
3
4
class Derived : public Base { 
public:
virtual void someOtherMethod() ;
};

为避免因为遗漏virtual关键字引发的问题,可将所有方法设置为virtual(包括析构函数,但不包括构造函数)。注意,由编译器生成的析构函数不是virtual!

重写方法的语法

为了重写某个方法,需要在派生类的定义中重新声明这个方法,就像在基类中声明的那样,并在派生类的实现文件中提供新的定义。例如,Base类包含了一个someMethod()方法,在Base.cpp中提供的someMethod()方法定义如下:

1
2
3
void Base::someMethod() {
cout << "This is Base's version of someMethod() ."<< endl;
}

注意在方法定义中不需要重复使用virtual关键字。

如果希望在Derived类中提供someMethod()的新定义,首先应该在Derived类定义中添加这个方法,如下所示:

1
2
3
4
5
class Derived : public Base {
public:
virtual void someMethod() override;
virtual void someOtherMethod();
};

建议在重写方法的声明末尾添加override关键字。

一旦将方法或析构函数标记为virtual,它们在所有派生类中就一直是 virtual,即使在派生类中删除了virtual关键字,也同样如此。例如,Derived类中,someMethod()仍然是virtual,可以被Derived的派生类重写,因为在Base类中将其标记为virtual。

客户对重写方法的看法

现在someMethod()的行为将根据对象所属类的不同而变化。例如,下面的代码与先前一样可以运行,调用Base版本的someMethod()

1
2
Base myBase;
myBase.someMethod();

如果声明一个Derived类对象,将自动调用派生类版本的someMethod()

1
2
Derived myDerived;
myDerived.someMethod();

Derived类对象的其他方面维持不变。从Base 类继承的其他方法仍然保持Base类提供的定义,除非在Derived类中显式地重写这些方法

如前所述,指针或引用可指向某个类或其派生类的对象。对象本身“知道”自己所属的类,因此只要这个方法声明为vitual,就会自动调用对应的方法。例如,如果一个对Base对象的引用实际引用的是Derived对象,调用someMethod()实际上会调用派生类版本,如下所示。如果在基类中省略了virtual 关键字,重写功能将无法正确运行。

1
2
3
Derived myDerived;
Base& ref = myDerived;
ref.someMethod(); // Calls Derived's version

记住,即使基类的引用或指针知道这实际上是一个派生类,也无法访问没有在基类中定义的派生类方法或成员。下面的代码无法编译,因为Base引用没有someOtherMethod()方法:

1
2
3
4
Derived myDerived;
Base& ref = myDerived;
myDerived.someOtherMethod(); // This is fine.
ref.someOtherMethod(); // Error

非指针或非引用对象无法正确处理派生类的特征信息。可将Derived对象转换为Base对象,或将Derived对象赋值给Base对象,因为Derived对象也是Base对象。然而,此时这个对象将遗失派生类的所有信息:

1
2
3
Derived myDerived;
Base assignedobject = myDerived; //Assigns a Derived to a Base .
assignedObject.someMethod(); // Calls Base's version of someMethod()

为记住这个看上去有点奇怪的行为,可考虑对象在内存中的状态。将Base对象当作占据内存的盒子。Derived对象是稍微大一点的盒子,因为它拥有Base对象的一切,还添加了一点内容。对于指向Derived对象的引用或指针,这个盒子并没有变,只是可以用新的方法访问它。然而,如果将Derived对象转换为Base对象,就会为了适应较小的盒子而扔掉Derived类全部的“独有特征”。

基类的指针或引用指向派生类对象时,派生类保留其重写方法。但是通过类型转换将派生类对象转换为基类对象时,就会丢失其独有特征。重写方法和派生类数据的丢失称为截断(slicing)。

override关键字

如果修改了Base类但忘记更新所有派生类,就会发生重载失败的问题。实际上就是创建了一个新的虚方法,而不是正确的重写这个方法。可用override关键字避免这种情况,如下所示:

1
2
3
4
class Derived : public Base {
public:
virtual void someMethod(int i) override ;
};

Derived类的定义将导致编译错误,因为override关键字表明,重写Base类的someMethod()方法,但Base类中的someMethod()方法只接收双精度数,而不接收整数。重命名基类中的某个方法,但忘记重命名派生类中的重写方法时,就会出现上述“不小心创建了新方法,而不是正确重写方法”的问题。

要想重写基类方法,始终在方法上使用override关键字。

virtual的真相

如果方法不是virtual,也可以试着重写这个方法,但是这样做会导致微妙的错误。

隐藏而不是重写

下面的代码显示了一个基类和一个派生类,每个类都有一个方法。派生类试图重写基类的方法,但是在基类中没有将这个方法声明为virtual。

1
2
3
4
5
6
7
8
class Base {
public:
void go() { cout << "go() called on Base" << endl; }
};
class Derived : public Base {
public:
void go() { cout << "go() called on Derived" << endl; }
};

试着用Derived对象调用go()方法好像没有问题。

1
2
Derived myDerived;
myDerived.go();

正如预期的那样,这个调用的结果是“go() called on Derived”。然而,由于这个方法不是virtual,因此实际上没有被重写。相反,Derived类创建了一个新的方法,名称也是go(),这个方法与Base类的go()方法完全没有关系。为证实这一点,只需要用Base指针或引用调用这个方法:

1
2
3
Derived myDerived;
Base& ref = myDerived;
ref.go();

你可能希望输出是“go() called on Derived”,但实际上,输出是“go() called on Base”。这是因为ref变量是一个Base引用,并省略了virtual关键字。当调用go()方法时,只是执行了Base类的go()方法。由于不是虛方法,不需要考虑派生类是否重写了这个方法。

如何实现virtual

为理解如何避免隐藏方法,需要了解virtual关键字的真正作用。C++在编译类时,会创建一个包含类中所有方法的二进制对象。在非虚情况下,将控制交给正确方法的代码是硬编码,此时会根据编译时的类型调用方法。这称为静态绑定(static binding),也称为早绑定(early binding)。

如果方法声明为vitual, 会使用名为虚表(vtable)的特定内存区域调用正确的实现。每个具有一个或多个虚方法的类都有一张虚表,这种类的每个对象都包含指向虚表的指针,这个虚表包含指向虛方法实现的指针。通过这种方法,当使用某个对象调用方法时,指针也进入虚表,然后根据实际的对象类型执行正确版本的方法。

这称为动态绑定(dynamic binding)或晚绑定(late binding)。

为更好地理解虚表是如何实现方法的重写的,考虑下面的Base和Derived类:

1
2
3
4
5
6
7
8
9
10
11
class Base {
public:
virtual void func1() { }
virtual void func2 () { }
void nonVirtualFunc() { }
};
class Derived : public Base {
public:
virtual void func2() override { }
void nonVirtualFunc { }
};

对于这个示例,考虑下面的两个实例:

1
2
Base myBase;
Derived myDerived;

图10-4 显示了这两个实例虚表的高级视图。myBase对象包含了指向虚表的一个指针,虚表有两项,一项是func1(),另一项是func2()。这两项指向Base::func1()Base::func2()的实现。

myDerived也包含指向虚表的一个指针,这个虚表也包含两项,一项是func1(),另一项是func2()。myDerived虚表的func1()项指向Base::func1(),因为Derived类没有重写func1();但是myDerived虛表的func2()项指向Derived::func2()

使用virtual的理由

首先创建virtual的原因,与虚表的开销有关。要调用虚方法,程序需要执行一项附加操作,即对指向要执行的适当代码的指针解除应用。在多数情况下,这样做会轻微地影响性能。如果方法永远不会重写,就没必要将其声明为virtual,从而影响性能。在多数应用程序中,无法察觉到使用虛方法和不使用虛方法带来的性能差别,因此应该遵循建议,将所有方法声明为virtual,包括析构函数。

但在某些情况下,性能开销确实不小,需要避免。例如,假设Point类有一个虚方法。 如果另一个数据结构存储着数百万个甚至数十亿个Point对象,在每个Point 对象上调用虚方法将带来极大的开销。此时,最好避免在Point类中使用虚方法。

virtual对于每个对象的内存使用也有轻微影响。除了方法的实现之外,每个对象还需要一个指向虚表的指针,这个指针会占用一点空间。

虚析构函数的需求

即使认为不应将所有方法都声明为virtual的程序员,也坚持认为应该将析构函数声明为virtual。原因是,如果析构函数未声明为virtual,很容易在销毁对象时不释放内存。唯一允许不把析构函数声明为virtual的例外情况是,类被标记为final。

例如,派生类使用的内存在构造函数中动态分配,在析构函数中释放。如果不调用析构函数,这块内存将无法释放。类似地,如果派生类具有一些成员,这些成员在类的实例销毁时自动删除,如stl:unique_ptrs,那么如果从未调用析构函数,将不会删除这些成员。

如果在析构函数中什么都不做,只想把它设置为virtual, 可显式地设置“default”,例如:

1
2
3
4
class Base {
public:
virtual ~Base() = default;
};

除非有特别原因,或者类被标记为final,否则强烈建议将所有方法(包括析构函数,构造函数除外)声明为virtual,构造函数不需要,也无法声明为virtual,因为在创建对象时,总会明确地指定类。

禁用重写

C++允许将方法标记为final,这意味着无法在派生类中重写这个方法。考虑下面的Base类:

1
2
3
4
5
class Base { 
public:
virtual ~Base() = default;
virtual void someMethod() final;
};

在下面的Derived类中重写someMethod()会导致编译错误,因为someMethod()在Base类中标记为final。

1
2
3
4
class Derived : public Base { 
public:
virtual void someMethod() override; // Error
};

利用父类

编写派生类时,需要知道父类和派生类之间的交互方式。创建顺序、构造函数链和类型转换都是潜在的bug来源。

父类构造函数

创建对象时必须同时创建父类和包含于其中的对象。C++定义了如下创建顺序:

  1. 如果某个类具有基类,执行基类的默认构造函数。除非在ctor-initializer中调用了基类构造函数,否则此时调用这个构造函数而不是默认构造函数。
  2. 类的非静态数据成员按照声明的顺序创建。
  3. 执行该类的构造函数。

下面的代码显示了创建顺序。代码正确执行时输出结果为123。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Something {
public:
Something() { cout << "2"; }
};

class Base {
public:
Base() { cout << "1"; }
};

class Derived : public Base {
public:
Derived() { cout << "3"; }
private:
Something mDataMember;
};

int main() {
Derived myDerived;
return 0;
}

创建myDerived对象时,首先调用Base构造函数,输出字符串“1”。 随后,初始化mDataMember,调用Something构造函数,输出字符串“2”。最后调用Derived构造函数,输出“3”。

注意Base构造函数是自动调用的。C++将自动调用父类的默认构造函数(如果存在的话)。如果父类的默认构造函数不存在,或者存在默认构造函数但希望使用其他构造函数,可在构造函数初始化器(constructor initializer)中像初始化数据成员那样链接构造函数。例如,下面的代码显示了没有默认构造函数的Base版本。相关版本的Derived必须显式地告诉编译器如何调用Base构造函数,否则代码将无法编译。

1
2
3
4
5
6
7
8
9
10
class Base {
public:
Base(int i);
};
class Derived : public Base
public:
Derived();
};

Derived::Derived() : Base(7) { }

在前面的代码中,Derived 构造函数向Base构造函数传递了固定值(7)。如果Derived构造函数需要一个参数,也可以传递变量:

1
Derived::Derived(int i) : Base(i) {}

从派生类向基类传递构造函数的参数很正常,毫无问题,但是无法传递数据成员。如果这么做,代码可以编译,但是记住在调用基类构造函数之后才会初始化数据成员。如果将数据成员作为参数传递给父类构造函数,数据成员不会初始化。

父类的析构函数

由于析构函数没有参数,因此始终可自动调用父类的析构函数。析构函数的调用顺序刚好与构造函数相反:

  1. 调用类的析构函数。
  2. 销毁类的数据成员,与创建的顺序相反。
  3. 如果有父类,调用父类的析构函数。

也可递归使用这些规则。链的最底层成员总是第一个被销毁。下面的代码在前面的示例中加入了析构函数。所有析构函数都声明为virtual。执行时代码将输出“123321”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Something {
public:
virtual ~Something() { cout << "2"; }
};
class Base {
public:
virtual ~Base() { cout << "1"; }
};
class Derived : public Base
public:
Derived() { cout << "3"; }
virtual ~Derived() { cout < < "3"; }
private:
Something mDataMember;
};

即使前面的析构函数没有声明为vitual,代码也可以继续运行。然而,如果代码使用delete删除一个实际指向派生类的基类指针,析构函数调用链将被破坏。例如,下面的代码与前面示例类似,但析构函数不是virtual。当使用指向Base对象的指针访问Derived对象并删除对象时,就会出问题。

1
2
Base* ptr = new Derived() ;
delete ptr;

代码的输出很短,是“1231”。当删除ptr 变量时,只调用了Base析构函数,因为析构函数没有声明为virtual。结果是没有调用Derived析构函数,也没有调用其数据成员的析构函数。

从技术角度看,将Base 析构函数声明为virtual,可纠正上面的问题。派生类将自动“虚化”。然而,建议显式地将所有析构函数声明为virtual,这样就不必担心这个问题。

将所有析构函数声明为virtual!编译器生成的默认析构函数不是virtual,因此应该定义自己(或显式设置为默认)的虚析构函数,至少在父类中应该这么做。

与构造函数一样,在析构函数中调用虚方法时,虚方法的行为将有所不同。如果派生类重写了基类中的虚方法,在基类的析构函数中调用该方法,会执行该方法的基类实现,而不是派生类的重写版本。

使用父类方法

在派生类中重写方法时,将有效地替换原始方法。然而,方法的父类版本仍然存在,仍然可以使用这些方法。考虑WeatherPrediction类中的getTemperature()方法,这个方法返回当前温度的字符串表示:

1
2
3
4
class WeatherPrediction {
public:
virtual std::string getTemperature() const;
};

在MyWeatherPrediction类中,可按如下方式重写这个方法:

1
2
3
4
class MyWeatherPrediction : public WeatherPrediction
public:
virtual std::string getTemperature() const override;
};

假定派生类要先调用基类的getTemperature()方法,然后将“°F”添加到string。为此,编写如下代码:

1
2
3
string MyWeatherPrediction::getTemperature() const {
return getTemperature() + "\u00B0E"; // BUG
}

然而,上述代码无法运行,根据C++的名称解析规则,首先解析的是局部作用域,然后是类作用域,根据这个顺序,函数中调用的是MyWeatherPrediction::getTemperature()。其结果是无限递归,直到耗尽堆栈空间。

为让代码运行,需要使用作用域解析运算符,如下所示:

1
2
3
string MyWeatherPrediction::getTemperature() const { 
return WeatherPrediction::getTemperature() + " \u00B0F" ;
}

在C++中,调用当前方法的父类版本是一种常见操作。如果存在派生类链,每个派生类都可能想执行基类中已经定义的操作,同时添加自己的附加功能。如果父类没有重写祖父类中的函数,C++会沿着类层次结构向上寻找实现了这个函数的类。

向上转型和向下转型

如前所述,对象可转换为父类对象,或者赋值给父类。如果类型转换或赋值是对某个普通对象执行,会产生截断:

1
Base myBase = myDerived; // Slicing!

这种情况下会导致截断,因为赋值结果是Base对象,而Base对象缺少Derived类中定义的附加功能。然而,如果用派生类对基类的指针或引用赋值,则不会产生截断:

1
Base& myBase = myDerived; // No slicing!

这是通过基类使用派生类的正确途径,也叫作向上转型(upcating)。这也是让方法和函数使用类的引用而不是直接使用类对象的原因。使用引用时,派生类在传递时没有截断。

当向上转型时,使用基类指针或引用以避免截断。

将基类转换为其派生类也叫作向下转型(downcasting),专业的C++程序员通常不赞成这种转换,因为无法保证对象实际上属于派生类,也因为向下转型是不好的设计。如果打算进行向下转型,应该使用dynamic_cast(),以使用对象内建的类型信息,拒绝没有意义的类型转换。这种内建信息通常驻留在虚表中,这意味着dynamic_cast只能用于具有虚表的对象,即至少有一个虚编号的对象。如果针对某个指针的dynamic_cast()失败,这个指针的值就是nullptr,而不是指向某个无意义的数据。如果针对对象引用的dynamic_cast()失败,将抛出stl::bad_cast异常。

继承与多态性

回到电子表格

下面给出了简化的SpreadsheetCell类定义。注意单元格可以是双精度值或字符串,然而这个示例中单元格的当前值总以字符串的形式返回。

1
2
3
4
5
6
7
8
9
10
11
class SpreadsheetCell {
public:
public:
virtual void set(double inDouble);
virtual void set(std::string_view inString);
virtual std::string getString() const;
private:
static std::string doubleTostring(double inValue);
static double stringToDouble(std::string_view inString);
double mValue;
};

设计多态性的电子表格单元格

SpreadsheeCell类急需改变层次结构。一种合理方法是让SpreadsheetCell只包含字符串,从而限制其范围,在此过程中或许将其重命名为StringSpreadsheetCell。为处理双精度值,可使用第二个类DoubleSpreadsheetCell。因为包含字符串的单元格与包含双精度值的单元格存在明显的关系。让这两个类地位同等,并有共同的父类SpreadsheetCell

  • 两个派生类都支持由基类定义的同一接口(方法集)。
  • 使用SpreadsheetCell对象的代码可调用接口中的任何方法,而不需要知道这个单元格是StringSpreadsheetCell还是DoubleSpreadsheetCell。
  • 由于虚方法的特殊能力,会根据对象所属的类调用接口中每个方法的正确实例。
  • 其他数据结构可通过引用父类类型,包含一组多类型的单元格。

SpreadsheetCell基类

初次尝试

SpreadsheeCell基类负责定义所有派生类支持的行为。在本例中,所有单元格都需要将值设置为字符串。此外,所有单元格都需要将当前值返回为字符串。基类定义中声明了这些方法,以及显式设置为默认的虚析构函数,但没有数据成员:

1
2
3
4
5
6
class SpreadsheetCell {
public:
virtual ~SpreadsheetCell() = default;
virtual void set (std::string_view inString) ;
virtual std::string getString() const;
};

纯虚方法和抽象基类

纯虚方法(pure virtual methods)在类定义中显式说明该方法不需要定义。如果将某个方法设置为纯虚方法,就是告诉编译器当前类中不存在这个方法的定义。具有至少一个纯虚方法的类称为抽象类,因为这个类没有实例。编译器会强制接受这个事实:如果某个类包含一个或多个纯虚方法,就无法构建这种类型的对象。

采用专门的语法指定纯虚方法:方法声明后紧接着=0。不需要编写任何代码。

1
2
3
4
5
6
class SpreadsheetCell {
public:
virtual ~Spreadsheetcell() = default;
virtual void set (std::string_view inString) = 0;
virtual std::string getString() const = 0;
};

一旦实现了StringSpreadsheetCell 类,下面的代码就可成功编译,原因在于实例化了抽象基类的派生类:

1
std::unique_ptr<SpreadsheetCell> cell (new StringSpreadsheetCell());

抽象类提供了一种禁止其他代码直接实例化对象的方法,而它的派生类可以实例化对象。

独立的派生类

StringSpreadsheetCell类定义

编写StringSpreadsheetCell类定义的第一步是从SpreadsheetCell类继承。第二步是重写继承的纯虚方法,此次不将其设置为0。最后一步是为字符串单元格添加一个私有数据成员mValue,在其中存储实际单元格数据。这个数据成员是stl::optional,从C++17开始定义在<optional>头文件中。optional类型是一个类模板,因此必须在尖括号之间指定所需的实际类型,如optional<string>

1
2
3
4
5
6
7
class StringSpreadsheetCell : public SpreadsheetCell {
public:
virtual void set(std::string_view inString) override;
virtual std: :string getString() const override;
private:
std::optional<std::string> mValue;
};

StringSpreadsheetCell的实现

StringSpreadsheetCell 的源文件包含方法的实现。set()方法十分简单,因为内部表示已经是一个字符串。如果mValue不具有值,getString()将返回一个空字符串。可使用std:optionalvalue_or()方法对此进行简化。使用mValue.value_or(" ")

1
2
3
4
5
6
void StringSpreadsheetCell::set(string_view inString) {
mValue = inString;
}
string StringSpreadsheetCell::getString() const {
return mValue.value_or("");
}

DoubleSpreadsheetCell 类的定义和实现

与StringSpreadsheetCell相同,这个类也有一个mValue数据成员,此时这个成员的类型是optional<double>

1
2
3
4
5
6
7
8
9
10
class DoubleSpreadsheetCell : public SpreadsheetCell {
public:
virtual void set (double inDouble);
virtual void set (std::string_view inString) override;
virtual std::string getString() const override;
private:
static std::string doubleToString (double inValue) ;
static double stringToDouble(std::string_view inValue);
std::optional<double> mValue;
};
1
2
3
4
5
6
7
8
9
void DoubleSpreadsheetCell::set (double inDouble) {
mValue = inDouble;
}
void DoubleSpreadsheetCell::set(string_view inString) {
mValue = stringToDouble (inString);
}
string DoubleSpreadsheetCell::getString() const {
return (mValue.has_value() ? doubleToString(mValue.value()) : "");
}

考虑将来

首先,即使不考虑改进设计,现在仍然缺少一个功能: 将某个单元格类型转换为其他类型。应添加一个转换构造函数(或类型构造函数),这个构造函数类似于复制构造函数,但参数不是对同类对象的引用,而是对同级类对象的引用。另外注意,现在必须声明一个默认构造函数,可将其显式设置为默认,因为一旦自行声明任何构造函数,编译器将停止生成:

1
2
3
4
5
class StringSpreadsheetCell : public SpreadsheetCell {
public:
StringSpreadsheetCell() = default;
StringSpreadsheetCell (const DoubleSpreadsheetCell& inDoubleCell) ;
};

将转换构造函数实现为如下形式:

1
2
3
4
StringSpreadsheetCell::StringSpreadsheetCell (
const DoubleSpreadsheetCell& inDoubleCell) {
mValue = inDoubleCell.getString();
}

通过转换构造函数,可很方便地用DoubleSpreadsheetCell创建StringSpreadsheetCell。然而不要将其与指针或引用的类型转换混淆,类型转换无法将一个指针或引用转换为同级的另一个指针或引用。

其次,如何为单元格实现运算符重载是一个很有趣的问题,一种方案是给出一种通用表示,前面的实现已将字符串作为标准化的通用类型表示。通过这种通用表示,一个operator+函数就可以处理所有情况。假定两个单元格相加的结果始终是字符串单元格,那么一个可能的实现如下所示:

1
2
3
4
5
StringSpreadsheetCell operator+ (const StringSpreadsheetCell& lhs, const StringSpreadsheetCell& rhs) {
StringSpreadsheetCell newCell;
newCell.set(lhs.getString() + rhs.getString()) ;
return newCell;
}

多重继承

从多个类继承

从语法角度看,定义具有多个父类的类很简单。为此,只需要在声明类名时分别列出基类:

1
2
class Baz : public Foo, public Bar
{ };

由于列出了多个父类,Baz 对象具有如下特性:

  • Baz对象支持Foo和Bar类的public方法,并且包含这两个类的数据成员。
  • Baz类的方法有权访问Foo和Bar类的protected数据成员和方法。
  • Baz对象可以向上转型为Foo或Bar对象。
  • 创建新的Baz对象将自动调用Foo和Bar类的默认构造函数,并按照类定义中列出的类顺序进行。
  • 删除Baz对象将自动调用Foo和Bar类的析构函数,调用顺序与类在类定义中的顺序相反。

名称冲突和歧义基类

多重继承崩溃的场景并不难想象,下面的示例显示了一些必须考虑的边缘情况。

名称歧义

如果两个类都有一个eat()方法,会发生什么?eat()方法的一个版本无法重写另一个版本——在派生类中这两个方法都存在。如果客户代码试图调用派生类的eat()方法,编译器将报错,指出对eat()方法的调用有歧义。

为了消除歧义,可使用dynamic_cast()显式地将对象向上转型(本质上是向编译器隐藏多余的方法版本),也可以使用歧义消除语法。下面的代码显示了调用eat()方法的Dog版本的两种方案:

1
2
dynamic_cast<Dog&> (myConfusedAnimal).eat(); // Calls Dog::eat()
myConfusedAnimal.Dog::eat();

使用与访问父类方法相同的语法(::运算符),派生类的方法本身可以显式地为同名的不同方法消除歧义。例如,派生类可以定义自己的eat()方法,从而消除其他代码中的歧义错误。在方法内部,可以判断调用哪个父类版本:

1
2
3
4
5
6
7
8
class DogBird : public Dog,public Bird {
public:
void eat() override;
};

void DogBird::eat() {
Dog::eat();
}

另一种防止歧义错误的方式是使用using 语句显式指定,在派生类中应继承哪个版本的eat()方法,如下面的DogBird类定义所示:

1
2
3
4
class DogBird : public Dog, public Bird
public:
using Dog::eat; // Explicitly inherit Dog's version of eat()
};

歧义基类

另一种引起歧义的情况是从同一个类继承两次。例如,如果出于某种原因Bird类从Dog类继承,DogBird类的代码将无法编译,因为Dog变成了歧义基类。

1
2
3
class Dog {};
class Bird : public Dog {};
class DogBird : public Bird, public Dog {}; // Error!

数据成员也可以引起歧义。如果Dog和Bird类具有同名的数据成员,当客户代码试图访问这个成员时,就会发生歧义错误。

多个父类本身也可能有共同的父类。例如,Bird和Dog类可能都是Animal类的派生类(菱形类结构)。

使用“菱形”类层次结构的最佳方法是将最顶部的类设置为抽象类,将所有方法都设置为纯虚方法。由于类只声明方法而不提供定义,在基类中没有方法可以调用,因此在这个层次上就没有歧义。

有趣而晦涩的继承问题

修改重写方法的特征

重写某个方法的主要原因是为了修改方法的实现。然而,有时是为了修改方法的其他特征。

修改方法的返回类型

重写方法要使用与基类一致的方法声明(或方法原型)。实现可以改变,但原型保持不变。然而事实未必总是如此,在C++中,如果原始的返回类型是某个类的指针或引用,重写的方法可将返回类型改为派生类的指针或引用。这种类型称为协变返回类型(covariant return types)。如果基类和派生类处于平行层次结构(parallel hierarchy)中,使用这个特性可以带来便利。平行层次结构是指,一个类层次结构与另一个类层次结构没有相交,但是存在联系。

修改方法的参数

如果在派生类的定义中使用父类中虚方法的名称,但参数与父类中同名方法的参数不同,那么这不是重写父类的方法,而是创建一个新方法。回到本章前面的Base 和Derived类示例,可试着在Derived类中使用新的参数列表重写someMethod()方法,如下所示:

1
2
3
4
5
6
7
8
9
class Base {
public:
virtual void someMethod() ;
};
class Derived : public Base {
public:
virtual void someMethod(int i); // Compiles, but doesn't override
virtual void someOtherMethod();
};

这个方法的实现如下所示:

1
2
3
void Derived::someMethod(int i) {
cout << "This is Derived's version of someMethod with argument "<< i << "." << endl;
}

实际上,C++标准指出,当Derived 类定义了这个方法时,原始的方法被隐藏。下面的代码无法编译,因为没有参数的someMethod()方法不再存在。

1
2
Derived myDerived;
myDerived.someMethod(); // Error! Won't compile because original method is hidden.

如果希望重写基类中的someMethod()方法,就应该像前面建议的那样使用override关键字。如果在重写方法时发生错误,编译器会报错。

可使用一种较晦涩的技术兼顾二者。也就是说,可使用这一技术在派生类中有效地用新的原型“重写”某个方法,并继承该方法的基类版本。这一技术使用using关键字显式地在派生类中包含这个方法的基类定义:

1
2
3
4
5
6
7
8
9
10
class Base { 
public:
virtual void someMethod();
};
class Derived : public Base {
public:
using Base::someMethod;
virtual void someMethod(int i);
virtual void someOtherMethod();
};

继承的构造 函数

允许在派生类中继承基类的构造函数。考虑下面的Base和Derived类定义:

1
2
3
4
5
6
7
8
9
class Base {
virtual ~Base() = default;
Base() = default;
Base(std::string_view str);
};
class Derived : public Base {
public:
Derived(int i);
};

只能用提供的Base构造函数构建Base对象,要么是默认构造函数,要么是包含string_view 参数的构造函数。另外,只能用Derived构造函数创建Derived 对象,这个构造函数需要一个整数作为参数。不能使用Base类中使用接收string_view 的构造函数来创建Derived对象。例如:

1
2
3
Base base ("Hello");  // OK, calls string_view Base ctor
Derived derived1 (1); // OK, calls integer Derived ctor
Derived derived2 ("Hel1o"); // Error, Derived does not inherit string_view ctor

如果喜欢使用基于string_view的Base构造函数构建Derived对象,可在Derived 类中显式地继承Base构造函数,如下所示:

1
2
3
4
5
class Derived : public Base {
public:
using Base::Base;
Derived(int i);
};

using语句从父类继承除默认构造函数外的其他所有构造函数,现在可通过两种方法构建Derived对象:

Derived类定义的构造函数可与从Base类继承的构造函数有相同的参数列表。与所有的重写一样,此时Derived类的构造函数的优先级高于继承的构造函数。

使用using子句从基类继承构造函数有一些限制。当从基类继承构造函数时,会继承除默认构造函数外的其他全部构造函数,不能只是继承基类构造函数的一个子集。第二个限制与多重继承有关。如果一个基类的某个构造函数与另一个基类的构造函数具有相同的参数列表,就不可能从基类继承构造函数,因为那样会导致歧义。为解决这个问题,Derived类必须显式地定义冲突的构造函数。例如,下面的Derived类试图继承Base1和Base2基类的所有构造函数,这会产生编译错误,因为使用浮点数作为参数的构造函数存在歧义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Base1 {
public:
virtual ~Base1() = default;
Base1() = default;
Base1(float f);
};
class Base2 {
public:
virtual ~Base2() = default;
Base2() = default;
Base2 (std::string_view str);
Base2(float f);
};
class Derived : public Base1, public Base2 {
public:
using Base1::Base1;
using Base2::Base2;
Derived(char c);
};

Derived类定义中的第一条using 语句继承了Base1类的构造函数。这意味着Derived类具有如下构造函数:

1
Derived(float f); // Inherited from Base1

Derived类定义中的第二条using子句试图继承Base2类的全部构造函数。然而,这会导致编译错误,因为这意味着Derived类拥有第二个Derived(float f)构造函数。为解决这个问题,可在Derived类中显式声明冲突的构造函数,如下所示:

1
2
3
4
5
6
7
class Derived : public Base1, public Base2 {
public:
using Base1::Basel;
using Base2::Base2;
Derived(char c);
Derived(float f);
};

现在,Derived 类显式地声明了一个采用浮点数作为参数的构造函数,从而解决了歧义问题。如果愿意,在Derived 类中显式声明的使用浮点数作为参数的构造函数仍然可以在ctor-initializer中调用Base1和Base2构造函数,如下所示:

1
Derived::Derived(float f) : Base1(f), Base2(f) {}

重写方法时的特殊情况

当重写方法时,需要注意几种特殊情况。本节将列出可能遇到的一些情况。

静态基类方法

在C++中,不能重写静态方法。对于多数情况而言,知道这一点就足够了。然而,在此需要了解一些推论。首先,方法不可能既是静态的又是虚的。出于这个原因,试图重写一个静态方法并不能得到预期的结果。如果派生类中存在的静态方法与基类中的静态方法同名,实际上这是两个独立的方法。下面的代码显示了两个类,这两个类都有一个名为beStatic()的静态方法。这两个方法毫无关系。

1
2
3
4
5
6
7
8
class BaseStatic {
public:
static void beStatic() { cout << "BaseStatic being static." << endl;}
};
class DerivedStatic : public BaseStatic {
public:
static void beStatic() { cout << "Derivedstatic keepin' it static." << endl; }
};

由于静态方法属于类,调用两个类的同名方法时,将调用各自的方法。

1
2
BaseStatic::beStatic();
DerivedStatic::beStatic();

输出为:

1
2
BaseStatic being static.
DerivedStatic keepin' it static.

用类名访问这些方法时一切都很正常。当涉及对象时,这一行为就不是那么明显。在C++中,可以使用对象调用静态方法,但由于方法是静态的,因此没有this指针,也无法访问对象本身,使用对象调用静态方法,等价于使用classname:method()调用静态方法。回到前面的示例,可以编写如下代码,但是结果令人惊讶:

1
2
3
4
DerivedStatic myDerivedStatic;
BaseStatic& ref = myDerivedStatic;
myDerivedStatic.beStatic();
ref.beStatic();

beStatic()的第一次调用显然调用了DerivedStatic版本,因为调用它的对象被显式地声明为DerivedStatic。第二次调用的对象是一个BaseStatic引用,但指向的是一个DerivedStatic对象。在此情况下,会调用BaseStatic版本的beStatic()。 原因是当调用静态方法时,C++不关心对象实际上是什么,只关心编译时的类型。在此情况下,该类型为指向BaseStatic对象的引用。

静态方法属于定义它的类,而不属于特定的对象。当类中的方法调用静态方法时,所调用的版本是通过正常的名称解析来决定的。当使用对象调用时,对象实际上并不涉及调用,只是用来判断编译时的类型。

重载基类方法

当指定名称和一组参数以重写某个方法时,编译器隐式地隐藏基类中同名方法的所有其他实例。考虑下面的Derived类,它重写了一个方法,而没有重写相关的同级重载方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Base {
public:
virtual ~Base() = default;
virtual void overload() { cout << "Base's overload()" << endl;
virtual void overload(int i) {
cout << "Base's overload(int i)" << endl;
}
};
class Derived : public Base {
public:
virtual void overload() override {
cout << "Derived's overload()" << endl;
}
};

如果试图用Derived对象调用以int值作为参数的overload()版本,代码将无法编译,因为没有显式地重写这个方法。然而,使用Derived对象访问该版本的方法是可行的。只需要使用指向Base对象的指针或引用:

1
2
3
Derived myDerived;
Base& ref = myDerived;
ref.overload(7) ;

在C++中,隐藏未实现的重载方法只是表象。显式声明为子类型实例的对象无法使用这些方法,但可将其转换为基类类型,以使用这些方法。

如果只想改变一个方法,可以使用using关键字避免重载该方法的所有版本。在下面的代码中,Derived类定义中使用了从Base类继承的一个overload()版本,并显式地重写了另一个版本:

1
2
3
4
5
class Derived : public Base {
public:
using Base::overload;
virtual void overload() override
};

基类方法具有默认参数

派生类与基类可具有不同的默认参数,但使用的参数取决于声明的变量类型,而不是底层的对象。下面是一个简单的派生类示例,派生类在重写的方法中提供了不同的默认参数:

1
2
3
4
5
6
7
8
class Base {
public:
virtual void go(int i = 2);
};
class Derived : public Base
public:
virtual void go(int i = 7) override;
};

如果调用Derived对象的go(),将执行Derived版本的go(),默认参数为7。如果调用Base对象的go(),将执行Base版本的go(),默认参数为2。然而(有些怪异),如果使用实际指向Derived对象的Base指针或Base引用调用go(),将调用Derived版本的go(),但使用Base版本的默认参数2。

产生这种行为的原因是C++根据表达式的编译时类型(而非运行时类型)绑定默认参数。在C++中,默认参数不会被“继承”。如果上面的Derived类没有像父类那样提供默认参数,就用新的非0参数版本重载go()方法。

当重写具有默认参数的方法时,也应该提供默认参数,这个参数的值应该与基类版本相同。建议使用符号常量作为默认值,这样可在派生类中使用同一个符号常量。

派生类中的复制构造函数和赋值运算符

当定义派生类时,必须注意复制构造函数和operator=。如果派生类没有任何需要使用非默认复制构造函数或operator=的特殊数据(通常是指针),无论基类是否有这类数据,都不需要它们。如果派生类省略了复制构造函数或operator=,派生类中指定的数据成员就使用默认的复制构造函数或operator=,基类中的数据成员使用基类的复制构造函数或operator=。

另外,如果在派生类中指定了复制构造函数,就需要显式地链接到父类的复制构造函数,下面的代码演示了这一内容。如果不这么做,将使用默认构造函数(不是复制构造函数!)初始化对象的父类部分。

1
2
3
4
5
6
7
class Derived : public Base {
public:
Derived() = default;
Derived (const Derived& src);
};

Derived::Derived(const Derived& src) : Base (src);

与此类似,如果派生类重写了operator=, 则几乎总是需要调用父类版本的operator=。唯一的例外是因为某些奇怪的原因,在赋值时只想给对象的一部分赋值。下面的代码显示了如何在派生类中调用父类的赋值运算符。

1
2
3
4
5
6
7
Derived& Derived: :operator= (const Derived& rhs) {
if (&rhs == this) {
return *this;
}
Base::operator=(rhs); // calls parent's operator=.
return *this;
}

如果派生类不指定自己的复制构造函数或operator=,基类的功能将继续运行。否则,就需要显式引用基类版本。

运行时类型工具

在C++中,有些特性提供了对象的运行时视角。这些特性通常归属于一个名为运行时类型信息(RunTime Type Information, RTTI)的特性集。RTTI的一个特性是typeid运算符,这个运算符可在运行时查询对象,从而判别对象的类型。大多数情况下,不应该使用typeid,因为最好用虚方法处理基于对象类型运行的代码。下面的代码使用了typeid,根据对象的类型输出消息:

1
2
3
4
5
6
7
8
#include <typeinfo>
void speak(const Animal& animal) {
if (typeid(animal) == typeid(Dog)) {
cout << "Woof!" << endl;
} else if (typeid(animal) == typeid(Bird)) {
cout << "Chirp!" << endl;
}
}

一旦看到这样的代码,就应该立即考虑用虚方法重新实现该功能。

类至少有一个虚方法,typeid 运算符才能正常运行。如果在没有虚方法的类上使用dynamic_cast(),会导致编译错误。typeid 运算符也会从实参中去除引用和const限定符。

typeid运算符的主要价值之一在于日志记录和调试。

非public继承

将父类的关系声明为protected,意味着在派生类中,基类所有的public方法和数据成员都成为受保护的。与此类似,指定private继承意味着基类所有的public、protected方法和数据成员在派生类中都成为私有的。使用这种方法统一降低父类的访问级别有许多原因,但多数原因都是层次结构的设计缺陷。

虚基类

如果希望被共享的父类拥有自己的功能,C++提供了另一种机制来解决这个问题。如果被共享的基类是一个虚基类(virtual base class),就不存在歧义。

理解灵活而奇特的C++

引用

在C++中,引用是另一个变量的别名。对引用的所有修改都会改变被引用的变量的值。可将引用当作隐式指针,这个指针没有取变量地址和解除引用的麻烦。也可将引用当作原始变量的另一个名称。

引用变量

引用变量在创建时必须初始化,如下所示:

1
2
int x = 3;
int& xRef = x;

使用xRef就是使用x的当前值。对xRef赋值会改变x的值。

创建引用时必须总是初始化它。通常会在声明引用时对其进行初始化,但是对于包含类而言,需要在构造函数初始化器中初始化引用数据成员。

不能创建对未命名值(例如一个整数字面量)的引用,除非这个引用是一个const值。

1
2
int& unnamedRef1 = 5; // DOES NOT COMPILE
const int& unnamedRef2 = 5; // Works as expected

临时对象同样如此。不能具有临时对象的非const引用,但可具有const引用。例如:

1
std::string getString() { return "Hello world!"; }

对于调用getString()的结果,可以有一个const引用;在该const引用超出作用域之前,将使std::string对象一直处于活动状态:

1
2
std::string& string1 = getString();    // DOES NOT COMPILE
const std::string& string2 = getString(); // Works as expected

修改引用

引用总是引用初始化的那个变量,且无法修改。如果在声明引用时用一个变量“赋值”,那么这个引用就指向这个变量。然而,如果在此后使用变量对引用赋值,被引用变量的值就变为被赋值变量的值。引用不会更新为指向这个变量。下面是示例代码:

1
2
3
int x = 3, y = 4;
int& xRef = x;
xRef = y; // Changes value of x to 4. Doesn't make xRef refer to y.

将一个引用赋值给另一个引用会让第一个引用指向第二个引用所指的变量吗?

1
2
3
4
int x = 3, z = 5;
int& xRef = x;
int& zRef = z;
zRef = xRef; // Assigns values, not references

最后一行代码没有改变zRef,只是将z的值设置为3,因为xRef指向x,x的值是3。

在初始化引用之后无法改变引用所指的变量,而只能改变该变量的值。

指向指针的引用和指向引用的指针

可创建任何类型的引用,包括指针类型。

1
2
3
4
int* intP;
int*& ptrRef = intP;
ptrRef = new int;
*ptrRef = 5;

语义实际上很简单:ptrRef是一个指向intP的引用,intP是一个指向int值的指针。修改ptrRef会更改intP

注意,对引用取地址的结果与对被引用变量取地址的结果相同。例如:

1
2
3
int x = 3;
int* xPtr = &xRef; // Address of a reference is pointer to value
*xPtr = 100;

上述代码通过取x引用的地址,使xPtr指向x。将*xPtr赋值为100,x的值也变为100。比较表达式xPtr==xRef将无法编译,因为类型不匹配;

引用数据成员

如果不指向其他变量,引用就无法存在。因此,必须在构造函数初始化器(constructor initializer)中初始化引用数据成员,而不是在构造函数体内。下面列举一个简单示例:

1
2
3
4
5
class MyClass {
public:
MyClass(int& ref) : mRef(ref) { }
private:
int& mRef;

引用参数

引用经常用作函数或方法的参数。当使用引用参数时,函数将引用作为参数。如果引用被修改,最初的参数变量也会被修改。

1
2
3
4
5
void swap(int& first, int& second) {
int temp = first;
first = second;
second = temp;
}

就像无法用常量初始化普通引用变量一样, 不能将常量作为参数传递给“按非const引用传递”的函数:

使用“按const引用传递”或“按右值引用传递”,可将常量作为参数传递给函数。

将指针转换为引用

某个函数或方法需要以一个引用作为参数,而你拥有一个指向被传递值的指针,在此情况下,可对指针解除引用(dereferencing),将指针“转换”为引用。这一行为会给出指针所指的值,随后编译器用这个值初始化引用参数。例如:

1
2
3
int x = 5, y = 6;
int* xp = &x, *yp = &y;
swap(*xp, *yp);

按引用传递与按值传递

按引用传递不需要将参数的副本复制到函数,在有些情况下这会带来两方面的好处。

  1. 效率:复制较大的对象或结构需要较长时间。按引用传递只是把指向对象或结构的指针传递给函数。
  2. 正确性:并非所有对象都允许按值传递,即使允许按值传递的对象,也可能不支持正确的深度复制(deep copying)。第9章提到,为支持深度复制,动态分配内存的对象必须提供自定义复制构造函数或复制赋值运算符。

如果要利用这些好处,但不想修改原始对象,可将参数标记为const,从而实现按常量引用传递参数。

将引用作为返回值

还可让函数或方法返回一个引用。这样做的主要原因是为了提高效率。返回对象的引用而不是返回整个对象可避免不必要的复制。当然,只有涉及的对象在函数终止之后仍然存在的情况下才能使用。

如果变量的作用域局限于函数或方法(例如堆栈中自动分配的变量,在函数结束时会被销毁),绝不能返回这个变量的引用。

如果从函数返回的类型支持移动语义,按值返回就几乎与返回引用一样高效。

返回引用的另一个原因是希望将返回值直接赋为左值(lvalue)(赋值语句的左边)。一些重载的运算符通常会返回引用。

右值引用

右值(rvalue)就是非左值(Ivalue),例如常量值、临时对象或值。通常而言,右值位于赋值运算符的右侧:

1
2
3
4
// lvalue reference parameter
void handleMessage(std::string& message) {
cout << "handleMessage with lvalue reference: "<< message << endl;
}

对于这个handleMessage()版本,不能采用如下方式调用它:

1
2
3
4
handleMessage("Hello World"); // A literal is not an lvalue.
std::string a = "Hello ";
std::string b = "World";
handleMessage(a + b); // A temporary is not an 1value .

要支持此类调用,需要一个接收右值引用的版本:

1
2
3
4
// rvalue reference parameter
void handleMessage (std::string&& message ) {
cout << "handleMessage with rvalue reference: " << message << endl;
}

使用引用还是指针

引用比指针安全,不可能存在无效引用,也不需要显式地解除引用,因此不会遇到像指针那样的解除引用问题。

大多数情况下,应该使用引用而不是指针。对象的引用甚至可像指向对象的指针那样支持多态性。但也有一些情况要求使用指针,一个例子是更改指向的位置,因为无法改变引用所指的变量。例如,动态分配内存时,应该将结果存储在指针而不是引用中。

需要使用指针的另一种情况是可选参数,即指针参数可以定义为带默认值nullptr的可选参数,而引用参数不能这样定义。还有一种情况是要在容器中存储多态类型。

有一种方法可以判断使用指针还是引用作为参数和返回类型:考虑谁拥有内存。如果接收变量的代码负责释放相关对象的内存,那么必须使用指向对象的指针,最好是智能指针,这是传递拥有权的推荐方式。如果接收变量的代码不需要释放内存,那么应该使用引用。

考虑将一个int数组分割为两个数组的函数:一个是偶数数组;另一个是奇数数组。这个函数并不知道源数组中有多少奇数和偶数,因此只有在检测完源数组后,才能为目标数组动态分配内存,此外还需要返回这两个新数组的大小。因此总共需要返回4项:指向两个新数组的指针和两个新数组的大小。显然必须使用按引用传递,用规范的C语言方式编写的这个函数如下所示:

1
void separateOddsAndEvens(const int arr[], size_t size, int** odds, size_t* numOdds, int** evens, size_t* numEvens)

如果要调用separateOddsAndEvens(),就必须传递两个指针的地址,这样函数才能修改实际的指针,还必须传递两个int值的地址,这样函数才能修改实际的int值。另外注意,主调方负责删除由separateOddsAndEvens()创建的两个数组!

如果觉得这种语法很难理解(应该是这样的),可以用引用实现真正的按引用传递,如下所示:

1
void separateOddsAndEvens(const int arr[], size_t size, int*& odds, size_t& numOdds, int*& evens, size_t& numEvens)

在此情况下, adds和evens参数是指向int*的引用。separateOddsAndEvens()可以修改用作函数参数的int*(通过引用),而不需要显式地解除引用。使用这个版本的函数时,不再需要传递指针或int值的地址,引用参数会自动进行处理:

1
separateOddsAndEvens(unSplit, std::size(unSplit), oddNums,numOdds, evenNums, numEvens);

关键字的疑问

const 关键字

const是constant的缩写,指保持不变的量。任何尝试改变常量的行为都会被当作错误处理。此外,当启用优化时,编译器可利用此信息生成更好的代码。关键字const有两种相关的用法。可以用这个关键字标记变量或参数,也可以用其标记方法。

const 变量和参数

可使用const来“保护”变量不被修改。这个关键字的一个重要用法是替换#define来定义常量,这是const最直接的应用。例如,可以这样声明常量PI:

1
const double PI = 3.141592653589793238462;

可将任何变量标记为const,包括全局变量和类数据成员。

还可使用const指定函数或方法的参数保持不变。例如,下面的函数接收一个const参数。在函数体内,不能修改整数param。如果试图修改这个变量,编译器将生成错误。

1
void func (const int param)

下面详细讨论两种特殊的const变量或参数:const指针和const引用。

const指针

当变量通过指针包含一层或多层间接取值时,const的应用将变得十分微妙。考虑下面的代码行:

1
2
3
int* ip;
ip = new int[10];
ip[4] = 5;

为阻止修改所指的值,可在ip的声明中这样添加关键字const:

1
2
3
const int* ip;
ip = new int[10];
ip[4] = 5;

下面是在语义上等价的另一种方法,将const放在int的前面还是后面并不影响其功能。

1
2
int const* ip;
ip[4] = 5; // DOES NOT COMPILE!

如果要将ip本身标记为const(而不是ip所指的值),可以这样做:

1
2
3
int* const ip = nullptr;
ip = new int[10]; // DOES NOT COMPILE !
ip[4] = 5; // Error: dereferencing a null pointer

现在ip本身无法修改,编译器要求在声明ip时就执行初始化,可以使用前面代码中的nullptr,也可以使用新分配的内存,如下所示:

1
2
int* const ip = new int[10];
ip[4] = 5;

还可将指针和所指的值全部标记为const,如下所示:

1
2
int const* const ip = nullptr;
const int* const ip = nullptr;

尽管这些语法看上去有点混乱,但规则实际上非常简单:将const 关键字应用于直接位于它左边的任何内容。再次考虑这一行:

1
int const* const ip = nullptr;

从左到右,第一个const直接位于int的右边,因此将const应用到ip所指的int,从而指定无法修改ip所指的值。第二个const直接位于*的右边,因此将const应用于指向int变量的指针,也就是ip变量。因此,无法修改ip(指针)本身。

还有一种易于记忆的、用于指出复杂变量声明的规则:从右向左读。考虑示例int* const ip。从右向左读这条语句,就可以知道ip是一个指向int值的const指针。另外,int const* ip读作ip 是一个指向const int的指针

const引用

将const应用于引用通常比应用于指针更简单,原因有两个。首先,引用默认为const,无法改变引用所指的对象。因此,不必显式地将引用标记为const。其次,无法创建指向引用的引用,所以引用通常只有一层间接取值。获取多层间接取值的唯一方法是创建指向指针的引用。

因此,C++程序员提到“const 引用”时,含义如下所示:

1
2
3
int z;
const int& zRef = z;
zRef = 4; // DOES NOT COMPILE

由于将const应用到int,因此无法对zRef赋值,如前所示。与指针类似,const int& zRef等价于int const& zRef。然而要注意,将zRef标记为const对z没有影响。仍然可以修改z的值,具体做法是直接改变z,而不是通过引用。

const引用经常用作参数,这非常有用。如果为了提高效率,想按引用传递某个值,但不想修改这个值,可将其标记为const引用。例如:

1
2
void doSomething (const BigClass& arg) 
// Implementation here

将对象作为参数传递时,默认选择是const引用。只有在明确需要修改对象时,才能忽略const。

const方法

可将类方法标记为const,以禁止方法修改类的任何非可变(non-mutable)数据成员。

constexpr关键字

在某些情况下需要常量表达式。例如当定义数组时,数组的大小就必须是一个常量表达式。由于这一限制,下面的代码在C++中是无效的:

1
2
3
4
5
const int getArraySize() { return 32; }
int main() {
int myArray [getArraySize()]; // Invalid in C++
return 0 ;
}

可使用constexpr关键字重新定义getAraySize()函数,把它变成常量表达式。常量表达式在编译时计算

将函数声明为constexpr会对函数的行为施加一些限制, 因为编译器必须在编译期间对constexpr函数求值,函数也不允许有任何副作用。下面是几个限制:

  • 函数体不包含goto语句、try catch块、未初始化的变量、非字面量类型的变量定义,也不抛出异常,但可调用其他constexpr 函数。
    • “字面量类型”(literal type)是constexpr变量的类型,可从constexpr函数返回。
    • 字面量类型可以是void(可能有const、volatile限定符)、标量类型(整型和浮点类型、枚举类型、指针类型、成员指针类型,这些类型有const/volatile 限定符)、引用类型、字面量数组类型或类类型。
    • 类类型可能也有const、volatile限定符,具有普通的(即非用户提供的)析构函数,至少有一个constexpr构造函数,所有非静态数据成员和基类都是字面量类型。
  • 函数的返回类型应该是字面量类型。
  • 如果constexpr函数是类的一个成员,那么这个函数不能是虚函数。
  • 函数所有的参数都应该是字面量类型。
  • 在编译单元(ranslation unit)中定义了constexpr函数后,才能调用这个函数,因为编译器需要知道完整的定义。
  • 不允许使用dynamic_cast()reinterpret_cast()
  • 不允许使用new和delete表达式。

通过定义constexpr构造函数,可创建用户自定义类型的常量表达式变量。constexpr构造函数具有很多限制,其中的一些限制如下所示:

  • 类不能具有任何虚基类。
  • 构造函数的所有参数都应该是字面量类型。
  • 构造函数体不应该是function-try-block。
  • 构造函数体应该满足与constexpr函数体相同的要求,并显式设置为默认(=default)。
  • 所有数据成员都应该用常量表达式初始化。

static关键字

静态数据成员和方法

可声明类的静态数据成员和方法。静态数据成员不是对象的一部分,这个数据成员只有一个副本,这个副本存在于类的任何对象之外。静态方法与此类似,位于类层次(而不是对象层次)。静态方法不会在某个特定对象环境中执行。

静态链接(static linkage)

默认情况下,函数和全局变量都拥有外部链接。然而,可在声明的前面使用关键字static指定内部(或静态)链接。如果f()函数具有内部(静态)链接,另一个文件无法使用这个函数。如果在源文件中定义了静态方法但是没有使用它,有些编译器会给出警告(指出这些方法不应该是静态的,因为其他文件可能会用到它们)。

将static用于内部链接的另一种方式是使 用匿名名称空间(anonymous namespaces)。可将变量或函数封装到一个没有名字的名称空间,而不是使用static,如下所示:

1
2
3
4
5
6
7
#include <iostream>
namespace {
void f();
void f() {
std::cout << "f\n";
}
}

在同一源文件中,可在声明匿名名称空间之后的任何位置访问名称空间中的项,但不能在其他源文件中访问。这一语义与static关键字相同。

extern关键字

extern关键字将它后面的名称指定为外部链接。某些情况下可使用这种方法。例如,const和typedef在默认情况下是内部链接,可使用extern使其变为外部链接。然而,extern有一点复杂。当指定某个名称为extern时,编译器将这条语句当作声明而不是定义。对于变量而言,这意味着编译器不会为这个变量分配空间。必须为这个变量提供单独的、不使用extern关键字的定义行。例如,下面是AnotherFile.cpp的内容:

1
2
extern int x;
int x = 3;

也可在extern行初始化x,这一行既是声明又是定义:

1
extern int x = 3;

这种情形下的extern并不是非常有用,因为x默认具有外部链接。当另一个源文件FirstFile.cpp使用x时,才会真正用到extern:

1
2
3
4
5
#include <iostream>
extern int x;
int main() {
std::cout << x << std::endl;
}

FirstFile.cpp 使用了extern 声明,因此可使用x。编译器需要知道x的声明,才能在main()函数中使用这个变量。然而,如果声明x时未使用extern关键字,编译器会认为这是定义,因而会为x分配空间,导致链接步
骤失败(因为有两个全局作用域的x变量)。使用extern,就可在多个源文件中全局访问这个变量。

函数中的静态变量

C++中static关键字的最终目的是创建离开和进入作用域时都可保留值的局部变量。函数中的静态变量就像只能在函数内部访问的全局变量。静态变量最常见的用法是“记住”某个函数是否执行了特定的初始化操作。

非局部变量的初始化顺序

程序中所有的全局变量和类的静态数据成员都会在main()函数开始之前初始化。给定源文件中的变量以在源文件中出现的顺序初始化。例如,在下面的文件中,Demo::x一定会在y之前初始化:

1
2
3
4
5
6
class Demo {
public:
static int x;
};
int Demo::x = 3;
int y = 34;

然而,C++没有提供规范,用以说明在不同源文件中初始化非局部变量的顺序。如果在某个源文件中有一个全局变量x,在另一个源文件中有一个全局变量y,无法知道哪个变量先初始化。如果某个全局变量或静态变量依赖于另一个变量,这两个全局对象在不同的源文件中声明,就不能指望一个全局对象在另一个全局对象之前构建,也无法控制它们的初始化顺序。不同编译器可能有不同的初始化顺序,即使同一编译器的不同版本也可能如此。

不同源文件中非局部变量的初始化顺序是不确定的。

非局部变量的销毁顺序

非局部变量按初始化的逆序进行销毁。不同源文件中非局部变量的初始化顺序是不确定的,所以销毁顺序也是不确定的。

类型和类型转换

类型别名

类型别名为现有的类型声明提供了新名称。下面为int*类型声明指定新名称IntPtr:

1
using IntPtr = int*;

类型别名最常见的用法是当实际类型的声明过于笨拙时,提供易于管理的名称。标准库广泛使用类型别名来提供类型的简短名称。例如,std:string实际上就是这样一个类型别名:

1
using string = basic_string<char>;

函数指针的类型别名

在C++中,可使用函数的地址,就像使用变量那样。函数指针的类型取决于兼容函数的参数类型的返回类型。处理函数指针的一种方式是使用类型别名。类型别名允许将一个类型名指定给具有指定特征的一系列函数。 例如,下面的代码行定义了MatchFunction类型,该类型表示一个指针,这个指针指向具有两个int参数并返回布尔值的任何函数:

1
using MatchFunction = bool(*) (int, int);

有了这个新类型,可编写将MatchFunction作为参数的函数。

1
2
3
4
5
6
7
void findMatches(int values1[], int values2[], size_t numValues, MatchFunction matcher) {
for (size_t i = 0; i < numValues; i++) {
if (matcher (values1[i],values2[i]) {
// ......
}
}
}

使用函数指针,可根据matcher参数自定义单个findMatches()函数的功能。

如果不使用这些旧式的函数指针,还可以使用stl::function

方法和数据成员的指针的类型别名

在C++中,取得类成员和方法的地址,获得指向它们的指针是完全合法的。但不能访问非静态成员,也不能在没有对象的情况下调用非静态方法。类数据成员和方法完全依赖于对象的存在。因此,通过指针调用方法或访问数据成员时,一定要在对象的上下文中解除对指针的引用。

1
2
3
Employee employee;
int (Employee::*methodPtr) () const = &Employee::getSalary;
cout << (employee.*methodPtr) () << endl;

不必担心上述语法。第二行声明了一个指针类型的变量methodPtr,该指针指向Employee类的一个非静态const方法,这个方法不接收参数并返回一个int值。同时,这行代码将这个变量初始化为指向Employee类的getSalary()方法。这种语法和声明简单函数指针的语法非常类似,只不过在*methodPtr的前面添加了Employee::。还要注意,在这种情况下需要使用&。

第3行代码调用employee对象的getSalary()方法(通过methodPtr指针)。注意在employlee.*methodPtr的周围使用了括号。

可通过类型别名简化第二行代码:

1
2
3
4
Employee employee;
using PtrToGet = int (Employee::*) () const;
PtrToGet methodPtr = &Employee::getSalary;
cout << (employee.*methodPtr) () << endl;

使用auto可进一步简化:

1
2
3
Employee employee;
auto methodPtr = &Employee::getSalary;
cout << (employee.*methodPtr)() << endl;

方法和数据成员的指针通常不会出现在程序中。然而,要记住,不能在没有对象的情况下解除对非静态方法或数据成员的指针的引用。C++允许在没有对象的情况下解除对静态方法或静态数据成员的指针的引用。

typedef

与类型别名一样,typedef为已有的类型声明提供新名称。例如,使用以下类型别名:

1
using IntPtr = int*;

如果不使用类型别名,就必须使用如下typedef:

1
typedef int* IntPtr;

在引入类型别名之前,必须为函数指针使用typedef,这更复杂。例如,对于以下类型别名:

1
using FunctionType = int (*) (char, double);

如果用typedef定义相同的FunctionType, 形式将如下:

1
typedef int (*FunctionType) (char, double);

类型别名和typedef并非完全等效。与typedef相比,类型别名与模板一起使用时功能更强大。

类型转换

C++还提供了4种类型转换:const_cast()static_cast()reinterpret_cast()dynamic_cast()。使用()的C风格类型转换在C++中仍然有效。

const_cast()

const_cast()最直接,可用于给变量添加常量特性,或去掉变量的常量特性。这是上述4种类型转换中唯可舍弃常量特性的类型转换。当然从理论上讲,并不需要const类型转换。如果某个变量是const,那么应该一直是const。然而实际中,有时某个函数需要采用const变量,但必须将这个变量传递给采用非const变量作为参数的函数。因此,有时需要舍弃变量的常量特性,但只有在确保调用的函数不修改对象的情况下才能这么做,否则就只能重新构建程序。下面是一个示例:

1
2
3
extern void ThirdPartyLibraryMethod(char* str);
void f(const char* str)
ThirdPartyLibraryMethod(const_cast<char*>(str));

从C++17开始,<utility>中定义了一个辅助方法std:as_const(),该方法返回引用参数的const引用版本。as_const(obj)基本上等同于const_cast<const T&>(obj),其中,T的类型为obj。可以看到,与使用const_cast()相比,使用as_const()更简短。示例如下:

1
2
std::string str = "C++";
const std::string& constStr = std::as_const(str);

as_const()auto一起使用时要保持警惕。auto将去除引用和const限定符!因此,下面的result变量具有类型std::string而非const std:string&

1
auto result = std::as_const(str);

static_cast()

可使用static_cast()显式地执行C++语言直接支持的转换。例如,如果编写了一个算术表达式,其中需要将int转换为double以避免整除,可以使用static_cast()

如果用户定义了相关的构造函数或转换例程,也可使用static_cast()执行显式转换。例如,如果类A的构造函数将类B的对象作为参数,就可使用static_cast()将B对象转换为A对象。许多情况下都需要这一行为,然而编译器会自动执行这个转换。

static_cast()的另一种用法是在继承层次结构中执行向下转换。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Base {
public:
virtual ~Base() = default;
};

class Derived : public Base {
public:
virtual ~Derived() = default;
};

int main() {
Base* b;
Derived* d = new Derived();
b = d; // Don't need a cast to go up the inheritance hierarchy
d = static_cast<Derived*>(b); // Need a cast to go down the hierarchy

Base base;
Derived derived;
Base& br = derived;
Derived& dr = static_cast<Derived&> (br) ;
return 0;
}

这种类型转换可以用于指针和引用,而不适用于对象本身。

注意static_cast()类型转换不执行运行期间的类型检测。它允许将任何Base指针转换为Derived指针,或将Base引用转换为Derived引用,哪怕在运行时Base对象实际上并不是Derived对象,也是如此。例如,下面的代码可以编译并执行,但使用指针d可能导致灾难性结果,包括内存重写超出对象的边界。

1
2
Base* b = new Base() ;
Derived* d = static_cast<Derived*>(b);

使用static_cast()无法将某种类型的指针转换为不相关的其他类型的指针。如果没有可用的转换构造函数,static_cast()无法将某种类型的对象直接转换为另一种类型的对象。

reinterpret_cast()

reinterpret_cast()的功能比static_cast()更强大,同时安全性更差。这种用法经常用于将指针转换为void*;这可隐式完成,不需要进行显式转换。但将void*转换为正确类型的指针需要reinterpret_cast()void*指针指向内存的某个位置。void*指针没有相关的类型信息。下面是一些示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class X {};
class Y {};

int main() {
X x;
Y y;
X* xp = &x;
Y* yp = &y;
// Need reinterpret cast for pointer conversion from unrelated classes
// static_cast doesn't work.
xp = reinterpret_cast<X*>(yp);
// No cast required for conversion from pointer to void*
void* p = xp;
// Need reinterpret cast for pointer conversion from void*
xp = reinterpret_cast<X*>(p) ;
// Need reinterpret cast for reference conversion from unrelated classes
// static_cast doesn't work.
X& xr = x;
Y& yr = reinterpret_cast<Y&>(x);
return 0;
}

reinterpret_cast()的一种用法是与普通可复制类型的二进制I/O起使用。所谓普通可复制类型,是指构成对象的基础字节的类型可复制到数组中。如果此后要将数组的数据复制回对象,对象将保持其原始值。例如,可将这种类型的单独字节写入文件中。将文件读入内存时,可使用reinterpret_cast()来正确地解释从文件读入的字节。

dynamic_cast()

dynamic_cast()为继承层次结构内的类型转换提供运行时检测。可用它转换指针或引用。dynamic_cast()在运行时检测底层对象的类型信息。如果类型转换没有意义,dynamic_cast()将返回一个空指针(用于指针)或抛出一个stl::bad_cast异常(用于引用)。例如,下面用于引用的dynamic_cast()将拋出一个异常:

1
2
3
4
5
6
7
8
Base base;
Derived derived;
Base& br = base;
try {
Derived& dr = dynamic_cast<Derived&>(br);
} catch (const bad_cast&) {
cout << "Bad cast!" << endl;
}

注意可使用static_cast()reinterpret_cast()沿着继承层次结构向下执行同样的类型转换。dynamic_cast()的不同之处在于它会执行运行时(动态)类型检测,而static_cast()reinterpret_cast()甚至会执行不正确的类型转换。因此,为使用dynamic_cast(),类至少要有一个虚方法。如果类不具有虚表,尝试使用dynamic_cast()将导致编译错误。

类型转换总结

表11-1总结了不同情形下应该使用的类型转换。

作用域解析

可使用名称空间、函数定义、花括号界定的块和类定义创建作用域。在一个for循环的初始化语句中,初始化的变量的作用域仅限于这个for循环,在这个for循环之外不可见。当试图访问某个变量、函数或类时,首先在最近的作用域内查找这个名称,然后查找相邻的作用域,以此类推,直到全局作用域。任何不在名称空间、函数、花括号界定的块和类中的名称都被认为在全局作用域内。如果在全局作用域内也找不到这个名称,编译器会给出未定义符号错误。

有时某个作用域内的名称会隐藏其他作用域内的同一名称。在另一些情况下,程序的特定行中的默认作用域解析并不包含需要的作用域。如果不想用默认的作用域解析某个名称,就可以使用作用域解析运算符::和特定的作用域限定这个名称。例如,为访问类的静态方法,第一种方法是将类名(方法的作用域)和作用域解析运算符放在方法名的前面,第二种方法是通过类的对象访问这个静态方法。下例演示了这两种方法。这个示例定义了一个具有静态方法get()的Demo类、一个具有全局作用域的get()函数以及一个位于NS名称空间的get()函数。

1
2
3
4
5
6
7
8
9
10
class Demo {
public:
static int get() { return 5; }
};

int get() { return 10; }

namespace NS {
int get() { return 20; }
}

全局作用域没有名称,但可使用作用域解析运算符本身(没有名称前缀)来访问。可采用以下方式调用不同的get()函数。在这个示例中,代码本身在main()函数中,main()函数总是位于全局作用域内:

1
2
3
4
5
6
7
8
9
10
11
int main() {
auto pd = std::make_unique<Demo>();
Demo d;
std::cout << pd->get() << std::endl; // prints 5
std::cout << d.get() << std::endl; // prints 5
std::cout << NS::get() << std::endl; // prints 20
std::cout << Demo::get() << std::endl; // prints 5
std::cout << ::get() << std::endl; // prints 10
std::cout << get() << std::endl; // prints 10
return 0;
}

注意,如果NS名称空间是一个匿名名称空间,下面的行将导致名称解析歧义错误,因为在全局作用域内定义了一个get()函数,在匿名名称空间中也定义了一个get()函数。

1
std::cout << get() << std::endl;

如果在main()函数之前使用using子句,也会发生同样的错误:

1
using namespace NS;

特性

特性(ttribute)是在源代码中添加可选信息(或者供应商指定的信息)的一种机制。 在C++11之前,供应商决定如何指定这些信息,例如__atribute____declspec等。自C++11以后,使用两个方括号语法[[attribute]]支持特性。C++标准只定义了6个标准特性。

[[noreturn]]特性

[[noreturm]]意味着函数永远不会将控制交还调用点。典型情况是函数导致某种终止(进程终止或线程终止)或者抛出异常。使用该特性,编译器可避免给出某种警告或错误,因为它现在对函数的意图了解更多。下面是
一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[[noreturn]] void forceProgramTermination() {
std::exit(1);
}

bool isDongleAvailable () {
bool isAvailable = false;
// Check whether a licensing dongle is available...
return isAvailable;
}

bool isFeatureLicensed(int featureId) {
if (!isDongleAvailable())
// No licensing dongle found, abort program execution!
forceProgramTermination() ;
} else
bool isLicensed = false;
// Dongle available, perform license check of the given feature...
}
return isLicensed;
}

这个代码片段可正常编译,不会发出任何警告或错误。但如果删除[[noretum]]特性,编译器将生成以下警告消息:

1
warning C4715: ' isFeatureLicensed': not all control paths return a value

[[deprecated]]特性

[[deprecated]]特性可用于把某个对象标记为废弃,表示仍可以使用,但不鼓励使用。这个特性接收一个可选参数,可用于解释废弃的原因,例如:

1
[[deprecated ("Unsafe method, please use xyz")]] void func();

如果使用这个特性,将看到编译错误或警告。例如,GCC会给出以下警告消息:

1
warning: 'void func()' is deprecated: Unsafe method, please use xyz

[[allthrough]]特性

从C++17开始,可使用[[allthrough]]特性告诉编译器:在switch语句中,fall through是有意安排的。如果没有指定该特性,用以说明这是有意为之的,编译器将给出警告消息。不需要为空的case分支指定这个特性。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
switch (backgroundColor) {
case Color::DarkBlue:
doSomethingForDarBlue();
[[fallthrough]];
case Color::Black:
// Code is executed for both a dar blue or black background color
doSomethingForBlackOrDarkBlue();
break;
case Color::Red:
case Color::Green:
// Code to execute for a red or green background color
break;
}

[[nodiscard]]特性

[[nodiscard]]特性可用于返回值的函数,如果函数什么也没做,还返回值,编译器将发出警告消息。下面是一个示例:

1
2
3
4
5
6
7
8
[[nodiscard]] int func() {
return 42;
}

int main() {
func();
return 0;
}

编译器将给出如下警告消息:

1
warning C4834: discarding return value of function with 'nodiscard' attribute

例如,可将这个特性用于返回错误代码的函数。通过给此类函数添加[[nodiscard]]特性,将无法忽略错误代码。

[[maybe_unused]]特性

如果未使用某项,[[maybe_unused]]特性可用于阻止编译器发出警告消息:

1
2
3
int func (int param1, [[maybe_unused]] int param2) {
return 42;
}

这里给第二个参数标记了[[maybe__unused]]特性。编译器将只为param1显示警告消息:

1
warning C4100: 'param1': unreferenced formal parameter

用户定义的字面量

C++有许多可在代码中使用的标准字面量(iteral),如下所示。

  • ‘a’: 字符
  • “character array”:以0结尾的字符数组(C风格的字符串)
  • 3.14f: 浮点数
  • 0xabc: 十六进制值

C++11允许定义自己的字面量。用户定义的字面量应该以下划线开头,下划线之后的第一个字符必须小写,例如_i_s_kmmiles等。可通过编写字面量运算符(literal operators)来实现。 字面量运算符能以生(raw)模式或熟(cooked)模式运行。在生模式中,字面量运算符接收一个字符序列;在熟模式中,字面量运算符接收一种经过解释的特定类型。例如,考虑C++字面量123。生模式字面量运算符会将其作为字符1 23,而熟模式字面量运算符会将其作为整数123

另一个示例:考虑C++字面量0x23。生模式字面量运算符将接收字符0x23,而熟模式字面量运算符将接收整数35

最后一个示例: 考虑C++字面量3.14,生模式字面量运算符将接收字符3.14,而熟模式字面量运算符将接收浮点值3.14

熟模式字面量运算符应该具有:

  • 一个unsigned long long、long double、char、wchar_t、char16_t或char32_t类型的参数,用来处理数值。
  • 或者两个参数,第一个参数是字符数组,第二个参数是字符数组的长度,用来处理字符串,例如const char* strsize_t len

下例使用熟模式字面量运算符实现了用户定义的字面量_i_i用来定义一个复数字面量。

1
2
3
std::complex<long double> operator"" _i (long double d) {
return std::complex<long double>(0, d) ;
}

_i字面量可这样使用:

1
2
3
std::complex<long double> c1 = 9.634_i;
auto c2 = 1.23_i;
// c2 has as type std::complex<long double>

另一个示例用熟模式字面量运算符实现了用户定义的字面量s,用于定义std::string字面量:

1
2
3
std::string operator""_s(const char* str, size_t len) {
return std::string(str, len);
}

这一字面量可这样使用:

1
2
3
std::string str1 = "Hello World"_s;
auto str2 = "Hello World"_s;
// str2 has as type std::string

如果没有_s字面量,自动推导的类型将是const char*

1
2
auto str3 = "Hello World";
// str has as type const char*

生模式字面量运算符需要一个const char*类型的参数,这是一个以0结尾的C风格字符串。下面的示例定义了字面量_i,此时使用的是生模式字面量运算符:

1
2
3
std::complex<long double> operator""_i (const char* p)
// Implementation omitted; it requires parsing the C-style
// string and converting it to a complex number

生模式字面量运算符的用法与熟模式字面量运算符的用法相同。

标准的用户定义字面量

C++定义了如下标准的用户定义字面量。注意,这些标准的用户定义字面量并非以下画线开头:

  • “s”用于创建std::string
    • 例如: auto myString = "Hello World"s;
    • 需要using namespace std::string_literals;
  • “sv”用于创建std::string_views
    • 例如:auto myStringView = "Hello world"sv;
    • 需要using namespace std::string_view_literals;
  • “h” “min” “s” “ms” “us” “ns”用于创建stl::chrono::duration时间段
    • 例如:auto myDuration = 42min;
    • 需要using namespace std::chrono_literals;
  • “i”、“il”、“if”分别用于创建复数complex<double>complex<long double>complex<float>
    • 例如:auto myComplexNumber = 1.3i;
    • 需要using namespace std::complex_literals;

头文件

头文件是为子系统或代码段提供抽象接口的一种机制。使用头文件需要注意的一点是:要避免循环引用或多次包含同一个头文件。可使用文件保护机制(include guards)来避免重复定义。在每个头文件的开头,用#ifndef指令检测是否还没有定义某个键值。如果这个键值已经定义,编译器
将跳到对应的#endif,这个指令通常位于文件的结尾。

1
2
3
4
5
6
#ifndef LOGGER_H
#define LOGGER_H
class Logger {
// ...
};
#endif // LOGGER_H

如今,几乎所有编译器都支持#pragma once指令(该指令可替代前面的文件保护机制)。例如:

1
2
3
#pragma once
class Logger {
};

前置声明是另一个避免产生头文件问题的工具。如果需要使用某个类,但是无法包含它的头文件,就可告诉编译器存在这么一个类,但是无法使用#include机制提供正式的定义,可在代码中使用这个类的指针或引用。也可声明函数,使其按值返回这种前置声明类,或将这种前置声明类作为按值传递的函数参数。当然,定义函数的代码以及调用函数的任何代码都需要添加正确的头文件,在头文件中要正确定义前置声明类。

建议尽可能在头文件中使用前置声明,而不是包含其他头文件。这可减少编译和重编译时间,因为破坏了一个头文件对其他头文件的依赖。当然,实现文件需要包含前置声明类的正确头文件,否则就不能编译。

为了查询是否存在某个头文件,C++17添加了__has_include("flename")__has_include(<filename>)预处理器常量。如果头文件存在,这些常量的结果就是1;如果头文件不存在,常量的结果就是0。

1
2
3
4
5
#if __has_include(<optional>)
#include <optional>
#elif __has_include(<experimental/optional>)
#include <experimental/optional>
#endif

C的实用工具

变长参数列表

C/C++可以编写参数数目可变的自定义函数。例如,假定要编写一个快速调试函数,这个函数应能接收任意数目和类型的参数并输出字符串。

1
2
3
4
5
6
7
8
#include <cstdio>
#include <cstdarg>
void debugOut(const char* str, ...) {
va_list ap
va_start(ap, str) ;
vfprintf(stderr, str, ap);
va_end(ap);
}

首先,注意debugOut()函数的原型包含一个具有类型和名称的参数str,之后...代表任意数目和类型的参数。声明一个va_list类型的变量,并调用va_start()来初始化它。va_start()的第二个参数必须是参数列表中最右边的已命名变量。所有具有变长参数列表的函数都至少应该有一个已命名参数。当vfprintf()返回时,debugOut()调用va_end()来终止对变长参数列表的访问。在调用va_start()之后必须调用va_end(),以确保函数结束后,堆栈处于稳定状态。

访问参数

如果要自行访问实参,可使用va_arg();它的第一个实参是va_list,接收要截获的实参类型。遗憾的是,如果不提供显式的方法,就无法知道参数列表的结尾是什么。例如,可以让第一个参数计算参数的数目,或者当参数是一组指针时,可以要求最后一个指针是nullptr。

下例演示了这种技术,其中调用者在第一个已命名参数中指定了所提供参数的数目。函数接收任意数目的int参数,并将其输出。

1
2
3
4
5
6
7
8
9
10
void printInts(size_t num, ...) {
int temp;
va_list ap
va_start(ap, num) ;
for (size_t i = 0; i < num; ++i) {
temp = va_arg(ap, int);
}
va_end(ap);
cout << endl;
}

访问C风格的变长参数列表并不十分安全,这种方法存在以下风险:

  • 不知道参数的数目。
  • 不知道参数的类型。va_arg()接收一种类型,用来解释当前的值。然而,可让va_arg()将这个值解释为任意类型,无法验证正确的类型。

预处理器宏

可使用C++预处理器编写宏,这与函数有点相似。下面是一个示例:

1
2
#define SQUARE(x) ((x) * (x)) // No semicolon after the macro definition!
int main() {cout << SQUARE(5) << endl;}

宏是C遗留下来的特性,非常类似于内联函数,但不执行类型检测。在调用宏时,预处理器会自动用扩展式替换。预处理器并不会真正地应用函数调用语义,这一行为可能导致无法预测的结果。

宏还会影响性能,假定按如下方式调用SQUARE宏:

1
cout << SQUARE (veryExpensiveFunctionCallToComputeNumber()) << endl;

预处理器把它替换为:

1
cout << ( (veryExpensiveFunctionCallToComputeNumber()) * (veryExpensiveFunctionCallToComputeNumber())) << endl;

现在,这个开销很大的函数调用了两次。这是避免使用宏的另一个原因。

宏还会导致调试问题,因为编写的代码并非编译器看到的代码或者调试工具中显示的代码(因为预处理器的查找和替换功能)。为此,应该全部用内联函数替代宏。

利用模板编写泛型代码

类模板

类模板定义了一个类,其中,将一些变量的类型、方法的返回类型和或方法的参数类型指定为参数。

编写类模板

最好编写一个通用的Grid类,该类可用于存储多种类型,编写类模板可避免编写需要指定一种或多种类型的类。客户通过指定要使用的类型对模板进行实例化。这称为泛型编程,其最大的优点是类型安全。

下例展示了如何得到模板化的Grid类。这里选用不带多态性的值语义来实现这个解决方案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template <typename T>
class Grid {
public:
explicit Grid(size_t width = kDefaultWidth, size_t height = kDefaultHeight);
virtual ~Grid() = default;
// Explicitly default a copy constructor and assignment operator.
Grid(const Grid& src) = default;
Grid<T>& operator=(const Grid& rhs) = default;
// Explicitly default a move constructor and assignment operator.
Grid(Grid&& src) = default;
Grid<T>& operator=(Grid&& rhs) = default;

std::optional<T>& at(size_t x,size_t y) ;
const std::optional<T>& at(size_t x,size_t y) const;
size_t getHeight() const { return mHeight; }
size_t getWidth() const { return mWidth; }
static const size_t kDefaultWidth = 10;
static const size_t kDefaultHeight = 10;
private:
void verifyCoordinate(size_t x, size_t y) const;
std::vector<std::vector<std::optional<T>>> mCells;
size_t mWidth, mHeight;
};

template <typename T>:第一行表示,下面的类定义是基于模板。templatetypename都是C++中的关键字。在模板中使用模板参数名称(例如T)表示调用者要指定的类型。基于历史原因,指定模板类型参数时,可用关键字class替代typename

在Grid类中,mCells是可选值的矢量的矢量,所以编译器生成的复制构造函数和赋值运算符可以运行得很好。一旦有了用户声明的析构函数,建议不要使用编译器隐式生成复制构造函数或赋值运算符,因此Grid类模板将其显式设置为默认,并且将移动构造函数和赋值运算符显式设置为默认。下面将复制赋值运算符显式设置为默认:

1
Grid<T>& operator=(const Grid& rhs) = default;

从中可以看出,rths参数的类型是const Grid&,还可将其指定为const Grid<T>&。在类定义中,编译器根据需要将Grid解释为Grid<T>。但在类定义之外,需要使用Grid<T>。在编写类模板时,以前的类名(Grid)现在实际上是模板名称。at()方法现在返回optional<T>&const optional<T>&,而不是返回unique_ptr

1
2
std::optional<T>& at(size_t x, size_t y);
const std::optional<T>& at(size_t x, size_t y) const;

Grid类的方法定义

template <typename T>访问说明符必须在Grid模板的每一个方法定义的前面。构造函数如下所示:

1
2
3
4
5
6
template <typename T>
Grid<T>::Grid(size_t width, size_t height) : mWidth(width), mHeight(height) {
mCells.resize(mWidth);
for (auto& column : mCells)
column.resize(mHeight);
}

模板要求将方法的实现也放在头文件中,因为编译器在创建模板的实例之前,需要知道完整的定义,包括方法的定义。

注意::之前的类名是Grid<T>。必须在所有的方法和静态数据成员定义中将Grid<T>指定为类名:

1
2
3
4
5
template <typename T>
void Grid<T>::verifyCoordinate(size_t x, size_t y) const {
if (x >= mWidth || y >= mHeight) {
throw std::out_of_range("");
}

如果类模板方法的实现需要特定模板类型参数(例如T)的默认值,可使用T()语法。如果T是类类型,T()调用对象的默认构造函数,或者如果T是简单类型,则生成0。这称为“初始化为0”语法。最好为类型尚不确定的变量提供合理的默认值。

使用Grid模板

创建网格对象时,不能单独使用Grid作为类型;必须指定这个网格保存的元素类型。为某种类型创建一个模板类对象的过程称为模板的实例化

1
2
3
4
5
6
7
8
9
Grid<int> myIntGrid; // declares a grid that stores ints,
Grid<double> myDoubleGrid(11, 11); // declares an 11x11 Grid of doubles
myIntGrid.at(0, 0) = 10;
int x = myIntGrid.at(0, 0).value_or(0);

Grid<int> grid2 (myIntGrid); // Copy constructor
Grid<int> anotherIntGrid;
anotherIntGrid = grid2;
// Assignment operator

这里使用了value_or()at()方法返回stl::optional引用。optional可包含值,也可不包含值。如果optional包含值,value_or()方法返回这个值;否则返回给value_or()提供的实参。

可在堆上动态分配Grid模板实例:

1
2
3
auto myGridOnHeap = make_unique<Grid<int>>(2, 2); // 2x2 Grid on the heap
myGridOnHeap->at(0, 0) = 10;
int x = myGridOnHeap->at(0, 0).value_or(0);

尖括号

本书的一些示例使用带双尖括号的模板,例如:

1
std::vector<std::vector<T>> mCells;

自C++11以来,上述语法都是正确的。但在C++11之前,双尖括号>>只表示>>运算符。根据所涉及的类型,这个>>运算符可以是右移位运算符或流提取运算符。这与模板代码相左,因为必须在双尖括号之间放置一个空格。前面的声明可以写为:

1
std::vector<std::vector<T> > mCells;

编译器处理模板的原理

编译器遇到模板方法定义时,会进行语法检查,但是并不编译模板。编译器无法编译模板定义,因为它不知道要使用什么类型。编译器遇到一个实例化的模板时,例如Grid<int> myIntGrid,就会将模板类定义中的每一个T替换为int,从而生成Grid模板的int版本代码。编译器生成代码的方式就好像语言不支持模板时程序员编写代码的方式:为每种元素类型编写一个不同的类。

选择性实例化

编译器总为泛型类的所有虚方法生成代码。但对于非虚方法,编译器只会为那些实际为某种类型调用的非虚方法生成代码。例如,给定前面定义的Grid模板类,假设在main()中编写这段代码:

1
2
Grid<int> myIntGrid;
myIntGrid.at(0, 0) = 10;

编译器只会为int版本的Grid类生成无参构造函数、析构函数和非常量at()方法的代码,不会为其他方法生成代码,例如复制构造函数、赋值运算符或getHeight()

模板对类型的要求

编写与类型无关的代码时,肯定对这些类型有一些假设。如果在程序中试图用一种不支持模板使用的所有操作的类型对模板进行实例化,那么这段代码无法编译,而且错误消息几乎总是晦涩难懂。然而,就算要使用的类型不支持所有模板代码所需的操作,也仍然可以利用选择性实例化使用某些方法,而避免使用另一些方法。

将模板代码分布在多个文件中

在任何使用了模板的源代码文件中,编译器都应该能同时访问模板类定义和方法定义。

将模板定义放在头文件中

方法定义可与类定义直接放在同一个头文件中。当使用了这个模板的源文件通过include包含这个文件时,编译器就能访问需要的所有代码。该机制用于前面的Grid实现。此外,还可将模板方法定义放在另一个头文件中,然后在类定义的头文件中通过#include包含这个头文件。一定要保证方法定义的#include在类定义之后,否则代码无法编译。例如:

1
2
3
4
template <typename T>
class Grid { };

#include "GridDefinitions.h"

任何需要使用Grid模板的客户只需要包含Grid.h头文件即可。这种分离方式有助于分开类定义和方法定义。

将模板定义放在源文件中

将方法实现放在头文件中看上去很奇怪。如果不喜欢这种语法,可将方法定义放在一个源代码文件中。然而,仍然需要让使用模板的代码能访问到定义,因此可在模板类定义头文件中通过#include包含类方法实现的源文件。尽管如果之前没有看过这种方式,会感到有点奇怪,但是这在C++中是合法的。头文件如下所示:

1
2
3
4
5
template <typename T>
class Grid {
};
// Class definition omitted for brevity
#include "Grid.cpp"

使用这种技术时,一定不要把Grid.cpp文件添加到项目中,因为这个文件本不应在项目中,而且无法单独编译;这个文件只能通过#include包含在一个头文件中。

实际上,可任意命名包含方法实现的文件。有些程序员喜欢给包含的源代码文件添加.inl后缀,例如Grid.inl。

限制模板类的实例化

如果希望模板类仅用于某些已知的类型,就可使用下面的技术。

1
2
3
4
5
6
7
8
#include "Grid.h"
#include <utility>
template <typename T>
Grid<T>::Grid(size_t width, size_t height) : mWidth(width), mHeight (height) {
mCells.resize(mWidth);
for (auto& column : mCells)
column.resize(mHeight);
}

为使这个方法能运行,需要给允许客户使用的类型显式实例化模板。这个文件的末尾应如下所示:

1
2
3
4
// Explicit instantiations for the types you want to allow.
template class Grid<int>;
template class Grid<double>;
template class Grid<std::vector<int>>;

有了这些显式的实例化,就不允许客户代码给其他类型使用Grid类模板。

使用显式类模板实例化,无论是否调用方法,编译器都会为类模板的所有方法生成代码。

模板参数

template <typename T>这个参数列表类似于函数或方法中的参数列表。与函数或方法一样,可使用任意多个模板参数来编写类。此外,这些参数未必是类型,而且可以有默认值。

非类型的模板参数

非类型的模板参数只能是整数类型(char、int、 long 等)、枚举类型、指针、引用和std:nullptr_t。从C++17开始,可指定autoauto&auto*等作为非类型模板参数的类型。此时,编译器会自动推导类型。在模板列表中指定非类型参数而不是在构造函数中指定的主要好处是:在编译代码之前就知道这些参数的值了。下面是新的类定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template <typename T, size_t WIDTH, size_t HEIGHT>
class Grid {
public:
Grid() = default;
virtual ~Grid() = default;
// Explicitly default a copy constructor and assignment operator.
Grid(const Grid& src) = default;
Grid<T, WIDTH, HEIGHT>& operator= (const Grid& rhs) = default;
std::optional<T>& at(size_t x, size_t y);
const std::optional<T>& at (size_t x, size_t y) const;
size_t getHeight() const { return HEIGHT; }
size_t getwidth() const { return WIDTH; }
private:
void verifyCoordinate(size_t X, size_t y) const;
std::optional<T> mCells [WIDTH] [HEIGHT];
};

这个类没有显式地将移动构造函数和移动赋值运算符设置为默认,原因是C风格的数组不支持移动语义。注意,模板参数列表需要3个参数:网格中保存的对象类型以及网格的宽度和高度。宽度和高度用于创建保存对象的二维数组。下面是类方法定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template <typename T, size_t WIDTH, size_t HEIGHT>
void Grid<T, WIDTH, HEIGHT>::verifyCoordinate(size_t x, size_t y) const {
if (x >= WIDTH || y >= HEIGHT) {
throw std::out_of_range ("") ;
}
}

template <typename T, size_t WIDTH, size_t HEIGHT>
const std::optional<T>& Grid<T, WIDTH, HEIGHT>::at(size_t x, size_t y) const {
verifyCoordinate(x, y);
return mCells[x] [y];
}

template <typename T, size_t WIDTH, size_t HEIGHT>
std::optional<T>& Grid<T, WIDTH, HEIGHT>::at(size_t x, size_t y) {
return const_cast<std::optional<T>&> (std::as_const(*this).at(x, y));
}

注意之前所有指定Grid<T>的地方,现在都必须指定Grid<T, WIDTH, HEIGHT>来表示这3个模板参数。可通过以下方式实例化这个模板:

1
2
3
4
5
Grid<int, 10, 10> myGrid;
Grid<int, 10, 10> anotherGrid;
myGrid.at(2, 3) = 42;
anotherGrid = myGrid;
cout << anotherGrid.at(2, 3).value_or(0);

这段代码看上去很棒。遗憾的是,实际中的限制比想象中的要多。首先,不能通过非常量的整数指定高度或宽度。下面的代码无法编译:

1
2
size_t height = 10;
Grid<int, 10, height> testGrid; // DOES NOT COMPILE

然而,如果把height声明为const,这段代码就可以编译了:

1
2
const size_t height = 10;
Grid<int, 10, height> testGrid; // Compiles and works

带有正确返回类型的constexpr函数也可以编译。例如,如果有一个返回size_t的constexpr函数,就可以使用它初始化height模板参数:

1
2
constexpr size_t getHeight() { return 10; }
Grid<double2getHeight()> myDoubleGrid;

另一个限制可能更明显。既然宽度和高度都是模板参数,那么它们也是每种网格类型的一部分。这意味着Grid<int, 10, 10>Grid<int, 10, 11>是两种不同类型。不能将一种类型的对象赋给另一种类型的对象,而且一种类型的变量不能传递给接收另一种类型的变量的函数或方法。

非类型模板参数是实例化的对象的类型规范中的一部分。

类型参数的默认值

如果继续采用将高度和宽度作为模板参数的方式,就可能需要为高度和宽度(它们是非类型模板参数)提供默认值,就像之前Grid<T>类的构造函数一样。C++允许使用类似的语法向模板参数提供默认值。在这里也可
以给T类型参数提供默认值。下面是类定义:

1
2
3
4
template <typename T = int, size_t WIDTH = 10, size_t HEIGHT = 10>
class Grid
// Remainder is identical to the previous version .
};

不需要在方法定义的模板规范中指定T、WIDTH和HEIGHT的默认值。例如,下面是at()方法的实现:

1
2
3
4
5
template <typename T, size_t WIDTH, size_t HEIGHT>
const std::optional<T>& Grid<T, WIDTH, HEIGHT>::at(size_t x, size_t y) const {
verifyCoordinate(x, y);
return mCells[x][y];
}

现在,实例化Grid时,可不指定模板参数,只指定元素类型,或者指定元素类型和宽度,或者指定元素类型、宽度和高度:

1
2
3
4
Grid<> myIntGrid;
Grid<int> myGrid;
Grid<int, 5> anotherGrid;
Grid<int, 5, 5> aFourthGrid;

构造函数的模板参数推导

C++17添加了一些功能,支持通过传递给类模板构造函数的实参自动推导模板参数。在C++17之前,必须显式地为类模板指定所有模板参数。函数模板始终支持基于传递给函数模板的实参自动推导模板参数。因此,make_pair()能根据传递给它的值自动推导模板类型参数。例如,编译器为以下调用推导pair<int, double>

1
auto pair2 = std::make_pair(1, 2.3);

在C++17中,不再需要这样的辅助函数模板。现在,编译器可以根据传递给构造函数的实参自动推导模板类型参数。对于pair类模板,只需要编写以下代码:

1
std::pair pair3(1, 2.3);

当然,推导的前提是类模板的所有模板参数要么有默认值,要么用作构造函数中的参数。

用户定义的推导原则

也可编写自己的推导原则,即用户定义的推导原则。这允许你编写如何推导模板参数的规则。这是一个高级主题,这里不对其进行详细讨论,但会举一个例子来演示其功能。假设具有以下SpreadsheetCell类模板:

1
2
3
4
5
6
7
8
template<typename T>
class SpreadsheetCell {
public:
SpreadsheetCell (const T& t) : mContent(t) { }
const T& getContent() const { return mContent; }
private:
T mContent;
};

通过自动推导模板参数,可使用std::string类型创建SpreadsheetCell:

1
2
std::string myString = "Hello world!";
SpreadsheetCell cell (myString) ;

但是,如果给SpreadsheetCell构造函数传递const char*,那么会将类型T推导为const char*,这不是需要的结果。可创建以下用户定义的推导原则,在将const char*作为实参传递给构造函数时,将T推导为std::string

1
SpreadsheetCell (const char*) -> SpreadsheetCell<std::string>;

方法模板

C++允许模板化类中的单个方法。这些方法可以在类模板中,也可以在非模板化的类中。但是不能用方法模板编写虚方法和析构函数。

在Grid类中添加模板化的复制构造函数和赋值运算符,可生成将一种网格类型转换为另一种网格类型的方法。下面是新的Grid类定义:

1
2
3
4
5
6
7
8
9
10
11
template <typename T>
class Grid {
public:
template < typename E>
Grid(const Grid<E>& src);

template <typename E>
Grid<T>& operator= (const Grid<E>& rhs);

void swap (Grid& other) noexcept;
};

首先检查新的模板化的复制构造函数:

1
Grid(const Grid<E>& src);

可看到另一个具有不同类型名称E(Element的简写)的模板声明。这个类在类型T上被模板化,这个新的复制构造函数又在另一个不同的类型E上被模板化。通过这种双重模板化可将一种类型的网格复制到另一种类型
的网格。下面是新的复制构造函数的定义:

1
2
3
4
5
6
7
template <typename T>
template <typename E>
Grid<T>::Grid(const Grid<E>& src) : Grid (src.getwidth(), src.getHeight()) {
for (size_t i = 0; i < mWidth; i++)
for (size_t j = 0; j < mHeight; j++)
mCells[i][j] = src.at(i, j);
}

可以看出,必须将声明类模板的那一行(带有T参数)放在成员模板的那一行声明(带有E参数)的前面。不能像下面这样合并两者:

1
2
template <typename T, typename E> // Wrong for nested template constructor!
Grid<T>::Grid(const Grid<E>& src)

除了构造函数定义之前的额外模板参数行之外,注意必须通过公共的访问方法getWidth()getHeight()at()访问src中的元素。这是因为复制目标对象的类型为Grid<T>,而复制来源对象的类型为Grid<E>。 这两者不是同一类型,因此必须使用公共方法。

模板化的赋值运算符接收const Grid<E>&作为参数,但返回Grid<T>&

1
2
3
4
5
6
7
template <typename T>
template <typename E>
Grid<T>& Grid<T>::operator= (const Grid<E>& rhs) {
Grid<T> temp(rhs); // Do all the work in a temporary instance
swap(temp) ; // Commit the work with only non-throwing operations
return *this;
}

带有非类型参数的方法模板

有了赋值运算符和复制构造函数的方法模板后,完全可实现对不同大小的网格进行赋值和复制。下面是类定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template <typename T, size_t WIDTH = 10, size_t HEIGHT = 10>
class Grid {
public:
Grid(const Grid& src) = default;
Grid<T, WIDTH, HEIGHT>& operator=(const Grid& rhs) = default;
template <typename E, size_t WIDTH2, size_t HEIGHT2>
Grid(const Grid<E, WIDTH2, HEIGHT2>& src) ;
template <typename E, size_t WIDTH2, size_t HEIGHT2>
Grid<T, WIDTH, HEIGHT>& operator= (const Grid<E, WIDTH2, HEIGHT2>& rhs);

void swap (Grid& other) noexcept;
std::optional<T>& at(size_t x, size_t y);
const std::optional<T>& at(size_t x, size_t y) const;
size_t getHeight() const { return HEIGHT; }
size_t getwidth() const { return WIDTH; }
private:
void verifyCoordinate(size_t x, size_t y) const; .
std::optional<T> mCells [WIDTH] [HEIGHT];
};

这个新定义包含复制构造函数和赋值运算符的方法模板,还包含辅助方法swap()。注意,将非模板化的复制构造函数和赋值运算符显式设置为默认(原因在于用户声明的析构函数)。这些方法只是将mCells从源对象复制或赋值到目标对象,语义和两个一样大小的网格的语义完全一致。

下面是模板化的复制构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
template <typename T, size_t WIDTH,  size_t HEIGHT>
template <typename E, size t WIDTH2, size t HEIGHT2>
Grid<T, WIDTH, HEIGHT>::Grid(const Grid<E, WIDTH2, HEIGHT2>& src) {
for (size_t i; i < WIDTH; i++) {
for (size_t j; j < HEIGHT; j ++) {
if(i < WIDTH2 && j < HEIGHT2 ) {
mCells[i][j] = src.at(i, j);
} else {
mcells[i][j].reset();
}
}
}
}

类模板的特例化

模板的另一个实现称为模板特例化(template specialization)。编写一个模板类特例化时,必须指明这是一个模板,以及正在为哪种特定的类型编写这个模板。下面是为const char*特例化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include "Grid.h"
class Grid<const char*> {
public:
explicit Grid(size_t width = kDefaultwidth, size_t height = kDefaultHeight);
virtual ~Grid() = default;
// Explicitly default a copy constructor and ass ignment operator.
Grid<const char*>& operator= (const Grid& rhs) = default;
// Explicitly default a move constructor and assignment operator.
Grid<const char*>& operator= (Grid&& rhs) = default;
std::optional<std::string>& at(size_t x, size_t y);
const std::optional<std::string>& at(size_t x, size_t y) const;
size_t getHeight() const { return mHeight; }
size_t getwidth() const { return mWidth;}
static const size_t kDefaultwidth = 10;
static const size_t kDefaultHeight = 10;
private:
void verifyCoordinate(size_t x, size_t y) const;
std::vector<std::vector<std::optional<std::string>>> mCells; .
size_t mWidth, mHeight;
};
1
2
template <>
class Grid<const char*>

上述语法告诉编译器,这个类是Grid类的const char*特例化版本。假设没有使用这种语法,而是尝试编写下面这样的代码:

1
class Grid

编译器不允许这样做,因为已经有一个名为Grid的类(原始的类模板)。只能通过特例化重用这个名称。特例化的主要好处就是可对用户隐藏。当用户创建int或SpreadsheetCell类型的Grid时,编译器从原始的Grid 模板生成代码。当用户创建const char*类型的Grid时,编译器会使用const char*的特例化版本。这些全部在后台自动完成。

下面是const char*特例化版本的方法的实现。与模板定义不同,不必在每个方法定义之前重复template<>语法:

1
2
3
4
5
6
Grid<const char*>::Grid(size_t width, size_t height) : mWidth(width), mHeight (height) {
mCells.resize (mWidth);
for (auto& column : mCells){
column.resize (mHeight) ;
}
}

从类模板派生

可从类模板派生。如果一个派生类从模板本身继承,那么这个派生类也必须是模板。此外,还可从类模板派生某个特定实例,这种情况下,这个派生类不需要是模板。下面针对前一种情况举一个例子:

1
2
3
4
5
6
template <typename T>
class GameBoard : public Grid<T> {
public:
explicit GameBoard(size_t width = Grid<T>::kDefaultwidth, size_t height = Grid<T>::kDefaultHeight);
void move(size_t xSrc, size_t ySrc, size_t xDest, size_t yDest);
};

继承的语法和普通继承一样,区别在于基类是Grid<T>,而不是Grid: public Grid<T>语法表明,这个类继承了Grid实例化对类型参数T有意义的所有内容。

下面是构造函数和move()方法的实现。同样,要注意调用基类构造函数时对Grid<T>的使用。此外,尽管很多编译器并没有强制使用this指针或Grid<T>::引用基类模板中的数据成员和方法,但名称查找规则要求使用this指针或Grid<T>::

1
2
3
4
5
6
7
8
9
10
11
template <typename T>
GameBoard<T>::GameBoard(size_t width, size_t height) : Grid<T> (width, height) { }

template <typename T>
void GameBoard<T>::move(size_t xSrc, size_t ySrc, size_t xDest, size_t yDest) {
Grid<T>::at(xDest, yDest) = std::move (Grid<T>::at(xSrc, ySrc));
Grid<T>::at(xSrc, ySrc).reset();
// or:
// this->at (xDest, yDest) = std::move (this->at(xSrc, ySrc));
// this->at (xSrC, ySrc).reset() ;
}

继承还是特例化

表12-1总结了两者的区别。

通过继承来扩展实现和使用多态。通过特例化自定义特定类型的实现。

模板别名

可使用类型别名给模板化的类赋予另一个名称。假定有如下类模板:

1
2
template<typename T1, typename T2>
class MyTemplateClass { };

可定义如下类型别名,给定两个模板类型参数:

1
using OtherName = MyTemplateClass<int, double>;

还可仅指定一些类型, 其他类型则保持为模板类型参数,这称为别名模板(alias template),例如:

1
2
template<typename T1>
using OtherName = MyTemplateClass<T1, double>;

函数模板

还可为独立函数编写模板。例如,可编写一个通用函数,该函数在数组中查找一个值并返回这个值的索引:

1
2
3
4
5
6
7
8
9
static const size_t NOT_FOUND = static_cast<size_t>(-1);
template <typename T>
size_t Find(const T& value, const T* arr, size_t size) {
for(size_t i = 0; i < size; i ++){
if (arr[i] == value)
return i; // Found it; return the index
}
return NOT_FOUND; // Failed to find it; return NOT_FOUND
}

可通过两种方式调用这个函数;一种是通过尖括号显式地指定类型;另一种是忽略类型,让编译器根据参数自动推断类型。下面列举一些例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int myInt = 3, intArray[] = {1, 2, 3, 4};
const size_t sizeIntArray = std::size(intArray);
size_t res;
res = Find(myInt,intArray, sizeIntArray); // calls Find<int> by deduction
res = Find<int>(myInt, intArray, sizeIntArray); // calls Find<int> explicitly
if (res != NOT_FOUND)
cout << res << endl;
else
cout << "Not found" << endl;
double myDouble = 5.6, doubleArray[] = {1.2, 3.4, 5.7, 7.5};
const size_t sizeDoubleArray = std::size(doubleArray);

// calls Find<double> by deduction
res = Find (myDouble, doubleArray, sizeDoubleArray);
// calls Find<double> explicitly
res = Find<double>(myDouble, doubleArray, sizeDoubleArray);
if (res != NOT_FOUND)
cout << res << endl;
else
cout << "Not found" << endl;
res = Find<double> (myInt, doubleArray, sizeDoubleArray);

SpreadsheetCe1l cell1(10), cellArray[] = {SpreadsheetCell(4), SpreadsheetCel1(10) };
const size_t sizeCellArray = std::size(cellArray);
res = Find(cell1, cellArray, sizeCellArray) ;
res = Find<Spreadsheetcell>(celll, cellArray, sizeCellArray) ;

前面Find()函数的实现需要把数组的大小作为一个参数。有时编译器知道数组的确切大小,例如,基于堆栈的数组。用这种数组调用Find()函数,就不需要传递数组的大小。为此,可添加如下函数模板。该实现仅把调用传递给前面的Find()函数模板。这也说明函数模板可接收非类型的参数,与类模板一样。

1
2
3
4
template <typename T, size_t N>
size_t Find(const T& value, const T(&arr) [N]) {
return Find(value, arr, N);
}

与类模板方法定义一样,函数模板定义(不仅是原型)必须能用于使用它们的所有源文件。因此,如果多个源文件使用函数模板,或使用本章前面讨论的显式实例化,就应把其定义放在头文件中。函数模板的模板参数可以有默认值,与类模板一样。

函数模板的特例化

就像类模板的特例化一样,函数模板也可特例化。

1
2
3
4
5
6
7
template<>
size_t Find<const char*> (const char* const& value, const char* const* arr, size_t size) {
for (size_t i = 0; i < size; i++)
if (strcmp(arr[i], value) == 0)
return i; // Found it; return the index
return NOT_FOUND; // Failed to find it; return NOT_FOUND
}

如果参数类型可通过参数推导出来,那么可在函数名中忽略<const char*>,将这个函数原型简化为:

1
2
template<>
size_t Find(const char* const& value, const char* const* arr, size_t size)

函数模板的重载

还可用非模板函数重载模板函数。例如,如果不编写用于const char*的Find()函数模板,那么需要编写一个非模板的独立Find()函数以直接操作const char*

1
2
3
4
5
6
size_t Find(const char* const& value, const char* const* arr, size_t size) {
for(size_t i = 0; i < size; i ++)
if (strcmp(arr[i], value) == 0)
return i;
return NOT_FOUND;
}

这个函数的调用规则有所不同:

1
2
3
4
5
6
const char* word = "two";
const char* words [] = {"two", "three", "four"};
const size_t sizeWords = std::size(words);
size_t res;

res = Find(word, words, sizeWords) ; // Calls non-template function!

因此,如果想要函数在显式指定了const char*时能正常工作,以及在没有指定时能通过自动类型推导正常工作,那么应该编写一个特例化的模板版本,而不是编写一个非模板的重载版本。

同时使用函数模板重载和特例化

可同时编写一个适用于const char*的特例化Find()函数模板,以及一个适用于const char*的独立Find()函数。编译器总是优先选择非模板化的函数,而不是选择模板化的版本。然而,如果显式地指定模板的实例化,那么会强制编译器使用模板化的版本。

类模板的友元函数模板

如果需要在类模板中重载运算符,函数模板会非常有用。假定operator+是一个独立的函数模板,其定义应该直接放在Grid.h中,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template <typename T>
Grid<T> operator+ (const Grid<T>& lhs,const Grid<T>& rhs) {
size_t minwidth = std::min (lhs.getwidth(), rhs.getwidth());
size_t minHeight = std::min(lhs.getHeight(), rhs.getHeight());
Grid<T> result (minWidth, minHeight);

for (size_t y = 0; y < minHeight; ++y) {
for (size_t x = 0; x < minWidth; ++x) {

const auto& leftElement = lhs.mCells[x][y];
const auto& rightElement = rhs.mCells[x][y];
if(leftElement.has_value() && rightElement.has_value())
result.at(x, y) = leftElement.value() + rightElement.value();
}
}
return result;
}

对模板参数推导的更多介绍

编译器根据传递给函数模板的实参来推导模板参数的类型;而对于无法推导的模板参数,则需要显式指定。例如,如下add()函数模板需要三个模板参数:返回值的类型以及两个操作数的类型。

1
2
template<typename RetType, typename T1, typename T2>
RetType add(const T1& t1, const T2& t2) { return t1+t2;}

调用这个函数模板时,可指定如下所有三个参数:

1
auto result = add<long long, int, int>(1, 2);

但由于模板参数T1和T2是函数的参数,编译器可以推导这两个参数,因此调用add()时可仅指定返回值的类型:

1
auto result = add<long long>(1, 2);

当然,仅在要推导的参数位于参数列表的最后时,这才可行。假设以如下方式定义函数模板:

1
2
template<typename T1, typename RetType, typename T2>
RetType add(const T1& t1, const T2& t2) { return t1 + t2; }

必须指定RetType,因为编译器无法推导该类型。但由于RetType 是第二个参数,因此必须显式指定T1:

1
auto result = add<int, long 1ong>(1, 2);

也可提供返回类型模板参数的默认值,这样调用add()时可不指定任何类型:

1
2
3
template<typename RetType = long long, typename T1,typename T2>
RetType add(const T2& t2) { return t1 + t2; }
auto result = add(1, 2);

函数模板的返回类型

add()函数模板的返回类型取决于模板类型参数,如何才能做到这一点?例如,考虑如下模板函数:

1
2
template<typename T1, typename T2>
RetType add(const T1& t1, const T2& t2) { return t1 + t2; }

在这个示例中,RetType应当是表达式t1+t2的类型,但由于不知道T1和T2是什么,因此并不知道这一点。只需要编写如下add()函数模板:

1
2
3
auto add(const T1& t1, const T2& & t2) {
return t1 + t2;
}

但是,使用auto来推导表达式类型时去掉了引用和const限定符;decltype没有去除这些。在继续使用add()函数模板前,先分析auto和decltype(使用非模板示例)之间的区别。假设有以下函数:

1
2
3
4
const std::string message = "Test"; 
const std::string& getString() {
return message;
}

auto s1 = getString();,由于auto会去掉引用和const限定符,因此s1的类型是string,并制作一个副本。如果需要一个const引用,可将其显式地设置为引用,并标记为const,如下所示:

1
const auto& s2 = getString();

另一个解决方案是使用decltypedecltype不会去掉引用和const限定符:

1
decltype (getString()) s3 = getString();

这里,s3的类型是const string&,但存在代码冗余,因为需要将getString()指定两次。如果getString()是更复杂的表达式,这将很麻烦。为解决这个问题,可使用decltype(auto):

1
decltype(auto) s4 = getString();

s4的类型也是const string&

了解到这些后,可使用decltype(auto)编写add()函数,以避免去掉任何const和引用限定符:

1
2
3
4
template<typename T1, typename T2>
decltype (auto) add (const T1& t1, const T2& t2) {
return t1 + t2;
}

在C++14之前,不支持推导函数的返回类型和decltype(auto)。C++11 引入的decltype(expression)解决了这个问题。例如,你或许会编写如下代码:

1
2
template<typename T1,typename T2>
decltype(t1+t2) add(const T1& t1, const T2& t2) { return t1 + t2; }

但这是错误的。你在原型行的开头使用了t1和t2,但这些尚且不知。在语义分析器到达参数列表的末尾时,才能知道t1和t2。

通常使用替换函数语法(alternative function syntax)解决这个问题。注意在这种新语法中,返回类型是在参数列表之后指定的(拖尾返回类型),因此在解析时参数的名称(以及参数的类型,因此也包括t1+t2类型)是已知的:

1
2
3
4
template<typename T1, typename T2>
auto add(const T1& t1, const T2& t2) -> decltype(t1+t2) {
return t1 + t2;
}

但现在,C++支持自动返回类型推导和decltype(auto),建议你使用其中的一种机制, 而不要使用替换函数语法。

可变模板

除了类模板、类方法模板和函数模板外,C++14 还添加了编写可变模板的功能。语法如下:

1
2
template <typename T>
constexpr T pi = T(3.141592653589793238462643383279502884);

这是pi值的可变模板。为了在某种类型中获得pi值,可使用如下语法:

1
2
float piFloat = pi<float>;
long double piLongDouble = pi<long double>;

这样总会得到在所请求的类型中可表示的pi近似值。与其他类型的模板一样, 可变模板也可以特殊化。

C++ IO揭秘

C++通过(stream)提供了更精良的输入输出方法。流是一种灵活且面向对象的IO方法。

使用流

流的含义

所有的流都可以看成数据滑槽。流的方向不同,关联的来源和目的地也不同。cout和cin都是C++在std名称空间中预定义的流实例。表13-1简要描述了所有预定义的流。

说明
cin 输入流,从“输入控制台”读取数据
cout 缓冲的输出流,向“输出控制台”写入数据
cerr 非缓冲的输出流,向“错误控制台”写入数据,“ 错误控制台”通常等同于“输出控制台”
clog cerr的缓冲版本

缓冲的流和非缓冲的流的区别在于,前者不是立即将数据发送到目的地,而是缓冲输入的数据,然后以块方式发送;而非缓冲的流则立即将数据发送到目的地。缓冲的目的通常是提高性能,对于某些目的地(如文件)而言,一次性写入较大的块时速度更快。注意,始终可使用flush()方法刷新缓冲区,强制要求缓冲的流将其当前所有的缓冲数据发送到目的地。

有关流的另一个要点是:流不仅包含普通数据,还包含称为当前位置(current position)的特殊数据。当前位置指的是流将要进行下一次读写操作的位置。

流的来源和目的地

在C++中,流可使用3个公共的来源和目的地:控制台、文件和字符串。

  • 控制台输入流允许程序在运行时从用户那里获得输入,使程序具有交互性。
  • 文件流从文件系统中读取数据并向文件系统写入数据。
  • 字符串流是将流隐喻应用于字符串类型的例子。使用字符串流时,可像处理其他任何流一样处理字符数据。

流式输出

输出的基本概念

输出流定义在<ostream>头文件中。大部分程序员都会在程序中包含<iostream>头文件,这个头文件又包含输入流和输出流的头文件。<iostream>头文件还声明了所有预定义的流实例:cout、cin、cerr、clog以及对应的宽版本。

使用输出流的最简单方法是使用<<运算符。通过<<可输出C++的基本类型。此外,C++的string类也兼容<<

cout流是写入控制台的内建流,控制台也称为标准输出(standard output)。可将<<的使用串联起来,从而输出多个数据段。这是因为<<运算符返回一个流的引用,因此可以立即对同一个流再次应用<<运算符。

C++流可正确解析C风格的转义字符,例如包含\n的字符串,也可使用std::endl开始一个新行。\nendl的区别是,\n仅开始一个新行,而endl还会刷新缓存区。使用endl时要小心,因为过多的缓存区刷新会降低性能。

输出流的方法

put()和write()

put()write()是原始的输出方法。这两个方法接收的不是定义了输出行为的对象或变量,put()接收单个字符,write()接收一个字符数组。传给这些方法的数据按照原本的形式输出,没有做任何特殊的格式化和处理操作。例如,下面的代码段接收一个C风格的字符串,并将它输出到控制台,这个函数没有使用<<运算符:

1
2
const char* test = "hello there\n";
cout.write(test, strlen(test));

下面的代码段通过put()方法,将C风格字符串的给定索引输出到控制台:

1
cout.put('a');

flush()

向输出流写入数据时,大部分输出流都会进行缓冲,也就是积累数据,而不是立即将得到的数据写出去。在以下任意一种条件下,流将刷新(或写出)积累的数据:

  • 遇到sentinel(如endl标记)时。
  • 流离开作用域被析构时。
  • 要求从对应的输入流输入数据时(即要求从cin输入时,cout会刷新)。
  • 流缓存满时。
  • 显式地要求流刷新缓存时。

显式要求流刷新缓存的方法是调用流的flush()方法。

不是所有的输出流都会缓存。例如,cer 流就不会缓存其输出。

处理输出错误

当一个流处于正常的可用状态时,称这个流是“好的”。调用流的good()方法可以判断这个流当前是否处于正常状态。

1
2
if(cout.good())
cout << "All good" << end1;

通过good()方法可方便地获得流的基本验证信息,但不能提供流不可用的原因。还有一个bad()方法提供了稍多信息。如果bad()方法返回true,意味着发生了致命错误(相对于非致命错误,例如到达文件结尾)。另一个方法fail()在最近一次操作失败时返回true,但没有说明下一次操作是否也会失败。例如,对输出流调用flush()后,可调用fail()确保流仍然可用。

1
2
3
cout.flush();
if(cout.fall())
cerr << "Unable to flush to standard out" << endl;

流具有可转换为bool类型的转换运算符。转换运算符与调用fail()时返回的结果相同。因此,可将前面的代码段重写为:

1
2
3
cout.flush();
if (!cout)
cerr << "Unable to flush to standard out" << endl;

有一点需要指出,遇到文件结束标记时,good()fail()都会返回false。关系如下:good() == (!fail() && !eof())

还可要求流在发生故障时抛出异常。然后编写一个catch处理程序来捕捉ios_base::failure异常,然后对这个异常调用what()方法,获得错误的描述信息,调用code()方法获得错误代码。不过,是否能获得有用信息取决于所使用的标准库实现:

1
2
3
4
5
6
cout.exceptions(ios::failbit | ios::badbit | ios::eofbit);
try {
cout << "Hello World." << endl;
} catch (const ios_base::failure& ex) {
cerr << "Caught exception:" << ex.what() << ", error code ="<< ex.code() << endl;
}

输出操作算子

C++流还能识别操作算子(manipulator),操作算子是能修改流行为的对象,而不是(或额外提供)流能够操作的数据。

endl就是一个操作算子。endl操作算子封装了数据和行为。它要求流输出一个行结束序列,并且刷新缓存。其他有用的操作算子大部分定义在<ios><iomanip>标准头文件中。列表后的例子展示了如何使用这些操作算子。

  • boolalpha和noboolalpha:要求流将布尔值输出为truefalse(boolalpha)或1和0(noboolalpha)。默认行为是noboolalpha。
  • hex、oct和dec:分别以十六进制、八进制和+进制输出数字。
  • stprecision:设置输出小数时的小数位数。这是一个参数化的操作算子。
  • setw:设置输出数值数据的字段宽度。这是一个参数化的操作算子。
  • setfill:当数字宽度小于指定宽度时,设置用于填充的字符。这是一个参数化的操作算子。
  • showpoint和noshowpoint:对于不带小数部分的浮点数,强制流总是显示或不显示小数点。
  • put_money:一个参数化的操作算子,向流写入一个格式化的货币值。
  • put_time:一个参数化的操作算子,向流写入一个格式化的时间值。
  • quoted:一个参数化的操作算子,把给定的字符串封装在引号中,并转义嵌入的引号。

上述操作算子对后续输出到流中的内容有效,直到重置操作算子为止,但setw仅对下一个输出有效。

如果不关心操作算子的概念,通常也能应付过去。流通过precision()这类方法提供了大部分相同的功能。以如下代码为例:

1
cout << "This should be '1.2346': "<< setprecision(5) << 1.23456789 << endl;

这行代码可转换为方法调用。该方法的优点是,它们返回前面的值以便恢复:

1
2
cout.precision(5);
cout << "This should be '1.2346'; "<< 1.23456789 << endl;

流式输入

输入流为结构化数据和非结构化数据的读入提供了简单方法。

输入的基本概念

读入数据对应的运算符是>>。通过>>从输入流读入数据时,代码提供的变量保存接收的值。默认情况下,>>运算符根据空白字符对输入值进行标志化。>>运算符可用于不同的变量类型,就像<<运算符一样。

处理输入错误

输入流提供了一些方法用于检测异常情形。大部分和输入流有关的错误条件都发生在无数据可读时。查询输入流状态的最常见方法是在条件语句中访问输入流。例如,只要cin保持在“良好”状态,下面的循环就继续进行:

1
while (cin) ( ... )

同时可以输入数据:

1
while (cin >> ch) { ... }

还可在输入流上调用good()bad()fail()方法,就像输出流那样。还有一个eof()方法,如果流到达尾部,就返回true。与输出流类似,遇到文件结束标记时,good()fail()都会返回false。关系如下:good() == (!fail() && !eof())

下面的程序展示了从流中读取数据并处理错误的常用模式。这个程序从标准输入中读取数字,到达文件末尾时显示这些数字的总和。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int sum = 0;
if (!cin.good()) {
cerr << "Standard input is in a bad state!" << endl;
return 1;
}
int number;
while(!cin.bad()) {
cin >> number:
if (cin.good()) {
sum += number;
} else if (cin.eof()) {
break;
} else if (cin.fail()) {
// Failure!
cin.clear();
// Clear the failure state.
string badToken;
cin >> badToken; // consume the bad input.
err << "WARNTNG:Bad input encountered:" << badToken << endl;
}
}

输入方法

输入流也提供了一些方法,获得相比普通>>运算符更底层的访问功能。

get()

get()方法允许从流中读入原始输入数据。get()的最简单版本返回流中的下一个字符,其他版本一次读入多个字符。get()常用于避免>>运算符的自动标志化。

在条件环境中对一个输入流求值时,只有当这个输入流可以用于下一次读取时才会返回true。如果遇到错误或者到达文件末尾,都会使流求值为false。

unget()

对于大多数场合来说,理解输入流的正确方式是将输入流理解为单方向的滑槽。数据被丢入滑槽,然后进入变量。unget()方法打破了这个模型,允许将数据塞回滑槽。调用unget()会导致流回退一个位置,将读入的前一个字符放回流中。调用fail()方法可查看unget()是否成功。

下面的代码使用了unget(),允许名字中出现空白字符。将这段代码逐字符读入,并检查字符是否为数字。如果字符不是数字,就将字符添加到guestName;如果字符是数字,就通过unget()将这个字符放回到流中,循环停止,然后通过>>运算符输入一个整数partySize。noskipws输入操作算子告知流不要跳过空白字符,就像读取其他任何字符一样读取空白字符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
vold getReservationData() {
string guestName;
int partySize = 0;
// Read characters until we find a digit
char ch;
cin >> noskipws;
while (cin >> ch) {
if (isdigit(ch)) {
cin.unget();
if(cin.fail ())
cout << "unget() failed" << endl;
break;
}
guestName += ch;
}
// Read partysize, if the stream is not in error state
if (cin)
cin >> partySize;
if (!cin) {
cerr << "Error getting party size." << endl;
return;
}

cout << "Thank you " << guestName << ", party of " << partysize << endl;
if (partysize> 10) {
cout << "An extra gratuity will apply." << endl;
}
}
putback()

putback()unget()一样,允许在输入流中反向移动一个字符。区别在于putback()方法将放回流中的字符接收为参数:

1
2
3
4
char ch1;
cin >> chl;
cin.putback('e');
// 'e' will be the next character read oft the stream.
peek()

通过peek()方法可预览调用get()后返回的下一个值。再次以滑槽为例,可想象为查看一下滑槽,但是不把值取出来。
peek()非常适合于在读取前需要预先查看一个值的场合。

getine()

getline()方法用一行数据填充字符缓存区,数据量最多至指定大小。指定的大小中包括\0字符。因此,下面的代码最多从cin 中读取kBufferSize - 1个字符,或者读到行尾为止:

1
2
char buffer[kBufferSize] = { 0 };
cin.getline (buffer, kBuffersize);

调用getline()时,从输入流中读取一行,读到行尾为止。不过,行尾字符不会出现在字符串中。有个版本的get()函数执行的操作和getline()一样,区别在于get()把换行序列留在输入流中。

还有一个用于C++字符串的std:getline()函数。这个函数定义在<string>头文件和std名称空间中。它接收一个流引用、一个字符串引用和一个可选的分隔符作为参数。使用这个版本的getine()函数的优点是不需要指定缓存区的大小。

1
2
string myString;
std::getine(cin, myString);

输入操作算子

下面列出了内建的输入操作算子,它们可发送到输入流中,以自定义数据读入的方式。

  • boolalpha和noboolalpha:如果使用了boolalpha,字符串false会被解释为布尔值false;其他任何字符串都会被解释为布尔值true。如果设置了noboolalpha,0会被解释为false,其他任何值都被解释为true。
  • hex、oct和dec:分别以十六进制、八进制和十进制读入数字。
  • skipws和noskipws:告诉输入流在标记化时跳过空白字符,或者读入空白字符作为标记。默认为skipws。
  • ws:一个简便的操作算子,表示跳过流中当前位置的一串空白字符。
  • get_money:一个参数化的操作算子,从流中读入一个格式化的货币值。
  • get_time:一个参数化的操作算子,从流中读入一个格式化的时间值。
  • quoted:一个参数化的操作算子,读取封装在引号中的字符串,并转义嵌入的引号。

字符串流

可通过字符串流将流语义用于字符串。通过这种方式,可得到一个内存中的流(in memory stream)来表示文本数据。字符串流也非常适合于解析文本,因为流内建了标记化的功能。std::ostringstream类用于将数据写入字符串,std::istringtream类用于从字符串中读出数据。这两个类都定义在<sstream>头文件中。由于ostringstreamistringstream把同样的行为分别继承为ostreamistream,因此这两个类的使用也非常类似。

下面的程序从用户那里请求单词,然后输出到一个ostringtream中,通过制表符将单词分开。在程序的最后,整个流通过str()方法转换为字符串对象,并写入控制台。输入标记“done”,可停止标记的输入。

1
2
3
4
5
6
7
8
9
ostringstream outstream;
while (cin) {
string nextToken;
cout << "Next token:";
cin >> nextToken;
if (!cin || nextToken == "done")
break;
outStream << nextToken << "\t";
}

从字符串流中读入数据非常类似。下面的函数创建一个Muffin对象,并填充字符串输入流中的数据。流数据的格式固定,因此这个函数可轻松地将数据值转换为对Mufin类的设置方法的调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Muffin createMuffin(istringstream& stream) {
Muffin muftin;
string description;
int size;
bool hasChips;

stream >> description >> size >> boolalpha >> hasChips;
if (stream) {
muffin.setsize(size);
muffin.setDescription (description);
muffin.setHasChocolateChips (hasChipa);
}
return muffin;
}

文件流

文件本身非常符合流的抽象,因为读写文件时,除数据外,还涉及读写的位置。在C++中,std::ofstreamstd::ifstream类提供了文件的输入输出功能。这两个类在<fstream>头文件中定义。在处理文件系统时,错误情形的检测和处理非常重要。可以通过前面描述的标准错误处理机制检测错误情形。

输出文件流和其他输出流的唯一主要区别在于:文件流的构造函数可以接收文件名以及打开文件的模式作为参数。默认模式是写文件(ios_base::out),这种模式从文件开头写文件,改写任何已有的数据。给文件流构造
函数的第二个参数指定常量ios_base::app,还可按追加模式打开输出文件流。

常量 说明
ios_base::app 打开文件,在每一次写操作之前,移到文件末尾
ios_base::ate 打开文件,打开之后立即移到文件末尾
ios_base::binary 以二进制模式执行输入输出操作(相对于文本模式)
ios_base::in 打开文件,从开头开始读取
ios_base::out 打开文件,从开头开始写入,覆盖已有的数据
ios_base::trunc 打开文件,并删除(截断)任何已有数据

注意,可组合模式。例如,如果要打开文件用于输出(以二进制模式),同时截断现有数据,可采用如下方式指定打开模式:

1
ios_base::out | ios_base::binary | ios_base::trunc

ifstream自动包含ios_base::in模式,ofstream自动包含ios_base::out模式,即使不显式地将in或out指定为模式,也同样如此。

下面的程序打开文件ts.txt,并输出程序的参数。istram 和ofstream析构函数会自动关闭底层文件,因此不需要显式调用close()

1
2
3
4
5
6
7
8
9
10
11
12
int main(int argc, char* argv[]) {
ofstream outFile("test.txt", ios_base::trunc);
if (!outFile.good()) {
cerr << "Error while opening output file!" << endl;
return -1;
}
outFile << "There were " << argc << "arguments to this program." << endl;
outFile << "They are:"<< endl;
for (int i = 0; i ≤ arge; i ++)
outFile << argv[i] << endl;
return 0;
}

文本模式与二进制模式

默认情况下,文件流在文本模式中打开。如果指定ios_base:binary标志,将在二进制模式中打开文件。在二进制模式中,要求把流处理的字节写入文件。读取时,将完全按文件中的形式返回字节。

在文本模式中,会执行一些隐式转换,写入文件或从文件中读取的每一行都以\n结束。但是,行结束符在文件中的编码方式与操作系统相关。因此,如果文件以文本模式打开,而写入的行以\n结尾,在写入文件前,底层实现会自动将\n转换为\r\n。同样,从文件读取行时,从文件读取的\r\n会自动转换回\n。

通过seek()和tell()在文件中转移

所有的输入流和输出流都有seek()tell()方法。

seek()方法允许在输入流或输出流中移动到任意位置。seek()有好几种形式。输入流中的seek()版本实际上称为seekg(),输出流中的seek()版本称为seekp()。有的流既可以输入又可以输出,例如文件流。这种情况下,流需要记住读位置和独立的写位置。这也称为双向IO

seekg()seekp()有两个重载版本。其中一个重载版本接收一个参数:绝对位置。这个重载版本将定位到这个绝对位置。另一个重载版本接收一个偏移量和一个位置,这个重载版本将定位到距离给定位置一定偏移量的位置。位置的类型为std::streampos,偏移量的类型为std::streamoff,这两种类型都以字节计数。预定义的三个位置如表所示。

位置 说明
ios_base::beg 表示流的开头
ios_base::end 表示流的结尾
ios_base::cur 表示流的当前位置

例如,要定位到输出流中的一个绝对位置,可使用接收一个参数的seekp()版本,如下所示,这个例子通过ios_base::beg常量定位到流的开头:

1
outStream.seekp(ios_base::beg);

在输入流中,定位方法完全一样, 只不过用的是seekp()方法:

1
instream.seekg(ios_base::beg);

接收两个参数的版本可定位到流中的相对位置。第一个参数表示要移动的位置数,第二个参数表示起始点。要相对文件的起始位置移动,使用ios_base::beg常量。要相对文件的末尾位置移动,使用ios_base::end常量。要相对文件的当前位置移动,使用`ios_base::cur常量。例如,下面这行代码从流的起始位置移动到第二个字节。

1
outStream.seekp(2, ios_base::beg);

下例转移到输入流中的倒数第3个字节:

1
instream.seekg(-3, ios_base::end);

可通过tell()方法查询流的当前位置,这个方法返回一个表示当前位置的streampos值。利用这个结果,可在执行seek()之前记住当前标记的位置,还可查询是否在某个特定位置。和seek()一样,输入流和输出流也有不同版本的tell()。输入流使用的是tellg(),输出流使用的是tellp()。下面的代码检查输入流的当前位置,并判断是否在起始位置:

1
2
3
std::streampos curPos = instream.tel1g();
if (ios_base::beg == curPos)
cout << "We're at the beginning." << endl;

将流链接在一起

任何输入流和输出流之间都可以建立链接,从而实现“访问时刷新”的行为。换句话说,当从输入流请求数据时,链接的输出流会自动刷新。这种行为可用于所有流,但对于可能互相依赖的文件流来说特别有用。通过tie()方法完成流的链接。要将输出流链接至输入流,对输入流调用tie()方法,并传入输出流的地址。要解除链接,传入nullptr。

下面的程序将一个文件的输入流链接至一个完全不同的文件的输出流。也可链接至同一个文件的输出流。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ifstream inFile("input.txt"); // Note:input.txt must exist.
ofstream outFile ("output.txt");
// Set up a link between inFile and outFile.
inFile.tie (&soutFile);

// output some text to outFile. Normally, this would
// not flush because std::endl is not sent.
outFlle << "Hello there!";
// outFlle has NOT been. flushed.
// Read some text from inF1le. This w111 trigger flush()
// on outFile
string nextToken;
inFile >> nextToken;
// outFile HAS been flushed.

这种关系意味着:每次写入一个文件时,发送给另一个文件的缓存数据会被刷新。可通过这种机制保持两个相关文件的同步。

这种流链接的一个例子是cout和cin之间的链接。每当从cin输入数据时,都会自动刷新cout。cerr和cout之间也存在链接,这意味着到cerr的任何输出都会导致刷新cout,而clog未链接到cout。

双向I/O

双向流可同时以输入流和输出流的方式操作。双向流是iostream的子类,而iostreamistreamostream的子类,因此这是一个多重继承示例。显然,双向流支持>><<运算符,还支持输入流和输出流的方法。fstram类提供了双向文件流。fstream特别适用于需要替换文件中数据的应用程序,因为可通过读取文件找到正确的位置,然后立即切换为写入文件。

双向流用不同的指针保存读位置和写位置。在读取和写入之间切换时,需要定位到正确的位置。

错误处理

错误与异常

C++提供了对异常的语言支持,但不要求使用异常。然而,在C++中无法完全忽略异常,因为一些基本工具(例如内存分配例程)会用到它们。

异常的含义

异常是这样一种机制:一段代码提醒另一段代码存在“异常”情况或错误情况,所采用的路径与正常的代码路径不同。遇到错误的代码抛出异常,处理异常的代码捕获异常。当某段代码抛出异常时,程序控制立刻停止逐步执行,并转向异常处理程序(exception handler),异常处理程序可在任何地方,可位于同一函数中的下一行,也可在堆栈中相隔好几个函数调用。

C++中异常的优点

C++错误处理标准使用函数返回的整数代码和errno宏表示错误,每个线程都有自己的errno值。errno用作线程局部整数变量(thread-local integer variable),被调用函数使用这个变量将发生的错误告诉调用函数。整数返回代码和errno的使用并不一致。有些函数可能用返回值0表示成功,用-1表示错误。这些不一致性可能会引起问题,因为程序员在遇到新函数时,会假定它的返回代码与其他类似函数相同。

异常具有许多优点。

  • 将返回代码作为报告错误的机制时,调用者可能会忽略返回的代码,不进行局部处理或不将错误代码向上提交。
  • 返回的整数代码通常不会包含足够的信息。使用异常时,可将任何信息从发现错误的代码传递到处理错误的代码。除错误信息外,异常还可用来传递其他信息。
  • 异常处理可跳过调用堆栈的层次。也就是说,某个函数可处理沿着堆栈进行数次函数调用后发生的错误,而中间函数不需要有错误处理程序。返回代码要求堆栈中每一层调用的函数都必须在前一层之后显式地执行清理。

在现代编译器中,不抛出异常时几乎没有这个开销,实际抛出异常时这一开销也非常小。这并不是坏事,因为抛出异常应是例外情况。在C++中,并不强制异常处理。在C++中函数可抛出它想要抛出的任何异常,除非指定不会抛出任何异常(使用noexcept 关键字)。

异常机制

抛出和捕获异常

为了使用异常,要在程序中包括两部分:处理异常的try/catch结构和抛出异常的throw语句。二者都必须以某种形式出现,以进行异常处理。然而在许多情况下,throw 在一些库的深处发生,程序员无法看到这一点, 但仍然不得不用try/catch结构处理抛出的异常。try/catch 结构如下所示:

1
2
3
4
5
6
7
try {
// code which may result in an exception being thrown
} catch (exception-type1 exception-name) {
// code which responds to the exception of type 1
} catch (except ion-type2 exception-name) {
// code which responds to the exception of type 2
}

导致抛出异常的代码可能直接包含throw语句,也可能调用一个函数,这个函数可能直接抛出异常,也可能经过多层调用后调用为一个抛出异常的函数。如果没有抛出异常,catch 块中的代码不会执行;如果抛出了异常,throw 语句之后或者在抛出异常的函数后的代码不会执行,根据抛出的异常的类型,控制会立刻转移到对应的catch块。

如果catch块没有执行控制转移(例如返回一个值,抛出新的异常或者重新抛出异常),那么会执行catch块最后语句之后的“剩余代码”。演示异常处理的最简单示例是避免除0。这个示例抛出一个std::invalid_argument类型的异常,这种异常类型需要<stdexcept>头文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
double SafeDivide (double num, double den) {
if (den == 0)
throw invalid_argument("Divide by zero");
return num / den;
}
int main() {
try {
cout << safeDivide(5, 2) << endl;
cout << safeDivide(10, 0) << endl;
cout << SafeDivide(3, 3) << endl;
} catch (const invalid_argument& e) {
cout << "Caught exception:"<< e.what() << endl;
}
return 0;
}

输出如下所示:

1
2
2.5
Caught exception:Divide by zero

throw是C++中的关键字,这是抛出异常的唯一方法。throw行的invalid_argument()部分意味着构建invalid_argument类型的新对象并准备将其抛出。该层次结构中的每个类都支持what()方法,该方法返回一个描述异常的const char*字符串。该字符串在异常的构造函数中提供。

异常处理是这样一种方法:“尝试”执行一块代码,并用另一块代码响应可能发生的任何错误。在下面的main()函数中,catch语句响应任何被try块抛出的exception类型异常,并输出错误消息。如果try块结束时没有抛出异常,catch 块将被忽略。可将try/catch块当作if语句。如果在try块中抛出异常,就会执行catch块,否则忽略catch 块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main() {
const string fileName = "IntegerFile.txt";
vector<int> myInts;
try {
myInts = readIntegerFile(fileName);
} catch (const exception& e) {
cerr << "Unable to open file "<< fileName << endl;
return 1;
}
for (const auto& element :myInts) {
cout << element << " ";
}
return 0;
}

尽管默认情况下,流不会抛出异常,但是针对错误情况,仍然可以调用exceptions()方法通知流抛出异常。

异常类型

可抛出任何类型的异常。可以抛出一个std:exception类型的对象,但异常未必是对象。也可以抛出一个简单的int值,如下所示:

1
2
3
4
5
6
7
vector<int> readIntegerFile(string_view fileName) {
ifstream inputstream (fileName.data());
if (inputstream.fail()) {
// We failed to open the file:throw an exception
throw 5;
}
}

此后必须修改catch语句:

1
2
3
4
5
6
try {
myInts = readIntegerFile (fileName);
} catch (int e) {
cerr << "Unable to open file "<< fileName << "(" << e << ")" << endl;
return 1;
}

另外,也可抛出一个C风格的const char*字符串。这项技术有时有用,因为字符串可包含与异常相关的信息。

1
2
3
4
5
6
7
vector<int> readIntegerFile(string_view fileName) {
ifstream inputStream (fileName .data());
if (inputStream.fail()) {
// We failed to open the file:throw an exception
throw "Unable to open file";
}
}

当捕获const char*异常时,可输出结果:

1
2
3
4
5
6
try {
myInts = readIntegerFile (fileName);
} catch (const char* e) {
cerr << e << end1;
return 1;
}

尽管前面有这样的示例,但通常应将对象作为异常抛出,原因有以下两点:

  • 对象的类名可传递信息。
  • 对象可存储信息,包括描述异常的字符串。

按const和引用捕获异常对象

在前面的示例中,readIntegerFile()抛出一个exception类型的对象。catch 行如下所示:

1
) catch (const exception& e) (

然而,在此并没有要求按const引用捕获对象,可按值捕获对象;此外,也可按非const引用捕获对象:

抛出并捕获多个异常

可让函数抛出两种不同类型的异常。invalid_argumentruntime_error都是定义在<stdexcept>头文件中的类,这个头文件是C++标准库的一部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
vector<int> readIntegerFile(string_view fileName) {
ifstream inputstream(fileName.data());
if (inputStream.fail())
throw invalid argument ("Unable to open the file.");

vector<int> integers;
int temp;
while (inputstream >> temp)
integers.push_back(temp);

if (!inputstream.eof())
throw runtime_error("Error reading the file.");
return integers;
}

invalid_argumentruntime_error类没有公有的默认构造函数,只有以字符串作为参数的构造函数。现在main()可用两个catch语句捕获invalid_argumentruntime_error异常:

1
2
3
4
5
6
7
8
9
try {
...
} catch (const invalid_arguments& e) {
cerr << e.what() << endl;
return 1;
} catch (const runtime_error& e) {
cerr << e.what() << endl;
return 2;
}

如果异常在try块内部抛出,编译器将使用恰当的catch处理程序与异常类型匹配。因此,如果readIntegerFile()无法打开文件并抛出invalid_argument异常,第一个catch语句将捕获这个异常。如果readntegerFile()无法正确读取文件并抛出runtime_error异常,第二个catch语句将捕获这个异常。

匹配和const

对于想要捕获的异常类型而言,增加const属性不会影响匹配的目的。也就是说,这一行可以与runtime_error类型的任何异常匹配:

1
) catch (const runtime_errors e) (

匹配所有异常

可用特定语法编写与所有异常匹配的catch行,如下所示:

1
2
3
4
5
6
try {
myInts = readIntegeile (fileName) ;
} catch (...) {
cerr << "Error reading or opening file" << fileName << endl;
return 1;
}

三个点是与所有异常类型匹配的通配符。与所有异常匹配的catch块可以用作默认的catch处理程序。当异常抛出时,会按在代码中的显示顺序查找catch处理程序。下例用catch处理程序显式地处理invalid_argumentruntime_error异常,并用默认的catch处理程序处理其他所有异常。

1
2
3
4
5
6
7
8
9
try {
// Code that can throw exceptions
} catch (const invalid argument& e) {
// Handle invalid_argument exception
} catch (const runtime_errore e) {
// Handle runtime_error exception
} catch (...) {
// Handle all other exceptions
}

未捕获的异常

如果程序抛出的异常没有捕获,程序将终止。可对main()函数使用trycatch结构,以捕获所有未经处理的异常,如下所示:

1
2
3
4
5
try {
main(argc, argv);
} catch (...) {
// issue error message and terminate program
}

然而,这一行为通常并非我们希望的。异常的作用在于给程序一个机会,以处理和修正不希望看到的或不曾预期的情况。

当程序遇到未捕获的异常时,会调用内建的terminate()函数,这个函数调用<stdlib>中的abort()来终止程序。可调用set_terminate()函数设置自己的terminate_handler(),这个函数采用指向回调函数(既没有参数,也没有返回值)的指针作为参数。terminat()set_terminate()terminate_handler()都在<exception>头文件中声明。下面的代码高度概括了其运行原理:

1
2
3
4
5
6
7
8
try {
main(argc, argv);
) catch (...) {
if (terminate_handler != nullptr) {
terminate_handler();
} else {
terminate();
}

回调函数必须终止程序。错误是无法忽略的,然而可在退出之前输出一条有益的错误消息。下例中,main()函数没有捕获readIntegerFile()抛出的异常,而将teminate_handler()设置为自定义回调。这个回调通过调用exit()显示错误消息并终止进程。exit()函数接收返回给操作系统的一个整数,这个整数可用于确定进程的退出方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
void myTerminate() {
cout << "Uncaught exception!" << endl;
exit(1);
}
int main() {
set_terminate (myTerminate);
const string fileName = "IntegerFile.txt";
vector<int> myInts readIntegerFile(fileName);
for (const auto& element :myInts)
cout << element << " ";
cout << endl;
return 0;
}

当设置新的terminate_handler()时,set_terminate()会返回旧的terminate_handler()terminate_handler()被应用于整个程序,因此当需要新terminate_handler()的代码结束后,最好重新设置旧的terminate_handler()

在专门编写的软件中,通常会设置terminate_handler(),在进程结束前创建崩溃转储。此后将崩溃转储上传给调试器,从而允许确定未捕获的异常是什么,起因是什么。

noexcept

使用函数时,可使用noexcept关键字标记函数,指出它不抛出任何异常。

如果一个函数带有noexcept标记,却以某种方式抛出了异常,C++将调用terminate()来终止应用程序。在派生类中重写虚方法时,可将重写的虛方法标记为noexcep,即使基类中的版本不是noexcept。

抛出列表

C++的旧版本允许指定函数或方法可抛出的异常,这种规范叫作抛出列表(throw list)或异常规范(exception specification)。

自C++11之后,已不赞成使用异常规范;自C++17之后,已不再支持异常规范。但noexceptthrow()除外。

自C++11之后,异常规范虽然仍受支持,但已经极少使用。下面的这个readIntegerFile()函数包含了异常规范:

1
vector<int> readIntegerFile(string_view fileName) throw (invalid_argument, runtime_error) { }

如果函数抛出的异常不在异常规范内,C++运行时std:unexpected()默认情况下调用std:teminate()来终止应用程序。

异常与多态性

类是最有用的异常类型。实际上异常类通常具有层次结构,因此在捕获异常时可使用多态性。

标准异常体系

图14-3显了完整的层次结构。

这个层次结构中的每个类都支持what()方法,这个方法返回一个描述异常的const char*字符串。可在错误信息中使用这个字符串。大多数异常类(基类exception是明显的例外)都要求在构造函数中设置what()返回的字符串。readntegerFile()的另一个版本在错误消息中包含文件名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
vector<int> readIntegerFile(string_view fileName) {
ifstream inputstream (fileName.data());
if (inputstream.fail()) {
// We failed to open the file: throw an exception
const string error = "Unable to open file "s + fileName.data();
throw invalid_argument (error);
}
// Read the integers one-by-one and add them to a vector
vector<int> integers;
int temp;
while (inputstream >> temp) {
integers.push_back(temp);
}
if (!inputstream.eof()) {
const string error = "Unable to read file "s + fileName.data();
throw runtime_error (error);
}
return integers;

在类层次结构中捕获异常

异常层次结构的一个特性是可利用多态性捕获异常。例如,如果观察main()中调用readIntegerFile()之后的两条catch语句,就可以发现这两条语句除了处理的异常类不同之外没有区别。invalid_argumentruntime_error都是exception的派生类,因此可使用exception类的一条catch语句替换这两条catch 语句:

1
2
3
4
5
6
try {
myInts = readIntegerFile (fileName);
} catch (const exception& e) {
cerr << e.what() << endl ;
return 2;
}

exception引用的catch语句可与exception的任何派生类匹配。

当利用多态性捕获异常时,一定要按引用捕获。如果按值捕获异常,就可能发生截断,在此情况下将丢失对象的信息。

当使用了多条catch子句时,会按在代码中出现的顺序匹配catch子句,第一条匹配的catch子句将被执行。如果某条catch子句比后面的catch子句范围更广,那么这条catch子句首先被匹配,而后面限制更多的catch子句根本不会被执行。因此,catch子句应按限制最多到限制最少的顺序出现。例如,假定要显式捕获readIntegerFile()invalid_argument,就应该让一般的异常与其他类型的异常匹配。正确做法如下所示:

1
2
3
4
5
6
7
8
try {
myInts = readIntegerFile (fileName) ;
} catch (const invalid_argument& e) ( // List the derived class first.
// Take some special action for invalid filename s
} catch (const exception& e) { // Now list exception
cerr << e.what() << endl;
return 1;
}

第一条catch子句捕获invalid_argument异常,第二条catch子句捕获任何类型的其他异常。然而,如果将catch子句的顺序弄反,第一条catch子句会捕获任何派生类类型的异常,第二条catch子句永远无法执行。

编写自己的异常类

编写自己的异常类有两个好处:

  1. C++标准库中的异常数目有限,可在程序中为特定错误创建更有意义的类名
  2. 可在异常中加入自己的信息,而标准层次结构中的异常只允许设置错误字符串

建议自己编写的异常类从标准的exception类直接或间接继承。例如,在readIntegerFile()中,invalid_argumentruntime_error不能很好地捕获文件打开和读取错误。可为文件错误定义自己的错误层次结构,从泛型类FileError开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class FileErrorpublic exception {
public:.
FileError(string_view fileName) :mFileName (fileName) { }
virtual const char* what() const noexcept override {
return mMessage.c_str();
}
string_view getFileName() const noexcept { return mFileName; }

protected:
void setMessage (string_view message) { mMessage = message; }
private:
string mFileName;
string mMessage;
};

编写exception的派生类时,需要重写what()方法,其返回值为一个在对象销毁之前一直有效的const char*字符串。在FileError中,这个字符串来自mMessage数据成员。FileError的派生类可使用受保护的setMessage()方法设置消息。泛型类`FileError还包含文件名以及文件名的公共访问器。

在编写将其对象用作异常的类时,有一个诀窍。当某段代码抛出一个异常时,使用移动构造函数或复制构造函数,移动或复制被抛出的值或对象。因此,如果编写的类的对象将作为异常抛出,对象必须复制和或移动。这意味着如果动态分配了内存,就必须编写析构函数、复制构造函数、复制赋值运算符和或移动构造函数与移动赋值运算符。

作为异常抛出的对象至少要复制或移动一次。异常可能被复制多次,但只有按值(而不是按引用)捕获异常才会如此。

按引用(最好是const 引用)捕获异常对象可避免不必要的复制。

嵌套异常

当处理第一个异常时,可能触发第二个异常,从而要求抛出第二个异常。遗憾的是,当抛出第二个异常时,正在处理的第一个异常的所有信息都会丢失。C++用嵌套异常(nested exception)提供了解决这一问题的方案,嵌套异常允许将捕获的异常嵌套到新的异常环境。

使用std::throw_with_nested()抛出一个异常时,这个异常中嵌套着另一个异常。第二个异常的catch处理程序可使用dynamic_cast()访问代表第一个异常的nested_exception。下例演示了嵌套异常的用法。这个示例定义了一个从exception类派生的MyException类,其构造函数接收一个字符串。

1
2
3
4
5
6
7
8
9
class MyExceptionpublic std::exception {
public:
MyException(string_view message) : mMessage (message) {}
virtual const char* what() const noexcept override {
return mMessage.c_str();
}
private:
string mMessage;
};

当处理第一个异常且需要抛出嵌套了第一个异常的第二个异常时,需要使用std::throw_with_nested()函数。下面的doSomething()函数抛出一个runtime_error异常,这个异常立即被catch处理程序捕获。catch 处理程序编写了一条消息,然后使用std::throw_with_nested()函数抛出第二个异常,第一个异常嵌套在其中。注意嵌套异常是自动实现的。

1
2
3
4
5
6
7
8
void doSomething() {
try {
throw runtime_error("Throwing a runtime_error exception");
} catch (const runtime_error& e) {
cout << "caught a runtime_error" << endl;
throw_with_nested(MyException ("MyException with std::throw_with_nested()"));
}
}

当捕获到这类异常时,会编写一条消息,然后使用dynamic_cast()访问嵌套的异常。如果内部没有嵌套异常,结果为空指针。如果有嵌套异常,会调用nested_exception
rethrow_nested()方法。这样会再次抛出嵌套异常,这一异常可在另一个try/catch块中捕获。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main() {
try {
doSomething();
} catch (const MyException& e) {
cout << "caught MyException:" << e.what() << endl;
const auto* pNested = dynamic_cast<const nested_exception*>(&e);
if (pNested) {
try {
pNested->rethrow_nested();
} catch(const runtime_error& e) {
cout << e.what() << endl;
}
}
}
return 0;
}

前面的main()函数使用dynamic_cast()检测嵌套异常。如果想要检测嵌套异常,就不得不经常执行dynamic_cast(),因此标准库提供了一个名为std::rethrow_if_nested()的小型辅助函数,其用法如下所示:

1
2
3
4
5
6
7
8
try {
doSomething();
} catch (const MyException& e) {
rethrow_if_nested(e);
} catch (const runtime_error& e) {
cout << "Nested exception:"<< e.what() << endl;
}
return 0;

重新抛出异常

可使用throw关键字重新抛出当前异常,如下例所示:

1
2
3
4
5
6
7
8
9
void g() { throw invalid_argument ("Some exception"); }
void f() {
try {
g();
} catch (const invalid_argument& e) {
cout << "caught in f:" << e.what() << endl;
throw; // rethrow
}
}

始终使用throw;重新抛出异常。永远不要试图用throw e;重新抛出e,因为这样存在潜在的截断风险,可能丢失信息。

堆栈的释放与清理

当发现catch处理程序时,堆栈会释放所有中间堆栈帧,直接跳到定义catch处理程序的堆栈层。堆栈释放(stack unwinding)意味着调用所有具有局部作用域的名称的析构函数,并忽略在当前执行点之前的每个函数中所有的代码。然而当释放堆栈时,并不释放指针变量,也不会执行其他清理。在C++中,应该用基于堆栈的内存分配或者下面将要讨论的技术处理这种情况。

使用智能指针

如果基于堆栈的内存分配不可用,就应使用智能指针。在处理异常时,智能指针可使编写的代码自动防止内存或资源的泄漏。无论什么时候销毁智能指针对象,都会释放底层资源。下 面使用了智能指针unique_ptr

1
2
3
4
5
void funcOne() {
string strl;
auto str2 = make_unique<string> ("hello");
funcTwo();
}

当从funcOne()返回或抛出异常时,将自动删除str2指针。

捕获、清理并重新抛出

避免内存和资源泄漏的另一种技术是针对每个函数,捕获可能抛出的所有异常,执行必要的清理,并重新抛出异常,供堆栈中更高层的函数处理。下面是使用这一技术修改后的funcOne()函数:

1
2
3
4
5
6
7
8
9
10
11
void funcOne () {
string str1;
string* str2 = new string();
try {
funcTwo () ;
} catch (...) {
delete str2;
throw; // Rethrow the exception.
}
delete str2;
}

这个函数用异常处理程序封装对funcTwo()函数的调用,处理程序执行清理(在str2上调用delete)并重新抛出异常。关键字throw本身会重新抛出最近捕获的任何异常。注意catch语句使用…语法捕获所有异常。这一方法运行良好,但有点繁杂。需要特别注意,现在有两行完全相同的代码在str2上调用delete

常见的错误处理问题

内存分配错误

如果无法分配内存,newnew[]的默认行为是抛出bad_alloc类型的异常,这种异常类型在<new>头文件中定义。代码应该捕获并正确地处理这些异常。

不可能把对newnew[]的调用都放在try/catch块中,但至少在分配大块内存时应这么做。下例演示了如何捕获内存分配异常:

1
2
3
4
5
6
7
8
int* ptr = nullptr;
size_t integerCount = numeric_limits<size_t>::max();
try {
ptr = new int [integerCount];
} catch (const bad_alloc& e) {
cerr << "Unable to allocate memory:" << e.what() << endl;
return;
}

另一个考虑是记录错误时可能尝试分配内存。如果new执行失败,可能没有记录错误消息的足够内存。

不抛出异常的new

旧的C模式下,如果无法分配内存,内存分配例程将返回一个空指针。C++提供了newnew[]nothrow版本,如果内存分配失败,将返回nullptr,而不是抛出异常。使用语
new(nothrow)而不是new可做到这一点,如下所示:

1
2
3
int* ptr = new (nothrow) int [integerCount];
if (ptr == nullptr)
cerr << "Unable to allocate memory!" << endl;

定制内存分配失败的行为

C++允许指定new handler回调函数。默认情况下不存在new handler,因此newnew[]只是抛出bad_alloc异常。然而如果存在new handler,当内存分配失败时,内存分配例程会调用new handler而不是抛出异常。如果new handler返回,内存分配例程试着再次分配内存:如果失败,会再次调用new handler。这个循环变成无限循环,除非new handler用下面的3个选项之一改变这种情况。下面列出这些选项,并给出了注释:

  • 提供更多的可用内存 提供空间的技巧之一是在程序启动时分配一大块内存,然后在new handler中释放这块内存。关键在于,在程序启动时,分配一块足以完整保存文档的内存。当触发new handler时,可释放这块内存、保存文档、重启应用程序并重新加载保存的文档。
  • 抛出异常 C++标准指出,如果new handler抛出异常,那么必须是bad_alloc异常或者派生于bad_alloc的异常。
    • 可编写和抛出document_recovery_alloc异常,这种异常从bad_alloc继承而来。可在应用程序的某个地方捕获这种异常,然后触发文档保存操作,并重启应用程序。
    • 可编写和抛出派生于bad_allocplease_terminate_me异常。在顶层函数中可捕获这种异常,并通过从顶层函数返回来对其进行处理。
  • 设置不同的new handler 从理论上讲,可使用一系列 new handler,每个都试图分配内存,并在失败时设置一个不同的new handler。然而,这种情形通常过于复杂,并不实用。

如果在new handler中没有这么做,任何内存分配失败都会导致无限循环。

如果有一些内存分配会失败,但又不想调用new handler,那么在调用new之前,只需要临时将新的new handler重新设置为默认值nullptr。

调用在<new>头文件中声明的set_new_handler(),从而设置new handler

1
2
3
4
5
class please_terminate_mepublic bad_alloc { };
void myNewHandler () {
cerr << "Unable to allocate memory." << endl;
throw please_terminate_me();
}

new handler 不能有参数,也不能返回值。如前面列表中的第2个选项所述,new handler 抛出please_terminate_me异常。可采用以下方式设置new handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main()
try {
// Set the new new_handler and save the old one.
new_handler oldHandler = set_new_handler (myNewHandler);
// Generate allocation error.
size_t numInts = numeric_limits<size_t>::max();
int* ptr = new int [numInts];
// Reset the old new_handler
set_new_handler(oldHandler);
} catch (const please_terminate_me&) {
cerr << "Terminating program." << endl;
return 1;
}
return 0;
}

注意new_handler是函数指针类型的typedef,set_new_handler()会将其作为参数。

构造函数中的错误

虽然无法在构造函数中返回值,但是可以抛出异常。通过异常可很方便地告诉客户是否成功创建了对象。在异常离开构造函数前,必须在构造函数中仔细清理所有资源,并释放分配的所有内存。本节以Matix类作为示例,这个类的构造函数可正确处理异常。

1
2
3
4
5
6
7
8
9
10
11
template <typename T>
class Matrix {
public:
Matrix(size_t width, size_t height);
virtual ~Matrix();
private :
void cleanup();
size_t mWidth = 0;
size_t mHeight = 0;
T** mMatrix = nullptr;
}

Matrix类的实现如下所示。注意:

  • 对new的第一个调用并没有用try/catch块保护。第一个new抛出异常也没有关系,因为构造函数此时还没有分配任何需要释放的内存。如果后面的new抛出异常,构造函数必须清理所有已经分配的内存。
  • 由于不知道T构造函数本身会抛出什么异常,因此用…捕获所有异常,并将捕获的异常嵌套在bad_alloc异常中。
  • 使用{}语法,通过首次调用new分配的数组执行零初始化,即每个元素都是nullptr。这简化了cleanup()方法,因为允许它在nullptr上调用delete。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
template <typename T>
Matrix<T>::Matrix(size_t width, size_t height) {
mMatrix = new T* [width] {}; // Array is zero-initialized!
mwidth = width;
mHeight = height;

try {
for(size_t i = 0; i < width; ++i) {
mMatrix[i] = new T[height];
}
} catch (...) {
std::cerr << "Exception caught in constructor, cleaning up..." << std::endl;
cleanup();
// Nest any caught exception inside a bad_alloc exception .
std::throw_with_nested(std::bad_alloc());
}
}

template <typename T>
Matrix<T>::~Matrix() {
cleanup();
}

template <typename T>
void Matrix<T>::cleanup() {
for(size_t i = 0; i < mWidth; ++i)
delete[]] mMatrix[i];
delete[] mMatrix;
mMatrix = nullptr;
mWidth = mHeight = 0;
}

如果异常离开了构造函数,将永远不会调用对象的析构函数!

构造函数的function-try-blocks

function-try-blocks用于普通函数和构造函数。本节重点介绍functin-try-blocks如何用于构造函数。下面的伪代码显示了构造函数的function-try-blocks的基本语法:

1
2
3
4
5
6
7
MyClass::MyClass()
try
: <ctor-initializer>
{ /* constructor body ... */
} catch (const exception& e) {
/* ... */
}

可以看出,try关键字应该刚好在ctor itializer之前。catch 语句应该在构造函数的右花括号之后,实际上是将catch语句放在构造函数体的外部。当使用构造函数的function-try-blocks时,要记住如下限制和指导方针:

  • catch语句将捕获任何异常,无论是构造函数体还是ctor-intializer直接或间接抛出的异常,都是如此。
  • catch语句必须重新抛出当前异常或抛出一个新异常。如果catch语句没有这么做,运行时将自动重新拋出当前异常。
  • atch语句可访问传递给构造函数的参数。
  • 当catch语句捕获function-tryblocks内的异常时,构造函数已构建的所有对象都会在执行catch 语句之前销毁。
  • 在catch语句中,不应访问对象成员变量,因为它们在执行catch语句前就销毁了。 但是,如果对象包含非类数据成员,例如裸指针,并且它们在抛出异常之前初始化,就可以访问它们。如果有这样的裸资源,就必须在catch语句中释放它们。
  • 对于functio-try-blocks中的catch语句而言,其中包含的函数不能使用return关键字返回值。构造函数与此无关,因为构造函数没有返回值。

由于有以上限制,构造函数的function-try-blocks 只在少数情况下有用:

  • 将ctor-intializer抛出的异常转换为其他异常。
  • 将消息记录到日志文件。
  • 释放在抛出异常之前就在ctor intializer 中分配了内存的裸资源。

下例演示function-try-blocks的用法

1
2
3
4
5
6
7
8
class Subobject {
public:
Subobject(int i);
};

Subobject::Subobject(int i) {
throw std::runtime_error ("Exception by Subobject ctor");
}

MyClass类有一个int*类型的成员变量以及一个SubObject类型的成员变量:

1
2
3
4
5
6
7
class MyClass {
public:
MyClass();
private:
int* mData = nullptr;
Subobject mSubobject;
};

SubObject 类没有默认构造函数。这意味着需要在MyClass类的ctor-intializer中初始化mSubObject。MyClass类的构造函数将使用function-try-blocks 捕获ctor-intializer 中抛出的异常。

1
2
3
4
5
6
7
8
9
MyClass::MyClass ()
try
: mData(new int[42]{ 1, 2, 3 }), mSubobject (42) {
/* ... constructor body ... */
} catch (const std::exception6 e) {
// Cleanup memory.
delete[] mData;
mData = nullptr;
}

记住,构造函数的function-try-blocks中的catch语句必须重新抛出当前异常,或者抛出新异常。前面的catch语句没有抛出任何异常,因此C++运行时将自动重新抛出当前异常。下面的简单函数使用了前面的类:

1
2
3
4
5
6
7
8
int main()
try {
MyClass m;
} catch (const std::exception& e) {
cout << "main() caught: " << e.what() << endl;
}
return 0;
}

通常,仅将裸资源作为数据成员时,才有必要使用function-try-blocks. 可使用诸如std::unique_ptr的RAII类来避免使用裸资源。

析构函数中的错误

必须在析构函数内部处理析构函数引起的所有错误。不应该让析构函数抛出任何异常,原因如下:

  1. 析构函数会被隐式标记为noexcept,除非添加了noexcept(false)标记,或者类具有子对象,而子对象的析构函数是noexcept(false)。如果带noexcept标记的析构函数抛出一个异常, C++运行时会调用std::teminate()来终止应用程序。
  2. 在堆栈释放过程中,如果存在另一个挂起的异常,析构函数可以运行。如果在堆栈释放期间从析构函数抛出一个异常,C++运行时会调用std::terminate()来终止应用程序。<exception>头文件中声明了一个函数uncaught_exception(),该函数可返回未捕获异常的数量;所谓未捕获异常,是指已经抛出但尚未到达匹配catch的异常。如果uncaught_exceptions()的结果大于0,则说明正在执行堆栈释放。
  3. 客户不会显式调用析构函数:客户调用delete,delete调用析构函数。如果在析构函数中抛出一个异常,客户无法在此使用对象调用delete,也不能显式地调用析构函数。
  4. 析构函数是释放对象使用的内存和资源的一个机会。如果因为异常而提前退出这个函数,就会浪费这个机会,将永远无法回头释放内存或资源。

C++运算符重载

运算符重载概述

重载运算符的原因

基本指导原则是:为了让自定义类的行为和内建类型一样。自定义类的行为越接近内建类型,就越便于这些类的客户使用。

运算符重载的限制

下面列出了重载运算符时不能做的事情:

  • 不能添加新的运算符。只能重定义语言中已经存在的运算符的意义。
  • 有少数运算符不能重载,例如.(对象成员访问运算符)、:(作用域解析运算符)、sizeof?(条件运算符)以及其他几个运算符。
  • arity描述了运算符关联的参数或操作数的数量。只能修改函数调用、new和delete运算符的arity。其他运算符的arity不能修改。
  • 不能修改运算符的优先级和结合性。这些规则确定了运算符在语句中的求值顺序。
  • 不能对内建类型重定义运算符。运算符必须是类中的一个方法,或者全局重载运算符函数至少有一个参数必须是一种用户定义的类型(例如一个类)。

运算符重载的选择

重载运算符时,需要编写名为operatorX的函数或方法,X是表示这个运算符的符号。例如:

1
SpreadsheetCell operator+(const SpreadsheetCells lhs, const SpreadsheetCells rhs);

方法还是全局函数

当运算符是类的方法时,运算符表达式的左侧必须是这个类的对象。当编写全局函数时,运算符表达式的左侧可以是不同类型的对象。有3种不同类型的运算符:

  • 必须为方法的运算符:C++语言要求一些运算符必须是类中的方法,因为这些运算符在类的外部没有意义。例如,operator-和类绑定得非常紧密,不能出现在其他地方。大部分运算符都没有施加这种要求。
  • 必须为全局函数的运算符:如果允许运算符左侧的变量是除了自定义的类之外的任何类型,那么必须将这个运算符定义为全局函数。确切地讲,这条规则适用于operator<<operator>>,这两个运算符的左侧是iostream对象,而不是自定义类的对象。此外,可交换的运算符(例如二元的+和-)允许运算符左侧的变量不是自定义类的对象。
  • 既可为方法又可为全局函数的运算符,建议遵循如下规则:把所有运算符都定义为方法,除非根据以上描述必须定义为全局函数。这条规则的一个主要优点是,方法可以是虚方法,但全局函数不能是虚函数。因此,如果准备在继承树中编写重载的运算符,应尽可能将这些运算符定义为方法。

将重载的运算符定义为方法时,如果这个运算符不修改对象,应将整个方法标记为const。这样,就可对const对象调用这个方法。

选择参数类型

参数类型的选择有一些限制,因为如前所述,大多数运算符不能修改参数数量。真正需要选择的地方在于判断是按值还是按引用接收参数,以及是否需要把参数标记为const。

按值传递还是按引用传递的决策很简单:应按引用接收每一个非基本类型的参数

const决策也很简单:除非要真正修改参数,否则将每个参数都设置为const

选择返回类型

应该让运算符返回的类型和运算符对内建类型操作时返回的类型一样。如果编写比较运算符,那么应该返回bool类型。如果编写算术运算符,那么应该返回表示运算结果的对象。返回值还是引用的一般原则是:如果可以,就返回一个引用,否则返回一个值:如果运算符构造了一个新对象,那么必须按值返回这个新对象。如果不构造新对象,可返回对调用这个运算符的对象的引用,或返回对其中一个参数的引用。

可作为左值(赋值表达式左侧的部分)修改的返回值必须是非const。否则,这个值应该是const。大部分很容易想到的运算符都要求返回左值,包括所有赋值运算符(operator-、operator+-和operator-等)。

不应重载的运算符

有些运算符即使允许重载,也不应该重载。具体来说,取地址运算符operator&的重载一般没什么特别的用途,如果重载会导致混乱,因为这样做会以可能异常的方式修改基础语言的行为(获得变量的地址)。整个标准库大量使用了运算符重载,但从没有重载取地址运算符。

可重载运算符小结

在表15-1中,T表示要编写重载运算符的类名,E是一种不同的类型。


右值引用

表15-1列出的普通赋值运算符的原型如下所示:

1
T& operator=(const T&);

移动赋值运算符的原型几乎一致,但使用了右值引用。这个运算符会修改参数,因此不能传递const参数。

1
T& operator=(T&&);

表15-1没有包含右值引用语义的示例原型。然而,对于大部分运算符来说,编写一个使用普通左值引用的版本以及一个使用右值引用的版本都是有意义的,但是否真正有意义取决于类的实现细节。标准库中的std:string类利用右值引用实现了operator+,如下所示:

1
string operator+(string&& lhs, string&& rhs);

这个运算符的实现会重用其中一个参数的内存,因为这些参数是以右值引用传递的。也就是说,这两个参数表示的都是operator+完成之后销毁的临时对象。上述operator+的实现具有以下效果:

1
return std::move(lhs.append(rhs));

1
return std::move(ths.insert(0, lhs));

事实上,std::string定义了几个具有不同左值引用和右值引用组合的重载的operator+运算符。下面列出std::string中所有接收两个字符串参数的operator+运算符

1
2
3
4
string operator+(const string& lhs, const string& rhs);
string operator+(string&& lhs, const string& rhs);
string operator+(const string& lhs, string&& rhs);
string operator+(string&& lhs, string&& rhs);

关系运算符

C++标准库有一个方便的<utility>头文件,它包含几个辅助函数和类,还在std::rel_ops名称空间中给关系运算符包含如下函数模板:

1
2
3
4
template<class T> bool operator!=(const T& a, const T& b);//Needs operator==
template<class T> bool operator>(const T& a, const T& b); //Needs operator<
template<class T> bool operator<=(const T& a, const T& b);//Needs operator<
template<class T> bool operator>=(const T& a, const T& b);//Needs operator<

这些函数模板根据==<运算符给任意类定义了运算符!=><=>=。如果在类中实现operator--operator<,就会通过这些模板自动获得其他关系运算符。只要添加#include <utility>和下面的using声明,就可将这些运算符用于自己的类:

1
using namespace std::rel_ops;

但是,这种技术带来的一个问题在于,现在可能为用于关系操作的所有类(而非只为自己的类)创建这些运算符。还有一个问题是隐式转换不可行。

重载算术运算符

重载一元负号和一元正号运算符

C++有几个一元算术运算符。一元负号和一元正号运算符是其中的两个。这些运算符不改变调用它们的对象,所以应把它们标记为const。下例将一元operator-运算符重载为SpreadsheetCell类的成员函数。

1
2
3
SpreadsheetCell SpreadsheetCell::operator-() const {
return SpreadsheetCell (-getValue());
}

operator-没有修改操作数,因此这个方法必须构造一个新的带有相反值的SpreadsheetCell对象,并返回这个对象的副本。因此,这个运算符不能返回引用。可按以下方式使用这个运算符:

1
2
SpreadsheetCell c1(4);
SpreadsheetCell c3 = -c1;

重载递增和递减运算符

可采用4种方法给变量加1:

1
2
3
4
i = i + 1;
i += 1;
++ i;
i ++;

后两种称为递增运算符。第一种形式是前缀递增,这个操作将变量增加1,然后返回增加后的新值,供表达式的其他部分使用。第二种形式是后缀递增,返回旧值(增加之前的值),供表达式的其他部分使用。递减运算符的功能类似。

operator++operator--的双重意义给重载带来了问题,C++引入了一种方法来区分前后缀:前缀版本的operator++operator--不接收参数,而后缀版本的接收一个不使用的int类型参数。如果要为SpreadsheetCell类重载这些运算符,原型如下所示:

1
2
3
4
SpreadsheetCell& operator++(); // Prefix
Spreadsheetcell operator++(int); // Postfix
SpreadsheetCell& operator--(); // Prefix
SpreadsheetCell operator--(int); // Postfix

前缀形式的结果值和操作数的最终值一致,因此前缀递增和前缀递减返回被调用对象的引用。然而后缀版本的递增操作和递减操作返回的结果值和操作数的最终值不同,因此不能返回引用。下面是operator++运算符的实现:

1
2
3
4
5
6
7
8
9
10
SpreadsheetCell& SpreadsheetCell::operator++() {
set(getValue() + 1);
return *this;
}

SpreadsheetCell SpreadsheetCell::operator++(int) {
auto oldCell(*this); // Save current value
++(*this); // Increment using prefix + 1
return oldCell; // Return the old value
}

递增和递减还能应用于指针。当编写的类是智能指针或迭代器时,可重载operator++operator--,以提供指针的递增和递减操作。

重载按位运算符和二元逻辑运算符

按位运算符和算术运算符类似,简写的按位赋值运算符也和简写的算术赋值运算符类似。逻辑运算符要困难一些。建议不要重载&&和||。这些运算符并不应用于单个类型,而是整合布尔表达式的结果。此外,重载这些运算符会失去短路求值,原因是在将运算符左侧和右侧的值绑定至重载的&&和运算符之前,必须对运算符的左侧和右侧进行求值。因此,一般对特定类型重载这些运算符都没有意义。

重载插入运算符和提取运算符

在编写插入和提取运算符前,需要决定如何将自定义的类向流输出,以及如何从流中提取自定义的类。在这个例子中,SpreadsheetCell将读取和写入double值。插入和提取运算符左侧的对象是istreamostream(例如cincout),而不是SpreadsheetCell对象。由于不能向istream类或ostream类添加方法,因此应将插入和提取运算符写为SpreadsheetCell类的全局函数。这些函数在SpreadsheetCell类中的声明如下所示:

1
2
3
4
5
class SpreadsheetCell{

};
std::ostream& operator<<(std::ostream& ostr, const SpreadsheetCell& cell);
std::istream& operator>>(std::istream& istr, SpreadsheetCell& cell);

将插入运算符的第一个参数设置为ostream的引用,这个运算符就能应用于文件输出流、字符串输出流、cout、cerr和clog等。与此类似,将提取运算符的参数设置为istream的引用,这个运算符就能应用于文件输入流、字符串输入流和cin。

operator<<operator>>的第二个参数是对要写入或读取的SpreadsheetCell对象的引用。插入运算符不会修改写入的SpreadsheetCell对象,因此这个引用可以是const引用。然而提取运算符会修改SpreadsheetCell对象,因此要求这个参数为非const引用。

这两个运算符返回的都是第一个参数传入的流的引用,所以这两个运算符的调用可以嵌套。记住,运算符的语法实际上是显式调用全局operator>>函数或operator<<函数的简写形式。 例如下面这行代码:

1
cin >> myCell >> anotherCell >> aThirdCell;

实际上是如下代码行的简写形式:

1
operator>>(operator>>(operator>>(cin, myCell), anotherCell), aThirdCell);

从中可以看出,第一次调用operator>>的返回值被用作下一次调用的输入值。因此必须返回流的引用,结果才能用于下一次嵌套的调用。否则嵌套调用无法编译。

重载下标运算符

一个动态分配的数组类允许设置和获取指定索引位置的元素,并会自动完成所有的内存分配操作,定义可能是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template <typename T>
class Array {
public:
Array();
virtual ~Array();
Array<T>& operator=(const Array<T>& rhs) = delete;
Array(const Array<T>& src) = delete;
const T& getElementAt(size_t x) const;
void setElementat(size_t x, const T& value):
private:
size_t getsize() const;
static const size_t kAllocSize = 4;
void resize(size_t newSize);
T* mElements = nullptr;
size_t mSize = 0;
};

这个接口支持设置和访问元素。它为随机访问提供了保证:客户可创建数组,并设置元素1、100和1000,而不必考虑内存管理问题。下面是这些方法的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template <typename T> Array<T>::Array() {
mSize = kAllocSize;
mElements = new T[mSize] {};// Elements are zero-initialized!
}
template <typename T> Array<T>::~Array() {
delete [] mElements;
mElements = nullptr;
}
template <typename T> void Array<T>::resize(size_t newSize) {
auto newArray = std::make_unique<T[]>(newSize);
for(size_t i = 0; i < mSize; i ++) {
newArray[i] = mElements[i];
}
delete[] mElements;
mSize = newSize;
mElements = newArray.release();
}

resize()首先创建一个适当大小的新数组,将其存储在unique_ptr中。然后将所有元素从旧数组复制到新数组。如果在复制值时任何地方出错,unique_ptr会自动清理内存。最后,在成功分配新数组和复制所有元素后,即未抛出异常,我们才删除旧的mElements数组,并为其指定新数组。最后一行必须使用release()来释放unique_ptr的新数组的所有权,否则,在调用unique_ptr的析构函数时,将销毁这个数组。

通过以下方式给类添加operator[]

1
2
3
4
5
template <typename T> T& Array<T>::operator[](size_t x) {
if (x >= mSize)
resize(x + kAllocSize);
return mElements[x];
}

operator[]可设置和获取元素,因为它返回的是一个对位置x处的元素的引用。可通过这个引用对这个元素赋值。当operator[]用在赋值语句的左侧时,赋值操作实际上修改了mElements数组中位置x处的值。

通过operator[]提供只读访问

理想情况下,可提供两个operator[]:一个返回引用,另一个返回const引用。为此,编写下面这样的代码:

1
2
3
4
5
template <typename T> const T& Array<T>::operator[](size_t x) const {
if (x >= mSize)
throw std::out_of_range("");
return mElements[x];
}

为const对象调用const operator[],因此无法增加数组大小。当给定索引越界时,当前实现抛出异常。另一种做法是返回零初始化值而非抛出零初始化元素。代码如下:

1
2
3
4
5
6
7
template <typename T> const T& Array<T>::operator[](size_t x) const {
if (x >= mSize) {
static T nullValue = T();
return.nullValue;
}
return mElements [x];
}

重载函数调用运算符

C++允许重载函数调用运算符,写作operator()。如果在自定义的类中编写了一个operator(),那么这个类的对象就可以当成函数指针使用。包含函数调用运算符的类对象称为函数对象,或简称为仿函数(functor)。只能将这个运算符重载为类中的非静态方法。下例是一个简单的类,它带有一个重载的operator()以及一个具有相同行为的类方法:

1
2
3
4
5
6
7
8
9
class FunctionObject {
public:
int operator() (int param); // Function call operator
int dosquare(int param) {return param * param; }
};

int FunctionObject::operator() (int param) {
return doSquare(param);
}

下面是使用函数调用运算符的代码示例:

1
2
3
4
int x = 3, xSquared, xSquaredAgain;
Functionobject square;
xSquared = square(x);
xSquaredAgain = square.doSquare (x);

相比标准的对象方法,函数对象的好处很简单:这些对象有时可以伪装成函数指针,可将这些函数对象当成回调函数传给其他函数

相比全局函数,函数对象的好处较为复杂。有两个主要好处:

  • 对象可在函数对象运算符的重复调用之间,在数据成员中保存信息。
  • 可通过设置数据成员来自定义函数对象的行为。

当然,通过全局变量或静态变量都可实现上述任何好处。然而,函数对象提供了一种更简洁的方式,而使用全局变量或静态变量在多线程应用程序中可能会产生问题。

函数调用运算符还可用于提供多维数组的下标。只要编写一个行为类似于operator[],但接收多个索引的operator()即可。这项技术的唯一问题是需要使用()而不是[]进行索引,例如myArray(3,4)-6。

重载解除引用运算符

可重载3个解除引用运算符:*->->*,只考虑*->的原始意义。*解除对指针的引用,允许直接访问这个指针指向的值,->是使用*解除引用之后再执行成员选择操作的简写。

在类中重载解除引用运算符,可使这个类的对象行为和指针一致。这种功能的主要用途是实现智能指针。还能用于标准库广泛使用的迭代器。

实现operator*

当解除对指针的引用时,经常希望能访问这个指针指向的内存:operator*应该返回一个引用:

1
2
3
4
5
6
template<typename T> T& Pointer<T>::operator*() {
return *mPtr;
}
template<typename T> T& Pointer<T>::operator*() const {
return *mPtr;
}

从这个例子可看出,operator*返回的是底层普通指针指向的对象或变量的引用。与重载下标运算符一样,同时提供方法的const版本和非const版本也很有用。

实现operator->

箭头运算符稍微复杂一些。应用箭头运算符的结果应该是对象的成员或方法。为实现这一点,应该能够实现operator*operator,而C++有充足的理由不允许重载operator.:不可能编写单个原型来捕捉任何可能选择的成员或方法。因此,C++将operator->当成一种特例。例如下面这行代码:

1
smartCell->set(5);

C++将这行代码解释为:

1
(smartCell.operator->())->set(5);

从中可看出,C++给重载的operator->返回的任何结果应用了另一个operator->。因此,必须返回一个指向对象的指针,如下所示:

1
2
3
4
5
6
7
8
9
10
11
template <typename T> class Pointer
public:
T* operator->();
const T* operator->() const;
};
template <typename T> T* Pointer<T>::operator->() {
return mPtr;
}
template <typename T> const T* Pointer<T>::operator->() const {
return mPtr;
}

operator.*和operator->*的含义

在C++中,不能在没有对象的情况下访问非静态数据成员或调用非静态方法。通过指针调用方法或访问数据成员时,必须在对象的上下文中解除对指针的引用。下例演示了这点。

1
2
3
SpreadsheetCell myCell;
double (Spreadsheetcell::*methodPtr) () const = &SpreadsheetCell::getValue();
cout << (mycell.*methodPtr) () << endl;

注意,.*运算符解除对方法指针的引用并调用这个方法。如果有一个指向对象的指针而不是对象本身,那么还有一个等效的operator->*可通过指针调用方法。这个运算符如下所示:

1
2
3
SpreadsheetCell* myCell = new SpreadsheetCell();
double (Spreadsheetcell::*methodPtr)() const = &spreadsheetCell::getValue;
cout << (myCell->*methodPtr)() <<endl;

编写转换运算符

可编写一个将SpreadsheetCell转换为double类型的转换运算符。原型如下所示:

1
operator double() const;

函数名为operator double。它没有返回类型,因为返回类型是通过运算符的名称确定的:double。这个函数是const,因为这个函数不会修改被调用的对象。实现如下所示:

1
SpreadsheetCell::operator double() const { return getValue(); }

使用显式转换运算符解决多义性问题

注意,为SpreadsheetCell对象编写double转换运算符时会引入多义性问题。例如下面这行加粗代码:

1
2
SpreadsheetCell cell(1.23);
double d2 = cell + 3.3;

现在这行代码无法成功编译。编译器不知道应该通过operator double()将cell对象转换为double类型,再执行double加法,还是通过double构造函数将3.3转换为SpreadsheetCell,再执行SpreadsheetCell加法。

在C++11之前,通常解决这个难题的方法是将构造函数标记为explicit,以避免使用这个构造函数进行自动转换。自C++11以后,可将double类型转换运算符标记为explicit以解决这个问题。下面的代码演示了这种方法的应用:

1
2
3
SpreadsheetCell cell = 6.6;
double d1 = static_cast<double>(cell);
double d2 = static_cast<double>(cell + 3.3);

下面解释上述代码中的各行:

  1. 使用隐式类型转换从double转换到SpreadsheetCell。由于是在声明中,因此这是通过调用接收double参数的构造函数进行的。
  2. 使用operator double()转换运算符。注意,由于这个转换运算符现在声明为explicit,因此要求进行强制类型转换。
  3. 通过隐式类型转换将3.3转换为SpreadsheetCell,再进行两个SpreadsheetCell对象的operator+操作,之后进行必要的显式类型转换以调用operator double()

重载内存分配和内存释放运算符

new和delete的工作原理

考虑下面这行代码:

1
SpreadsheetCell* cell = new SpreadsheetCell();

new SpreadsheetCell()这部分称为new表达式。它完成了两件事情。首先,通过调用operator new为SpreadsheetCell对象分配了空间。然后,为这个对象调用构造函数。只有这个构造函数完成了,才返回指针。

delete的工作方式与此类似。考虑下面这行代码:

1
delete cell;

这一行称为delete表达式。它首先调用cell的析构函数,然后调用operator delete来释放内存。可重载operator newoperator delete来控制内存的分配和释放,但不能重载new表达式和delete表达式。

new表达式和operator new

有6种不同形式的new表达式,每种形式都有对应的operator new。前面的章节已经展示了4种new表达式:newnew[]new(nothrow)new(nothrow) []。下面列出了<new>头文件中对应的4种operator new形式:

1
2
3
4
void* operator new(size_t size);
void* operator new[](size_t size);
void* operator new(size_t size, const std::nothrow_t&)noexcept;
void* operator new[](size_t size, const std::nothrow_t&) noexcept;

有两种特殊的new表达式,它们不进行内存分配,而在已有存储段上调用构造函数。这种操作称为placement new运算符。它们在已有的内存中构造对象,如下所示:

1
2
void* ptr = allocateMemorySomehow();
spreadsheetCell* cell = new (ptr) SpreadsheetCe11();

这个特性有点儿偏门,但知道这项特性的存在非常重要。如果需要实现内存池,以便在不释放内存的情况下重用内存,这项特性就非常方便。对应的operator new形式如下,但C++标准禁止重载它们:

1
2
void* operator new(size_t size, void* p) noexcept;
void* operator new[](size_t size, void* p) noexcept;

delete表达式和operator delete

只可调用两种不同形式的delete表达式:deletedelete[],没有nothrow和placement形式。然而,operator delete有6种形式,nothrow和placement形式只有在构造函数抛出异常时才会使用。

这种情况下,匹配调用构造函数之前分配内存时使用的operator newoperator delete会被调用。然而,如果正常地删除指针,delete会调用operator deleteoperator delete[],绝不会调用nothrow或placement形式。C++标准指出,从delete抛出异常的行为是未定义的。也就是说,delete永远都不应该抛出异常。因此nothrow版本的operator delete是多余的;而placement版本的delete应该是一个空操作,因为在placement new中并没有分配内存,因此也不需要释放内存。

更有用的技术是重载特定类的operator newoperator delete。仅当分配或释放特定类的对象时,才会调用这些重载的运算符。下面这个类重载了4个非placement形式的operator newoperator delete

1
2
3
4
5
6
7
8
9
10
11
class MemoryDemo {
public:
void* operator new(size_t size);
void operator delete(voId* ptr) noexcept;
void* operator new[](size_t size);
void operator delete[](void* ptr) noexcept;
void* operator new(size_t size, const std::nothrow_t&) noexcept;
void operator delete(void* ptr, const std::nothrow_t&) noexcept;
void* operator new[](size_t size, const std::nothrow_t&) noexcept;
void operator delete[](void* ptr, const std::nothrow_t&) noexcept;
};

当重载operator new时,要重载对应形式的operator delete。否则,内存会根据指定的方式分配,但是根据内建的语义释放,这两者可能不兼容。

显式地删除/默认化operator new和operator delete

下面的类删除了operator newnew[]。也就是说,这个类不能通过newnew[]动态创建:

1
2
3
4
5
class MyClass {
public:
void*operator new(size_t size) = delete;
void* operator new[](size_t size) = delete;
};

重载带有额外参数的operator new和operator delete

除了重载标准形式的operator new外,还可编写带额外参数的版本。这些额外参数可用于向内存分配例程传递各种标志或计数器。例如,一些运行时库在调试模式中使用这种形式。下面是MemoryDemo类中带有额外整数参数的operator newoperator delete原型:

1
2
void* operator new(size_t size, int extra);
void operator delete(void* ptr, int extra) noexcept;

编写带有额外参数的重载operator new时,编译器会自动允许编写对应的new表达式。new的额外参数以函数调用的语法传递。因此,可编写这样的代码:

1
2
MemoryDemo* memp = new (5) MemoryDemo();
delete memp;

定义带有额外参数的operator new时,还应该定义带有额外参数的对应operator delete。不能自己调用这个带有额外参数的operator delete,只有在使用了带有额外参数的operator new且对象的构造函数抛出异常时,才调用这个operator delete

C++标准库概述

编码原则

  • 使用模板:模板用于实现泛型编程。通过模板,才能编写适用于所有类型对象的代码,模板甚至可用于编写代码时未知的对象。
  • 使用运算符重载:C++标准库大量使用了运算符重载。

C++标准库概述

字符串

C++在<string>头文件提供内建的string类,处理内存管理:提供一些边界检查、赋值语义以及比较操作;还支持些操作,例如串联、子字符串提取以及子字符串或字符的替换。

从技术角度看,std::string是对std::basic_string模板进行char实例化的类型别名。

标准库还提供在<string_view>中定义的string_view类。这是各类字符串表示的只读视图,可用于简单替换const string&,而且不会带来开销。它从不复制字符串!

正则表达式

<regex>头文件提供了正则表达式。正则表达式简化了文本处理中常用的模式匹配任务。通过模式匹配可在字符串中搜索特定的模式,还能酌情将搜索到的模式替换为新模式。

I/0流

C++引入了一种新的使用流的输入输出模型。IO功能在如下几个头文件中定义:<fstream>
<iomanip><ios><iosfwd><iostream><istream><ostream><sstream><streambuf><strstream>

智能指针

  • 第一个问题是根本没有删除对象(没有释放存储)。这称为内存泄漏。
  • 另一个问题是一段代码删除了存储,而另一段代码仍然引用了这个存储,导致指向那个存储的指针不再可用或已重新分配用作他用,这称为悬挂指针(dangling pointer)。
  • 还有一个问题是一段代码释放了一块存储,而另一段代码试图释放同一块存储。这称为双重释放(doublefreeing)。

所有这些问题都会导致程序发生某种故障。C++用智能指针unique_ptrshared_ptrweek_ptr解决了这些问题。shared_ptrweek_ptr是线程安全的,在<memory>头文件中定义。

在C++11之前,unique_ptr的功能由名为auto_ptr的类型完成,C++17废弃了auto_ptr,不应再使用这种类型。

异常

C++语言支持异常,函数和方法能通过异常将不同类型的错误向上传递至调用的函数或方法。异常支持在如下几个头文件中定义:<exception><stdexcept><system_error>

数学工具

C++有完整且常见的数学函数可供使用,C++17增加了大量的特殊数学函数,处理勒让德多项式、B函数、椭圆积分、贝塞尔函数、柱函数和诺伊曼函数等。

标准库在<complex>头文件中提供了一个复数类,名为complex,这个类提供了对包含实部和虚部的复数的操作抽象。

标准库还在<valarray>头文件中包含一个valarray类,这个类和vector类相似,但对高性能数值应用做了特别优化。这个库提供了一些表示矢量切片概念的相关类。通过这些构件,可构建执行矩阵数学运算的类。

C++还提供了一种获取数值极限的标准方式,例如当前平台允许的整数的最大值。在C语言中,可以通过访问#define来获得这些信息,例如INT_MAX。尽管在C++中仍可使用这种方法,但建议使用定义在<limits>头文件中的numeric_limits类模板:

1
2
cout <<"Max int value:" <<numeric_limits<int>::max() << endl;
cout <<"Min int value:" <<numeric_limits<int>::min() << endl;

注意min()lowest()之间的差异:

  • 对于整数而言,min值等于lowest值。
  • 对于浮点类型而言,min值等于可表示的最小正值,而lowest值等于可表示的最大负值,即-max()

时间工具

C++在<chrono>头文件中包含了Chrono库。这个库简化了与时间相关的操作,例如特定时间间隔的定时操作和定时相关的操作。

容器

标准库中的所有容器都是类模板,因此可通过这些容器保存任意类型的数据,从内建的int和double等类型到自定义的类。每个容器实例都只能保存一种类型的对象,也就是说,这些容器都是同构集合。如果需要大小可变的异构集合,可将每个元素包装在std:any实例中,并将这些实例存储在容器中。另外,可在容器中存储std:variant实例。

vector

<vector>头文件定义了vector,vector保存了元素序列,提供对这些元素的随机访问。与数组一样,vector中的元素保存在连续内存中。

vector能够在vector尾部快速地插入和删除元素(摊还常量时间,amortized constant time)。摊还常量时间指的是大部分插入操作都是在常量时间内完成的。然而,有时vector需要增长大小以容纳新元素,此时的复杂度为O(N)。这个结果的平均复杂度为0(1),或称为摊还常量时间。

vector其他部位的插入和删除操作比较慢(线性时间),因为这种操作必须将所有元素向上或向下挪动一个位置,为新元素腾出空间,或填充删除元素后留下的空间。与数组一样,vector提供对任意元素的快速访问。

对于在vector<bool>中保存布尔值有一个专门的模板。这个特制模板特别对布尔元素进行空间分配的优化,然而标准并未规定vector<bool>的实现应该如何优化空间。vector<bool>和本章后面要讨论的bitset之间的区别在于,bitset容器的大小是固定的。

list

标准库list是一种双向链表数据结构,在<list>中定义。与数组和vector一样,list保存了元素的序列。然而,与数组或vector的不同之处在于,list中的元素不一定保存在连续内存中。相反,list中的每个元素都指定了如何在list中找到前一个和后一个元素,所以得名双向链表。

list的性能特征和vector完全相反。list提供较慢的元素查找和访问(线性时间),而找到相应的位置之后,元素的插入和删除却很快(常量时间)。然而,vector通常比list更快。可使用性能分析器确认这一点。

forward_list

<forward_list>中定义的forward_list是一种单向链表,而list容器是双向链表。forward_list只支持前向迭代,需要的内存比list少。与list类似,一旦找到相关位置, forward_list允许在任何位置执行插入和删除操作(常量时间);与list一样,不能快速地随机访问元素,

deque

deque是双头队列(double-ended queue)的简称。deque在<deque>中定义,能实现快速的元素访问(常量时间)。在序列的两端还实现了快速插入和删除(摊还常量时间),但在序列中间插入和删除的速度较慢线性时间),deque中的元素在内存中的存储不连续,速度可能比vector慢,如果需要在序列两头快速插入或删除元素,还要求快速访问所有元素,那么应该使用deque而不是vector。

array

<array>头文件定义了array,这是标准C风格数组的替代品。有时可事先知道容器中元素的确切数量,因此不需要vector或list提供的灵活性,。array特别适用于大小固定的集合,而且没有vector的开销。

使用array有几点好处:

  • array总能知道自己的大小;
  • 不会自动转换为指针类型,从而避免了某些类型的bug。

array没有提供插入和删除操作,但大小固定。大小固定的优点是,允许array在堆栈上分配内存,而不总是像vector那样需要堆访问权限。与vector一样,对元素的访问速度极快(常量时间)。

queue

queue容器在<queue>中定义,提供标准的先入先出语义。在使用queue容器时,从一端插入元素,从另一端取出元素。插入元素(摊还常量时间)和删除元素(常量时间)的操作都很快。

priority_queue

priority_queue也在<queue>中定义,提供与queue相同的功能,但其中的每个元素都有优先级。元素按优先顺序从队列中移除。在优先级相同的情况下,删除元素的顺序没有定义。对prionity_queue的插入和删除一般比简单的队列插入和删除要慢,因为只有对元素重排序,才能支持优先级。

stack

<stack>头文件定义了stack,它提供标准的先入后出。在堆栈中,最新插入的元素第一个被移除。stack容器实现了元素的快速插入和删除(常量时间)。

set和multiset

set类模板在<set>头文件中定义。顾名思义,标准库中的set保存的是元素的集合:每个元素都是唯一的,在集合中每个元素最多只有一个实例。标准库中的set和数学中集合的概念有一点区别:在标准库中,元素按照一定的顺序保存。set提供对数时间的插入、删除和查找操作。这意味着插入和删除操作比vector快,但比list慢。查找操作比list块,但比vector慢。

如果需要保持顺序,而且要求插入,删除和查找操作的性能接近,那么应当优先使用set而不是vector或list。如果严禁出现重复元素,也应当使用set。

注意,set不允许重复元素。也就是说,set中的每个元素都必须唯一。如果要存储重复元素,必须使用<set>头文件中定义的multiset。

map和multimap

<map>头文件定义了map类模板,这是一个关联数组。可将其用作数组,其中的索引可以是任意类型,如string。map保存的是键/值对。map按顺序保存元素,排序的依据是键值而非对象值。它还提供operator[]。如果需要关联键和值,就应该使用map。

multimap也在<map>头文件中定义,它和map的关系等同于multiset和set的关系。确切地讲,multimap是允许重复键的map。

无序关联容器/哈希表

标准库支持哈希表(hash table),哈希表也称为无序关联容器(unordered associative container)。有4个无序关联容器:

  • unordered map
  • unordered_multimap
  • unordered_set
  • unordered multiset

前两个在<unordered_map>中定义,后两个在<unordered_set>中定义。更贴切的名字应该是hash_maphash_set等。

这些无序关联容器的插入、删除和查找操作能以平均常量时间完成。最坏情况是线性时间。在无序的容器中查找元素的速度比普通map或set中的查找速度快得多。

bitset

bitset类抽象了位操作。<bitset>头文件定义了bitset容器,但没有实现某种特定的可插入或删除元素的数据结构;bitset有固定大小,不支持迭代器。可将bitset想象为可以读写的布尔值序列。

bitset不局限于int或其他基本数据类型的大小。因此,能操作40位的bitset,也能操作213位的bitset。bitset的实现会使用实现N个位所需的足够存储空间,通过bitset<N>声明bitset时指定N。

标准库容器小结

表16-1总结了标准库提供的容器。

算法

除容器外,标准库还提供了很多泛型算法的实现。算法指的是执行某项任务时采取的策略,例如排序任务和搜索任务。这些算法也是用函数模板实现的,因此可用于大部分不同类型的容器。

迭代器是算法和容器之间的中介。选代器提供了顺序遍历容器中元素的标准接口,因此任何算法都可以操作任何容器。

标准库中大约有100种算法,下面将这些算法分为不同的类别。除非特别说明,否则这些算法都在<algorithm>头文件中定义。

非修改顺序算法

非修改类的算法查找元素的序列,返回一些有关元素的信息。因为是非修改类的算法,所以这些算法不会改变序列中元素的值或顺序。

从C++17开始,search0接收一个可选的附加参数,以指定要使用的搜索算法(default_searcher、boyer_moore_searcher或boyer_moore_horspool_searcher),使用boyer_moore搜索算法,最坏情况下,模式未找到时的复杂度是O(N+M),模式找到时的复杂度为O(NM)。

标准库提供了表16-4中列出的比较算法。这些算法都不要求排序源序列。所有算法的最差复杂度都为线性复杂度

计数算法如表16-5所示。

修改序列算法

修改算法会修改序列中的一些元素或所有元素。有些修改算法在原位置修改元素,因此原始序列发生变化。另一些修改算法将结果复制到另一个不同的序列,所以原始序列没有变化。所有这些修改算法的最坏复杂度都为线性复杂度。表16-6汇总了这些修改算法。

操作算法

操作算法在单独的元素序列上执行函数。C++标准库提供了两种操作算法,如表16-7所示。它们的复杂度都是线性复杂度,不要求对原始序列进行排序。

交换算法

C++标准库提供如表16-8所示的交换算法。

分区算法

如果谓词返回true的所有元素都在谓词返回false的所有元素的前面,则按某个谓词对序列进行分区。序列中不满足谓词的第一个元素称为分区点(partition point)。C++标准库提供如表16-9所示的分区算法。

排序算法

C++标准库提供了一些不同的排序算法,不同的排序算法有不同的性能保证,如表16-10所示。

二叉树搜索算法

下面的二叉树搜索算法通常用于已排序的序列。排好序的序列也满足这个要求。所有这些算法都具有对数复杂度。

集合算法

集合算法是特殊的修改算法,对序列执行集合操作,如表16-12所示。这些算法最适合操作set容器的序列,但也能操作大部分容器的排序后序列。

堆算法

堆(heap)是一种标准的数据结构,数组或序列中的元素在其中以半排序的方式排序,因此能够快速找到“顶部”元素。

最大/最小算法

数值处理算法

<numeric>头文件提供了下述数值处理算法。这些算法都不要求排序原始序列。所有算法的复杂度都为线性复杂度,如表16-15所示。

置换算法

序列的置换包含相同的元素,但顺序变了。表16-16列出了用于置换的算法。

理解容器与迭代器

容器概述

标准库提供了16个容器,分为4大类。

  • 顺序容器
    • vector(动态数组)
    • deque
    • list
    • forward_list
    • array
  • 关联容器
    • map
    • multimap
    • set
    • multiset
  • 无序关联容器或哈希表
    • unordered_map
    • unordered_multimap
    • unordered_set
    • unordered_multiset
  • 容器适配器
    • queue
    • priority_queue
    • stack

此外,C++的string和流也可在某种程度上用作标准库容器,bitset可以用于存储固定数目的位。

对元素的要求

标准库容器对元素使用值语义(value semantic)。也就是说,在输入元素时保存元素的一份副本,通过赋值运算符给元素赋值,通过析构函数销毁元素。因此,编写要用于标准库的类时,一定要保证它们是可以复制的。请求容器中的元素时,会返回所存副本的引用。

如果喜欢引用语义,可存储元素的指针而非元素本身。当容器复制指针时,结果仍然指向同一元素。另一种方式是在容器中存储std::reference_wrapper。可使用std::ref()std::cref()创建reference_wrapper,使引用变得可以复制。reference_wrapper类模板以及ref()cref()函数模板在<functional>头文件中定义。

在容器中,可能存储“仅移动”类型,这是非可复制类型,但当这么做时,容器上的一些操作可能无法编译。“仅移动”类型的一个例子是std::unique_ptr

标准库容器的一个模板类型参数是所谓的分配器(allocator)。标准库容器可使用分配器为元素分配或释放内存。分配器类型参数具有默认值,因此几乎总是可以忽略它。

有关使用默认内存分配器和比较器的容器中元素的特别需求在表17-1中列出。

迭代器

标准库通过迭代器模式提供了访问容器元素使用的泛型抽象。每个容器都提供了容器特定的迭代器,迭代器实际上是增强版的智能指针,这种指针知道如何遍历特定容器的元素。可将迭代器想象为指向容器中特定元素的指针。与指向数组元素的指针一样,迭代器可以通过operator++移到下一个元素。C++标准定义了5大类迭代器,如表17-2所示。

另外,满足输出迭代器要求的迭代器称为“可变迭代器”,否则称为“不变迭代器”。还可使用std::distance()计算容器的两个迭代器之间的距离。迭代器的实现类似于智能指针类,因为它们都重载了特定的运算符。

基本的迭代器操作类似于普通指针(dumb pointer)支持的操作,因此普通指针可以合法用作特定容器的迭代器。事实上,vector选代器在技术上就是通过简单的普通指针实现的。

标准库中每个支持迭代器的容器类都为其选代器类型提供了公共类型别名,名为iteratorconst_iterator。允许反向迭代元素的容器还提供了名为reverse_iteratorconst_reverse_iterator的公共类型别名。通过这种方式,客户使用容器迭代器时不需要关心实际类型。

const_iteratorconst_reverse_iterator提供对容器元素的只读访问。

容器还提供了begin()end()方法。begin()方法返回引用容器中第一个元素的迭代器,end()方法返回的选代器等于在引用序列中最后一个元素的选代器上执行operator++后的结果。begin()end()一起提供了一个半开区间,包含第一个元素但不包含最后一个元素。采用这种看似复杂方式的原因是为了支持空区间(不包含任何元素的容器),此时begin()等于end()

与此类似,还有:

  • 返回const迭代器的cbegin()cend()方法
  • 返回反向选代器的rbegin()rend()方法
  • 返回const反向选代器的crbegin()crend()方法

顺序容器

vector

可以在vector中建立索引,还可以在尾部或任何位置添加新的元素。向vector插入元素或从vector删除元素通常需要线性时间,但这些操作在vector尾部执行时,实际运行时间为摊还常量时间。

vector概述

vector在<vector>头文件中被定义为一个带有两个类型参数的类模板:一个参数为要保存的元素类型,另一个参数为分配器(allocator)类型

1
template <class T, class Allocator = allocator<T>> class vector;

Allocator参数指定了内存分配器对象的类型, 客户可设置内存分配器,以便使用自定义的内存分配器。这个模板参数具有默认值。

Allocator类型参数的默认值足够大部分应用程序使用。

固定长度的vector

使用vector的最简单方式是将其用作固定长度的数组。vector提供了一个可以指定元素数量的构造函数,还提供了一个重载的operator[]以便访问和修改这些元素。C++标准指出:通过operator[]访问vector边界之外的元素时,得到的结果是未定义的。

除使用operator[]运算符外,还可通过at()front()back()访问vector元素。at()方法等同于operator[]运算符,区别在于at()会执行边界检查,如果索引超出边界,at()会抛出out_of_range异常。front()back()分别返回vector的第一个元素和最后一个元素的引用。

对vector应用operator[]运算符通常会返回一个对元素的引用,可将这个引用放在赋值语句的左侧。如果对const vector对象应用operator[]运算符,就会返回一个对const元素的引用,这个引用不能用作赋值的目标。

动态长度的vector

vector的真正强大之处在于动态增长的能力。push_back()方法能为新元素分配空间。基于区间的for循环不需要做任何修改。

构造函数和析构函数

默认的构造函数创建一个不包含元素的vector。

1
vector<int> intVector;

可指定元素个数,还可指定这些元素的值,如下所示:

1
vector<int> intVector(10, 100);

如果没有提供默认值,那么对新对象进行0初始化。0初始化通过默认构造函数构建对象。

可以使用包含初始元素的initializer_list构建vector:

1
vector<int> intVector({ 1, 2, 3, 4,5,6 ));

initializer_list还可以用于第1章提到的统一初始化。统一初始化可用于大部分标准库容器。例如:

1
2
3
4
5
6
vector<int> intVector1 = {1, 2, 3, 4, 5, 6};
vector<int> intVector2{1, 2, 3, 4, 5, 6};

还可以在堆上分配vector:
```C++
auto elementVector = make_unique<vector<Element>>(10);

vector的复制和赋值

vector存储对象的副本,其析构函数调用每个对象的析构函数。vector类的复制构造函数和赋值运算符对vector中的所有元素执行深度复制。因此,出于效率方面的考虑,应该通过引用或const引用向函数和方法传递vector。

除普通的复制和赋值外,vector还提供了assign()方法,这个方法删除所有现有的元素,并添加任意数目的新元素。这个方法特别适合于vector的重用。下面是一个简单的例子。intVector包含10个默认值为0的元素。然后通过assign()删除所有10个元素,并以5个值为100的元素代之。

1
2
vector<int> intVector(10);
intVector.assign(5,100);

如下所示,assign()还可接收initializer_list。intVector现在有4个具有给定值的元素。

1
intVector.assign({1, 2, 3, 4});

vector还提供了swap()方法,这个方法可交换两个vector的内容,并且具有常量时间复杂度。下面举一个简单示例:

1
2
3
vector<int> vectorOne(10);
vectorcint> vectorTwo(5, 100);
vectorOne.swap (vectorTwo);

vector的比较

标准库在vector中提供了6个重载的比较运算符:==!=<><=>=。如果两个vector的元素数量相等,而且对应元素都相等,那么这两个vector相等。两个vector的比较采用字典顺序:如果第一个vector中从0到i-1的所有元素都等于第二个vector中从0到i-1的所有元素,但第一个vector中的元素i小于第二个vector中的元素i,其中i在0到n之间,且n必须小于size(),那么第一个vector“小于”第二个vector。

通过operator==operator!=比较两个vector时,要求每个元素都能通过operator==运算符进行比较。通过

vector迭代器

首先,看一下for循环的初始化语句:

1
vector<double>::iterator iter = begin (doubleVector);

begin()返回引用容器中第一个元素的相应类型的迭代器。因此,这条初始化语句在iter变量中获取了引用doubleVector中第一个元素的迭代器。下面看一下for循环的比较语句:

1
iter != end(doubleVector);

这条语句检查迭代器是否超越了vector中元素序列的尾部。当到达这一点时,循环终止。递增语句++iter递增迭代器,以引用vector中的下一个元素。

只要可能,尽量使用前递增而不要使用后递增,因为前递增至少效率不会差,一般更高效。iter++必须返回一个新的选代器对象,而++iter只是返回对iter的引用。

上述使用迭代器的for循环可通过auto关键字简化:

1
2
for (auto iter = begin(doubleVector); iter != end(doubleVector); ++iter)
cout << *iter << " ";

访问对象元素中的字段

如果容器中的元素是对象,那么可对迭代器使用->运算符,调用对象的方法或访问对象的成员。

1
2
3
4
vector<string> stringVector(10, "hello");
for (auto it = begin(stringVector); it!= end(stringVector); ++ it)
it->append(" there");
}

使用基于区间的for循环,这段代码可以重写为:

1
2
3
vector<string> stringVector(10, "hello");
for (auto& str : stringVector)
str.append("there");

const_iterator

const_iterator是只读的,不能通过const_iterator修改元素。iterator始终可以转换为const_iterator,因此下面这种写法是安全的:

1
vector<type>::const_iterator it = begin (myVector);

然而,const_iterator不能转换为iterator。如果myVector是const_iterator,那么下面这行代码无法编译:

1
2
3
4
5
6
7
vector<type>::iterator it = begin (myVector);

在使用auto关键字时,const_iterator的使用看上去有一点区别。假设有以下代码:
```C++
vector<string> stringVector(10, "hello");
for (auto iter = begin(stringVector); iter != end(stringVector); ++iter)
cout << *iter << endl;

由于使用了auto关键字,编译器会自动判定iter变量的类型,然后将其设置为普通的iterator,因为stringVector不是const_iterator。如果需要结合auto使用只读的const_iterator,那么需要使用cbegin()cend(),而不是begin()end(),如下所示:

1
2
3
vector<string> stringVector(10, "hello");
for (auto iter = cbegin(stringVector); iter != cend(stringVector); ++iter)
cout << *iter << endl;

现在编译器会将iter变量的类型设置为const_iterator,因为cbegin()返回的就是const_iterator。

基于区间的for循环也可用于强制使用const_ iterator,如下所示:

1
2
3
vector<string> stringVector(10, "hello");
for (const auto& element : stringVector)
cout << element << endl;

迭代器还是索引?

  • 使用迭代器可在容器的任意位置插入、删除元素或元素序列。
  • 使用迭代器可使用标准库算法。
  • 通过迭代器顺序访问元素,通常比编制容器索引以单独检索每个元素的效率要高。

在vector中存储引用

可在诸如vector的容器中存储引用。为此,在容器中存储std:reference_wrapperstd:ref()cref()函数模板用于创建非const和const reference_wrapper实例。需要包含<functional>头文件。示例如下:

1
2
3
4
5
6
7
8
9
10
11
string str1 = "Hello";
string str2 = "World";

vector<reference_wrapper<string>> vec{ ref(str1) };
vec.push_back(ref(str2));

// Modify the string referred to by the second reference in the vector.
vec[1].get() += "!";

// The end result is that str2 is actually modified.
cout << str1 << " " << str2 << endl;

添加和删除元素

根据前面的描述,通过push_back()方法可向vector追加元素。vector还提供了删除元素的对应方法:pop_back()

pop_back()不会返回已删除的元素。如果要访问这个元素,必须首先通过back()获得这个元素。通过insert()方法可在vector中的任意位置插入元素,这个方法在迭代器指定的位置添加一个或多个元素,将所有后续元素向后移动,给新元素腾出空间。insert()有5种不同的重载形式:

  • 插入单个元素
  • 插入单个元素的n份副本
  • 从某个迭代器范围插入元素
  • 使用移动语义,将给定元素转移到vector中,插入一个元素
  • 向vector中插入一列元素,这列元素是通过initializer_list指定的

push_back()insert()还有把左值或右值作为参数的版本。两个版本都根据需要分配内存,以存储新元素。左值版本保存新元素的副本。右值版本使用移动语义,将给定元素的所有权转移到vector,而不是复制它们。

通过erase()可在vector中的任意位置删除元素,通过clear()可删除所有元素。erase()有两种形式:一种接收单个迭代器,删除单个元素;另一种接收两个迭代器,删除迭代器指定的元素范围。要删除满足指定条件的多个元素,一种解决方法是编写一个循环来遍历所有元素,然后删除每个满足条件的元素。然而,这种方法具有二次(平方)复杂度,对性能有很大影响。这种情况下,可使用删除擦除惯用法(remove-erase-idiom),这种方法的复杂度为线性复杂度。

移动语义

所有的标准库容器都包含移动构造函数和移动赋值运算符,从而实现了移动语义。这带来的一大好处是可以通过传值的方式从函数返回标准库容器,而不会降低性能。分析下面这个函数:

1
2
3
4
5
6
7
8
9
10
vector<int> createVectorofsize(size_t size) {
vector<int> vec(size);
int contents = 0;
for (auto& i : vec)
i = contents++;
return vec;
}

vector<int> myVector;
myVector.createVectorofsize (123);

如果没有移动语义,那么将createVectorOfSize()赋给myVector时,会调用复制赋值运算符。有了标准库容器中支持的移动语义后,就可避免这种vector复制。相反,对myVector的赋值会触发调用移动赋值运算符。

与此类似,push操作在某些情况下也会通过移动语义提升性能。vector类还定义了push_back(T&&val),这是push_back(const T& val)的移动版本。如果按照下列方式调用push_back()方法,则可以避免这种复制:

1
vec.push_back(move(myElement));

现在可以明确地说,myElement应移入vector。注意在执行这个调用后,myElement处于有效但不确定的状态。不应再使用myElement,除非通过调用clear()等使其重返确定状态。也可以这样调用push_back()

1
vec.push_back(string(5, 'a'));

上述vec.push_back()调用会触发移动版本的调用,因为调用string构造函数后生成的是一个临时string对象。push_back()方法将这个临时string对象移到vector中,从而避免了复制。

emplace操作

C++在大部分标准库容器中添加了对emplace操作的支持。emplace的意思是“放置到位”。emplace操作的一个示例是vector对象上的emplace_back(),这个方法在容器中分配空间,然后就地构建对象。例如:

从C++17开始,emplace_back()方法返回已插入元素的引用。在C++17之前,emplace_back()的返回类型是void。还有一个emplace()方法,可在vector的指定位置就地构建对象,并返回所插入元素的迭代器。

算法复杂度和迭代器失效

在vector中插入或删除元素,引用插入点、删除点或随后位置的所有迭代器在操作之后都失效了。vector内部的重分配可能导致引用vector中元素的所有迭代器失效,而不只是那些引用插入点或删除点之后的元素的迭代器。

vector内存分配方案

vector会自动分配内存来保存插入的元素。每次vector申请更多内存时,要分配一块更大的内存块,将所有元素复制到新的内存块。这个过程非常耗时,因此 vector的实现在执行重分配时,会分配比所需内存更多的内存,以尽量避免这个复制转移过程。

必须理解vector内部的内存工作原理有两个原因:

  1. 效率。vector分配方案能保证元素插入采用摊还常量时间复杂度:也就是说,大部分操作都采用常量时间,但是也会有线性时间(需要重新分配内存时)。如果关注运行效率,那么可控制vector执行内存重分配的时机。
  2. 迭代器失效。重分配会使引用vector内元素的所有选代器失效。因此,vector接口允许查询和控制vector的重分配。如果不显式地控制重分配,那么应该假定每次插入都会导致重分配以及所有迭代器失效。

大小和容量

vector提供了两个可获得大小信息的方法:size()capacity()size()方法返回vector中元素的个数,而capacity()返回的是vector在重分配之前可以保存的元素个数。因此,在重分配之前还能插入的元素个数为capacity() - size()

C++17引入了非成员的std::size()std::empty()全局函数。这些与用于获取迭代器的非成员函数(如std::begin()std::end()等)类似。非成员函数size()empty()可用于所有容器,也可用于静态分配的C风格数组(不通过指针访问)以及initializer_list。下面是一个将它们用于vector的例子:

1
2
3
vector<int> vec{1, 2, 3};
cout << size(vec) << endl;
cout << empty(vec) << endl;

预留容量

一种预分配空间的方式是调用reserve()。这个方法负责分配保存指定数目元素的足够空间。

另一种预分配空间的方法是在构造函数中,或者通过resize()assign()方法指定vector要保存的元素数目。这种方法会创建指定大小的vector(容量也可能就是这么大)。

直接访问数据

vector在内存中连续存储数据,可使用data()方法获取指向这块内存的指针。C++17引入了非成员的std::data()全局函数来获取数据的指针。它可用于array. vector容器、字符串、静态分配的C风格数组(不通过指针访问)和initializer_lists。下面是一个用于vector的示例:

1
2
3
vector<int> vec{1, 2, 3};
int* data1 = vec.data();
int* data2 = data(vec);

vector特化

C++标准要求对布尔值的vector进行部分特化,目的是通过“打包”布尔值的方式来优化空间分配。C++没有正好保存一个位的原始类型。一些编译器使用和char大小相同的类型来表示布尔值。其他一些编译器使用int类型。vector<bool>特化应该用单个位来存储“布尔数组”,从而节省空间。可将vector<bool>表示为位字段(bit-field)而不是vector。

vector<bool>特化实际上定义了一个名为reference的类,用作底层布尔(或位)值的代理。当调用operator[]at()或类似方法时,vector<bool>返回reference对象,这个对象是实际布尔值的代理。

由于vector<bool>返回的引用实际上是代理,因此不能取地址以获得指向容器中实际元素的指针。

在实际应用中,通过包装布尔值而节省一点空间似乎得不偿失。更糟糕的是,访问和修改vector<bool>中的元素比访问vector<int>中的元素慢得多。应该避免使用vector<bool>,而是使用bitset。

如果确实需要动态大小的位字段,建议使用vector<std::int_fast8_t>vector<unsigned char>std::int_fast8_t类型在<cstdint>中定义。这是一种带符号的整数类型,编译器必须为其使用最快的整数类型(至少8位)。

deque

deque(double-ended qucue的简称)几乎和vector是等同的,但用得更少。deque定义在<deque>头文件中。主要区别如下:

  • 不要求元素保存在连续内存中
  • deque支持首尾两端常量时间的元素插入和删除操作(vector只支持尾端的摊还常量时间)
  • deque提供了push_front()pop_front()emplace_front(),而vector没有提供
  • 在开头和末尾插入元素时,deque未使选代器失效
  • deque没有通过reserve()capacity()公开内存管理方案

list

list定义在<list>头文件中,是一种标准的双链表。list支持链表中任意位置常量时间的元素插入和删除操作,但访问单独元素的速度较慢(线性时间)。事实上,list根本没有提供诸如operator[]的随机访问操作。只有通过迭代器才能访问单个元素。

访问元素

list提供的访问元素的方法仅有front()back(),这两个方法的复杂度都是常量时间。这两个方法返回链表中第一个元素和最后一个元素的引用。对所有其他元素的访问都必须通过迭代器进行。

list支持begin()方法,这个方法返回引用链表中第一个元素的迭代器;还支持end()方法,这个方法返回引用链表中最后一个元素之后那个元素的迭代器。与vector类似,list还支持cbegin()cend()rbegin()rend()crbegin()crend()

list不支持元素的随机访问。

迭代器

list迭代器是双向的,不像vector迭代器那样提供随机访问。这意味着list迭代器之间不能进行加减操作和其他指针运算。

添加和删除元素

和vector一样,list也支持添加和删除元素的方法,包括push_back()pop_back()emplace()emplace_back()5种形式的insert()以及两种形式的erase()clear()。和deque一样,list还提供了push_front()emplace_front()pop_front()。list的奇妙之处在于,只要找到正确的操作位置,所有这些方法(clear()除外)的复杂度都是常量时间。

list大小

list不公开底层的内存模型。因此,list支持size()empty()resize(),但不支持reserve()capacity()。注意,list的size()方法具有常量时间复杂度。

list特殊操作

list提供了一些特殊操作,以利用其元素插入和删除很快这一特性。

串联

由于list类的本质是链表,因此可在另一个list的任意位置串联(splice)或插入整个list,其复杂度是常量时间。

串联���作对作为参数传入的list来说是破坏性的:从一个list中删除要插入另一个list的元素。

更高效的算法版本

splice()外,list类还提供了一些泛型标准库算法的特殊实现。

表17-3总结了list以方法形式提供特殊实现的算法。

forward_list

forward_list在<forward_list>头文件中定义,forward_list是单链表,而list是双链表。这意味着forward_list只支持前向迭代,如果需要修改任何链表,首先需要访问第一个元素之前的那个元素。由于forward_list没有提供反向遍历的迭代器,因此没有简单的方法可以访问前一个元素。所以,要修改的范围(例如提供给erase()splice()的范围)必须是前开的。

forward_list类定义了一个before_begin()方法,它返回一个指向链表开头元素之前的假想元素的迭代器。不能解除这个迭代器的引用,因为这个迭代器指向非法数据。然而,将这个迭代器递增1可得到与begin()返回的迭代器同样的效果;因此,这个方法可以用于构建前开的范围。表17-4总结了list和forward_list之间的区别。

forward_list和list的构造函数及赋值运算符类似。C++标准表明forward_list应该尽可能使用最小的空间。

array

array类定义在<array>头文件中,和vector类似,区别在于array的大小是固定的,不能增加或收缩。这个类的目的是让array能分配在栈上,而不是像vector那样总是需要访问堆。和vector一样,array支持随机访问迭代器,元素都保存在连续内存中。array支持front()back()at()operator[],还支持使用fill()方法通过特定元素将array填满。由于array大小固定,因此不支持push_back()pop_back()insert()erase()clear()resize()reserve()capacity()

与vector相比,array的缺点是,array的swap()方法具有线性时间复杂度,而vector的swap()方法具有常量时间复杂度。array声明需要两个模板参数:第一个参数指定元素类型,第二个参数指定array中元素的固定数量:

1
2
3
4
5
6
7
8
9
10
array<int, 3> arr = {9, 8, 7};
cout <<"Array size "<< arr.size() << endl;

for (const auto& i : arr)
cout << i << endl;

arr.fill(3);

for (auto iter = cbegin(arr); iter != cend(arr); ++iter)
cout << *iter << endl;

可使用std:get<n>()函数模板,从std::array检索位于索引位置n的元素。索引必须是常量表达式,不能是循环变量等。使用std:get<n>()的优势在于编译器在编译时检查给定索引是有效的,否则将导致编译错误,如下所示:

1
2
3
array<int, 3> myArray(1122, 33);
cout<< std::get<1>(myArray) <<endl;
cout << std::get<10>(myArray)<< endl; // Compilation error!

容器适配器

除标准的顺序容器外,标准库还提供了3种容器适配器:queue,priority_queue和stack。每种容器适配器都是对一种顺序容器的包装。它们允许交换底层容器,无须修改其他代码。容器适配器的作用是简化接口,只提供那些stack和queue抽象所需的功能。

queue

queue容器适配器定义在头文件<queue>中,queue提供了标准的“先入先出”语义。与通常情况一样,queue也写为类模板形式,如下所示:

1
template <class T, class Container = deque<T> > class queue;

T模板参数指定要保存在queue中的类型。另一个模板参数指定queue适配的底层容器。不过,由于queue要求顺序容器同时支持push_back()pop_front()两个操作,因此只有两个内建的选项:deque和list。大部分情况下,只使用默认的选项deque即可。

queue操作

queue接口非常简单:只有8个方法,再加上构造函数和普通的比较运算符。push()emplace()方法在queue的尾部添加一个新元素,pop()从queue的头部移除元素。通过front()back()可以分别获得第一个元素和最后一个元素的引用,而不会删除元素。与其他容器一样,在调用const对象时,front()back()返回的是const引用:调用非const对象时,这些方法返回的是非const引用(可读写)。

pop()不会返回弹出的元素。如果需要获得一份元素的副本,必须首先通过front()获得这个元素。queue还支持size()empty()swap()

priority_queue

优先队列(priority_queue)是一种按顺序保存元素的队列。优先队列不保证严格的FIFO顺序,而是保证在队列头部的元素任何时刻都具有最高优先级。这个元素可能是队列中最老的那个元素,也可能是最新的那个元素。如果两个元素的优先级相等,那么它们在队列中的相对顺序是未确定的。priority_queue容器适配器也定义在<queue>中。其模板定义如下:

1
template <class T, class Container = vector<T>, class Compare = less<T>>;

这个类没有看上去这么复杂。之前看到了前两个参数:T是priority_queue中保存的元素类型;Container是priority_queue适配的底层容器。priority_queue默认使用vector,但是也可以使用deque。这里不能使用list,因为priority_queue要求随机访问元素。第3个参数Compare复杂一些。less是一个类模板,支持两个类型为T的元素通过operator<运算符进行比较。也就是说,要根据operator<来确定队列中元素的优先级,可以自定义这里使用的比较操作。目前,只要保证为保存在priority_queue中的类型正确定义了operator<即可。

priority_queue的头元素是优先级最高的元素,默认情况下优先级是通过operator<运算符来判断的,比其他元素“小”的元素的优先级比其他元素低

priority_queue提供的操作

priority_queue提供的操作比queue还要少。push()emplace()可以插入元素,pop()可以删除元素,top()可以返回头元素的const引用。

在非const对象上调用top()top()返回的也是const引用,因为修改元素可能会改变元素的顺序,所以不允许修改。priority_queue没有提供获得尾元素的机制。

pop()不返回弹出的元素。如果需要获得一份副本,必须首先通过top()获得这个元素。

与queue一样,priority_queue支持size()empty()swap()。然而,priority_queue没有提供任何比较运算符。

stack

stack和queue几乎相同,区别在于stack提供先入后出(FILO)的语义,这种语义也称为后入先出,以区别于FIFO。stack定义在<stack>头文件中。模板定义如下所示:

1
template <class T, class Container = deque<T>>class stack;

可将vector、list或deque用作stack的底层容器。

stack操作

与queue类似,stack提供了push()emplace()pop()。区别在于:push()在stack项部添加一个新元素,将之前插入的所有元素都“向下推”;而pop()从stack顶部删除一个元素,这个元素就是最近插入的元素。如果在const对象上调用,top()方法返回顶部元素的const引用;如果在非const对象上调用,top()方法返回非const引用。

pop()不返回弹出的元素。如果需要获得一份副本,必须首先通过top()获得这个元素。stack支持empty()size()swap()和标准的比较运算符。

有序关联容器

有序关联容器将键映射到值。通常情况下,有序关联容器的插入、删除和查找时间是相等的。标准库提供的4个有序关联容器分别为map、multimap、set和multiset。每种有序关联容器都将元素保存在类似于树的有序数据结构中。还有4个无序关联容器:unordered_map、unordered_multimap、unordered_set和unordered_multiset。

pair工具类

pair类在<utility>头文件中定义。pair是一个类模板,它将两个可能属于不同类型的值组合起来。通过first和second公共数据成员访问这两个值。pair类定义了operator==operator<,用于比较first和second元素。下面给出了一些示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
pair<string, int> myPair("hello", 5);
pair<string, int> myotherPair;

myotherPair.first = "hello";
myotherPair.second = 6;

pair<string, int> myThirdPair (myOtherPair);

if (mypair < myOtherPair)
cout <<"myPair is less than myotherPair"<<endl;
else
cout << "myPair is greater than or equal to myOtherPair" << endl;

if (myotherPair == myThirdPair)
cout << "myotherPair 1s equal to myThirdPair" << endl;
else
cout << "myOtherPair is not equal to myThirdPair" << endl;

这个库还提供了一个工具函数模板make_pair(),用于从两个值构造一个pair。例如:

1
pair<int, double> aPair = make_ pair(510.10);

如果需要向函数传递pair,或者把它赋予已有的变量,那么make_pair()更有用。与类模板不同,函数模板可从参数中推导类型,因此可通过make_pair()构建pair,而不需要显式地指定类型。还可结合使用make_pair()与auto关键字:

1
auto aSecondPair = make_pair(5, 10.10);

结构化绑定是另一个C++17特性,可用于将pair的元素分解为单独的变量。下面是一个示例:

1
2
3
4
5
pair<string, int> myPair("hello", 5);
auto[thestring, theint] = myPair;

cout << "theString:" << thestring << endl;
cout << "theInt: "<< theInt <<endl;

map

map定义在<map>头文件中,它保存的是键/值对,而不是只保存值。插入、查找和删除操作都是基于键的,值只不过是附属品。map根据键对元素排序存储,因此插入、删除和查找的复杂度都是对数时间。由于排好了序,因此枚举元素时,元素按类型的operator<或用户定义的比较器确定的顺序出现。通常情况下,map实现为某种形式的平衡树,例如红黑树。不过,树的结构并没有向客户公开。

构建map

map类模板接收4种类型:键类型、值类型、比较类型以及分配器类型。比较类型和之前描述的priority_queue中的比较类型类似,允许提供与默认不同的比较类。如果忽略比较参数和分配器参数,那么map的构建和vector或list的构建是一样的,区别在于,在模板实例化中需要分别指定键和值的类型。

1
2
3
4
5
6
7
8
9
class Data final {
public:
explicit Data(int value = 0): mValue(value) ()
int getValue() const { return mValue; }
void setValue(int value) { mValue = value; }
private:
int mValue;
};
map<int, Data> dataMaps;

map还支持统一初始化机制:

1
2
3
4
5
map<string, int> m = {
{ "Marc G.", 123 },
{ "Warren B.", 456},
{ "Peter V.W.", 789)
};

插入元素

map和其他关联容器插入时不需要指定位置。map的内部实现会判定要保存新元素的位置,只需要提供键和值即可。

map和其他有序关联容器提供了接收选代器位置作为参数的insert()方法。然而,这个位置只是容器找到正确位置的一种“提示”。不强制容器在那个位置插入元素。

在插入元素时,一定要记住map需要“唯一键”:map中的每个元素都要有不同的键。如果需要支持多个带有同一键的元素,有两个选择:可使用map,把另一个容器(如vector或array)用作键的值,也可以使用后面描述的multimap。

insert()方法

可使用insert()方法向map添加元素,它有一个好处:允许判断键是否已经存在。insert()方法的一个问题是必须将键/值对指定为pair对象或initializer_listinsert()的基本形式的返回类型是迭代器和布尔值组成的pair,返回类型这么复杂的原因是,如果指定的键已经存在,那么insert()不会改写元素值。返回的pair中的bool元素指出,insert()是否真的插入了新的键/值对。迭代器引用的是map中带有指定键的元素。

1
2
3
4
5
6
7
8
9
10
11
12
map<int, Data> dataMap;
auto ret = dataMap.insert({1, Data(4) });
if (ret.second)
cout <<"Insert succeeded!" << endl;
else
cout << "Insert failed!" << endl;

ret = dataMap.insert(make_pair(1, Data(6))); // Using a pair object
it (ret.second)
cout << "Insert succeeded!" << endl;
else
cout << "Insert failed!" << endl;

ret变量的类型是pair,如下所示:

1
pair<map<int, Data>::iterator, bool>ret;

pair的第一个元素是键类型为int、值类型为Data的map的map选代器。该pair的第二个元素为布尔值。

使用if语句的初始化器,只使用一条语句,即可将数据插入map并检查结果,如下所示:

1
2
3
4
if (auto result = dataMap.insert({1, Data(4) }); result.second)
cout << "Insert succeeded!"<< endl;
else
cout <<"Insert failed!" << endl;

甚至可将其与C++17结构化绑定结合使用:

1
2
3
4
if (auto [iter, success] = dataMap.insert({ 1, Data(4) }); success)
cout << "Insert succeeded!" << endl;
else
cout <<"Insert failed!"<<endl;

insert_or_assign()方法

insert_or_assign()insert()的返回类型类似。但是,如果已经存在具有给定键的元素,insert_or_assign()将用新值重写旧值,而insert()在这种情况下不重写旧值。与insert()的另一个区别在于,insert_or_assign()有两个独立的参数:键和值。

1
2
3
4
5
ret = dataMap.insert_or_assign(1, Data(7));
if (ret.second)
cout << "Inserted." << endl;
else
cout << "Overwritten."<< endl;

operator[]

向map插入元素的另一种方法是通过重载的operator[]。这种方法的区别主要在于语法:键和值是分别指定的。此外,operator[]总是成功。如果给定键没有对应的元素值,就会创建带有对应键值的新元素。如果具有给定键的元素已经存在,operator[]会将元素值替换为新指定的值。

1
2
3
map<int, Data> dataMap;
dataMap[1] = Data(4);
dataMap[1] = Data(6); // Replaces the element with key 1

不过,operator[]有一点要注意:它总会构建一个新的值对象,即使并不需要使用这个值对象也同样如此。因此,需要为元素值提供一个默认的构造函数,从而可能会比insert()的效率低。

如果请求的元素不存在,operator[]会在map中创建一个新元素,所以这个运算符没有被标记为const。尽管这很明显,但有时可能会看上去违背常理。

emplace方法

map支持emplace()emplace_hint(),从而在原位置构建元素,这与vector的emplace方法类似。C++17添加了try_emplace()方法,如果给定的键尚不存在,则在原位置插入元素;如果map中已经存在相应的键,则什么都不做。

map迭代器

map迭代器的工作方式类似于顺序容器的选代器。主要区别在于选代器引用的是键值对,而不只是值。如果要访问值,必须通过pair对象的second字段来访问。

1
2
for (auto iter = cbegin(dataMap); iter != cend(dataMap); ++iter)
cout << iter->second.getValue() << endl;

查找元素

map可根据指定的键查找元素,复杂度为指数时间。如果知道指定键的元素存在于map中,那么查找它的最简单方式是,只要在非const map或对map的非const引用上调用,就通过operator[]进行查找。operator[]的好处在于返回可直接使用和修改的元素引用,而不必考虑从pair对象中获得值。

1
2
3
4
map<int, Data> dataMap;
dataMap[1] = Data(4);
dataMap[1] = Data(6);
dataMap[1].setValue(100);

然而,如果不知道元素是否存在,就不能使用operator[]。因为如果元素不存在,这个运算符会插入一个包含相应键的新元素。作为替换方案,map提供了find()方法。如果元素在map中存在, 这个方法返回指向具有指定键的元素的选代器;如果元素在map中不存在,则返回end()选代器。

1
2
3
auto it = dataMap.find(1);
if(it != end(dataMap))
it->second.setValue (100);

如果只想知道在map中是否存在具有给定键的元素,那么可以使用count()成员函数。这个函数返回map中给定键的元素个数。对于map来说,这个函数返回的结果不是0就是1,因为map中不允许有具有重复键的元素

删除元素

map允许在指定的选代器位置删除一个元素或删除指定迭代器范围内的所有元素,这两种操作的复杂度分别为摊还常量时间和对数时间。从客户的角度看,用于执行上述操作的两个erase()方法等同于顺序容器中的erase()方法。

节点

所有有序和无序的关联容器都被称为基于节点的数据结构。从C++17开始,标准库以节点句柄(node handle)的形式,提供对节点的直接访问。确切类型并未指定,但每个容器都有一个名为node_type的类型别名,它指定容器节点句柄的类型。节点句柄只能移动,是节点中存储的元素的所有者。它提供对键和值的读写访问。

可基于给定的选代器位置或键,从关联容器(作为节点句柄)提取节点。从容器提取节点时,将其从容器中删除,因为返回的节点句柄是所提取元素的唯一拥有者。

C++提供了新的insert()重载,以允许在容器中插入节点句柄。使用extract()来提取节点句柄,使用insert()来插入节点句柄,可有效地将数据从一个关联容器传递给另一个关联容器,而不需要执行任何复制或移动。甚至可将节点从map移到multimap,从set移到multiset。下面的代码将键为1的节点转到第二个map:

1
2
3
map<int, Data> dataMap2;
auto extractedNode = dataMap.extract(1);
dataMap2.insert(std::move (extractedNode));

还有一个操作merge(),可将所有节点从一个关联容器移到另一个关联容器。无法移动的节点留在源容器中。一个示例如下:

1
2
3
map<int, int> src = { {1, 11}, {2, 22} };
map<int, int> dst = { {2, 22}, {3, 33}, {4, 44}, {5, 55} };
dst.merge(src);

完成合并操作后,src仍然包含一个元素{2, 22},因为目标已经包含这个元素,所以无法移动。操作后,dst包含{1, 11}、{2, 22}、{3, 33}、{4, 44}和{5, 55}。

multimap

multimap是一种允许多个元素使用同一个键的map。和map一样,multimap支持统一初始化。multimap的接口和map的接口几乎相同,区别在于:

  • multimap不提供operator[]at()。它们的语义在多个元素可以使用同一个键的情况下没有意义。
  • 在multimap上执行插入操作总是会成功。因此,添加单个元素的multimap::insert()方法只返回iterator而不返回pair。
  • map支持insert_or_assign()try_emplace()方法,而multimap不支持。

multimap允许插入相同的键值对。如果要避免这种冗余,必须在插入新元素之前执行显式检查。

multimap的最棘手之处是查找元素。不能使用operator[],因为并没有提供operator[]find()也不是非常有用,因为find()返回的是指向具有给定键的任意一个元素的iterator(未必是具有这个键的第一个元素)。

然而,multimap将所有带同一个键的元素保存在一起,并提供方法以获得这个子范围的iterator,这个子范围内的元素在容器中具有相同的键。lower_bound()upper_bound()方法分别返回匹配给定键的第一个元素和最后一个元素之后那个元素的对应 iterator。如果没有元素匹配这个键,那么lower_bound()upper_bound()返回的iterator相等。

如果需要获得具有给定键的元素对应的iterator,使用equal_range()方法比依次调用lower_bound()upper_bound()更高效。equal_range()返回两个iterator的pair,这两个iterator分别是lower_bound()upper_bound()返回的iterator。

map中也有lower_bound()upper_bound()equal_range()方法,但由于map中不允许多个元素带有同一个键,因此在map中,这些方法的用处不大。

set

set容器定义在<set>头文件中,和map非常类似。 区别在于set保存的不是键值对,在set中,值本身就是键。如果信息没有显式的键,且希望进行排序(不包含重复)以便快速地执行插入、查找和删除,就可以考虑使用set容器来存储此类信息。

set提供的接口几乎和map提供的接口完全相同,主要区别在于set没有提供operator[]insert_or_assign()try_emplace()。不能修改set中元素的键/值,因为修改容器中的set元素会破坏顺序。

multiset

multiset和set的关系等同于multimap和map的关系。multiset支持set的所有操作,但允许容器中同时保存多个互等的元素。这里没有提供multiset的例子,因为multiset与set和multimap太相似了。

无序关联容器/哈希表

标准库支持无序关联容器或哈希表。这种容器有4个:unordered_map,unordered_multimap,unordered_set和unordered_multiset。此前讨论的map、multimap、set和multiset容器对元素进行排序,而这些新的无序版本不会对元素进行排序。

哈希函数

无序关联容器也称为哈希表,这是因为它们使用了哈希函数(hash function)。哈希表的实现通常会使用某种形式的数组,数组中的每个元素都称为桶(bucket)。每个桶都有一个特定的数值索引,哈希函数将键转换为哈希值,再转换为桶索引。与这个键关联的值在桶中存储。

哈希函数的结果未必是唯一的。两个或多个键哈希到同一个桶索引,就称为冲突(collision)。当使用不同的键得到相同的哈希值,或把不同的哈希值转换为同一桶索引时,会发生冲突。可采用多种方法来处理冲突,例如二次重哈希线性链等方法。使用线性链时,桶不直接包含与键关联的数据值,而包含一个指向链表的指针。这个链表包含特定桶中的所有数据值。图17-1展示了原理。

从中能看出,相比普通map的查找方式,这种查找方式要快得多,但查找速度完全取决于冲突次数。哈希函数的选择非常重要。不产生冲突的哈希函数称为“完美哈希”。完美哈希的查找时间是常量:常规的哈希查找时间平均接近于1,与元素数量无关。随着冲突数的增加,查找时间会增加,性能会降低。增加基本哈希表的大小,可以减少冲突,但需要考虑高速缓存的大小。

C++标准为指针和所有基本数据类型提供了哈希函数,还为error_codeerror_conditionoptionalvariantbitsetunique_ptrshared_ptrtype_indexstringstring_viewvector<bool>thread::id提供了哈希函数。如果要使用的键类型没有可用的标准哈希函数,就必须实现自己的哈希函数。

下面的示例演示了如何编写自定义哈希函数。这个示例仅将请求传递给可用的一个标准哈希函数。

1
2
3
4
5
6
7
8
9
10
11
class IntWrapper {
public:
IntWrapper(int i) : mWrappedInt(i) { }
int getValue() const { return mWrappedint; }
private:
int mWrappedint;
};

bool operator--(const IntWrapper& lhs, const IntWrapper& rhs) {
return lhs.getValue() == ths.getValue();
}

为给IntWrapper编写哈希函数,应给IntWrapper编写std:hash模板的特例。std::hash模板在<functional>中定义。这个特例需要实现函数调用运算符,以计算并返回给定IntWrapper实例的哈希。对于这个示例,仅把请求传递给整数的标准哈希函数:

1
2
3
4
5
6
7
8
9
namespace std {
template<> struct hash<IntWrapper> {
using argument_type = IntWrapper;
using result_type = size_t;
result_type operator () (const argument_type& f) const {
return std::hash<int>()(f.getValue());
}
};
}

注意一般不允许把任何内容放在std名称空间中,但std类模板特例是这条规则的例外。hash类模板需要两个类型定义。函数调用运算符的实现只有一行代码,它为整数的标准哈希函数创建了一个实例std:hash<int>(),然后对该实例通过参数f.getValue()执行函数调用运算符。

unordered_map

unordered_map容器在<unordered_map>头文件中定义,也是一个类模板,如下所示:

1
2
3
4
5
6
7
template <class Key,
class T,
class Hash = hash<Key>,
Class Pred = std::equal_to<Key>,
class Alloc = std::allocator<std::pair<const Key,T>>
>
class unordered_map;

共有5个模板参数:键类型、值类型、哈希类型、判等比较类型和分配器类型。通过后面3个参数可以分别自定义哈希函数、判等比较函数和分配器函数。通常可忽略这些参数,因为它们有默认值。建议保留默认值。最重要的参数是前两个参数。与map一样,可使用统一初始化机制来初始化unordered_map, 如下所示:

1
2
3
4
5
6
7
8
9
10
11
unordered_map<int, string> m = {
{ 1, "Item 1"},
{ 2, "Item 2"},
{ 3, "Item 3"},
{ 4, "Item 4"}
};

cout << key << " " <<value << endl;

for (const auto&[key, value] : m)
cout << key << value << endl;


与普通的map一样,unordered_map中的所有键都应该是唯一的。

unordered_multimap

unordered_multimap是允许多个元素带有同一个键的unordered_map。 两者的接口几乎相同,区别在于:

  • unordered_multimap没有提供operator[]运算符和at(),它们的语义在多个元素可以使用同一个键的情况下没有意义。
  • 在unordered_multimap上执行插入操作总是会成功。因此,添加单个元素的unordered_multimap:insert()方法只返回迭代器而非pair。
  • unordered_map支持insert_or_assign()try_emplace()方法,而ordered_multimap不支持这两个方法。

unordered_set/unordered_multiset

<unordered_set>头文件定义了unordered_set和unordered_multiset,这两者分别类似于set和multiset;区别在于它们不会对键进行排序,而且使用了哈希函数。unordered_set和unordered_map的区别和之前讨论的set和map之间的区别类似,因此这里不再赘述。

其他容器

标准C风格数组

string

可将string看成字符的顺序容器。因此,C++ string实际上是一种功能完备的顺序容器。string包含的begin()end()方法返回string中的选代器,还包含insert()push_back()erase()size()empty()方法,以及基本顺序容器包含的其他所有内容。string非常接近于vector,甚至还提供了reserve()capacity()方法。

传统意义上,输入流和输出流并不是容器,因为它们并不保存元素。然而,可以把它们看成元素的序列,因而具有标准库容器的一些特性。C++流没有直接提供与标准库相关的任何方法,但是标准库提供了名为istream_iteratorostream_iterator的特殊迭代器,用于“遍历”输入流和输出流。

bitset

bitset是固定长度的位序列的抽象。一个位只能表示两个值——1和0,这两个值可以表示开关和真/假等意义。bitset还使用了设置(set)和清零(unset)两个术语。可将一个位从一个值切换(toggle)或翻转(nip)为另一个值。

bitset并不是真正的标准库容器:bitset的大小固定,没有对元素类型进行模板化,也不支持迭代。

bitset基础

bitset定义在<bitset>头文件中,根据保存的位数进行模板化。默认构造函数将bitset的所有字段初始化为0。另一个构造函数根据由0和1字符组成的字符串创建bitset。可通过set()reset()flip()方法改变单个位的值,通过重载的operator[]运算符可访问和设置单个字段的值。

注意对非const对象应用operator[]会返回一个代理对象,可为这个代理对象赋予一个布尔值,调用flip()~取反。还可通过test()方法访问单独字段。此外,通过普通的插入和抽取运算符可以流式处理bitsetbitset以包含0和1字符的字符串形式进行流式处理。

下面是一个简单例子:

1
2
3
4
5
6
7
8
9
10
bitset<10> myBitset;
myBitset.set(3);
myBitset.set(6);
myBitset[8] = true;
myBitset[9] = myBitset[3];

if (myBitset.test (3)) {
cout << "Bit 3 is set!"<< endl;
cout << myBitset << endl;
}

按位运算符

除基本的位操作外,bitset还实现了所有的按位运算符:&|^~<<>>&=|=^=<<=>>=。这些运算符的行为和操作真正的位序列相同。

1
2
3
4
5
6
7
8
9
10
auto str1 = "0011001100";
auto str2 - "0000111100";
bitset<10> bitsOne(stri);
bitset<10> bitsTwo(str2);

auto bitsThree = bitsOne & bitsTwo;
cout << bitsThree << endl;

bitsThree <<=4;
cout << bitsThree << endl;

掌握标准库算法

算法概述

算法把迭代器作为中介操作容器,而不直接操作容器本身。这样,算法没有绑定至特定的容器实现。所有标准库算法都实现为函数模板的形式,其中模板类型参数一般都是迭代器类型。将迭代器本身指定为函数的参数。大部分算法都定义在<algorithm>头文件中,一些数值算法定义在<numeric>头文件中。它们都在std名称空间中

find()和find_if()算法

find()在某个迭代器范围内查找特定元素。可将其用于任意容器类型的元素。这个算法返回引用所找到元素的迭代器;如果没有找到元素,则返回迭代器范围的尾迭代器。注意调用find()时指定的范围不要求是容器中元素的完整范围,还可以是元素的子集。

如果find()没有找到元素,那么返回的迭代器等于函数调用中指定的尾迭代器,而不是底层容器的尾迭代器

下面是一个std::find()示例。

1
2
3
4
5
6
7
8
9
while (true) {
cout <<"Enter a number to lookup (0 to stop):";
cin >> num;
if (num -- 0)
break;
auto endit = cend (myVector);
auto it = find(cbegin(myVector), endIt, num);
if (it == endit)
cout <<"Could not find "<< num << endl;

调用find()时将cbegin(myVector)endIt作为参数,其中,endIt定义为cend(myVector),因此搜索的是vector的所有元素。如果需要搜索一个子范围,可修改这两个迭代器。

使用if语句的初始化器(C++17),可使用如下加粗语句来调用find()并查找结果:

1
2
if (auto it = find(cbegin(myVector), endIt, num); it == endIt) {
cout << "Found " << *it << endl;

如果容器提供的方法具有与泛型算法同样的功能,那么应该使用相应的方法,那样速度更快。比如,泛型算法find()的复杂度为线性时间,用于map迭代器时也是如此;而map中find()方法的复杂度是对数时间。

find_if()find()类似,区别在于find_if()接收谓词函数回调作为参数,而不是简单的匹配元素。谓词返回true或false。find_if()算法对范围内的每个元素调用谓词,直到谓词返回true;如果返回了true,find_if()返回引用这个元素的迭代器。

accumulate()算法

我们经常需要计算容器中所有元素的总和或其他算术值。accumulate()函数就提供了这种功能,该函数在<numeric>中定义。通过这个函数的最基本形式可计算指定范围内元素的总和。例如,下面的函数计算vector中整数序列的算术平均值。

1
2
3
4
double arithmeticMean(const vector<int>& nums) {
double sum = accumulate(cbegin(nums), cend(nums), 0);
return sum / nums.size();
}

accumulate()算法接收的第三个参数是总和的初始值,在这个例子中为0(加法计算的恒等值),表示从0开始累加总和。accumulate()的第二种形式允许调用者指定要执行的操作,而不是执行默认的加法操作。这个操作的形式是二元回调。假设需要计算几何平均数。如果一个序列中有m个数字,那么几何平均数就是m个数字连乘的m次方根。在这个例子中,调用accumulate()计算乘积而不是总和。因此这个程序可以这样写:

1
2
3
4
5
6
7
8
int product (int num1, int num2) {
return num1 * num2;
}

double geometricMean(const vector<int>& nums) {
double mult = accumulate (cbegin(nums), cend(nums), 1, product);
return pow (mult, 1.0 / nums.size());
}

注意,将product()函数作为回调传递给accumulate(),而把累计的初始值设置为1(乘法计算的恒等值)而不是0。

在算法中使用移动语义

与标准库容器一样,标准库算法也做了优化,以便在合适时使用移动语义。这可极大地加速特定的算法,例如remove()。因此,强烈建议在需要保存到容器中的自定义元素类中实现移动语义。通过实现移动构造函数和移动赋值运算符,任何类都可添加移动语义。它们都被标记为noexcept,因为它们不应抛出异常。

std::function

std::function<functional>头文件中定义,可用来创建指向函数、函数对象或lambda表达式的类型:从根本上说可以指向任何可调用的对象。它被称为多态函数包装器,可以当成函数指针使用,还可用作实现回调的函数的参数。std::function模板的模板参数看上去和大多数模板参数都有所不同。语法如下所示:

1
std::function<R(ArgTypes...)>

R是函数返回值的类型,ArgTypes是一个以逗号分隔的函数参数类型的列表。

下例演示如何使用std::function实现一个函数指针。这段代码创建了一个函数指针f1,它指向函数func()。定义f1后,可通过函数名funcf1调用func()

1
2
3
4
5
6
7
8
9
void func(int num, const string& str) {
cout << "func(" << num <<", "<< str << endl;
}

int main() {
function<void(int, const string&)> f1 = func;
f1(1, "test");
return 0;
}

下面的f1定义实现了同样的功能,而且简短得多,但f1的编译器推断类型是函数指针(即void(*f1)(int, const string&))而不是std::function

1
auto f1 = func;

由于std::function类型的行为和函数指针一致,因此可传递给标准库算法,如下面这个使用了find_if()算法的例子所示:

1
2
3
4
5
6
7
8
9
10
11
12
bool isEven(int num1) {
return num % 2 == 0;
}

int main {
vector<int> vec{1,2,3,4,5,6,7,8,9};
function<bool (int)> fcn = isEven;
auto result = find_if(cbegin(vec), cend(vec), fcn);
if (result != cend(vec))
cout <<"First even number:"<< *result << endl;
return 0;
}

std::function真正有用的场合是将回调作为类的成员变量。在接收函数指针作为自定义函数的参数时,也可以使用std::function。下例定义了process()函数,这个函数接收一个对vector的引用和std::functionprocess()函数迭代给定vector中的所有元素,然后对每个元素调用指定的函数f。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void process(const vector<int>& vec, function<void(int)> f) {
for (auto& i : vec)
f(i);
}

void print (int num) {
cout << num <<" ";
}

int main() {
vector<int>vec{ 0,1,2,3,4,5,6,7,8,9 };
process(vec, print);

int sum = 0;
process (vec, [&sum](int num) {sum += num;});
return 0;
}

lambda表达式

使用lambda表达式可编写内嵌的匿名函数,而不必编写独立函数或函数对象,使代码更容易阅读和理解。

语法

下面定义一个lambda表达式,它仅把一个字符串写入控制台。

lambda表达式以方括号[]开始(这称为lambda引入符),其后是花括号{},其中包含lambda表达式体。lambda表达式被赋予自动类型变量basicLambda。第二行使用普通的函数调用语法执行lambda表达式。

1
2
auto basicLambda = [] {cout << "Hello from Lambda"<< endl; };
basicLambda();

lambda表达式可以接收参数。参数在圆括号中指定,用逗号分隔开,与普通函数相同。下面是使用参数的示例:

1
2
auto parametersLambda = [](int value){ cout <<"The value is "<<value << endl; };
parametersLambda(42);

如果lambda表达式不接收参数,就可指定空圆括号或忽略它们。

lambda表达式可返回值。返回类型在箭头后面指定,称为拖尾返回类型。下例定义的lambda表达式接收两个参数,返回它们的和:

1
2
auto returningLambda = [] (int a, int b) -> int { return a + b; };
int sum = returningLambda(11, 22);

可以忽略返回类型。如果忽略了返回类型,编译器就根据函数返回类型推断规则来推断lambda表达式的返回类型,如下所示:

1
2
auto returningLambda = [](int a, int b) { return a + b; };
int sum = returningLambda(11, 22);

lambda表达式可以在其封装的作用域内捕捉变量。例如,下面的lambda表达式捕捉变量data,将它用于lambda表达式体:

1
2
double data = 1.23;
auto capturingLambda = [data] { cout <<"Data "<< data << endl; };

lambda表达式的方括号部分称为lambda捕捉块(capture block)。捕捉变量的意思是可在lambda表达式体中使用这个变量。指定空白的捕捉块[]表示不从所在作用域内捕捉变量。如上例所示,在捕捉块中只写出变量名,将按值捕捉该变量。

编译器将lambda表达式转换为某种未命名的仿函数(即函数对象),捕捉的变量变成这个仿函数的数据成员。将按值捕捉的变量复制到仿函数的数据成员中。这些数据成员与捕捉的变量具有相同的const性质。在前面的capturingLambda示例中,仿函数得到非const数据成员data,因为捕捉的变量data不是const。但在下例中,仿函数得到const数据成员data, 因为捕捉的变量是const。

1
2
const double data = 1.23;
auto capturingLambda = [data]{ cout << "Data " << data << endl; };

仿函数总是实现函数调用运算符operator()。对于lambda表达式,这个函数调用运算符被默认标记为const,这表示即使在lambda表达式中按值捕捉了非const变量,lambda表达式也不能修改其副本。把lambda表达式指定为mutable,就可以把函数调用运算符标记为非const:

1
2
double data = 1.23;
auto capturinglambda = [data] () mutable { data *= 2; cout << "Data - " << data << endl; };

在这个示例中,非const变量data是按值捕捉的,因此仿函数得到了一个非const数据成员,它是data的副本。因为使用了mutable关键字,函数调用运算符被标记为非const,所以lambda表达式体可以修改data的副本。注意如果指定了mutable,就必须给参数指定圆括号,即使圆括号为空,也是如此。

在变量名前面加上&,就可按引用捕捉它。按引用捕捉变量时,必须确保执行lambda表达式时,该引用仍然是有效的。可采用两种方式来捕捉所在作用域内的所有变量。

  • [=]:通过值捕捉所有变量,
  • [&]:通过引用捕捉所有变量

还可以酌情决定捕捉哪些变量以及这些变量的捕捉方法,方法是指定一个捕捉列表,其中带有可选的默认捕捉选项。前缀为&的变量通过引用捕捉。不带前缀的变量通过值捕捉。默认捕捉应该是捕捉列表中的第一个元素,可以是=&。例如

  • [&x]:只通过引用捕捉x,不捕捉其他变量
  • [x]:只通过值捕捉x,不捕捉其他变量,
  • [=,&x,&y]:默认通过值捕捉,变量x和y是例外,这两个变量通过引用捕捉。
  • [&,x]:默认通过引用捕捉,变量x是例外,这个变量通过值捕捉。
  • [&x,&x]:非法,因为标识符不允许重复。
  • [this]:捕捉周围的对象。即使没有使用this->,也可在lambda表达式体中访问这个对象。
  • [*this]:捕捉当前对象的副本。如果在执行lambda表达式时对象不再存在,这将十分有用。

使用默认捕捉时,只有在lambda表达式体中真正使用的变量才会被捕捉,使用值(=)或引用(&)捕捉。未使用的变量不捕捉,

不建议使用默认捕捉,即使只捕捉在lambda表达式体中真正使用的变量,也同样如此。使用=默认捕捉可能在无意中引发昂贵的复制。使用&默认捕捉可能在无意间修改所在作用域内的变量,建议显式指定要捕捉的变量。

lambda表达式的完整语法如下所示:

1
2
3
[capture_block] (parameters) mutable constexpr
noexcept_specifier attributes ->
return_type { body }

lambda表达式包含以下部分

  • 捕捉块(capture block):指定如何捕捉所在作用域内的变量,并供lambda主体部分使用。
  • 参数(parameter,可选):lambda表达式使用的参数列表。只有在不需要任何参数并且没有指定mutable、constexpr、noexcep说明符、属性和返回类型的情况下才能忽略参数列表。该参数列表和普通函数的参数列表类似,
  • mutable(可选):把lambda表达式标记为mutable
  • constexpr(可选):将lambda表达式标记为constexpr,从而可在编译时计算。如果满足某些限制条件,即使忽略,也可能为lambda表达式隐式使用constexpr。
  • noexcept说明符(可选):用于指定noexcept子句,与普通函数的noexcept子句类似。
  • 特性(attribute,可选):用于指定lambda表达式的特性。
  • 返回类型(可选):返回值的类型。如果忽略,编译器会根据函数返回类型推断原则判断返回类型。

泛型lambda表达式

可以给lambda表达式的参数使用自动推断类型功能,而无须显式指定它们的具体类型。要为参数使用自动推断类型功能,只需要将类型指定为auto,类型推断规则与模板参数推断规则相同。

下例定义了一个泛型lambda表达式isGreaterThan100。这个lambda表达式与find_if()算法一起使用,一次用于整数vector,另一次用于双精度vector。

1
2
3
4
5
6
7
8
9
10
11
auto isGreaterThan100 = [](auto i)( return i > 100; };

vector<int> ints(11, 55, 101, 200);
auto it1 = find_if(cbegin(ints), cend(ints), isGreaterThan100);
if (it1 != cend(ints))
cout << "Found a value > 100: " << *it1 <<endl;

vector<double> doubles { 11.1, 55.5, 200.2 };
auto it2 = find_if(cbegin(doubles), cend(doubles), isGreaterThan100);
if (it2 != cend(doubles))
cout << "Found a value > 100: "<< *it2 << endl;

lambda捕捉表达式

lambda捕捉表达式允许用任何类型的表达式初始化捕捉变量。这可用于在lambda表达式中引入根本不在其内部的作用域内捕捉的变量,例如,下面的代码创建一个lambda表达式,其中有两个变量:myCapture使用lambda捕捉表达式初始化为字符串Pi:,pi在内部的作用域内按值捕捉。注意,用捕捉初始化器初始化的非引用捕捉变量,如myCapture,是通过复制来构建的,这表示省略了const限定符:

1
2
double pi = 3.1415;
auto myLambda = [myCapture = "Pi: ", pi] { cout << myCapture << pi; };

lambda捕捉变量可用任何类型的表达式初始化,也可用std:move()初始化。这对于不能复制、只能移动的对象而言很重要,例如unique_ptr。默认情况下,按值捕捉要使用复制语义,所以不可能在lambda表达式中按值捕捉unique_ptr。使用lambda捕捉表达式,可通过移动来捕捉它,例如:

1
2
auto myPtr = std::make_unique<double>(3.1415);
auto mylambda = [ p = std::move(myPtr)] { cout << *p; };

将lambda表达式用作返回类型

使用前面讨论的std::function,可从函数返回lambda表达式,分析以下定义:

1
2
function<int(vold)> multiplyBy2Lambda(int x) {
return [x]{ return 2 * x; };

这个函数的主体部分创建一个lambda表达式,通过值捕捉所在作用域的变量x,并返回一个整数,这个整数是传给multiplyBy2Lambda()的值的两倍。multiplyBy2Lambda()函数的返回类型为function<int(void)>,即一个不接收参数并返回一个整数的函数。函数体中定义的lambda表达式正好匹配这个原型。变量x通过值捕捉,因此,
在lambda表达式从函数返回之前,x值的副本被绑定至lambda表达式中的x。可按如下方式调用该函数:

1
2
function<int(void)> fn = multiplyBy2Lambda(5);
cout << fn() << endl;

标准库算法示例

count_if()

下例通过count_if()算法计算给定vector中满足特定条件的元素个数。通过lambda表达式的形式给出条件,这个lambda表达式通过值捕捉所在作用域内的value变量。

1
2
3
vector<int> vec {1, 2, 3, 4, 5, 6, 7, 8, 9};
int value = 3;
int cnt = count_if(cbegin(vec), cend(vec), [value] (int i) { return i > value;});

可对上面的这个例子进行扩展,以演示通过引用捕捉变量的方式。下面的lambda表达式通过递增所在作用域内按引用捕捉的一个变量,来计算调用次数。

1
2
3
4
vector<int> vec = {1, 2, 3, 4, 5, 6, 7, 8, 9};
int value = 3;
int cntlambdaCalled = 0;
int cnt = count_if(cbegin(vec), cend(vec), [value, &cntLambdaCalled] (int i) {++cntLambdaCalled; return i > value;});

generate()

generate()算法需要一个迭代器范围,它把该迭代器范围内的值替换为从函数返回的值,并作为第三个参数。下例结合generate()算法和一个lambda表达式将2、4、8、16等值填充到vector。

1
2
3
vector<int> vec(10);
int value = 1;
generate(begin(vec), end(vec), [&value]{ value *= 2; return value; });

函数对象

在类中,可重载函数调用运算符,使类的对象可取代函数指针。将这些对象称为函数对象(finction object),或称为仿函数(functor)。

很多标准库算法,例如find_if()以及accumulate(),可接收函数指针、lambda表达式和仿函数作为参数,以更改函数行为。

建议尽可能使用lambda表达式,而不是小型函数对象,因为lambda表达式更便于使用、读取和理解。

算术函数对象

C++提供了5类二元算术运算符的仿函数类模板:plus,minus,multiplies,divides和modulus。此外提供了一元的取反操作。这些类对操作数的类型模板化,是对实际运算符的包装。它们接收一个或两个模板类型的参数,执行操作并返回结果。下面是一个使用plus类模板的示例:

1
2
3
plus<int> myPlus;
int res = myPlus(4, 5);
cout << res <<endl;

算术函数对象的好处在于可将它们以回调形式传递给算法,而使用算术运算符时却不能直接这样做。

算术函数对象只不过是对算术运算符的简单包装。如果在算法中使用函数对象作为回调,务必保证容器中的对象实现了恰当的操作。

透明运算符仿函数

C++支持透明运算符仿函数,允许忽略模板类型参数。例如,可只指定multiplies<>(而非multiplies<int>()

1
2
3
double geometricMeanTransparent(const vector<int>& nums)
double mult = accumulate(cbegin (nums), cend(nums), 1, multiplies<>());
return pow (mult, 1.0 / nums.size());

这些透明运算符仿函数的一个重要特性是,它们是异构的,即它们不仅比非透明运算符仿函数更简明,而且具有真正的函数性优势。例如,下面的代码使用透明运算符仿函数和双精度数1.1作为初始值,而vector包
含整数。accumulate()会把结果计算为double值,result是6.6:

1
2
vector<int> nums { 1,2,3 };
double result = accumulate(cbegin(nums), cend(nums), 1.1, mu1tip1ies<>());

如果这些代码使用非透明运算符仿函数,accumulate()会把结果计算为整数,result就是6。编译这些代码时,编译器会给出可能丢失数据的警告:

1
2
vector<int> nums { 1,2,3 };
double result - accumulate (cbegin (nums), cend(nums), 1.1, multiplies<int>());

比较函数对象

除算术函数对象类外,C++语言还提供了所有标准的比较:equal_tonot_equal_tolessgreaterless_equalgreater_equal。可将priority_queue的比较模板修改为greater。priority_queue模板定义如下所示:

1
template <class T, class Container = vector<T>, class Compare = less<T>>;

遗憾的是,Compare类型参数是最后一个参数,这意味着要指定比较操作,还必须指定容器类型。如果希望priority_queue通过greater按升序对元素排序,需要把上例中的priority_queue定义改为:

1
priority_queue<int, vector<int>, greater<>>myQueue;

注意,使用透明运算符greater<>定义了myQueue。与使用非透明运算符相比,使用透明比较回调的性能稍好一些。

逻辑函数对象

C++为3个逻辑操作提供了函数对象类,它们分别是:logical_not(operator!)logical_and(operator&&)logical_or(operator||)。逻辑操作只操作true和false值。

例如,可使用逻辑仿函数来实现allTrue()函数,这个函数检查容器中的所有布尔标志是否都为true:

1
2
3
bool allTrue (const vector<bool>& flags) {
return accumulate(begin(flags), end(flags), true, logical_and<>());
}

类似地,可使用logical_or仿函数实现anyTrue()函数,如果容器中至少有一个布尔标志为true,那么这个函数返回true:

1
2
3
bool anyTrue (const vector<bool>& flags) {
return accumulate(begin(flags),end(flags), false, logical_or<>());
}

按位函数对象

C++为所有按位操作添加了函数对象,它们分别是:bit_and(operator&)、bit_or(operator|)、bit_xor(operator^)和bit_not(operator!)。

函数对象适配器

函数适配器对函数组合(functional composition)提供了一些支持,也就是能将函数组合在一起,以精确提供所需的行为。

绑定器

绑定器(binder)可用于将函数的参数绑定至特定的值。为此要使用<functional>头文件中定义的std::bind(),它允许采用灵活的方式绑定可调用的参数。既可将函数的参数绑定至固定值,甚至还能重新安排函数参数的顺序。下面通过一个例子进行解释,假定有一个func()函数,它接收两个参数:

1
void func(int num, string_view str)

下面的代码演示如何通过bind()func()函数的第二个参数绑定至固定值myString。结果保存在f1()中。使用auto关键字是因为C++标准未指定bind()的返回类型,因而是特定于实现的。没有绑定至指定值的参数应该标记为_1_2_3等。这些都定义在std::placeholders名称空间中。

f1()的定义中,_1指定了调用func()时,f1()的第一个参数应该出现的位置。之后,就可以用一个整型参数调用f1()

1
2
3
string mystring = "abc";
auto f1 = bind(func, placeholders::_1, myString);
f1(16);

bind()还可用于重新排列参数的顺序,如下列代码所示。_2指定了调用func()时,20的第二个参数应该出
现的位置。换句话说,12(绑定的意义是:f2()的第一个参数将成为函数func()的第二个参数,f2()的第二个参数将成为函数func()的第一个参数

1
2
auto f2 = bind(func, placeholders::_2, placeholders::_1);
f2("Test"32);

<functional>头文件定义了std::ref()cref()辅助模板函数。它们可分别用于绑定引用或const引用。如果使用bind()调用函数,引用变量的值就不递增,因为建立了变量的一个副本。使用std::ref()正确传递对应的引用后会递增。

结合重载函数使用时,绑定参数会出现一个小问题。如果要对这些重载的函数使用bind(),那么必须显式地指定绑定这两个重载中的哪一个。如果需要绑定接收浮点数参数的重载函数的参数,需要使用以下语法:

1
auto f4 = bind((void(*)(float))overloaded, placeholders::_1);

取反器

not_fn

取反器(negator)类似于绑定器(binder),但对调用结果取反。例如,如果想要找到测试分数序列中第一个小于100的元素,那么可以对perfectScore()的结果应用notl()取反器适配器,如下所示:

1
2
3
4
auto enditer = end(myVector);
auto it = find_if(begin(myVector), endIter, not_fn(perfectScore));
if (it == enditer)
cout << "All perfect scores" << endl;

not_fn()仿函数对作为参数传入的每个调用结果取反。注意在这个示例中,也可以使用find_if_not()算法。

前面使用not_fn()取反器的find_if()调用可以用lambda表达式更简洁地表达:

1
auto it = find_if(begin(myVector), endIter, [](int i){return i < 100; });
not1和not2

C++17引入了std::not_fn()适配器。在C++17之前,可使用std:not1()not2()适配器。not1中的“1”是指:它的操作数必须是一个一元函数(只接收一个参数)。如果操作数是二元函数(接收两个参数),则必须改用not2()。下面是一个示例:

1
2
3
auto enditer = end(myVector);
function<bool(int)> f = perfectscore;
auto it = find_if(begin(myVector), endIter, not1(f));

如果想将not1()用于自己的仿函数类,则必须确保仿函数类的定义包含两个typedef:argument_typeresult_type。如果想要使用not2(),则仿函数类的定义必须提供3个typedef:first_argument_typesecond_argument_typeresult_type。为此,最简便的方式是从unary_functionbinary_function派生自己的函数对象类,具体取决于使用的是一个参数还是两个参数。在<functional>中定义的两个类在所提供函数的参数和返回类型上模板化。例如:

1
2
3
4
5
6
class Perfectscore : public std::unary_function<int, bool> {
public:
result_type operator() (const argument_type& score) const {
return score >= 100;
}
};

可采用如下方式使用这个仿函数:

1
auto it = find_if(begin(myVector), endIter, not1(PerfectScore()));
调用成员函数

假设有一个对象容器,有时需要传递一个指向类方法的指针作为算法的回调。调用方法指针的代码和调用普通函数指针的代码是不一样的,因为前者必须在对象的上下文中调用。C++提供了mem_fn()转换函数,在传递给算法之前可以对函数指针调用这个函数。必须将string中的empty()方法指针指定为&string::empty&string::部分不是可选的。

1
2
3
4
5
void findEmptyString(const vector<string>& strings) {
auto enditer = end(strings);
auto it = find_if(begin(strings), endIter, mem_fn(&string::empty));
if (it == enditer)
cout <<"No empty strings!"<< endl;

mem_fn()生成一个用作find_if()回调的函数对象。每次调用它时,都会对参数调用empty()方法。

即使容器内保存的不是对象本身,而是对象指针,mem_fn()的使用方法也完全一样,例如:

1
2
3
void findEmptystring(const vector<string*>& strings) {
auto enditer = end(strings);
auto it = find_if(begin(strings), enditer, mem_fn(&string::empty));

std::invoke()

C++17引入了std::invoke()std::invoke()<functional>中定义,可用于通过一组参数调用任何可调用对象。下例使用了三次invoke():一次调用普通函数,另一次调用lambda表达式,还有一次调用string实例的成员函数

1
2
3
4
5
6
7
8
9
void printMessage(string_view message) { cout << message << endl; }

int main() {
invoke(printMessage, "Hello invoke.");
invoke([](const auto& msg) { cout << msg << endl; }, "Hello invoke.");

string msg = "Hello invoke.";
cout << invoke(&string::size, msg) << endl;
}

invoke()本身的作用并不大,因为可以直接调用函数或lambda表达式。但在模板代码中,如果需要调用任意可调用对象,invoke()的作用会发挥出来。

算法详解

迭代器

迭代器有5类:输入、输出、正向、双向和随机访问迭代器。确切地讲,每个随机访问迭代器也是双向的,每个双向迭代器也是正向的,每个正向迭代器也是输入迭代器。满足输出迭代器要求的迭代器称为可变迭代器(mutable iterator),否则称为不可变迭代器(constant iterator)。

算法指定要使用的迭代器类型的标准方法是,在迭代器模板类型参数中使用以下名称:InputIteratorOutputIteratorForwardIteratorBidirectionalIteratorRandomAccessIterator。这些只是名称,没有提供绑定类型的检查。因此,可在调用需要RandomAccessIterator的算法时,传入双向迭代器。模板不会进行类型检查,因此允许这样的实例化。然而,函数中使用随机访问迭代器功能的代码,在使用双向迭代器时将无法成功编译。

非修改序列算法

非修改序列算法包括在某个范围内搜索元素的函数、比较两个范围的函数以及许多工具算法。

搜索算法

标准库提供了基本find()算法的一些其他变种,这些算法对元素序列执行操作。所有算法都使用默认的比较运算符operator==operator<,还提供了重载版本,以允许指定比较回调。下面是一些搜索算法的示例:

1
2
3
4
5
6
7
vector<int> myVector = {5, 6, 9. 8, 8, 3};
auto beginiter = cbegin (myVector);
auto enditer = cend(myVector);

auto it = find_if_not(beginiter, enditer, [](int i){ return i<8;});
if (it != enditer)
cout <<"First element not < 8 is "<< *it << endl;

专用的搜索算法

C++17给search()算法增加了额外的可选参数,允许指定要使用的搜索算法。有三个选项:default_searcherboyer_moore_searcherboyer_moore_horspool_searcher,它们都在<functional>中定义。后两个选项实现了知名的Boyer-Moore和Boyer-Moore-Horspool搜索算法。它们十分高效,可用于在一大块文本中查找子字符串。Boyer-Moore搜索算法的复杂度如下,N是在其中搜索的序列的大小,M是要查找的模式的大小。如果未找到模式,最坏情况下的复杂度为O(N+M),如果找到模式,最坏情况下的复杂度为O(NM)。Boyer-Moore和 Boyer-Moore-Horspool算法的区别在于,在初始化以及算法的每个循环迭代中,后者的固定开销较少;但是,后者在最坏情况下的复杂度明显高于前者算法。

比较算法

可通过3种不同的方法比较整个范围内的元素:equal()mismatch()和lexicographical_compare()`。这些算法的好处是可比较不同容器内的范围。例如,可比较vector和list的内容。一般情况下,这些算法最适用于顺序容器。这些算法的工作方法是比较两个集合中对应位置的值。下面列出每个算法的工作方式。

  • equal():如果所有对应元素都相等,则返回true。最初,equal()接收三个迭代器,分别是第一个范围的首尾迭代器,以及第二个范围的首迭代器。该版本要求两个范围的元素数目相同。
  • mismatch():返回多个迭代器,每个范围对应一个迭代器,表示范围内不匹配的对应元素。与equal()一样,存在三迭代器版本和四迭代器版本。
  • lexicographical_compare():如果第一个范围内的第一个不相等元素小于第二个范围内的对应元素,或如果第一个范围内的元素个数少于第二个范围,且第一个范围内的所有元素都等于第二个范围内对应的初始子序列,那么返回true。

如果要比较两个同类型容器的元素,可使用运算符operator==operator<,而不是equal()lexicographical_compare()

计数算法

非修改计数算法有all_of()any_of()none_of()count()count_if()。下面是一个算法的示例。

1
2
3
4
//all_of()
vector<int> vec2 = {1, 1, 1, 1};
if(all_of(cbegin(vec2), cend(vec2), [](int i){ return i== 1;)))
cout << “All elements are == 1"<<endl;

修改序列算法

标准库提供了多种修改序列算法,这些算法执行的任务包括:从一个范围向另一个范围复制元素、删除元素以及反转某个范围内元素的顺序。

修改算法不能将元素插入目标范围中,仅可重写/修改目标范围中已经存在的元素。

map和multimap的范围不能用作修改算法的目标范围。这些算法改写全部元素,而在map中,元素是键值对。map和multimap将键标记为const,因此不能为其赋值。set和multiset也是如此。 替换方案是使用插入迭代器。

转换

transform()算法对范围内的每个元素应用回调,期望回调生成一个新元素,并保存在指定的目标范围中。如果希望transform()将范围内的每个元素替换为调用回调产生的结果,那么源范围和目标范围可以是同一范围。其参数是源序列的首尾迭代器、目标序列的首迭代器以及回调。例如,可按如下方式将vector中的每个元素增加100:

1
transform(begin(myVector), end(myVector), begin(myVector), [](int i){ return it 100;}};

transform()的另一种形式对范围内的元素对调用二元函数,需要将第一个范围的首尾迭代器、第二个范围的首迭代器以及目标范围的首迭代器作为参数。 下例创建两个vector,然后通过transform()计算元素对的和,并将结果保存回第一个vector:

1
transform(begin(vec1), end(vec1), begin(vec2), begin(vec1), [](int a, int b){return a + b;});

transform()和其他修改算法通常返回一个引用目标范围内最后一个值后面那个位置(past-the-end)的迭代器。

复制

copy()算法可将一个范围内的元素复制到另一个范围,从这个范围内的第一个元素开始直到最后一个元素。源范围和目标范围必须不同,但在一定限制条件下可以重叠。限制条件如下:对于copy(b,e,d),如果d在b之前,则可以重叠;但如果d处于[b,e]范围,则行为不确定。与所有修改算法类似,copy()不会向目标范围插入元素,只改写已有的元素。

下面举一个使用copy()的简单例子,这个例子对vector应用resize()方法,以确保目标容器中有足够空间。这个例子将vec1中的所有元素复制到vec2:

1
2
3
4
vector<int> vecl, vec2;

vec2.resize(size(vec1));
copy(cbegin(vec1), cend(vec1), begin(vec2));

还有一个copy_backward()算法,这个算法将源范围内的元素反向复制到目标范围。换句话说,这个算法从源范围的最后一个元素开始,将这个元素放在目标范围的最后一个位置,然后在每一次复制之后反向移动。分析copy_backward(),源范围和目标范围必须是不同的,但在一定限制条件下可以重叠。限制条件如下:对于copy_backward(b,e,d),如果d在e之后,则能正确重叠;但如果d处于(b,e]范围,则行为不确定。前面的例子可按如下代码修改为使用copy_backward()而不是copy()。注意第三个参数应该指定end(vec2)而不是begin(vec2)

1
copy_backward (cbegin (vec1),cend(vec1), end(vec2));

得到的输出完全一致。

在使用copy_if()算法时,需要提供由两个迭代器指定的输入范围、由一个迭代器指定的输出范围以及一个谓词(函数或lambda表达式)。该算法将满足给定谓词的所有元素复制到目标范围。记住,复制不会创建或扩大容器,只是替换现有元素。因此,目标范围应当足够大,从而保存要复制的所有元素。当然,复制元素后,最好删除超出最后一个元素复制位置的空间。为便于达到这个目的,copy_if()返回了目标范围内最后一个复制的元素后面那个位置的迭代器,以便确定需要从目标容器中删除的元素个数。

1
2
3
4
5
vector<int> vec1, vec2;

vec2.resize(size(vec1));
auto enditerator = copy_if(cbegin(vec1), cend(vec1), begin(vec2), [](int i){ return i % 2 == 0;});
vec2.erase(enditerator, end(vec2));

copy_n()从源范围复制n个元素到目标范围。copy_n()的第一个参数是起始迭代器,第二个参数是指定要复制的元素个数,第三个参数是目标迭代器。copy_n()算法不执行任何边界检查,因此一定要确保起始迭代器递增n个要复制的元素后,不会超过集合的end(),否则程序会产生未定义的行为。

1
2
3
4
5
6
vector<int> vec1, vec2;

size_t cnt = 0;
cnt = min(cnt, size(vec1));
vec2.resize(cnt);
copy_n(cbegin(vec1), cnt, begin(vec2));

移动

有两个和移动相关的算法:move()move_backward()。如果要在自定义类型元素的容器中使用这两个算法,那么需要在元素类中提供移动赋值运算符。main()函数创建了一个带有3个MyClass对象的vector,然后将这些元素从vecSrc移到vecDst。注意这段代码包含两种不同的move()用法。一种是,move()函数接收一个参数,将Ivalue转换为rvalue;而另一种是,接收3个参数的move()是标准库的move()算法,这个算法在容器之间移动元素。

1
2
3
4
5
6
7
Class MyClass {}

int main() {
vector<MyClass> vecSrc{MyClass("a"), MyClass("b"), MyClass("c")};
vector<MyClass> vecDst(vecSrc.size());
move(begin(vecSrc), end(vecSrc),begin(vecDst));
}

替换

replace()replace_if()算法将一个范围内匹配某个值或满足某个谓词的元素替换为新的值。比如replace_if()算法的第一个和第二个参数指定了容器中元素的范围。第三个参数是一个返回true或false的函数或lambda表达式,如果它返回true,那么容器中的对应值被替换为第四个参数指定的值;如果它返回false,则保留原始值。例如,假定要将容器中的所有奇数值替换为0:

1
2
vector<int> vec;
replace_if(begin(vec),end(vec), [](int i) { return i%2 != 0;}, 0);

replace()replace_if()也有名为replace_copy()replace_copy_if()的变体,这些变体将结果复制到不同的目标范围。它们类似于copy(),因为目标范围必须足够大,以容纳新元素。

删除

如果对vector容器应用erase(),这个解决方案的效率非常低下,因为要保持vector在内存中的连续性,会涉及很多内存操作,因而得到:二次(平方)复杂度;所谓二次复杂度,是指运行时间是输入大小的平方的函数。这个问题的正确解决方案是“删除擦除法”,

算法只能访问迭代器抽象,不能访问容器。因此删除算法不能真正地从底层容器中删除元素,而是将匹配给定值或谓词的元素替换为下一个不匹配给定值或渭词的元素。为此使用移动赋值。结果是将范围分为两个集合:一个用于保存要保留的元素,另一个用于保存要删除的元素。返回的迭代器指向要删除的元素范围内的第一个元素。如果真的需要从容器中删除这些元素,必须先使用remove()算法,然后调用容器的erase()方法,将从返回的迭代器开始到范围尾部的所有元素删除。这就是删除擦除法。

1
2
3
auto it = remove_if(begin(strings), end(strings), [](const string& str) { return str.empty(); });

strings.erase(it, end(strings));

使用“删除擦除法”时,切勿忘记erase()的第二个参数!如果忘掉第二个参数,erase()将仅从容器中删除一个元素,即作为第一个参数传递的迭代器指向的元素。

remove()remove_if()remove_copy()remove_copy_if()变体不会改变源范围,而将所有未删除的元素复制到另一个目标范围。这些算法和copy()类似,要求目标范围必须足够大,以便保存新元素。

唯一化

unique()算法是特殊的remove()remove()能将所有重复的连续元素删除。list容器提供了自己的具有同样语义的unique()方法。

抽样

sample()算法从给定的源范围返回n个随机选择的元素,并存储在目标范围。它需要5个参数:

  • 要从中抽样的范围的首尾迭代器
  • 目标范围的首迭代器,将随机选择的元素存储在目标范围
  • 要选择的元素数量
  • 随机数生成引擎

反转

reverse()算法反转某个范围内元素的顺序。将范围内的第一个元素和最后一个元素交换,将第二个元素和倒数第二个元素交换,依此类推。reverse()最基本的形式是就地运行,要求两个参数:范围的首尾迭代器。还有一个名为reverse_copy()的版本,这个版本将结果复制到新的目标范围,它需要3个参数:源范围的首尾迭代器以及目标范围的起始迭代器。
目标范围必须足够大,以便保存新元素。

shuffle()以随机顺序重新安排某个范围内的元素,其复杂度为线性时间。它可用于实现洗牌等任务。shuffle()的参数是要乱序的范围的首尾迭代器,以及一个统一的随机数生成器对象,它指定如何生成随机数。

操作算法

此类算法只有两个:for_each()for_each_n(),后者是在C++17中引入的。它们对范围内的每个元素执行回调,或对范围内的前n个元素执行回调。

for_each()

下例说明如何使用for_each()算法和lambda表达式,计算范围内元素的和与积。注意,lambda表达式只显式捕捉需要的变量,它按引用捕捉变量,否则lambda表达式内对sum和product的修改无法在lambda表达式外可见:

1
2
3
4
5
vector<int> myVector;

int sum = O;
int product = 1;
for_each(cbegin(myVector), cend(myVector), [&sum, &product](int i){ sum += i; product *= i;});

for_each_n()

for_each_n()算法需要范围的起始迭代器、要迭代的元素数量以及函数回调。它返回的迭代器等于begin+n。它通常不执行任何边界检查。下例只迭代map的前两个元素:

1
for_each_n(cbegin(myMap),2, [] (const auto& p) {cout << p.first << p.second << endl; });

交换算法

C++标准库提供了以下交换算法

swap()

std::swap()用于有效地交换两个值,并使用移动语义(如果可用的话)。它的使用十分简单:

1
2
3
4
5
int a = 11;
int b = 22;
cout << "Before swap(): a = "<< a << ", b = "<< b << endl;
swap(a, b);
cout << "After swap(): a =" << a << ", b = "<< b << endl;

exchange()

std::exchange()<utility>中定义,用新值替换旧值,并返回旧值,如下所示:

1
2
3
4
5
int a = 11;
int b = 22;
cout << "Before exchange(): a = "<< a << ", b = "<< b << endl;
int returnedValue = exchange(a, b);
cout << "After exchange(): a = " << a << ", b = "<< b << endl;

exchange()用于实现移动赋值运算符。移动赋值运算符需要将数据从源对象移到目标对象。通常,源对象中的数据会变为null。

分区算法

partition_copy()算法将来自某个来源的元素复制到两个不同的目标。为每个元素选择特定目标的依据是谓词的结果:true或false。partition_copy()的返回值是一对迭代器:一个迭代器引用第一个目标范围内最后复制的那个元素的后一个位置,另一个迭代器引用第二个目标范围内最后复制的那个元素的后一个位置。将这些返回的迭代器与erase()结合使用,可删除两个目标范围内多余的元素。

1
2
3
4
5
6
vector<int> vec1, vecodd, veceven;
vecodd.resize(size(vec1));
vecEven.resize(size(vec1));
auto pairIters = partition_copy(cbegin(vec1), cend(vec1), begin (veceven), begin (vecodd), [](int i){return i%2 == 0;});
vecEven.erase(pairIters.first, end(veceven));
vecodd.erase (pairIters.second, end(vecodd));

partition()算法对序列排序,使谓词返回true的所有元素放在前面,使谓词返回false的所有元素放在后面,在每个分区中不保留元素最初的顺序。下例演示了如何把vector分为偶数在前、奇数在后的分区:

1
2
vector<int> vec;
partition(begin(vec), end(vec), [] (int i) {return i%2==0;});

排序算法

“排序算法”重新排列容器中元素的顺序,使集合中的元素保持连续顺序。因此,排序算法只能应用于顺序集合。通用的排序算法最适用于vector、deque、array和C风格数组。sort()函数一般情况下在O(NlogN)时间内对某个范围内的元素排序。将sort()应用于一个范围之后,根据运算符operator<,这个范围内的元素以非递减顺序排列(最低到最高)。如果不希望使用这个顺序,可以指定一个不同的比较回调,例如greater。sort()函数的一个名为stable_sort()的变体能保持范围内相等元素的相对顺序。然而,由于这个算法需要维护范围内相等元素的相对顺序,因此这个算法比sort()算法低效。

二叉树搜索算法

有几个搜索算法只用于有序序列或至少已分区的元素序列。这些算法有binary_search()lower_bound()upper_bound()equal_range()lower_bound()upper_bound()equal_range()算法类似于map和set容器中的对应方法。

lower_bound()算法在有序范围内查找不小于(即大于或等于)给定值的第一个元素,经常用于发现在有序的vector中应将新值插入哪个位置,使vector依然有序。下面是一个示例:

1
2
auto iter = lower_bound(begin (vec), end(vec), num);
vec.insert(iter, num);

binary_search()算法以对数时间而不是线性时间搜索元素,需要指定范围的首尾迭代器、要搜索的值以及可选的比较回调。如果在指定范围内找到这个值,这个算法返回true,否则返回false。 下面的例子演示了这个算法:

1
2
if (binary_search(cbegin (vec), cend(vec),num))
cout<<"That number is in the vector."<<endl;

集合算法

集合算法可用于任意有序范围。includes()算法实现了标准的子集判断功能,检查某个有序范围内的所有元素是否包含在另一个有序范围内,顺序任意。

set_union()set_intersection()set_difference()set_symmetric_difference()算法实现了这些操作的标准语义。在集合论中,并集得到的结果是两个集合中的所有元素。交集得到的结果是所有同时存在于两个集合中的元素。差集得到的结果是所有存在于第一个集合中,但是不存在于第二个集合中的元素。对称差集得到的结果是两个集合的“异或”:所有存在于其中一个集合中,但不同时存在于两个集合中的元素。

务必确保结果范围足够大,以保存操作的结果。对于set_union()set_symmetric_difference(),结果大小的上限是两个输入范围的总和。对于set_intersection(),结果大小的上限是两个输入范围的最小大小。对于set_difference()结果大小的上限是第一个输入范围的大小。

不能使用关联容器(包括set)中的迭代器范围来保存结果,因为这些容器不允许修改键。下面是这些算法的使用示例:

1
2
3
4
if (includes(cbegin(vec1), cend(vec1), cbegin (vec2), cend(vec2)))
cout << "The second set is a subset of the first." << endl;

auto newEnd = set_union(cbegin (vec1), cend(vecl), cbegin (vec2), cend (vec2), begin(result));

merge()算法可将两个排好序的范围归并在一起,并保持排好的顺序。结果是一个包含两个源范围内所有元素的有序范围。这个算法的复杂度为线性时间。这个算法需要以下参数:

  • 第一个源范围的首尾迭代器
  • 第二个源范围的首尾迭代器
  • 目标范围的起始迭代器
  • (可选)比较回调

如果没有merge(),还可通过串联两个范围,然后对串联的结果应用sort(),以达到同样的目的,但这样做的效率更低,复杂度为O(NlogN)而不是merge()的线性复杂度。

最大/最小算法

min()max()算法通过运算符operator<或用户提供的二元谓词比较两个或多个任意类型的元素,分别返回一个引用较小或较大元素的const引用。minmax()算法返回一个包含两个或多个元素中最小值和最大值的pair。这些算法不接收迭代器参数。还有使用迭代器范围的min_element()max_element()minmax_element()。下面的程序给出了一些示例:

并行算法

对于60多种标准库算法,C++17支持并行执行它们以提高性能,示例包括for_each()all_of()copy()count_if()find()replace()search()sort()transform(}等。支持并行执行的算法包含选项,接收所谓的执行策略作为第一个参数。

执行策略允许指定是否允许算法以并行方式或矢量方式执行。有三类标准执行策略,以及这些类型的三个全局实例,它们全部定义在std::execution名称空间的<execution>头文件中。如表所示。

执行策略类型 全局实例 描述
sequenced_policy seq 不允许算法并行执行
parallel_policy par 允许算法并行执行
parallel_unscquenced_policy par_unscq 允许算法并行执行和矢量化执行,还允许在线程之间迁移执行

注意,使算法使用parallel_unsequenced_policy执行策略,以允许对回调进行交错函数调用,即不按顺序执行,这意味着会对函数回调施加诸多限制。例如,不能分配释放内存、获取互斥以及使用非锁std::atomics等。对于其他标准策略,函数调用按顺序执行,但顺序无法确定。此类策略不会对函数调用操作施加限制。

下例使用并行策略,对vector的内容进行排序:

1
sort(std::execution::par, begin (myVector), end(myVector));

数值处理算法

inner_product()

<numeric>中定义的inner_product()算法计算两个序列的内积,例如,下面程序中的内积计算为(1*9)+(2*8)+(3*7)+(4*6)

1
2
3
vector<int> v1{1, 2, 3, 4};
vector<int> v2{9, 8, 7, 6};
cout << inner_product(cbegin(v1),cend(v1), cbegin(v2), 0) << endl;

iota()

<numeric>头文件中定义的iota()算法会生成指定范围内的序列值,从给定的值开始,并应用operator++来生成每个后续值。下面的例子展示了如何将这个新算法用于整数的vector,不过要注意这个算法可用于任意实现了operator++的元素类型:

1
2
3
4
vector<int> vec(10);
iota(begin (vec), end(vec), 5);
for (auto& i : vec)
cout << i << endl;

gcd()和lcm()

gcd()算法返回两个整数的最大公约数,而lcm()算法返回两个整数的最小公倍数。它们都定义在<numeric>中。下面是一个示例:

reduce()

需要使用新引入的std:reduce()算法,通过并行执行选项,计算广义和。例如,以下两行同样是求和,但是reduce()以并行和矢量化方式运行,因此速度更快,对于大型输入范围尤其如此:

1
2
double result1 = std::accumulate(cbegin(vec), cend(vec),0.0);
double result2 = std::reduce(std::execution::par_unseq, cbegin(vec), cend(vec));

transform_reduce()

std::inner_product()是另一个不支持并行执行的算法。 相反,需要使用广义的transform_reduce()算法,它具有并行执行选项,可用于计算内积等。

字符串的本地化与正则表达式

本地化

本地化字符串字面量

为能正确地本地化字符串,可采用下面的方式来实现:

1
cout << Format (IDS_TRANSFERRED, n) << endl;

IDS_TRANSFERRED是字符串资源表中一个条目的名称。对于英文版,IDS_TRANSFERRED可定义为"Read $1 bytes";对于荷兰语版,这条资源可以定义为"$1 bytes gelezen"Format()函数加载字符串资源,并将$1替换为n的值。

宽字符

用字节表示字符的问题在于,并不是所有的语言(或字符集)都可以用8位(即1个字节)来表示。C++有一种内建类型wchar_t,可以保存宽字符(wide character)。带有非ASCII字符的语言,例如日语和阿拉伯语,在C++中可以用wchar_t表示。然而,C++标准并没有定义wchar_t的大小。一些编译器使用16位,而另一些编译器使用32位。

在使用wchar_t时,需要在字符串和字符字面量的前面加上字母L,以表示应该使用宽字符编码。例如,要将wchar_t字符初始化为字母m,应该编写以下代码:

1
wchar_t myWideCharacter = L"m";

大部分常用类型和类都有宽字符版本。宽字符版本的string类为wstring。“前缀字母w”模式也可以应用于流。wofstrcam处理宽字符文件输出流,wifstream处理宽字符文件输入流。cout、cin、cerr和clog也有宽字节版本:wcout、wcin、wcerr和wclog。这些版本的使用和非宽字节版本的使用没有区别:

非西方字符集

宽字符是很大的进步,因为宽字符增加了定义一个字符可用的空间。在宽字符集中,和ASCII一样,字符用编号表示,现在称为码点。唯一的区别在于编号不能放在8个位中。下面的列表总结了支持的所有字符类型。

  • char:存储8个位。可用于保存ASCII字符,还可用作保存UTF-8编码的Unicode字符的基本构建块。使用UTF-8时,一个Unicode字符编码为1到4个char。
  • char16_t:存储16个位。可用作保存UTF-16编码的Unicode字符的基本构建块。其中,一个Unicode字符编码为一个或两个charl6_t。
  • char32_t:存储至少32个位。可用于保存UTF-32编码的Unicode字符,每个字符编码为一个char32_t。
  • wchar_t:保存一个宽字符,宽字符的大小和编码取决于编译器。

使用char16_t和char32_t而不是wchar_t的好处在于:char16_t的大小至少16位,char32_t的大小至少32位,它们的大小和编译器无关,而wchar_t不能保证最小的大小。

C++标准还定义了以下两个宏。

  • __STDC_UTF_32__:如果编译器定义了这个宏,那么类型char32_t使用UTF-32编码。如果没有定义这个宏,那么类型char32_t使用与编译器相关的编码。
  • __STDC_UTF_16__:如果编译器定义了这个宏,那么类型char16_I使用UTF-16编码。如果没有定义这个宏,那么类型char16_t使用与编译器相关的编码。

使用字符串前缀可将字符串字面量转换为特定类型。下面列出所有支持的字符串前缀。

  • u8:采用UTF-8编码的char字符串字面量
  • u:表示char16_t字符串字面量,如果编译器定义了__STDC_UTF_16__宏,则表示UTF-16编码。
  • U:表示char32_t字符串字面量,如果编译器定义了__STDC_UTF_32__宏,则表示UTF-32编码。
  • L:采用编译器相关编码的wchar_t字符串字面量。

所有这些字符串字面量都可与第2章介绍的原始字符串字面量前缀R结合使用。例如:

1
2
3
4
const char* s1 = u8R"(Raw UTF-8 encoded string literal)";
const wchar_t* s2 = LR"(Raw wide string literal)";
const char16_t* s3 = uR"(Raw char16_t string literal)";
const char32_t* s4 = UR"(Raw char32_t string literal)";

如果通过u8 UTF-8字符串字面量使用了Unicode编码,或者编译器定义了__STDC_UTF_16____STDC_UTF_32__宏,那么在非原始字符串字面量中可通过\uABCD符号插入指定的Unicode码点。例如,\u03C0表示pi字符。

std::string类外,目前还支持wstring、u16string和u32string。它们的定义如下:

1
2
3
4
using string = basic_string<char>;
using wstring = basic_string<wchar_t>;
using u16string = basic_string<char16_t>;
using u32string = basic_string<char32_t>;

多字节字符由一个或多个依赖编译器编码的字节组成,类似于Unicode通过UTF-8用1到4个字节表示,或者通过UTF-16用一个或两个16位值表示。

转换

C++标准提供codecvt类模板,以帮助在不同编码之间转换。<locale>头文件定义了如表19-1所示的4个编码转换类。

在C++17之前,<codecvt>中定义了以下三种代码转换:codecvt_utf8codecvt_utf16codecvt_utf8_utf16。可通过两种简便的转换接口使用它们:wstring_convertwbuffer_convert

正则表达式

正则表达式在<regex>头文件中定义,是标准库中的一个强大工具。C++包含对以下几种语法的支持。

  • ECMAScript:基于ECMAScript标准的语法。 ECMAScript是符合ECMA-262标准的脚本语言。
  • JavaScript:ActionScript和Jscript等语言的核心都使用ECMAScript语言标准。
  • basic:基本的POSIX语法
  • extended:扩展的POSIX语法
  • awk:POSIX awk实用工具使用的语法。
  • grep:POSIX grep实用工具使用的语法。
  • egrep:POSIX grep实用工具使用的语法,包含E参数,

ECMAScript语法

正则表达式模式是一个字符序列,这种模式表达了要匹配的内容。正则表达式中的任何字符都表示匹配自己,但以下特殊字符除外:^ $ \ . * ? () [] {} | 下面将逐一讲解这些特殊字符。如果需要匹配这些特殊字符,那么需要通过\字符将其转义

锚点

特殊字符^$称为锚点(anchor)。^字符匹配行终止符前面的位置,$字符匹配行终止符所在的位置。^$默认还分别匹配字符串的开头和结尾位置,但可以禁用这种行为。

例如,^test$只匹配字符串test,不匹配包含test和其他任何字符的字符串,例如Itesttest2testabc等。

通配符

通配符(wildcard)可用于匹配除换行符外的任意字符。例如,正则表达式a.c可以匹配abcaSc,但不匹配ab5cac

替代

|字符表示“或”的关系。例如,a|b表示匹配ab

分组

圆括号()用于标记子表达式,子表达式也称为捕捉组(capture group)。捕捉组有以下用途:

  • 捕捉组可用于识别源字符串中单独的子序列,在结果中会返回每一个标记的子表达式(捕捉组)。以如下正则表达式为例:(.)(ab|cd)(.)。 其中有3个标记的子表达式。对字符串1cd4运行regex_search(),执行这个正则表达式会得到含有4个条目的匹配结果。第一个条目是完整匹配1cd4,接下来的3个条目是3个标记的子表达式。这3个条目为:1cd4
  • 捕捉组可在匹配的过程中用于后向引用(back reference)的目的。
  • 捕捉组可在替换操作的过程中用于识别组件。

重复

使用以下4个重复字符可重复匹配正则:表达式中的部分模式:

  • *匹配零次或多次之前的部分。例如:a*b可匹配babaabaaaab等字符串。
  • +匹配一次或多次之前的部分。例如:a+b可匹配abaabaaaab等字符串,但不能匹配b
  • ?匹配零次或一次之前的部分。例如:a?b匹配bab,不能匹配其他任何字符串。
  • {...}表示区间重复。a{n}重复匹配a正好n次;a{n,}重复将a匹配n次或更多次;a{n,m}重复将a匹配n到m次,包含n次和m次。例如,^a{3,4}$可以匹配aaaaaaa,但不能匹配aaaaaaaa等字符串。

以上列表中列出的重复匹配字符称为贪婪匹配,因为这些字符可以找出最长匹配,但仍匹配正则表达式的其余部分。为进行非贪婪匹配,可在重复字符的后面加上一个?,例如*?+???{...}?。非贪婪匹配将其模式重复尽可能少的次数,但仍匹配正则表达式的其余部分。

优先级

与数学公式一样,正则表达式中元素的优先级也很重要。正则表达式的优先级如下。

  • 元素:例如a,是正则表达式最基本的构建块.
  • 量词:例如+*?{...},紧密绑定至左侧的元素,例如b+
  • 串联:例如ab+c,在量词之后绑定,
  • 替代符:例如,最后绑定。

例如正则表达式ab+c|d,它匹配abcabbcabbbc等字符串,还能匹配d。圆括号可以改变优先级顺序。例如,ab+(c|d)可以匹配abc、abbc、abbbc、 …abd、abbd和abbbd等字符串。不过,如果使用了圆括号,也意味着将圆括号内的内容标记为子表达式或捕捉组。

字符集合匹配

(a|b|c|...|z)这种表达式既冗长,又会引入捕捉组,为了避免这种正则表达式,可以使用一种特殊的语法,指定一组字符或字符的范围。此外,还可以使用“否定”形式的匹配。在方括号之间指定字符集合,[c1c2...cn]可以匹配字符c1、c2、…、cn中的任意字符。例如,[abc]可以匹配a、b和c中的任意字符。如果第一 一个字符是^表示“除了这些字符之外的任意字符”:

  • ab[cde]匹配abcabdabe
  • ab[^cde]匹配abfabp等字符串,但不匹配abcabdabe

如果想要指定所有字母,可编写下面这样的字符集合:

  • 使用方括号内的范围描述,这允许使用[a-zA-Z]这样的表达方式,这种表达方式能识别a到z和A到Z范围内的所有字母。如果需要匹配连字符,则需要转义这个字符,例如[a-zA-Z\-]+匹配任意单词,包括带连字符的单词。

词边界

词边界(word boundary的意思可能是:

  • 如果源字符串的第一个字符在单词字符(即字母、数字或下划线)之后,则表示源字符串的开头位置。匹配源字符串的开头位置默认为启用,但也可以禁用。
  • 如果源字符串的最后一个字符是单词字符之一,则表示源字符串的结束位置。匹配源字符串的结束位置默认为启用,但也可以禁用。
  • 单词的第一个字符,这个字符是单词字符之一,而且之前的字符不是单词字符。
  • 单词的结尾字符,这是单词字符之后的非单词字符,之前的字符是单词字符。

通过\b可匹配单词边界,通过\B匹配除单词边界外的任何内容。

后向引用

通过后向引用可引用正则:表达式本身的捕捉组:\n表示第n个捕捉组,且n>0。例如,正则表达式(d+)-.*-\1匹配以下格式的字符串:

  • 在一个捕捉组中(d+)捕捉的一个或多个数字
  • 接下来是一个连字符-
  • 接下来是0个或多个字符.*
  • 接下来是另一个连字符-
  • 接下来是第一个捕捉组捕捉到的相同数字\1

这个正则表达式能匹配123-abc-1231234-a-1234等字符串,但不能匹配123-abc-1234123-abc-321等字符串。

regex库

正则表达式库的所有内容都在<regex>头文件和std名称空间中。正则表达式库中定义的基本模板类型包括如下几种

  • basic_regex:表示某个特定正则表达式的对象。
  • match_results:匹配正则表达式的子字符串,包括所有的捕捉组。它是sub_match的集合。
  • sub_match:包含输入序列中一个迭代器对的对象,这些迭代器表示匹配的特定捕捉组。迭代器对中的一个迭代器指向匹配的捕捉组中的第一个字符,另一个迭代器指向匹配的捕捉组中最后一个字符后面的那个字符。它的str()方法把匹配的捕捉组返回为字符串

regex库提供了3个关键算法:regex_match()regex_search()regex_replace()。所有这些算法都有不同的版本,不同的版本允许将源字符串指定为STL字符串、字符数组或表示开始和结束的迭代器对。迭代器可以具有以下类型:

  • const char*
  • const wchar_t*
  • string::const_iterator
  • wstring::const_iterator

regex库还定义了以下两类正则表达式迭代器,这两类正则表达式迭代器非常适合于查找源字符串中的所有模式

  • regex_iterator:遍历一个模式在源字符串中出现的所有位置。
  • regex_token_iterator:遍历一个模式在源字符串中出现的所有捕捉组。

为方便regex库的使用,C++标准定义了很多属于以上模板的类型别名,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
using regex = basic_regex<char>;
using wregex = basic_regex<wchar_t>;
using csub_match = sub_match<const char*>;
using wcsub_match = sub_match<const wchar_t*>;

using ssub_match = sub_match<string::const_iterator>;
using wssub_match = sub_match<wstring::const_iterator>;
using cmatch = match_results<const char*>;
using wcmatch = match_results<const wchar_t*>;
using smatch = match_results<string::const_iterator>;
using wsmatch = match_results<wstring::const_iterator>;

using cregex_iterator = regex_iterator<const char*>;
using wcregex_iterator = regex_iterator<const wchar_t*>;
using sregex_iterator = regex_iterator<string::const_iterator>;
using wsregex_iterator = regex_iterator<wstring::const_iterator>;

using cregex_token_iterator = regex_token_iterator<const char*>;
using wcregex_token_iterator = regex_token_iterator<const wchar_t*>;
using sregex_token_iterator = regex_token_iterator<string::const_iterator>;
using wsregex_token_iterator = regex_token_iterator<wstring::const_iterator>;

regex_match()

regex_match()算法可用于比较给定的源字符串和正则表达式模式。如果正则表达式模式匹配整个源字符串,则返回true,否则返回false。这个算法很容易使用。regex_match()算法有6个版本,这些版本接收不同类型的参数。它们都使用如下形式:

1
2
template<...>
bool regex_match (InputSequence[, MatchResults], RegEx[, Flags]);

InputSequence可以表示为:

  • 源字符串的首尾迭代器
  • std::string
  • C风格的字符串

可选的MatchResults参数是对match_results的引用,它接收匹配。如果regex_match()返回false,就只能调用match_results::empty()match_results::size(),其余内容都未定义。如果regex_match(),返回true表示找到匹配,可以通过match_results对象查看匹配的具体内容。

RegEx参数是需要匹配的正则表达式。可选的Flags参数指定匹配算法的选项。大多数情况下,可使用默认选项。

如果整个源字符串匹配正则表达式,那么前面介绍的regex_match()算法返回true,否则返回false。这个算法不能用于查找源字符串中匹配的子字符串,但通过regex_search()算法可以在源字符串中搜索匹配特定模式的子字符串。regex_search()算法有6个不同版本。 它们都具有如下形式:

1
2
template<...>
bool regex_search(InputSequence[, MatchResults], RegEx[,Flags]);

在输入字符串中找到匹配时,所有变体返回true,否则返回false;参数类似于regex_match()的参数。有两个版本的regex_search()算法接收要处理的字符串的首尾迭代器。你可能想在循环中使用regex_search()的这个版本,通过操作每个regex_search()调用的首尾迭代器,找到源字符串中某个模式的所有实例。千万不要这样做!如果正则表达式中使用了锚点(^$)和单词边界等,这样的程序会出问题。由于空匹配,这样会产生无限循环。

regex_iterator

根据前面的解释,绝对不要在循环中通过regex_search()获得模式在源字符串中的所有实例。应改用regex_iteratorregex_token_iterator。这两个迭代器和标准库容器的迭代器类似。

regex_iterator示例

这个例子中的正则表达式为[\\w]+,以搜索一个或多个单词字母。这个例子使用std::string作为来源,所以使用sregex_iterator作为迭代器。 这里使用了标准的迭代器循环,但是在这个例子中,尾迭代器的处理和普通标准库容器的尾迭代器稍有不同。一般情况下,需要为某个特定的容器指定尾迭代器,但对于regex_iterator,只有一个end迭代器。只需要通过默认的构造函数声明regex_iterator类型,就可获得这个尾迭代器。

for循环创建了一个首迭代器iter,它接收源字符串的首尾迭代器以及正则表达式作为参数。每次找到匹配时调用循环体,在这个例子中是每个单词。sregex_iterator遍历所有的匹配。通过解引用sregex_iterator,可得到一个smatch对象。访问这个smatch对象的第一个元素[0]可得到匹配的子字符串:

1
2
3
4
5
regex reg("[\\w]+");
const sregex_iterator end;
for (sregex_iterator iter(cbegin(str), cend(str), reg);
iter != end; ++iter)
cout << (*iter)[0] << endl;

从这个例子中可以看出,即使是简单的正则表达式,也能执行强大的字符串操作。

注意,regex_iteratorregex_token_iterator在内部都包含一个指向给定正则表达式的指针。它们都显式删除接收右值正则表达式的构造函数,因此无法使用临时regex对象构建它们。例如,下面的代码无法编译:

1
for (sregex_iterator iter (cbegin(stc), cend(str), regex("[\\w]+")); iter != end; ++iter)

regex_token_iterator

regex_token_iterator可用于在所有匹配的模式中自动遍历所有的或选中的捕捉组。regex_token_iterator有4个构造函数,格式如下:

1
2
3
4
5
regex_token_iterator (BidirectionalIterator a,
Bidirectionaliterator b,
const regex_type& re
[, SubMatches
[, Flags1]]);

所有构造函数都需要把首尾迭代器作为输入序列,还需要一个正则表达式。可选的SubMatches参数用于指定应迭代哪个捕捉组。可以用4种方式指定SubMatches:

  • 一个整数,表示要迭代的捕捉组的索引。
  • 一个vector,其中的整数表示要迭代的捕捉组的索引。
  • 带有捕捉组索引的initializer_list.
  • 带有捕捉组索引的C风格数组

忽略SubMatches或把它指定为0时,获得的迭代器将遍历索引为0的所有捕捉组,这些捕捉组是匹配整个正则表达式的子字符串。可选的Flags参数指定匹配算法的选项。大多数情况下,可以使用默认选项。

regex_token_iterator示例

可用regex_token_iterator重写前面的regex_iterator示例,如下所示。注意,与regex_iterator示例一样,在循环体中使用*iter而非(*iter)[0],因为使用submatch的默认值0时,记号迭代器会自动遍历索引为0的所有捕捉组。这段代码的输出和regex_iterator示例完全一致:

1
2
3
regex reg("[\\w]+");
for (sregex_token_iterator iter(cbegin(str), cend(str), reg); iter != end; ++iter)
cout<< "\"" << *iter << "\"" << endl;

regex_replace()

regex_replace()算法要求输入一个正则表达式,以及一个用于替换匹配子字符串的格式化字符串。这个格式化字符串可通过表中的转义序列,引用匹配子字符串中的部分内容。

转义序列 替换为
$n 匹配第n个捕捉组的字符串,例如$1表示第一个捕捉组,$2表示第二个捕捉组,依此类推;n必须大于0
$& 匹配整个正则表达式的字符串
$` 在输入序列中,在匹配正则表达式的子字符串左侧的部分
$’ 在输入序列中,在匹配正则表达式的子字符串右侧的部分
$$ 单个美元符号

regex_replace()算法有6个不同版本。这些版本之间的区别在于参数的类型。其中的4个版本使用如下格式:

1
string regex_replace (InputSequence, RegEx, FormatString[, Flags]);

这4个版本都在执行替换操作,后返回得到的字符串。InputSequence和 FormatString可以是std::string或C风格的字符串。RegEx参数是需要匹配的正则表达式。可选的Flags参数指定替换算法的选项。

regex_replace()算法的另外两个版本采用如下形式:

1
2
3
4
OutputIterator regex_replace (OutputIterator,
Bidirectionaliterator first,
BidirectionalIterator last,
RegEx, FormatString[, Flags]);

这两个版本把得到的字符串写入给定的输出迭代器,并返回这个输出迭代器。输入序列给定为首尾迭代器。其他参数与regex_replace()的另外4个版本相同。

其他库工具

ratio库

可通过ratio库精确地表示任何可在编译时使用的有限有理数。ratio对象在std::chrono:duration类中使用。与有理数相关的所有内容都在<ratio>头文件中定义,并且都在std名称空间中。有理数的分子和分母通过类型为std:intmax_t的编译时常量表示,这是一种有符号的整数类型,其最大宽度由编译器指定。ratio对象的定义方式和普通对象的定义方式不同,而且不能调用ratio对象的方法。需要使用类型别名。例如,下面这行代码定义了一个表示1/60的有理数编译时常量:

1
using r1 = ratio<1,60>;

r1有理数的分子和分母是编译时常量,可通过以下方式访问:

1
2
intmax_t num = r1::num;
intmax_t den = r1::den;

记住ratio是一个编译时常量,也就是说,分子和分母需要在编译时确定。下面的代码会产生编译错误:

1
2
3
intmax_t n = 1;
intmax_t d = 60;
using r1 = ratio<n, d>;

将n和d定义为常量就不会有编译错误了。

有理数总是化简的。对于有理数ratio<n,d>,计算最大公约数gcd、分子num和分母den的定义如下:

1
2
num = sign(n)*sign(d)*abs(n)/gcd
den = abs(d)/gcd

ratio库支持有理数的加法、减法、乘法和除法运算。由于所有这些操作都是在编译时进行的,因此不能使用标准的算术运算符,而应使用特定的模板和类型别名组合。可用的算术ratio模板包括ratio_addratio_subtractratio_multiplyratio_divide。这些模板将结果计算为新的ratio类型。这种类型可通过名为type的内嵌类型别名访问。

例如,下面的代码首先定义了两个ratio对象,一个表示1/60,另一个表示1/30。ratio_add模板将两个有理数相加,得到的result有理数应该是化简之后的1/20。

1
2
3
using r1 = ratio<1, 60>;
using r2 = ratio<1, 30>;
using result = ratio_add<r1, r2>::type;

C++标准还定义了一些ratio比较模板:ratio_equalratio_not_equalratio_lessratio_less_equalratio_greaterratio_greater_equal。与算术ratio模板一样,ratio比较模板也是在编译时求值的。这些比较模板创建了一种新类型std::bool_constant来表示结果。bool_constant也是std::integral_constant,即struct模板,里面保存了一种类型和一个编译时常量值。

例如,integral constant<int, 15>保存了一个值为15的整型值。bool_constant还是布尔类型的integral_constant。例如,bool_constant<true>integral_constant<bool,true>,存储值为true的布尔值。ratio比较模板的结果要么是bool_constant<bool,true>,要么是bool_constant<bool, false>。与bool_constantintegral_constant关联的值可通过value数据成员访问。

下面的代码演示了ratio_less的使用。

1
2
3
4
using r1 = ratio<1,60>;
using r2 = ratio<1,30>;
using res = ratio_less<r2, r1>;
cout << res::value << endl;

chrono库

chrono库是一组操作时间的库。这个库包含以下组件:

  • 持续时间
  • 时钟
  • 时点

所有组件都在std::chrono名称空间中定义,而且需要包含<chrono>头文件。 下面讲解每个组件。

持续时间

持续时间(duration)表示的是两个时间点之间的间隔时间,通过模板化的duration类来表示。duration类保存了滴答数和滴答周期(tick period)。滴答周期指的是两个滴答之间的秒数,是一个编译时ratio常量,也就是说可以是1秒的分数。duration模板接收两个模板参数,定义如下所示:

1
template <class Rep, class Period = ratio<1>> class duration {...}

第一个模板参数Rep表示保存滴答数的变量类型,应该是一种算术类型,例如long和double等。第二个模板参数Period是表示滴答周期的有理数常量。如果不指定滴答周期,那么会使用默认值ratio<1>,也就是说默认滴答周期为1秒。

duration类提供了3个构造函数:一个是默认构造函数;另一个构造函数接收一个表示滴答数的值作为参数;第三个构造函数接收另一个duration作为参数。后者可用于将一个duration转换为另一个duration,例如将分钟转换为秒。

duration支持算术运算,还支持比较运算符。duration类包含多个方法,如表所示。

方法 说明
Rep count() const 以滴答数返回duration值,返回类型是duration模板中指定的类型参数
static duration zero() 返回持续时间值等于0的duration
static duration min() 返回duration模板指定的类型参数表示的最小值/最大值持续时间的duration值
static duration max() 返回duration模板指定的类型参数表示的最小值/最大值持续时间的duration值

C++17添加了用于持续时间的floor()ceil()round()abs()操作,行为与用于数值数据时类似。

下面看一下如何在实际代码中使用duration。 每一个滴答周期为1秒的duration定义如下所示:

1
duration<long> d1;

由于ratio<1>是默认的滴答周期,因此这行代码等同于:

1
duration<long, ratio<1>> d1;

下面的代码指定了滴答周期为1分钟的duration(滴答周期为60秒):

1
duration<long, ratio<60>> d2;

下面的代码定义了每个滴答周期为1/60秒的duration:

1
duration<double, ratio<1, 60>> d3;

下面的例子展示了duration的几个方面。它展示了如何定义duration,如何对duration执行算术操作,以及如何将一个duration转换为另一个滴答周期不同的duration:

1
2
3
4
5
6
7
8
9
duration<long, ratio<60>> d1(123);
cout << d1.count() << endl;

duration<double> d2;
d2 = d2.max();
cout << d2.count () << endl;

duration<long, ratio<60>> d3(10); // = 10 minutes
duration<long, ratio<1>> d4(14); // = 14 seconds

特别注意下面两行:

1
2
duration<double, ratio<60>>d5 = d3+d4;
duration<long, ratio<1>>d6 = d3+d4;

这两行都计算了d3+d4,但第一行将结果保存在表示分钟的浮点值中,第二行将结果保存在表示秒的整数中。分钟到秒的转换(或秒到分钟的转换)自动进行。

chrono库还提供了以下标准的duration类型,它们位于std::chrono名称空间中:

1
2
3
4
5
6
using nanoseconds = duration<X 64 bits, nano>;
using microseconds = duration<X 55 bits, micro>;
using milliseconds = duration<X 45 bits, milli>;
using seconds = duration<X 35 bits>;
using minutes = duration<X 29 bits, ratio<60>>;
using hours = duration<X 23 bits, ratio<3600>>;

X的具体类型取决于编译器,但C++标准要求X的类型为至少指定大小的整数类型。使用这些预定义的类型,不是编写:

1
duration<long, ratio<60>> d9(10); // minutes

而是编写:

1
minutes d9(10);

时钟

clock类由time_pointduration组成。C++标准定义了3个clock。第一个称为system_clock,表示来自系统实时时钟的真实时间。第二个称为steady_clock,是一个能保证其time_point绝不递减的时钟。system_clock无法做出这种保证,因为系统时钟可以随时调整。第三个称为high_resolution_clock,这个时钟的滴答周期达到了最小值。high_resolution_clock可能就是stead_clocksystem_clock的别名,具体取决于编译器。

每个clock都有一个静态的now()方法,用于把当前时间用作time_pointsystem_clock定义了两个静态辅助函数,用于time_point和C风格的时间表示方法time_t之间的相互转换。第一个辅助函数为to_time_t(),它将给定time_point转换为time_t;第二个辅助函数为from_time_t(),它返回用给定time_t初始化的time_pointtime_t类型在<ctime.h>头文件中定义。下例展示了一个完整程序,它从系统获得当前时间,然后将这个时间以可供用户读取的格式输出到控制台。localtime()函数将time_t转换为用tm表示的本地时间,定义在<ctime>头文件中。

1
2
3
4
system_clock::time_point tpoint = system_clock::now();
time_t tt = system_clock::to_time_t(tpoint);

tm* t m localtime(&tt);

如果想要将时间转换为字符串,可使用std::stringstream,定义在<ctime>中。使用strftime()函数时,要求提供一个足够大的缓冲区,以容纳用户可读格式的给定时间。

1
2
3
4
5
system_clock::time_point tpoint = system_clock::now();
time_t tt = system_clock::to_time_t(tpoint);

char buffer[80] = {0};
strftime(buffer, sizeof(buffer), "%H:%M:%S", t);

通过chrono库还可计算一段代码执行所消耗的时间。下例展示了这个过程。变量start和end的实际类型为system_clock::time_pointdiff的实际类型为duration:

1
2
3
4
5
6
7
8
auto start = high_resolution_clock::now();

double d = 0;
for (int i = 0; i < 1000000; ++i)
d += sqrt(sin(i) * cos(i));

auto end = high_resolution_clock::now();
auto diff = end - start;

时点

time_point类表示的是时间中的某个时点,存储为相对于纪元(epoch)的duration。time_point总是和特定的clock关联,纪元就是所关联clock的原点。例如,经典UNIX/Linux的时间纪元是1970年1月1日。

time_point类包含time_since_epoch()函数,它返回的duration表示所关联clock的纪元和保存的时间点之间的时间,C++支持合理的time_pointduration算术运算。C++支持使用比较运算符来比较两个时间点,提供了两个静态方法:min()返回最小的时间点,而max()返回最大的时间点。

time_point类有3个构造函数

  • time_point():构造一个time_point,通过duration::zero()进行初始化。得到的time_point表示所关联clock的纪元
  • time_point(const duration& d):构造一个time_point,通过给定的duration进行初始化。得到的time_point表示纪元+d
  • template <class Duration2> time_point(const time_point<clock, Duration2>&t):构造一个time_point,通过t.time_since_epoch()进行初始化

每个time_point都关联一个clock。创建time_point时,指定clock作为模板参数:

1
time_point<steady_clock> tp1;

每个clock都知道各自的time_point类型,因此可编写以下代码:

1
steady_clock::time_point tpl;

下面的示例演示了time_point类:

1
2
3
4
5
time_point<steady_clock> tp1;
tp1 += minutes(10);

auto d1 = tp1.time_since_epoch();
duration<double> d2(dl);

生成随机数

在C++11之前,生成随机数的唯一方法是使用C风格的srand()rand()函数。srand()函数需要在应用程序中调用一次,这个函数初始化随机数生成器,也称为设置种子(sccding)。通常应该使用当前系统时间作为种子。初始化随机数生成器后,通过rand()生成随机数。下例展示了如何使用srand()rand()time(nullptr)调用返回系统时间,这个函数在<ctime>头文件中定义:

1
2
srand(static_cast<unsigned int> (time(nullptr)));
cout << rand() << endl;

可通过以下函数生成特定范围内的随机数:

1
2
3
int getRandom(int min, int max) {
return (rand() % static_cast<int>(max + 1 - min)) + min;
}

C++11的库能根据不同的算法和分布生成随机数。这个库定义在<random>头文件中。这个库有3个主要组件:随机数引擎(engine)、随机数引擎适配器(engine adapter)和分布(distribution)。 随机数引擎负责生成实际的随机数,并将生成后续随机数需要的状态保存起来。分布判断生成随机数的范围以及随机数在这个范围内的数学分布情况。随机数引擎适配器修改相关联的随机数引擎生成的结果。

随机数引擎

以下随机数引擎可供使用:

  • random_device
  • linear_congruential_engine
  • mersenne_twister_engine
  • subtract_with_carry_engine

random_device引擎不是基于软件的随机数生成器;这是一种特殊引擎,要求计算机连接能真正生成不确定随机数的硬件。随机数生成器的质量由随机数的熵(entropy)决定。如果random_device类使用的是基于软件的伪随机数生成器,那么这个类的entropy()方法返回的值为0.0。random_device的速度通常比伪随机数引擎更慢。因此,如果需要生成大量的随机数,建议使用伪随机数引擎,使用random_device为随机数引擎生成种子,除了random_device随机数引擎之外,还有3个伪随机数引擎:

  • 线性同余引擎(linear congruential engine)保存状态所需的内存量最少。状态是一个包含上一次生成的随机数的整数,如果尚未生成随机数,则保存的是初始种子。
  • 在这3个伪随机数引擎中,梅森旋转算法生成的随机数质量最高。梅森旋转算法的周期取决于算法参数,但比线性同余引擎的周期要长得多。梅森旋转算法保存状态所需的内存量也取决于算法参数,但是比线性同余引擎的整数状态高得多。例如,预定义的梅森旋转算法mt9937的周期为2^19937-1,状态包含625个整数,约为2.5KB。它是最快的随机数引擎之一
  • 带进位减法(subtract withcarry)引擎要求保存大约100字节的状态。不过,这个随机数引擎生成的随机数质量不如梅森旋转算法。

optional

std::optional<optional>中定义,用于保存特定类型的值,或什么都不保存。如果希望值是可选的,可将其用作函数的参数。如果函数可以返回一些值,或什么都不返回,通常也可将其用作函数的返回类型。这样,就不必从函数返回特殊值,如nullptr、-1和EOF等。在下面的示例中,函数返回optional:

1
2
3
4
5
optional<int> getData(bool givelt) {
if (giveIt)
return 42;
return nullopt;
}

可采用如下方式调用该函数:

1
2
auto data1 = getData(true);
auto data2 = getData(false);

要确定optional是否具有值,可使用has_value()方法,或在证语句中使用optional:

1
2
3
4
cout << "datal.has_value = "<< datal.has_value() <<endl;
if(data2) {
cout << "data2 has a value." << endl;
}

如果optional具有值,可使用value()接收它,或使用以下反引用运算符:

1
2
cout << "data1.value = " << data1.value() << endl;
cout << "data1.value = " << *data1 << endl;

如果在空的optional上调用value(),将抛出bad_optional_access异常。可使用value_or()返回optional值,或在optional为空时返回另一个值:

1
cout << "data2.value" << data2.value_or(0) << endl;

注意,不能在optional中存储引用,因此optional<T&>不可行。相反,应当使用<optionakT*>optional<reference_wrapper<T>>optional<reference_wrapper<const T>>

variant

std::variant<variant>中定义,可用于保存给定类型集合的一个值。定义variant时,必须指定它可能包含的类型。例如,以下代码定义variant一次可以包含整数、字符串或浮点值:

1
variant<int, string, float> v;

这里,这个默认构造的variant包含第一个类型(此处是int)的默认构造值。要默认构造variant,务必确保variant的第一个类型是默认可构造的。例如,下面的代码无法编译,因为Foo不是默认可构造的。

1
2
3
4
5
class Foo { public: Foo() = delete; Foo(int) { } };
class Bar { public: Bar() = delete; Bar(int) { } };
int main () {
variant<Foo, Bar> v;
}

事实上,Foo和Bar都不是默认可构造的。如果仍需要默认构造variant,可使用std:monostate(一个空的替代)作为variant的第一个类型:

1
variant<monostate, Foo, Bar> v;

可使用赋值运算符,在variant中存储内容:

1
2
3
4
variant<int, string, float> v;
v = 12;
v = 2.5f;
v = "An std::string"s;

variant在任何给定时间只能包含一个值。因此,对于这三行代码,首先将整数12存储在variant中,然后将variant改为包含浮点值,最后将variant改为包含字符串。

可使用index()方法来查询当前存储在variant中的值类型的索引。std:holds_alternative()函数模板可用于确定variant当前是否包含特定类型的值:

1
2
cout <<"Type index: "<< v.index() << endl;
cout <<"Contains an int:" << holds_alternative<int>(V) << endl;

使用std::get<index>()std::get<T>()从variant检索值。如果使用类型的索引,或使用与variant的当前值不匹配的类型,这些函数抛出bad_variant_access异常:

1
2
3
4
5
6
cout << std::get<string>(v) << endl;
try {
cout << std::get<0>(v)<<endl;
} catch (const bad_variant_access& ex) {
cout <<"Exception: " << ex.what()<<endl;
}

为避免异常,可使用std::get_if<index>()std::get_if<T>()辅助函数。这些函数接收指向variant的指针,返回指向请求值的指针;如果遇到错误,则返回nullptr。

1
2
string* theString = std::get_if<string>(&v);
int* theint = std::get_if<int>(&v);

可使用std::visit()辅助函数,将visitor模式应用于variant。假设以下类定义了多个重载的函数调用运算符,variant中的每个可能类型对应一个:

1
2
3
4
5
6
class MyVisitor {
public:
void operator ()(int i) { cout << "int " << i<< endl; }
void operator ()(const string& s) { cout<< "string" << s << endl; }
void operator ()(float t) { cout <<"float" << f << endl; }
};

可将其与std::visit()一起使用,如下所示:

1
visit(MyVisitor(), v);

这样就会根据variant中当前存储的值,调用适当的重载的函数调用运算符。

any

std::any<any>中定义,是一个可包含任意类型值的类。一旦构建,可确认any实例中是否包含值,以及所包含值的类型。要访问包含的值,需要使用any_cast(),如果失败,会抛出bad_any_cast类型的异常。下面是一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
any empty;
any anint(3);
any aString("An std::string."s);
cout << "empty.has_value = " << empty.has_value() << endl;
cout << "anint.has_value = " << anint.has_value() << endl << endl;
cout <<"anint wrapped type = " << anint.type().name() << endl;
cout <<"aString wrapped type = " << aString.type().name() << endl <<endl;

int theInt = any_cast<int>(anInt);
cout << theInt << endl;
try {
int test = any_cast<int>(aString);
cout << test << endl;
} catch (const bad_any_cast& ex) {
cout << "Exception: " << ex.what() << endl;
}

输出如下所示。注意,aString的包装类型与编译器相关。

1
2
3
4
5
6
empty.has_value = 0
anint.has_value = 1
anInt wrapped type = int
astring wrapped type = class std: :basic_string<char, struct std::char_traits<char>,class std::allocator<char> >
3
Exception: Bad any_cast

可将新值赋给any实例,甚至是不同类型的新值:

1
2
3
any something(3);
// Now it contains an integer.
something = "An std::string"s; //Now the same instance contains a string.

any的实例可存储在标准库容器中。这样就可在单个容器中存放异构数据。这么做的唯一缺点在于,只能通过显式执行any_cast来检索特定值,如下所示:

1
2
3
4
vector<any> v;
v.push_back(any(42));
v.push_back(any("An std::string"s));
cout << any_cast<string>(v[1]) << endl;

与optional和variant一样,无法存储any实例的引用。可存储指针,也可存储reference_wrapper<const T>reference_wrapper<T>的实例。

元组

<utility>中定义的std::pair类可保存两个值,每个值都有特定的类型。每个值的类型都应该在编译时确定。下面是一个简单的例子:

1
2
pair<int, string> p1(16, "Hello World");
pair<bool, float> p2(true, 0.123f);

还有std::tuple类,这个类定义在<tuple>头文件中。tuple(元组)是pair的泛化,允许存储任意数量的值,每个值都有自己特定的类型。和pair一样,tuple的大小和值类型都是编译时确定的,都是固定的。tuple可通过tuple构造函数创建,需要指定模板类型和实际值。例如,下面的代码创建了一个tuple,其第一个元素是一个整数,第二个元素是一个字符串,最后一个元素是一个布尔值:

1
2
using MyTuple = tuple<int, string, bool>;
MyTuple t1(16, "Test", true);

std::get<i>()从tuple中获得第i个元素,i是从0开始的索引;因此<0>表示tuple的第一个元素,<1>表示tuple的第二个元素,依此类推。返回值的类型是tuple中那个索引位置的正确类型:

1
cout << "t1 = (" << get<0>(t1) << "," << get<1>(t1) << "," << get<2>(t1) << ")" << endl;

可通过<typeinfo>头文件中的typeid()检查get<i>()是否返回了正确的类型。下面这段代码的输出表明,get<I>(t1)返回的值确实是std::string

1
2
cout << "Type of get<1>(t1) = " << typeid(get<1>(t1)).name() << endl;
// Outputs: Type of get<1>(t1) = class std::basic_string<char, struct std::char_traits<char>, class std::allocator<char>>

也可根据类型使用std::get<T>()从tuple中提取元素,其中T是要提取的元素(而不是索引)的类型。如果tuple有几个所需类型的元素,编译器会生成错误。例如,可从tl中提取字符串元素:

1
cout << "String = "<< get<string>(t1) << endl;

遗憾的是,迭代tuple的值并不简单。无法编写简单循环或调用get<i>(mytuple)等,因为i的值在编译时必须是已知的。

可通过std:tuple_size模板来查询tuple的大小。 注意,tuple_size要求指定tuple的类型,而不是实际的tuple实例,例如t1:

1
2
cout << "Tuple Size " << tuple_size<MyTuple>::value << endl;
// Outputs: Tuple Size 3

如果不知道准确的tuple类型,始终可以使用decltype(),如下所示:

1
cout << "Tuple Size - " << tuple_size<decltype(t1)>::value << endl;

在C++17中,提供了构造函数的模板参数推导规则。在构造tuple时,可忽略模板类型形参,让编译器根据传递给构造函数的实参类型,自动进行推导。例如,下面定义同样的t1元组,它包含一个整数、一个字符串和一个布尔值。

1
std::tuple t1(16, "Test"s, true);

缘于类型的自动推导,不能通过&来指定引用。如果需要通过构造函数的模板参数推导方式,生成一个包含引用或常量引用的tuple,那么需要分别使用ref()cref()ref()cref()辅助函数在<functional>头文件中定义。例如,下面的构造会生成一个类型为tuple<int, double&, const double&, string&>的tuple:

1
2
3
double d = 3.14;
string str1 = "Test";
std::tuple t2(16, ref(d), cref(d), ref(str1));

为测试元组t2中的double引用,下面的代码首先将double变量的值写入控制台。然后调用get<1>(t2),这个函数实际上返回的是对d的引用,因为第二个tuple(索引1)元素使用了ref(d)。第二行修改引用的变量的值,最后一行展示了d的值的确通过保存在tuple中的引用修改了。注意,第三行未能编译,因为cref(d)用于第三个tuple元素,也就是说,它是d的常量引用。

1
2
3
4
5
6
cout << "d = "<< d << endl;
get<1>(t2) *= 21
// get<2>(t2) *= 2;
// ERROR because of cref()

cout <<"d = " << d << endl;

如果不使用构造函数的模板参数推导方法,可以使用std::make_tuple()工具函数创建一个tuple。利用这个辅助函数模板,只需要指定实际值,即可创建一个tuple。在编译时自动推导类型,例如:

1
auto t2 = std::make_tuple(16, ref(d), cref(d), ref(str1));

分解元组

可采用两种方法,将一个元组分解为单独的元素:结构化绑定(C++17)以及std::tie()

结构化绑定

C++17引入了结构化绑定,允许方便地将一个元组分解为多个变量。例如,下面的代码定义了一个tuple,这个tuple包括一个整数、一个字符串和一个布尔值;此后,使用结构化绑定,将这个tuple分解为三个独立的变量:

1
2
3
tuple t1(16, "Test"s, true);
auto[i, str, b] = t1;
cout<< "Decomposed: i = " << i << ", str = " << str << ", b = " << b << endl;

使用结构化绑定,无法在分解时忽略特定元素。如果tuple包含三个元素,则结构化绑定需要三个变量。如果想忽略元素,则必须使用tie()

tie()

如果在分解元组时不使用结构化绑定,可使用std:tie()工具函数,它生成一个引用tuple。下例首先创建一个tuple,这个tuple包含一个整数、一个字符串和一个布尔值;然后创建三个变量,即整型变量、字符串变量和布尔变量,将这些变量的值写入控制台。tie(i, str, b)调用会创建一个tuple,其中包含对i的引用、对str的引用以及对b的引用。使用赋值运算符,将t1赋给tie()的结果。由于tie()的结果是一个引用tuple,赋值实际上更改了三个独立变量中的值。

1
2
3
4
5
6
7
tuple<int, string, bool>t1(16, "Test", true);
int i = 0;
string str;
bool b = false;
cout << "Betore: i = " << i << ", str = " << str << ", b = "<< b <<endl;
tie(I, str, b) = t1;
cout << "After: i = " << i << ", str = " << str << ", b = "<< b <<endl;

串联

通过std::tuple_cat()可将两个tuple串联为一个tuple。在下面的例子中,t3的类型为tuple<int, string, bool, double, string>

1
2
3
tuple<int, string, bool> t1(16, "Test", true);
tuple<double, string> t2(3.14, "string 2");
auto t3 = tuple_cat(t1, t2);

比较

tuple还支持以下比较运算符:==、!=、<、>、<=和>=。为了能使用这些运算符,tuple中存储的元素类型也应该支持这些操作。例如:

1
2
3
4
5
6
tuple<int, string> t1(123, "def");
tuple<int, string> t2(123, "abc");
if(t1<t2)
cout << "tl < t2"<< endl;
else
cout << "t1 >= t2" << endl;

make_from_tuple()

使用std:make_from_tuple()可构建一个T类型的对象,将给定tuple的元素作为参数传递给T的构造函数。例如,假设具有以下类:

1
2
3
4
5
6
7
Class Foo {
public:
Foo(string str, int i):mStr(str), mInt(i) {}
private:
string mStr;
int mint;
};

可按如下方式使用make_from_tuple()

1
2
auto myTuple = make_tuple("Hello world.", 42);
auto foo = make_from_tuple<Foo>(myTuple);

提供给make_from_tuple()的实参未必是一个tuple,但必须支持std:get<>()std::tuple_sizestd::arraystd::pair也满足这些要求。

apply()

std::apply()调用给定的函数、lambda表达式和函数对象等,将给定tuple的元素作为实参传递。下面是一个例子:

1
2
int add(int a, int b) { return a + b; }
cout << apply(add, std::make_tuple(39, 3)) << endl;

make_from_tuple()一样,在日常工作中,该函数并不实用;不过,如果要编写使用模板的泛型代码,或进行模板元编程,那么这个函数可提供便利。

文件系统支持库

C++17引入了文件系统支持库,它们全部定义在<filesystem>头文件中,位于std::filesystem名称空间。它允许你编写可移植的用于文件系统的代码。使用它,可以区分是目录还是文件,迭代目录的内容,操纵路径,检索文件信息(如大小、扩展名和创建时间等)。下面介绍这个库最重要的两个方面:path(路径)和directory_entry(目录项)。

path

这个库的基本组件是path。path可以是绝对路径,也可以是相对路径,可包含文件名,也可不包含文件名。例如,以下代码定义了一些路径,注意使用了原始字符串字面量来避免对反斜线进行转义。

1
2
3
4
5
path p1(LR"(D:\Foo\Bar)");
path p2(L"D:/Foo/Bar");
path p3(L"D:/Foo/Bar/MyFile.txt");
path P4(LR"(..\SomeFolder)");
path p5(L"/usr/lib/X11");

将path转换为字符串(如使用c_str()方法)或插入流时,会将其转换为运行代码的系统中的本地格式。例如:

1
2
3
4
path p1(LR"(D:\Foo\Bar)");
path p2(L"D:/Foo/Bar");
cout << p1 << endl;
cout << p2 << endl;

可使用append()方法或operator/=,将组件追加到路径。路径会自动添加分隔符。例如:

1
2
3
4
path p(L"D:\\Foo");
p.append("Bar");
P /= "Bar";
cout << p << endl;

输出是D:\Foo\Bar\Bar

可使用concat()方法或operator+=,将字符串与现有路径相连。此时路径不会添加分隔符。append()operator/=自动添加路径分隔符,而concat()operator+=不会自动添加。

path接口支持remove_filenamereplace_filename()replace_extension()root_name()parent_path() extension()has_extension()is_absolute()is_relative()等操作。

directory_entry

path只表示文件系统的目录或文件。path可能指不存在的目录或文件。如果想要查询文件系统中的实际目录或文件,需要从path构建一个directory_entry。 如果给定目录或文件不存在,该结构会失败。directory_entry接口支持is_directory()is_regular_file()is_socket()is_symlink()file_size()last_write_time()等操作。

辅助函数

有一组完整的辅助函数可供使用。例如,可使用copy()复制文件或目录,使用create_directory()在文件系统中创建新目录,使用exists()查询给定目录或文件是否存在,使用file_size()获取文件大小,使用last_write_time()获取文件最近一次的修改时间,使用remove()删除文件,使用temp_directory_path()获取适于保存临时文件的目录,使用space()查询文件系统中的可用空间,等等。

目录迭代

如果想要递归地迭代给定目录中的所有文件和子目录,可使用如下recursive_directory_iterator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void processPath (const path& p) {
if (!exists(p))
return;

auto begin = recursive_directory_iterator(p);
auto end = recursive_directory_iterator();
for (auto iter = begin; iter != end; ++ iter) {
const string spacer(iter.depth()*2, ' ');
auto& entry = *iter;

if (is_regular_file(entry))
cout << spacer << "File: " << entry << endl;
else if (is_directory(entry))
cout << spacer << "Dir: "<< entry << endl;

自定义和扩展标准库

分配器

每个标准库容器都接收Allocator类型作为模板参数,大部分情况下默认值就足够了。例如,vector模板的定义如下所示

1
template <class T, class Allocator = allocator<T>>class vector;

容器构造函数还允许指定Allocator类型的对象。通过这些额外参数可自定义容器分配内存的方式。容器执行的每一次内存分配都是通过调用Allocator对象的allocate()方法进行的,每一次内存释放都是通过调用Allocator对象的deallocate()方法进行的。

有几种原因需要使用自定义的分配器。例如:

  • 如果底层分配器的性能无法接受,但可构建替换的分配器
  • 如果内存碎片问题(大量不同的分配和释放操作导致内存中出现很多不可用的小空洞)严重
  • 如果必须给操作系统特定的功能分配空间,

C++17引入了多态内存分配器的概念。对于指定为模板类型参数的容器的分配器,问题在于两个十分相似但具有不同分配器类型的容器区别很大。例如,具有不同分配器模板类型参数的两个vector容器是不同的。std::pmr名称空间的<memory_resource>中定义的多态内存分配器有助于解决这个问题。std::pmr::polymorphic_allocator是适当的分配器类,因为它满足各种要求,如具有allocate()deallocate()方法。polymorphic allocator的分配行为取决于构建期间的memory_resource,而非取决于模板类型参数。因此,在分配和释放内存时,虽然具有相同的类型,但不同polymorphic_allocator的行为迥异。

流适配器

标准库提供了4个流适配器(stream iterator)。它们是类似于迭代器的类模板,允许将输入流和输出流视为输入迭代器和输出迭代器。通过这些迭代器可对输入流和输出流进行适配,将它们在不同的标准库算法中分别当成来源和目标。下面列出可用的流迭代器:

  • ostream_iterator是一个输出流迭代器
  • istream_iterator是一个输入流迭代器

输出流迭代器

ostream_iterator是一个输出流迭代器,是一个类模板,接收元素类型作为类型参数。这个类的构造函数接收的参数包括一个输出流以及要在写入每个元素之后写入流的分隔符字符串。ostream_iterator通过operator<<运算符写入元素。可以用ostream_iterator一行代码打印出容器中的元素。

1
2
3
vector<int> myVector(10);
iota(begin (myVector), end (myVector), 1); // Fill vector with 1,2,3...10
copy (cbegin (myVector), cend (myVector), ostream_iterator<int>(cout, " "));

输入流迭代器

还可使用输入流迭代器istream_iterator通过迭代器抽象从输入流中读取值。这是类模板,将元素类型作为类型参数,通过operator>>运算符读取元素。istream_iterator可用作算法和容器方法的来源。

迭代器适配器

标准库提供了3个迭代器适配器(iterator adapter), 它们是基于其他迭代器构建的特殊迭代器。这3个迭代器适配器都在<iterator>头文件中定义。

反向迭代器

标准库提供了std::reverse_iterator类模板,以反向遍历双向迭代器或随机访问迭代器。标准库中所有可反向迭代的容器都提供了类型别名reverse_iterator以及rbegin()rend()方法。这些reverse_iterator类型别名的类型是std::reverse_iterator<T>,T等于容器的iterator类型别名。rbegin()方法返回指向容器中最后一个元素的reverse_iteratorrend()方法也返回一个reverse_iterator,这个迭代器指向容器中第一个元素之前的元素。对reverse_iterator应用operator++运算符,会对底层容器迭代器调用operator--运算符,反之亦然。例如,可通过以下方式从头到尾遍历一个集合:

1
for (auto iter = begin(collection); iter != end (collection); ++iter)

要从尾到头遍历这个集合的元素,可调用rbegin()rend()来使用reverse_iterator。注意,这里仍使用++iter

1
for(auto iter = rbegin(collection); iter != rend(collection); ++iter)

std::reverse_iterator主要用在标准库中没有等价算法能够反向运行的情况。

插入迭代器

为了让copy()这类算法的用途更广泛,标准库提供了3个插入迭代器以真正将元素插入容器:insert_iteratorback_insert_iteratorfront_insert_iterator。插入迭代器根据容器类型模板化,在构造函数中接收实际的容器引用。通过提供必要的迭代器接口,这些适配器可用作copy()这类算法的目标迭代器。这些适配器不会替换容器中的元素,而通过调用容器真正插入新元素。

基本的insert_iterator调用容器的insert(position, element)方法,back_insert_iterator调用push_back(element)方法,front insert_iterator调用push_front(element)方法,例如,结合back_insert_iteratorcopy_if()算法能为vectorTwo填充来自vectorOne的不等于100的所有元素:

1
2
3
4
vector<int> vectorone, vectorTwo;
back_insert_iterator<vector<int>> inserter(vectorTwo);
copy_if(cbegin (vectorOne), cend (vectorone), inserter, [](int i){return i != 100;});
copy (cbegin (vectorTwo), cend (vectorTwo), ostream_iterator<int>(cout, ""));

从这段代码可看出,在使用插入迭代器时,不需要事先调整目标容器的大小。

也可通过std::back_inserter()工具函数创建一个back_insert_iterator。例如,在前一个例子中,可删除定义inserter变量的那一行代码,然后将copy_if()调用改写为以下代码。结果和之前的实现完全相同:

1
copy_if(cbegin (vectorOne), cend (vectorOne), back_inserter (vectorTwo), [](int i){return i!=100;});

front_insert_iteratorinsert_iterator的工作方式类似,区别在于insert_iterator在构造函数中还接收初始的迭代器位置作为参数,并将这个位置传入第一次insert(position, element)调用。后续的迭代器位置提示通过每一次insert()调用的返回值生成。

使用insert_iterator的一个巨大好处是可将关联容器用作修改类算法的目标。关联容器实际上支持将接收迭代器位置作为参数的insert(),并将这个位置用作“提示”,但这个位置可忽略。在关联容器上使用insert_iterator时,可传入容器的begin()end()迭代器用作提示。insert_iterator在每次调用insert()后修改传输给insert()的迭代器提示,使其成为刚插入元素之后的那个位置。

移动迭代器

迭代适配器std:move_iterator的解除引用运算符会自动将值转换为rvalue引用,也就是说,这个值可移动到新的目的地,而不会有复制开销。在使用移动语义前,需要保证对象支持移动语义。

扩展标准库

编写标准库算法

find_all()

假设需要在指定范围内找到满足某个谓词的所有元素。find()find_if()是最符合条件的备选算法,但这些算法返回的都是仅引用一个元素的迭代器。可使用copy_if()找出所有满足谓词的元素, 但会用所找到元素的副本填充输出。如果想要避免复制,可使用copy_in()back_insert_iterator()vector<reference_wrapper<T>>中),但这不能给出所找到元素的位置。可自行编写能提供这个功能的版本,称为find_all()

copy_if()一样, 该算法给输出序列返回一个迭代器,指向输出序列中存储的最后一个元素后面的那个元素。下面是算法原型:

1
2
template <typename InputIterator, typename OutputIterator, typename Predicate>
OutputIterator find_all(InputIterator first, InputIterator last, OutputIterator dest, Predicate pred);

另一种可选方案是忽略输出迭代器,给输入序列返回一个迭代器,遍历输入序列中所有匹配的元素,但是这种方案要求编写自定义的迭代器类。

下一项任务是编写算法的实现。find_all()算法遍历输入序列中的所有元素,给每个元素调用谓词,把匹配元素的迭代器存储在输出序列中。下面是算法的实现:

1
2
3
4
5
6
7
8
9
10
11
template <typename InputIterator, typename OutputIterator, typename Predicate>
OutputIterator find_all(InputIterator first, Inputiterator last,OutputIterator dest, Predicate pred) {
while (first != last) {
if(pred(*first)) {
*dest = first;
++dest;
}
++first;
}
return dest;
}

iterator_traits

一些算法的实现需要迭代器的额外信息。例如,为保存临时值,算法可能需要知道迭代器引用的元素的类型,还可能需要知道迭代器是双向访问的还是随机访问的。C++提供了一个名为iterator_traits的类模板,以找到这些信息。通过要使用的迭代器类型实例化iterator_traits类模板,然后可访问以下5个类型别名:value_typedifference_typeiterator_categorypointerreference。例如,下面的模板函数声明了一个临时变量,其类型是iteratorType类型的迭代器引用的类型。注意,在iterator_traits这行前面要使用typename关键字。访问基于一个或多个模板参数的类型时,必须显式地指定typename。在这个例子中,模板参数IteratorType用于访问value_type类型:

1
2
3
4
5
template <typename IteratorType>
void iteratortraitsTest(IteratorType it) {
typename std::iterator_traits<IteratorType>::value_type temp;
temp = *it;
cout << temp << endl;

可通过以下代码测试这个函数:

1
2
vector<int> v{ 5 };
iteratorTraitsTest(cbegin(v));

在这段代码中,iteratorTraitsTest()函数中temp变量的类型为int。输出是5。

高级模板

深入了解模板参数

实际上有3种模板参数:类型参数、非类型参数和template template参数。

深入了解模板类型参数

模板的类型参数是模板的精髓。可声明任意数目的类型参数。标准库定义了几个模板化的容器类,包括vector和deque。下面是带有额外模板参数的类定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template <typename T, typename Container>
class Grid {
public
explicit Grid(size_t width = kDefaultwidth, size_t height = kDefaultHeight);
virtual ~Grid() = default;

Grid(const Grid& src) = default;
Grid<T, Container>& operator=(const Grid& rhs) = default;

Grid(Grids& src) = default;
Grid<T, Container>& operator= (Gride&& rhs) = default;
typename Container::value_type& at(size_t x, size_t y);
const typename Container::value_types at(size_t x, size_t y) const;
size_t getHeight()const { return mHeight; }
size_t getwidth()const { return mwidth; }

static const size_t kDefaultwidth = 10;
static const size_t kDefaultHeight = 10;
private:
void verifyCoordinate(size_t x, size_t y) const;
std::vector<Container> mCells;
size_t midth = 0, mHeight = 0;
};

现在这个模板有两个参数:T和Container。因此,所有引用了Grid<T>的地方现在都必须指定Grid<T, Container>以表示两个模板参数。其他仅有的变化是,mCells现在是Container的vector,而不是vector的vector。下面是构造函数的定义:

1
2
3
4
5
6
template <typename T, typename Container>
Grid<T, Container>::Grid(size_t width, size_t height) : mwidth(width), mheight(height) {
mCells.resize(mWidth);
for (auto& column : mCells)
column.resize(mHeight);
}

这个构造函数假设Container类型具有resize()方法。如果尝试通过指定没有resize()方法的类型来实例化这个模板,编译器将生成错误。at()方法的返回类型是存储在给定类型容器中的元素类型。可以使用typename Container:value_type访问该类型。下面是其余方法的实现:

1
2
3
4
5
template <typename T, typename Container>
void Grid<T, Container>::verifyCoordinate(size_t x, size_t y) const {
if(x >= mWidth || y >= mHeight)
throw std::out_of_range("");
}

现在,可按以下方式实例化和使用Grid对象:

1
2
Grid<int, vector<optional<int>>> myIntVectorGrid;
Grid<int, deque<optional<int>>> myIntDequeGrid;

给参数名称使用Container并不意味着类型必须是容器。可尝试用int实例化Grid类:

1
Grid<int, int> test; // WILL NOT COMPILE

此行代码无法成功编译,在尝试处理类模板定义的这一行之前,一切都正常

1
typename Container::value_types at(size_t x, size_t y);

在这一行,编译器意识到column是int类型,没有嵌入的value_type类型别名。

与函数参数一样,可给模板参数指定默认值。例如,可能想表示Grid的默认容器是vector。这个模板类定义如下所示:

1
template <typename T, typename Container = std::vector<std::optional<T>>>

可以使用第一个模板参数中的类型T作为第二个模板参数的默认值中optional模板的参数。C++语法要求不能在方法定义的模板标题行中重复默认值。现在有了这个默认参数后,实例化网格时,客户可指定或不指定底层容器:

1
2
3
Grid<int, deque<optional<int>>> myDequeGrid;
Grid<int, vector<optional<int>>> myVectorGrid;
Grid<int> myVectorGrid2 (myVectorGrid);

template template参数介绍

如果能编写以下代码就好了:

1
Grid<int, vector> myIntGrid;

Grid类应该能够判断出需要一个元素类型为int的optional vector。不过编译器不会允许传递这样的参数给普通的类型参数,因为vector本身并不是类型,而是模板。如果想要接收模板作为模板参数,那么必须使用一种特殊参数,称为template template参数。指定template template参数时,template template参数的完整规范包括该模板的参数。例如,vector和deque等容器有一个模板参数列表,如下所示。

1
2
3
template <typename E, typename Allocator = std::allocator<E>>
class vector {
};

要把这样的容器传递为template template参数, 只能复制并粘贴类模板的声明(在本例中是template <typename E, typename Allocator = allocator<E>> class vector),用参数名(Container)替代类名(vector),并把它用作另一个模板声明的template template参数,而不是简单的类型名。有了前面的模板规范,下面是接收一个容器模板作为第二个模板参数的Grid类的类模板定义:

1
2
3
4
5
6
7
8
9
10
11
template <typename T,
template <typename E, typename Allocator = std::allocator<E>> class Container = std::vector>
class Grid {
public:
std::optional<T>& at(size_t x, size_t y);
const std::optional<T>& at(size_t x, size_t y) const;
private:
void verifyCoordinate(size_t x, size_t y) const;
std::vector<Container<std::optional<T>>>mCells;
size_t mWidth = 0, mHeight = 0;
};

第一个模板参数与以前一样:元素类型T。第二个模板参数现在本身就是容器的模板,如vector或dcque。如前所述, 这种“模板类型”必须接收两个参数:元素类型E和分配器类型。

注意嵌套模板参数列表后面重复的单词class。这个参数在Grid模板中的名称是Container。默认值现为vector而不是vector<T>,因为Container是模板而不是实际类型。

template template参数更通用的语法规则是:

1
template <..., template <TemplateTypeParams> class ParameterName, ...>

从C++17开始,也可以用typename关键字替代class,如下所示:

1
template <..., template <TemplateTypeParams> typename ParameterName, ...>

在代码中不要使用Container本身,而必须把Container<std::optiona<T>>指定为容器类型。例如,现在mCells的声明如下:

1
std::vector<Container<std::optional<T>>> mCells;

不需要更改方法定义,但必须更改模板行,例如:

1
2
3
4
5
6
template <typename T,
template <typename E, typename Allocator = std::allocator<E>> class Container>
void Grid<T, Container>::verifyCoordinate(size_t x, size_t y) const {
if (x >= mWidth || y >= mHeight)
throw std::out_of_range("");
}

可以这样使用Grid模板:

1
2
3
4
Grid<int, vector>myGrid;
myGrid.at(1, 2) = 3;
cout << myGrid.at(1,2).value_or(0) << endl;
Grid<int, vector> myGrid2(myGrid);

上述C++语法有点令人费解,因为它试图获得最大的灵活性。尽量不要在这里陷入语法困境,记住主要概念:可向其他模板传入模板作为参数

深入了解非类型模板参数

有时可能想让用户指定一个默认元素,用来初始化网格中的每个单元格。下面是实现这个目标的一种完全合理的方法,它使用T()作为第二个模板参数的默认值:

1
2
template <typename T, const T DEFAULT = T()>
class Grid { };

这个定义是合法的。可使用第一个参数中的类型T作为第二个参数的类型,非类型参数可为const,就像函数参数一样。可使用T的初始值来初始化网格中的每个单元格:

1
2
3
4
5
6
7
8
9
10
template <typename T, const T DEFAULT>
Grid<T, DEFAULT>::Grid(size_t width, size_t height) : mWidth(width), mHeight(height) {
mCells.resize(mWidth);
for (auto& column : mcells) {
column.resize(mHeight);
for (auto& element : column) {
element = DEFAULT;
}
}
}

其他的方法定义保持不变,只是必须向模板行添加第二个模板参数,所有Grid<T>实例要变为Grid<T,DEFAULT>。完成这些修改后,可实例化一个int网格,并为所有元素设置初始值:

1
2
Grid<int> myIntGrid;
Grid<int, 10> myIntGrid2;

非类型参数不能是对象,甚至不能是double和float值。非类型参数被限定为整型、 枚举、指针和引用。

允许用户指定网格初始元素值的一种更详尽方式是使用T引用作为非类型模板参数。下面是新的类定义:

1
2
template <typename T, const T& DEFAULT>
class Grid {};

现在可为任何类型实例化这个模板类。C++17标准指定,作为第二个模板参数传入的引用必须是转换的常量表达式(模板参数类型),不允许引用子对象、临时对象、字符串字面量、typeid表达式的结果或预定义的__func__变量。下例声明了带有初始值的int网格和SpreadsheetCell网格。

1
2
3
4
5
6
7
int main () {
int defaultint = 11;
Grid<int, defaultint> myIntGrid;
SpreadsheetCell defaultCe11(1.2);
Grid<Spreadsheetcell, defaultcell> mySpreadsheet;
return 0;
}

但这些是C++17的规则,大多数编译器尚未实施这些规则。在C++17之前,传给引用非类型模板参数的实参不能是临时的,不能是无链接(外部或内部)的命名左值。因此,对于上面的示例,下面使用C++17之前的规则。使用内部链接定义初始值:

1
2
3
4
5
6
7
8
9
namespace {
int defaultInt = 11;
spreadsheetcell defaultCell(1.2);
}
int main () {
Grid<int, defaultint> myIntGrid;
Grid<Spreadsheetcell, defaultcell> mySpreadsheet;
return 0;
}

模板类部分特例化

可编写部分特例化的类,这个类允许特例化部分模板参数,而不处理其他参数。例如,基本版本的Grid模板带有宽度和高度的非类型参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <typename T, size_t WIDTH, size_t HEIGHT>
class Grid {
public:
Grid() = default;
virtual ~Grid() = default;
Grid(const Grid& src) = default;
Grid& operator=(const Grid& rhs) = default;
std::optional<T>& at(size_t x, size_t y);
const std::optional<T>& at(size_t x, size_t y) const;
size_t getHeight() const { return HEIGHT; }
size_t getWidth() const { return WIDTH; }
private:
void verifyCoordinate(size_t x, size_t y) const;
std::optional<T> mCells[WIDTH][HEIGHT];
};

可采用这种方式为char*C风格字符串特例化这个模板类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <size_t WIDTH, size_t HEIGHT>
class Grid<const char*, WIDTH, WEIGHT> {
public:
Grid() = default;
virtual ~Grid() = default;
Grid(const Grid& src) = default;
Gride operator-(const Grid& rhs) = default;
std::optional<std::string>& at(size_t x, size_t y);
const std::optional<std::string>& at(size_t x, size_t y) const;
size_t getHeight()const { return HEIGHT; }
size_t getWidth() const { return WIDTH; }
private:
void verifyCoordinate(size_t x, size_t y) const;
std::optional<std::string> mCells [WIDTH][HEIGHT];
};

在这个例子中,没有特例化所有模板参数。因此,模板代码行如下所示:

1
2
template <size_t WIDTH, size_t HEIGHT>
class Grid<const char*, WIDTH, HEIGHT>

注意,这个模板只有两个参数:WIDTH和HEIGHT。然而,这个Grid类带有3个参数:T、WIDTH和HEIGHT。因此,模板参数列表包含两个参数,而显式的Grid<const char*, WIDTH, HEIGHT>包含3个参数。实例化模板时仍然必须指定3个参数。不能只通过高度和宽度实例化模板:

1
2
3
Grid<int, 2, 2> myIntGrid;
Grid<const char*, 2, 2> myStringGrid;
Grid<2, 3> test;

上述语法的确很乱。更糟糕的是,在部分特例化中,与完整特例化不同,在每个方法定义的前面要包含模板代码行,如下所示:

1
2
3
4
5
6
template <size_t WIDTH, size_t HEIGHT>
const std::optional<std::string>&
Grid<const char*, WIDTH, HEIGHT>::at(size_t x, size_t y) const {
verifyCoordinate(x, y);
return mCells[x][y];
}

需要这一带有两个参数的模板行,以表示这个方法针对这两个参数做了参数化处理。注意,需要表示完整类名时,都要使用Grid<const char*, WIDTH, HEIGHT>

前面的例子并没有表现出部分特例化的真正威力。可为可能的类型子集编写特例化的实现,而不需要为每种类型特例化。下面是类的定义,假设只用一个参数特例化最早版本的Grid。在这个实现中,Grid成为所提供指针的拥有者,所以它在需要时自动释放内存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template <typename T>
class Grid<T*> {
public:
explicit Grid(size_t width = kDefaultWidth, size_t height = kDefaultHeight);
virtual ~Grid() = default;

Grid(const Grid& src);
Grid<T*>& operator=(const Grids rhs);

Grid(Grid&& src) = default;
Grid<T*>& operator=(Grid&& rhs) = default;
void swap(Gride other) noexcept;
std::unique_ptr<T>& at(size_t x, size_t y);
const std::unique_ptr<T>& at(size_t x, size_t y) const;
size_t getHeight() const { return mHeight; }
size_t getwidth()const { return mWidth; }
static const size_t kDefaultwidth = 10;
static const size_t kDefaultHeight = 10;
private:
void verlfyCoordinate(size_t x, size_t y) const;
std::vector<std::vector<std::unique_ptr<T>>>mCells;
size_t mwidth = 0, mileight = 0;

像往常一样,下面这两行代码是关键所在:

1
2
template <typename T>
class Grid<T*>

上述语法表明这个类是Grid模板对所有指针类型的特例化。只有T是指针类型的情况下才提供实现。请注意,如果像下面这样实例化网格:Grid<int*> myIntGrid,那么T实际上是int而非int*。这不够直观,但遗憾的是,这种语法就是这样使用的。下面是一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Grid<int> myIntGrid; // Uses the non-specialized grid
Grid<int*> psGrid(2, 2); // Uses the partial specialization for pointer types
psGrid.at(0, 0) = make_unique<int>(1);
psGrid.at(0, 1) = make_unique<int>(2);
psGrid.at(1, 0) = make_unique<int>(3);
Grid<int*> psGrid2(psGrid);
Grid<int*> psGrid3;
psGrid3 = psGrid2;

auto& element = psGrid2.at(1, 0);
if (element) {
cout << *element << endl;
*element = 6;
}

通过重载模拟函数部分特例化

C++标准不允许函数的模板部分特例化。相反,可用另一个模板重载函数。区别十分微妙。假设要编写一个特例化的Find()函数模板,这个特例化对指针解除引用,对指向的对象直接调用operator--。根据类模板部分特例化的语法,可能会编写下面的代码:

1
2
3
4
5
6
7
template <typename T>
size_t Find<T*>(T* const& value, T* const* arr, size_t size) {
for (size_t i = 0; i < size; i ++)
if (*arr[i] == *value)
return i;
return NOT_FOUND;
}

然而,这种声明函数模板部分特例化的语法是C++标准所不允许的。实现所需行为的正确方法是为Find()编写一个新模板,区别看似微不足道且不切合实际,但不这样就无法编译:

1
2
3
4
5
6
7
template <typename T>
size_t Find(T* const& value, T* const* arr, size_t size) {
for (size_t i = 0; i < size; i ++)
if (*arr[i] == *value)
return i;
return NOT_FOUND;
}

这个Find()版本的第一个参数是T* const&,这是为了与原来的Find()函数模板(它把const T&作为第一个参数)保持一致,但这里将T*(而不是T* const&)用作Find()部分特例化的第一个参数,这也是可行的。

模板递归

N维网格:初次尝试

前面的Grid模板示例到现在为止只支持两个维度,这限制了它的实用性。一种方法是只编写一个一维网格。然后,利用另一个网格作为元素类型实例化Grid,可创建任意维度的网格。这种Grid元素类型本身可以用网格作为元素类型进行实例化,依此类推。下面是OneDGrid类模板的实现。这只是前面例子中Grid模板的一维版本,添加了resize()方法,并用operator[]替换了at()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
template <typename T>
class OneDGrid {
public:
explicit OneDGrid(size_t size = kDefaultsize);
virtual ~OneDGrid() = default;
T& operator[](size_t x);
const T& operator[](size_t x) const;
void resize(size_t newSize);
size_t getsize() const { return mElements.size();}
static const size_t kDefaultsize = 10;
private:
std::vector<T> mElements;
};
template <typename T>
OneDGrid<T>::OneDGrid(size_t size) {
resize(size);
}
template <typename T>
void OneDGtid<T>::resize(size t newSize) {
mElements.resize(newSize);
}
template <typename T>
T& OneDGrid<T>::operator [] (size_t x) {
return mElements[x];
}

有了OneDGrid的这个实现,就可通过如下方式创建多维网格:

1
2
3
4
5
6
OneDGrid<int> singleDGrid;
OneDGrid<OneDGrid<int>> twoDGrid;
OneDGrid<OneDGrid<OneDGrid<int>>> threeDGrid;
singleDGrid[3] = 5;
twoDGrid[3][3] = 5;
threeDGrid[3][3][3] = 5;

真正的N维网格

可以编写一个类模板来自动进行递归。然后,可创建如下N维网格:

1
2
3
NDGrid<int, 1> singleDGrid;
NDGrid<int, 2> twoDGrid;
NDGrid<int, 3> threeDGrid;

NDGrid类模板需要元素类型和表示维度的整数作为参数。这里的关键问题在于,NDGrid的元素类型不是模板参数列表中指定的元素类型,而是上一层递归的维度中指定的另一个NDGrid。换句话说,三维网格是二维网格的矢量,二维网格是一维网格的各个矢量。

使用递归时,需要处理基本情形(base case)。 可编写维度为1的部分特例化的NDGrid,其中元素类型不是另一个NDGrid,而是模板参数指定的元素类型。下面是NDGrid模板定义的一般形式,突出显示了与前面OneDGrid的不同之处:

1
2
3
4
5
6
7
8
9
10
11
12
13
template <typename T, size_t N>
class NDGrid {
public:
explicit NDGrid(size_t size = kDefaultSize);
virtual ~NDGrid() = default;
NDGrid<T, N-1>& operator[](size_t x);
const NDGrid<T, N-1>& operator [] (size_t x) const;
void resize(size_t newSize);
size_t getsize() const { return mElements.size();}
static const size_t kDefaultsize =10;
private:
std::vector<NDGrid<T, N-1>> mElements;
};

注意,mElements是NDGrid<T, N-1>的矢量:这是递归步骤。此外,operator[]返回一个指向元素类型的引用,依然是NDGrid<T, N-1>而非T。基本情形的模板定义是维度为1的部分特例化:

1
2
3
4
5
6
7
8
9
10
11
12
13
template <typename T>
class NDGrid<T, 1> {
public:
explicit NDGrid(size_t size = kDefaultSize);
virtual ~NDGrid() = default;
T& operator[](size_t x);
const T& operator[](size_t x) const;
void resize(size_t newSize);
size_t getSize() const { return mElements.size();}
static const size_t kDefaultsize = 10;
private:
std::vector<T> mElements;
};

模板递归实现最棘手的部分不是模板递归本身,而是网格中每个维度的正确大小。这个实现创建了N维网格,每个维度都是一样大的。为每个维度指定不同的大小要困难得多。

下面是NDGrid主模板的实现,这里突出显示了与OneDGrid之间的差异:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <typename T, size_t N>
NDGrid<T, N>::NDGrid(size_t size) {
resize(size);
}
template <typename T, size_t N>
void NDGrid<T, N>::resize(size_t newSize) {
mElements.resize(newSize);
for (auto& element : mElements)
element.resize(newSize);
}

template <typename T, size_t N>
NDGrid<T, N-1>& NDGrid<T, N>::operator[](size_t x) {
return mElements[x];
}

下面是部分特例化的实现(基本情形)。请注意,必须重写很多代码,因为不能在特例化中继承任何实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
template <typename T>
NDGrid<T, 1>::NDGrid(size_t size) {
resize(size);
}
template <typename T>
void NDGrid<T, 1>::resize(size_t newSize) {
mElements.resize (newSize);
}

template <typename T>
T& NDGrid<T, 1>::operator[](size_t x) {
return mElements[x];
}

现在,可编写下面这样的代码:

1
2
3
4
NDGrid<int, 3> my3DGrid;
my3DGrid[2][1][2] = 5;
my3DGrid[1][1][1] = 5;
cout << my3DGrid[2][1](2]<< endl;

可变参数模板

普通模板只可采取固定数量的模板参数。可变参数模板(variadic template)可接收可变数目的模板参数。例如,下面的代码定义了一个模板,它可以接收任何数目的模板参数,使用称为Types的参数包(parameter pack):

1
2
template<typename... Types>
Class MyVarladicTemplate { };

可用任何数量的类型实例化MyVariadicTemplate,例如:

1
2
MyVariadicTemplate<int> instancel;
MyVariadicTemplate<string, double, list<int>>instance2;

甚至可用零个模板参数实例化:

1
MyVariadicTemplate<> instance3;

为避免用零个模板参数实例化可变参数模板,可以像下面这样编写模板:

1
2
template<typename T1, typename... Types>
class MyVariadicTemplate { };

类型安全的变长参数列表

可变参数模板允许创建类型安全的变长参数列表。下面的例子定义了一个可变参数模板processValues(),它允许以类型安全的方式接收不同类型的可变数目的参数。函数processValues()会处理变长参数列表中的每个值,对每个参数执行handleValue()函数。 这意味着必须对每种要处理的类型编写handleValue()函数,例如下例中的int、double和string:

1
2
3
4
5
6
7
8
9
10
void handleValue(int value) { cout <<"Integer: " << value << endl; }
void handleValue(double value) { cout << "Double: " << value << endl; }
void handleValue(string_view value) { cout << value << endl; }
void processValues() { /* Nothing to do in this base case.*/ }

template<typename T1, typename... Tn>
void processValues(T1 arg1, Tn... args) {
handleValue(arg1);
processValues(args...);
}

在前面的例子中,三点运算符“…”用了两次。这个运算符出现在3个地方,有两个不同的含义。首先,用在模板参数列表中typename的后面以及函数参数列表中类型Tn的后面。在这两种情况下,它都表示参数包。参数包可接收可变数目的参数。

“…”运算符的第二种用法是在函数体中参数名args的后面。这种情况下,它表示参数包扩展。这个运算符会解包/展开参数包,得到各个参数。它基本上提取出运算符左边的内容,为包中的每个模板参数重复该内容,并用逗号隔开。从前面的例子中取出以下行:

1
processValues (args...);

这一行将args参数包解包(或扩展)为不同的参数,通过逗号分隔参数,然后用这些展开的参数调用processValues()函数。模板总是需要至少一个模板参数: T1。通过args...递归调用processValues()的结果是: 每次调用都会少一个模板参数。

由于processValues()函数的实现是递归的,因此需要采用一种方法来停止递归。为此,实现一个processValues()函数,要求它接收零个参数。可通过下面的代码来测试processValues()可变参数模板:

1
processValues(1, 2, 3.56, "test", 1.1f);

这个例子生成的递归调用是:

1
2
3
4
5
6
7
8
9
10
11
processValues(1, 2, 3.56, "test", 1.1f);
handleValue(1);
processValues(2, 3.56, "test", 1.1f);
handleValue(2);
processValues(3.56, "test", 1.1f);
handleValue(3.56);
processValues("test", 1.1f);
handleValue("test");
processValues(1.1f);
handleValue(1.1f);
processValues();

重要的是要记住,这种变长参数列表是完全类型安全的。processValues()函数会根据实际类型自动调用正确的handleValue()重载版本。C++中也会像通常那样自动执行类型转换。然而,如果调用processValues()时带有某种类型的参数,而这种类型没有对应的handleValue()函数,编译器会产生错误。

为了在使用非const引用的同时也能使用字面量值,可使用转发引用(forwarding references)。以下实现使用了转发引用T&&,还使用std::forward()完美转发所有参数。“完美转发”意味着,如果把rvalue传递给processValues(),就将它作为ralue引用转发:如果把lvalue或lvalue引用传递给processValues(),就将它作为lvalue引用转发。

1
2
3
4
5
6
void processValues() {/* Nothing to do in this base case.*/}
template<typename T1, typename... Tn>
void processValues(T1&& arg1, Tn&&... args) {
handleValue(std::forward<T1> (arg1));
processValues(std::forward<Tn>(args)...);
}

有一行代码需要做进一步解释:

1
processValues(std::forward<Tn> (args)...);

“…”运算符用于解开参数包,它在参数包中的每个参数上使用std::forward(),用逗号把它们隔开。例如,假设args是一个参数包,有三个参数(al、a2和a3),分别对应三种类型(A1、A2和A3)。扩展后的调用如下:

1
processValues(std::forward<A1>(a1), std::forward<A2>(a2), std::forward<A3>(a3));

在使用了参数包的函数体中,可通过以下方法获得参数包中参数的个数:

1
int numOfArgs = sizeof...(args);

折叠表达式

C++17增加了对折叠表达式(folding expression)的支持。这样一来,将可更容易地在可变参数模板中处理参数包。下面分析一些示例。以递归方式定义前面的processValues()函数模板,如下所示:

1
2
3
4
5
6
void processValues(){/* Nothing to do in this base case.*/}
template<typename T1, typename... Tn>
void ProcessValues(T1 arg1, Tn...args) {
handleValue(arg1);
processValues(args...);
}

由于以递归方式定义,因此需要基本情形来停止递归。使用折叠表达式,利用一元右折叠,通过单个函数模板来实现。此时,不需要基本情形:

1
2
3
4
template<typename... Tn>
void processvalues (const Tn&... args) {
(handleValue(args), ...);
}

基本上,函数体中的三个点触发折叠。扩展这一行,针对参数包中的每个参数调用handleValue(),对handleValue()的每个调用用逗号分隔。例如,假设args是包含三个参数(a1、a2和a3)的参数包。一元右折叠扩展后的形式如下:

1
(handleValue(a1), (handleValue(a2), handleValue(a3)));

下面是另一个示例。printValues()函数模板将所有实参写入控制台,实参之间用换行符分开。

1
2
3
4
template<typename... Values>
void printValues (const Values&... values) {
((cout << values << endl), ...);
}

假设values是包含三个参数(v1、v2和v3)的参数包。一元右折叠扩展后的形式如下:

1
((cout << v1 << endl), ((cout << v2 << endl), (cout << v3 << endl)));

调用printValues()时可使用任意数量的实参,如下所示:

1
printValues(1, "test", 2.34);

模板元编程

模板元编程的目标是在编译时而不是运行时执行一些计算。模板元编程基本上是基于C++的一种小型编程语言。下面首先讨论一个简单示例,这个例子在编译时计算一个数的阶乘,并在运行时能将计算结果用作简单的常数。

编译时阶乘

下面的代码演示了在编译时如何计算一个数的阶乘。代码使用了本章前面介绍的模板递归,我们需要一个递归模板和用于停止递归的基本模板。根据数学定义,0的阶乘是1,所以用作基本情形:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<unsigned char f>
class Factorial {
public:
static const unsigned long long val = (f * Factorial<f - 1>::val);
};

template<>
class Factorial<0> {
public:
static const unsigned long long val = 1;
};

int main () {
cout << Factorial<6>::val << enal;
return 0;
}

这将计算6的阶乘,数学表达为6!,值为1x2x3x4x5x6或720。

上面这个具体示例在编译时计算一个数的阶乘,但未必需要使用模板元编程。由于引入了constexpr,可不使用模板,写成如下形式。不过,模板实现仍然是实现递归模板的优秀示例。

1
2
3
4
5
6
constexpr unsigned long long factorial (unsigned char f) {
if(f == 0)
return 1;
else
return f * factorial(f-1);
}

如果调用如下版本,则在编译时计算值:

1
constexpr auto f1 = factorial(6);

不过,在这条语句中,切勿忘掉constexpr。 如果编写如下代码,将在运行时完成计算!

1
auto f1 = factorial(6);

在模板元编程版本中,不能犯此类错误。始终使计算在编译时完成。

循环展开

模板元编程的第二个例子是在编译时展开循环,而不是在运行时执行循环。注意循环展开(loop unrolling)应仅在需要时使用,因为编译器通常足够智能,会自动展开可以展开的循环。

这个例子再次使用了模板递归,因为需要在编译时在循环中完成一些事情。在每次递归中,Loop模板都会通过i-1实例化自身。当到达0时,停止递归。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<int i>
class Loop {
public:
template <typename FuncType>
static inline void Do(FuncType func) {
Loop<i - 1>::Do(func);
func(i);
}
};

template<>
class Loop<0> {
public:
template <typename FuncType>
static inline void Do(FuncType /* func*/){}
};

可以像下面这样使用Loop模板:

1
2
3
4
void DoWork(int i) { cout << "DoWork("<< i<<")" << endl;}
int main() {
Loop<3>::Do(DoWork);
}

这段代码将导致编译器展开循环,并连续3次调用DoWork()函数。这个程序的输出如下所示:

1
2
3
DoWork(1)
DoWork(2)
DoWork(3)

使用lambda表达式,可使用接收多个参数的DoWork20版本:

1
2
3
4
5
6
7
void DoWork2(string str, int i) {
cout << "DoWork2("<< str << ", " << i << ")" << endl;
}
int main() {
Loop<2>::Do([](int i) { DoWork2("TestStr", i); });
return 0;
}

上述代码首先实现了一个函数,这个函数接收一个字符串和一个int值。main()函数使用lambda表达式,在每个迭代上将一个固定的字符串TestStr作为第一个参数调用DoWork20。编译并运行上述代码,输出应该如下所示:

1
2
DoWork2(TestStr, 1)
DoWork2(TestStr, 2)

打印元组

这个例子通过模板元编程来打印std::tuple中的各个元素。与模板元编程中的大部分情况一样,这个例子也使用了模板递归。tuple_print类模板接收两个模板参数:tuple类型和初始化为元组大小的整数。然后在构造函数中递归地实例化自身,每一次调用都将大小减小。当大小变成0时,tuple_print的一个部分特例化停止递归。main()函数演示了如何使用这个tuple_print类模板。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template<typename TupleType, int n>
class tuple_print {
public:
tuple_print (const TupleType& t) {
tuple_print<TupleType, n - 1> tp(t);
cout << get<n - 1>(t) << endl;
}
};

template<typename TupleType>
class tuple_print<TupleType, 0> {
public:
tuple_print(const TupleType&) {}
};

int main() {
using MyTuple = tuple<int, string, bool>;
MyTuple t1(16, "Test", true);
tuple_print<MyTuple, tuple_size<MyTuple>::value> tp(t1);
}

constexpr if

C++17引入了constexpr if。这些是在编译时(而非运行时)执行的if语句。如果constexpr if语句的分支从未到达,就不会进行编译。这可用于简化大量的模板元编程技术,也可用于本章后面讨论的SFINAE。

例如,可按如下方式使用constexpr if,简化前面的打印元组元素的代码。注意,不再需要模板递归基本情形,原因在于可通过constexpr if语句停止递归。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<typename TupleType, int n>
class tuple_print_helper {
public:
tuple_print_helper (const TupleTypes t) {
if constexpr(n>1) {
tuple_print_helper<TupleType, n - 1>tp(t);
}
cout << get<n - 1>(t) << endl;
}
};

template<typename T>
void tuple_print(const T& t) {
tuple_print_helper<T, tuple_size<T>::value>tph(t);
}

现在,甚至可丢弃类模板本身,替换为简单的函数模板tuple_print_helper:

1
2
3
4
5
6
7
8
9
10
11
12
template<typename TupleType, int n>
void tuple_print_helper (const TupleType& t) {
if constexpr(n>1) {
tuple_print_helper<TupleType, n - 1>(t);
}
cout << get<n - 1>(t) << endl;
}

template<typename T>
void tuple_print (const T& t) {
tuple_print_helper<T, tuple_size<T>::value>(t);
}

可对其进一步简化。将两个方法合为一个,如下所示:

1
2
3
4
5
6
template<typename TupleType, int n = tuple_size<TupleType>::value>
void tuple_print(const TupleType& t) {
if constexpr(n > 1)
tuple_print<TupleType, n - 1>(t);
cout << get<n - 1>(t) << endl;
}

仍然像前面那样进行调用:

1
2
auto t1 = make_tuple(167, "Testing", false, 2.3);
tuple_print(t1);

使用编译时整数序列和折叠

C++使用std::integer_sequence(在<utility>中定义)支持编译时整数序列。模板元编程的一个常见用例是生成编译时索引序列,即size_t类型的整数序列。此处,可使用辅助用的std::index_sequence。可使用std::index_sequence_for生成与给定的参数包等长的索引序列。

下面使用可变参数模板、编译时索引序列和C++17折叠表达式,实现元组打印程序:

1
2
3
4
5
6
7
8
template<typename Tuple, size_t... Indices>
void tuple_print_helper(const Tuple& t, index_sequence<Indices...>) {
((cout << get<Indices>(t) << endl), ...);
}
template<typename... Args>
void tuple_print (const tuple<Args...>& t) {
tuple_print_helper(t, index_sequence_for<Args...>());
}

可按与前面相同的方式调用:

1
2
auto t1 = make_tuple(167, "Testing", false, 2.3);
tuple_print(t1);

调用时,tuple_print_helper()函数模板中的一元右折叠表达式扩展为如下形式:

1
2
3
4
(((cout << get<0>(t) << endl),
((cout << get<1>(t) << endl),
((cout << get<2>(t) << endl),
(cout << get<3>(t) << endl)))));

类型trait

通过类型trait可在编译时根据类型做出决策。例如,可编写一个模板,这个模板要求从某种特定类型派生的类型,或者要求可转换为某种特定类型的类型,或者要求整数类型,等等。C++标准为此定义了一些辅助类。所有与类型trait相关的功能都定义在<type_traits>头文件中。类型trait分为几个不同类别。下面列出了每个类别的可用类型trait的一些例子。

使用类型类别

在给出使用类型trait的模板示例前,首先要了解一下诸如is_integral的类的工作方式。C++标准对integral_constant类的定义如下所示:

1
2
3
4
5
6
7
8
template <class T, T v>
struct integral_constant {
static constexpr T value = v;
using value_type = T;
using type = integral_constant<T,v>;
constexpr operator value_type() const noexcept { return value; }
constexpr value_type operator() () const noexcept { return value; }
};

这也定义了bool_constanttrue_typefalse_type类型别名:

1
2
3
4
template <bool B>
using bool_constant = integral_constant<bool,B>;
using true_type = bool_constant<true>;
using false_type = bool_constant<false>;

这定义了两种类型:true_typefalse_type。当调用true_type::value时,得到的值是true;调用false_type::value时,得到的值是false。还可调用true_type::type。这将返回true_type类型。这同样适用于false_type。诸如is_integralis_class的类继承了true_typefalse_type。例如,is_integral为类型bool特例化,如下所示:

1
template<> struct is_integral<bool> : public true_type { };

这样就可编写is_integral<bool>::value,并返回true。注意,不需要自己编写这些特例化,这些是标准库的部分。下面的代码演示了使用类型类别的最简单例子:

1
2
3
4
5
6
7
8
if (is_integral<int>::value)
cout <<"int is integral" << endl;
else
cout << "int is not integral" << endl;
if (is_class<string>::value)
cout << "string is a class" << endl;
else
cout << "string is not a class" << endl;

这个例子通过is_integral来检查int是否为整数类型,并通过is_class来检查string是否为类。输出如下:

1
2
int is integral
string is a class

对于每一个具有value成员的trait,C++17添加了一个变量模板,它与trait同名,后跟_v。不是编写some_trait<T>::value,而是编写some_trait_v<T>,例如is_integral_v<T>is_const_v<T>等。 下面用变量模板重写了前面的例子:

1
2
3
4
5
6
7
8
if (is_integral_v<int>)
cout <<"int is integral" << endl;
else
cout <<"int is not integral" << endl;
if(is_class_v<string>)
cout <<"string is a class"<< endl;
else
cout <<"string is not a class" <<endl;

只有结合模板根据类型的某些属性生成代码时,类型trait才更有用。下面的模板示例演示了这一点。代码定义了函数模板process_helper()两个重载版本,这个函数模板接收一种类型作为模板参数。第一个参数是一个值,第二个参数是true_typefalse_type的实例。process()函数模板接收一个参数,并调用process_helper()函数:

1
2
3
4
5
6
7
8
9
10
11
12
template<typename T>
void process_helper(const T& t, true_type) {
cout << t <<" is an integral type." << endl;
}
template<typename T>
void process_helper (const T& t, talse_type) {
cout << t <<"is a non-integral type."<<endl;
}
template<typename T>
void process (const T& t) {
process_helper(t, typename is_integral<T>::type());
}

process_helper()函数调用的第二个参数定义如下:

1
typename is_integral<T>::type()

该参数使用is_integral判断T是否为整数类型。使用::type访问结果integral_constant类型,可以是true_typefalse_typeprocess_helper()函数需要true_typefalse_type的一个实例作为第二个参数,这也是为什么::type后面有两个空括号的原因。注意,process_helper()函数的两个重载版本使用了类型为true_typefalse_type的无名参数。因为在函数体的内部没有使用这些参数,所以这些参数是无名的。这些参数仅用于函数重载解析。

这些代码的测试如下:

1
2
3
process(123);
process(2.2);
process("Test"s);

这个例子的输出如下:

1
2
3
123 is an integral type,
2.2 is a non-integral type.
Test is a non-integral type

前面的例子只使用单个函数模板来编写,但没有说明如何使用类型trait,以基于类型选择不同的重载。

1
2
3
4
5
6
7
8
template<typename T>
void process (const T& t) {
if constexpr (is_integral_v<T>){
cout <it <<" is an integral type."<<endl;
} else {
cout <<t <<"is a non-integral type."<<endl;
}
}

使用类型关系

有三种类型关系:is_sameis_base_ofis_convertible。下面将给出一个例子来展示如何使用is_same。其余类型关系的工作原理类似。下面的same()函数模板通过is_same类型trait特性判断两个给定参数是否类型相同,然后输出相应的信息。

1
2
3
4
5
6
7
8
9
10
11
12
template<typename T1, typename T2>
void same (const T1& t1, const T2& t2) {
bool areTypesTheSame = is_same_v<T1,T2>;
cout << "'" << t1 << "' and '" << t2 << "' are '";
cout << (areTypesTheSame ? "the same types." : "different types.") << endl;
}

int main() {
same(1, 32);
same(1, 3.01);
same(3.01, "Test"s);
}

输出如下所示:

1
2
3
'1' and '32' are the same types.
'1’ and '3.01' are different types
'3.01' and 'Test' are different types

使用enable_if

使用enable_if需要了解“替换失败不是错误”SFINAE特性,这是C++中一个复杂晦涩的特性。下面仅讲解SFINAE的基础知识。

如果有一组重载函数,就可以使用enable_if根据某些类型特性有选择地禁用某些重载。enable_if通常用于重载函数组的返回类型。enable_if接收两个模板类型参数。第一个参数是布尔值,第二个参数是默认为void的类型。如果布尔值是true,enable_if类就有一种可使用::type访问的嵌套类型,这种嵌套类型由第二个模板类型参数给定。如果布尔值是false,就没有嵌套类型。

C++标准为具有type成员的trait(如enable_if)定义别名模板,这些与trait同名,但附加了_t。例如,不编写如下代码:

1
typename enable_if<...,bool>::type

而编写如下更简短的版本:

1
enable_if_t<..., bool>

通过enable_if,可将前面使用same()函数模板的例子重写为一个重载的check_type()函数模板。在这个版本中,check_type()函数根据给定值的类型是否相同, 返回true或false。如果不希望check_type()返回任何内容,可删除return语句,可删除enable_if的第二个模板类型参数,或用void替换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template<typename T1, typename T2>
enable_if_t<is_same_v<T1, T2>, bool>
check_type (const T1& t1, const T2& t2) {
cout << "'" << t1 << "' and '" << t2 << "' ";
cout << "are the same types."<< endl;
return true;
}

template<typename T1, typename T2>
enable_if_t<!is_same_v<T1,T2>, bool>
check_type (const T1& t1, const T2& t2) {
cout << "'" << t1 << "' and '" << t2 <<"' ";
cout << "are different types." << endl;
return false;
}

int main () {
check_type(1, 32);
check_type(1, 3.01);
check_type(3.01, "Test"s);
}

输出与前面的相同。

上述代码定义了两个版本的check_type(),它们的返回类型都是enable_if的嵌套类型bool。首先,通过is_same_v检查两种类型是否相同,然后通过enable_if_t获得结果。当enable_if_t的第一个参数为true时,enable_if_t的类型就是bool;当第一个参数为false时, 将不会有返回类型。这就是SFINAE发挥作用的地方。

当编译器开始编译main()函数的第一行时,它试图找到接收两个整型值的check_type()函数。编译器会在源代码中找到第一个重载的check_type()函数模板,并将T1和T2都设置为整数,以推断可使用这个模板的实例。然后,编译器会尝试确定返回类型。由于这两个参数是整数,因此是相同的类型,is_same_v<T1,T2>将返回true,这导致enable_if_t<true, bool>返回类型bool。这样实例化时一切都很好,编译器可使用该版本的check_type()

然而,当编译器尝试编译main()函数的第二行时,编译器会再次尝试找到合适的check_type()函数。编译器从第一个check_type()开始, 判断出可将T1设置为int类型,将T2设置为double类型。然后,编译器会尝试确定返回类型。这一次,T1和T2是不同的类型,这意味着is_same_v<T1,T2>将返回false。因此enable_if_t<false,bool>不表示类型,check_type()函数不会有返回类型。编译器会注意到这个错误,但由于SFINAE,还不会产生真正的编译错误。编译器将正常回溯,并试图找到另一个check_type()函数。这种情况下,第二个check_type()可以正常工作,因为!is_same_v<T1,T2>为true,此时enable_if_t<true,bool>返回类型bool。

如果希望在一组构造函数上使用enable_if,就不能将它用于返回类型,因为构造函数没有返回类型。此时可在带默认值的额外构造函数参数上使用enable_if

使用constexpr if简化enable_if结构

从前面的示例可以看到,使用enable_if将十分复杂。某些情况下,C++17引入的constexpr if特性有助于极大地简化enable_if。例如,假设有以下两个类:

1
2
3
4
5
6
class IsDoable {
public:
void doit() const { cout << "IsDoable::doit()"<<endl;}
};

class Derived : public IsDoable { };

可创建一个函数模板call_doit()。如果方法可用,它调用doit()方法;否则在控制台上打印错误消息。为此,可使用enable_if,检查给定类型是否从IsDoable派生:

1
2
3
4
5
6
7
8
9
10
template<typename T>
enable_if_t<is_base_of_v<IsDoable, T>,void>
call_doit (const T& T) {
t.doit();
}
template<typename T:
enable_if_t<!is_base_of_v<IsDoable, T>, void>
call_doit (const T&) {
cout << "Cannot call doit()!" << endl;
}

下面的代码对该实现进行测试:

1
2
3
Derived d;
call_doit(d);
call_doit(123);

输出如下:

1
2
IsDoable::doit()
Cannot call doit()!

使用C++17的constexpr if可极大地简化enable_if实现:

1
2
3
4
5
6
7
template<typename T>
void call_doit(const T& [[maybe_unused]] t) {
if constexpr(is_base_of_v<IsDoable, T>){
t.doit();
else
cout << "Cannot call doit()!"<< endl;
}

无法使用普通if语句做到这一点!使用普通if语句,两个分支都需要编译,而如果指定并非从IsDoable派生的类型T,这将失败。此时,t.doit()一行无法编译。但是,使用constexpr if语句,如果提供了并非从IsDoable派生的类型,t.doit()一行甚至不会编译!

不使用is_base_of类型trait,也可使用C++17新引入的is_invocabletrait,这个trait可用于确定在调用给定函数时是否可以使用一组给定的参数。下面是使用is_invocable trait的call_doit()实现:

1
2
3
4
5
6
7
template<typename T>
void call_doit (const T& [[maybe_unused]] t) {
if constexpr (is_invocable_v<decltype (6IsDoable::doit),T>){
t.doit();
else
cout << "Cannot call doit()!" << endl;
}

逻辑运算符trait

在三种逻辑运算符trait:串联(conjunction)、分离(disjunction)与否定(negation)。以_v结尾的可变模板也可供使用。这些trait接收可变数量的模板类型参数,可用于在类型trait上执行逻辑操作,如下所示:

1
2
3
4
cout << conjunction_v<is_integral<int>, is_integral<short>> << " ";
cout << conjunction_v<is_integral<int>, is_integra1<double>> << " ";
cout << disjunction_v<is_integral<int>, is_integral<double>, is_integral<short>> << " ";
cout << negation_v<is_integral<int>> << " ";

C++多线程编程

多线程编程概述

C++98/03不支持多线程编程,所以必须借助第三方库或目标操作系统中的多线程API。自C++11开始,C++有了一个标准的多线程库,使编写跨平台的多线程应用程序变得更容易了。目前的C++标准仅针对CPU,不适用于GPU,这种情形将来可能会改变。

争用条件

当多个线程要访问任何种类的共享资源时,可能发生争用条件。共享内存上下文的争用条件称为“数据争用”。当多个线程访问共享的内存,且至少有一个线程写入共享的内存时,就会发生数据争用。

撕裂

撕裂(tearing)是数据争用的特例或结果。有两种撕裂类型:撕裂读和撕裂写。如果线程已将数据的一部分写入内存,但还有部分数据没有写入,此时读取数据的其他任何线程将看到不一致的数据,发生撕裂读。如果两个线程同时写入数据,其中一个线程可能写入数据的一部分,而另一个线程可能写入数据的另一部分,最终结果将不一致,发生撕裂写。

死锁

死锁指的是两个线程因为等待访问另一个阻塞线程锁定的资源而造成无限阻塞,这也可扩展到超过两个线程的情形。例如,假设有两个线程想要访问某共享资源,它们必须拥有权限才能访问该资源。如果其中一个线程当前拥有访问该资源的权限,但由于其他一些原因而被无限期阻塞,那么此时,试图获取同一资源权限的另一个线程也将无限期阻塞。

现在设想两个线程中的代码按如下顺序执行。

  • 线程1:获取A
  • 线程2:获取B
  • 线程1:获取B(等待/阻塞,因为B被线程2持有)
  • 线程2:获取A(等待/阻塞,因为A被线程1持有)

现在两个线程都在无限期地等待,这就是死锁情形。可以看到一个表示死锁情形的环。这两个线程将无限期地等待。

最好总是以相同的顺序获得权限,以避免这种死锁。也可在程序中包含打破这类死锁的机制。一种可行的方法是试图等待一定的时间,看看能否获得某个资源的权限。如果不能在某个时间间隔内获得这个权限,那么线程停止等待,并释放当前持有的其他锁。线程可能睡眠一小段时间,然后重新尝试获取需要的所有资源。这种方法也可能给其他线程获得必要的锁并继续执行的机会。这种方法是否可用在很大程度上取决于特定的死锁情形。

不要使用前一段中描述的那种变通方法,而是应该尝试避免任何可能的死锁情形。如果需要获得由多个互斥对象保护的多个资源的权限,而非单独获取每个资源的权限,推荐使用标准的std::lock()std::try_lock()函数。这两个函数会通过一次调用获得或尝试获得多个资源的权限。

伪共享

大多数缓存都使用所谓的“缓存行(cache line)”。对于现代CPU而言,缓存行通常是64个字节。如果需要将一些内容写入缓存行,则需要锁定整行。如果代码结构设计不当,对于多线程代码而言,这会带来严重的性能问题。可使用显式的内存对齐(memory alignment)方式优化数据结构,确保由多个线程处理的数据不共享任何缓存行。为了以便携方式做到这一点,C++17引入了hardware_destructive_interference_size常量,该常量在<new>中定义,为避免共享缓存行,返回两个并发访问的对象之间的建议偏移量。可将这个值与alignas关键字结合使用,以合理地对齐数据。

线程

借助在<thread>头文件中定义的C++线程库,启动新的线程将变得非常容易。可通过多种方式指定新线程中需要执行的内容。可让新线程执行全局函数、函数对象的operator()、lambda表达式甚至某个类实例的成员函数。

通过函数指针创建线程

标准C++的std::thread类使用的函数可以有任意数量的参数。假设counter()函数接收两个整数:第一个表示ID,第二个表示这个函数要循环的迭代次数。函数体是一个循环,这个循环执行给定次数的迭代。

1
2
3
4
5
void counter(int id, int numIterations) {
for(int i = 0; i < numIterations; ++i) {
cout << "Counter" << id << " has value " << i << endl;
}
}

可通过std::thread启动执行此函数的多个线程。 可创建线程t1,使用参数1和6执行counter():

1
thread t1 (counter, 1, 6);

thread类的构造函数是一个可变参数模板,也就是说,可接收任意数目的参数。第一个参数是新线程要执行的函数的名称。当线程开始执行时,将随后可变数目的参数传递给这个函数。

如果一个线程对象表示系统当前或过去的某个活动线程,则认为它是可结合的(joinable)。即使这个线程执行完毕,该线程对象也依然处于可结合状态。默认构造的线程对象是不可结合的。在销毁一个可结合的线程对象前,必须调用其join()detach()方法。对join()的调用是阻塞调用,会一直等到线程完成工作为止。调用detach()时,会将线程对象与底层OS线程分离。此时,OS线程将继续独立运行。调用这两个方法时,都会导致线程变得不可结合。如果一个仍可结合的线程对象被销毁,析构函数会调用std::terminate(),这会突然间终止所有线程以及应用程序本身。

下面的代码启动两个线程来执行counter()函数。启动线程后,main()调用这两个线程的join()方法。

1
2
3
4
thread t1(counter, 1, 6);
thread t2(counter, 2, 4);
t1.join();
t2.join();

这个示例的可能输出如下所示:

1
2
3
4
5
6
7
8
9
10
Counter 2 has value 0
Counter 1 has value 0
Counter 1 has value 1
Counter 1 has value 2
Counter 1 has value 3
Counter 1 has value 4
Counter 1 has value 5
Counter 2 has value 1
Counter 2 has value 2
Counter 2 has value 3

输出取决于系统中处理核心的数量以及操作系统的线程调度。

默认情况下,从不同线程访问cout是线程安全的,没有任何数据争用,除非在第一个输出或输入操作之前调用了cout.sync_with_stdio(false)。然而,即使没有数据争用,来自不同线程的输出仍然可以交错。

线程函数的参数总是被复制到线程的某个内部存储中。通过<functional>头文件中的std::ref()cref()按引用传递参数。

通过函数对象创建线程

不使用函数指针,也可以使用函数对象在线程中执行。之前使用函数指针技术,给线程传递信息的唯一方式是给函数传递参数。而使用函数对象,可向函数对象类添加成员变量,并可以采用任何方式初始化和使用这些变量。下例首先定义Counter类。这个类有两个成员变量:一个表示ID,另一个表示循环迭代次数。这两个成员变量都通过类的构造函数进行初始化。为让Counter类成为函数对象,需要实现operator()operator()的实现和counter()函数一样:

1
2
3
4
5
6
7
8
9
10
11
Class Counter {
public:
Counter(int id, int numIterations) : mId(id), mNumIterations(numIterations) { }
void operator () () const {
for (int i = 0; i < mNumIterations; ++i)
cout << "Counter" << mId << " has value " << i << endl;
}
private:
int mId;
int mNumIterations;
};

下面的代码片段演示了通过函数对象初始化线程的三种方法。

  • 第一种方法使用了统一初始化语法。通过构造函数参数创建Counter类的一个实例,然后把这个实例放在花括号中,传递给thread类的构造函数。
  • 第二种方法定义了Counter类的一个命名实例,并将它传递给thread类的构造函数。
  • 第三种方法类似于第一种方法:创建Counter类的一个实例并传递给thread类的构造函数,但是使用了圆括号而不是花括号。
1
2
3
4
5
6
7
8
9
10
thread t1 { Counter{ 1,20 } };

Counter c(2,12);
thread t2(c);

thread t3 (Counter(3, 10));

t1.join();
t2.join();
t3.join();

通过lambda创建线程

lambda表达式能很好地用于标准C++线程库。下例启动一个线程来执行给定的lambda表达式:

1
2
3
4
5
6
7
8
9
int main () {
int id = 1;
int numIterations = 5;
thread t1([id, numIterations] {
for (int i = 0; i < numIterations; ++i) {
cout << "Counter" << id << " has value " << i << endl;
}
});
t1.join();

通过成员函数创建线程

还可在线程中指定要执行的类的成员函数。下例定义了带有process()方法的基类Request。main()函数创建Request类的一个实例,并启动一个新的线程,这个线程执行Request实例reqprocess()成员函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Request {
public:
Request (int id) : mId(id) { }
void process () {
cout <<"Processing request "<< mId << endl;
}
private:
int mId;
}

int main() {
Request req(100);
thread t { sRequest::process, &req };
t.join();
}

通过这种技术,可在不同线程中执行某个对象中的方法。如果有其他线程访问同一个对象,那么需要确认这种访问是线程安全的,以避免争用条件。

线程本地存储

C++标准支持线程本地存储的概念。通过关键字thread_local,可将任何变量标记为线程本地数据,即每个线程都有这个变量的独立副本,而且这个变量能在线程的整个生命周期中持续存在。对于每个线程,该变量正好初始化一次。例如,在下面的代码中,定义了两个全局变量;每个线程都共享唯一的k副本,而且每个线程都有自己的n副本:

1
2
int k;
thread_local int n;

注意,如果thread_local变量在函数作用域内声明,那么这个变量的行为和声明为静态变量是一致的,只不过每个线程都有自己独立的副本,而且不论这个函数在线程中调用多少次,每个线程仅初始化这个变量一次。

取消线程

C++标准没有包含在一个线程中取消另一个已运行线程的任何机制。实现这一目标的最好方法是提供两个线程都支持的某种通信机制。最简单的机制是提供一一个共享变量,目标线程定期检查这个变量,判断是否应该终止。其他线程可设置这个共享变量,间接指示线程关闭。这里必须注意,因为是由多个线程访问这个共享变量,其中至少有一个线程向共享变量写入内容。

从线程获得结果

一种方法是向线程传入指向结果变量的指针或引用,线程将结果保存在其中。另一种方法是将结果存储在函数对象的类成员变量中,线程执行结束后可获得结果值。只有使用std:ref(),将函数对象按引用传递给thread构造函数时,这才能生效。

然而,还有一种更简单的方法可从线程获得结果:future。通过future也能更方便地处理线程中发生的错误。

复制和重新抛出异常

如果一个线程抛出的异常不能在另一个线程中捕获,C++运行库将调用std:terminate(),从而终止整个应用程序。从一个线程抛出的异常不能在另一个线程中捕获。

不使用标准线程库,就很难在线程间正常地处理异常,甚至根本办不到。标准线程库通过以下和异常相关的函数解决了这个问题。这些函数不仅可用于std::exception,还可以用于所有类型的异常:int、string、自定义异常等。

exception_ptr current_exception() noexcept;

这个函数在catch块中调用,返回一个exception_ptr对象,这个对象引用目前正在处理的异常或其副本。如果没有处理异常,则返回空的exception_ptr对象。 只要存在引用异常对象的exception_ptr类型的对象,引用的异常对象就是可用的。exception_ptr对象的类型是NullablePointer,这意味着这个变量很容易通过简单的if语句来检查。

[[noreturn]] void rethrow_exception(exception_ptr p);

这个函数重新抛出由exception_ptr参数引用的异常。 未必在最开始生成引用的异常的那个线程中重新抛出这个异常,因此这个特性特别适合于跨不同线程的异常处理。[[noretun]]特性表示这个函数绝不会正常地返回。

templateexception_ptr make_exception_ptr(E e)noexcept;

这个函数创建一个引用给定异常对象副本的exception_ptr对象。这实际上是以下代码的简写形式:

1
2
3
4
5
try {
throw e;
} catch(...) {
return current_exception();
}

下面看一下如何通过这些函数实现不同线程间的异常处理。下面的代码定义了一个函数,这个函数完成一些事情并抛出异常。这个函数最终将运行在一个独立的线程中:

1
2
3
4
5
6
void doSomeWork() {
for (int i = 0; i < 5; ++ i)
cout << i << endl;
cout << "Thread throwing a runtime_error exception..."<<endl;
throw runtime_error("Exception from thread");
}

下面的threadFunc()函数将上述函数包装在一个try/catch块中,捕获doSomeWork()可能抛出的所有异常。为threadFunc()传入一个参数,其类型为exception_ptr&。一旦捕获到异常,就通过current_exception()函数获得正在处理的异常的引用,然后将引用赋给exception_ptr参数。之后,线程正常退出:

1
2
3
4
5
6
7
8
void threadFunc (exception_ptr& err) {
try {
dosomeWork();
} catch (...) {
cout << "thread caught exception, returning exception..." << endl;
err = current_exception();
}
}

以下doWorkInThread()函数在主线程中调用,其职责是创建一个新的线程,并开始在这个线程中执行threadFunc()函数。对类型为exception_ptr的对象的引用被作为参数传入threadFunc()。一旦创建了线程,
doWorkInThread()函数就使用join()方法等待线程执行完毕,之后检查error对象。由于exception_ptr的类型为NullablePointer,因此很容易通过if语句进行检查。如果是一个非空值,则在当前线程中重新抛出异常,在这个例子中,当前线程即主线程。在主线程中重新抛出异常,异常就从一个线程转移到另一个线程。

1
2
3
4
5
6
7
8
9
10
11
void doWorkInThread() {
exception_ptr error;

thread t{ threadFunc, ref(error) };
t.join();
if (error) {
cout << "Main thread received exception, rethrowing it..." << endl;
rethrow_exception (error);
} else {
cout << "Main thread did not receive any exception." << endl;
}

main()函数相当简单。它调用doWorkInThread(),将这一个调用包装在一个try/catch块中,捕获由doWorkInThread()创建的任何线程抛出的异常:

1
2
3
4
5
6
int main() {
try {
doWorkInThread();
} catch (const exception& e) {
cout << "Main function caught: " << e.what() << endl;
}

为让这个例子紧凑且更容易理解,main()函数通常使用join()阻塞主线程,并等待线程完成。当然,在实际的应用程序中,你不想阻塞主线程。例如,在GUI应用程序中,阻塞主线程意味着UI失去响应。此时,可使用消息传递范型在线程之间通信。例如,可让前面的threadFunc()函数给UI线程发送一条消息,消息的参数为current_exception()结果的一份副本。

原子操作库

原子类型允许原子访问,这意味着不需要额外的同步机制就可执行并发的读写操作。没有原子操作,递增变量就不是线程安全的,因为编译器首先将值从内存加载到寄存器中,递增后再把结果保存回内存。另一个线程可能在这个递增操作的执行过程中接触到内存,导致数据争用。

为使这个线程安全且不显式地使用任何同步机制,可使用std::atomic类型。下面是使用原子整数的相同代码:

1
2
atomic<int> counter(0);
++counter;

为使用这些原子类型,需要包含<atomic>头文件。 C++标准为所有基本类型定义了命名的整型原子类型

可使用原子类型,而不显式使用任何同步机制。但在底层,某些类型的原子操作可能使用同步机制。如果目标硬件缺少以原子方式执行操作的指令,则可能发生这种情况。可在原子类型上使用is_lock free()方法来查询它是否支持无锁操作;所谓无锁操作,是指在运行时,底层没有显式的同步机制。

可将std::atomic类模板与所有类型一起使用,并非仅限于整数类型。例如,可创建atomic<double>atomic<MyType>,但这要求MyType具有is_trivially_copy特点。底层可能需要显式的同步机制,具体取决于指定类型的大小。在下例中,Foo和Bar具有is_trivially_copy特点,即std::is_trivially_copyable_v都等于true。但atomic<Foo>并非无锁操作,而atomic<Bar>是无锁操作。

1
2
3
4
5
6
7
8
9
10
11
class Foo { private: int mArray[123]; };
class Bar { private: int mInt; };

int main() {
atomic<Foo> f;
cout << is_trivially_copyable_v<Foo> <<" "<<f.is_lock_free() <<endl;
// output: 1 0
atomic<Bar> b;
cout << is_trivially_copyable_v<Bar> <<" "<<b.is_lock_free() << endl;
// output: 1 1
}

原子操作

下面是一个原子操作示例:

1
bool atomic<T>::compare_exchange_strong(T& expected, T desired);

这个操作以原子方式实现了以下逻辑,伪代码如下:

1
2
3
4
5
6
7
if (*this == expected) {
*this = desired;
return true;
} else {
expected = *this;
return false;
}

这个逻辑初看起来令人感到陌生,但这是编写无锁并发数据结构的关键组件。无锁并发数据结构允许不使用任何同步机制来操作数据。

另一个例子是用于整型原子类型的atomic<T>::fetch_add()。这个操作获取该原子类型的当前值,将给定的递增值添加到这个原子值,然后返回未递增的原始值。例如:

1
2
3
4
5
atomic<int> value(10);
cout <<"Value = "<< value << endl;
int fetched = value.fetch_add(4);
cout << "Fetched = " << fetched << endl;
cout <<"Value = "<< value << endl;

如果没有其他线程操作fetched和value变量的内容,那么输出如下:

1
2
3
Value = 10
Fetched = 10
Value = 14

整型原子类型支持以下原子操作:fetch_add()fetch_sub()fetch_and()fetch_or()fetch_xor()++--&=^=|=。原子指针类型支持fetch_add()fetch_sub()++--+=-=

大部分原子操作可接收一个额外参数,用于指定想要的内存顺序。例如:

1
T atomic<T>::fetch_add(T value, memory_order = memory_order_seq_cst);

可改变默认的memory_order。C++标准提供了memory_order_relaxedmemory_order_consumememory_order acquirememory_order_releasememory_order_acq_relmemory_order_seq_cst,这些都定义在std名称空间中。然而,很少有必要使用默认之外的顺序。尽管其他内存顺序可能会比默认顺序性能好。

互斥

标准库支持互斥的形式包括互斥体(mutex)类和锁类。这些类都可以用来实现线程之间的同步。

互斥体类

互斥体(mutex,代表mutual exclusion)的基本使用机制如下:

  • 希望与其他线程共享内存读写的一个线程试图锁定互斥体对象。如果另一个线程正在持有这个锁,希望获得访问的线程将被阻塞,直到锁被释放,或直到超时。
  • 一旦线程获得锁,这个线程就可随意使用共享的内存,因为这要假定希望使用共享数据的所有线程都正确获得了互斥体对象上的锁
  • 线程读写完共享的内存后,线程将锁释放,使其他线程有机会获得访问共享内存的锁。如果两个或多个线程正在等待锁,没有机制能保证哪个线程优先获得锁,并且继续访问数据。

C++标准提供了非定时的互斥体类和定时的互斥体类。

非定时的互斥体类

标准库有三个非定时的互斥体类:std::mutexrecursive_mutexshared_mutex。前两个类在<mutex>中定义,最后一个类在<shared_mutex>中定义。每个类都支持下列方法。

  • lock():调用线程将尝试获取锁,并阻塞直到获得锁。这个方法会无限期阻塞。如果希望设置线程阻塞的最长时间,应该使用定时的互斥体类
  • try_lock():调用线程将尝试获取锁。如果当前锁被其他线程持有,这个调用会立即返回。如果成功获取锁,try_lock()返回true,否则返回false
  • unlock():释放由调用线程持有的锁,使另一个线程能获取这个锁

std::mutex是一个标准的具有独占所有权语义的互斥体类。只能有一个线程拥有互斥体。如果另一个线程想获得互斥体的所有权,那么这个线程既可通过lock()阻塞,也可通过try_lock()尝试失败。已经拥有std::mutex所有权的线程不能在这个互斥体上再次调用lock()try_lock(),否则可能导致死锁!

std::recursive_mutex的行为几乎和std::mutex一致,区别在于已经获得递归互斥体所有权的线程允许在同一个互斥体上再次调用lock()try_lock()。调用线程调用unlock()方法的次数应该等于获得这个递归互斥体上锁的次数。

shared_mutex支持“共享锁拥有权”的概念,这也称为readerswriters锁。线程可获取锁的独占所有权或共享所有权。独占拥有权也称为写锁,仅当没有其他线程拥有独占或共享所有权时才能获得。共享所有权也称读锁,如果其他线程都没有独占所有权,则可获得,但允许其他线程获取共享所有权。shared_mutex类支持lock()try_lock()unlock()。这些方法获取和释放独占锁。另外,它们具有以下与共享所有权相关的方法:lock_shared()try_lock_shared()unlock_shared()。这些方法与其他方法集合的工作方式相似,但尝试获取或释放共享所有权。

不允许已经在shared_mutex上拥有锁的线程在互斥体上获取第二个锁,否则会产生死锁!

定时的互斥体类

标准库提供了3个定时的互斥体类:std:timed_mutexrecursive_timed_mutexshared_timed_mutex。前两个类在<mutex>中定义,最后一个类在<shared_mutex>中定义。它们都支持lock()try_lock()unlock()方法,shared_timed_mutex还支持lock_shared()try_lock_shared()unlock_shared()。所有这些方法的行为与前面描述的类似。此外,它们还支持以下方法。

  • try_lock_for(rel_time):调用线程尝试在给定的相对时间内获得这个锁。如果不能获得这个锁,这个调用失败并返回false。如果在超时之前获得了这个锁,这个调用成功并返回true。
  • try_lock_until(abs_time):调用线程将尝试获得这个锁,直到系统时间等于或超过指定的绝对时间。如果能在超时之前获得这个锁,调用返回true。如果系统时间超过给定的绝对时间,将不再尝试获得锁,并返回false。

shared_timed_mutex还支持try_lock_shared_for()try_lock_shared_until()

已经拥有timed_mutexshared_timed_mutex所有权的线程不允许再次获得这个互斥体上的锁,否则可能导致死锁!

recursive_timed_mutex的行为和recursive_mutex类似,允许一个线程多次获取锁。

锁类是RAII类,可用于更方便地正确获得和释放互斥体上的锁;锁类的析构函数会自动释放所关联的互斥体。C++标准定义了4种类型的锁:std::lock_guardunique_lockshared_lockscoped_lock。最后一类是在C++17中引入的

lock_guard

lock_guard<mutex>中定义,有两个构造函数

  • explicit lock_guard(mutex_type& m);
    • 接收一个互斥体引用的构造函数。这个构造函数尝试获得互斥体上的锁,并阻塞直到获得锁。
  • lock_guard(mutex_type& m, adopt_lock_t);
    • 接收一个互斥体引用和一个std::adopt_lock_t实例的构造函数。C++提供了一个预定义的adopt_lock_t实例,名为std:adopt_lock。该锁假定调用线程已经获得引用的互斥体上的锁,管理该锁,在销毁锁时自动释放互斥体。

unique_lock

std:unique_lock定义在<mutex>中,是一类更复杂的锁,允许将获得锁的时间延迟到计算需要时,远在声明时之后。使用owns_lock()方法可以确定是否获得了锁。unique_lock也有bool转换运算符,可用于检查是否获得了锁。

unique_lock有如下几个构造函数。

  • explicit unique_lock(mutex_type& m);
    • 接收一个互斥体引用的构造函数。这个构造函数尝试获得互斥体上的锁,并且阻塞直到获得锁。
  • unique_lock(mutex_type& m, defer_lock_t) noexcept;
    • 接收一个互斥体引用和一个std::defer_lock_t实例的构造函数。C++提供了一个预定义的defer_lock_t实例,名为std::defer_lockunique_lock存储互斥体的引用,但不立即尝试获得锁,锁可以稍后获得。
  • unique_lock(mutex_type& m, try_to_lock_t);
    • 接收一个互斥体引用和一个std::try_to_lock_t实例的构造函数。C++提供了一个预定义的try_to_lock_t实例,名为std::try_to_lock。这个锁尝试获得引用的互斥体上的锁,但即便未能获得也不阻塞;此时,会在稍后获取锁
  • unique_lock(mutex_type& m, adopt_lock_t);
    • 接收一个互斥体引用和一个std::adopt_lock_t实例的构造函数。这个锁假定调用线程已经获得引用的互斥体上的锁。锁管理互斥体,并在销毁锁时自动释放互斥体。
  • unique_lock(mutex_type& m, const chrono:time_point<Clock, Duration>& abs_time);
    • 接收一个互斥体引用和一个绝对时间的构造函数。这个构造函数试图获取一个锁,直到系统时间超过给定的绝对时间。
  • unique_lock(mutex_type& m, const chrono:duration<Rep, Period>& rel_time);
    • 接收一个互斥体引用和一个相对时间的构造函数。这个构造函数试图获得一个互斥体上的锁,直到到达给定的相对超时时间。

unique_lock类也有以下方法:lock()try_lock()try_lock_for()try_lock_until()unlock()

shared_lock

shared_lock类在<shared_mutex>中定义,它的构造函数和方法与unique_lock相同。区别是,shared_lock类在底层的共享互斥体上调用与共享拥有权相关的方法。因此,shared_lock的方法称为lock()try_lock()等,但在底层的共享互斥体上,它们称为lock_shared()try_lock_shared()等。所以,shared_lockunique_lock有相同的接口,可用作unique_lock的替代品,但获得的是共享锁,而不是独占锁。

一次性获得多个锁

C++有两个泛型锁函数,可用于同时获得多个互斥体对象上的锁,而不会出现死锁。这两个泛型锁函数都在std名称空间中定义,都是可变参数模板函数。第第一个函数lock()不按指定顺序锁定所有给定的互斥体对象,没有出现死锁的风险。如果其中一个互斥锁调用抛出异常,则在已经获得的所有锁上调用unlock()。原型如下:

1
template <class L1, class L2, class L3> void lock(L1&, L2&, L3&...);

try_lock()函数具有类似的原型,但它通过顺序调用每个给定互斥体对象的try_lock(),试图获得所有互斥体对象上的锁。如果所有try_lock()调用都成功,那么这个函数返回-1。如果任何try_lock()调用失败,那么对所有已经获得的锁调用unlock(),返回值是在其上调用try_lock()失败的互斥体的参数位置索引。

scoped_lock

std::scoped_lock<mutex>中定义,与lock_guard类似,只是接收数量可变的互斥体。这样,就可极方便地获取多个锁。例如,可以使用scoped_lock,编写包含process()函数的那个示例,如下所示:

1
2
3
4
5
mutex mut1;
mutex mut2;
void process() {
scoped_lock locks(mutl, mut2);
}

std::call_once

结合使用std:call_once()std::once_flag可确保某个函数或方法正好只调用一次,不论有多少个线程试图调用call_once()(在同一once_flag上)都同样如此。 只有一个call_once()调用能真正调用给定的函数或方法。如果给定的函数不抛出任何异常,则这个调用称为有效的call_once()调用。如果给定的函数抛出异常,异常将传回调用者,选择另一个调用者来执行此函数。某个特定的once_flag实例的有效调用在对同一个once_flag实例的其他所有call_once()调用之前完成。 在同一个once_flag实例上调用call_once()的其他线程都会阻塞,直到调用结束。

下例演示了call_once()的使用。这个例子运行使用某个共享资源的processingFunction(),启动了3个线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
once_flag gOnceFlag;
void initializeSharedResources {
cout <<"Shared resources initialized."<< endl;
}

void processingEunction () {
call_once (gOnceFlag, initializeSharedResources);
cout << "Processing" << endl;
}
int main () {
vector<thread> threads (3);
for (auto& t : threads)
t = thread{ processingFunction };

for (autos t : threads)
t.join();
}

这段代码的输出如下所示:

1
2
3
4
Shared resources initialized
Processing
Processing
Processing

互斥体对象的用法示例

以线程安全方式写入流

下面的例子同步Counter类中所有对cont的访问。为实现这种同步,向这个类中添加一个静态的mutex对象。这个对象应该是静态的,因为类的所有实例都应该使用同一个mutex实例。在写入cout之前,使用lock_guard获得这个mutex对象上的锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Counter {
public:
Counter(int id, int numIterations) : mId(id), mNumIterations(numIterations) { }
void operator () () const {
for (int i = 0; i < mNumIterations; ++i) {
lock_guard 1ock(sMutex);
cout << "Counter" << mid <<" has value "<< i << endl;
}
}
private:
int mid;
int mNumIterations;
static mutex sMutex;
};
mutex Counter::sMutex;

这段代码在for循环的每次迭代中创建了一个lock_guard实例。建议尽可能限制拥有锁的时间,否则阻塞其他线程的时间就会过长。例如,如果lock_guard实例在for循环之前创建一次,就基本上丢失了这段代码中的所有多线程特性,因为一个线程在其for循环的整个执行期间都拥有锁,所有其他线程都等待这个锁被释放。

使用定时锁

下面的示例演示如何使用定时的互斥体。结合unique_lock使用了timed_mutex。将200毫秒的相对时间传给unique_lock构造函数,试图在200毫秒内获得一个锁。如果不能在这个时间间隔内获得这个锁,构造函数返回。之后,可检查这个锁是否已经获得,对这个lock变量应用if语句就可执行这种检查,因为unique_lock类定义了bool转换运算符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Counter {
public:
Counter(int id, int numIterations) : mId(id), mNumIterations(numIterations) { }
void operator () () const
for (int i = 0; i < mNumIterations; ++ i) {
unique_lock lock (sTimedMutex, 200ms);
if(lock)
cout << "Counter" << mId << " has value " << i << endl;
else
// Lock not acquired in 200ms, skip output
}
private:
int mId;
int mNumIterations;
static timed_mutex sTimedMutex;
}

timed_mutex Counter::sTimedMutex;

条件变量

条件变量允许一个线程阻塞,直到另一个线程设置某个条件或系统时间到达某个指定的时间。条件变量允许显式的线程间通信。需要包含<condition_variable>头文件来使用条件变量。有两类条件变量。

  • std::condition_variable:只能等待unique_lock<mutex>上的条件变量:根据C++标准的描述,这个条件变量可在特定平台上达到最高效率,
  • std::condition_variable_any:可等待任何对象的条件变量,包括自定义的锁类型。

condition_variable类支持以下方法。

  • notify_one();
    • 唤醒等待这个条件变量的线程之一。
  • notify_all();
    • 唤醒等待这个条件变量的所有线程。
  • wait(unique_lock<mutex>& lk);
    • 调用wait()的线程应该已经获得lk上的锁。调用wait()的效果是以原子方式调用lk.unlock()并阻塞线程,等待通知。当线程被另一个线程中的notify_one()notify_all()调用解除阻塞时,这个函数会再次调用lk.lock(),可能会被这个锁阻塞,然后返回。
  • wait_for(unique_lock<mutex>& lk, const chrono::duration<Rep, Period>& rel_time);
    • 类似于此前的wait()方法,区别在于这个线程会被notify_one()notify_all()调用解除阻塞,也可能在给定超时时间到达后解除阻塞。
  • wait_until(unique_lock<mutex>& lk, const chrono::time_point<Clock, Duration>& abs_time);
  • 类似于此前的wait()方法,区别在于这个线程会被notify_one()notify_all()调用解除阻塞,也可能在系统时间超过给定的绝对时间时解除阻塞

也有一些其他版本的wait()wait_for()wait_until()接收一个额外的谓词参数。例如,接收一个额外谓词的wait()等同于:

1
2
while (!predicate())
wait(lk);

condition_variable_any类支持的方法和condition_variable类相同,区别在于condition_variable_any可接收任何类型的锁类,而不只是unique_lock<mutex>。锁类应提供lock()unlock()方法。

假唤醒

等待条件变量的线程可在另一个线程调用notify_one()notify_all()时醒过来,或在系统时间超过给定时间时醒过来,也可能不合时宜地醒过来。这意味着,即使没有其他线程调用任何通知方法,线程也会醒过来。因此,当线程等待一个条件变量并醒过来时,就需要检查它是否因为获得通知而醒过来。一种检查方法是使用接收谓词参数的wait()版本。

使用条件变量

例如,条件变量可用于处理队列项的后台线程。可定义队列,在队列中插入要处理的项。后台线程等待队列中出现项。把一项插入到队列中时,线程就醒过来,处理项,然后继续休眠,等待下一项。假设有以下队列:

1
queue<string> mQueue;

需要确保在任何时候只有一个线程修改这个队列。可通过互斥体实现这一点:

1
mutex mutex;

为了能在添加一项时通知后台线程,需要一个条件变量:

1
condition_variable mCondVar;

需要向队列中添加项的线程首先要获得这个互斥体上的锁,然后向队列中添加项,最后通知后台线程。无论当前是否拥有锁,都可以调用notify_one()notify_all(),它们都会正常工作:

1
2
3
4
5
// Lock mutex and add entry to the queue.
unique_lock lock (mMutex);
mQueue.push (entry);
// Notify condition variable to wake up thread.
mCondVar.notify_all();

后台线程在一个无限循环中等待通知。注意这里使用接收谓词参数的wait()方法正确处理线程不合时宜地醒过来的情形。谓词检查队列中是否有队列项。对wait()的调用返回时,就可以肯定队列中有队列项了。

1
2
3
4
unique_lock lock(mMutex);
while (true) {
// Wait for a notification.
mCondVar.wait (lock, [this]{ return !mQueue.empty();});

C++标准还定义了辅助函数std::notify_all_at_thread_exit(cond, lk),其中cond是一个条件变量,lk是一个unique_lock<mutex>实例。调用这个函数的线程应该已经获得了锁lk。当线程退出时,会自动执行以下代码:

1
2
lk.unlock();
cond.notify_all();

注意将锁lk保持锁定,直到该线程退出为止。所以,一定要确保这不会在代码中造成任何死锁,例如由于错误的锁顺序而产生的死锁。

future

可使用future更方便地获得线程的结果,并将异常转移到另一个线程中,然后另一个线程可以任意处置这个异常。当然,应该总是尝试在线程本身中处理异常,不要让异常离开线程。

futurepromise中存储结果。可通过future来获取promise中存储的结果。也就是说,promise是结果的输入端;future是输出端。一旦在同一线程或另一线程中运行的函数计算出希望返回的值,就把这个值放在promise中。然后可以通过future来获取这个值。可将future/promise对想象为线程间传递结果的通信信道。

C++提供标准的future,名为std::future。可从std::future检索结果。T是计算结果的类型。

1
2
future<T> myFuture;
T result = myFuture.get();

调用get()以取出结果,并保存在变量result中。如果另一个线程尚未计算完结果,对get()的调用将阻塞,直到该结果值可用。只能在future上调用一次get()。按照标准,第二次调用的行为是不确定的。可首先通过向future询问结果是否可用的方式来避免阻塞:

1
2
3
4
if (myFuture.wait_for(0))
T result = myFuture.get();
else
// Value is not yet available

std::promise和std::future

C++提供了std::promise类,作为实现promise概念的一 种方式。可在promise上调用set_value()来存储结果,也可调用set_exception()在promise中存储异常。注意,只能在特定的promise上调用set_value()set_exception()一次。如果多次调用它,将抛出std::future_error异常。

如果线程A启动另一个线程B以执行计算,则线程A可创建一个std::promise将其传给已启动的线程。注意,无法复制promise,但可将其移到线程中。线程B使用promise存储结果。将promise移入线程B之前,线程A在创建的promise上调用get_future(),这样,线程B完成后就能访问结果。下面是一个简单示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void DoWork (promise<int> thePromise) {
thePromise.set_value(42);
}
int main () {
promise<int> myPromise;
// Get the future of the promise.
auto theFuture = myPromise.get_future();
// Create a thread and move the promise into it.
thread theThread{ DoWork, std::move(myPromise) };

// Get the result,
int result = theFuture.get();
cout << "Result: " << result << endl;
// Make sure to join the thread,
theThread.join();
}

std::packaged_task

有了std::packaged_task,将可以更方便地使用promise,下面的代码创建一个packaged_task来执行CalculateSum(),通过调用get_future(),从packaged_task检索future。启动一个线程,并将packaged_task移入其中。无法复制packaged_task!启动线程后,在检索到的future上调用get()来获得结果。在结果可用前,将一直阻塞。

CalculateSum()不需要在任何类型的promise中显式存储任何数据。packaged_task自动创建promise,自动在promise中存储被调用函数(这里是CalculateSum())的结果,并自动在promise中存储函数抛出的任何异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int CalculateSum(int a, int b) { return a+b; }
int main () {
// Create a packaged task to run CalculateSum.
packaged_task<int(int, int)> task (CalculateSum);
// Get the future for the result of the packaged task.
auto theFuture = task.get_future():
// Create a thread, move the packaged task into it, and
// execute the packaged task with the given arguments.
thread theThread{ std::move(task), 39, 3};
// Do some more work...

int result = theFuture.get();
cout << result << endl;
// Make sure to join the thread.
theThread.join();
}

std::async

如果想让C++运行时更多地控制是否创建-一个线程以进行某种计算,可使用std::async()。它接收一个将要执行的函数,并返回可用于检索结果的future。async()可通过两种方法运行函数:

  • 创建一个新的线程,异步运行提供的函数。
  • 在返回的future上调用get()方法时,在主调线程上同步地运行函数。

如果没有通过额外参数来调用async(),C++运行时会根据一些因素(例如系统中处理器的数目)从两种方法中自动选择一种方法。也可指定策略参数,从而调整C++运行时的行为。

  • launch::async:强制C++运行时在一个不同的线程上异步地执行函数。
  • launch::deferred:强制C++运行时在调用get()时,在主调线程上同步地执行函数。
  • launch::async|launch::deferred:允许C++运行时进行选择(一默认行为)。

下例演示了async()的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
int calculate () {
return 123;
}

int main () {
auto myFuture = async (calculate);
//auto myFuture = async (launch::async, calculate);
//auto myFuture = async(launch: :deferred, calculate);
// Do some more work..
// Get the result.
int result = myFuture.get();
cout << result << endl;
}

从这个例子可看出,std::async()是以异步方式(在不同线程中)或同步方式(在同一线程中)执行一些计算并在随后获取结果的最简单方法之一。

异常处理

使用future的一大优点是它们会自动在线程之间传递异常。在future上调用get()时,要么返回计算结果,要么重新抛出与future关联的promise中存储的任何异常。使用packaged_taskasync()时,从已启动的函数抛出的任何异常将自动存储在promise中。如果将std::promise用作promise, 可调用set_exception()以在其中存储异常。下面是一个使用async()的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int calculate() {
throw runtime_error("Exception thrown from calculate().");
}
int main() {
// Use the launch::async policy to force asynchronous execution.
auto myFuture = async (launch::async, calculate);

// Get the result.
try {
int result = myFuture.get();
cout << result << endl;
} catch (const exception& ex) {
cout << "Caught exception: " << ex.what() << endl;
}
}

std::shared_future

std::future<T>只要求T可移动构建。在future<T>上调用get()时,结果将移出future,并返回给你。这意味着只能在future<T>上调用get()一次。

如果要多次调用get(),甚至从多个线程多次调用,则需要使用std::shared_future<T>,此时,T需要可复制构建。可使用std::future::share(),或给shared_future构造函数传递future,以创建shared_future。注意,future不可复制,因此需要将其移入shared_future构造函数。

shared_future可用于同时唤醒多个线程。例如,下面的代码片段定义了两个lambda表达式,它们在不同的线程上异步地执行。每个lambda表达式首先将值设置为各自的promise,以指示已经启动。接着在signalFuture
调用get(),这一直阻塞,直到可通过future获得参数为止:此后将继续执行。每个lambda表达式按引用捕获各自的promise,按值捕获signalFuture,因此这两个lambda表达式都有signalFuture的副本。主线程使用async()在不同线程上执行这两个lambda表达式,一直等到线程启动,然后设置signalPromise中的参数以唤醒这两个线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
promise<void> thread1started, thread2Started;
promise<int> signalPromise;

auto signalFutrue = signalPromise.get_future().share();
//shared_future<int> signalFuture (signalPromise.get_future());

auto function1 = [&thread1started, signalFuture] {
threadistarted.set_value();
// Wait until parameter is set
int parameter = signalFuture.get();
};

auto function2 = [&thread2Started, signalFuture] {
thread2started.set_value();
// Wait until parameter is set.
int parameter = signalFuture.get();
};

// Run both lambda expressions asynchronously.
// Remember to capture the future returned by async()!
auto result1 = async (launch::async, function1);
auto result2 = async (launch::async, function2);

// Wait until both threads have started.
thread1started.get_future().wait();
thread2started.get_future().wait();
// Both threads are now waiting for the parameter.
// Set the parameter to wake up both of them.
signalPromise.set_value(42);

线程池

如果不在程序的整个生命周期中动态地创建和删除线程,还可以创建可根据需要使用的线程池。这种技术通常用于需要在线程中处理某类事件的程序。

由于线程池中的所有线程都是预先存在的,因此操作系统调度这些线程并运行的效率大大高于操作系统创建线程并响应输入的效率。此外,线程池的使用允许管理创建的线程数,因此根据平台的不同,可以少至1个线程,也可以多达数千个线程有几个库实现了线程池。

线程设计和最佳实践

本节简要介绍几个有关多线程编程的最佳实践

  • 使用并行标准库算法:标准库中包含大量算法。从C++17开始,有60多个算法支持并行执行。尽量使用这些并行算法,而非编写自己的多线程代码。
  • 终止应用程序前,确保所有thread对象都不是可结合的:确保对所有thread对象都调用了join()detach()。仍可结合的thread析构函数将调用std::terminate(),从而突然间终止所有线程和应用程序。最好的同步就是没有同步:如果采用合理的方式设计不同的线程,让所有的线程在使用共享数据时只从共享数据读取,而不写入共享数据,或者只写入其他线程不会读取的部分,那么多线程编程就会变得简单很多。
  • 尝试使用单线程的所有权模式:这意味着同一时间拥有1个数据块的线程数不多于1。拥有数据意味着不允许其他任何线程读写这些数据。当线程处理完数据时,数据可传递到另一个线程,那个线程目前拥有这些数据的唯一且完整的责任/拥有权。这种情况下,没必要进行同步。
  • 在可能时使用原子类型和操作:通过原子类型和原子操作更容易编写没有争用条件和死锁的代码,因为它们能自动处理同步。如果在多线程设计中不可能使用原子类型和操作,而且需要共享数据,那么需要使用同步机制(如互斥)来确保同步的正确性。
  • 使用锁保护可变的共享数据:如果需要多个线程可写入的可变共享数据,而且不能使用原子类型和操作,那么必须使用锁机制,以确保不同线程之间的读写是同步的。
  • 尽快释放锁:当需要通过锁保护共享数据时,务必尽快释放锁。当一个线程持有一个锁时,会使得其他线程阻塞等待这个锁,这可能会降低性能
  • 不要手动获取多个锁:应当改用std::lock()std::try_lock()。如果多个线程需要获取多个锁,那么所有线程都要以同样的顺序获得这些锁,以防止死锁。可通过泛型函数std::lock()std::try_lock()获取多个锁。
  • 使用RAII锁对象:使用lock_guardunique_lockshared_lockscoped_lockRAII类,在正确的时间自动释放锁
  • 使用支持多线程的分析器:通过支持多线程的分析器找到多线程应用程序中的性能瓶颈,分析多个线程是否确实利用了系统中所有可用的处理能力。
    • 使用线程池,而不是动态创建和销毁大量线程:动态地创建和销毁大量的线程会导致性能下降。这种情况下,最好使用线程池来重用现有的线程。
  • 使用高级多线程库:尽可能使用高级多线程库,例如Intel Threading Building Blocks(TBB). Microsoft Parallel Pattems Library(PPL)等,而不是自己实现。

充分利用软件工程方法

编写高效的C++程序

语言层次的效率

使用某些语言级别的优化(如按引用传递)是良好的编码风格。

高效地操纵对象

C++在幕后做了很多工作,特别是和对象相关的工作。总是应该注意编写的代码对性能的影响。

通过引用传递

应该尽可能不要通过值向函数或方法传递对象。如果函数形参的类型是基类,而将派生类的对象作为实参按值传递,则会将派生对象切片,以符合基类类型。这导致信息丢失,而按引用传递能避免这种开销。这条规则很难记住的一个原因是:从表面上看,按值传递不会有任何问题。

如果函数必须修改对象,可通过引用传递对象。如果函数不应该修改对象,可通过const引用传递。

应避免通过指针传递,因为相对按引用传递,按指针传递相对过时,相当于倒退到C语言了,很少适合于C++。

按引用返回

正如应该通过引用将对象传递给函数一样,也应该从函数返回引用,以避免对象发生不必要的复制。但有时不可能通过引用返回对象,例如编写重载的operator+和其他类似运算符时。永远都不要返回指向局部对象的引用或指针,局部对象会在函数退出时被销毁。

自C++11以后,C++语言支持移动语义,允许高效地按值返回对象,而不是使用引用语义。

通过引用捕捉异常

应该通过引用捕捉异常,以避免分片和额外的复制。抛出异常的性能开销很大,因此任何提升效率的小事情都是有帮助的。

使用移动语义

应该为类实现移动构造函数和移动赋值运算符,以允许C++编译器为类对象使用移动语义。根据“零规则”,设计类时,使编译器生成复制和移动构造函数以及复制和移动赋值运算符便足够了。如果编译器不能隐式定义这些类,那么在允许的情况下,可显式将它们设置为default。如果这行不通,应当自行实现。

对象使用了移动语义时,从函数中通过值返回不会产生很大的复制开销,因而效率更高。

避免创建临时对象

有些情况下,编译器会创建临时的无名对象。为一个类编写全局operator+之后,可对这个类的对象和其他类型的对象进行加法运算,只要其他类型的对象可转换为这个类的对象即可。

返回值优化

通过值返回对象的函数可能导致创建一个临时对象。

预分配内存

使用C++标准库容器的一个重要好处是:它们自动处理内存管理。给容器添加元素时,容器会自动扩展。但有时,这会带来性能问题。例如,std::vector容器在内存中连续存储元素。如果需要扩展,则需要分配新的内存块,然后将所有元素移动(或复制)到新的内存中。

如果预先知道要在vector中添加的元素数量,或大致能够评估出来,就可以在开始添加元素前预分配足够的内存。vector具有容量(capacity)和大小(size),容量指不需要重新分配的情况下可添加的元素数量,大小指容器中的实际元素数量。可以预分配内存,使用reserve()更改容量,使用resize()重新设置vector的大小。

使用内联方法和函数

内联(inline)方法或函数的代码可以直接插到被调用的地方,从而避免函数调用的开销。

一方面,应将所有符合这种优化条件的函数和方法标记为inline。但不要过度使用该功能,因为它实际上背离了基本设计原则;基本设计原则是将接口与实现分离,这样一来,不需要更改接口即可完善实现。仅考虑为常用的基本类使用该功能。另外记住,程序员的内联请求只是给编译器提供的建议,编译器有权拒绝这些建议。

另一方面,编译器会在优化过程中内联一些适当的函数和方法,即使这些函数没有用inline关键字标记,甚至即使这些函数在源文件(而非头文件)中实现也是如此。

设计层次的效率

尽可能多地缓存

下面是通常执行缓慢的任务清单。

  • 磁盘访问:在程序中应避免多次打开和读取同一个文件。如果内存可用,并且需要频繁访问这个文件,那么应将文件内容保存在内存中,
  • 网络通信:如果需要经由网络通信,那么程序会受网络负载的影响而行为不定。将网络访问当成文件访问处理,尽可能多地缓存静态信息。
  • 数学计算:如果需要在多个地方使用非常复杂的计算结果,那么执行这种计算一次并共享结果。但是,
  • 如果计算不是非常复杂,那么计算可能比从缓存中提取更快。如果需要确定这种情形,可使用分析器。
  • 对象分配:如果程序需要大量创建和使用短期对象,可以考虑使用本章后面讨论的对象池。
  • 线程创建:这个任务也很慢。可将线程“缓存”在线程池中,类似于在对象池中缓存对象。

常见的缓存问题是:保存的数据往往是底层信息的副本。在缓存的生命周期中,原始数据可能发生变化。

缓存失效的技术之一是要求管理底层数据的实体通知“程序数据发生了变化”。可通过程序在管理器中注册回调的方式实现这一点。另外,程序还可轮询某些会触发自动重新填充缓存的事件。

使用对象池

存在不同类型的对象池。一种对象池是一次分配一大块内存,此时,对象池就地创建多个较小对象。可将这些对象分发给客户,在客户完成时重用它们,这样就不必另外调用内存管理器为各个对象分配内存或解除内存分配

本节描述另一类对象池。如果程序需要大量同类型的短期对象,这些对象的构造函数开销很大,分析器确认这些对象的内存分配和释放是性能瓶颈,就可为这些对象创建对象池或缓存。每当代码中需要一个对象时,可从对象池中请求一个。当用完对象时,将这个对象返回对象池中。对象池只创建一次对象,因此对象的构造函数只调用一次,而不是每次需要使用时都调用。

因此,对象池适用于构造函数需要为很多对象进行一些设置操作的情况,也适用于通过构造函数之外的方法调用为对象设置一些实例特有的参数。

对象池的实现

对象池实现中最困难的部分是跟踪哪些对象是空闲的,哪些对象正在使用。这个实现采取的方法是将空闲对象保存在一个队列中。每次客户端请求对象时,对象池从队列前端取出一个对象给客户端。

代码使用std::queue类。实现并非是线程安全的。要达到线程安全的目的,一种方式是使用无锁并发队列。但标准库并不提供任何并发数据结构,因此必须使用第三方库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template <typename T>
class ObjectPool {
public:
ObjectPool() = default;
virtual ~ObjectPool() = default;
// Prevent assignment and pass-by-value
ObjectPool(const ObjectPool<T>& src) = delete;
ObjectPool<T>& operator=(const ObjectPool<T>& rhs) = delete;
// The type of smart pointer returned by acquireObject().
using Object = std::shared_ptr<T>;
// Reserves and returns an object for use.
Object acquireObject();
private:
// stores the objects that are not currently in use by clients.
std::queue<std::unique_ptr<T>> mFreeList;
};

使用这个对象池时,必须确保对象池自身的寿命超出对象池给出的所有对象的寿命。对象池的用户通过模板参数,指定用于创建对象的类的名称。

acquireObject()从空闲列表返回顶部对象,如果没有空闲对象,则首先分配新对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <typename T>
typename ObjectPool<T>::Object ObjectPool<T>::acquireObject() {
if (mFreeList.empty())
mFreeList.emplace (std::make_unique<T>());

std::unique_ptr<T> obj(std::move(mFreeList.front()));
mFreeList.pop();

Object smartobject(obj.release(), [this](T* t){
mFreelist.emplace(t);
});

return smartobject;
}

使用对象池

假设一个应用程序使用了大量短期对象,这些短期对象具有昂贵的构造函数。假设有如下ExpensiveObject类定义:

1
2
3
4
5
6
7
8
class ExpensiveObject {
public:
Expensiveobject() { /*Expensive construction */ }
virtual ~ExpensiveObject() = default;

private:
// Data members (not shown)
};

不是在程序的生命周期中创建和删除大量此类对象,而是可以使用前面开发的对象池。程序结构如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
ObjectPool<ExpensiveObject>::Object getExpensiveObject(ObjectPool<ExpensiveObject>& pool) {
// Obtain an ExpensiveObject object from the pool.
auto object = pool.acquireObject();
// Populate the object. (not shown)
return object;
}

void processExpensiveObject(ObjectPool <Expensiveobject>::Object& object) {
// Process the object. (not shown)
}

int main() {
ObjectPool<ExpensiveObject> requestPool;
{
vector<ObjectPool<ExpensiveObject>::Object> objects;
for(size_t i = 0; i < 10; ++ i)
objects.push_back (getExpensiveobject (requestPool));
}
for (size_t i = 0; i < 100; ++ i) {
auto reg = getExpensiveObject (requestPool);
processExpensiveObject (req);
}
return 0;
}

main()函数的第一部分包含一个内部代码块,它创建了10个ExpensiveObject对象,并将它们存储在Object容器中。由于创建的所有Object对象都存储在vector中并持续存在,对象池将不得不创建10个ExpensiveObject实例。在这个内部代码块的闭括号处,vector超出作用域,其中的所有Object对象会自动释放,回到对象池中。

在第二个for循环中,getExpensiveObject()返回的Object对象(=shared_ptrs)在for循环每个迭代的结束处超出作用域,因此自动释放,回到对象池中。如果给ExpensiveObject类的构造函数添加一条输出语句,你将看到,在整个程序运行期间,只对构造函数调用10次,即使main()函数中第二个for循环的循环次数达到数百次也同样如此。

熟练掌握调试技术

调试的基本定律

断言

<cassert>头文件定义了assert宏。它接收一个布尔表达式,如果表达式求值为false,则打印出一条错误消息并终止程序。如果表达式求值为true,则什么也不做。

断言可迫使程序在bug来源的确切点公开bug。如果没有在这一点设置断言,那么程序可能会带着错误的值继续执行,因而bug可能在后面才显现出来。因此,断言允许尽早检测到bug。

标准assert宏的行为取决于NDEBUG预处理符号:如果没有定义该符号,则发生断言,否则忽略断言。编译器通常在编译发布版本时定义这个符号。如果要在发布版本中保留断言,就必须改变编译器的设置,或者编写自己的不受NDEBUG值影响的断言。

可在代码中任何需要“假设”变量处于某些状态的地方使用断言。例如,如果调用的库函数应该返回一个指针,并且声称绝对不会返回nullptr,那么在函数调用之后抛出断言,以确保指针不是nullptr。

注意,假设应该尽可能少。例如,如果正在编写一个库函数,不要断言参数的合法性。相反,要对参数进行检查,如果参数非法,返回错误代码或抛出异常作为规则,断言应只用于真正有问题的情形,因此在开发过程中遇到的断言绝不应忽略。如果在开发过程中遇到一个断言,应修复而不是禁用它。

静态断言

static_assert允许在编译时对断言求值。static_assert调用按收两个参数:编译时求值的表达式和字符串。当表达式计算为false时,编译器将给出包含指定字符串的错误提示。下例核实是否在使用64位编译器进行编译:

1
static_assert(sizeof(void*) == 8, "Requires 64-bit compilation.");

如果编译时使用32位编译器,指针是4个字符,编译器将给出错误提示,如下所示:

1
test.cpp(3): error C2338: Requires 64-bit compilation.

从C++17开始,字符串参数变为可选的,如下所示:

1
static_assert (sizeof(void*)==8);

此时,如果表达式的计算结果是false,将得到与编译器相关的错误消息。

另一个展示static_assert强大功能的例子是和类型trait结合使用。例如,如果编写一个函数模板或类模板,那么可结合使用static_assert和类型trait,当模板类型不符合一定条件时,生成编译器错误。下例要求process()的模板类型将Base1作为基类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Base1 {};
class Base1child : public Base1 {};
class Base2 {};
class Base2Child : public Base2 {};

template<typename T>
void process (const T& t) {
static_assert(is_base_of_v<Base1, T>, "Basel should be a base for T.");
}

int main() {
process(Base1());
process (Base1Child());
// process(Base2());
// Exror
// process(Base2Child());
// Error
}

调试技术

调试可重复的bug

可一致高效地重现bug时,应开始在代码中找到导致bug的根源。此时的目标是找到触发这个问题的准确代码行。可采用两种不同的策略

  • 记录调试消息:在程序中添加足够的调试消息并观察bug重现时的输出
  • 使用调试器:通过调试器可单步跟踪程序的执行,定点观察内存状态和变量的值。

调试不可重现的bug

  • 尝试将不可重现的bug转换为可重现的bug。
  • 分析错误日志。如果程序根据前面的描述带有生成错误日志的功能,那么这一点很容易实现。
  • 获取和分析跟踪。如果程序带有跟踪输出(例如之前描述的环形缓冲区),那么这一点很容易实现。
  • 如果有的话,检查崩溃/内存文件。在UNIX和Linux上,这些内存转储文件称为核心文件(core file)。每个平台都提供了分析这些内存转储文件的工具。例如,这些工具可用来生成应用程序的堆栈跟踪信息,或查看应用程序崩溃之前内存中的内容。
  • 检查代码。遗憾的是,这往往是检查不可重现bug的根源的唯一策略。
  • 使用内存观察工具。

调试内存问题

内存错误的分类

下面介绍内存错误的分类。

表总结了5种涉及释放内存的主要错误。

可以看出,有些内存释放错误不会立即导致程序终止。这些bug更微妙,会导致程序在运行一段时间之后出错。

内存访问错误另一类的内存错误涉及实际的内存读写,如表所示。

调试内存错误的技巧

  • 验证带有动态分配内存的类具有以下这种析构函数:能准确地释放对象中分配的内存,不多也不少。

  • 确保类能够通过复制构造函数和赋值运算符:正确处理复制和赋值。确保移动构造函数和移动赋值运算符把源对象中的指针,正确设置为nullptr,这样其析构函数才不会释放该内存。

  • 检查可疑的类型转换。如果将对象的指针从一种类型转换为另一种类型,确保转换是合法的。在可能的情况下,使用dynamic_cast。

  • 确保每个new调用都匹配一个delete调用。同样,每个对malloc、alloc和calloc的调用都要匹配一个对free的调用。每个new[]调用也要匹配一个delete[]调用。为避免多次释放内存或使用已释放的内存,建议释放内存后将指针设置为nullptr。

  • 检查缓冲区溢出。每次迭代访问数组或读写C风格的字符串时,验证没有越过数组或字符串的结尾访问内存。

  • 检查无效指针的解除引用。

  • 在堆栈上声明指针时,确保总是在声明中初始化指针。例如,使用T* p = nullptrT* p = new T,但是绝不要使用T* p

  • 同样,确保总在类的初始化器或构造函数中初始化指针数据成员,既可以在构造函数中分配内存,也可将指针设置为nullptr。

651 673

使用设计技术和框架

容易忘记的语法

编写类

不要忘了开头部分。下面是一个简单的类定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#pragma once

class Simple {
public:
Simple();
virtual ~Simple() = default;

Simple(const Simple& src) = delete;
Simple& operator=(const Simple& rhs) = delete;

Simple (Simple&& src) = default;
Simple& operator=(Simple&& rhs) = default;

virtual void publicMethod();
int mPublicinteger;
protected:
virtual void protectedMethod();
int mProtectedinteger = 41;
private:
virtual void privateMethod();
int mPrivateInteger = 42;
static const int kconstant = 2;
static int sStaticInt;
};

通常,至少要将析构函数设置为virtual,因为其他人可能想从这个类派生新类。也允许保留析构函数为非virtual,但这只限于将类标记为final,以防止其他类从其派生的情况。如果只想将析构函数设置为virtual,但不需要析构函数中的任何代码,则可显式地设置为default,如Simple类示例所示。

这个示例也说明,可显式地将特殊成员函数设置为delete或default。将复制构造函数和复制赋值运算符设置为delete,以防止赋值和按值传递,而将移动构造函数和移动赋值运算符显式设置为default。

派生类

要从现有的类派生,可声明一个新类,这个类是另一个类的扩展类。下面是DerivedSimple类的定义,DerivedSimple从Simple派生而来:

1
2
3
4
5
6
7
8
#pragma once

class Derivedsimple : public Simple {
public:
DerivedSimple();
virtual void publicMethod() override; // Overridden method
virtual void anotherMethod();
};

使用“复制和交换”惯用语法

只需要创建对象的一个副本,修改这个副本(可以是复杂算法,可能抛出异常)。最后,当不再抛出异常时,将这个副本与原始对象进行交换。赋值运算符是一个可使用“复制和交换”惯用语法的操作示例。赋值运算符首先制作原始对象的一个本地副本,此后仅使用不抛出异常的swap()实现,将这个副本与当前对象进行交换。

始终存在更好的方法

RAII

RAII(Resource Acquisition Is Initialization,资源获得即初始化)是一个简单却十分强大的概念。它用于在RAII实例离开作用域时自动释放已获取的资源。这是在确定的时间点发生的。基本上,新RAII实例的构造函数获取特定资源的所有权,并使用资源初始化实例,因此得名RAII。在销毁RAII实例时,析构函数自动释放所获取的资源。下面的RAII类File安全地包装C风格的文件句柄(std::FILE),并在RAII实例离开作用域时自动关闭文件。RAII类也提供get()release()reset()方法,这些方法的行为类似于标准库类(如std::unique_ptr)中的同名方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <cstdio>
class File final {
public:
File(std::FILE* file);
~File();

File(const File& src) = delete;
File& operator=(const File& rhs) = delete;

File(File&& src) noexcept = default;
File& operator=(File&& rhs) noexcept = default;

std::FILE* get() const noexcept;
std::FILE* release() noexcept;
void reset(std::FILE* file = nullptr) noexcept;
private:
std::FILE* mFile;
};

File::File(std::FILE* file): mFile(file) { }
File::~File() {
reset();
}
std::FILE* File::get() const noexcept {
return mFile;
}
std::FILE* File::release() noexcept {
std::FILE* file = mFile;
mFile = nullptr;
return file;
}
void File::reset (std::FILE* file) noexcept {
if (mFile)
fclose(mFile);
mFile = file;
}

用法如下:

1
File myFile(fopen("input.txt", "z"));

myFile实例一旦离开作用域,就会调用它的析构函数,并自动关闭文件。

双分派

双分派(double dispatch)技术用于给多态性概念添加附加维度。C++没有提供相应的语言机制,以根据多个对象的运行时类型选择行为。虚方法本身不足以建立这种场景的模型,它们仅根据接收对象的运行时类型来确定方法或行为。

有些面向对象语言允许基于两个或多个对象的运行时类型,在运行时选择方法,它们将该功能称为多方法(multi-methods)。而在C++中,并没有支持多方法的核心语言功能,但可以使用双分派技术,从而创建针对多个对象的虚函数。

注意双分派实际上是多分派的特例。所谓多分派,是指根据两个或多个对象的运行时类型来选择行为。在实践中,双分派可根据两个对象的运行时类型选择行为,这通常就能满足需要。

首先重点分析单个派生类,可能是Bear类。该类需要一个具有以下声明的方法:

1
virtual bool eats (const Animal& prey) const override;

双分派的关键在于基于参数上的方法调用来确定结果。假设Animal类有一个eatenBy()方法,该方法将Animal引用作为参数。如果当前Animal会被传入的动物捕食,该方法返回true。有了这个方法,eats()方法的定义变得十分简单:

1
2
3
bool Bear::eats(const Animal& prey) const {
return prey.eatenBy(*this);
}

初看起来,这个解决方案给单多态方法添加了另一个方法调用层。毕竟,每个派生类都必须为每个Animal派生类实现eatenBy()版本。但有一个重要区别:多态发生了两次!当调用eats()方法时,多态性确定,是调用Bear::eats()Fish::eats()还是其他。当调用eatenBy()方法时,多态性再次确定要调用哪个类的方法版本,调用prey对象的运行时类型的eatenBy()。注意,*this的运行时类型始终与编译时类型相同,这样,编译器可为实参(这里是Bear)调用eatenBy()的正确重载版本。

下面是使用双分派的Animal层次结构的类定义。注意forward declarations是必需的,因为基类使用派生类的引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Fish;
class Bear;
class Dinosaur;

class Animal {
public:
virtual bool eats (const Animal& prey)const = 0;
virtual bool eatenBy (const Bear&) const = 0;
virtual bool eatenBy (const Fish&) const = 0;
virtual bool eatenBy (const Dinosaur&) const = 0;
};
class Bear : public Animal {
public:
virtual bool eats(const Animal& prey) const override;
virtual bool eatenBy(const Bear&) const override;
virtual bool eatenBy(const Fish&) const override;
virtual bool eatenBy(const Dinosaur&) const override;
};

实现代码如下所示。注意,Animal的派生类以相同的方式实现eats()方法,但不能向上延伸到基类;如果尝试这么做,编译器不知道要调用eatenBy()方法的哪个重载版本,因为*this是Animal而非特定的派生类。根据对象的编译时类型(而非运行时类型)来确定方法重载方案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool Bear::eats(const Animal& prey) const { return prey.eatenBy(*this); }
bool Bear::eatenBy(const Bear&) const { return false; }
bool Bear::eatenBy(const Fish&) const { return false; }
bool Bear::eatenBy(const Dinosaur&) const { return true; }

bool Fish::eats(const Animal& prey) const { return prey.eatenBy(*this): }
bool Fish::eatenBy(const Bear&) const { return true; }
bool Fish::eatenBy(const Fish&) const { return true; }
bool Fish::eatenBy(const Dinosaur&) const { return true; }

bool Dinosaur::eats(const Animal& prey) const { return prey.eatenBy(*this); }
bool Dinosaur::eatenBy(const Bear&) const { return false; }
bool Dinosaur::eatenBy (const Fish&) const { return false; }
bool Dinosaur::eatenBy(const Dinosaur&) const { return true; }

开发跨平台和跨语言应用程序

跨平台开发

整数大小

C++标准并未定义整数类型的准确大小。C++标准仅指出:有5种标准的有符号整数类型:signed charshort intintlong intlong long int。在这个列表中,后一种类型占用的存储空间大于或等于前一种类型。

二进制兼容性

为支持不具备二进制兼容性的平台,一种解决方案是在每个目标平台上使用编译器分别构建每个版本。另一种解决方案是交叉编译(cross-compiling)。 如果为开发使用平台X,但想使程序运行在平台Y和Z上,可在平台X上使用交叉编译器,为平台Y和Z生成二进制代码。

地址大小

当提到架构是32位时,通常是说地址大小是32位或4字节。通常而言,具有更大地址大小的系统可处理更多内存,在复杂系统上的运行速度更快,由于指针是内存地址,自然与地址大小密切相关。许多程序员都认为,指针的大小始终是4字节,但这是错误的。需要意识到,大多数大小都不是C+标准预先确定的。C++标准只是说,short integer占用的空间小于或等于integerinteger占用的空间小于或等于long integer

指针的大小未必与整数大小相同。例如,在64位平台上,指针是64位,但整数可能是32位。将64位指针强制转换为32位整数时,将丢失32个关键位!C++标准在<cstdin>中定义了std::intptr_t整数类型,它的大小至少足以存储一个指针。根据C++标准,这种类型的定义是可选的,但几乎所有编译器都支持它。

字节顺序

现在将数字分为“内存大小”部分。剩余的唯一问题是如何将它们存储在内存中。需要两个字节,但字节序是不确定的,事实上取决于相关系统的架构,一种表示数字的方式是将高位字节首先放入内存,接着将低位字节放入内存。这种策略被称为大端序,因为数字的较大部分首先放入。另一种是按相反顺序放置字节,首先将低序字节放入内存。这种策略被称为小端序,因为数字的较小部分首先放置。

跨语言开发

链接C代码

在C++程序中开始使用已经编译的C代码之前,首先需要了解“名称改编”这个概念。为实现函数重载,会对复杂的C++名称平台进行“扁平化”。例如,如果有一个C++程序,则编写以下代码是合法的:

1
2
3
void MyFunc(double);
void MyFunc(int);
void MyFunc(int, int);

这意味着链接器将看到MyFunc,但是不知道该调用哪种版本的MyFunc函数。因此,所有C++编译器执行名称改编操作,以生成合理的名称,如下所示:

1
2
3
MyFunc_double
MyFunc_int
MyFunc_int_int

为避免与定义的其他名称发生冲突,生成的名称通常使用一些字符。对于链接器而言,这些字符是合法的;对于C++源代码而言,这些字符是非法的。例如,Microsoft VC++生成如下名称:

1
2
3
?MyFunc8@YAXN@Z
?MyFunc8@YAXH@Z
?MyFunc@@YAXHH@Z

C语言不支持函数重载(编译器将报错,指出是重复定义)。因此,C编译器生成的名称十分简单,例如_MyFunc。因此,如果用C++编译器编译一个简单的程序,即便仅有一个MyFunc名称实例,也仍会生成一个请求,要求链接到改编后的名称。但是当链接C库时,找不到所需的已改编名称,因此链接器将报错。因此,有必要告知C++编译器不要改编相应的名称。为此,需要在头文件中使用extern"language"限定,以告知客户端代码创建与指定语言兼容的名称;如果库源是C++,还需要在定义站点使用这个限定,以告知库代码生成与指定语言兼容的名称。

extern"language"的语法如下:

1
2
extern "language" declaration1();
extern "language" declaration2();

也可能如下:

1
2
3
4
extern "language" {
declaration1();
declaration2();
}

C++标准指出,可使用任何语言规范;因此,从原理上讲,编译器可支持以下代码:

1
2
3
4
extern "C" MyFunc(int i);
extern "Fortran" MatrixInvert (Matrix* M);
extern "Pascal" SomeLegacySubroutine(int n);
extern "Ada" AimMissileDefense (double angle);

但实际上,许多编译器只支持C。每个编译器供应商都会告知你所支持的语言指示符。

例如,在以下代码中,将doCFunction()的函数原型指定为外部C函数:

1
2
3
4
5
6
7
8
extern "C" {
void docFunction(int i);
}

int main() {
doCFunction(8); // Calls the C function.
return 0;
}

在链接阶段,在附加的已编译二进制文件中提供doCFunction()的实际定义。 extern关键字告知编译器:链接的代码是用C编译的。使用extern的更常见模式是在头文件级别。可以编写另一个头文件,将原始文件打包到extern块中,以指定定义函数的整个头文件是用C编写的。

另一个常见模型是编写单个头文件,然后根据条件针对C或C++对其进行编译。如果为C++编译,C++编译器将预定义__cplusplus符号。该符号不是为C编译定义的。因此,可以经常看到以下形式的头文件:

1
2
3
4
5
6
7
8
#ifdef __cplusplus
extern"C" {
#endif
declaration1();
declaration2();
#ifdef __cplusplus
}
#endif

附录C

标准库头文件C++标准库的接口包含87个头文件,其中有26个表示C标准库。要记住源代码中应该包含哪些头文件往往很难,所以这个附录简要描述每个头文件的内容,按照以下8类组织:

  • C标准库
  • 容器算法、迭代器和分配器
  • 通用工具
  • 数学工具
  • 异常
  • I/O流
  • 线程支持库

C标准库

C++标准库包含完整的C标准库。头文件通常是一样的,除了以下两点:

  • 头文件为<cname>而不是<name.h>
  • <cname>头文件中声明的所有名称都在std名称空间中

为了后向兼容,如有必要,仍可包含<name.h>。然而,这样会把名字放在全局名称空间而不是std名称空间中。另外,<name.h>已不赞成使用。建议避免这种用法。

表C-1总结了最常用功能。注意建议避免使用C功能,而尽量使用等价的C++功能。

头文件名 内容
<cassert> assert()
<ccomplex> 只包括<complex>。从C++17开始,已不赞成使用
<cctype> 字符谓词和操作函数,例如isspace()tolower()
<cerrno> 定义errno表达式,它是一个宏,获得某些C函数的最后一个错误编号
<cfenv> 支持浮点数环境,例如浮点异常、浮点数取整等
<cfloat> 和浮点数算术相关的C风格定义,例如FLT_MAX
<cinttypes> 定义与printf()scanf()和类似函数结合使用的一些宏,还定义一些操作intmax_t的函数
<ciso646> 在C语言中,<iso646.h>文件定义宏and和or等。在C++中,这些都是关键字,所以这个头文件为空
<climits> C风格的限制定义,例如INT_MAX。建议改用C++中对应的<limits>
<clocale> 用于本地化的宏和函数,例如LC_ALLsetlocale()。见C++中对应的<locale>
<cmath> 数学工具,包括三角函数、sqrt()fabs()
<csetjmp> setjmp()和longjmp()`,绝不要在C++中使用
<csignal> signal()raise(),避免在C++中使用
<cstdalign> 和对齐相关的宏__alignas_is_defined,从C++17开始已不赞成使用
<cstdarg> 处理变长参数列表的宏和类型
<cstdbool> 与布尔类型相关的宏__bool_true_false_are_defined,从C++17开始已不赞成使用
<cstddef> 重要的常量,例如NULL,以及重要的类型,例如size_t
<cstdint> 定义一些标准的整数类型,例如int8_t和int64_t等,还包含表示这些类型的最大值和最小值的宏
<cstdio> 文件操作,包括fopen()fclose()。格式化I/O:printf()scanf()等系列函数。字符I/O:getc()putc()等系列函数。文件定位:fseck()ftell()。建议改用C++流
<cstdlib> 随机数操作:rand()srand(),从C++14开始已不建议使用,而改用C++<random>。这个头文件包含abort()exit()函数,应该避免使用这两个函数。C风格的内存分配函数:calloc()malloc()realloc()free()。C风格的排序和搜索函数;qsort()bscarch(),字符串到数值的转换函数;atof()atoi()等。一组与多字节/宽字符串处理相关的函数
<cstring> 底层内存管理函数,包括memcpy()memset()。C风格的字符串函数,例如strcpy()strcmp()
<ctgmath> 只包含<ccomplex><cmath>,从C++17开始已不赞成使用
<ctime> 时间相关的函数,包括time()localtime()
<cuchar> 定义一些与Unicode相关的宏和函数例如mbrtoc16()
<cwchar> 宽字符版本的字符串、内存和I/O函数
<cwctype> <cctype>中函数的宽字符版本:iswspace()towlower()

容器

可在以下12个头文件中找到标准库容器的定义,如表C-2所示。

头文件名 内容
<array> array类模板
<bitset> bitset类模板
<deque> deque类模板
<forward_list> forward list类模板
<list> list类模板
<map> map和multimap类模板
<queue> queue和priority_queue类模板
<set> set和multisct类模板
<stack> stack类模板
<unordered_map> unordered_map和unordered_multimap类模板
<unordered_set> unordered_set和unordered multiset类模板
<vector> vector类模板和vector<bool>特例化

每个头文件都包含使用特定容器需要的所有定义,包括迭代器。

算法、迭代器和分配器

表中的不同头文件定义可用的标准库算法、迭代器和分配器。

头文件名 内容
<algorithm> 标准库中大部分算法的原型
<execution> 定义与标准库算法起使用的执行策略类型
<functional> 定义内建函数对象、取反器、绑定器和适配器
<iterator> 定义iterator_trait、迭代器标签、iterator、reverse_iterator、插入迭代器(例如back_insert_iterator)和流迭代器
<memory> 定义默认分配器、一些处理容器内未初始化内存的工具函数
<memory_resource> 定义多态分配器和内存资源
<numeric> 一些数值算法的原型,比如accumulate()inner_product()partial_sum()adjacent_difference()
<scoped_allocator> 可用于内嵌容器的分配器,例如字符串的vector、map的vector

通用工具

标准库在一些不同的头文件中包含一些通用的工具函数

头文件名 内容
<any> 定义any类
<charconv> 定义chars_format枚举类、from_chars()函数、to_chars()函数和相关结构
<chrono> 定义chrono库
<codecvt> 为不同字符编码提供代码转换的facet。从C++17开始,已经不赞成使用这个头文件
<filesystem> 定义用于处理文件系统的可用类和函数
<initializer_list> 定义initializer_list类
<limits> 定义numeric_limits类模板,以及大部分内建类型的特例化
<locale> 定义locale类、use_facet()has_facet()模板函数以及facet系列函数
<new> 定义bad_alloc异常和set_new_handler()函数,以及operator newoperator delete的所有6种原型
<optional> 定义optional类
<random> 定义随机数生成器库
<ratio> 定义Ratio库,以操作编译时有理数
<regex> 定义正则表达式库
<string> 定义basic_string类模板以及string和wstring的类型别名实例
<string_view> 定义basic_string_view类模板和类型别名aliases string_view和wstring_view
<system_error> 定义错误分类和错误代码
<tuple> 定义tuple类模板,作为pair类模板的泛化
<type_traits> 定义模板元编程中使用的类型trait
<typeindex> 定义type_info的简单包装,可在关联容器和无序关联容器中用作索引类型
<typeinfo> 定义bad_cast和bad_typeid异常。 定义type_info类,typcid运算符返回这个类的对象
<utility> 定义pair类模板和make_pair()。这个头文件还定义了工具函数swap()exchange()move()
<variant> 定义variant类

数学工具

C++提供了一些数值处理功能。

头文件名 内容
<complex> 定义处理复数的complex类模板
<valarray> 定义valarray类,以及处理数学矢量和矩阵的相关类和类模板

异常

头文件名 内容
<exception> 定义exception和bad_exception类,以及set_unexpected()set_terminate()uncaught_exception()函数
<stdexcept> 没有定义在<exception>中的非领域相关的异常

I/O流

通常情况下应用程序只需要包含<fstream><iomanip><iostream><istream><ostrcam><sstream>

头文件名 内容
<fstream> 定义了basic_filebuf、basic_ifstream、basic_ofstream和basic_fstream类,声明了filebufwfilebufifstreamwifstreamofstreamwofstreamfstreamwfstream类型别名
<iomanip> 声明了其他地方没有声明的I/O运算符
<ios> 定义了ios_base和basic_ios类,声明了大部分流运算符。几乎不需要直接包含这个头文件
<iosfwd> 其他I/O流头文件中出现的模板和类型别名的前向声明。几乎不需要直接包含这个头文件
<iostream> 声明了cin、cout. cerr和clog以及对应的宽字符版本。注意这不仅是<istream><ostrcam>的组合
<istream> 定义了basic_istreambasic_iostream类,声明了istreamwistreamiostreamwiostream类型别名
<ostream> 定义了basic_ostream类。声明了ostream和wostream类型别名
<sstream> 定义了basic_stringbufbasic_istringstreambasic_ostringstreambasic_stringstream类,声明了stringbufwstringbufistringstreamwistringstreamostringstreamwostringstreamstringstreamwstringstream类型别名
<strcambuf> 声明了basic_streambuf类以及streambufwstreambuf类型别名。 几乎不需要直接包含这个头文件
<strstream> 已不赞成使用

线程库

C++包含一个线程库,允许编写与平台无关的多线程应用程序。

头文件名 内容
<atomic> 定义了原子类型、atomic<T>以及原子操作
<condition_variable> 定义了condition_variablecondition_variable_any
<future> 定义了futurepromisepackaged_taskasync()
<mutex> 定义了不同的非共享互斥体、锁类以及call_once()
<shared_mutex> 定义了shared_mutexshared_timed_mutexshared_lock
<thread> 定义了thread类

https://colab.research.google.com/drive/1-dkuFrfver70j-UCpAp0XQZszsJexHkH#scrollTo=GJHxLzmGfb3G

CUDA编程模型为应用和硬件设备之间的桥梁,所以CUDA C是编译型语言,不是解释型语言,OpenCL就有点类似于解释型语言,通过编译器和链接,给操作系统执行(操作系统包括GPU在内的系统)

首先安装插件并加载:

1
2
3
!git config --global http.sslVerify"False"
!pip install git+https://github.com/andreinechaev/nvcc4jupyter.git
%load_ext nvcc_plugin

从hello world开始:要在笔记本中运行代码,请在代码的开头添加%%cu扩展名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
%%cu
#include <stdio.h>

//kernel function
__global__ void helloFromGPU (void)
{
printf("Hello World from GPU!\n");
}

int main()
{
helloFromGPU <<<1, 1>>>();
cudaDeviceSynchronize();
return 0;
}

__global__:声明一个函数作为一个存在的kernel。这样的一个函数是:

  1. 在设备上执行的,
  2. 仅可从主机调用。
  3. 其调用形式为:helloFromGPU<<<1,10>>>();

一个kernel是由一组线程执行,所有线程执行相同的代码。上面一行三对尖括号中的1和10 ,表示启动一个 grid 为 螺纹块 的内核。执行配置中的第一个参数1指定网格中线程块的数量,第二个参数10指定线程块中的线程数。

有一点需要注意的是,printf的输出是在GPU内部执行的,你若想在控制台(网页上)收到该输出,你必须添加

1
cudaDeviceSynchronize();

矩阵加法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
%%cu
#include <stdio.h>

#define VECTOR_LENGTH 10000
#define MAX_ERR 1e-4

__global__ void vector_add(float *out, float *a, float *b, int n)
{
for(int i = 0; i < n; i++)
{
out[i] = a[i] + b[i];
}
}

int main()
{
float *a, *b, *out;
float *d_a, *d_b, *d_out;

a = (float*)malloc(sizeof(float) * VECTOR_LENGTH);
b = (float*)malloc(sizeof(float) * VECTOR_LENGTH);
out = (float*)malloc(sizeof(float) * VECTOR_LENGTH);

for(int i = 0; i < VECTOR_LENGTH; i++)
{
a[i] = 3.0f;
b[i] = 0.14f;
}

cudaMalloc((void**)&d_a, sizeof(float) * VECTOR_LENGTH);
cudaMalloc((void**)&d_b, sizeof(float) * VECTOR_LENGTH);
cudaMalloc((void**)&d_out, sizeof(float) * VECTOR_LENGTH);

cudaMemcpy(d_a, a, sizeof(float) * VECTOR_LENGTH, cudaMemcpyHostToDevice);
cudaMemcpy(d_b, b, sizeof(float) * VECTOR_LENGTH, cudaMemcpyHostToDevice);

vector_add<<<1,1>>>(d_out, d_a, d_b, VECTOR_LENGTH);

cudaMemcpy(out, d_out, sizeof(float) * VECTOR_LENGTH, cudaMemcpyDeviceToHost);
// Test the result
for(int i = 0; i < VECTOR_LENGTH; i++)
{
printf("%f\n", out[i]);
}

cudaFree(d_a);
cudaFree(d_b);
cudaFree(d_out);
free(a);
free(b);
free(out);

}

概述

一个异构环境,通常有多个CPU多个GPU,他们都通过PCIe总线相互通信,也是通过PCIe总线分隔开的。所以我们要区分一下两种设备的内存:

  • 主机:CPU及其内存
  • 设备:GPU及其内存

注意这两个内存从硬件到软件都是隔离的(CUDA6.0 以后支持统一寻址),我们目前先不研究统一寻址,我们现在还是用内存来回拷贝的方法来编写调试程序,以巩固大家对两个内存隔离这个事实的理解。

一个完整的CUDA应用可能的执行顺序如下图:

标准C函数 CUDA C 函数 说明
malloc cudaMalloc 内存分配
memcpy cudaMemcpy 内存复制
memset cudaMemset 内存设置
free cudaFree 释放内存
1
2
cudaError_t cudaMemcpy(void * dst,const void * src,size_t count,
cudaMemcpyKind kind)

这个函数是内存拷贝过程,可以完成以下几种过程(cudaMemcpyKind kind)

  • cudaMemcpyHostToHost
  • cudaMemcpyHostToDevice
  • cudaMemcpyDeviceToHost
  • cudaMemcpyDeviceToDevice

这四个过程的方向可以清楚的从字面上看出来,这里就不废话了,如果函数执行成功,则会返回 cudaSuccess 否则返回 cudaErrorMemoryAllocation

使用下面这个指令可以吧上面的错误代码翻译成详细信息:

1
char* cudaGetErrorString(cudaError_t error)

内存是分层次的,下图可以简单地描述,但是不够准确,后面我们会详细介绍每一个具体的环节:

共享内存(shared Memory)和全局内存(global Memory)后面我们会特别详细深入的研究,这里我们来个例子,两个向量的加法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
%%cu
#include <cuda_runtime.h>
#include <stdio.h>

#define CHECK(a) a

bool checkResult(float *a, float *b, int size)
{
for(int i=0;i<size;i++) {
if (a[i] != b[i]) {
printf("the %d is different, %f, %f\n", i, a[i], b[i]);
return false;
}
else
printf("the %d is same, %f, %f\n", i, a[i], b[i]);
}
return true;
}

void initialData(float *a, int size)
{
for(int i=0;i<size;i++)
a[i] = 1.0 * i;
}

void sumArrays(float * a,float * b,float * res,const int size)
{
for(int i=0;i<size;i+=4)
{
res[i]=a[i]+b[i];
res[i+1]=a[i+1]+b[i+1];
res[i+2]=a[i+2]+b[i+2];
res[i+3]=a[i+3]+b[i+3];
}
}
__global__ void sumArraysGPU(float*a,float*b,float*res)
{
int i=threadIdx.x;
res[i]=a[i]+b[i];
}
int main(int argc,char **argv)
{
int dev = 0;
cudaSetDevice(dev);

int nElem=32;
printf("Vector size:%d\n",nElem);
int nByte=sizeof(float)*nElem;
float *a_h=(float*)malloc(nByte);
float *b_h=(float*)malloc(nByte);
float *res_h=(float*)malloc(nByte);
float *res_from_gpu_h=(float*)malloc(nByte);
memset(res_h,0,nByte);
memset(res_from_gpu_h,0,nByte);

float *a_d,*b_d,*res_d;
CHECK(cudaMalloc((float**)&a_d,nByte));
CHECK(cudaMalloc((float**)&b_d,nByte));
CHECK(cudaMalloc((float**)&res_d,nByte));

initialData(a_h,nElem);
initialData(b_h,nElem);

CHECK(cudaMemcpy(a_d,a_h,nByte,cudaMemcpyHostToDevice));
CHECK(cudaMemcpy(b_d,b_h,nByte,cudaMemcpyHostToDevice));

dim3 block(nElem);
dim3 grid(nElem/block.x);
sumArraysGPU<<<grid,block>>>(a_d,b_d,res_d);
printf("Execution configuration<<<%d,%d>>>\n",block.x,grid.x);

CHECK(cudaMemcpy(res_from_gpu_h,res_d,nByte,cudaMemcpyDeviceToHost));
sumArrays(a_h,b_h,res_h,nElem);

checkResult(res_h,res_from_gpu_h,nElem);
cudaFree(a_d);
cudaFree(b_d);
cudaFree(res_d);

free(a_h);
free(b_h);
free(res_h);
free(res_from_gpu_h);

return 0;
}

线程管理

当内核函数开始执行,如何组织GPU的线程就变成了最主要的问题了,我们必须明确,一个核函数只能有一个grid,一个grid可以有很多个块,每个块可以有很多的线程,这种分层的组织结构使得我们的并行过程更加自如灵活:

一个线程块block中的线程可以完成下述协作:

  • 同步
  • 共享内存

不同块内线程不能相互影响!他们是物理隔离的!

接下来就是给每个线程一个编号了,我们知道每个线程都执行同样的一段串行代码,那么怎么让这段相同的代码对应不同的数据呢?首先第一步就是让这些线程彼此区分开,才能对应到相应从线程,使得这些线程也能区分自己的数据。如果线程本身没有任何标记,那么没办法确认其行为。依靠下面两个内置结构体确定线程标号:

  • blockIdx(线程块在线程网格内的位置索引)
  • threadIdx(线程在线程块内的位置索引)

注意这里的Idx是index的缩写(我之前一直以为是identity x的缩写),这两个内置结构体基于 uint3 定义,包含三个无符号整数的结构,通过三个字段来指定:

  • blockIdx.x
  • blockIdx.y
  • blockIdx.z
  • threadIdx.x
  • threadIdx.y
  • threadIdx.z

上面这两个是坐标,当然我们要有同样对应的两个结构体来保存其范围,也就是blockIdx中三个字段的范围threadIdx中三个字段的范围:

  • blockDim
  • gridDim

他们是dim3类型(基于uint3定义的数据结构)的变量,也包含三个字段x,y,z.

  • blockDim.x
  • blockDim.y
  • blockDim.z

网格和块的维度一般是二维和三维的,也就是说一个网格通常被分成二维的块,而每个块常被分成三维的线程。

注意:dim3是手工定义的,主机端可见。uint3是设备端在执行的时候可见的,不可以在核函数运行时修改,初始化完成后uint3值就不变了。他们是有区别的!这一点必须要注意。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
%%cu
#include <cuda_runtime.h>
#include <stdio.h>

__global__ void checkIndex(void)
{
printf("threadIdx:(%d,%d,%d)
blockIdx:(%d,%d,%d)
blockDim:(%d,%d,%d)
gridDim(%d,%d,%d)\n",
threadIdx.x,threadIdx.y,threadIdx.z,
blockIdx.x,blockIdx.y,blockIdx.z,
blockDim.x,blockDim.y,blockDim.z,
gridDim.x,gridDim.y,gridDim.z);
}
int main(int argc,char **argv)
{
int nElem=6;
dim3 block(3);
dim3 grid((nElem+block.x-1)/block.x);
printf("grid.x %d grid.y %d grid.z %d\n",grid.x,grid.y,grid.z);
printf("block.x %d block.y %d block.z %d\n",block.x,block.y,block.z);
checkIndex<<<grid,block>>>();
cudaDeviceReset();
return 0;
}

输出:

1
2
3
4
5
6
7
8
grid.x 2 grid.y 1 grid.z 1
block.x 3 block.y 1 block.z 1
threadIdx:(0,0,0) blockIdx:(0,0,0) blockDim:(3,1,1) gridDim(2,1,1)
threadIdx:(1,0,0) blockIdx:(0,0,0) blockDim:(3,1,1) gridDim(2,1,1)
threadIdx:(2,0,0) blockIdx:(0,0,0) blockDim:(3,1,1) gridDim(2,1,1)
threadIdx:(0,0,0) blockIdx:(1,0,0) blockDim:(3,1,1) gridDim(2,1,1)
threadIdx:(1,0,0) blockIdx:(1,0,0) blockDim:(3,1,1) gridDim(2,1,1)
threadIdx:(2,0,0) blockIdx:(1,0,0) blockDim:(3,1,1) gridDim(2,1,1)

核函数概述

核函数就是在CUDA模型上诸多线程中运行的那段串行代码,这段代码在设备上运行,用NVCC编译,产生的机器码是GPU的机器码,所以我们写CUDA程序就是写核函数。

启动核函数,通过的以下的ANSI C 扩展出的CUDA C指令:

1
kernel_name<<<grid,block>>>(argument list);

其标准C的原型就是C语言函数调用

1
function_name(argument list);

这个三个尖括号<<<grid,block>>>内是对设备代码执行的线程结构的配置(或者简称为对内核进行配置),也就是我们上一篇中提到的线程结构中的网格,块。回忆一下上文,我们通过CUDA C内置的数据类型dim3类型的变量来配置grid和block。通过指定grid和block的维度,我们可以配置:

  • 内核中线程的数目
  • 内核中使用的线程布局

我们可以使用dim3类型的grid维度和block维度配置内核,也可以使用int类型的变量,或者常量直接初始化:

1
kernel_name<<<4,8>>>(argument list);

我们的核函数是同时复制到多个线程执行的,上文我们说过一个对应问题,多个计算执行在一个数据,肯定是浪费时间,所以为了让多线程按照我们的意愿对应到不同的数据,就要给线程一个唯一的标识,由于设备内存是线性的(基本市面上的内存硬件都是线性形式存储数据的)我们观察上图,可以用threadIdx.x 和blockIdx.x 来组合获得对应的线程的唯一标识

接下来我们就是修改代码的时间了,改变核函数的配置,产生运行出结果一样,但效率不同的代码:

一个块:

1
kernel_name<<<1,32>>>(argument list);

32个块

1
kernel_name<<<32,1>>>(argument list);

上述代码如果没有特殊结构在核函数中,执行结果应该一致,但是有些效率会一直比较低。

上面这些是启动部分,当主机启动了核函数,控制权马上回到主机,而不是主机等待设备完成核函数的运行,这一点我们上一篇文章也有提到过(就是等待hello world输出的那段代码后面要加一句)

想要主机等待设备端执行可以用下面这个指令:

1
cudaError_t cudaDeviceSynchronize(void);

这是一个显示的方法,对应的也有隐式方法,隐式方法就是不明确说明主机要等待设备端,而是设备端不执行完,主机没办法进行,比如内存拷贝函数:

1
2
cudaError_t cudaMemcpy(void* dst,const void * src,
size_t count,cudaMemcpyKind kind);

这个函数上文已经介绍过了,当核函数启动后的下一条指令就是从设备复制数据回主机端,那么主机端必须要等待设备端计算完成。

所有CUDA核函数的启动都是异步的,这点与C语言是完全不同的

编写核函数

我们会启动核函数了,但是核函数哪里来的?当然我们写的,核函数也是一个函数,但是声明核函数有一个比较模板化的方法:

1
__global__ void kernel_name(argument list);

注意:声明和定义是不同的,这点CUDA与C语言是一致的

在C语言函数前没有的限定符global,CUDA C中还有一些其他我们在C中没有的限定符,如下:

限定符 执行 调用 备注
global 设备端执行 可以从主机调用也可以从计算能力3以上的设备调用 必须有一个void的返回类型
device 设备端执行 设备端调用
host 主机端执行 主机调用 可以省略

而且这里有个特殊的情况就是有些函数可以同时定义为 device 和 host ,这种函数可以同时被设备和主机端的代码调用,主机端代码调用函数很正常,设备端调用函数与C语言一致,但是要声明成设备端代码,告诉nvcc编译成设备机器码,同时声明主机端设备端函数,那么就要告诉编译器,生成两份不同设备的机器码。

Kernel核函数编写有以下限制

  • 只能访问设备内存
  • 必须有void返回类型
  • 不支持可变数量的参数
  • 不支持静态变量
  • 显示异步行为

并行程序中经常的一种现象:把串行代码并行化时对串行代码块for的操作,也就是把for并行化。例如:

1
2
3
4
__global__ void sumArraysOnGPU(float *A, float *B, float *C) {
int i = threadIdx.x;
C[i] = A[i] + B[i];
}

这两个简单的段不能执行,但是我们可以大致的看一下for展开并行化的样子。

验证核函数

验证核函数就是验证其正确性,下面这段代码上文出现过,但是同样包含验证核函数的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
/*
* 3_sum_arrays
*/
#include <cuda_runtime.h>
#include <stdio.h>


void sumArrays(float * a,float * b,float * res,const int size)
{
for(int i=0;i<size;i+=4)
{
res[i]=a[i]+b[i];
res[i+1]=a[i+1]+b[i+1];
res[i+2]=a[i+2]+b[i+2];
res[i+3]=a[i+3]+b[i+3];
}
}
__global__ void sumArraysGPU(float*a,float*b,float*res)
{
int i=threadIdx.x;
res[i]=a[i]+b[i];
}
int main(int argc,char **argv)
{
int dev = 0;
cudaSetDevice(dev);

int nElem=32;
printf("Vector size:%d\n",nElem);
int nByte=sizeof(float)*nElem;
float *a_h=(float*)malloc(nByte);
float *b_h=(float*)malloc(nByte);
float *res_h=(float*)malloc(nByte);
float *res_from_gpu_h=(float*)malloc(nByte);
memset(res_h,0,nByte);
memset(res_from_gpu_h,0,nByte);

float *a_d,*b_d,*res_d;
CHECK(cudaMalloc((float**)&a_d,nByte));
CHECK(cudaMalloc((float**)&b_d,nByte));
CHECK(cudaMalloc((float**)&res_d,nByte));

initialData(a_h,nElem);
initialData(b_h,nElem);

CHECK(cudaMemcpy(a_d,a_h,nByte,cudaMemcpyHostToDevice));
CHECK(cudaMemcpy(b_d,b_h,nByte,cudaMemcpyHostToDevice));

dim3 block(nElem);
dim3 grid(nElem/block.x);
sumArraysGPU<<<grid,block>>>(a_d,b_d,res_d);
printf("Execution configuration<<<%d,%d>>>\n",block.x,grid.x);

CHECK(cudaMemcpy(res_from_gpu_h,res_d,nByte,cudaMemcpyDeviceToHost));
sumArrays(a_h,b_h,res_h,nElem);

checkResult(res_h,res_from_gpu_h,nElem);
cudaFree(a_d);
cudaFree(b_d);
cudaFree(res_d);

free(a_h);
free(b_h);
free(res_h);
free(res_from_gpu_h);

return 0;
}

CUDA小技巧,当我们进行调试的时候可以把核函数配置成单线程的:

1
kernel_name<<<1,1>>>(argument list)

当错误出现的时候,不一定是哪一条指令触发的,这一点非常头疼;这时候我们就需要对错误进行防御性处理了,例如我们代码库头文件里面的这个宏:

1
2
3
4
5
6
7
8
9
10
#define CHECK(call)\
{\
const cudaError_t error=call;\
if(error!=cudaSuccess)\
{\
printf("ERROR: %s:%d,",__FILE__,__LINE__);\
printf("code:%d,reason:%s\n",error,cudaGetErrorString(error));\
exit(1);\
}\
}

就是获得每个函数执行后的返回结果,然后对不成功的信息加以处理,CUDA C 的API每个调用都会返回一个错误代码,这个代码我们就可以好好利用了,当然在release版本中可以去除这部分,但是开发的时候一定要有的。

编程接口

CUDA的编程接口由一系列C语言的扩展和运行库(runtime library)组成。

C语言的扩展在第二章“编程模型”中有所提及,如内核函数、线程网格和线程块等;
运行库则是在CUDA Driver API的基础上建立的。用户可以直接在应用程序中跳过CUDA,直接调用CUDA Driver API,以便更底层地操作GPU,如操作GPU的上下文。不过对于大多数应用来说,使用CUDA提供的运行库就足够了。

本章讲首先讲解CUDA程序的编译过程,之后会介绍CUDA运行库,最后会介绍程序兼容性等问题。

使用NVCC编译CUDA程序

CUDA程序使用NVCC编译器。
NVCC提供了简单方便的接口,能够很好的同时处理主机端和设备端代码。这里将简要介绍NVCC编译CUDA程序的流程,更多信息请参考nvcc user manual。

编译流程

离线编译

NVCC进行离线编译的操作流程是:

  • 分离CUDA程序中的主机端代码(host code)和设备端代码(device code)
  • 将设备端代码编译成一种虚拟汇编文件(名为PTX),再接着编译成二进制代码(名为cubin)
  • 将主机端代码中含有”<<<>>>”的代码(即内核调用)替换为CUDA运行库中的函数调用代码
  • NVCC会借助其他编译器(如gcc)将主机端代码编译出来
  • 主机端代码和设备端代码被编译好后,nvcc会将两段代码链接起来

在线编译(JIT Compilation)

PTX是一个虚拟汇编文件。其形式虽然很像汇编,但里面的每一条指令实际上是一个虚拟的指令,与机器码无法对应。需要编译器或设备驱动程序将其翻译成对应平台的汇编/机器码才能运行。

如果在编译过程中,NVCC不将设备端代码编译为cubin文件,即二进制代码,而是停在PTX代码上。设备驱动(device driver)会负责在运行时,使用PTX代码生成二进制代码。这个过程被称作在线编译(JIT Compilation, Just-In-Time Compilation)。

在线编译必然会使得程序启动的时间延长,不过设备驱动程序会自动缓存编译出来的二进制代码(也被称作compute cache)。

在线编译一方面的优势在于兼容性。另一方面的优势在于,当设备驱动程序有关编译的部分得到优化时,同样的PTX编出来的cubin文件同样会得到优化。也就是说,一段祖传的PTX代码,很有可能因为驱动程序不断的优化,而躺着得到了优化。而如果直接离线编译得到了cubin文件的话,则无法享受到这一优化。

二进制代码的兼容性

二进制代码cubin是受到GPU计算能力的限制的。在编译时,需要使用-code来指定将代码编译到哪个计算能力平台上,如-code=sm_35代表生成的cubin代码是运行在计算能力为3.5的平台上的。

二进制代码若要兼容,首先架构得一致。不同架构上的二进制代码不能互相兼容,如在Maxwell架构上编译出来的代码,不能在其他架构上运行。
其次,若执行平台的次版本号版本比编译时指定的的次版本号高,则可以运行。例如如果在编译时指定-code=sm_35,则在计算能力3.7的平台上也可以运行。反之则不可以。

另外需要说明的是,上述二进制代码的兼容性原则只限于桌面款显卡。

PTX代码的兼容性

PTX代码的兼容性远强于二进制代码。只要不涉及到不同架构上的特性差异,PTX可以在任何架构上运行。

不过PTX代码在两种情况下其兼容性会受限:

  1. 若PTX代码使用了较高级别架构的特有特性,则无法在较低架构上运行。例如若PTX代码用到了计算能力3.0以上才能使用的Warp Shuffle特性,则无法在2.x或1.x平台上运行。
  2. 若PTX在较低架构上生成,则虽然能够在所有更高级别的架构上运行,但无法充分利用这些架构的硬件特性,造成性能无法最大化的问题。

在编译时,可以通过-arch来指定生成的PTX代码的版本,如-arch=compute_30

应用程序兼容性

为了保证应用程序的兼容性,最好是将代码编译成PTX代码,然后依靠各个计算能力的驱动程序在线编译成对应平台的二进制代码cubin。

除了使用-arch-code来分别指定C->PTX和PTX->cubin的计算能力外,还可以用-gencode关键字来操作,如下例:

1
2
3
4
nvcc x.cu
-gencode arch=compute_35,code=sm_35
-gencode arch=compute_50,code=sm_50
-gencode arch=compute_60,code=\'compute_60,sm_60\'

使用上述编译指令后,会生成3.5/5.0/6.0的cubin文件,以及6.0的PTX代码。

对于主机端代码,会自动编译,并在运行时决定调用哪一个版本的执行。对于上例,主机端代码会编译为:3.5/5.0/6.0的二进制文件,以及7.0的PTX文件。

另外,在程序中可以使用__CUDA_ARCH__宏来指定计算能力(只能用于修饰设备端代码)。计算能力3.5在程序中对应的__CUDA_ARCH__为350。

有一点需要注意的是,7.0以前,都是以线程束为单位在调度,线程束内指令永远是同步的,被成为锁步。而Volta架构(计算能力7.x)引入了Independent Thread Scheduling,破坏了线程束内的隐式同步。因此,如果老版本的代码里面有默认锁步的代码,在Volta架构下运行时可能会因为锁步的消失而出问题,可以指定-arch=compute_60 -code=sm_70,即将PTX编到Pascal架构下以禁用Independent Thread Scheduling特性。(当然,也可以修改代码来显示同步)

另外,版本相关编译指令有缩写的情况,具体看手册。

C/C++兼容性

对于主机端代码,nvcc支持C++的全部特性;而对于设备端代码,只支持C++的部分特性。具体查阅手册。

32/64位兼容性

当且仅当主机端代码按照64位编译时,设备端代码才能编译为64位。当主机端代码编译为32位时,设备端代码只能编译成32位。即设备端代码的位数和主机端永远保持一致。

具体编译成32/64位的哪一种,取决于nvcc本身的版本。32位nvcc会自动编出32位的代码,不过可以使用-m64来编出64位代码。对于64位编译器亦然。

CUDA C 运行库

运行库实际上在cudart库内,可以使静态链接库cudart.lib/libcudart.a,或者动态链接库cudart.dll/cudart.so

所有程序的入口都是cuda

初始化

CUDA运行库没有显式的初始化函数,在调用第一个函数时会自动初始化(设备和版本管理函数不行)。初始化时,会产生一个全局可见的设备上下文(device context)。

当主机端代码调用了cudaDeviceReset()函数,则会销毁掉这个上下文。注意,销毁的上下文是主机端正在操纵的设备。如要更换,需要使用cudaSetDevice()来进行切换。

设备内存

CUDA运行库提供了函数以分配/释放设备端的内存,以及与主机端内存传输数据。

这里的设备内存,指的是全局内存+常量内存+纹理内存。

设备内存有两种分配模式:线性存储(linear memory)、CUDA arrays。 其中CUDA arrays与纹理内存有关,本导读略去不谈。

线性内存是我们常用的内存方式,在GPU上用40位的地址线寻址。线性内存可以用cudaMalloc()分配,用cudaFree()释放,用cudaMemcpy()复制数据,用cudaMemset()赋值。

对于2D或3D数组,可以使用cudaMallocPitch()cudaMalloc3D()来分配内存。这两个函数会自动padding,以满足内存对齐的要求,提高内存读写效率。内存对齐的问题,会在第五章里详细阐述。

另外,如果要在设备内存中定义全局变量,则需要使用使用__constant____device__来修饰,并使用cudaMemcpyToSymbol()cudaMemcpyFromSymbol()来读写。如下例:

1
2
3
4
5
6
7
8
9
10
11
12
13
__constant__ float constData[256];
float data[256];
cudaMemcpyToSymbol(constData, data, sizeof(data));
cudaMemcpyFromSymbol(data, constData, sizeof(data));

__device__ float devData;
float value = 3.14f;
cudaMemcpyToSymbol(devData, &value, sizeof(float));

__device__ float* devPointer;
float* ptr;
cudaMalloc(&ptr, 256 * sizeof(float));
cudaMemcpyToSymbol(devPointer, &ptr, sizeof(ptr));

实际上,当使用__constant__关键字时,是申请了一块常量内存;而使用__device__时,是普通的全局内存。因此__device__申请的内存需要申请,而__constant__不用。不管是全局内存,还是常量内存,需要用带有Symbol的函数拷贝。

共享内存

不管是全局变量还是局部变量,都需要使用__shared__来修饰。不过需要注意的是,即使定义为全局变量,共享内存依旧只能被同一线程块内的线程可见。

举个例子,对于如下代码,虽然是定义了一个全局的共享内存hist_shared,但实际上,在每一个线程块被调度到SM上时,都会在SM的共享内存区开一块内存。因此,每一个线程块都有一个hist_shared,且之间无法互相访问。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
__shared__ unsigned int hist_shared[256];   //共享内存仅在线程块内共享

__global__ void getGrayHistincuda_usesharemem(unsigned char * const grayData,
unsigned int * const hist,
uint imgheight,
uint imgwidth) //使用共享内存加速
{
const unsigned int idx = blockDim.x * blockIdx.x + threadIdx.x;
const unsigned int idy = blockDim.y * blockIdx.y + threadIdx.y;
const unsigned char inner_idx = threadIdx.y * blockDim.x + threadIdx.x;

hist_shared[inner_idx%256] = 0; //清空数据,由于每个块的inner_idx可以超过256,所以这样可以保证hist_shared被全部清零

__syncthreads(); //等待其他线程完成

if(idx < imgwidth && idy < imgheight)
{
const unsigned long pid = imgwidth * idy + idx;
const unsigned char value = grayData[pid];
atomicAdd(&(hist_shared[value]), 1);
}

__syncthreads();

if(threadIdx.y < 8) //每个线程块将自己共享内存中的值合并到全局内存中去
{
atomicAdd(&(hist[inner_idx]), hist_shared[inner_idx]);
}

}

当然,共享内存的声明放在内核函数里面也是可以的,效果一致。

使用共享内存,可以获得等同于L1 cache的访存速度,其速度远快于全局内存。

但是注意,并不是什么时候都可以使用共享内存来获取加速的。例如内核函数计算出来结果后,如果这个结果只需要传输回主机端,而不需要再次被用到时,直接写回全局内存会比较快。如果先写回共享内存,再写回全局内存,反而会比较缓慢。
一般来讲,当需要频繁读写,或是有原子操作时,使用共享内存替代全局内存,会取得比较大的增益。

强调一下,共享内存只能为线程块内的线程共享。如果需要整个线程网格中线程都能访问,则需要全局内存或常量内存。

另外,共享内存是一个稀缺资源。有些架构可以通过配置,分配L1 cache和共享内存的比例。

锁页内存(Page-Locked Host Memory/Pinned Memory)

锁页内存指的是主机端上不会被换出到虚拟内存(位于硬盘)上的内存。

锁页内存的分配与释放:在CUDA程序中,使用cudaHostAlloc(),可以分配锁页内存,使用cudaFreeHost()来释放锁页内存,或者使用cudaHostRegister()来将malloc()分配的内存指定为锁页内存

NVIDIA官方给出的锁页内存相对于普通的内存的的好处是:

  • 使用锁页内存后,锁页内存与设备内存之间的数据传输,可以使用流的方式,和内核函数执行并行。
  • 使用锁页内存后,可以将锁页内存映射到设备内存上。
  • 对于使用前端总线的系统,使用锁页内存可以提升主机端到设备端传输的带宽;如果将锁页内存指定为合并写(write_combining),则可以进一步提高带宽。

另一本书对于锁页内存之所以快的解释是:

  • 如果主机端将数据放在锁页内存,则可以使用PCI-E的DMA与设备内存进行数据传输,而不需要CPU来搬运数据。 这也是为何使用了锁页内存后,可以使用流和内存映射,来让CPU程序、数据传输和内核执行并行。
  • 如果主机端将数据放在普通内存,则CUDA会先申请一块锁页内存,然后将数据拷贝到锁页内存,再做后面的操作。 拷贝的过程浪费了一定时间。

注意,锁页内存在 non I/O coherent Tegra 设备上不支持

Portable Memory

NVIDIA官方文档表示:上述所说的锁页内存的优点,只有在使用cudaHostAlloc()时,传入cudaHostAllocPortable flag,或者在使用cudaHostRegister()时传入cudaHostRegisterPortable flag,才能体现。否则锁页内存并不会有上述优点。

《GPU编程指南》一书中是这么描述的:如果传入了cudaHostAllocPortable flag,则锁页内存在所有的CUDA上下文中变成锁页的和可见的。如果需要在CUDA上下文之间或者主机处理器的线程之间传递指针,则必须使用这个标志。

合并写内存(Write-Combining Memory)

锁页内存默认是使用缓存的。如果将flag cudaHostAllocWriteCombined 传入到 cudaHostAlloc(),则可以将这块锁页内存指定为合并写内存。

合并写内存不再使用主机端的L1&L2 cache,使得更多的cache可以供其他任务使用。

另外,对于通过PCI-E传输数据的情景,使用合并写内存不会被snooped (是不是指的是不会被缓存管?不理解这个snooped什么意思),可以提升40%的传输性能。

此外需要注意的是,由于合并写内存不使用缓存,因此读入CPU核的操作会非常的慢。因此合并写内存最好只用作向GPU传数据的内存,而不是传回数据的内存。

内存映射(Mapped Memory)

CUDA中的内存映射,指的是将CPU端的锁页内存,映射到GPU端。

通过向cudaHostAlloc()传入cudaHostAllocMapped flag,或向cudaHostRegister()传入cudaHostAllocMapped flag,来将一块内存指定为向GPU映射的内存。

映射的内存有两个地址,一个是CPU端访问的地址,一个是GPU端访问的地址。CPU端的地址在调用malloc()cudaHostAlloc()时就已经返回; GPU端的地址使用cudaHostGetDevicePointer()函数来获取。

使用内存映射有以下好处:

  • 使用内存映射,可以让CPU/GPU之间的数据传输隐式执行,而不需要显示的分配GPU内存并传输数据。
  • 当设备端执行内核函数需要某一块数据时,如果数据实际上在CPU端,会给出一个PCI-E传输请求(比全局内存还慢),从主机端内存获取数据。此时给出数据请求的线程会被换出,直到数据就位后再被换入。因此如果使用内存映射,需要使用足够多的线程来隐藏PCI-E的传输延迟。
  • 内存映射可以替代流,实现数据传输和内核执行的并行 有一点不是很确定:内存映射是否会在GPU端缓存数据;据我的记忆是不会缓存的,因此多次请求同一块数据的话,会启动多个PCI-E传输,效率很低

使用内存映射必须要注意的几点:

  • 由于映射的内存会被CPU和GPU两方共享,因此程序需要注意数据同步问题
  • 如果要使用内存映射,必须在其他CUDA函数执行前,执行cudaSetDeviceFlags()并传入cudaDeviceMapHost,来使能设备的内存映射功能。否则cudaHostGetDevicePointer()函数会返回error。
  • 如果设备本身不支持内存映射,则使用cudaHostGetDevicePointer()一定会返回error。可以通过查看设备的canMapHostMemory信息来确认。
  • 如果使用原子操作(atomicXXX),需要注意,主机端和设备端的同时操作是不原子的。

异步并行执行

CUDA允许以下操作互相并行:

  • 主机端计算
  • 设备端计算(内核执行)
  • 主机端to设备端传数据
  • 设备端to主机端传数据
  • 设备端内部传数据
  • 设备间传数据(可通过PCI-E直接传输,不需要先传到主机端再转发,不过这一操作跟使用的操作系统有关)

主机端/设备端并行

设备端的如下操作,可以跟主机端并行:

  • 内核启动与执行(可以通过将CUDA_LAUNCH_BLOCKING设为1,来disable内核执行并行,debug使用)
  • 设备端内部传输数据 64KB及以下的 host-to-device数据传输
  • 使用流(带有Async前缀的内存传输函数)或内存映射传输数据(不再受64KB的限制)
  • 设备端memset函数(cudaMemset())

其中第3、4条说明,在使用cudaMemcpy()时,如果数据小于等于64KB,其实传输相对于CPU是异步的。 如果数据多于64KB,则CPU会阻塞到数据传输完成。 这时使用带Async的内存传输函数,会释放CPU资源。使用Async传输函数,不仅可以和CPU并行,而且可以和内核执行并行。

需要注意的是,如果没有使用锁页内存,即使使用了Async函数,内存传输也不是并行的(和CPU?还是GPU?)。

内核并行执行

计算能力2.x及以上的设备,支持多个内核函数同时执行。(可以通过检查concurrentKernels来确定)

执行多个内核函数,需要主机端不同的线程启动。如果一个线程依次启动多个内核,则这些内核会串行执行。同一线程的内核函数返回时会触发隐式的同步。

另外,多个内核函数必须位于同一个CUDA上下文(CUDA context)上。不同CUDA上下文上的内核不能并行。这意味着,启动多个内核的多个线程必须使用相同的CUDA上下文。(如何传递CUDA上下文?)

数据传输和内核执行并行(需要使用锁页内存)

一些设备支持数据传输(主机端/设备端、设备端/设备端)和内核执行并行,可通过检查asyncEngineCount来确认。

一些设备支持设备端内部数据传输和内核执行/数据传输并行,可通过检查concurrentKernels来确认。

这一特性需要使用锁页内存。

数据并行传输(需要使用锁页内存)

计算能力2.x及以上的设备,支持数据传入和传出并行。

必须使用锁页内存。

流(streams)

在CUDA中,流(streams)指的是在GPU上一连串执行的命令。

不同的线程,可以向同一个流填入任务。

同一个流内的任务会按顺序执行;同一设备上不同的流有可能并行,其执行顺序不会有保证。

流的创建和销毁

下述代码是一个流的创建和销毁的例子。该程序创建了两个流,分配了两个锁页内存传输数据,依次启动了两个内核,最后销毁了这两个流。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
cudaStream_t stream[2];
for (int i = 0; i < 2; ++i)
cudaStreamCreate(&stream[i]);
float* hostPtr;
cudaMallocHost(&hostPtr, 2 * size);

for (int i = 0; i < 2; ++i) {
cudaMemcpyAsync(inputDevPtr + i * size, hostPtr + i * size,
size, cudaMemcpyHostToDevice, stream[i]);
MyKernel <<<100, 512, 0, stream[i]>>>
(outputDevPtr + i * size, inputDevPtr + i * size, size);
cudaMemcpyAsync(hostPtr + i * size, outputDevPtr + i * size,
size, cudaMemcpyDeviceToHost, stream[i]);
}

for (int i = 0; i < 2; ++i)
cudaStreamDestroy(stream[i]);

从上例中可以看到,流的创建需要定义cudaStream_t结构,并调用cudaStreamCreate()来初始化。
流的销毁需要调用cudaStreamDestroy()来实现。

当向流中添加内核函数任务时,<<<...>>>不再是<<<blocksPerGrid, threadsPerBlock>>>,而是<<<blocksPerGrid, threadsPerBlock, dynamic_shared_memory, stream>>>
其中dynamic_shared_memory指的是动态共享内存的大小(回去翻书); stream就是cudaStream_t结构。

当设备还在执行流中的任务,而用户调用cudaStreamDestroy()函数时,函数会立刻执行(不会阻塞)。之后,当流中的任务完成后,与流相关的资源会自动释放。

另外需要注意的是,上例中主机端线程、数据拷贝和内核执行完全异步,因此在”拷贝回主机端”这一操作完成之前,主机端的内存数据是不正确的。必须在数据返回的一步做同步操作,方能保证数据是正确的。

默认流(Default Stream)

在调用内核函数时,不指定流或者将流指定为0,则代表使用了默认流(default stream)。

如果在编译时使用了--default-stream per-thread,或是在include任何cuda头文件前#define CUDA_API_PER_THREAD_DEFAULT_STREAM,则主机端的每一个线程都有自己专属的默认流。

而如果在编译时未指定相关flag,或指定--default-stream legacy,则默认流是一个特殊的流,称作NULL stream。主机端的所有线程会共享这个NULL stream。NULL stream是一个同步流,所有命令会产生隐式的同步。

显式同步(Explicit Synchronization)

可以使用如下函数进行显式同步:

  • cudaDeviceSynchronize():直到所有线程向设备端的所有流所有已送入指令完成,才会退出阻塞。
  • cudaStreamSynchronize():直到指定流之前所有已送入指令完成,才会退出阻塞。此函数可以用作同步指定流,而其他流可以不受干扰地继续运行。
  • cudaStreamWaitEvent():需要stream和event作为输入参数。在调用该函数之后的命令,需要等待该函数等待的事件(Event)发生后,才能执行。如果stream指定为0,则对于向所有stream加入的命令来说,只要加在了该函数之后,都会阻塞直到等待的时间发生方可执行。

注意,同步函数慎用,因为有可能会产生速度的下降。

隐式同步(Implicit Synchronization)

一般来讲,不同流内的命令可以并行。但是当任何一个流执行如下的命令时,情况例外,不能并行:
锁页内存的分配 设备端内存分配 设备端内存设置(memset) 设备内部拷贝 NULL stream内的命令 L1 cache/共享内存空间的重新分配

操作重叠(Overlapping Behavior)

操作的重叠程度,一方面取决于各个操作的顺序,另一方面取决于设备支持重叠的程度(是否支持内核执行并行/数据传输与内核执行并行/数据传输并行)

回调函数(Callbacks)

可以使用cudaStreamAddCallback()函数,向流中添加callback。该callback会在流中之前所有的任务完成后被调用。如果stream参数设为0,则代表之前的所有stream的任务执行完后就调用该callback。

回调函数和cudaStreamWaitEvent()一样,对于在加在callback之后的指令,必须等待callback执行完成后,才会继续执行。

下例是一个使用回调的例子。该例中,两个stream将数据拷回主机端后,会调用回调函数。

1
2
3
4
5
6
7
8
9
10
void CUDART_CB MyCallback(cudaStream_t stream, cudaError_t status, void *data){
printf("Inside callback %d\n", (size_t)data);
}
...
for (size_t i = 0; i < 2; ++i) {
cudaMemcpyAsync(devPtrIn[i], hostPtr[i], size, cudaMemcpyHostToDevice, stream[i]);
MyKernel<<<100, 512, 0, stream[i]>>>(devPtrOut[i], devPtrIn[i], size);
cudaMemcpyAsync(hostPtr[i], devPtrOut[i], size, cudaMemcpyDeviceToHost, stream[i]);
cudaStreamAddCallback(stream[i], MyCallback, (void*)i, 0);
}

回调函数中不能直接或间接的执行CUDA函数,否则会因为等待自己完成而造成死锁。 (原因尚不太明白)

流的优先级(Stream Priorities)

可以通过cudaStreamCreateWithPriority()来在创建流时指定流的优先级。可以指定的优先级可由cudaDeviceGetStreamPriorityRange()来获得。

运行时,高优先级stream中的线程块不能打断正在执行的低优先级stream的线程块(即不是抢占式的)。但是当低优先级stream的线程块退出SM时,高优先级stream中的线程块会被优先调度进SM。

事件(Event)

事件(Event)可以被压入流中以监视流的运行情况,或者用于精确计时。

如果向stream 0压入事件,则当压入事件前向所有流压入的任务完成后,事件才被触发。

事件的创建和销毁
1
2
3
4
5
6
cudaEvent_t start, stop;    //创建
cudaEventCreate(&start);
cudaEventCreate(&stop);
...
cudaEventDestroy(start); //销毁
cudaEventDestroy(stop);
计算时间

下例是一个使用Event计算时间的例子:

1
2
3
4
5
6
7
8
9
10
cudaEventRecord(start, 0);  //记录事件(将事件压入流),流0则代表所有流完成任务后事件才会被触发
for (int i = 0; i < 2; ++i) {
cudaMemcpyAsync(inputDev + i * size, inputHost + i * size, size, cudaMemcpyHostToDevice, stream[i]);
MyKernel<<<100, 512, 0, stream[i]>>>(outputDev + i * size, inputDev + i * size, size);
cudaMemcpyAsync(outputHost + i * size, outputDev + i * size, size, cudaMemcpyDeviceToHost, stream[i]);
}
cudaEventRecord(stop, 0);
cudaEventSynchronize(stop);
float elapsedTime;
cudaEventElapsedTime(&elapsedTime, start, stop); //获取两个事件发生的时间差(ms)

3.2.6 多设备系统(Multi-Device System)

设备枚举(Device Enumeration)

下例是如何枚举设备,并获取设备信息的例子:

1
2
3
4
5
6
7
8
int deviceCount;
cudaGetDeviceCount(&deviceCount); //获取设备数量
int device;
for (device = 0; device < deviceCount; ++device) {
cudaDeviceProp deviceProp;
cudaGetDeviceProperties(&deviceProp, device);
printf("Device %d has compute capability %d.%d.\n", device, deviceProp.major, deviceProp.minor);
}

3.2.6.2 设备选择(Device Selection)

使用cudaSetDevice()选择设备,当不选择时,默认使用设备0。

注意,所有的内存分配、内核函数启动、流和事件的创建等,都是针对当前选择的设备的。

下例是一个设备选择的例子:

1
2
3
4
5
6
7
8
9
size_t size = 1024 * sizeof(float);
cudaSetDevice(0); // Set device 0 as current
float* p0;
cudaMalloc(&p0, size); // Allocate memory on device 0
MyKernel<<<1000, 128>>>(p0); // Launch kernel on device 0
cudaSetDevice(1); // Set device 1 as current
float* p1;
cudaMalloc(&p1, size); // Allocate memory on device 1
MyKernel<<<1000, 128>>>(p1); // Launch kernel on device 1

(多设备下)流和事件的执行情况

下面将讨论,如果对一个不属于当前设备的流或事件进行操作,哪些操作会成功,哪些操作会失败:

  • 内核启动(will fail):如果将内核压入不属于当前设备的流中,则内核会启动失败。也就是说,如果要向一个流中压入内核,必须先切换到流所在的设备:
1
2
3
4
5
6
7
8
9
10
11
cudaSetDevice(0);   // Set device 0 as current
cudaStream_t s0;
cudaStreamCreate(&s0); // Create stream s0 on device 0
MyKernel<<<100, 64, 0, s0>>>(); // Launch kernel on device 0 in s0
cudaSetDevice(1); // Set device 1 as current
cudaStream_t s1;
cudaStreamCreate(&s1); // Create stream s1 on device 1
MyKernel<<<100, 64, 0, s1>>>(); // Launch kernel on device 1 in s1

// This kernel launch will fail:
MyKernel<<<100, 64, 0, s0>>>(); // Launch kernel on device 1 in s0
  • 内存拷贝(will success):如果对一个不属于当前设备的流进行内存拷贝工作,内存拷贝会成功。
  • cudaEventRecord()(will fail):必须现将设备上下文切换过去,再向流压入事件。
  • cudaEventElapsedTime()(will fail):计算时间差前,必须先切换设备。
  • cudaEventSynchronize() and cudaEventQuery()(will success):即使处于不同的设备,事件同步和事件查询依然有效。
  • cudaStreamWaitEvent()(will success):比较特殊,即使函数输入的流和事件不在同一个设备上,也能成功执行。也就是说,可以让流等待另一个设备上(当然当前设备也可以)的事件。这个函数可以用作多个设备间的同步。

另外需要注意,每个设备都有自己的默认流。因此在没有指定流的情况下,向不同设备分派的任务,实际上是压入了各个设备的默认流,他们之间是并行执行的。

3.2.6.4 (设备间)对等内存访问(Peer-to-Peer Memory Access)

计算能力2.0及以上的设备支持设备间对等内存访问,这意味着两个GPU之间的传输和访问可以不经过主机端中转,速度会有提升。查询cudaDeviceCanAccessPeer()可以得知设备是否支持这一特性。(官方文档说还需要一个条件:64位程序,存疑)

需要使用cudaDeviceEnablePeerAccess()来使能这一特性。

对等设备的的地址是统一编址的,可用同一个指针访问,如下例:

1
2
3
4
5
6
7
8
9
10
11
cudaSetDevice(0);   // Set device 0 as current
float* p0;
size_t size = 1024 * sizeof(float);
cudaMalloc(&p0, size); // Allocate memory on device 0
MyKernel<<<1000, 128>>>(p0); // Launch kernel on device 0
cudaSetDevice(1); // Set device 1 as current
cudaDeviceEnablePeerAccess(0, 0); // Enable peer-to-peer access with device 0

// Launch kernel on device 1
// This kernel launch can access memory on device 0 at address p0
MyKernel<<<1000, 128>>>(p0);

(设备间)对等内存拷贝(Peer-to-Peer Memory Copy)

对等设备的地址是统一编址的,可以使用cudaMemcpyPeer()、cudaMemcpyPeerAsync()、cudaMemcpy3DPeer、cudaMemcpy3DPeerAsync()来进行直接拷贝。无需先拷贝会主机端内存,再转到另一块卡上。如下例:

1
2
3
4
5
6
7
8
9
10
11
12
cudaSetDevice(0);   // Set device 0 as current
float* p0;
size_t size = 1024 * sizeof(float);
cudaMalloc(&p0, size); // Allocate memory on device 0
cudaSetDevice(1);
float* p1;
cudaMalloc(&p1, size); // Allocate memory on device 1
cudaSetDevice(0); // Set Device 0 as Current
MyKernel<<<1000, 128>>>(p0); // Launch Kernel on Device 0
cudaSetDevice(1); // Set Device 1 as Current
cudaMemcpyPeer(p1, 1, p0, 0, size); // Copy p0 to p1
MyKernel<<<1000, 128>>>(p1); // Launch Kernel on Device 1

关于设备间的对等拷贝,如果使用的是NULL stream,则有如下性质:
如果拷贝的双方中的任何一方,在设备拷贝前有任务未完成,则拷贝会被阻塞,直至任务完成。 只有拷贝结束后,两者的后续任务才能继续执行。

(使用的如果不是NULL Stream,又会怎样呢?)

统一虚拟地址空间(Unified Virtual Address Space)

当程序是64位程序时,所有主机端内存,以及计算能力≥2.0的设备的内存是统一编址的。所有通过CUDA API分配的主机内存和设备内存,都在统一编址的范围内,有自己的虚拟地址。因此:

  • 可以通过cudaPointerGetAttributes(),来确定指针所指的内存处在主机端还是设备端。
  • 进行拷贝时,可以将cudaMemcpy***()中的cudaMemcpyKind参数设置为cudaMemcpyDefault,去让函数根据指针所处的位置自行判断应该是从哪里拷到哪里。
  • 使用cudaHostAlloc()分配的锁页内存,自动是Portable的,所有支持统一虚拟编址的设备均可访问。cudaHostAlloc()返回的指针,无需通过cudaHostGetDevicePointer(),就可以直接被设备端使用。

可以通过查询unifiedAddressing来查看设备是否支持统一虚拟编址。

进程间通讯(Interprocess Communication)

线程间通讯,可以很方便的通过共享的变量来实现。然而进程间通讯不行。

为了在进程间共享设备端内存的指针或者事件,必须使用IPC(Inter Process Communication) API。IPC API只支持64位程序,并且要求设备计算能力≥2.0。

通过IPC中的cudaIpcGetMemHandle(),可以得到设备内存指针的IPC句柄。该句柄可以通过标准的IPC机制(interprocess shared memory or files)传递到另一个进程,再使用cudaIpcOpenMemHandle()解码得到该进程可以使用的设备内存指针。
事件的共享也是如此。

错误检查(Error Checking)

所有的runtime function都会返回一个error code,可通过检查error code判断是否出错。

但是对于异步函数,由于在执行前就会返回,因此返回的error code仅仅代表函数启动时的错误(如参数校验);异步函数不会返回运行时出现的错误。如果运行时出了错,会被后面的某个函数捕获并返回。

检查异步函数是否出错的唯一方式,就是在异步函数启动后,进行同步。 如在异步函数后,调用cudaDeviceSynchronize(),则异步函数的错误会被cudaDeviceSynchronize()捕获到。

事实上,除了runtime function会返回error code之外,每一个主机端线程都会有一个初始化为cudaSuccess的变量,用于指示错误。一旦发生了错误,该变量也会被设置为相应的error code。

该变量不会被直接调用,但可以被cudaPeekAtLastError()cudaGetLastError()访问到。不同的是,cudaGetLastError()在返回这一变量的同时,会把它重置为cudaSuccess

内核函数不会返回值,因此只能通过cudaPeekAtLastError()cudaGetLastError()来知悉调用内核是否有错误。
当然,为了排除错误出现在调用内核之前就有错误,可以先检验之前的错误变量是否为cudaSuccess

另外需要注意的是,cudaStreamQuery()cudaEventQuery()这类函数,有可能会返回cudaErrorNotReady。但这不被认为是错误,因此不会被cudaPeekAtLastError()cudaGetLastError()捕获到。

计算模式(Compute Mode)

NVIDIA的设备可以设置三种计算模式:

  • 默认模式(Default Compute Mode):多个主机端线程可以同时使用一个设备(通过调用cudaSetDevice())
  • 专属进程模式(Exclusive-Process Compute Mode):对于一个设备,只能由一个进程创建设备上下文。一旦创建成功后,该进程的所有线程都可以使用该设备,而其他进程则不行。
  • 禁止模式(Prohibited Compute Mode):无法对设备建立CUDA上下文。

正常情况下,如果程序没有调用cudaSetDevice(),则会默认使用0号设备。但是如果0号设备被置成禁止模式,亦或是被其他进程所专属,则会在其他设备上创建上下文并使用。 可以向cudaSetValidDevices()函数输入一个设备列表,函数会在第一个可以使用的设备上创建上下文。

性能优化

性能优化概述

CUDA程序性能优化有三个原则:

  • 最大化并行,以提升资源利用率
  • 优化内存排布,以最大化内存吞吐
  • 最大化指令吞吐

在性能优化前,需要先分析程序性能的瓶颈,再针对瓶颈优化,否则收益会很低。分析程序瓶颈,可以使用CUDA profiler等工具。

最大化利用率(Maximize Utilization)

最大化利用率的方法就是并行。

应用级别并行(Application Level)

从程序最高层来看,应该尽可能让主机端、设备端、PCI-E总线并行工作。对此可以使用异步CUDA函数,以及流(Stream)来实现。

同步操作,以及内存的共享会影响程序的并行性。因此需要仔细设计算法流程,尽量减少同步和内存共享。 如果一定需要同步和内存共享,尽量在线程块内完成(线程块同步——使用__syncthreads()涉及到的线程少,且可以通过SM内的共享内存共享数据。如果需要线程网格内同步,则需要两个内核调用,且共享数据只能通过全局内存,速度慢)。

设备级别并行(Device Level)

可以通过流的方式,尽可能的让多个内核并行,提升利用率。

处理器级别并行(Multiprocessor Level)

延迟(latency)指的是线程束(从上一个动作开始)到它处于ready状态的时钟数。 例如线程束先提交了一个内存访问请求,然后等了400个时钟周期,内存管理系统才返回数据,线程束可以继续执行。这400个时钟周期称为延迟。

当一个线程束发生延迟时,线程束调度器(warp scheduler)会将其他处于ready状态的线程束调度到SP上。等到延迟结束后,再将该线程调度回SP继续执行。这样一来,前一个线程束的延迟,就被另一个线程束的执行所隐藏了。 这一过程被称作延迟的隐藏(hidden latency)。

隐藏延迟是GPU编程的核心概念。由于GPU具有巨大的寄存器空间,线程的切换不存在损耗。因此,通过向GPU上分配足够多的线程,可以让这些线程延迟互相交错,以起到隐藏延迟的作用,提高硬件利用率。

造成线程(束)产生延迟的原因有:

  • 指令执行:不同指令有不同的执行延迟
  • 内存请求:共享内存、全局内存、PCI-E(Mapped Memory)的读写请求
  • 同步操作:如使用__syncthreads()后,先完成的线程(束),会等待线程块中其他线程(束)达到同步点。

通过配置线程网格、线程块、寄存器和共享内存用量,让SM可以运行尽可能多的线程束,以隐藏延迟。例如对于计算能力3.x的设备,为了完全隐藏全局内存读取的延迟(200-400时钟),需要大概40个线程束。

举个例子,设SM有32KB共享內存空间。程序每个线程需要32B共享內存,即一个线程束需要1KB共享內存,考虑下述两种方案:

  • 方案1:每个线程块有16个线程束,则每个线程块需要16KB共享內存。可以调度两个线程块到SM上。
  • 方案2:每个线程块有18个线程束,则每个线程块需要18KB共享內存,则只能调度一个线程块到SM上。

虽然方案2在一个线程块上,有更多的线程束,但是实际上SM上运行的线程束减少了(32->18)。因此方案2隐藏延迟的能力弱于方案1,资源利用率较低。

此外,如果寄存器使用过多,超过了SM上的寄存器空间,则会使用本地内存作为寄存器。本地内存是存在在全局内存上的,速度很慢,会严重影响程序速度。因此需要严格考虑寄存器使用数量。

最后强调一点,线程块中的线程数量,最好是32的整数倍。这样,就不会有为了补齐线程束,而出现的永远不会激活的线程。这些不激活的线程也会占用SM的资源,降低资源利用率。

CUDA具有Occupancy Calculator,帮助程序员设计。

最大化内存吞吐(Maximize Memory Throughput)

最大化内存吞吐,主要手段就是少用低带宽的内存。这意味着首先要尽可能减少主机端和设备端间的设备传输(PCI-E,特别慢),其次要尽可能减少全局内存的读写(快于PCI-E,但是相对于片内内存来说,还是挺慢的);尽可能的使用片内的内存(寄存器、cache、共享内存)。

这里需要强调一下cache和共享内存的事情。

共享内存是程序可控的高速缓存。一般情况下,共享内存的使用流程为:

  • 将数据从全局内存拷贝到共享内存,或初始化共享内存*
  • 进行一个同步操作,确保共享内存全部被赋值
  • 利用共享内存的数据,运行程序*
  • 如果出现了共享内存的写操作,一般需要进行一个同步操作,确保写操作全部完成后再进行下面的操作
  • 将数据写回全局内存

这里有一点要强调,只有在数据需要反复读写的时候,共享内存才有意义。如果数据只会被读一次,处理完后又写回并不再处理。则直接从全局内存读出->寄存器运行->写回全局内存是最快的。在共享内存中转反而是慢的。

缓存(L1/L2 cache)是程序员无法显式编程的。但是如果了解缓存的特性的话,可以通过合适的程序设计,增加缓存命中率。

主机端和设备端间数据传输

由于PCI-E传输并不快,因此要尽量减少主机端和设备端间的数据传输: 一种方式是让中间结果尽可能的在设备端产生,在设备端使用。 另一种方式是将很多小的数据,打包传输。 还有可以通过分配锁页内存来加快前端总线系统的带宽。

当使用内存映射时,需要注意,每次内存访问都会启动一次PCI-E传输。因此,尽量保证数据只被读写一次,且尽可能合并访问以提升有效内存带宽。

有些GPU设备,主机端和设备端内存,在物理上就是同一块。这种情况下,主机端和设备端传输是不存在的。可通过标志integrated来查看。

设备内存访问

全局内存(global memory)

全局内存支持合并访问,可以一次性传输连续的32、 64、 128字节的数据。因此,在设计内核时,线程束内的线程尽量连续的访问内存。

考虑如下两个内核:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//假设gpuData是一个二维数组,尺寸为32x32
int gpuData[32][32]; //这样是不合法的,因为这么定义实际上是在主机端,还需要拷贝到设备端,这里只是为了方便说明问题

__global__ void Kernel1(int gpuData[][32])
{
const int tid = blockIdx.x * blockDim.x + threadIdx.x;
int sum = 0;
for(int i = 0; i < 32; i++)
sum += gpuData[i][tid]; //行访问
...
}

__global__ void Kernel2(int gpu[][32])
{
const int tid = blockIdx.x * blockDim.x + threadIdx.x;
int sum = 0;
for(int i = 0; i < 32; i++)
sum += gpuData[tid][i]; //列访问
...
}

上例中,执行Kernel1的线程束中的线程,在一次循环中,32个线程依次访问gpuData[0][0], gpuData[0][1], gpuData[0][2], …, gpuData[0][31]。在内存中,这32个变量是连续存储的,因此可以被合并访问。这种访问被称为行访问。

而Kernel2在一次循环中,读取的变量为gpuData[0][0], gpuData[1][0], gpuData[2][0], …, gpuData[31][0]。这32个变量是不连续的,需要进行32次内存请求。这种访问被称为列访问。

上例中,列访问之所以效率低,原因有二:

  • 对于执行一次循环,行访问只需要一个内存请求指令,而列访问需要32个内存请求指令。从指令角度来讲,行访问的内存请求指令带宽是列访问的1/32。
  • 全局内存的最大带宽为一次取128Byte,但是内核每次只需要4个Byte的数据。这使得列访问的内存带宽为峰值带宽的1/32。事实上,即使内核只需要4Byte,GPU也会取连续的32Byte,然后丢掉后面的28Byte,造成资源的浪费。但是缓存的引入(自计算能力2.x开始),这一问题得到了缓解,28Byte会先放到缓存中,下次会命中。

因此,从上例中可以看到,好好安排内存排布,尽量使得内存访问可以合并,可以加速全局内存的读写。

对齐(Alignment)

当变量的尺寸为1/2/4/8/16字节时,变量会对齐。但如果不是的话,变量无法对齐,会产生额外的内存访问。

C/C++内建的变量(int/float等),以及CUDA支持的向量(float2/float4等),是对齐的。

一些结构体可能会产生不对齐的情况,看下例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct struct1{
float x;
float y;
};

struct struct2{
float x;
float y;
float z;
};

struct struct3 __align__(16){
float x;
float y;
float z;
};

上例中,struct1是8字节的结构体,自动会对齐; struct2具有12个字节,无法对齐; struct3使用了__align__(16)关键字,显式指定对齐到16。

使用各类malloc分配的设备内存,一定是256字节对齐的。

本地内存(local memory)

通过看PTX代码,可以看到标记为.local的变量,就是本地内存。
即使PTX代码里没有使用本地内存,在编译到cubin代码的过程中,仍然会使用本地内存,编译器会报告lmem的使用情况。

前面多次强调过了,一旦使用了本地内存,其速度会非常慢。不过本地内存在存储的时候,是按照32个线程连续存储的,因此可以合并访问。
对于计算能力3.x的设备,本地内存会被缓存在L1/L2 cahce;对于计算能力5.x和6.x设备,本地内存会被缓存到L2 cache。即便如此,其速度还是慢于寄存器。

共享内存(shared memory)

共享内存实际上是被分为多个存储体(memory bank)。多个线程访问同一个存储体会造成串行化。
(存疑:存储体其实是可以广播的,因此多个线程读同一个存储体是不存在冲突的,只是写会存在串行化问题)

因此,编写内核时,需要认真设计,以避免存储体访问的冲突。

最大化指令吞吐(Maximize Instruction Throughput)

可以使用如下方法来最大化指令吞吐:

  • 尽量少使用吞吐率低的算数指令
  • 尽量减少线程束内的分支
  • 尽量减少指令数,如少用__syncthreads(),或者在合适的时候使用__restrict__

指令吞吐的定义:每个SP在每个时钟周期内执行的操作数。如果一个线程束在一个时钟周期内执行了N个操作,则指令吞吐为N/32。

算数指令(Arithmetic Instructions)

官方文档这里比较混乱,但主要有如下几点: 不同架构的设备,不同指令有不同的指令吞吐,可以查表 有一些快速的内联(inline)函数,如使用__fdividef()(快速浮点数除法)来代替普通的除法来加速 整形的除法和取余会比较慢,可能需要20个机器周期;因此对于n为2的幂次的情况,使用i>>log2(n)代替i/n,使用i&(n-1)来代替i%n 半精度(浮点数)运算(Half Precision Arithmetic):可以使用half2数据类型,并使用对应的运算指令(如__hadd2, __hsub2, __hmul2, __hfma2等),来让一个周期内执行两次运算,以节省指令带宽。可以通过__halves2half2将两个半精度浮点数合并为half2数据类型。 (半精度又是咋定义的?) * 数据类型转换:当使用char或short,亦或是双精度常量与单精度变量相互操作时,会触发数据类型转换,需要一定执行时间(实际上,char和short,不管是存储在寄存器中,还是在运算时,都是以int型进行的)

控制流指令(Control Flow Instructions)

尽量避免向线程束中引入分支。

此外,可以使用#pragma unroll宏,来进行循环展开,减少控制指令。

同步指令(Synchronization Instruction)

下表为不同计算能力的设备,同步指令__syncthreads()需要消耗的指令周期为:

计算能力 __syncthreads()消耗的指令周期
3.x 128
5.x,6.1,6.2 64
6.0 32
7.x 16

注意,__syncthreads()会造成线程块中的线程等待,影响内核执行效率。

给核函数计时

gettimeofday是linux下的一个库函数,创建一个cpu计时器,从1970年1月1日0点以来到现在的秒数,需要头文件sys/time.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <cuda_runtime.h>
#include <stdio.h>
#include "freshman.h"

__global__ void sumArraysGPU(float*a,float*b,float*res,int N)
{
int i=blockIdx.x*blockDim.x+threadIdx.x;
if(i < N)
res[i]=a[i]+b[i];
}

int main(int argc,char **argv)
{
// set up device.....

// init data ......

//timer
double iStart,iElaps;
iStart=cpuSecond();
sumArraysGPU<<<grid,block>>>(a_d,b_d,res_d,nElem);
cudaDeviceSynchronize();
iElaps=cpuSecond()-iStart;

// ......
}

主要分析计时这段,首先iStart是cpuSecond返回一个秒数,接着执行核函数,核函数开始执行后马上返回主机线程,所以我们必须要加一个同步函数等待核函数执行完毕,如果不加这个同步函数,那么测试的时间是从调用核函数,到核函数返回给主机线程的时间段,而不是核函数的执行时间,加上了

1
cudaDeviceSynchronize();

函数后,计时是从调用核函数开始,到核函数执行完并返回给主机的时间段,下面图大致描述了执行过程的不同时间节点:

我们可以大概分析下核函数启动到结束的过程:

  • 主机线程启动核函数
  • 核函数启动成功
  • 控制返回主机线程
  • 核函数执行完成
  • 主机同步函数侦测到核函数执行完

我们要测试的是24的时间,但是用CPU计时方法,只能测试15的时间,所以测试得到的时间偏长。

用nvprof计时

CUDA 5.0后有一个工具叫做nvprof的命令行分析工具,后面还要介绍一个图形化的工具,现在我们来学习一下nvprof,学习工具主要技巧是学习工具的功能,当你掌握了一个工具的全部功能,那就是学习成功了。
nvprof的用法如下:

1
$ nvprof [nvprof_args] <application>[application_args]

工具不仅给出了kernel执行的时间,比例,还有其他cuda函数的执行时间,可以看出核函数执行时间只有4%左右,其他内存分配,内存拷贝占了大部分事件。

组织并行线程

使用块和线程建立矩阵索引

多线程的优点就是每个线程处理不同的数据计算,那么怎么分配好每个线程处理不同的数据,而不至于多个不同的线程处理同一个数据。下图可以非常形象的反应线程模型:

这里(ix,iy)就是整个线程模型中任意一个线程的索引,或者叫做全局地址,局部地址当然就是(threadIdx.x,threadIdx.y)了,当然这个局部地址目前还没有什么用处,他只能索引线程块内的线程,不同线程块中有相同的局部索引值,比如同一个小区,A栋有16楼,B栋也有16楼,A栋和B栋就是blockIdx,而16就是threadIdx啦。图中的横坐标就是:ix=threadIdx.x+blockIdx.x×blockDim.x,纵坐标是:iy=threadIdx.y+blockIdx.y×blockDim.y

这样我们就得到了每个线程的唯一标号,并且在运行时kernel是可以访问这个标号的。前面讲过CUDA每一个线程执行相同的代码,也就是异构计算中说的多线程单指令,如果每个不同的线程执行同样的代码,又处理同一组数据,将会得到多个相同的结果,显然这是没意义的,为了让不同线程处理不同的数据,CUDA常用的做法是让不同的线程对应不同的数据,也就是用线程的全局标号对应不同组的数据。

设备内存或者主机内存都是线性存在的,我们要做管理的就是:

  • 线程和块索引(来计算线程的全局索引)
  • 矩阵中给定点的坐标(ix,iy)
  • (ix,iy)对应的线性内存的位置

线性位置的计算方法是:idx=ix+iy∗nx

我们上面已经计算出了线程的全局坐标,用线程的全局坐标对应矩阵的坐标,也就是说,线程的坐标(ix,iy)对应矩阵中(ix,iy)的元素,这样就形成了一一对应,不同的线程处理矩阵中不同的数据,举个具体的例子,ix=10,iy=10的线程去处理矩阵中(10,10)的数据,当然你也可以设计别的对应模式,但是这种方法是最简单出错可能最低的。我们接下来的代码来输出每个线程的标号信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
__global__ void printThreadIndex(float *A,const int nx,const int ny)
{
int ix=threadIdx.x+blockIdx.x*blockDim.x;
int iy=threadIdx.y+blockIdx.y*blockDim.y;
unsigned int idx=iy*nx+ix;
printf("thread_id(%d,%d) block_id(%d,%d) coordinate(%d,%d)"
"global index %2d ival %2d\n",threadIdx.x,threadIdx.y,
blockIdx.x,blockIdx.y,ix,iy,idx,A[idx]);
}
int main(int argc,char** argv)
{
initDevice(0);
int nx=8,ny=6;
int nxy=nx*ny;
int nBytes=nxy*sizeof(float);

//Malloc
float* A_host=(float*)malloc(nBytes);
initialData(A_host,nxy);
printMatrix(A_host,nx,ny);

//cudaMalloc
float *A_dev=NULL;
CHECK(cudaMalloc((void**)&A_dev,nBytes));

cudaMemcpy(A_dev,A_host,nBytes,cudaMemcpyHostToDevice);

dim3 block(4,2);
dim3 grid((nx-1)/block.x+1,(ny-1)/block.y+1);

printThreadIndex<<<grid,block>>>(A_dev,nx,ny);

CHECK(cudaDeviceSynchronize());
cudaFree(A_dev);
free(A_host);

cudaDeviceReset();
return 0;
}

二维矩阵加法

我们利用上面的线程与数据的对应完成了下面的核函数:

1
2
3
4
5
6
7
8
9
10
__global__ void sumMatrix(float * MatA,float * MatB,float * MatC,int nx,int ny)
{
int ix=threadIdx.x+blockDim.x*blockIdx.x;
int iy=threadIdx.y+blockDim.y*blockIdx.y;
int idx=ix+iy*ny;
if (ix<nx && iy<ny)
{
MatC[idx]=MatA[idx]+MatB[idx];
}
}

二维网格和二维块

首先来看二维网格二维模块的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 2d block and 2d grid
dim3 block_0(dimx,dimy);
dim3 grid_0((nx-1)/block_0.x+1,(ny-1)/block_0.y+1);

iStart=cpuSecond();

sumMatrix<<<grid_0,block_0>>>(A_dev,B_dev,C_dev,nx,ny);
CHECK(cudaDeviceSynchronize());

iElaps=cpuSecond()-iStart;

printf("GPU Execution configuration<<<(%d,%d),(%d,%d)>>> Time elapsed %f sec\n",
grid_0.x,grid_0.y,block_0.x,block_0.y,iElaps);

CHECK(cudaMemcpy(C_from_gpu,C_dev,nBytes,cudaMemcpyDeviceToHost));
checkResult(C_host,C_from_gpu,nxy);

一维网格和一维块

接着我们使用一维网格一维块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 1d block and 1d grid
dimx=32;
dim3 block_1(dimx);
dim3 grid_1((nxy-1)/block_1.x+1);

iStart=cpuSecond();
sumMatrix<<<grid_1,block_1>>>(A_dev,B_dev,C_dev,nx*ny ,1);
CHECK(cudaDeviceSynchronize());
iElaps=cpuSecond()-iStart;

printf("GPU Execution configuration<<<(%d,%d),(%d,%d)>>> Time elapsed %f sec\n",
grid_1.x,grid_1.y,block_1.x,block_1.y,iElaps);

CHECK(cudaMemcpy(C_from_gpu,C_dev,nBytes,cudaMemcpyDeviceToHost));
checkResult(C_host,C_from_gpu,nxy);

GPU设备信息

在软件内查询信息,用到如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
#include <cuda_runtime.h>
#include <stdio.h>

int main(int argc,char** argv)
{
printf("%s Starting ...\n",argv[0]);
int deviceCount = 0;
cudaError_t error_id = cudaGetDeviceCount(&deviceCount);
if(error_id!=cudaSuccess)
{
printf("cudaGetDeviceCount returned %d\n ->%s\n",
(int)error_id,cudaGetErrorString(error_id));
printf("Result = FAIL\n");
exit(EXIT_FAILURE);
}
if(deviceCount==0)
{
printf("There are no available device(s) that support CUDA\n");
}
else
{
printf("Detected %d CUDA Capable device(s)\n",deviceCount);
}
int dev=0,driverVersion=0,runtimeVersion=0;
cudaSetDevice(dev);
cudaDeviceProp deviceProp;
cudaGetDeviceProperties(&deviceProp,dev);
printf("Device %d:\"%s\"\n",dev,deviceProp.name);
cudaDriverGetVersion(&driverVersion);
cudaRuntimeGetVersion(&runtimeVersion);
printf(" CUDA Driver Version / Runtime Version %d.%d / %d.%d\n",
driverVersion/1000,(driverVersion%100)/10,
runtimeVersion/1000,(runtimeVersion%100)/10);
printf(" CUDA Capability Major/Minor version number: %d.%d\n",
deviceProp.major,deviceProp.minor);
printf(" Total amount of global memory: %.2f MBytes (%llu bytes)\n",
(float)deviceProp.totalGlobalMem/pow(1024.0,3));
printf(" GPU Clock rate: %.0f MHz (%0.2f GHz)\n",
deviceProp.clockRate*1e-3f,deviceProp.clockRate*1e-6f);
printf(" Memory Bus width: %d-bits\n",
deviceProp.memoryBusWidth);
if (deviceProp.l2CacheSize)
{
printf(" L2 Cache Size: %d bytes\n",
deviceProp.l2CacheSize);
}
printf(" Max Texture Dimension Size (x,y,z) 1D=(%d),2D=(%d,%d),3D=(%d,%d,%d)\n",
deviceProp.maxTexture1D,deviceProp.maxTexture2D[0],deviceProp.maxTexture2D[1]
,deviceProp.maxTexture3D[0],deviceProp.maxTexture3D[1],deviceProp.maxTexture3D[2]);
printf(" Max Layered Texture Size (dim) x layers 1D=(%d) x %d,2D=(%d,%d) x %d\n",
deviceProp.maxTexture1DLayered[0],deviceProp.maxTexture1DLayered[1],
deviceProp.maxTexture2DLayered[0],deviceProp.maxTexture2DLayered[1],
deviceProp.maxTexture2DLayered[2]);
printf(" Total amount of constant memory %lu bytes\n",
deviceProp.totalConstMem);
printf(" Total amount of shared memory per block: %lu bytes\n",
deviceProp.sharedMemPerBlock);
printf(" Total number of registers available per block:%d\n",
deviceProp.regsPerBlock);
printf(" Wrap size: %d\n",deviceProp.warpSize);
printf(" Maximun number of thread per multiprocesser: %d\n",
deviceProp.maxThreadsPerMultiProcessor);
printf(" Maximun number of thread per block: %d\n",
deviceProp.maxThreadsPerBlock);
printf(" Maximun size of each dimension of a block: %d x %d x %d\n",
deviceProp.maxThreadsDim[0],deviceProp.maxThreadsDim[1],deviceProp.maxThreadsDim[2]);
printf(" Maximun size of each dimension of a grid: %d x %d x %d\n",
deviceProp.maxGridSize[0],
deviceProp.maxGridSize[1],
deviceProp.maxGridSize[2]);
printf(" Maximu memory pitch %lu bytes\n",deviceProp.memPitch);
exit(EXIT_SUCCESS);
}

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Detected 1 CUDA Capable device(s)
Device 0:"Tesla T4"
CUDA Driver Version / Runtime Version 11.2 / 11.1
CUDA Capability Major/Minor version number: 7.5
Total amount of global memory: 14.76 MBytes (140518271855200 bytes)
GPU Clock rate: 1590 MHz (1.59 GHz)
Memory Bus width: 256-bits
L2 Cache Size: 4194304 bytes
Max Texture Dimension Size (x,y,z) 1D=(131072),2D=(131072,65536),3D=(16384,16384,16384)
Max Layered Texture Size (dim) x layers 1D=(32768) x 2048,2D=(32768,32768) x 2048
Total amount of constant memory 65536 bytes
Total amount of shared memory per block: 49152 bytes
Total number of registers available per block:65536
Wrap size: 32
Maximun number of thread per multiprocesser: 1024
Maximun number of thread per block: 1024
Maximun size of each dimension of a block: 1024 x 1024 x 64
Maximun size of each dimension of a grid: 2147483647 x 65535 x 65535
Maximu memory pitch 2147483647 bytes

这里面很多参数是我们后面要介绍的,而且每一个都对性能有影响:

  • CUDA驱动版本
  • 设备计算能力编号
  • 全局内存大小(1.95G,原文有错误,写成MBytes了)
  • GPU主频
  • GPU带宽
  • L2缓存大小
  • 纹理维度最大值,不同维度下的
  • 层叠纹理维度最大值
  • 常量内存大小
  • 块内共享内存大小
  • 块内寄存器大小
  • 线程束大小
  • 每个处理器硬件处理的最大线程数
  • 每个块处理的最大线程数
  • 块的最大尺寸
  • 网格的最大尺寸
  • 最大连续线性内存

CUDA执行模型概述

CUDA执行模型揭示了GPU并行架构的抽象视图,再设计硬件的时候,其功能和特性都已经被设计好了,然后去开发硬件,如果这个过程模型特性或功能与硬件设计有冲突,双方就会进行商讨妥协,知道最后产品定型量产,功能和特性算是全部定型,而这些功能和特性就是变成模型的设计基础,而编程模型又直接反应了硬件设计,从而反映了设备的硬件特性。

比如最直观的一个就是内存,线程的层次结构帮助我们控制大规模并行,这个特性就是硬件设计最初设计好,然后集成电路工程师拿去设计,定型后程序员开发驱动,然后在上层可以直接使用这种执行模型来控制硬件。
所以了解CUDA的执行模型,可以帮助我们优化指令吞吐量,和内存使用来获得极限速度。

GPU架构概述

GPU架构是围绕一个流式多处理器(SM)的扩展阵列搭建的。通过复制这种结构来实现GPU的硬件并行。

上图包括关键组件:

  • CUDA核心
  • 共享内存/一级缓存
  • 寄存器文件
  • 加载/存储单元
  • 特殊功能单元
  • 线程束调度器

GPU中每个SM都能支持数百个线程并发执行,每个GPU通常有多个SM,当一个核函数的网格被启动的时候,多个block会被同时分配给可用的SM上执行。

注意: 当一个blcok被分配给一个SM后,他就只能在这个SM上执行了,不可能重新分配到其他SM上了,多个线程块可以被分配到同一个SM上。

在SM上同一个块内的多个线程进行线程级别并行,而同一线程内,指令利用指令级并行将单个线程处理成流水线。

线程束

CUDA 采用单指令多线程SIMT架构管理执行线程,不同设备有不同的线程束大小,但是到目前为止基本所有设备都是维持在32,也就是说每个SM上有多个block,一个block有多个线程(可以是几百个,但不会超过某个最大值),但是从机器的角度,在某时刻T,SM上只执行一个线程束,也就是32个线程在同时同步执行,线程束中的每个线程执行同一条指令,包括有分支的部分,这个我们后面会讲到,

SIMD vs SIMT

单指令多数据的执行属于向量机,比如我们有四个数字要加上四个数字,那么我们可以用这种单指令多数据的指令来一次完成本来要做四次的运算。这种机制的问题就是过于死板,不允许每个分支有不同的操作,所有分支必须同时执行相同的指令,必须执行没有例外。

相比之下单指令多线程SIMT就更加灵活了,虽然两者都是将相同指令广播给多个执行单元,但是SIMT的某些线程可以选择不执行,也就是说同一时刻所有线程被分配给相同的指令,SIMD规定所有人必须执行,而SIMT则规定有些人可以根据需要不执行,这样SIMT就保证了线程级别的并行,而SIMD更像是指令级别的并行。

SIMT包括以下SIMD不具有的关键特性:

  • 每个线程都有自己的指令地址计数器
  • 每个线程都有自己的寄存器状态
  • 每个线程可以有一个独立的执行路径

而上面这三个特性在编程模型可用的方式就是给每个线程一个唯一的标号(blckIdx,threadIdx),并且这三个特性保证了各线程之间的独立

32

32是个神奇数字,他的产生是硬件系统设计的结果,也就是集成电路工程师搞出来的,所以软件工程师只能接受。

从概念上讲,32是SM以SIMD方式同时处理的工作粒度,这句话这么理解,可能学过后面的会更深刻的明白,一个SM上在某一个时刻,有32个线程在执行同一条指令,这32个线程可以选择性执行,虽然有些可以不执行,但是他也不能执行别的指令,需要另外需要执行这条指令的线程执行完

CUDA编程的组件与逻辑

下图从逻辑角度和硬件角度描述了CUDA编程模型对应的组件。

SM中共享内存,和寄存器是关键的资源,线程块中线程通过共享内存和寄存器相互通信协调。寄存器和共享内存的分配可以严重影响性能!

因为SM有限,虽然我们的编程模型层面看所有线程都是并行执行的,但是在微观上看,所有线程块也是分批次的在物理层面的机器上执行,线程块里不同的线程可能进度都不一样,但是同一个线程束内的线程拥有相同的进度。

并行就会引起竞争,多线程以未定义的顺序访问同一个数据,就导致了不可预测的行为,CUDA只提供了一种块内同步的方式,块之间没办法同步!同一个SM上可以有不止一个常驻的线程束,有些在执行,有些在等待,他们之间状态的转换是不需要开销的。

理解线程束执行的本质

从外表来看,CUDA执行所有的线程,并行的,没有先后次序的,但实际上硬件资源是有限的,不可能同时执行百万个线程,所以从硬件角度来看,物理层面上执行的也只是线程的一部分,而每次执行的这一部分,就是我们前面提到的线程束。

线程束是SM中基本的执行单元,当一个网格被启动(网格被启动,等价于一个内核被启动,每个内核对应于自己的网格),网格中包含线程块,线程块被分配到某一个SM上以后,将分为多个线程束,每个线程束一般是32个线程(目前的GPU都是32个线程,但不保证未来还是32个)在一个线程束中,所有线程按照单指令多线程SIMT的方式执行,每一步执行相同的指令,但是处理的数据为私有的数据。

在块中,每个线程有唯一的编号(可能是个三维的编号),threadIdx。网格中,每个线程块也有唯一的编号(可能是个三维的编号),blockIdx。那么每个线程就有在网格中的唯一编号。当一个线程块中有128个线程的时候,其分配到SM上执行时,会分成4个块:

1
2
3
4
warp0: thread  0,........thread31
warp1: thread 32,........thread63
warp2: thread 64,........thread95
warp3: thread 96,........thread127

当编号使用三维编号时,x位于最内层,y位于中层,z位于最外层,想象下c语言的数组,如果把上面这句话写成c语言,假设三维数组t保存了所有的线程,那么(threadIdx.x,threadIdx.y,threadIdx.z)表示为t[z][y][x];

计算出三维对应的线性地址是:tid=threadIdx.x+threadIdx.y×blockDim.x+threadIdx.z×blockDim.x×blockDim.y。上面的公式可以借助c语言的三维数组计算相对地址的方法

因为线程束分化导致的性能下降就应该用线程束的方法解决,根本思路是避免同一个线程束内的线程分化,而让我们能控制线程束内线程行为的原因是线程块中线程分配到线程束是有规律的而不是随机的。这就使得我们根据线程编号来设计分支是可以的,补充说明下,当一个线程束中所有的线程都执行if或者,都执行else时,不存在性能下降;只有当线程束内有分歧产生分支的时候,性能才会急剧下降。

线程束内的线程是可以被我们控制的,那么我们就把都执行if的线程塞到一个线程束中,或者让一个线程束中的线程都执行if,另外线程都执行else的这种方式可以将效率提高很多。下面这个kernel可以产生一个比较低效的分支:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
__global__ void mathKernel1(float *c)
{
int tid = blockIdx.x* blockDim.x + threadIdx.x;

float a = 0.0;
float b = 0.0;
if (tid % 2 == 0)
{
a = 100.0f;
}
else
{
b = 200.0f;
}
c[tid] = a + b;
}

这种情况下我们假设只配置一个x=64的一维线程块,那么只有两个个线程束,线程束内奇数线程(threadIdx.x为奇数)会执行else,偶数线程执行if,分化很严重。

但是如果我们换一种方法,得到相同但是错乱的结果C,这个顺序其实是无所谓的,因为我们可以后期调整。那么下面代码就会很高效

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
__global__ void mathKernel2(float *c)
{
int tid = blockIdx.x* blockDim.x + threadIdx.x;
float a = 0.0;
float b = 0.0;
if ((tid/warpSize) % 2 == 0)
{
a = 100.0f;
}
else
{
b = 200.0f;
}
c[tid] = a + b;
}

第一个线程束内的线程编号tid从0到31,tid/warpSize都等于0,那么就都执行if语句。第二个线程束内的线程编号tid从32到63,tid/warpSize都等于1,执行else。线程束内没有分支,效率较高。

延迟隐藏

与其他类型的编程相比,GPU的延迟隐藏及其重要。对于指令的延迟,通常分为两种:

  • 算术指令
  • 内存指令

算数指令延迟是一个算术操作从开始,到产生结果之间的时间,这个时间段内只有某些计算单元处于工作状态,而其他逻辑计算单元处于空闲。内存指令延迟很好理解,当产生内存访问的时候,计算单元要等数据从内存拿到寄存器,这个周期是非常长的。

延迟:

  • 算术延迟 10~20 个时钟周期
  • 内存延迟 400~800 个时钟周期

同步

并发程序对同步非常有用,比如pthread中的锁,openmp中的同步机制,主要目的是避免内存竞争。CUDA同步这里只讲两种:

  • 线程块内同步
  • 系统级别

块级别的就是同一个块内的线程会同时停止在某个设定的位置,用

1
__syncthread();

这个函数完成,这个函数只能同步同一个块内的线程,不能同步不同块内的线程,想要同步不同块内的线程,就只能让核函数执行完成,控制程序交换主机,这种方式来同步所有线程。

内存竞争是非常危险的,一定要非常小心,这里经常出错。

并行性表现

本文的主要内容就是进一步理解线程束在硬件上执行的本质过程,结合上几篇关于执行模型的学习,本文相对简单,通过修改核函数的配置,来观察核函数的执行速度,以及分析硬件利用数据,分析性能,调整核函数配置是CUDA开发人员必须掌握的技能,本篇只研究对核函数的配置是如何影响效率的(也就是通过网格,块的配置来获得不同的执行效率。)本文全文只用到下面的核函数

1
2
3
4
5
6
7
8
9
10
__global__ void sumMatrix(float * MatA,float * MatB,float * MatC,int nx,int ny)
{
int ix=threadIdx.x+blockDim.x*blockIdx.x;
int iy=threadIdx.y+blockDim.y*blockIdx.y;
int idx=ix+iy*ny;
if (ix<nx && iy<ny)
{
MatC[idx]=MatA[idx]+MatB[idx];
}
}

没有任何优化的最简单的二维矩阵加法。

全部代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
int main(int argc,char** argv)
{
//printf("strating...\n");
//initDevice(0);
int nx=1<<13;
int ny=1<<13;
int nxy=nx*ny;
int nBytes=nxy*sizeof(float);

//Malloc
float* A_host=(float*)malloc(nBytes);
float* B_host=(float*)malloc(nBytes);
float* C_host=(float*)malloc(nBytes);
float* C_from_gpu=(float*)malloc(nBytes);
initialData(A_host,nxy);
initialData(B_host,nxy);

//cudaMalloc
float *A_dev=NULL;
float *B_dev=NULL;
float *C_dev=NULL;
CHECK(cudaMalloc((void**)&A_dev,nBytes));
CHECK(cudaMalloc((void**)&B_dev,nBytes));
CHECK(cudaMalloc((void**)&C_dev,nBytes));


CHECK(cudaMemcpy(A_dev,A_host,nBytes,cudaMemcpyHostToDevice));
CHECK(cudaMemcpy(B_dev,B_host,nBytes,cudaMemcpyHostToDevice));

int dimx=argc>2?atoi(argv[1]):32;
int dimy=argc>2?atoi(argv[2]):32;

double iStart,iElaps;

// 2d block and 2d grid
dim3 block(dimx,dimy);
dim3 grid((nx-1)/block.x+1,(ny-1)/block.y+1);
iStart=cpuSecond();
sumMatrix<<<grid,block>>>(A_dev,B_dev,C_dev,nx,ny);
CHECK(cudaDeviceSynchronize());
iElaps=cpuSecond()-iStart;
printf("GPU Execution configuration<<<(%d,%d),(%d,%d)|%f sec\n",
grid.x,grid.y,block.x,block.y,iElaps);
CHECK(cudaMemcpy(C_from_gpu,C_dev,nBytes,cudaMemcpyDeviceToHost));

cudaFree(A_dev);
cudaFree(B_dev);
cudaFree(C_dev);
free(A_host);
free(B_host);
free(C_host);
free(C_from_gpu);
cudaDeviceReset();
return 0;
}

可见我们用两个 8192×8192 的矩阵相加来测试我们效率。

避免分支分化

并行规约问题

在串行编程中,我们最最最常见的一个问题就是一组特别多数字通过计算变成一个数字,比如加法,也就是求这一组数据的和,或者乘法,对应的加法或者乘法就是交换律和结合律。归约的方式基本包括如下几个步骤:

  • 将输入向量划分到更小的数据块中
  • 用一个线程计算一个数据块的部分和
  • 对每个数据块的部分和再求和得到最终的结果。
  • 数据分块保证我们可以用一个线程块来处理一个数据块。
  • 一个线程处理更小的块,所以一个线程块可以处理一个较大的块,然后多个块完成整个数据集的处理。
  • 最后将所有线程块得到的结果相加,就是结果,这一步一般在cpu上完成。

归约问题最常见的加法计算是把向量的数据分成对,然后用不同线程计算每一对元素,得到的结果作为输入继续分成对,迭代的进行,直到最后一个元素。成对的划分常见的方法有以下两种:

  1. 相邻配对:元素与他们相邻的元素配对
  2. 交错配对:元素与一定距离的元素配对

首先是cpu版本实现交错配对归约计算的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
int recursiveReduce(int *data, int const size)
{
// terminate check
if (size == 1)
return data[0];
// renew the stride
int const stride = size / 2;
if (size % 2 == 1)
{
for (int i = 0; i < stride; i++)
{
data[i] += data[i + stride];
}
data[0] += data[size - 1];
}
else
{
for (int i = 0; i < stride; i++)
{
data[i] += data[i + stride];
}
}
// call
return recursiveReduce(data, stride);
}

并行规约中的分化

线程束分化已经明确说明了,有判断条件的地方就会产生分支,比如if 和 for这类关键词。

第一步:是把这个一个数组分块,每一块只包含部分数据,如上图那样(图中数据较少,但是我们假设一块上只有这么多。),我们假定这是线程块的全部数据

第二步:就是每个线程要做的事,橙色圆圈就是每个线程做的操作,可见线程threadIdx.x=0 的线程进行了三次计算,奇数线程一致在陪跑,没做过任何计算,但是根据3.2中介绍,这些线程虽然什么都不干,但是不可以执行别的指令,4号线程做了两步计算,2号和6号只做了一次计算。

第三步:将所有块得到的结果相加,就是最终结果

这个计算划分就是最简单的并行规约算法,完全符合上面我们提到的三步走的套路

值得注意的是,我们每次进行一轮计算(黄色框,这些操作同时并行)的时候,部分全局内存要进行一次修改,但只有部分被替换,而不被替换的,也不会在后面被使用到,如蓝色框里标注的内存,就被读了一次,后面就完全没有人管了。

我们现在把我们的内核代码贴出来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
__global__ void reduceNeighbored(int * g_idata,int * g_odata,unsigned int n)
{
//set thread ID
unsigned int tid = threadIdx.x;
//boundary check
if (tid >= n) return;
//convert global data pointer to the
int *idata = g_idata + blockIdx.x*blockDim.x;
//in-place reduction in global memory
for (int stride = 1; stride < blockDim.x; stride *= 2)
{
if ((tid % (2 * stride)) == 0)
{
idata[tid] += idata[tid + stride];
}
//synchronize within block
__syncthreads();
}
//write result for this block to global mem
if (tid == 0)
g_odata[blockIdx.x] = idata[0];
}

这里面唯一要注意的地方就是同步指令

1
__syncthreads();

原因还是能从图上找到,我们的每一轮操作都是并行的,但是不保证所有线程能同时执行完毕,所以需要等待,执行的快的等待慢的,这样就能避免块内的线程竞争内存了。

被操作的两个对象之间的距离叫做跨度,也就是变量stride,

展开循环

目前CUDA的编译器还不能帮我们做这种优化,人为的展开核函数内的循环,能够非常大的提升内核性能。在CUDA中展开循环的目的还是那两个:

  • 减少指令消耗
  • 增加更多的独立调度指令来提高性能

如果这种指令

1
2
3
4
a[i+0]=b[i+0]+c[i+0];
a[i+1]=b[i+1]+c[i+1];
a[i+2]=b[i+2]+c[i+2];
a[i+3]=b[i+3]+c[i+3];

被添加到CUDA流水线上,是非常受欢迎的,因为其能最大限度的提高指令和内存带宽。下面我们就在前面归约的例子上继续挖掘性能,看看是否能得到更高的效率。

cuda内存模型

CUDA内存模型相对于CPU来说那是相当丰富了,GPU上的内存设备有:

  • 寄存器
  • 共享内存
  • 本地内存
  • 常量内存
  • 纹理内存
  • 全局内存

上述各种都有自己的作用域,生命周期和缓存行为。CUDA中每个线程都有自己的私有的本地内存;线程块有自己的共享内存,对线程块内所有线程可见;所有线程都能访问读取常量内存和纹理内存,但是不能写,因为他们是只读的;全局内存,常量内存和纹理内存空间有不同的用途。对于一个应用来说,全局内存,常量内存和纹理内存有相同的生命周期。下图总结了上面这段话,后面的大篇幅文章就是挨个介绍这些内存的性质和使用的。

寄存器

寄存器无论是在CPU还是在GPU都是速度最快的内存空间,但是和CPU不同的是GPU的寄存器储量要多一些,而且当我们在核函数内不加修饰的声明一个变量,此变量就存储在寄存器中,但是CPU运行的程序有些不同,只有当前在计算的变量存储在寄存器中,其余在主存中,使用时传输至寄存器。在核函数中定义的有常数长度的数组也是在寄存器中分配地址的。

寄存器对于每个线程是私有的,寄存器通常保存被频繁使用的私有变量,注意这里的变量也一定不能使共有的,不然的话彼此之间不可见,就会导致大家同时改变一个变量而互相不知道,寄存器变量的声明周期和核函数一致,从开始运行到运行结束,执行完毕后,寄存器就不能访问了。

寄存器是SM中的稀缺资源,Fermi架构中每个线程最多63个寄存器。Kepler结构扩展到255个寄存器,一个线程如果使用更少的寄存器,那么就会有更多的常驻线程块,SM上并发的线程块越多,效率越高,性能和使用率也就越高。

那么问题就来了,如果一个线程里面的变量太多,以至于寄存器完全不够呢?这时候寄存器发生溢出,本地内存就会过来帮忙存储多出来的变量,这种情况会对效率产生非常负面的影响,所以,不到万不得已,一定要避免此种情况发生。

为了避免寄存器溢出,可以在核函数的代码中配置额外的信息来辅助编译器优化,比如:

1
2
3
4
5
__global__ void
__lauch_bounds__(maxThreadaPerBlock,minBlocksPerMultiprocessor)
kernel(...) {
/* kernel code */
}

这里面在核函数定义前加了一个 关键字 lauch_bounds,然后他后面对应了两个变量:

  1. maxThreadaPerBlock:线程块内包含的最大线程数,线程块由核函数来启动
  2. minBlocksPerMultiprocessor:可选参数,每个SM中预期的最小的常驻内存块参数。

注意,对于一定的核函数,优化的启动边界会因为不同的结构而不同。也可以在编译选项中加入-maxrregcount=32来控制一个编译单元里所有核函数使用的最大数量。

本地内存

核函数中符合存储在寄存器中但不能进入被核函数分配的寄存器空间中的变量将存储在本地内存中,编译器可能存放在本地内存中的变量有以下几种:

  • 使用未知索引引用的本地数组
  • 可能会占用大量寄存器空间的较大本地数组或者结构体
  • 任何不满足核函数寄存器限定条件的变量

本地内存实质上是和全局内存一样在同一块存储区域当中的,其访问特点——高延迟,低带宽。对于2.0以上的设备,本地内存存储在每个SM的一级缓存,或者设备的二级缓存上。

共享内存

在核函数中使用如下修饰符的内存,称为共享内存:__share__

每个SM都有一定数量的由线程块分配的共享内存,共享内存是片上内存,跟主存相比,速度要快很多,也即是延迟低,带宽高。其类似于一级缓存,但是可以被编程。使用共享内存的时候一定要注意,不要因为过度使用共享内存,而导致SM上活跃的线程束减少,也就是说,一个线程块使用的共享内存过多,导致更过的线程块没办法被SM启动,这样影响活跃的线程束数量。

共享内存在核函数内声明,生命周期和线程块一致,线程块运行开始,此块的共享内存被分配,当此块结束,则共享内存被释放。因为共享内存是块内线程可见的,所以就有竞争问题的存在,也可以通过共享内存进行通信,当然,为了避免内存竞争,可以使用同步语句:

1
void __syncthreads();

此语句相当于在线程块执行时各个线程的一个障碍点,当块内所有线程都执行到本障碍点的时候才能进行下一步的计算,这样可以设计出避免内存竞争的共享内存使用程序。

注意,__syncthreads();频繁使用会影响内核执行效率。

SM中的一级缓存,和共享内存共享一个64k的片上内存(不知道现在的设备有没有提高),他们通过静态划分,划分彼此的容量,运行时可以通过下面语句进行设置:

1
cudaError_t cudaFuncSetCacheConfig(const void * func,enum cudaFuncCache);

这个函数可以设置内核的共享内存和一级缓存之间的比例。cudaFuncCache参数可选如下配置:

1
2
3
4
cudaFuncCachePreferNone//无参考值,默认设置
cudaFuncCachePreferShared//48k共享内存,16k一级缓存
cudaFuncCachePreferL1// 48k一级缓存,16k共享内存
cudaFuncCachePreferEqual// 32k一级缓存,32k共享内存

常量内存

常量内存驻留在设备内存中,每个SM都有专用的常量内存缓存,常量内存使用:__constant__修饰,常量内存在核函数外,全局范围内声明,对于所有设备,只可以声明64k的常量内存,常量内存静态声明,并对同一编译单元中的所有核函数可见。

常量内存,显然是不能被修改的,这里不能被修改指的是被核函数修改,主机端代码是可以初始化常量内存的,不然这个内存谁都不能改就没有什么使用意义了,常量内存,被主机端初始化后不能被核函数修改,初始化函数如下:

1
cudaError_t cudaMemcpyToSymbol(const void* symbol,const void *src,size_t count);

同 cudaMemcpy的参数列表相似,从src复制count个字节的内存到symbol里面,也就是设备端的常量内存。多数情况下此函数是同步的,也就是会马上被执行。

当线程束中所有线程都从相同的地址取数据时,常量内存表现较好,比如执行某一个多项式计算,系数都存在常量内存里效率会非常高,但是如果不同的线程取不同地址的数据,常量内存就不那么好了,因为常量内存的读取机制是:一次读取会广播给所有线程束内的线程。

纹理内存

纹理内存驻留在设备内存中,在每个SM的只读缓存中缓存,纹理内存是通过指定的缓存访问的全局内存,只读缓存包括硬件滤波的支持,它可以将浮点插入作为读取过程中的一部分来执行,纹理内存是对二维空间局部性的优化。总的来说纹理内存设计目的应该是为了GPU本职工作显示设计的,但是对于某些特定的程序可能效果更好,比如需要滤波的程序,可以直接通过硬件完成。

全局内存

GPU上最大的内存空间,延迟最高,使用最常见的内存,global指的是作用域和生命周期,一般在主机端代码里定义,也可以在设备端定义,不过需要加修饰符,只要不销毁,是和应用程序同生命周期的。全局内存对应于设备内存,一个是逻辑表示,一个是硬件表示。

全局内存可以动态声明,或者静态声明,可以用下面的修饰符在设备代码中静态的声明一个变量:__device__。我们前面声明的所有的在GPU上访问的内存都是全局内存,或者说到目前为止我们还没对内存进行任何优化。因为全局内存的性质,当有多个核函数同时执行的时候,如果使用到了同一全局变量,应注意内存竞争。

全局内存访问是对齐,也就是一次要读取指定大小(32,64,128)整数倍字节的内存,所以当线程束执行内存加载/存储时,需要满足的传输数量通常取决与以下两个因素:

  • 跨线程的内存地址分布
  • 内存事务的对齐方式。

一般情况下满足内存请求的事务越多,未使用的字节被传输的可能性越大,数据吞吐量就会降低,换句话说,对齐的读写模式使得不需要的数据也被传输,所以,利用率低到时吞吐量下降。1.1以下的设备对内存访问要求非常严格(为了达到高效,访问受到限制)因为当时还没有缓存,现在的设备都有缓存了,所以宽松了一些。

GPU缓存

与CPU缓存类似,GPU缓存不可编程,其行为出厂是时已经设定好了。GPU上有4种缓存:

  • 一级缓存
  • 二级缓存
  • 只读常量缓存
  • 只读纹理缓存

每个SM都有一个一级缓存,所有SM公用一个二级缓存。一级二级缓存的作用都是被用来存储本地内存和全局内存中的数据,也包括寄存器溢出的部分。Fermi,Kepler以及以后的设备,CUDA允许我们配置读操作的数据是使用一级缓存和二级缓存,还是只使用二级缓存。

与CPU不同的是,CPU读写过程都有可能被缓存,但是GPU写的过程不被缓存,只有加载会被缓存!

每个SM有一个只读常量缓存,只读纹理缓存,它们用于设备内存中提高来自于各自内存空间内的读取性能。

CUDA变量声明总结
用表格进行总结:

修饰符 变量名称 存储器 作用域 生命周期
float var 寄存器 线程 线程
float var[100] 本地 线程 线程
share float var* 共享
device float var* 全局 全局 应用程序
__constant float var* 常量 全局 应用程序

设备存储器的重要特征:

存储器 片上/片外 缓存 存取 范围 生命周期
寄存器 片上 n/a R/W 一个线程 线程
本地 片外 1.0以上有 R/W 一个线程 线程
共享 片上 n/a R/W 块内所有线程
全局 片外 1.0以上有 R/W 所有线程+主机 主机配置
常量 片外 Yes R 所有线程+主机 主机配置
纹理 片外 Yes R 所有线程+主机 主机配置

静态全局内存

CPU内存有动态分配和静态分配两种类型,从内存位置来说,动态分配在堆上进行,静态分配在栈上进行,在代码上的表现是一个需要new,malloc等类似的函数动态分配空间,并用delete和free来释放。在CUDA中也有类似的动态静态之分,我们前面用的都是要cudaMalloc的,所以对比来说就是动态分配,我们今天来个静态分配的,不过与动态分配相同是,也需要显式的将内存copy到设备端,我们用下面代码来看一下程序的运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <cuda_runtime.h>
#include <stdio.h>
__device__ float devData;
__global__ void checkGlobalVariable()
{
printf("Device: The value of the global variable is %f\n",devData);
devData+=2.0;
}
int main()
{
float value=3.14f;
cudaMemcpyToSymbol(devData,&value,sizeof(float));
printf("Host: copy %f to the global variable\n",value);
checkGlobalVariable<<<1,1>>>();
cudaMemcpyFromSymbol(&value,devData,sizeof(float));
printf("Host: the value changed by the kernel to %f \n",value);
cudaDeviceReset();
return EXIT_SUCCESS;
}

这个唯一要注意的就是,这一句

1
cudaMemcpyToSymbol(devData,&value,sizeof(float));

函数原型说的是第一个应该是个void*,但是这里写了一个device float devData;变量,这个说到底还是设备上的变量定义和主机变量定义的不同,设备变量在代码中定义的时候其实就是一个指针,这个指针指向何处,主机端是不知道的,指向的内容也不知道,想知道指向的内容,唯一的办法还是通过显式的办法传输过来:

1
cudaMemcpyFromSymbol(&value,devData,sizeof(float));

这里需要注意的只有这点:在主机端,devData只是一个标识符,不是设备全局内存的变量地址
在核函数中,devData就是一个全局内存中的变量。主机代码不能直接访问设备变量,设备也不能访问主机变量,这就是CUDA编程与CPU多核最大的不同之处

1
cudaMemcpy(&value,devData,sizeof(float));

是不可以的!这个函数是无效的!就是你不能用动态copy的方法给静态变量赋值!

如果你死活都要用cudaMemcpy,只能用下面的方式:

1
2
3
float *dptr=NULL;
cudaGetSymbolAddress((void**)&dptr,devData);
cudaMemcpy(dptr,&value,sizeof(float),cudaMemcpyHostToDevice);

主机端不可以对设备变量进行取地址操作!这是非法的!

想要得到devData的地址可以用下面方法:

1
2
float *dptr=NULL;
cudaGetSymbolAddress((void**)&dptr,devData);

当然也有一个例外,可以直接从主机引用GPU内存——CUDA固定内存。后面我们会研究这部分。

CUDA运行时API能访问主机和设备变量,但这取决于你给正确的函数是否提供了正确的参数,使用运行时API,如果参数填错,尤其是主机和设备上的指针,结果是无法预测的。

内存管理

CUDA是C语言的扩展,内存方面基本集成了C语言的方式,由程序员控制CUDA内存,当然,这些内存的物理设备是在GPU上的,而且与CPU内存分配不同,CPU内存分配完就完事了,GPU还涉及到数据传输,主机和设备之间的传输。接下来我们要了解的是:

  • 分配释放设备内存
  • 在主机和设备间传输内存

为达到最优性能,CUDA提供了在主机端准备设备内存的函数,并且显式地向设备传递数据,显式的从设备取回数据。

内存分配和释放

内存的分配和释放我们在前面已经用过很多次了,前面所有的要计算的例子都包含这一步:

1
cudaError_t cudaMalloc(void ** devPtr,size_t count)

这个函数用过很多次了,唯一要注意的是第一个参数,是指针的指针,一般的用法是首先我们生命一个指针变量,然后调用这个函数:

1
2
float * devMem=NULL;
cudaError_t cudaMalloc((float**) devMem, count)

这里是这样的,devMem是一个指针,定义时初始化指向NULL,这样做是安全的,避免出现野指针,cudaMalloc函数要修改devMem的值,所以必须把他的指针传递给函数,如果把devMem当做参数传递,经过函数后,指针的内容还是NULL。

内存分配支持所有的数据类型,什么int,float。。。这些都无所谓,因为他是按照字节分配的,只要是正数字节的变量都能分配,当然我们根本没有半个字节的东西。函数执行失败返回:cudaErrorMemoryAllocation。

当分配完地址后,可以使用下面函数进行初始化:

1
cudaError_t cudaMemset(void * devPtr,int value,size_t count)

用法和Memset类似,但是注意,这些被我们操作的内存对应的物理内存都在GPU上。

当分配的内存不被使用时,使用下面语句释放程序。

1
cudaError_t cudaFree(void * devPtr)

注意这个参数一定是前面cudaMalloc类的函数(还有其他分配函数)分配到空间,如果输入非法指针参数,会返回 cudaErrorInvalidDevicePointer 错误,如果重复释放一个空间,也会报错。

内存传输

下面介绍点C语言没有的,C语言的内存分配完成后就可以直接读写了,但是对于异构计算,这样是不行的,因为主机线程不能访问设备内存,设备线程也不能访问主机内存,这时候我们要传送数据了:

1
cudaError_t cudaMemcpy(void *dst,const void * src,size_t count,enum cudaMemcpyKind kind)

这个函数我们前面也反复用到,注意这里的参数是指针,而不是指针的指针,第一个参数dst是目标地址,第二个参数src是原始地址,然后是拷贝的内存大小,最后是传输类型,传输类型包括以下几种:

  • cudaMemcpyHostToHost
  • cudaMemcpyHostToDevice
  • cudaMemcpyDeviceToHost
  • cudaMemcpyDeviceToDevice

这个例子也不用说了,前面随便找个有数据传输的都有这两步:从主机到设备,然后计算,最后从设备到主机。

image-20220910105700518

GPU的内存理论峰值带宽非常高,对于Fermi C2050 有144GB/s,这个值估计现在的GPU应该都超过了,CPU和GPU之间通信要经过PCIe总线,总线的理论峰值要低很多——8GB/s左右,也就是说所,管理不当,算到半路需要从主机读数据,那效率瞬间全挂在PCIe上了。

CUDA编程需要大家减少主机和设备之间的内存传输

固定内存

主机内存采用分页式管理,通俗的说法就是操作系统把物理内存分成一些“页”,然后给一个应用程序一大块内存,而操作系统可能随时更换物理地址的页,但是从主机传输到设备上的时候,如果此时发生了页面移动,对于传输操作来说是致命的,所以在数据传输之前,CUDA驱动会锁定页面,或者直接分配固定的主机内存,将主机源数据复制到固定内存上,然后从固定内存传输数据到设备上:

image-20220910105840559

上图左边是正常分配内存,传输过程是:锁页-复制到固定内存-复制到设备。右边时分配时就是固定内存,直接传输到设备上。

下面函数用来分配固定内存:

1
cudaError_t cudaMallocHost(void ** devPtr,size_t count)

分配count字节的固定内存,这些内存是页面锁定的,可以直接传输到设备的。这样就是的传输带宽变得高很多。

固定的主机内存释放使用:

1
cudaError_t cudaFreeHost(void *ptr)

我们可以测试一下固定内存和分页内存的传输效率,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
#include <cuda_runtime.h>
#include <stdio.h>
#include "freshman.h"


void sumArrays(float * a,float * b,float * res,const int size)
{
for(int i=0;i<size;i+=4)
{
res[i]=a[i]+b[i];
res[i+1]=a[i+1]+b[i+1];
res[i+2]=a[i+2]+b[i+2];
res[i+3]=a[i+3]+b[i+3];
}
}
__global__ void sumArraysGPU(float*a,float*b,float*res)
{
int i=blockIdx.x*blockDim.x+threadIdx.x;
res[i]=a[i]+b[i];
}
int main(int argc,char **argv)
{
int dev = 0;
cudaSetDevice(dev);

int nElem=1<<14;
printf("Vector size:%d\n",nElem);
int nByte=sizeof(float)*nElem;
float *a_h=(float*)malloc(nByte);
float *b_h=(float*)malloc(nByte);
float *res_h=(float*)malloc(nByte);
float *res_from_gpu_h=(float*)malloc(nByte);
memset(res_h,0,nByte);
memset(res_from_gpu_h,0,nByte);

float *a_d,*b_d,*res_d;
// pine memory malloc
CHECK(cudaMallocHost((float**)&a_d,nByte));
CHECK(cudaMallocHost((float**)&b_d,nByte));
CHECK(cudaMallocHost((float**)&res_d,nByte));

initialData(a_h,nElem);
initialData(b_h,nElem);

CHECK(cudaMemcpy(a_d,a_h,nByte,cudaMemcpyHostToDevice));
CHECK(cudaMemcpy(b_d,b_h,nByte,cudaMemcpyHostToDevice));

dim3 block(1024);
dim3 grid(nElem/block.x);
sumArraysGPU<<<grid,block>>>(a_d,b_d,res_d);
printf("Execution configuration<<<%d,%d>>>\n",grid.x,block.x);

CHECK(cudaMemcpy(res_from_gpu_h,res_d,nByte,cudaMemcpyDeviceToHost));
sumArrays(a_h,b_h,res_h,nElem);

checkResult(res_h,res_from_gpu_h,nElem);
cudaFreeHost(a_d);
cudaFreeHost(b_d);
cudaFreeHost(res_d);

free(a_h);
free(b_h);
free(res_h);
free(res_from_gpu_h);

return 0;
}

使用

1
nvprof ./pine_memory

固定内存的释放和分配成本比可分页内存要高很多,但是传输速度更快,所以对于大规模数据,固定内存效率更高。

零拷贝内存

截止到目前,我们所接触到的内存知识的基础都是:主机直接不能访问设备内存,设备不能直接访问主机内存。对于早期设备,这是肯定的,但是后来,一个例外出现了——零拷贝内存。GPU线程可以直接访问零拷贝内存,这部分内存在主机内存里面,CUDA核函数使用零拷贝内存有以下几种情况:

  • 当设备内存不足的时候可以利用主机内存
  • 避免主机和设备之间的显式内存传输
  • 提高PCIe传输率

前面我们讲,注意线程之间的内存竞争,因为他们可以同时访问同一个内存地址,现在设备和主机可以同时访问同一个设备地址了,所以,我们要注意主机和设备的内存竞争——当使用零拷贝内存的时候。

零拷贝内存是固定内存,不可分页。可以通过以下函数创建零拷贝内存:

1
cudaError_t cudaHostAlloc(void ** pHost,size_t count,unsigned int flags)

最后一个标志参数,可以选择以下值:

  • cudaHostAllocDefalt
  • cudaHostAllocPortable
  • cudaHostAllocWriteCombined
  • cudaHostAllocMapped

cudaHostAllocDefaltcudaMallocHost函数一致,cudaHostAllocPortable函数返回能被所有CUDA上下文使用的固定内存,cudaHostAllocWriteCombined返回写结合内存,在某些设备上这种内存传输效率更高。cudaHostAllocMapped产生零拷贝内存。

注意,零拷贝内存虽然不需要显式的传递到设备上,但是设备还不能通过pHost直接访问对应的内存地址,设备需要访问主机上的零拷贝内存,需要先获得另一个地址,这个地址帮助设备访问到主机对应的内存,方法是:

1
cudaError_t cudaHostGetDevicePointer(void ** pDevice,void * pHost,unsigned flags);

pDevice就是设备上访问主机零拷贝内存的指针了!零拷贝内存可以当做比设备主存储器更慢的一个设备。

频繁的读写,零拷贝内存效率极低,这个非常容易理解,因为每次都要经过PCIe。

我们下面进行一个小实验,数组加法,改编自前面的代码,然后我们看看效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
int main(int argc,char **argv)
{
int dev = 0;
cudaSetDevice(dev);
int power=10;
if(argc>=2)
power=atoi(argv[1]);
int nElem=1<<power;
printf("Vector size:%d\n",nElem);
int nByte=sizeof(float)*nElem;
float *res_from_gpu_h=(float*)malloc(nByte);
float *res_h=(float*)malloc(nByte);
memset(res_h,0,nByte);
memset(res_from_gpu_h,0,nByte);

float *a_host,*b_host,*res_d;
double iStart,iElaps;
dim3 block(1024);
dim3 grid(nElem/block.x);
res_from_gpu_h=(float*)malloc(nByte);
float *a_dev,*b_dev;
CHECK(cudaHostAlloc((float**)&a_host,nByte,cudaHostAllocMapped));
CHECK(cudaHostAlloc((float**)&b_host,nByte,cudaHostAllocMapped));
CHECK(cudaMalloc((float**)&res_d,nByte));
initialData(a_host,nElem);
initialData(b_host,nElem);

//=============================================================//
iStart = cpuSecond();
CHECK(cudaHostGetDevicePointer((void**)&a_dev,(void*) a_host,0));
CHECK(cudaHostGetDevicePointer((void**)&b_dev,(void*) b_host,0));
sumArraysGPU<<<grid,block>>>(a_dev,b_dev,res_d);
CHECK(cudaMemcpy(res_from_gpu_h,res_d,nByte,cudaMemcpyDeviceToHost));
iElaps = cpuSecond() - iStart;
//=============================================================//
printf("zero copy memory elapsed %lf ms \n", iElaps);
printf("Execution configuration<<<%d,%d>>>\n",grid.x,block.x);
//-----------------------normal memory---------------------------
float *a_h_n=(float*)malloc(nByte);
float *b_h_n=(float*)malloc(nByte);
float *res_h_n=(float*)malloc(nByte);
float *res_from_gpu_h_n=(float*)malloc(nByte);
memset(res_h_n,0,nByte);
memset(res_from_gpu_h_n,0,nByte);

float *a_d_n,*b_d_n,*res_d_n;
CHECK(cudaMalloc((float**)&a_d_n,nByte));
CHECK(cudaMalloc((float**)&b_d_n,nByte));
CHECK(cudaMalloc((float**)&res_d_n,nByte));

initialData(a_h_n,nElem);
initialData(b_h_n,nElem);
//=============================================================//
iStart = cpuSecond();
CHECK(cudaMemcpy(a_d_n,a_h_n,nByte,cudaMemcpyHostToDevice));
CHECK(cudaMemcpy(b_d_n,b_h_n,nByte,cudaMemcpyHostToDevice));
sumArraysGPU<<<grid,block>>>(a_d_n,b_d_n,res_d_n);
CHECK(cudaMemcpy(res_from_gpu_h,res_d,nByte,cudaMemcpyDeviceToHost));
iElaps = cpuSecond() - iStart;
//=============================================================//
printf("device memory elapsed %lf ms \n", iElaps);
printf("Execution configuration<<<%d,%d>>>\n",grid.x,block.x);
//--------------------------------------------------------------------

sumArrays(a_host,b_host,res_h,nElem);
checkResult(res_h,res_from_gpu_h,nElem);

cudaFreeHost(a_host);
cudaFreeHost(b_host);
cudaFree(res_d);
free(res_h);
free(res_from_gpu_h);

cudaFree(a_d_n);
cudaFree(b_d_n);
cudaFree(res_d_n);

free(a_h_n);
free(b_h_n);
free(res_h_n);
free(res_from_gpu_h_n);
return 0;
}

我们把结果写在一个表里面:

数据规模n( 2^n ) 常规内存(us) 零拷贝内存(us)
10 2.5 3.0
12 3.0 4.1
14 7.8 8.6
16 23.1 25.8
18 86.5 98.2
20 290.9 310.5

这是通过观察运行时间得到的,当然也可以通过我们上面的nvprof得到内核执行时间:

数据规模n( 2^n ) 常规内存(us) 零拷贝内存(us)
10 1.088 4.257
12 1.056 8.00
14 1.920 24.578
16 4.544 86.63

统一虚拟寻址

设备架构2.0以后,Nvida又有新创意,他们搞了一套称为同一寻址方式(UVA)的内存机制,这样,设备内存和主机内存被映射到同一虚拟内存地址中。如图

image-20220910110833322

UVA之前,我们要管理所有的设备和主机内存,尤其是他们的指针。通过UVA,cudaHostAlloc函数分配的固定主机内存具有相同的主机和设备地址,可以直接将返回的地址传递给核函数。

前面的零拷贝内存,可以知道以下几个方面:

  • 分配映射的固定主机内存
  • 使用CUDA运行时函数获取映射到固定内存的设备指针
  • 将设备指针传递给核函数

有了UVA,可以不用上面的那个获得设备上访问零拷贝内存的函数了:

1
cudaError_t cudaHostGetDevicePointer(void ** pDevice,void * pHost,unsigned flags);

UVA来了以后,此函数基本失业了。

1
2
3
4
5
6
7
8
9
10
11
12
13
  float *a_host,*b_host,*res_d;
CHECK(cudaHostAlloc((float**)&a_host,nByte,cudaHostAllocMapped));
CHECK(cudaHostAlloc((float**)&b_host,nByte,cudaHostAllocMapped));
CHECK(cudaMalloc((float**)&res_d,nByte));
res_from_gpu_h=(float*)malloc(nByte);

initialData(a_host,nElem);
initialData(b_host,nElem);

dim3 block(1024);
dim3 grid(nElem/block.x);
sumArraysGPU<<<grid,block>>>(a_host,b_host,res_d);
}

UVA代码主要就是差个获取指针,UVA可以直接使用主机端的地址。

内存访问模式

多数GPU程序容易受到内存带宽的限制,所以最大程度的利用全局内存带宽,提高全局加载效率,是调控内核函数性能的基本条件。

CUDA执行模型告诉我们,CUDA执行的基本单位是线程束,所以,内存访问也是以线程束为基本单位发布和执行的,存储也一致。

对齐与合并访问

全局内存通过缓存实现加载和存储的过程如下图

image-20220910111100141

全局内存是一个逻辑层面的模型,我们编程的时候有两种模型考虑:一种是逻辑层面的,也就是我们在写程序的时候(包括串行程序和并行程序),写的一维(多维)数组,结构体,定义的变量,这些都是在逻辑层面的;一种是硬件角度,就是一块DRAM上的电信号,以及最底层内存驱动代码所完成数字信号的处理。

L1表示一级缓存,每个SM都有自己L1,但是L2是所有SM公用的,除了L1缓存外,还有只读缓存和常量缓存。

核函数运行时需要从全局内存(DRAM)中读取数据,只有两种粒度,这个是关键的:

  • 128字节
  • 32字节

解释下“粒度”,可以理解为最小单位,也就是核函数运行时每次读内存,哪怕是读一个字节的变量,也要读128字节,或者32字节,而具体是到底是32还是128还是要看访问方式:

  • 使用一级缓存
  • 不使用一级缓存

对于CPU来说,一级缓存或者二级缓存是不能被编程的,但是CUDA是支持通过编译指令停用一级缓存的。如果启用一级缓存,那么每次从DRAM上加载数据的粒度是128字节,如果不适用一级缓存,只是用二级缓存,那么粒度是32字节。

还要强调一下CUDA内存模型的内存读写,我们现在讨论的都是单个SM上的情况,多个SM只是下面我们描述的情形的复制:SM执行的基础是线程束,也就是说,当一个SM中正在被执行的某个线程需要访问内存,那么,和它同线程束的其他31个线程也要访问内存,这个基础就表示,即使每个线程只访问一个字节,那么在执行的时候,只要有内存请求,至少是32个字节,所以不使用一级缓存的内存加载,一次粒度是32字节而不是更小。

在优化内存的时候,我们要最关注的是以下两个特性

  • 对齐内存访问
  • 合并内存访问

我们把一次内存请求——也就是从内核函数发起请求,到硬件响应返回数据这个过程称为一个内存事务(加载和存储都行)。

当一个内存事务的首个访问地址是缓存粒度(32或128字节)的偶数倍的时候:比如二级缓存32字节的偶数倍64,128字节的偶数倍256的时候,这个时候被称为对齐内存访问,非对齐访问就是除上述的其他情况,非对齐的内存访问会造成带宽浪费。

当一个线程束内的线程访问的内存都在一个内存块里的时候,就会出现合并访问。

对齐合并访问的状态是理想化的,也是最高速的访问方式,当线程束内的所有线程访问的数据在一个内存块,并且数据是从内存块的首地址开始被需要的,那么对齐合并访问出现了。为了最大化全局内存访问的理想状态,尽量将线程束访问内存组织成对齐合并的方式,这样的效率是最高的。下面看一个例子。

  • 一个线程束加载数据,使用一级缓存,并且这个事务所请求的所有数据在一个128字节的对齐的地址段上(对齐的地址段是我自己发明的名字,就是首地址是粒度的偶数倍,那么上面这句话的意思是,所有请求的数据在某个首地址是粒度偶数倍的后128个字节里),具体形式如下图,这里请求的数据是连续的,其实可以不连续,但是不要越界就好。

image-20220910112226266

上面蓝色表示全局内存,下面橙色是线程束要的数据,绿色就是我称为对齐的地址段。

  • 如果一个事务加载的数据分布在不一个对齐的地址段上,就会有以下两种情况:
    • 连续的,但是不在一个对齐的段上,比如,请求访问的数据分布在内存地址1128,那么0127和128~255这两段数据要传递两次到SM
    • 不连续的,也不在一个对齐的段上,比如,请求访问的数据分布在内存地址063和128191上,明显这也需要两次加载。

image-20220910112303270

上图就是典型的一个线程束,数据分散开了,thread0的请求在128之前,后面还有请求在256之后,所以需要三个内存事务,而利用率,也就是从主存取回来的数据被使用到的比例,只有 1/3 的比例。这个比例低会造成带宽的浪费,最极端的表现,就是如果每个线程的请求都在不同的段,也就是一个128字节的事务只有1个字节是有用的,那么利用率只有 1/128

全局内存读取

注意我们说的都是读取,也就是加载过程,写或者叫做存储是另外一回事!SM加载数据,根据不同的设备和类型分为三种路径:

  1. 一级和二级缓存
  2. 常量缓存
  3. 只读缓存

常规的路径是一级和二级缓存,需要使用常量和只读缓存的需要在代码中显式声明。但是提高性能,主要还是要取决于访问模式。

控制全局加载操作是否通过一级缓存可以通过编译选项来控制,当然比较老的设备可能就没有一级缓存。

编译器禁用一级缓存的选项是:

1
-Xptxas -dlcm=cg

编译器启用一级缓存的选项是:

1
-Xptxas -dlcm=ca

当一级缓存被禁用的时候,对全局内存的加载请求直接进入二级缓存,如果二级缓存缺失,则由DRAM完成请求。

每次内存事务可由一个两个或者四个部分执行,每个部分有32个字节,也就是32,64或者128字节一次(注意前面我们讲到是否使用一级缓存决定了读取粒度是128还是32字节,这里增加的64并不在此情况,所以需要注意)。

启用一级缓存后,当SM有全局加载请求会首先通过尝试一级缓存,如果一级缓存缺失,则尝试二级缓存,如果二级缓存也没有,那么直接DRAM。

在有些设备上一级缓存不用来缓存全局内存访问,而是只用来存储寄存器溢出的本地数据,比如Kepler 的K10,K20。

内存加载可以分为两类:

  • 缓存加载
  • 没有缓存的加载

内存访问有以下特点:

  • 是否使用缓存:一级缓存是否介入加载过程
  • 对齐与非对齐的:如果访问的第一个地址是32的倍数(前面说是32或者128的偶数倍,这里似乎产生了矛盾,为什么我现在也很迷惑)
  • 合并与非合并,访问连续数据块则是合并的

缓存加载

下面是使用一级缓存的加载过程,图片表达很清楚,我们只用少量文字进行说明:

  1. 对齐合并的访问,利用率100%

image-20220910113212684

  1. 对齐的,但是不是连续的,每个线程访问的数据都在一个块内,但是位置是交叉的,利用率100%

image-20220910113231769

  1. 连续非对齐的,线程束请求一个连续的非对齐的,32个4字节数据,那么会出现,数据横跨两个块,但是没有对齐,当启用一级缓存的时候,就要两个128字节的事务来完成

image-20220910113248353

  1. 线程束所有线程请求同一个地址,那么肯定落在一个缓存行范围(缓存行的概念没提到过,就是主存上一个可以被一次读到缓存中的一段数据。),那么如果按照请求的是4字节数据来说,使用一级缓存的利用率是 4/128=3.125%

image-20220910113311093

  1. 比较坏的情况,前面提到过最坏的,就是每个线程束内的线程请求的都是不同的缓存行内,这里比较坏的情况就是,所有数据分布在 N 个缓存行上,其中 1≤N≤32,那么请求32个4字节的数据,就需要 N 个事务来完成,利用率也是 1/N

image-20220910113413684

CPU和GPU的一级缓存有显著的差异,GPU的一级缓存可以通过编译选项等控制,CPU不可以,而且CPU的一级缓存是的替换算法是有使用频率和时间局部性的,GPU则没有。

没有缓存的加载

没有缓存的加载是指的没有通过一级缓存,二级缓存则是不得不经过的。

当不使用一级缓存的时候,内存事务的粒度变为32字节,更细粒度的好处是提高利用律。

  1. 对齐合并访问128字节,不用说,还是最理想的情况,使用4个段,利用率 100%

    image-20220910113606985

  2. 对齐不连续访问128字节,都在四个段内,且互不相同,这样的利用率也是 100%
    image-20220910113619337

  3. 连续不对齐,一个段32字节,所以,一个连续的128字节的请求,即使不对齐,最多也不会超过五个段,所以利用率是 45=80%45=80% ,如果不明白为啥不能超过5个段,请注意前提是连续的,这个时候不可能超过五段
    image-20220910113636979

  4. 所有线程访问一个4字节的数据,那么此时的利用率是 432=12.5%432=12.5%
    image-20220910113651178

  5. 最坏的情况,所有目标数据分散在内存的各个角落,那么需要 N 个内存段, 此时与使用一级缓存的作比较也是有优势的因为 N×128 还是要比 N×32 大不少,这里假设 N 不会因为 128 还是 32 而变的,而实际情况,当使用大粒度的缓存行的时候, N 有可能会减小
    image-20220910113706123

非对齐读取示例

下面就非对齐读取进行演示,
代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#include <cuda_runtime.h>
#include <stdio.h>
#include "freshman.h"


void sumArrays(float * a,float * b,float * res,int offset,const int size)
{

for(int i=0,k=offset;k<size;i++,k++)
{
res[i]=a[k]+b[k];
}

}
__global__ void sumArraysGPU(float*a,float*b,float*res,int offset,int n)
{
//int i=threadIdx.x;
int i=blockIdx.x*blockDim.x+threadIdx.x;
int k=i+offset;
if(k<n)
res[i]=a[k]+b[k];
}
int main(int argc,char **argv)
{
int dev = 0;
cudaSetDevice(dev);

int nElem=1<<18;
int offset=0;
if(argc>=2)
offset=atoi(argv[1]);
printf("Vector size:%d\n",nElem);
int nByte=sizeof(float)*nElem;
float *a_h=(float*)malloc(nByte);
float *b_h=(float*)malloc(nByte);
float *res_h=(float*)malloc(nByte);
float *res_from_gpu_h=(float*)malloc(nByte);
memset(res_h,0,nByte);
memset(res_from_gpu_h,0,nByte);

float *a_d,*b_d,*res_d;
CHECK(cudaMalloc((float**)&a_d,nByte));
CHECK(cudaMalloc((float**)&b_d,nByte));
CHECK(cudaMalloc((float**)&res_d,nByte));
CHECK(cudaMemset(res_d,0,nByte));
initialData(a_h,nElem);
initialData(b_h,nElem);

CHECK(cudaMemcpy(a_d,a_h,nByte,cudaMemcpyHostToDevice));
CHECK(cudaMemcpy(b_d,b_h,nByte,cudaMemcpyHostToDevice));

dim3 block(1024);
dim3 grid(nElem/block.x);
double iStart,iElaps;
iStart=cpuSecond();
sumArraysGPU<<<grid,block>>>(a_d,b_d,res_d,offset,nElem);
cudaDeviceSynchronize();
iElaps=cpuSecond()-iStart;
CHECK(cudaMemcpy(res_from_gpu_h,res_d,nByte,cudaMemcpyDeviceToHost));
printf("Execution configuration<<<%d,%d>>> Time elapsed %f sec --offset:%d \n",grid.x,block.x,iElaps,offset);


sumArrays(a_h,b_h,res_h,offset,nElem);

checkResult(res_h,res_from_gpu_h,nElem);
cudaFree(a_d);
cudaFree(b_d);
cudaFree(res_d);

free(a_h);
free(b_h);
free(res_h);
free(res_from_gpu_h);

return 0;
}

编译指令:

1
tony@tony-Lenovo:~/Project/CUDA_Freshman/18_sum_array_offset$ nvcc -O3 -arch=sm_35 -Xptxas -dlcm=cg -I ../include/ sum_array_offset.cu -o sum_array_offset

只读缓存

只读缓存最初是留给纹理内存加载用的,在3.5以上的设备,只读缓存也支持使用全局内存加载代替一级缓存。也就是说3.5以后的设备,可以通过只读缓存从全局内存中读数据了。

只读缓存粒度32字节,对于分散读取,细粒度优于一级缓存

有两种方法指导内存从只读缓存读取:

  1. 使用函数 _ldg
  2. 在间接引用的指针上使用修饰符

代码:

1
2
3
4
5
6
__global__ void copyKernel(float * in,float* out)
{
int idx=blockDim*blockIdx.x+threadIdx.x;
out[idx]=__ldg(&in[idx]);

}

注意函数参数,然后就能强制使用只读缓存了。

核函数可达到的带宽

内存延迟是影响核函数的一大关键,内存延迟,也就是从你发起内存请求到数据进入SM的寄存器的整个时间。内存带宽,也就是SM访问内存的速度,它以单位时间内传输的字节数进行测量。上一节我们用了两种方法改善内核性能:

  • 最大化线程束的数量来隐藏内存延迟,维持更多的正在执行的内存访问达到更好的总线利用率
  • 通过适当的对齐和合并访问,提高带宽效率

内存带宽

多数内核对带宽敏感,也就是说,工人们生产效率特别高,而原料来的很慢,这限制了生产速度。去哪聚内存中数据的安排方式和线程束的访问方式都对带宽有显著影响。一般有如下两种带宽

  • 理论带宽
  • 有效带宽

理论带宽就是硬件设计的绝对最大值,硬件限制了这个最大值为多少,比如对于不使用ECC的Fermi M2090来说,理论峰值 117.6 GB/s。有效带宽是核函数实际达到的带宽,是测量带宽,可以用下面公式计算:

有效带宽=(读字节数+写字节数)×10−9运行时间(1)(1)有效带宽=(读字节数+写字节数)×10−9运行时间

注意吞吐量和带宽的区别,吞吐量是衡量计算核心效率的,用的单位是每秒多少十亿次浮点运算(gflops),有效吞吐量其不止和有效带宽有关,还和带宽的利用率等因素有关,当然最主要的还是设备的运算核心。

当然,也有内存吞吐量这种说法这种说法就是单位时间上内存访问的总量,用单位 GB/s 表示,这个值越大表示读取到的数据越多,但是这些数据不一定是有用的。

矩阵转置问题

矩阵转置就是交换矩阵的坐标,我们本文研究有二维矩阵,转置结果如下:

使用串行编程很容易实现:

1
2
3
4
5
6
7
8
9
10
void transformMatrix2D_CPU(float * MatA,float * MatB,int nx,int ny)
{
for(int j=0;j<ny;j++)
{
for(int i=0;i<nx;i++)
{
MatB[i*nx+j]=MatA[j*nx+i];
}
}
}

这段代码应该比较容易懂,这是串行解决的方法,必须要注意的是,我们所有的数据,结构体也好,数组也好,多维数组也好,所有的数据,在内存硬件层面都是一维排布的,所以我们这里也是使用一维的数组作为输入输出,那么从真实的角度看内存中的数据就是下面这样的:

image-20220910144503244

转置操作:

  • 读:原矩阵行进行读取,请求的内存是连续的,可以进行合并访问
  • 写:写到转置矩阵的列中,访问是交叉的

图中的颜色需要大家注意一下,读的过程同一颜色可以看成是合并读取的,但是转置发生后写入的过程,是交叉的。

如果按照我们上文的观点,如果按照下面两种方法进行读

image-20220910144646230

最初的想法肯定是:按照图一合并读更有效率,因为写的时候不需要经过一级缓存,所以对于有一级缓存的程序,合并的读取应该是更有效率的。如果你这么想,恭喜你,你想的不对(我当时也是这么想的)。

我们需要补充下关于一级缓存的作用,上文我们讲到合并,可能第一印象就是一级缓存是缓冲从全局内存里过来的数据一样,但是我们忽略了一些东西,就是内存发起加载请求的时候,会现在一级缓存里看看有没有这个数据,如果有,这个就是一个命中,这和CPU的缓存运行原理是一样的,如果命中了,就不需要再去全局内存读了,如果用在上面这个例子,虽然按照列读是不合并的,但是使用一级缓存加载过来的数据在后面会被使用,我们必须要注意虽然,一级缓存一次读取128字节的数据,其中只有一个单位是有用的,但是剩下的并不会被马上覆盖,粒度是128字节,但是一级缓存的大小有几k或是更大,这些数据很有可能不会被替换,所以,我们按列读取数据,虽然第一行只用了一个,但是下一列的时候,理想情况是所有需要读取的元素都在一级缓存中,这时候,数据直接从缓存里面读取

为转置核函数设置上限和下限

我们本例子中的瓶颈在交叉访问,所以我们假设没有交叉访问,和全是交叉访问的情况,来给出上限和下限:

  • 行读取,行存储来复制矩阵(上限)
  • 列读取,列存储来复制矩阵(下限)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
__global__ void copyRow(float * MatA,float * MatB,int nx,int ny)
{
int ix=threadIdx.x+blockDim.x*blockIdx.x;
int iy=threadIdx.y+blockDim.y*blockIdx.y;
int idx=ix+iy*nx;
if (ix<nx && iy<ny)
{
MatB[idx]=MatA[idx];
}
}
__global__ void copyCol(float * MatA,float * MatB,int nx,int ny)
{
int ix=threadIdx.x+blockDim.x*blockIdx.x;
int iy=threadIdx.y+blockDim.y*blockIdx.y;
int idx=ix*ny+iy;
if (ix<nx && iy<ny)
{
MatB[idx]=MatA[idx];
}
}

我们使用命令行编译,开启一级缓存:

1
nvcc -O3 -arch=sm_35 -Xptxas -dlcm=ca -I ../include/ transform_matrix2D.cu -o transform_matrix2D

可以得到:

核函数 试验1 试验2 试验3 平均值
上限 0.001611 0.001614 0.001606 0.001610
下限 0.004191 0.004210 0.004205 0.004202

这个时间是三次测试出来的平均值,基本可以肯定在当前数据规模下,上限在0.001610s,下限在0.004202s

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
int main(int argc,char** argv)
{
printf("strating...\n");
initDevice(0);
int nx=1<<12;
int ny=1<<12;
int nxy=nx*ny;
int nBytes=nxy*sizeof(float);
int transform_kernel=0;
if(argc>=2)
transform_kernel=atoi(argv[1]);
//Malloc
float* A_host=(float*)malloc(nBytes);
float* B_host=(float*)malloc(nBytes);
initialData(A_host,nxy);

//cudaMalloc
float *A_dev=NULL;
float *B_dev=NULL;
CHECK(cudaMalloc((void**)&A_dev,nBytes));
CHECK(cudaMalloc((void**)&B_dev,nBytes));

CHECK(cudaMemcpy(A_dev,A_host,nBytes,cudaMemcpyHostToDevice));
CHECK(cudaMemset(B_dev,0,nBytes));

int dimx=32;
int dimy=32;

// cpu compute
double iStart=cpuSecond();
transformMatrix2D_CPU(A_host,B_host,nx,ny);
double iElaps=cpuSecond()-iStart;
printf("CPU Execution Time elapsed %f sec\n",iElaps);

// 2d block and 2d grid
dim3 block(dimx,dimy);
dim3 grid((nx-1)/block.x+1,(ny-1)/block.y+1);
dim3 block_1(dimx,dimy);
dim3 grid_1((nx-1)/(block_1.x*4)+1,(ny-1)/block_1.y+1);
iStart=cpuSecond();
switch(transform_kernel)
{
case 0:
copyRow<<<grid,block>>>(A_dev,B_dev,nx,ny);
break;
case 1:
copyCol<<<grid,block>>>(A_dev,B_dev,nx,ny);
break;
case 2:
transformNaiveRow<<<grid,block>>>(A_dev,B_dev,nx,ny);
break;
case 3:
transformNaiveCol<<<grid,block>>>(A_dev,B_dev,nx,ny);
break;
case 4:
transformNaiveColUnroll<<<grid_1,block_1>>>(A_dev,B_dev,nx,ny);
break;
case 5:
transformNaiveColUnroll<<<grid_1,block_1>>>(A_dev,B_dev,nx,ny);
break;
case 6:
transformNaiveRowDiagonal<<<grid,block>>>(A_dev,B_dev,nx,ny);
break;
case 7:
transformNaiveColDiagonal<<<grid,block>>>(A_dev,B_dev,nx,ny);
break;
default:
break;
}
CHECK(cudaDeviceSynchronize());
iElaps=cpuSecond()-iStart;
printf(" Time elapsed %f sec\n",iElaps);
CHECK(cudaMemcpy(B_host,B_dev,nBytes,cudaMemcpyDeviceToHost));
checkResult(B_host,B_host,nxy);

cudaFree(A_dev);
cudaFree(B_dev);
free(A_host);
free(B_host);
cudaDeviceReset();
return 0;
}

展开转置:读取行与读取列

接下来这个是老套路了,有效地隐藏延迟,从展开操作开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
__global__ void transformNaiveRowUnroll(float * MatA,float * MatB,int nx,int ny)
{
int ix=threadIdx.x+blockDim.x*blockIdx.x*4;
int iy=threadIdx.y+blockDim.y*blockIdx.y;
int idx_row=ix+iy*nx;
int idx_col=ix*ny+iy;
if (ix<nx && iy<ny)
{
MatB[idx_col]=MatA[idx_row];
MatB[idx_col+ny*1*blockDim.x]=MatA[idx_row+1*blockDim.x];
MatB[idx_col+ny*2*blockDim.x]=MatA[idx_row+2*blockDim.x];
MatB[idx_col+ny*3*blockDim.x]=MatA[idx_row+3*blockDim.x];
}
}
__global__ void transformNaiveColUnroll(float * MatA,float * MatB,int nx,int ny)
{
int ix=threadIdx.x+blockDim.x*blockIdx.x*4;
int iy=threadIdx.y+blockDim.y*blockIdx.y;
int idx_row=ix+iy*nx;
int idx_col=ix*ny+iy;
if (ix<nx && iy<ny)
{
MatB[idx_row]=MatA[idx_col];
MatB[idx_row+1*blockDim.x]=MatA[idx_col+ny*1*blockDim.x];
MatB[idx_row+2*blockDim.x]=MatA[idx_col+ny*2*blockDim.x];
MatB[idx_row+3*blockDim.x]=MatA[idx_col+ny*3*blockDim.x];
}
}

使用统一内存的向量加法

统一内存矩阵加法

统一内存的基本思路就是减少指向同一个地址的指针,比如我们经常见到的,在本地分配内存,然后传输到设备,然后在从设备传输回来,使用统一内存,就没有这些显式的需求了,而是驱动程序帮我们完成。具体的做法就是:

1
2
3
CHECK(cudaMallocManaged((float**)&a_d,nByte));
CHECK(cudaMallocManaged((float**)&b_d,nByte));
CHECK(cudaMallocManaged((float**)&res_d,nByte));

使用cudaMallocManaged来分配内存,这种内存在表面上看在设备和主机端都能访问,但是内部过程和我们前面手动copy过来copy过去是一样的,也就是memcopy是本质,而这个只是封装了一下。

我们来看看完整的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include <cuda_runtime.h>
#include <stdio.h>

void sumArrays(float * a,float * b,float * res,const int size)
{
for(int i=0;i<size;i+=4)
{
res[i]=a[i]+b[i];
res[i+1]=a[i+1]+b[i+1];
res[i+2]=a[i+2]+b[i+2];
res[i+3]=a[i+3]+b[i+3];
}
}
__global__ void sumArraysGPU(float*a,float*b,float*res,int N)
{
int i=blockIdx.x*blockDim.x+threadIdx.x;
if(i < N)
res[i]=a[i]+b[i];
}
int main(int argc,char **argv)
{
// set up device
initDevice(0);

int nElem=1<<24;
printf("Vector size:%d\n",nElem);
int nByte=sizeof(float)*nElem;
float *res_h=(float*)malloc(nByte);
memset(res_h,0,nByte);
memset(res_from_gpu_h,0,nByte);

float *a_d,*b_d,*res_d;
CHECK(cudaMallocManaged((float**)&a_d,nByte));
CHECK(cudaMallocManaged((float**)&b_d,nByte));
CHECK(cudaMallocManaged((float**)&res_d,nByte));

initialData(a_d,nElem);
initialData(b_d,nElem);

//CHECK(cudaMemcpy(a_d,a_h,nByte,cudaMemcpyHostToDevice));
//CHECK(cudaMemcpy(b_d,b_h,nByte,cudaMemcpyHostToDevice));

dim3 block(512);
dim3 grid((nElem-1)/block.x+1);

sumArraysGPU<<<grid,block>>>(a_d,b_d,res_d,nElem);
cudaDeviceSynchronize();

//CHECK(cudaMemcpy(res_from_gpu_h,res_d,nByte,cudaMemcpyDeviceToHost));
sumArrays(b_d,b_d,res_h,nElem);

checkResult(res_h,res_d,nElem);
cudaFree(a_d);
cudaFree(b_d);
cudaFree(res_d);

free(res_h);

return 0;
}

注意我们注释掉的,这就是省去的代码部分。

共享内存和常量内存

共享内存

共享内存(shared memory,SMEM)是GPU的一个关键部分,物理层面,每个SM都有一个小的内存池,这个线程池被次SM上执行的线程块中的所有线程所共享。共享内存使同一个线程块中可以相互协同,便于片上的内存可以被最大化的利用,降低回到全局内存读取的延迟。
共享内存是被我们用代码控制的,这也是是他称为我们手中最灵活的优化武器。

结合我们前面学习的一级缓存,二级缓存,今天的共享内存,以及后面的只读和常量缓存,他们的关系如下图:

image-20220910151624832

SM上有共享内存,L1一级缓存,ReadOnly 只读缓存,Constant常量缓存。所有从Dram全局内存中过来的数据都要经过二级缓存,相比之下,更接近SM计算核心的SMEM,L1,ReadOnly,Constant拥有更快的读取速度,SMEM和L1相比于L2延迟低大概20~30倍,带宽大约是10倍。

共享内存是在他所属的线程块被执行时建立,线程块执行完毕后共享内存释放,线程块和他的共享内存有相同的生命周期。

对于每个线程对共享内存的访问请求

  1. 最好的情况是当前线程束中的每个线程都访问一个不冲突的共享内存,具体是什么样的我们后面再说,这种情况,大家互不干扰,一个事务完成整个线程束的访问,效率最高
  2. 当有访问冲突的时候,具体怎么冲突也要后面详细说,这时候一个线程束32个线程,需要32个事务。
  3. 如果线程束内32个线程访问同一个地址,那么一个线程访问完后以广播的形式告诉大家

注意我们刚才说的共享内存的生命周期是和其所属的线程块相同的,这个共享内存是编程模型层面上的。物理层面上,一个SM上的所有的正在执行的线程块共同使用物理的共享内存,所以共享内存也成为了活跃线程块的限制,共享内存越大,或者块使用的共享内存越小,那么线程块级别的并行度就越高。

共享内存分配

分配和定义共享内存的方法有多种,动态的声明,静态的声明都是可以的。可以在核函数内,也可以在核函数外(也就是本地的和全局的,这里是说变量的作用域,在一个文件中),CUDA支持1,2,3维的共享内存声明。

声明共享内存通过关键字:

1
__shared__

声明一个二维浮点数共享内存数组的方法是:

1
__shared__ float a[size_x][size_y];

这里的size_xsize_y和声明c++数组一样,要是一个编译时确定的数字,不能是变量。如果想动态声明一个共享内存数组,可以使用extern关键字,并在核函数启动时添加第三个参数。声明:

1
extern __shared__ int tile[];

在执行上面这个声明的核函数时,使用下面这种配置:

1
kernel<<<grid,block,isize*sizeof(int)>>>(...);

isize就是共享内存要存储的数组的大小。比如一个十个元素的int数组,isize就是10。

共享内存存储体和访问模式

内存存储体

共享内存是一个一维的地址空间,注意这句话的意思是,共享内存的地址是一维的,也就是和所有我们前面提到过的内存一样,都是线性的,二维三维更多维的地址都要转换成一维的来对应物理上的内存地址。

共享内存有个特殊的形式是,分为32个同样大小的内存模型,称为存储体,可以同时访问。32个存储体的目的是对应一个线程束中有32个线程,这些线程在访问共享内存的时候,如果都访问不同存储体(无冲突),那么一个事务就能够完成,否则(有冲突)需要多个内存事务了,这样带宽利用率降低。

存储体冲突

当多个线程要访问一个存储体的时候,冲突就发生了,注意这里是说访问同一个存储体,而不是同一个地址,访问同一个地址不存在冲突(广播形式)。当发生冲突就会有等待和更多的事务产生,这是严重影响效率的。线程束访问共享内存的时候有下面3种经典模式:

  1. 并行访问,多地址访问多存储体
  2. 串行访问,多地址访问同一存储体
  3. 广播访问,单一地址读取单一存储体

并行访问是最常见,也是效率较高的一种,但是也可以分为完全无冲突,和小部分冲突的情况,完全无冲突是理想模式,线程束中所有线程通过一个内存事务完成自己的需求,互不干扰,效率最高,当有小部分冲突的时候,大部分不冲突的部分可以通过一个内存事务完成,冲突的被分割成另外的不冲突的事务被执行,这样效率稍低。

上面的小部分冲突变成完全冲突就是串行模式了,这是最糟糕的形式,所有线程访问同一个存储体,注意不是同一个地址,是同一个存储体,一个存储体有很多地址。这时就是串行访问。

广播访问是所有线程访问一个地址,这时候,一个内存事务执行完毕后,一个线程得到了这个地址的数据,他会通过广播的形式告诉其他所有线程,虽然这个延迟相比于完全的并行访问并不慢,但是他只读取了一个数据,带宽利用率很差。

最优访问模式(并行不冲突):

image-20220910152219591

不规则的访问模式(并行不冲突):

image-20220910152231251

不规则的访问模式(并行可能冲突,也可能不冲突)

image-20220910152242868

这时候又两种可能

  1. 冲突:这时候就要等待了
  2. 不冲突:访问同一个存储体的线程都要访问同一个地址,通过广播解决问题。

以上就是产生冲突的根本原因,我们通过调整数据,代码,算法,最好规避冲突,提高性能。

访问模式

共享内存的存储体和地址有什么关系呢?这个关系决定了访问模式。内存存储体的宽度随设备计算能力不同而变化,有以下两种情况:

  1. 2.x计算能力的设备,为4字节(32位)
  2. 3.x计算能力的设备,为8字节(64位)

image-20220910152701764

同一个线程束中的两个线程访问同一个地址不会发生冲突,一个线程读取后广播告诉有相同需求的线程。但是对于写入,这个就不确定了,结果不可预料。

我们之前一次只能取四个西瓜,现在可以取八个西瓜了,这时候如果有两个线程访问同一个存储体,按照我们前面的解释,一种是访问同一个地址,这时候通过广播来解决冲突,还有一种冲突是需要用等待解决的,当桶变宽了,如果一个线程想要桶里左边的西瓜,而一个线程想要右边的西瓜,这时候是不冲突的,因为桶是够宽的。

或者我们可以理解为更宽的桶,在桶中间又进行了一次间隔,左右两边各一个空间,读取不影响,如果两个线程都要左边的西瓜则等待,如果一个要左边的一个要右边的,这时候可以同时进行不冲突。

把桶换成存储体就是

image-20220910154608530

下图显示64位宽的存储体无冲突访问的一种情况,每个bank被划分成了两部分

image-20220910154618890

下图是另一种无冲突方式:

image-20220910154630133

一种冲突方式,两个线程访问同一个小桶:

image-20220910154641152

另一种冲突方式,三个线程访问同一个小桶

image-20220910154652275

内存填充

存储体冲突会严重影响共享内存的效率,那么当我们遇到严重冲突的情况下,可以使用填充的办法让数据错位,来降低冲突。假如我们当前存储体内的数据罗列如下,这里假设共4个存储体,实际是32个

image-20220910154710457

当我们的线程束访问bank0中的不同数据的时候就会发生一个5线程的冲突,这时候我们假如我们分配内存时候的声明是:

1
__shared__ int a[5][4];

这时候我们的就会得到上面的图中的这种内存布局,但是当我们声明的时候改成

1
__shared__ int a[5][5];

就会产生这个效果,在编程时候加入一行填充物

image-20220910154956112

然后编译器会将这个二维数组重新分配到存储体,因为存储体一共就4个,我们每一行有5个元素,所以有一个元素进入存储体的下一行,这样,所有元素都错开了,就不会出现冲突了。

image-20220910155010929

共享内存在确定大小的时候,比如编译的时候,就已经被确定好每个地址在哪个存储体中了,想要改变分布,就在声明共享内存的时候调整就行,跟将要存储到共享内存中的数据没有关系。

注意:共享内存声明时,就决定了每个地址所在的存储体,想要调整每个地址对应的存储体,就要扩大声明的共享内存的大小,至于扩大多少,就要根据我们前面的公式好好计算了。这段是本文较难理解的一段。

访问模式配置

访问模式查询:可以通过以下语句,查询是4字节还是8字节:

1
cudaError_t cudaDeviceGetSharedMemConfig(cudaSharedMemConfig * pConfig);

返回的pConfig可以是下面的结果:

1
2
cudaSharedMemBankSizeFourByte
cudaSharedMemBankSizeEightByte

在可以配置的设备上,可以用下面函数来配置新的存储体大小:

1
cudaError_t cudaDeviceSetShareMemConfig(cudaSharedMemConfig config);

其中 config可以是:

1
2
3
cudaSharedMemBankSizeDefault
cudaSharedMemBankSizeFourByte
cudaSharedMemBankSizeEightByte

不同的核函数启动之间,更改共享内存的配置,可能需要一个隐式的设备同步点,更改共享内存存储体的大小不会增加共享内存的使用,也不会影响内核函数的占用率,但其对性能可能有重大的影响。大的存储体可能有更高的带宽,大可能导致更多的冲突,要根据具体情况进行分析。

配置共享内存

每个SM上有64KB的片上内存,共享内存和L1共享这64KB,并且可以配置。CUDA为配置一级缓存和共享内存提供以下两种方法:

  1. 按设备进行配置
  2. 按核函数进行配置

配置函数:

1
cudaError_t cudaDeviceSetCacheConfig(cudaFuncCache cacheConfig);

其中配置参数如下:

1
2
3
4
cudaFuncCachePreferNone: no preference(default)
cudaFuncCachePreferShared: prefer 48KB shared memory and 16 KB L1 cache
cudaFuncCachePreferL1: prefer 48KB L1 cache and 16 KB shared memory
cudaFuncCachePreferEqual: prefer 32KB L1 cache and 32 KB shared memory

那种更好全看核函数:

  1. 共享内存使用较多,那么更多的共享内存更好
  2. 更多的寄存器使用,L1更多更好。

另一个函数是通过不同核函数自动配置的。

1
cudaError_t cudaFuncSetCacheConfig(const void* func,enum cudaFuncCacheca cheConfig);

这里的func是核函数指针,当我们调用某个核函数时,次核函数已经配置了对应的L1和共享内存,那么其如果和当前配置不同,则会重新配置,否则直接执行。
一级缓存和共享内存都在同一个片上,但是行为大不相同,共享内存靠的的是存储体来管理数据,而L1则是通过缓存行进行访问。我们对共享内存有绝对的控制权,但是L1的删除工作是硬件完成的。

GPU缓存比CPU的更难理解,GPU使用启发式算法删除数据,由于GPU使用缓存的线程更多,所以数据删除更频繁而且不可预知。共享内存则可以很好的被控制,减少不必要的误删造成的低效,保证SM的局部性。

同步

同步是并行的重要机制,其主要目的就是防止冲突。同步基本方法:

  1. 障碍
  2. 内存栅栏

障碍是所有调用线程等待其余调用线程达到障碍点。内存栅栏,所有调用线程必须等到全部内存修改对其余线程可见时才继续进行。

弱排序内存模型

CUDA采用宽松的内存模型,也就是内存访问不一定按照他们在程序中出现的位置进行的。宽松的内存模型,导致了更激进的编译器。

GPU线程在不同的内存,比如SMEM,全局内存,锁页内存或对等设备内存中,写入数据的顺序是不一定和这些数据在源代码中访问的顺序相同,当一个线程的写入顺序对其他线程可见的时候,他可能和写操作被执行的实际顺序不一致。指令之间相互独立,线程从不同内存中读取数据的顺序和读指令在程序中的顺序不一定相同。换句话说,核函数内连续两个内存访问指令,如果独立,其不一定哪个先被执行。

显示障碍

CUDA中,障碍点设置在核函数中,注意这个指令只能在核函数中调用,并只对同一线程块内线程有效。

1
void __syncthreads();
  1. __syncthreads()作为一个障碍点,他保证在同一线程块内所有线程没到达此障碍点时,不能继续向下执行。

  2. 同一线程块内此障碍点之前的所有全局内存,共享内存操作,对后面的线程都是可见的。

  3. 这个也就能解决同一线程块内,内存竞争的问题,同步,保证先后顺序,不会混乱。

  4. 避免死锁情况出现,比如下面这种情况,就会导致内核死锁:

  5. 只能解决一个块内的线程同步,想做块之间的,只能通过核函数的执行和结束来进行块之间的同步。(把要同步的地方作为核函数的结束,来隐式的同步线程块)

    1
    2
    3
    4
    5
    if (threadID % 2 == 0) {
    __syncthreads();
    } else {
    __syncthreads();
    }

内存栅栏

内存栅栏能保证栅栏前的内核内存写操作对栅栏后的其他线程都是可见的,有以下三种栅栏:块,网格,系统。

  1. 线程块内:

    1
    void __threadfence_block();

保证同一块中的其他线程对于栅栏前的内存写操作可见

  1. 网格级内存栅栏

    1
    void __threadfence();

挂起调用线程,直到全局内存中所有写操作对相同的网格内的所有线程可见

  1. 系统级栅栏,夸系统,包括主机和设备,

    1
    void __threadfence_system();

挂起调用线程,以保证该线程对全局内存,锁页主机内存和其他设备内存中的所有写操作对全部设备中的线程和主机线程可见。

Volatile修饰符

volatile声明一个变量,防止编译器优化,防止这个变量存入缓存,如果恰好此时被其他线程改写,那就会造成内存缓存不一致的错误,所以volatile声明的变量始终在全局内存中。

减少全局内存访问

使用共享内存的并行归约

我们首先来回忆全局内存下的,完全展开的归约计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
__global__ void reduceGmem(int * g_idata,int * g_odata,unsigned int n)
{
//set thread ID
unsigned int tid = threadIdx.x;
unsigned int idx = blockDim.x*blockIdx.x+threadIdx.x;
//boundary check
if (tid >= n) return;
//convert global data pointer to the
int *idata = g_idata + blockIdx.x*blockDim.x;

//in-place reduction in global memory
if(blockDim.x>=1024 && tid <512)
idata[tid]+=idata[tid+512];
__syncthreads();
if(blockDim.x>=512 && tid <256)
idata[tid]+=idata[tid+256];
__syncthreads();
if(blockDim.x>=256 && tid <128)
idata[tid]+=idata[tid+128];
__syncthreads();
if(blockDim.x>=128 && tid <64)
idata[tid]+=idata[tid+64];
__syncthreads();
//write result for this block to global mem
if(tid<32)
{
volatile int *vmem = idata;
vmem[tid]+=vmem[tid+32];
vmem[tid]+=vmem[tid+16];
vmem[tid]+=vmem[tid+8];
vmem[tid]+=vmem[tid+4];
vmem[tid]+=vmem[tid+2];
vmem[tid]+=vmem[tid+1];

}

if (tid == 0)
g_odata[blockIdx.x] = idata[0];

}

下面这步是计算当前线程的索引位置:

1
unsigned int idx = blockDim.x*blockIdx.x+threadIdx.x;

当前线程块对应的数据块首地址

1
int *idata = g_idata + blockIdx.x*blockDim.x;

然后是展开循环的部分,tid是当前线程块中线程的标号,主要区别于全局编号idx:

1
2
3
4
5
6
7
8
9
10
11
12
if(blockDim.x>=1024 && tid <512)
idata[tid]+=idata[tid+512];
__syncthreads();
if(blockDim.x>=512 && tid <256)
idata[tid]+=idata[tid+256];
__syncthreads();
if(blockDim.x>=256 && tid <128)
idata[tid]+=idata[tid+128];
__syncthreads();
if(blockDim.x>=128 && tid <64)
idata[tid]+=idata[tid+64];
__syncthreads();

这一步把是当前线程块中的所有数据归约到前64个元素中,接着使用如下代码,将最后64个元素归约成一个

1
2
3
4
5
6
7
8
9
10
if(tid<32)
{
volatile int *vmem = idata;
vmem[tid]+=vmem[tid+32];
vmem[tid]+=vmem[tid+16];
vmem[tid]+=vmem[tid+8];
vmem[tid]+=vmem[tid+4];
vmem[tid]+=vmem[tid+2];
vmem[tid]+=vmem[tid+1];
}

注意这里声明了一个volatile变量,如果我们不这么做,编译器不能保证这些数据读写操作按照代码中的顺序执行,所以必须要这么做。

然后我们对上面的代码进行改写,改写成共享内存的版本,来看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
__global__ void reduceSmem(int * g_idata,int * g_odata,unsigned int n)
{
//set thread ID
__shared__ int smem[DIM];
unsigned int tid = threadIdx.x;
//unsigned int idx = blockDim.x*blockIdx.x+threadIdx.x;
//boundary check
if (tid >= n) return;
//convert global data pointer to the
int *idata = g_idata + blockIdx.x*blockDim.x;

smem[tid]=idata[tid];
__syncthreads();
//in-place reduction in global memory
if(blockDim.x>=1024 && tid <512)
smem[tid]+=smem[tid+512];
__syncthreads();
if(blockDim.x>=512 && tid <256)
smem[tid]+=smem[tid+256];
__syncthreads();
if(blockDim.x>=256 && tid <128)
smem[tid]+=smem[tid+128];
__syncthreads();
if(blockDim.x>=128 && tid <64)
smem[tid]+=smem[tid+64];
__syncthreads();
//write result for this block to global mem
if(tid<32)
{
volatile int *vsmem = smem;
vsmem[tid]+=vsmem[tid+32];
vsmem[tid]+=vsmem[tid+16];
vsmem[tid]+=vsmem[tid+8];
vsmem[tid]+=vsmem[tid+4];
vsmem[tid]+=vsmem[tid+2];
vsmem[tid]+=vsmem[tid+1];

}

if (tid == 0)
g_odata[blockIdx.x] = smem[0];

}

唯一的不同就是多了一个共享内存的声明,以及各线程将全局写入共享内存,以及后面的同步指令:

1
2
smem[tid]=idata[tid];
__syncthreads();

这一步过后同步保证该线程块内的所有线程,都执行到此处后继续向下进行,这是可以理解的,因为我们的归约只针对本块内,当然如果想跨几个块执行,可能同步这里就有问题了,这个是上一节课要讨论的,这里就不过多解释了,我们接着就看到一个volatile类型的指针,指向共享内存,对最后64个归约结果进行归约,整个过程和全局内存一毛一样,只不过一个在全局内存操作,一个在共享内存操作。

使用展开的并行归约

可能看到上面的截图你已经知道我接下来要并行4块了,对于前面说的,使用共享内存不能并行四块,是因为没办法同步读四个块,这里我们还是用老方法进行并行四个块,就是在写入共享内存之前进行归约,4个块变成一个,然后把这一个存入共享内存,进行常规的共享内存归约:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
__global__ void reduceUnroll4Smem(int * g_idata,int * g_odata,unsigned int n)
{
//set thread ID
__shared__ int smem[DIM];
unsigned int tid = threadIdx.x;
unsigned int idx = blockDim.x*blockIdx.x*4+threadIdx.x;
//boundary check
if (tid >= n) return;
//convert global data pointer to the
int tempSum=0;
if(idx+3 * blockDim.x<=n)
{
int a1=g_idata[idx];
int a2=g_idata[idx+blockDim.x];
int a3=g_idata[idx+2*blockDim.x];
int a4=g_idata[idx+3*blockDim.x];
tempSum=a1+a2+a3+a4;

}
smem[tid]=tempSum;
__syncthreads();
//in-place reduction in global memory
if(blockDim.x>=1024 && tid <512)
smem[tid]+=smem[tid+512];
__syncthreads();
if(blockDim.x>=512 && tid <256)
smem[tid]+=smem[tid+256];
__syncthreads();
if(blockDim.x>=256 && tid <128)
smem[tid]+=smem[tid+128];
__syncthreads();
if(blockDim.x>=128 && tid <64)
smem[tid]+=smem[tid+64];
__syncthreads();
//write result for this block to global mem
if(tid<32)
{
volatile int *vsmem = smem;
vsmem[tid]+=vsmem[tid+32];
vsmem[tid]+=vsmem[tid+16];
vsmem[tid]+=vsmem[tid+8];
vsmem[tid]+=vsmem[tid+4];
vsmem[tid]+=vsmem[tid+2];
vsmem[tid]+=vsmem[tid+1];

}

if (tid == 0)
g_odata[blockIdx.x] = smem[0];

}

这段代码就是多了其他三块的求和:

1
2
3
4
5
6
7
8
9
10
11
12
13
unsigned int idx = blockDim.x*blockIdx.x*4+threadIdx.x;
//boundary check
if (tid >= n) return;
//convert global data pointer to the
int tempSum=0;
if(idx+3 * blockDim.x<=n)
{
int a1=g_idata[idx];
int a2=g_idata[idx+blockDim.x];
int a3=g_idata[idx+2*blockDim.x];
int a4=g_idata[idx+3*blockDim.x];
tempSum=a1+a2+a3+a4;
}

这一步在3.5中已经介绍过了为什么能加速了,因为可以通过增加三步计算而减少之前的3个线程块的计算,这是非常大的减少。同时多步内存加载也可以使内存带宽达到更好的使用。

流和并发

流和事件概述

CUDA流:一系列异步CUDA操作,比如我们常见的套路,在主机端分配设备主存(cudaMalloc),主机向设备传输数据(cudaMemcpy),核函数启动,复制数据回主机(Memcpy)这些操作中有些是异步的,执行顺序也是按照主机代码中的顺序执行的(但是异步操作的结束不一定是按照代码中的顺序的)。

流能封装这些异步操作,并保持操作顺序,允许操作在流中排队。保证其在前面所有操作启动之后启动,有了流,我们就能查询排队状态了。

我们上面举得一般情况下的操作基本可以分为以下三种:

  • 主机与设备间的数据传输
  • 核函数启动
  • 其他的由主机发出的设备执行的命令

流中的操作相对于主机来说总是异步的,CUDA运行时决定何时可以在设备上执行操作。我们要做的就是控制这些操作在其结果出来之前,不启动需要调用这个结果的操作。

一个流中的不同操作有着严格的顺序。但是不同流之间是没有任何限制的。多个流同时启动多个内核,就形成了网格级别的并行。CUDA流中排队的操作和主机都是异步的,所以排队的过程中并不耽误主机运行其他指令,所以这就隐藏了执行这些操作的开销。CUDA编程的一个典型模式是,也就是我们上面讲到的一般套路:

  1. 将输入数据从主机复制到设备上
  2. 在设备上执行一个内核
  3. 将结果从设备移回主机

一般的生产情况下,内核执行的时间要长于数据传输,所以我们前面的例子大多是数据传输更耗时,这是不实际的。当重叠核函数执行和数据传输操作,可以屏蔽数据移动造成的时间消耗,当然正在执行的内核的数据需要提前复制到设备上的,这里说的数据传输和内核执行是同时操作的是指当前传输的数据是接下来流中的内核需要的。这样总的执行时间就被缩减了。流在CUDA的API调用可以实现流水线和双缓冲技术。

CUDA的API也分为同步和异步的两种:

  • 同步行为的函数会阻塞主机端线程直到其完成
  • 异步行为的函数在调用后会立刻把控制权返还给主机。

异步行为和流式构建网格级并行的支柱。

虽然我们从软件模型上提出了流,网格级并行的概念,但是说来说去我们能用的就那么一个设备,如果设备空闲当然可以同时执行多个核,但是如果设备已经跑满了,那么我们认为并行的指令也必须排队等待——PCIe总线和SM数量是有限的,当他们被完全占用,流是没办法做什么的,除了等待

我们接下来就要研究多种计算能力的设备上的流是如何运行的。

CUDA流

我们的所有CUDA操作都是在流中进行的,虽然我们可能没发现,但是有我们前面的例子中的指令,内核启动,都是在CUDA流中进行的,只是这种操作是隐式的,所以肯定还有显式的,所以,流分为:

  • 隐式声明的流,我们叫做空流
  • 显式声明的流,我们叫做非空流

如果我们没有特别声明一个流,那么我们的所有操作是在默认的空流中完成的,我们前面的所有例子都是在默认的空流中进行的。
空流是没办法管理的,因为他连个名字都没有,似乎也没有默认名,所以当我们想控制流,非空流是非常必要的。
基于流的异步内核启动和数据传输支持以下类型的粗粒度并发

  • 重叠主机和设备计算
  • 重叠主机计算和主机设备数据传输
  • 重叠主机设备数据传输和设备计算
  • 并发设备计算(多个设备)

CUDA编程和普通的C++不同的就是,我们有两个“可运算的设备”也就是CPU和GPU这两个东西,这种情况下,他们之间的同步并不是每一步指令都互相通信执行进度的,设备不知道主机在干啥,主机也不是完全知道设备在干啥。但是数据传输是同步的,也就是主机要等设备接收完数据才干别的。内核启动就是异步的。异步操作,可以重叠主机计算和设备计算。

前面用的cudaMemcpy就是个同步操作,我们还提到过隐式同步——从设备复制结果数据回主机,要等设备执行完。当然数据传输有异步版本:

1
cudaError_t cudaMemcpyAsync(void* dst, const void* src, size_t count,cudaMemcpyKind kind, cudaStream_t stream = 0);

值得注意的就是最后一个参数,stream表示流,一般情况设置为默认流,这个函数和主机是异步的,执行后控制权立刻归还主机,当然我们需要声明一个非空流:

1
cudaError_t cudaStreamCreate(cudaStream_t* pStream);

这样我们就有一个可以被管理的流了,这段代码是创建了一个流,有C++经验的人能看出来,这个是为一个流分配必要资源的函数,给流命名声明流的操作应该是:

1
cudaStream_t a;

定义了一个叫a的流,但是这个流没法用,相当于只有了名字,资源还是要用cudaStreamCreate分配的。

接下来必须要特别注意:执行异步数据传输时,主机端的内存必须是固定的,非分页的!!

讲内存模型的时候我们说到过,分配方式:

1
2
cudaError_t cudaMallocHost(void **ptr, size_t size);
cudaError_t cudaHostAlloc(void **pHost, size_t size, unsigned int flags);

主机虚拟内存中分配的数据在物理内存中是随时可能被移动的,我们必须确保其在整个生存周期中位置不变,这样在异步操作中才能准确的转移数据,否则如果操作系统移动了数据的物理地址,那么我们的设备可能还是回到之前的物理地址取数据,这就会出现未定义的错误。

在非空流中执行内核需要在启动核函数的时候加入一个附加的启动配置:

1
kernel_name<<<grid, block, sharedMemSize, stream>>>(argument list);

pStream参数就是附加的参数,使用目标流的名字作为参数,比如想把核函数加入到a流中,那么这个stream就变成a。前面我们为一个流分配资源,当然后面就要回收资源,回收方式:

1
cudaError_t cudaStreamDestroy(cudaStream_t stream);

这个回收函数很有意思,由于流和主机端是异步的,你在使用上面指令回收流的资源的时候,很有可能流还在执行,这时候,这条指令会正常执行,但是不会立刻停止流,而是等待流执行完成后,立刻回收该流中的资源。这样做是合理的也是安全的。

当然,我们可以查询流执行的怎么样了,下面两个函数就是帮我们查查我们的流到哪了:

1
2
cudaError_t cudaStreamSynchronize(cudaStream_t stream);
cudaError_t cudaStreamQuery(cudaStream_t stream);

这两条执行的行为非常不同,cudaStreamSynchronize会阻塞主机,直到流完成。cudaStreamQuery则是立即返回,如果查询的流执行完了,那么返回cudaSuccess否则返回cudaErrorNotReady。

下面这段示例代码就是典型多个流中调度CUDA操作的常见模式:

1
2
3
4
5
6
7
8
9
for (int i = 0; i < nStreams; i++) {
int offset = i * bytesPerStream;
cudaMemcpyAsync(&d_a[offset], &a[offset], bytePerStream, streams[i]);
kernel<<grid, block, 0, streams[i]>>(&d_a[offset]);
cudaMemcpyAsync(&a[offset], &d_a[offset], bytesPerStream, streams[i]);
}
for (int i = 0; i < nStreams; i++) {
cudaStreamSynchronize(streams[i]);
}

第一个for中循环执行了nStreams个流,每个流中都是“复制数据,执行核函数,最后将结果复制回主机”这一系列操作。

下面的图就是一个简单的时间轴示意图,假设nStreams=3,所有传输和核启动都是并发的:

image-20220910162611009

H2D是主机到设备的内存传输,D2H是设备到主机的内存传输。显然这些操作没有并发执行,而是错开的,原因是PCIe总线是共享的,当第一个流占据了主线,后来的就一定要等待,等待主线空闲。编程模型和硬件的实际执行时有差距了。

上面同时从主机到设备涉及硬件竞争要等待,如果是从主机到设备和从设备到主机同时发生,这时候不会产生等待,而是同时进行。

内核并发最大数量也是有极限的,不同计算能力的设备不同,Fermi设备支持16路并发,Kepler支持32路并发。设备上的所有资源都是限制并发数量的原因,比如共享内存,寄存器,本地内存,这些资源都会限制最大并发数。

流调度

虚假的依赖关系

在Fermi架构上16路流并发执行但是所有流最终都是在单一硬件上执行的,Fermi只有一个硬件工作队列,所以他们虽然在编程模型上式并行的,但是在硬件执行过程中是在一个队列中(像串行一样)。当要执行某个网格的时候CUDA会检测任务依赖关系,如果其依赖于其他结果,那么要等结果出来后才能继续执行。单一流水线可能会导致虚假依赖关系:

image-20220910162638841

这个图就是虚假依赖的最准确的描述,我们有三个流,流中的操作相互依赖,比如B要等待A的结果,Z要等待Y的结果,当我们把三个流塞到一个队列中,那么我们就会得到紫色箭头的样子,这个硬件队列中的任务可以并行执行,但是要考虑依赖关系,所以,我们按照顺序会这样执行:

  1. 执行A,同时检查B是否有依赖关系,当然此时B依赖于A而A没执行完,所以整个队列阻塞
  2. A执行完成后执行B,同时检查C,发现依赖,等待
  3. B执行完后,执行C同时检查,发现P没有依赖,如果此时硬件有多于资源P开始执行
  4. P执行时检查Q,发现Q依赖P,所以等待

这种一个队列的模式,会产生一种,虽然P依赖B的感觉,虽然不依赖,但是B不执行完,P没办法执行,而所谓并行,只有一个依赖链的头和尾有可能并行,也就是红圈中任务可能并行,而我们的编程模型中设想的并不是这样的。

Hyper-Q技术

解决上面虚假依赖的最好办法就是多个工作队列,这样就从根本上解决了虚假依赖关系,Hyper-Q就是这种技术,32个硬件工作队列同时执行多个流,这就可以实现所有流的并发,最小化虚假依赖:

image-20220910162656480

流的优先级

3.5以上的设备可以给流优先级,也就是优先级高的(数字上更小的,类似于C++运算符优先级)。优先级只影响核函数,不影响数据传输,高优先级的流可以占用低优先级的工作。下面函数创建一个有指定优先级的流

1
cudaError_t cudaStreamCreateWithPriority(cudaStream_t* pStream, unsigned int flags,int priority);

不同的设备有不同的优先级等级,下面函数可以查询当前设备的优先级分布情况:

1
cudaError_t cudaDeviceGetStreamPriorityRange(int *leastPriority, int *greatestPriority);

leastPriority表示最低优先级(整数,远离0);greatestPriority表示最高优先级(整数,数字较接近0);如果设备不支持优先级返回0。

CUDA事件

CUDA事件不同于我们前面介绍的内存事务,不要搞混,事件也是软件层面上的概念。事件的本质就是一个标记,它与其所在的流内的特定点相关联。可以使用时间来执行以下两个基本任务:

  • 同步流执行

  • 监控设备的进展

流中的任意点都可以通过API插入事件以及查询事件完成的函数,只有事件所在流中其之前的操作都完成后才能触发事件完成。默认流中设置事件,那么其前面的所有操作都完成时,事件才出发完成。

事件就像一个个路标,其本身不执行什么功能,就像我们最原始测试c语言程序的时候插入的无数多个printf一样。

创建和销毁

事件的声明如下:

1
cudaEvent_t event;

同样声明完后要分配资源:

1
cudaError_t cudaEventCreate(cudaEvent_t* event);

回收事件的资源

1
cudaError_t cudaEventDestroy(cudaEvent_t event);

如果回收指令执行的时候事件还没有完成,那么回收指令立即完成,当事件完成后,资源马上被回收。

记录事件和计算运行时间

事件的一个主要用途就是记录事件之间的时间间隔。
事件通过下面指令添加到CUDA流:

1
cudaError_t cudaEventRecord(cudaEvent_t event, cudaStream_t stream = 0);

在流中的事件主要左右就是等待前面的操作完成,或者测试指定流中操作完成情况,下面和流类似的事件测试指令(是否出发完成)会阻塞主机线程知道事件被完成。

1
cudaError_t cudaEventSynchronize(cudaEvent_t event);

同样,也有异步版本:

1
cudaError_t cudaEventQuery(cudaEvent_t event);

这个不会阻塞主机线程,而是直接返回结果和stream版本的类似。另一个函数用在事件上的是记录两个事件之间的时间间隔:

1
cudaError_t cudaEventElapsedTime(float* ms, cudaEvent_t start, cudaEvent_t stop);

这个函数记录两个事件start和stop之间的时间间隔,单位毫秒,两个事件不一定是同一个流中。这个时间间隔可能会比实际大一些,因为cudaEventRecord这个函数是异步的,所以加入时间完全不可控,不能保证两个事件之间的间隔刚好是两个事件之间的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// create two events
cudaEvent_t start, stop;
cudaEventCreate(&start);
cudaEventCreate(&stop);
// record start event on the default stream
cudaEventRecord(start);
// execute kernel
kernel<<<grid, block>>>(arguments);
// record stop event on the default stream
cudaEventRecord(stop);
// wait until the stop event completes
cudaEventSynchronize(stop);
// calculate the elapsed time between two events
float time;
cudaEventElapsedTime(&time, start, stop);
// clean up the two events
cudaEventDestroy(start);
cudaEventDestroy(stop);

这段代码显示,我们的事件被插入到空流中,设置两个事件作为标记,然后记录他们之间的时间间隔。cudaEventRecord是异步的,所以间隔不准,这是特别要注意的。

流同步

流分成阻塞流和非阻塞流,在非空流中所有操作都是非阻塞的,所以流启动以后,主机还要完成自己的任务,有时候就可能需要同步主机和流之间的进度,或者同步流和流之间的进度。从主机的角度,CUDA操作可以分为两类:

  • 内存相关操作
  • 内核启动

内核启动总是异步的,虽然某些内存是同步的,但是他们也有异步版本。

前面我们提到了流的两种类型:

  • 异步流(非空流)
  • 同步流(空流/默认流)

没有显式声明的流式默认同步流,程序员声明的流都是异步流,异步流通常不会阻塞主机,同步流中部分操作会造成阻塞,主机等待,什么都不做,直到某操作完成。

非空流并不都是非阻塞的,其也可以分为两种类型:

  • 阻塞流
  • 非阻塞流

虽然正常来讲,非空流都是异步操作,不存在阻塞主机的情况,但是有时候可能被空流中的操作阻塞。如果一个非空流被声明为非阻塞的,那么没人能阻塞他,如果声明为阻塞流,则会被空流阻塞。

有点晕,就是非空流有时候可能需要在运行到一半和主机通信,这时候我们更希望他能被阻塞,而不是不受控制,这样我们就可以自己设定这个流到底受不受控制,也就是是否能被阻塞,下面我们研究如何使用这两种流。

阻塞流和非阻塞流

cudaStreamCreate创建的是阻塞流,意味着里面有些操作会被阻塞,直到空流中默写操作完成。空流不需要显式声明,而是隐式的,他是阻塞的,跟所有阻塞流同步。

下面这个过程很重要:当操作A发布到空流中,A执行之前,CUDA会等待A之前的全部操作都发布到阻塞流中,所有发布到阻塞流中的操作都会挂起,等待,直到在此操作指令之前的操作都完成,才开始执行。

有点复杂,因为这涉及到代码编写的过程和执行的过程,两个过程混在一起说,肯定有点乱,我们来个例子压压惊就好了:

1
2
3
kernel_1<<<1, 1, 0, stream_1>>>();
kernel_2<<<1, 1>>>();
kernel_3<<<1, 1, 0, stream_2>>>();

上面这段代码,有三个流,两个有名字的,一个空流,我们认为stream_1和stream_2是阻塞流,空流是阻塞的,这三个核函数都在阻塞流上执行,具体过程是,kernel_1被启动,控制权返回主机,然后启动kernel_2,但是此时kernel_2 不会并不会马上执行,他会等到kernel_1执行完毕,同理启动完kernel_2 控制权立刻返回给主机,主机继续启动kernel_3,这时候kernel_3 也要等待,直到kernel_2执行完,但是从主机的角度,这三个核都是异步的,启动后控制权马上还给主机。
然后我们就想创建一个非阻塞流,因为我们默认创建的是阻塞版本:

1
cudaError_t cudaStreamCreateWithFlags(cudaStream_t* pStream, unsigned int flags);

第二个参数就是选择阻塞还是非阻塞版本:

1
2
cudaStreamDefault;// 默认阻塞流
cudaStreamNonBlocking: //非阻塞流,对空流的阻塞行为失效。

如果前面的stream_1和stream_2声明为非阻塞的,那么上面的调用方法的结果是三个核函数同时执行。

隐式同步

前面几章核函数计时的时候,我们说过要同步,并且提到过cudaMemcpy 可以隐式同步,也介绍了

1
2
3
cudaDeviceSynchronize;
cudaStreamSynchronize;
cudaEventSynchronize;

这几个也是同步指令,可以用来同步不同的对象,这些是显式的调用的;与上面的隐式不同。
隐式同步的指令其最原始的函数功能并不是同步,所以同步效果是隐式的,这个我们需要非常注意,忽略隐式同步会造成性能下降。所谓同步就是阻塞的意思,被忽视的隐式同步就是被忽略的阻塞,隐式操作常出现在内存操作上,比如:

  • 锁页主机内存分布
  • 设备内存分配
  • 设备内存初始化
  • 同一设备两地址之间的内存复制
  • 一级缓存,共享内存配置修改

这些操作都要时刻小心,因为他们带来的阻塞非常不容易察觉

显式同步

显式同步相比就更加光明磊落了,因为一条指令就一个作用,没啥副作用,常见的同步有:

  • 同步设备
  • 同步流
  • 同步流中的事件
  • 使用事件跨流同步

下面的函数就可以阻塞主机线程,直到设备完成所有操作:

1
cudaError_t cudaDeviceSynchronize(void);

这个函数我们前面常用,但是尽量少用,这个会拖慢效率。然后是流版本的,我们可以同步流,使用下面两个函数:

1
2
cudaError_t cudaStreamSynchronize(cudaStream_t stream);
cudaError_t cudaStreamQuery(cudaStream_t stream);

这两个函数,第一个是同步流的,阻塞主机直到完成,第二个可以完成非阻塞流测试。也就是测试一下这个流是否完成。

我们提到事件,事件的作用就是在流中设定一些标记用来同步,和检查是否执行到关键点位(事件位置),也是用类似的函数

1
2
cudaError_t cudaEventSynchronize(cudaEvent_t event);
cudaError_t cudaEventQuery(cudaEvent_t event);

这两个函数的性质和上面的非常类似。

事件提供了一个流之间同步的方法:

1
cudaError_t cudaStreamWaitEvent(cudaStream_t stream, cudaEvent_t event);

这条命令的含义是,指定的流要等待指定的事件,事件完成后流才能继续,这个事件可以在这个流中,也可以不在,当在不同的流的时候,这个就是实现了跨流同步。

image-20220910164710321

可配置事件

CDUA提供了一种控制事件行为和性能的函数:

1
cudaError_t cudaEventCreateWithFlags(cudaEvent_t* event, unsigned int flags);

其中参数是:

1
2
3
4
cudaEventDefault
cudaEventBlockingSync
cudaEventDisableTiming
cudaEventInterprocess

其中cudaEventBlockingSync指定使用cudaEventSynchronize同步会造成阻塞调用线程。cudaEventSynchronize默认是使用cpu周期不断重复查询事件状态,而当指定了事件是cudaEventBlockingSync的时候,会将查询放在另一个线程中,而原始线程继续执行,直到事件满足条件,才会通知原始线程,这样可以减少CPU的浪费,但是由于通讯的时间,会造成一定的延迟。

cudaEventDisableTiming表示事件不用于计时,可以减少系统不必要的开支也能提升cudaStreamWaitEventcudaEventQuery的效率,cudaEventInterprocess表明可能被用于进程之间的事件

并发内核执行

非空流中的并发内核

我们的核函数是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
__global__ void kernel_1()
{
double sum=0.0;
for(int i=0;i<N;i++)
sum=sum+tan(0.1)*tan(0.1);
}
__global__ void kernel_2()
{
double sum=0.0;
for(int i=0;i<N;i++)
sum=sum+tan(0.1)*tan(0.1);
}
__global__ void kernel_3()
{
double sum=0.0;
for(int i=0;i<N;i++)
sum=sum+tan(0.1)*tan(0.1);
}
__global__ void kernel_4()
{
double sum=0.0;
for(int i=0;i<N;i++)
sum=sum+tan(0.1)*tan(0.1);
}

四个核函数,N是100,tan计算在GPU中应该有优化过的高速版本,但是就算优化,这个也是相对耗时的,足够我们进行观察了。

我们本章主要关注主机代码,下面是创建流的代码:

1
2
3
4
5
cudaStream_t *stream=(cudaStream_t*)malloc(n_stream*sizeof(cudaStream_t));
for(int i=0;i<n_stream;i++)
{
cudaStreamCreate(&stream[i]);
}

首先声明一个流的头结构,是malloc的注意后面要free掉

然后为每个流的头结构分配资源,也就是Create的过程,这样我们就有n_stream个流可以使用了,接着,我们添加核函数到流,并观察运行效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
dim3 block(1);
dim3 grid(1);
cudaEvent_t start,stop;
cudaEventCreate(&start);
cudaEventCreate(&stop);
cudaEventRecord(start);
for(int i=0;i<n_stream;i++)
{
kernel_1<<<grid,block,0,stream[i]>>>();
kernel_2<<<grid,block,0,stream[i]>>>();
kernel_3<<<grid,block,0,stream[i]>>>();
kernel_4<<<grid,block,0,stream[i]>>>();
}
cudaEventRecord(stop);
CHECK(cudaEventSynchronize(stop));
float elapsed_time;
cudaEventElapsedTime(&elapsed_time,start,stop);
printf("elapsed time:%f ms\n",elapsed_time);

这不是完整的代码,这个循环是将每个核函数都放入不同的流之中,也就是假设我们有10个流,那么这10个流中每个流都要按照上面的顺序执行这4个核函数。
注意如果没有

1
cudaEventSynchronize(stop)

nvvp将会无法运行,因为所有这些都是异步操作,不会等到操作完再返回,而是启动后自动把控制权返回主机,如果没有一个阻塞指令,主机进程就会执行完毕推出,这样就跟设备失联了,nvvp也会相应的报错。

使用OpenMP的调度操作

OpenMP是一种非常好用的并行工具,比pthread更加好用,但是没有pthread那么灵活,这里我们不光要让核函数或者设备操作用多个流处理,同时也让主机在多线程下工作,我们尝试使用每个线程来操作一个流:

1
2
3
4
5
6
7
8
9
omp_set_num_thread(n_stream);
#pragma omp parallel
{
int i=omp_get_thread_num();
kernel_1<<<grid,block,0,stream[i]>>>();
kernel_2<<<grid,block,0,stream[i]>>>();
kernel_3<<<grid,block,0,stream[i]>>>();
kernel_4<<<grid,block,0,stream[i]>>>();
}

解释下代码

1
2
omp_set_num_thread(n_stream);
#pragma omp parallel

调用OpenMP的API创建n_stream个线程,然后宏指令告诉编译器下面大括号中的部分就是每个线程都要执行的部分,有点类似于核函数,或者叫做并行单元。

重叠内核执行和数据传输

使用深度优先调度重叠

向量加法的内核我们很熟悉了

1
2
3
4
5
6
7
8
9
10
__global__ void sumArraysGPU(float*a,float*b,float*res,int N)
{
int idx=blockIdx.x*blockDim.x+threadIdx.x;
if(idx < N)
//for delay
{
for(int j=0;j<N_REPEAT;j++)
res[idx]=a[idx]+b[idx];
}
}

我们这一章的重点都不是在核函数上,所以,我们使用这种非常简单的内核函数。但是不同的是,我们使用N_REPEAT进行多次冗余计算,原因是为了延长线程的执行时间,方便nvvp捕捉运行数据。

向量加法的过程是:

  1. 两个输入向量从主机传入内核
  2. 内核运算,计算加法结果
  3. 将结果(一个向量)从设备回传到主机

由于这个问题就是一个一步问题,我们没办法让内核和数据传输重叠,因为内核需要全部的数据,但是,我们如果思考一下,向量加法之所以能够并发执行,因为每一位都互不干扰,那么我们可以把向量分块,然后每一个块都是一个上面的过程,并且A块中的数据只用于A块的内核,而跟B,C,D内核没有关系,于是我们来把整个过程分成 N_SEGMENT 份,也就是 N_SEGMENT 个流分别执行,在主机代码中流的使用如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
cudaStream_t stream[N_SEGMENT];
for(int i=0;i<N_SEGMENT;i++)
{
CHECK(cudaStreamCreate(&stream[i]));
}
cudaEvent_t start,stop;
cudaEventCreate(&start);
cudaEventCreate(&stop);
cudaEventRecord(start,0);
for(int i=0;i<N_SEGMENT;i++)
{
int ioffset=i*iElem;
CHECK(cudaMemcpyAsync(&a_d[ioffset],&a_h[ioffset],nByte/N_SEGMENT,cudaMemcpyHostToDevice,stream[i]));
CHECK(cudaMemcpyAsync(&b_d[ioffset],&b_h[ioffset],nByte/N_SEGMENT,cudaMemcpyHostToDevice,stream[i]));
sumArraysGPU<<<grid,block,0,stream[i]>>>(&a_d[ioffset],&b_d[ioffset],&res_d[ioffset],iElem);
CHECK(cudaMemcpyAsync(&res_from_gpu_h[ioffset],&res_d[ioffset],nByte/N_SEGMENT,cudaMemcpyDeviceToHost,stream[i]));
}
//timer
CHECK(cudaEventRecord(stop, 0));
CHECK(cudaEventSynchronize(stop));

其中和前面唯一有区别的就是

1
2
3
4
5
6
7
8
for(int i=0;i<N_SEGMENT;i++)
{
int ioffset=i*iElem;
CHECK(cudaMemcpyAsync(&a_d[ioffset],&a_h[ioffset],nByte/N_SEGMENT,cudaMemcpyHostToDevice,stream[i]));
CHECK(cudaMemcpyAsync(&b_d[ioffset],&b_h[ioffset],nByte/N_SEGMENT,cudaMemcpyHostToDevice,stream[i]));
sumArraysGPU<<<grid,block,0,stream[i]>>>(&a_d[ioffset],&b_d[ioffset],&res_d[ioffset],iElem);
CHECK(cudaMemcpyAsync(&res_from_gpu_h[ioffset],&res_d[ioffset],nByte/N_SEGMENT,cudaMemcpyDeviceToHost,stream[i]));
}

数据传输使用异步方式,注意异步处理的数据要声明称为固定内存,不能是分页的,如果是分页的可能会出现未知错误。

使用广度优先调度重叠

同样的,我们看完深度优先之后看一下广度优先
代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
for(int i=0;i<N_SEGMENT;i++)
{
int ioffset=i*iElem;
CHECK(cudaMemcpyAsync(&a_d[ioffset],&a_h[ioffset],nByte/N_SEGMENT,cudaMemcpyHostToDevice,stream[i]));
CHECK(cudaMemcpyAsync(&b_d[ioffset],&b_h[ioffset],nByte/N_SEGMENT,cudaMemcpyHostToDevice,stream[i]));
}
for(int i=0;i<N_SEGMENT;i++)
{
int ioffset=i*iElem;
sumArraysGPU<<<grid,block,0,stream[i]>>>(&a_d[ioffset],&b_d[ioffset],&res_d[ioffset],iElem);
}
for(int i=0;i<N_SEGMENT;i++)
{
int ioffset=i*iElem;
CHECK(cudaMemcpyAsync(&res_from_gpu_h[ioffset],&res_d[ioffset],nByte/N_SEGMENT,cudaMemcpyDeviceToHost,stream[i]));
}

矩阵乘法实例

我们再实现一个稍微复杂一些的例子,就是两个矩阵的乘法,设输入矩阵为 A 和 B ,要得到 C=A×B 。实现思路是每个线程计算 C 的一个元素值 Ci,j ,对于矩阵运算,应该选用grid和block为2-D的。首先定义矩阵的结构体:

1
2
3
4
5
6
7
// 矩阵类型,行优先,M(row, col) = *(M.elements + row * M.width + col)
struct Matrix
{
int width;
int height;
float *elements;
};

image-20220910174756825

然后实现矩阵乘法的核函数,这里我们定义了两个辅助的__device__函数分别用于获取矩阵的元素值和为矩阵元素赋值,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 获取矩阵A的(row, col)元素
__device__ float getElement(Matrix *A, int row, int col)
{
return A->elements[row * A->width + col];
}

// 为矩阵A的(row, col)元素赋值
__device__ void setElement(Matrix *A, int row, int col, float value)
{
A->elements[row * A->width + col] = value;
}

// 矩阵相乘kernel,2-D,每个线程计算一个元素
__global__ void matMulKernel(Matrix *A, Matrix *B, Matrix *C)
{
float Cvalue = 0.0;
int row = threadIdx.y + blockIdx.y * blockDim.y;
int col = threadIdx.x + blockIdx.x * blockDim.x;
for (int i = 0; i < A->width; ++i)
{
Cvalue += getElement(A, row, i) * getElement(B, i, col);
}
setElement(C, row, col, Cvalue);
}

最后我们采用统一内存编写矩阵相乘的测试实例。CUDA 6.0引入统一内存,使用一个托管内存来共同管理host和device中的内存,并且自动在host和device中进行数据传输。CUDA中使用cudaMallocManaged函数分配托管内存:

1
cudaError_t cudaMallocManaged(void **devPtr, size_t size, unsigned int flag=0);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
int main()
{
int width = 1 << 10;
int height = 1 << 10;
Matrix *A, *B, *C;
// 申请托管内存
cudaMallocManaged((void**)&A, sizeof(Matrix));
cudaMallocManaged((void**)&B, sizeof(Matrix));
cudaMallocManaged((void**)&C, sizeof(Matrix));
int nBytes = width * height * sizeof(float);
cudaMallocManaged((void**)&A->elements, nBytes);
cudaMallocManaged((void**)&B->elements, nBytes);
cudaMallocManaged((void**)&C->elements, nBytes);

// 初始化数据
A->height = height;
A->width = width;
B->height = height;
B->width = width;
C->height = height;
C->width = width;
for (int i = 0; i < width * height; ++i)
{
A->elements[i] = 1.0;
B->elements[i] = 2.0;
}

// 定义kernel的执行配置
dim3 blockSize(32, 32);
dim3 gridSize((width + blockSize.x - 1) / blockSize.x,
(height + blockSize.y - 1) / blockSize.y);
// 执行kernel
matMulKernel << < gridSize, blockSize >> >(A, B, C);


// 同步device 保证结果能正确访问
cudaDeviceSynchronize();
// 检查执行结果
float maxError = 0.0;
for (int i = 0; i < width * height; ++i)
maxError = fmax(maxError, fabs(C->elements[i] - 2 * width));
std::cout << "最大误差: " << maxError << std::endl;

return 0;
}

这里矩阵大小为,设计的线程的block大小为(32, 32),那么grid大小为(32, 32),最终测试结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
nvprof cuda9.exe
==16304== NVPROF is profiling process 16304, command: cuda9.exe
最大误差: 0
==16304== Profiling application: cuda9.exe
==16304== Profiling result:
Type Time(%) Time Calls Avg Min Max Name
GPU activities: 100.00% 1.32752s 1 1.32752s 1.32752s 1.32752s matMulKernel(Matrix*, Matrix*, Matrix*)
API calls: 83.11% 1.32762s 1 1.32762s 1.32762s 1.32762s cudaDeviceSynchronize
13.99% 223.40ms 6 37.233ms 37.341us 217.66ms cudaMallocManaged
2.81% 44.810ms 1 44.810ms 44.810ms 44.810ms cudaLaunch
0.08% 1.3300ms 94 14.149us 0ns 884.64us cuDeviceGetAttribute
0.01% 199.03us 1 199.03us 199.03us 199.03us cuDeviceGetName
0.00% 10.009us 1 10.009us 10.009us 10.009us cuDeviceTotalMem
0.00% 6.5440us 1 6.5440us 6.5440us 6.5440us cudaConfigureCall
0.00% 3.0800us 3 1.0260us 385ns 1.5400us cudaSetupArgument
0.00% 2.6940us 3 898ns 385ns 1.5390us cuDeviceGetCount
0.00% 1.9250us 2 962ns 385ns 1.5400us cuDeviceGet

==16304== Unified Memory profiling result:
Device "GeForce GT 730 (0)"
Count Avg Size Min Size Max Size Total Size Total Time Name
2051 4.0000KB 4.0000KB 4.0000KB 8.011719MB 21.20721ms Host To Device
270 45.570KB 4.0000KB 1.0000MB 12.01563MB 7.032508ms Device To Host

当然,这不是最高效的实现,后面可以继续优化…

第1章 你好,C++的并发世界!

开始入门

一个C++多线程程序是什么样子呢?通常是变量、类以及函数的组合。唯一的区别在于某些函数可以并发运行,所以需要确保共享数据在并发访问时是安全的。当然,为了并发地运行函数,必须使用特定的函数以及对象来管理各个线程。

一个非常简单的在单线程中运行的Hello World程序如下所示,当我们谈到多线程时,它可以作为一个基准。

1
2
3
4
5
#include <iostream>
int main()
{
std::cout << "Hello World\n";
}

这个程序所做的就是将“Hello World”写进标准输出流。让我们将它与下面清单所示的简单的“Hello, Concurrent World”程序做个比较,它启动了一个独立的线程来显示这个信息。

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
#include <thread> //①
void hello() //②
{
std::cout << "Hello Concurrent World\n";
}
int main()
{
std::thread t(hello); //③
t.join(); //④
}

第一个区别是增加了#include <thread>①,标准C++库中对多线程支持的声明在新的头文件中:管理线程的函数和类在<thread>中声明,而保护共享数据的函数和类在其他头文件中声明。

其次,打印信息的代码被移动到了一个独立的函数中②。因为每个线程都必须具有一个初始函数(initial function),新线程的执行从这里开始。对于应用程序来说,初始线程是main(),但是对于其他线程,可以在std::thread对象的构造函数中指定——本例中,被命名为t③std::thread对象拥有新函数hello()作为其初始函数。

下一个区别:与直接写入标准输出或是从main()调用hello()不同,该程序启动了一个全新的线程来实现,将线程数量一分为二——初始线程始于main(),而新线程始于hello()

新的线程启动之后③,初始线程继续执行。如果它不等待新线程结束,它就将自顾自地继续运行到main()的结束,从而结束程序——有可能发生在新线程运行之前。这就是为什么在④这里调用join()的原因——详见第2章,这会导致调用线程(在main()中)等待与std::thread对象相关联的线程,即这个例子中的t。

线程管理

C++标准库中只需要管理std::thread关联的线程,无需把注意力放在其他方面。不过,标准库太灵活,所以管理起来不会太容易。

本章将从基本开始:启动一个线程,等待这个线程结束,或放在后台运行。再看看怎么给已经启动的线程函数传递参数,以及怎么将一个线程的所有权从当前std::thread对象移交给另一个。最后,再来确定线程数,以及识别特殊线程。

线程管理的基础

每个程序至少有一个线程:执行main()函数的线程,其余线程有其各自的入口函数。线程与原始线程(以main()为入口函数的线程)同时运行。如同main()函数执行完会退出一样,当线程执行完入口函数后,线程也会退出。在为一个线程创建了一个std::thread对象后,需要等待这个线程结束;不过,线程需要先进行启动。下面就来启动线程。

启动线程

最简单的情况下,任务也会很简单,通常是无参数无返回的函数。这种函数在其所属线程上运行,直到函数执行完毕,线程也就结束了。在一些极端情况下,线程运行时,任务中的函数对象需要通过某种通讯机制进行参数的传递,或者执行一系列独立操作;可以通过通讯机制传递信号,让线程停止。线程要做什么,以及什么时候启动,其实都无关紧要。总之,使用C++线程库启动线程,可以归结为构造std::thread对象:

1
2
void do_some_work();
std::thread my_thread(do_some_work);

为了让编译器识别std::thread类,这个简单的例子也要包含<thread>头文件。如同大多数C++标准库一样,std::thread可以用可调用类型构造,将带有函数调用符类型的实例传入std::thread类中,替换默认的构造函数。

1
2
3
4
5
6
7
8
9
10
11
class background_task
{
public:
void operator()() const
{
do_something();
do_something_else();
}
};
background_task f;
std::thread my_thread(f);

代码中,提供的函数对象会复制到新线程的存储空间当中,函数对象的执行和调用都在线程的内存空间中进行。函数对象的副本应与原始函数对象保持一致,否则得到的结果会与我们的期望不同。

有件事需要注意,当把函数对象传入到线程构造函数中时,需要避免“最令人头痛的语法解析”。如果你传递了一个临时变量,而不是一个命名的变量;C++编译器会将其解析为函数声明,而不是类型对象的定义。

例如:

1
std::thread my_thread(background_task());

这里相当与声明了一个名为my_thread的函数,这个函数带有一个参数(函数指针指向没有参数并返回background_task对象的函数),返回一个std::thread对象的函数,而非启动了一个线程。

使用在前面命名函数对象的方式,或使用多组括号①,或使用新统一的初始化语法②,可以避免这个问题。

如下所示:

1
2
std::thread my_thread((background_task()));  // 1
std::thread my_thread{background_task()}; // 2

使用lambda表达式也能避免这个问题。lambda表达式是C++11的一个新特性,它允许使用一个可以捕获局部变量的局部函数。之前的例子可以改写为lambda表达式的类型:

1
2
3
4
std::thread my_thread([]{
do_something();
do_something_else();
});

启动了线程,你需要明确是要等待线程结束,还是让其自主运行。如果std::thread对象销毁之前还没有做出决定,程序就会终止(std::thread的析构函数会调用std::terminate())。因此,即便是有异常存在,也需要确保线程能够正确的加入(joined)或分离(detached)。需要注意的是,必须在std::thread对象销毁之前做出决定,否则你的程序将会终止(std::thread的析构函数会调用std::terminate(),这时再去决定会触发相应异常)。

如果不等待线程,就必须保证线程结束之前,可访问的数据得有效性。这不是一个新问题——单线程代码中,对象销毁之后再去访问,也会产生未定义行为——不过,线程的生命周期增加了这个问题发生的几率。

这种情况很可能发生在线程还没结束,函数已经退出的时候,这时线程函数还持有函数局部变量的指针或引用。下面的清单中就展示了这样的一种情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct func
{
int& i;
func(int& i_) : i(i_) {}
void operator() ()
{
for (unsigned j=0 ; j<1000000 ; ++j)
{
do_something(i); // 1. 潜在访问隐患:悬空引用
}
}
};
void oops()
{
int some_local_state=0;
func my_func(some_local_state);
std::thread my_thread(my_func);
my_thread.detach(); // 2. 不等待线程结束
} // 3. 新线程可能还在运行

这个例子中,已经决定不等待线程结束(使用了detach()②),所以当oops()函数执行完成时③,新线程中的函数可能还在运行。如果线程还在运行,它就会去调用do_something(i)函数①,这时就会访问已经销毁的变量。如同一个单线程程序——允许在函数完成后继续持有局部变量的指针或引用;当然,这从来就不是一个好主意——这种情况发生时,错误并不明显,会使多线程更容易出错。

处理这种情况的常规方法:使线程函数的功能齐全,将数据复制到线程中,而非复制到共享数据中。如果使用一个可调用的对象作为线程函数,这个对象就会复制到线程中,而后原始对象就会立即销毁。但对于对象中包含的指针和引用还需谨慎。使用一个能访问局部变量的函数去创建线程是一个糟糕的主意(除非十分确定线程会在函数完成前结束)。此外,可以通过join()函数来确保线程在函数完成前结束。

等待线程完成

如果需要等待线程,相关的std::thread实例需要使用join()。清单2.1中,将my_thread.detach()替换为my_thread.join(),就可以确保局部变量在线程完成后,才被销毁。在这种情况下,因为原始线程在其生命周期中并没有做什么事,使得用一个独立的线程去执行函数变得收益甚微,但在实际编程中,原始线程要么有自己的工作要做;要么会启动多个子线程来做一些有用的工作,并等待这些线程结束。

join()是简单粗暴的等待线程完成或不等待。当你需要对等待中的线程有更灵活的控制时,比如,看一下某个线程是否结束,或者只等待一段时间(超过时间就判定为超时)。想要做到这些,你需要使用其他机制来完成,比如条件变量和期待(futures)。调用join()的行为,还清理了线程相关的存储部分,这样std::thread对象将不再与已经完成的线程有任何关联。这意味着,只能对一个线程使用一次join();一旦已经使用过join()std::thread对象就不能再次加入了,当对其使用joinable()时,将返回false。

特殊情况下的等待

如前所述,需要对一个还未销毁的std::thread对象使用join()detach()。如果想要分离一个线程,可以在线程启动后,直接使用detach()进行分离。如果打算等待对应线程,则需要细心挑选调用join()的位置。当在线程运行之后产生异常,在join()调用之前抛出,就意味着这次调用会被跳过。

避免应用被抛出的异常所终止,就需要作出一个决定。通常,当倾向于在无异常的情况下使用join()时,需要在异常处理过程中调用join(),从而避免生命周期的问题。下面的程序清单是一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct func; // 定义在清单2.1中
void f()
{
int some_local_state=0;
func my_func(some_local_state);
std::thread t(my_func);
try
{
do_something_in_current_thread();
}
catch(...)
{
t.join(); // 1
throw;
}
t.join(); // 2
}

清单2.2中的代码使用了try/catch块确保访问本地状态的线程退出后,函数才结束。当函数正常退出时,会执行到②处;当函数执行过程中抛出异常,程序会执行到①处。try/catch块能轻易的捕获轻量级错误,所以这种情况,并非放之四海而皆准。如需确保线程在函数之前结束——查看是否因为线程函数使用了局部变量的引用,以及其他原因——而后再确定一下程序可能会退出的途径,无论正常与否,可以提供一个简洁的机制,来做解决这个问题。

一种方式是使用“资源获取即初始化方式”(RAII,Resource Acquisition Is Initialization),并且提供一个类,在析构函数中使用join(),如同下面清单中的代码。看它如何简化f()函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class thread_guard
{
std::thread& t;
public:
explicit thread_guard(std::thread& t_):
t(t_)
{}
~thread_guard()
{
if(t.joinable()) // 1
{
t.join(); // 2
}
}
thread_guard(thread_guard const&)=delete; // 3
thread_guard& operator=(thread_guard const&)=delete;
};
struct func; // 定义在清单2.1中
void f()
{
int some_local_state=0;
func my_func(some_local_state);
std::thread t(my_func);
thread_guard g(t);
do_something_in_current_thread();
} // 4

当线程执行到④处时,局部对象就要被逆序销毁了。因此,thread_guard对象g是第一个被销毁的,这时线程在析构函数中被加入②到原始线程中。即使do_something_in_current_thread抛出一个异常,这个销毁依旧会发生。

thread_guard的析构函数的测试中,首先判断线程是否已加入①,如果没有会调用join()②进行加入。这很重要,因为join()只能对给定的对象调用一次,所以对给已加入的线程再次进行加入操作时,将会导致错误。

拷贝构造函数和拷贝赋值操作被标记为=delete③,是为了不让编译器自动生成它们。直接对一个对象进行拷贝或赋值是危险的,因为这可能会弄丢已经加入的线程。通过删除声明,任何尝试给thread_guard对象赋值的操作都会引发一个编译错误。

如果不想等待线程结束,可以分离(detaching)线程,从而避免异常安全*(exception-safety)问题。不过,这就打破了线程与std::thread对象的联系,即使线程仍然在后台运行着,分离操作也能确保std::terminate()std::thread对象销毁才被调用。

后台运行线程

使用detach()会让线程在后台运行,这就意味着主线程不能与之产生直接交互。也就是说,不会等待这个线程结束;如果线程分离,那么就不可能有std::thread对象能引用它,分离线程的确在后台运行,所以分离线程不能被加入。不过C++运行库保证,当线程退出时,相关资源的能够正确回收,后台线程的归属和控制C++运行库都会处理。

通常称分离线程为守护线程(daemon threads),UNIX中守护线程是指,没有任何显式的用户接口,并在后台运行的线程。这种线程的特点就是长时间运行;线程的生命周期可能会从某一个应用起始到结束,可能会在后台监视文件系统,还有可能对缓存进行清理,亦或对数据结构进行优化。另一方面,分离线程的另一方面只能确定线程什么时候结束,发后即忘(fire and forget)的任务就使用到线程的这种方式。

如2.1.2节所示,调用std::thread成员函数detach()来分离一个线程。之后,相应的std::thread对象就与实际执行的线程无关了,并且这个线程也无法加入:

1
2
3
std::thread t(do_background_work);
t.detach();
assert(!t.joinable());

为了从std::thread对象中分离线程(前提是有可进行分离的线程),不能对没有执行线程的std::thread对象使用detach(),也是join()的使用条件,并且要用同样的方式进行检查——当std::thread对象使用t.joinable()返回的是true,就可以使用t.detach()

向线程函数传递参数

清单2.4中,向std::thread构造函数中的可调用对象,或函数传递一个参数很简单。需要注意的是,默认参数要拷贝到线程独立内存中,即使参数是引用的形式,也可以在新线程中进行访问。再来看一个例子:

1
2
void f(int i, std::string const& s);
std::thread t(f, 3, "hello");

代码创建了一个调用f(3, “hello”)的线程。注意,函数f需要一个std::string对象作为第二个参数,但这里使用的是字符串的字面值,也就是char const *类型。之后,在线程的上下文中完成字面值向std::string对象的转化。需要特别要注意,当指向动态变量的指针作为参数传递给线程的情况,代码如下:

1
2
3
4
5
6
7
8
void f(int i,std::string const& s);
void oops(int some_param)
{
char buffer[1024]; // 1
sprintf(buffer, "%i",some_param);
std::thread t(f,3,buffer); // 2
t.detach();
}

这种情况下,buffer②是一个指针变量,指向本地变量,然后本地变量通过buffer传递到新线程中②。并且,函数有很有可能会在字面值转化成std::string对象之前崩溃(oops),从而导致一些未定义的行为。并且想要依赖隐式转换将字面值转换为函数期待的std::string对象,但因std::thread的构造函数会复制提供的变量,就只复制了没有转换成期望类型的字符串字面值。

解决方案就是在传递到std::thread构造函数之前就将字面值转化为std::string对象:

1
2
3
4
5
6
7
8
void f(int i,std::string const& s);
void not_oops(int some_param)
{
char buffer[1024];
sprintf(buffer,"%i",some_param);
std::thread t(f,3,std::string(buffer)); // 使用std::string,避免悬垂指针
t.detach();
}

还可能遇到相反的情况:期望传递一个引用,但整个对象被复制了。当线程更新一个引用传递的数据结构时,这种情况就可能发生,比如:

1
2
3
4
5
6
7
8
9
void update_data_for_widget(widget_id w,widget_data& data); // 1
void oops_again(widget_id w)
{
widget_data data;
std::thread t(update_data_for_widget,w,data); // 2
display_status();
t.join();
process_widget_data(data); // 3
}

虽然update_data_for_widget①的第二个参数期待传入一个引用,但是std::thread的构造函数②并不知晓;构造函数无视函数期待的参数类型,并盲目的拷贝已提供的变量。当线程调用update_data_for_widget函数时,传递给函数的参数是data变量内部拷贝的引用,而非数据本身的引用。因此,当线程结束时,内部拷贝数据将会在数据更新阶段被销毁,且process_widget_data将会接收到没有修改的data变量③。可以使用std::ref将参数转换成引用的形式,从而可将线程的调用改为以下形式:

1
std::thread t(update_data_for_widget,w,std::ref(data));

在这之后,update_data_for_widget就会接收到一个data变量的引用,而非一个data变量拷贝的引用。

如果你熟悉std::bind,就应该不会对以上述传参的形式感到奇怪,因为std::thread构造函数和std::bind的操作都在标准库中定义好了,可以传递一个成员函数指针作为线程函数,并提供一个合适的对象指针作为第一个参数:

1
2
3
4
5
6
7
class X
{
public:
void do_lengthy_work();
};
X my_x;
std::thread t(&X::do_lengthy_work,&my_x); // 1

这段代码中,新线程将my_x.do_lengthy_work()作为线程函数;my_x的地址①作为指针对象提供给函数。也可以为成员函数提供参数:std::thread构造函数的第三个参数就是成员函数的第一个参数,以此类推(代码如下,译者自加)。

1
2
3
4
5
6
7
8
class X
{
public:
void do_lengthy_work(int);
};
X my_x;
int num(0);
std::thread t(&X::do_lengthy_work, &my_x, num);

有趣的是,提供的参数可以移动,但不能拷贝。”移动”是指:原始对象中的数据转移给另一对象,而转移的这些数据就不再在原始对象中保存了。std::unique_ptr就是这样一种类型,这种类型为动态分配的对象提供内存自动管理机制。同一时间内,只允许一个std::unique_ptr实现指向一个给定对象,并且当这个实现销毁时,指向的对象也将被删除。移动构造函数(move constructor)和移动赋值操作符(move assignment operator)允许一个对象在多个std::unique_ptr实现中传递。使用”移动”转移原对象后,就会留下一个空指针(NULL)。移动操作可以将对象转换成可接受的类型,例如:函数参数或函数返回的类型。当原对象是一个临时变量时,自动进行移动操作,但当原对象是一个命名变量,那么转移的时候就需要使用std::move()进行显示移动。下面的代码展示了std::move的用法,展示了std::move是如何转移一个动态对象到一个线程中去的:

1
2
3
4
void process_big_object(std::unique_ptr<big_object>);
std::unique_ptr<big_object> p(new big_object);
p->prepare_data(42);
std::thread t(process_big_object,std::move(p));

std::thread的构造函数中指定std::move(p)big_object对象的所有权就被首先转移到新创建线程的的内部存储中,之后传递给process_big_object函数。

标准线程库中和std::unique_ptr在所属权上有相似语义类型的类有好几种,std::thread为其中之一。虽然,std::thread实例不像std::unique_ptr那样能占有一个动态对象的所有权,但是它能占有其他资源:每个实例都负责管理一个执行线程。执行线程的所有权可以在多个std::thread实例中互相转移,这是依赖于std::thread实例的可移动且不可复制性。不可复制保性证了在同一时间点,一个std::thread实例只能关联一个执行线程;可移动性使得程序员可以自己决定,哪个实例拥有实际执行线程的所有权。

转移线程所有权

假设要写一个在后台启动线程的函数,想通过新线程返回的所有权去调用这个函数,而不是等待线程结束再去调用;或完全与之相反的想法:创建一个线程,并在函数中转移所有权,都必须要等待线程结束。总之,新线程的所有权都需要转移。

这就是移动引入std::thread的原因,C++标准库中有很多资源占有(resource-owning)类型,比如std::ifstreamstd::unique_ptr还有std::thread都是可移动,但不可拷贝。这就说明执行线程的所有权可以在std::thread实例中移动,下面将展示一个例子。例子中,创建了两个执行线程,并且在std::thread实例之间(t1,t2和t3)转移所有权:

1
2
3
4
5
6
7
8
void some_function();
void some_other_function();
std::thread t1(some_function); // 1
std::thread t2=std::move(t1); // 2
t1=std::thread(some_other_function); // 3
std::thread t3; // 4
t3=std::move(t2); // 5
t1=std::move(t3); // 6 赋值操作将使程序崩溃

当显式使用std::move()创建t2后②,t1的所有权就转移给了t2。之后,t1和执行线程已经没有关联了;执行some_function的函数现在与t2关联。

然后,与一个临时std::thread对象相关的线程启动了③。为什么不显式调用std::move()转移所有权呢?因为,所有者是一个临时对象——移动操作将会隐式的调用。

t3使用默认构造方式创建④,与任何执行线程都没有关联。调用std::move()将与t2关联线程的所有权转移到t3中⑤。因为t2是一个命名对象,需要显式的调用std::move()。移动操作⑤完成后,t1与执行some_other_function的线程相关联,t2与任何线程都无关联,t3与执行some_function的线程相关联。

最后一个移动操作,将some_function线程的所有权转移⑥给t1。不过,t1已经有了一个关联的线程(执行some_other_function的线程),所以这里系统直接调用std::terminate()终止程序继续运行。这样做(不抛出异常,std::terminate()是noexcept函数)是为了保证与std::thread的析构函数的行为一致。2.1.1节中,需要在线程对象被析构前,显式的等待线程完成,或者分离它;进行赋值时也需要满足这些条件。

std::thread支持移动,就意味着线程的所有权可以在函数外进行转移,就如下面程序一样。

1
2
3
4
5
6
7
8
9
10
11
std::thread f()
{
void some_function();
return std::thread(some_function);
}
std::thread g()
{
void some_other_function(int);
std::thread t(some_other_function,42);
return t;
}

当所有权可以在函数内部传递,就允许std::thread实例可作为参数进行传递,代码如下:

1
2
3
4
5
6
7
8
void f(std::thread t);
void g()
{
void some_function();
f(std::thread(some_function));
std::thread t(some_function);
f(std::move(t));
}

std::thread支持移动的好处是可以创建thread_guard类的实例,并且拥有其线程的所有权。当thread_guard对象所持有的线程已经被引用,移动操作就可以避免很多不必要的麻烦;这意味着,当某个对象转移了线程的所有权后,它就不能对线程进行加入或分离。为了确保线程程序退出前完成,下面的代码里定义了scoped_thread类。现在,我们来看一下这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class scoped_thread
{
std::thread t;
public:
explicit scoped_thread(std::thread t_): // 1
t(std::move(t_))
{
if(!t.joinable()) // 2
throw std::logic_error(“No thread”);
}
~scoped_thread()
{
t.join(); // 3
}
scoped_thread(scoped_thread const&)=delete;
scoped_thread& operator=(scoped_thread const&)=delete;
};
struct func; // 定义在清单2.1中
void f()
{
int some_local_state;
scoped_thread t(std::thread(func(some_local_state))); // 4
do_something_in_current_thread();
} // 5

与清单2.3相似,不过这里新线程是直接传递到scoped_thread中④,而非创建一个独立的命名变量。当主线程到达f()函数的末尾时,scoped_thread对象将会销毁,然后加入③到的构造函数①创建的线程对象中去。而在清单2.3中的thread_guard类,就要在析构的时候检查线程是否”可加入”。这里把检查放在了构造函数中②,并且当线程不可加入时,抛出异常。

std::thread对象的容器,如果这个容器是移动敏感的(比如,标准中的std::vector<>),那么移动操作同样适用于这些容器。了解这些后,就可以写出类似清单2.7中的代码,代码量产了一些线程,并且等待它们结束。

1
2
3
4
5
6
7
8
9
10
11
void do_work(unsigned id);
void f()
{
std::vector<std::thread> threads;
for(unsigned i=0; i < 20; ++i)
{
threads.push_back(std::thread(do_work,i)); // 产生线程
}
std::for_each(threads.begin(),threads.end(),
std::mem_fn(&std::thread::join)); // 对每个线程调用join()
}

我们经常需要线程去分割一个算法的总工作量,所以在算法结束的之前,所有的线程必须结束。清单2.7说明线程所做的工作都是独立的,并且结果仅会受到共享数据的影响。如果f()有返回值,这个返回值就依赖于线程得到的结果。在写入返回值之前,程序会检查使用共享数据的线程是否终止。

std::thread放入std::vector是向线程自动化管理迈出的第一步:并非为这些线程创建独立的变量,并且将他们直接加入,可以把它们当做一个组。创建一组线程(数量在运行时确定),可使得这一步迈的更大,而非像清单2.7那样创建固定数量的线程。

运行时决定线程数量

std::thread::hardware_concurrency()在新版C++标准库中是一个很有用的函数。这个函数将返回能同时并发在一个程序中的线程数量。例如,多核系统中,返回值可以是CPU核芯的数量。返回值也仅仅是一个提示,当系统信息无法获取时,函数也会返回0。但是,这也无法掩盖这个函数对启动线程数量的帮助。

清单2.8实现了一个并行版的std::accumulate。代码中将整体工作拆分成小任务交给每个线程去做,其中设置最小任务数,是为了避免产生太多的线程。程序可能会在操作数量为0的时候抛出异常。比如,std::thread构造函数无法启动一个执行线程,就会抛出一个异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
template<typename Iterator,typename T>
struct accumulate_block
{
void operator()(Iterator first,Iterator last,T& result)
{
result=std::accumulate(first,last,result);
}
};
template<typename Iterator,typename T>
T parallel_accumulate(Iterator first,Iterator last,T init)
{
unsigned long const length=std::distance(first,last);
if(!length) // 1
return init;
unsigned long const min_per_thread=25;
unsigned long const max_threads = (length+min_per_thread-1)/min_per_thread; // 2
unsigned long const hardware_threads = std::thread::hardware_concurrency();
unsigned long const num_threads = std::min(hardware_threads != 0 ? hardware_threads : 2, max_threads); // 3
unsigned long const block_size = length/num_threads; // 4
std::vector<T> results(num_threads);
std::vector<std::thread> threads(num_threads-1); // 5
Iterator block_start=first;

for(unsigned long i=0; i < (num_threads-1); ++i)
{
Iterator block_end=block_start;
std::advance(block_end,block_size); // 6
threads[i]=std::thread( // 7
accumulate_block<Iterator,T>(),
block_start,block_end,std::ref(results[i]));
block_start=block_end; // 8
}

accumulate_block<Iterator,T>()(
block_start,last,results[num_threads-1]); // 9
std::for_each(threads.begin(),threads.end(),
std::mem_fn(&std::thread::join)); // 10
return std::accumulate(results.begin(),results.end(),init); // 11
}

函数看起来很长,但不复杂。如果输入的范围为空①,就会得到init的值。反之,如果范围内多于一个元素时,都需要用范围内元素的总数量除以线程(块)中最小任务数,从而确定启动线程的最大数量②,这样能避免无谓的计算资源的浪费。比如,一台32芯的机器上,只有5个数需要计算,却启动了32个线程。

计算量的最大值和硬件支持线程数中,较小的值为启动线程的数量③。因为上下文频繁的切换会降低线程的性能,所以你肯定不想启动的线程数多于硬件支持的线程数量。当std::thread::hardware_concurrency()返回0,你可以选择一个合适的数作为你的选择;在本例中,我选择了”2”。你也不想在一台单核机器上启动太多的线程,因为这样反而会降低性能,有可能最终让你放弃使用并发。

每个线程中处理的元素数量,是范围中元素的总量除以线程的个数得出的④。对于分配是否得当,我们会在后面讨论。

现在,确定了线程个数,通过创建一个std::vector<T>容器存放中间结果,并为线程创建一个std::vector<std::thread>容器⑤。这里需要注意的是,启动的线程数必须比num_threads少1个,因为在启动之前已经有了一个线程(主线程)。

使用简单的循环来启动线程:block_end迭代器指向当前块的末尾⑥,并启动一个新线程为当前块累加结果⑦。当迭代器指向当前块的末尾时,启动下一个块⑧。

启动所有线程后,⑨中的线程会处理最终块的结果。对于分配不均,因为知道最终块是哪一个,那么这个块中有多少个元素就无所谓了。

当累加最终块的结果后,可以等待std::for_each⑩创建线程的完成,之后使用std::accumulate将所有结果进行累加⑪。

结束这个例子之前,需要明确:T类型的加法运算不满足结合律(比如,对于float型或double型,在进行加法操作时,系统很可能会做截断操作),因为对范围中元素的分组,会导致parallel_accumulate得到的结果可能与std::accumulate得到的结果不同。同样的,这里对迭代器的要求更加严格:必须都是向前迭代器,而std::accumulate可以在只传入迭代器的情况下工作。对于创建出results容器,需要保证T有默认构造函数。对于算法并行,通常都要这样的修改;不过,需要根据算法本身的特性,选择不同的并行方式。需要注意的:因为不能直接从一个线程中返回一个值,所以需要传递results容器的引用到线程中去。

当线程运行时,所有必要的信息都需要传入到线程中去,包括存储计算结果的位置。不过,并非总需如此:有时候这是识别线程的可行方案,可以传递一个标识数,例如清单2.7中的i。不过,当需要标识的函数在调用栈的深层,同时其他线程也可调用该函数,那么标识数就会变的捉襟见肘。好消息是在设计C++的线程库时,就有预见了这种情况,在之后的实现中就给每个线程附加了唯一标识符。

识别线程

线程标识类型是std::thread::id,可以通过两种方式进行检索。第一种,可以通过调用std::thread对象的成员函数get_id()来直接获取。如果std::thread对象没有与任何执行线程相关联,get_id()将返回std::thread::type默认构造值,这个值表示“没有线程”。第二种,当前线程中调用std::this_thread::get_id()(这个函数定义在<thread>头文件中)也可以获得线程标识。

std::thread::id对象可以自由的拷贝和对比,因为标识符就可以复用。如果两个对象的std::thread::id相等,那它们就是同一个线程,或者都“没有线程”。如果不等,那么就代表了两个不同线程,或者一个有线程,另一没有。

线程库不会限制你去检查线程标识是否一样,std::thread::id类型对象提供相当丰富的对比操作;比如,提供为不同的值进行排序。这意味着允许程序员将其当做为容器的键值,做排序,或做其他方式的比较。按默认顺序比较不同值的std::thread::id,所以这个行为可预见的:当a<bb<c时,得a<c,等等。标准库也提供std::hash<std::thread::id>容器,所以std::thread::id也可以作为无序容器的键值。

std::thread::id实例常用作检测线程是否需要进行一些操作,比如:当用线程来分割一项工作,主线程可能要做一些与其他线程不同的工作。这种情况下,启动其他线程前,它可以将自己的线程ID通过std::this_thread::get_id()得到,并进行存储。就是算法核心部分(所有线程都一样的),每个线程都要检查一下,其拥有的线程ID是否与初始线程的ID相同。

1
2
3
4
5
6
7
8
9
std::thread::id master_thread;
void some_core_part_of_algorithm()
{
if(std::this_thread::get_id()==master_thread)
{
do_master_thread_work();
}
do_common_work();
}

另外,当前线程的std::thread::id将存储到一个数据结构中。之后在这个结构体中对当前线程的ID与存储的线程ID做对比,来决定操作是被“允许”,还是“需要”(permitted/required)。

同样,作为线程和本地存储不适配的替代方案,线程ID在容器中可作为键值。例如,容器可以存储其掌控下每个线程的信息,或在多个线程中互传信息。

std::thread::id可以作为一个线程的通用标识符,当标识符只与语义相关(比如,数组的索引)时,就需要这个方案了。也可以使用输出流(std::cout)来记录一个std::thread::id对象的值。

1
std::cout<<std::this_thread::get_id();

具体的输出结果是严格依赖于具体实现的,C++标准的唯一要求就是要保证ID比较结果相等的线程,必须有相同的输出。

线程间共享数据

共享数据带来的问题

线程间潜在问题就是修改共享数据,致使不变量遭到破坏。当不做些事来确保在这个过程中不会有其他线程进行访问的话,可能就有线程访问到刚刚删除一边的节点;这样的话,线程就读取到要删除节点的数据(因为只有一边的连接被修改,如图3.1(b)),所以不变量就被破坏。破坏不变量的后果是多样,当其他线程按从左往右的顺序来访问列表时,它将跳过被删除的节点。在一方面,如有第二个线程尝试删除图中右边的节点,那么可能会让数据结构产生永久性的损坏,使程序崩溃。无论结果如何,都是并行代码常见错误:条件竞争。

条件竞争

并发中竞争条件的形成,取决于一个以上线程的相对执行顺序,每个线程都抢着完成自己的任务。

恶性条件竞争通常发生于完成对多于一个的数据块的修改时。因为操作要访问两个独立的数据块,独立的指令将会对数据块将进行修改,并且其中一个线程可能正在进行时,另一个线程就对数据块进行了访问。当系统负载增加时,随着执行数量的增加,执行序列的问题复现的概率也在增加,这样的问题只可能会出现在负载比较大的情况下。

避免恶性条件竞争

这里提供一些方法来解决恶性条件竞争,最简单的办法就是对数据结构采用某种保护机制,确保只有进行修改的线程才能看到不变量被破坏时的中间状态。

另一个选择是对数据结构和不变量的设计进行修改,修改完的结构必须能完成一系列不可分割的变化,也就是保证每个不变量保持稳定的状态,这就是所谓的无锁编程。

另一种处理条件竞争的方式是,使用事务的方式去处理数据结构的更新。所需的一些数据和读取都存储在事务日志中,然后将之前的操作合为一步,再进行提交。当数据结构被另一个线程修改后,或处理已经重启的情况下,提交就会无法进行,这称作为“软件事务内存”。

使用互斥量保护共享数据

当访问共享数据前,使用互斥量将相关数据锁住,再当访问结束后,再将数据解锁。线程库需要保证,当一个线程使用特定互斥量锁住共享数据时,其他的线程想要访问锁住的数据,都必须等到之前那个线程对数据进行解锁后,才能进行访问。这就保证了所有线程能看到共享数据,而不破坏不变量。

C++中使用互斥量

C++中通过实例化std::mutex创建互斥量,通过调用成员函数lock()进行上锁,unlock()进行解锁。C++标准库为互斥量提供了一个RAII语法的模板类std::lock_guard,其会在构造的时候提供已锁的互斥量,并在析构的时候进行解锁,从而保证了一个已锁的互斥量总是会被正确的解锁。std::mutexstd::lock_guard都在<mutex>头文件中声明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <list>
#include <mutex>
#include <algorithm>
std::list<int> some_list; // 1
std::mutex some_mutex; // 2
void add_to_list(int new_value)
{
std::lock_guard<std::mutex> guard(some_mutex); // 3
some_list.push_back(new_value);
}
bool list_contains(int value_to_find)
{
std::lock_guard<std::mutex> guard(some_mutex); // 4
return std::find(some_list.begin(),some_list.end(),value_to_find) != some_list.end();
}

add_to_list()③和list_contains()④函数中使用std::lock_guard<std::mutex>,使得这两个函数中对数据的访问是互斥的:list_contains()不可能看到正在被add_to_list()修改的列表。

互斥量和要保护的数据,在类中都需要定义为private成员,这会让访问数据的代码变的清晰,并且容易看出在什么时候对互斥量上锁。当所有成员函数都会在调用时对数据上锁,结束时对数据解锁,那么就保证了数据访问时不变量不被破坏。

精心组织代码来保护共享数据

使用互斥量来保护数据,并不是仅仅在每一个成员函数中都加入一个std::lock_guard对象那么简单。在确保成员函数不会传出指针或引用的同时,检查成员函数是否通过指针或引用的方式来调用也是很重要的(尤其是这个操作不在你的控制下时)。函数可能没在互斥量保护的区域内,存储着指针或者引用,这样就很危险。更危险的是:将保护数据作为一个运行时参数,如同下面清单中所示那样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class some_data
{
int a;
std::string b;
public:
void do_something();
};
class data_wrapper
{
private:
some_data data;
std::mutex m;
public:
template<typename Function>
void process_data(Function func)
{
std::lock_guard<std::mutex> l(m);
func(data); // 1 传递“保护”数据给用户函数
}
};
some_data* unprotected;
void malicious_function(some_data& protected_data)
{
unprotected=&protected_data;
}

data_wrapper x;
void foo()
{
x.process_data(malicious_function); // 2 传递一个恶意函数
unprotected->do_something(); // 3 在无保护的情况下访问保护数据
}

例子中process_data看起来没有任何问题,std::lock_guard对数据做了很好的保护,但调用用户提供的函数func①,就意味着foo能够绕过保护机制将函数malicious_function传递进去②,在没有锁定互斥量的情况下调用do_something()

这段代码的问题在于根本没有保护,只是将所有可访问的数据结构代码标记为互斥。函数foo()中调用unprotected->do_something()的代码未能被标记为互斥。这种情况下,C++线程库无法提供任何帮助,只能由程序员来使用正确的互斥锁来保护数据。

发现接口内在的条件竞争

尽管链表的个别操作是安全的,但不意味着你就能走出困境;即使在一个很简单的接口中,依旧可能遇到条件竞争。例如,构建一个类似于std::stack结构的栈,除了构造函数和swap()以外,需要对std::stack提供五个操作:push()一个新元素进栈,pop()一个元素出栈,top()查看栈顶元素,empty()判断栈是否是空栈,size()了解栈中有多少个元素。即使修改了top(),使其返回一个拷贝而非引用,对内部数据使用一个互斥量进行保护,不过这个接口仍存在条件竞争。这个问题不仅存在于基于互斥量实现的接口中,在无锁实现的接口中,条件竞争依旧会产生。这是接口的问题,与其实现方式无关。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template<typename T,typename Container=std::deque<T> >
class stack
{
public:
explicit stack(const Container&);
explicit stack(Container&& = Container());
template <class Alloc> explicit stack(const Alloc&);
template <class Alloc> stack(const Container&, const Alloc&);
template <class Alloc> stack(Container&&, const Alloc&);
template <class Alloc> stack(stack&&, const Alloc&);
bool empty() const;
size_t size() const;
T& top();
T const& top() const;
void push(T const&);
void push(T&&);
void pop();
void swap(stack&&);
};

虽然empty()size()可能在被调用并返回时是正确的,但其的结果是不可靠的;当它们返回后,其他线程就可以自由地访问栈,并且可能push()多个新元素到栈中,也可能pop()一些已在栈中的元素。这样的话,之前从empty()size()得到的结果就有问题了。

特别地,当栈实例是非共享的,如果栈非空,使用empty()检查再调用top()访问栈顶部的元素是安全的。如下代码所示:

1
2
3
4
5
6
stack<int> s;
if (! s.empty()){ // 1
int const value = s.top(); // 2
s.pop(); // 3
do_something(value);
}

对于共享的栈对象,这样的调用顺序就不再安全了,因为在调用empty()①和调用top()②之间,可能有来自另一个线程的pop()调用并删除了最后一个元素。这是一个经典的条件竞争,使用互斥量对栈内部数据进行保护,但依旧不能阻止条件竞争的发生,这就是接口固有的问题。

当仔细的观察过之前的代码段,就会发现另一个潜在的条件竞争在调用top()②和pop()③之间。假设两个线程运行着前面的代码,并且都引用同一个栈对象s。这并非罕见的情况,当为性能而使用线程时,多个线程在不同的数据上执行相同的操作是很平常的,并且共享同一个栈可以将工作分摊给它们。假设,一开始栈中只有两个元素,这时任一线程上的empty()和top()都存在竞争,只需要考虑可能的执行顺序即可。

当栈被一个内部互斥量所保护时,只有一个线程可以调用栈的成员函数,所以调用可以很好地交错,并且do_something()是可以并发运行的。

当线程运行时,调用两次top(),栈没被修改,所以每个线程能得到同样的值。不仅是这样,在调用top()函数调用的过程中(两次),pop()函数都没有被调用。这样,在其中一个值再读取的时候,虽然不会出现“写后读”的情况,但其值已被处理了两次。这种条件竞争,比未定义的empty()/top()竞争更加严重;虽然其结果依赖于do_something()的结果,但因为看起来没有任何错误,就会让这个Bug很难定位。

这就需要接口设计上有较大的改动,提议之一就是使用同一互斥量来保护top()和pop()。

pop()操作分为两部分:先获取顶部元素(top()),然后从栈中移除(pop())。这样,在不能安全的将元素拷贝出去的情况下,栈中的这个数据还依旧存在,没有丢失。这样的分割却制造了本想避免或消除的条件竞争。幸运的是,我们还有的别的选项,但是使用这些选项是要付出代价的。

选项1: 传入一个引用。第一个选项是将变量的引用作为参数,传入pop()函数中获取想要的“弹出值”:

1
2
std::vector<int> result;
some_stack.pop(result);

大多数情况下,这种方式还不错,但有明显的缺点:需要构造出一个栈中类型的实例,用于接收目标值。

选项2:无异常抛出的拷贝构造函数或移动构造函数。对于有返回值的pop()函数来说,只有“异常安全”方面的担忧。很多类型都有拷贝构造函数,它们不会抛出异常,并且随着新标准中对“右值引用”的支持,很多类型都将会有一个移动构造函数,即使他们和拷贝构造函数做着相同的事情,它也不会抛出异常。

选项3:返回指向弹出值的指针。指针的优势是自由拷贝,并且不会产生异常。对于选择这个方案的接口,使用std::shared_ptr是个不错的选择;不仅能避免内存泄露(因为当对象中指针销毁时,对象也会被销毁),而且标准库能够完全控制内存分配方案,也就不需要new和delete操作。

一个接口没有条件竞争的堆栈类定义,它实现了选项1和选项3:重载了pop(),使用一个局部引用去存储弹出值,并返回一个std::shared_ptr<>对象。它有一个简单的接口,只有两个函数:push()pop();

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <exception>
#include <memory> // For std::shared_ptr<>
struct empty_stack: std::exception
{
const char* what() const throw();
};
template<typename T>
class threadsafe_stack
{
public:
threadsafe_stack();
threadsafe_stack(const threadsafe_stack&);
threadsafe_stack& operator=(const threadsafe_stack&) = delete; // 1 赋值操作被删除
void push(T new_value);
std::shared_ptr<T> pop();
void pop(T& value);
bool empty() const;
};

削减接口可以获得最大程度的安全,甚至限制对栈的一些操作。栈是不能直接赋值的,因为赋值操作已经删除了①,并且这里没有swap()函数。栈可以拷贝的,假设栈中的元素可以拷贝。当栈为空时,pop()函数会抛出一个empty_stack异常,所以在empty()函数被调用后,其他部件还能正常工作。如选项3描述的那样,使用std::shared_ptr可以避免内存分配管理的问题,并避免多次使用new和delete操作。

下面的代码将展示一个简单的实现——封装std::stack<>的线程安全堆栈。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <exception>
#include <memory>
#include <mutex>
#include <stack>
struct empty_stack: std::exception
{
const char* what() const throw() {
return "empty stack!";
};
};
template<typename T>
class threadsafe_stack
{
private:
std::stack<T> data;
mutable std::mutex m;
public:
threadsafe_stack()
: data(std::stack<T>()){}
threadsafe_stack(const threadsafe_stack& other)
{
std::lock_guard<std::mutex> lock(other.m);
data = other.data; // 1 在构造函数体中的执行拷贝
}
threadsafe_stack& operator=(const threadsafe_stack&) = delete;
void push(T new_value)
{
std::lock_guard<std::mutex> lock(m);
data.push(new_value);
}
std::shared_ptr<T> pop()
{
std::lock_guard<std::mutex> lock(m);
if(data.empty()) throw empty_stack(); // 在调用pop前,检查栈是否为空
std::shared_ptr<T> const res(std::make_shared<T>(data.top())); // 在修改堆栈前,分配出返回值
data.pop();
return res;
}
void pop(T& value)
{
std::lock_guard<std::mutex> lock(m);
if(data.empty()) throw empty_stack();
value=data.top();
data.pop();
}
bool empty() const
{
std::lock_guard<std::mutex> lock(m);
return data.empty();
}
};

使用多个互斥量保护所有的数据,细粒度锁也有问题。如前所述,当增大互斥量覆盖数据的粒度时,只需要锁住一个互斥量。但是,这种方案并非放之四海皆准,比如:互斥量正在保护一个独立类的实例;这种情况下,锁的状态的下一个阶段,不是离开锁定区域将锁定区域还给用户,就是有独立的互斥量去保护这个类的全部实例。当然,这两种方式都不理想。

一个给定操作需要两个或两个以上的互斥量时,另一个潜在的问题将出现:死锁。与条件竞争完全相反——不同的两个线程会互相等待,从而什么都没做。

死锁:问题描述及解决方案

避免死锁的一般建议,就是让两个互斥量总以相同的顺序上锁:总在互斥量B之前锁住互斥量A,就永远不会死锁。不过,选择一个固定的顺序(例如,实例提供的第一互斥量作为第一个参数,提供的第二个互斥量为第二个参数),可能会适得其反:在参数交换了之后,两个线程试图在相同的两个实例间进行数据交换时,程序又死锁了!

很幸运,C++标准库有办法解决这个问题,std::lock——可以一次性锁住多个(两个以上)的互斥量,并且没有副作用(死锁风险)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 这里的std::lock()需要包含<mutex>头文件
class some_big_object;
void swap(some_big_object& lhs,some_big_object& rhs);
class X
{
private:
some_big_object some_detail;
std::mutex m;
public:
X(some_big_object const& sd):some_detail(sd){}
friend void swap(X& lhs, X& rhs)
{
if(&lhs==&rhs)
return;
std::lock(lhs.m,rhs.m); // 1
std::lock_guard<std::mutex> lock_a(lhs.m,std::adopt_lock); // 2
std::lock_guard<std::mutex> lock_b(rhs.m,std::adopt_lock); // 3
swap(lhs.some_detail,rhs.some_detail);
}
};

首先,检查参数是否是不同的实例,因为操作试图获取std::mutex对象上的锁,所以当其被获取时,结果很难预料。然后,调用std::lock()①锁住两个互斥量,并且两个std:lock_guard实例已经创建好②③。提供std::adopt_lock参数除了表示std::lock_guard对象可获取锁之外,还将锁交由std::lock_guard对象管理,而不需要std::lock_guard对象再去构建新的锁。

这样,就能保证在大多数情况下,函数退出时互斥量能被正确的解锁(保护操作可能会抛出一个异常),也允许使用一个简单的“return”作为返回。还有,需要注意的是,当使用std::lock去锁lhs.mrhs.m时,可能会抛出异常;这种情况下,异常会传播到std::lock之外。当std::lock成功的获取一个互斥量上的锁,并且当其尝试从另一个互斥量上再获取锁时,就会有异常抛出,第一个锁也会随着异常的产生而自动释放,所以std::lock要么将两个锁都锁住,要不一个都不锁。

虽然std::lock可以在这情况下(获取两个以上的锁)避免死锁,但它没办法帮助你获取其中一个锁。

避免死锁的进阶指导

避免嵌套锁

一个线程已获得一个锁时,再别去获取第二个。即使互斥锁造成死锁的最常见原因,也可能会在其他方面受到死锁的困扰(比如:线程间的互相等待)。当你需要获取多个锁,使用一个std::lock来做这件事(对获取锁的操作上锁),避免产生死锁。

避免在持有锁时调用用户提供的代码

你在持有锁的情况下,调用用户提供的代码;如果用户代码要获取一个锁,就会违反第一个指导意见,并造成死锁(有时,这是无法避免的)。

使用固定顺序获取锁

当硬性条件要求你获取两个以上(包括两个)的锁,并且不能使用std::lock单独操作来获取它们;那么最好在每个线程上,用固定的顺序获取它们获取它们(锁)。

使用锁的层次结构

虽然,这对于定义锁的顺序,的确是一个特殊的情况,但锁的层次的意义在于提供对运行时约定是否被坚持的检查。这个建议需要对你的应用进行分层,并且识别在给定层上所有可上锁的互斥量。当代码试图对一个互斥量上锁,在该层锁已被低层持有时,上锁是不允许的。你可以在运行时对其进行检查,通过分配层数到每个互斥量上,以及记录被每个线程上锁的互斥量。下面的代码列表中将展示两个线程如何使用分层互斥。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
hierarchical_mutex high_level_mutex(10000); // 1
hierarchical_mutex low_level_mutex(5000); // 2
int do_low_level_stuff();
int low_level_func()
{
std::lock_guard<hierarchical_mutex> lk(low_level_mutex); // 3
return do_low_level_stuff();
}
void high_level_stuff(int some_param);
void high_level_func()
{
std::lock_guard<hierarchical_mutex> lk(high_level_mutex); // 4
high_level_stuff(low_level_func()); // 5
}
void thread_a() // 6
{
high_level_func();
}
hierarchical_mutex other_mutex(100); // 7
void do_other_stuff();
void other_stuff()
{
high_level_func(); // 8
do_other_stuff();
}
void thread_b() // 9
{
std::lock_guard<hierarchical_mutex> lk(other_mutex); // 10
other_stuff();
}

thread_a()⑥遵守规则,所以它运行的没问题。另一方面,thread_b()⑨无视规则,因此在运行的时候肯定会失败。thread_a()调用high_level_func(),让high_level_mutex④上锁(其层级值为10000①),为了获取high_level_stuff()的参数对互斥量上锁,之后调用low_level_func()⑤。low_level_func()会对low_level_mutex上锁,这就没有问题了,因为这个互斥量有一个低层值5000②。

thread_b()运行就不会顺利了。首先,它锁住了other_mutex⑩,这个互斥量的层级值只有100⑦。这就意味着,超低层级的数据已被保护。当other_stuff()调用high_level_func()⑧时,就违反了层级结构:high_level_func()试图获取high_level_mutex,这个互斥量的层级值是10000,要比当前层级值100大很多。因此hierarchical_mutex将会产生一个错误,可能会是抛出一个异常,或直接终止程序。在层级互斥量上产生死锁,是不可能的,因为互斥量本身会严格遵循约定顺序,进行上锁。这也意味,当多个互斥量在是在同一级上时,不能同时持有多个锁,所以“手递手”锁的方案需要每个互斥量在一条链上,并且每个互斥量都比其前一个有更低的层级值,这在某些情况下无法实现。

例子也展示了另一点,std::lock_guard<>模板与用户定义的互斥量类型一起使用。虽然hierarchical_mutex不是C++标准的一部分,但是它写起来很容易。尽管它是一个用户定义类型,它可以用于std::lock_guard<>模板中,因为它的实现有三个成员函数为了满足互斥量操作:lock(), unlock()try_lock()。虽然你还没见过try_lock()怎么使用,但是其使用起来很简单:当互斥量上的锁被一个线程持有,它将返回false,而不是等待调用的线程,直到能够获取互斥量上的锁为止。在std::lock()的内部实现中,try_lock()会作为避免死锁算法的一部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class hierarchical_mutex
{
std::mutex internal_mutex;
unsigned long const hierarchy_value;
unsigned long previous_hierarchy_value;
static thread_local unsigned long this_thread_hierarchy_value; // 1
void check_for_hierarchy_violation()
{
if(this_thread_hierarchy_value <= hierarchy_value) // 2
{
throw std::logic_error(“mutex hierarchy violated”);
}
}
void update_hierarchy_value()
{
previous_hierarchy_value=this_thread_hierarchy_value; // 3
this_thread_hierarchy_value=hierarchy_value;
}
public:
explicit hierarchical_mutex(unsigned long value):
hierarchy_value(value),
previous_hierarchy_value(0)
{}
void lock()
{
check_for_hierarchy_violation();
internal_mutex.lock(); // 4
update_hierarchy_value(); // 5
}
void unlock()
{
this_thread_hierarchy_value=previous_hierarchy_value; // 6
internal_mutex.unlock();
}
bool try_lock()
{
check_for_hierarchy_violation();
if(!internal_mutex.try_lock()) // 7
return false;
update_hierarchy_value();
return true;
}
};
thread_local unsigned long
hierarchical_mutex::this_thread_hierarchy_value(ULONG_MAX); // 8

这里重点是使用了thread_local的值来代表当前线程的层级值:this_thread_hierarchy_value①。它被初始化为最大值⑧,所以最初所有线程都能被锁住。因为其声明中有thread_local,所以每个线程都有其拷贝副本,这样线程中变量状态完全独立,当从另一个线程进行读取时,变量的状态也完全独立。

所以,第一次线程锁住一个hierarchical_mutex时,this_thread_hierarchy_value的值是ULONG_MAX。由于其本身的性质,这个值会大于其他任何值,所以会通过check_for_hierarchy_vilation()②的检查。在这种检查方式下,lock()代表内部互斥锁已被锁住④。一旦成功锁住,你可以更新层级值了⑤。

当你现在锁住另一个hierarchical_mutex时,还持有第一个锁,this_thread_hierarchy_value的值将会显示第一个互斥量的层级值。第二个互斥量的层级值必须小于已经持有互斥量检查函数②才能通过。

现在,最重要的是为当前线程存储之前的层级值,所以你可以调用unlock()⑥对层级值进行保存;否则,就锁不住任何互斥量(第二个互斥量的层级数高于第一个互斥量),即使线程没有持有任何锁。因为保存了之前的层级值,只有当持有internal_mutex③,且在解锁内部互斥量⑥之前存储它的层级值,才能安全的将hierarchical_mutex自身进行存储。这是因为hierarchical_mutex被内部互斥量的锁所保护着。

try_lock()lock()的功能相似,除了在调用internal_mutextry_lock()⑦失败时,不能持有对应锁,所以不必更新层级值,并直接返回false。

std::unique_lock——灵活的锁

std::unqiue_lock使用更为自由的不变量,这样std::unique_lock实例不会总与互斥量的数据类型相关,使用起来要比std:lock_guard更加灵活。首先,可将std::adopt_lock作为第二个参数传入构造函数,对互斥量进行管理;也可以将std::defer_lock作为第二个参数传递进去,表明互斥量应保持解锁状态。这样,就可以被std::unique_lock对象(不是互斥量)的lock()函数的所获取,或传递std::unique_lock对象到std::lock()中。保证灵活性要付出代价,这个代价就是允许std::unique_lock实例不带互斥量:信息已被存储,且已被更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class some_big_object;
void swap(some_big_object& lhs,some_big_object& rhs);
class X
{
private:
some_big_object some_detail;
std::mutex m;
public:
X(some_big_object const& sd):some_detail(sd){}
friend void swap(X& lhs, X& rhs)
{
if(&lhs==&rhs)
return;
std::unique_lock<std::mutex> lock_a(lhs.m,std::defer_lock); // 1
std::unique_lock<std::mutex> lock_b(rhs.m,std::defer_lock); // 1 std::def_lock 留下未上锁的互斥量
std::lock(lock_a,lock_b); // 2 互斥量在这里上锁
swap(lhs.some_detail,rhs.some_detail);
}
};

因为std::unique_lock支持lock(), try_lock()unlock()成员函数,所以能将std::unique_lock对象传递到std::lock()②。这些同名的成员函数在低层做着实际的工作,并且仅更新std::unique_lock实例中的标志,来确定该实例是否拥有特定的互斥量,这个标志是为了确保unlock()在析构函数中被正确调用。如果实例拥有互斥量,那么析构函数必须调用unlock();但当实例中没有互斥量时,析构函数就不能去调用unlock()。这个标志可以通过owns_lock()成员变量进行查询。

可能如你期望的那样,这个标志被存储在某个地方。因此,std::unique_lock对象的体积通常要比std::lock_guard对象大,当使用std::unique_lock替代std::lock_guard,因为会对标志进行适当的更新或检查,就会做些轻微的性能惩罚。当std::lock_guard已经能够满足你的需求,那么还是建议你继续使用它。当需要更加灵活的锁时,最好选择std::unique_lock,因为它更适合于你的任务。

不同域中互斥量所有权的传递

std::unique_lock实例没有与自身相关的互斥量,一个互斥量的所有权可以通过移动操作,在不同的实例中进行传递。某些情况下,这种转移是自动发生的,例如:当函数返回一个实例;另些情况下,需要显式的调用std::move()来执行移动操作。从本质上来说,需要依赖于源值是否是左值——一个实际的值或是引用——或一个右值——一个临时类型。当源值是一个右值,为了避免转移所有权过程出错,就必须显式移动成左值。std::unique_lock是可移动,但不可赋值的类型。

一种使用可能是允许一个函数去锁住一个互斥量,并且将所有权移到调用者上,所以调用者可以在这个锁保护的范围内执行额外的动作。

下面的程序片段展示了:函数get_lock()锁住了互斥量,然后准备数据,返回锁的调用函数:

1
2
3
4
5
6
7
8
9
10
11
12
std::unique_lock<std::mutex> get_lock()
{
extern std::mutex some_mutex;
std::unique_lock<std::mutex> lk(some_mutex);
prepare_data();
return lk; // 1
}
void process_data()
{
std::unique_lock<std::mutex> lk(get_lock()); // 2
do_something();
}

lk在函数中被声明为自动变量,它不需要调用std::move(),可以直接返回①(编译器负责调用移动构造函数)。process_data()函数直接转移std::unique_lock实例的所有权②,调用do_something()可使用的正确数据(数据没有受到其他线程的修改)。

通常这种模式会用于已锁的互斥量,其依赖于当前程序的状态,或依赖于传入返回类型为std::unique_lock的函数(或以参数返回)。这样的用法不会直接返回锁,不过网关类的一个数据成员可用来确认已经对保护数据的访问权限进行上锁。这种情况下,所有的访问都必须通过网关类:当你想要访问数据,需要获取网关类的实例(如同前面的例子,通过调用get_lock()之类函数)来获取锁。之后你就可以通过网关类的成员函数对数据进行访问。当完成访问,可以销毁这个网关类对象,将锁进行释放,让别的线程来访问保护数据。这样的一个网关类可能是可移动的(所以他可以从一个函数进行返回),在这种情况下锁对象的数据必须是可移动的。

std::unique_lock的灵活性同样也允许实例在销毁之前放弃其拥有的锁。可以使用unlock()来做这件事,如同一个互斥量:std::unique_lock的成员函数提供类似于锁定和解锁互斥量的功能。std::unique_lock实例在销毁前释放锁的能力,当锁没有必要在持有的时候,可以在特定的代码分支对其进行选择性的释放。这对于应用性能来说很重要,因为持有锁的时间增加会导致性能下降,其他线程会等待这个锁的释放,避免超越操作。

锁的粒度

一个细粒度锁(a fine-grained lock)能够保护较小的数据量,一个粗粒度锁(a coarse-grained lock)能够保护较多的数据量。

如果很多线程正在等待同一个资源,当有线程持有锁的时间过长,这就会增加等待的时间。在可能的情况下,锁住互斥量的同时只能对共享数据进行访问;试图对锁外数据进行处理。

std::unique_lock在这种情况下工作正常,在调用unlock()时,代码不需要再访问共享数据;而后当再次需要对共享数据进行访问时,就可以再调用lock()了。下面代码就是这样的一种情况:

1
2
3
4
5
6
7
8
9
void get_and_process_data()
{
std::unique_lock<std::mutex> my_lock(the_mutex);
some_class data_to_process=get_next_data_chunk();
my_lock.unlock(); // 1 不要让锁住的互斥量越过process()函数的调用
result_type result=process(data_to_process);
my_lock.lock(); // 2 为了写入数据,对互斥量再次上锁
write_result(data_to_process,result);
}

不需要让锁住的互斥量越过对process()函数的调用,所以可以在函数调用①前对互斥量手动解锁,并且在之后对其再次上锁②。

这能表示只有一个互斥量保护整个数据结构时的情况,不仅可能会有更多对锁的竞争,也会增加锁持锁的时间。较多的操作步骤需要获取同一个互斥量上的锁,所以持有锁的时间会更长。成本上的双重打击也算是为向细粒度锁转移提供了双重激励和可能。

如同上面的例子,锁不仅是能锁住合适粒度的数据,还要控制锁的持有时间,以及什么操作在执行的同时能够拥有锁。一般情况下,执行必要的操作时,尽可能将持有锁的时间缩减到最小。这也就意味有一些浪费时间的操作,比如:获取另外一个锁(即使你知道这不会造成死锁),或等待输入/输出操作完成时没有必要持有一个锁(除非绝对需要)。

保护共享数据的替代设施

互斥量是最通用的机制,但其并非保护共享数据的唯一方式。这里有很多替代方式可以在特定情况下,提供更加合适的保护。

一个特别极端(但十分常见)的情况就是,共享数据在并发访问和初始化时(都需要保护),但是之后需要进行隐式同步。这可能是因为数据作为只读方式创建,所以没有同步问题;或者因为必要的保护作为对数据操作的一部分,所以隐式的执行。任何情况下,数据初始化后锁住一个互斥量,纯粹是为了保护其初始化过程(这是没有必要的),并且这会给性能带来不必要的冲击。出于以上的原因,C++标准提供了一种纯粹保护共享数据初始化过程的机制。

保护共享数据的初始化过程

假设你与一个共享源,构建代价很昂贵,可能它会打开一个数据库连接或分配出很多的内存。

延迟初始化(Lazy initialization)在单线程代码很常见——每一个操作都需要先对源进行检查,为了了解数据是否被初始化,然后在其使用前决定,数据是否需要初始化:

1
2
3
4
5
6
7
8
9
std::shared_ptr<some_resource> resource_ptr;
void foo()
{
if(!resource_ptr)
{
resource_ptr.reset(new some_resource); // 1
}
resource_ptr->do_something();
}

当共享数据对于并发访问是安全的,①是转为多线程代码时,需要保护的,但是下面天真的转换会使得线程资源产生不必要的序列化。这是因为每个线程必须等待互斥量,为了确定数据源已经初始化了。

1
2
3
4
5
6
7
8
9
10
11
12
std::shared_ptr<some_resource> resource_ptr;
std::mutex resource_mutex;
void foo()
{
std::unique_lock<std::mutex> lk(resource_mutex); // 所有线程在此序列化
if(!resource_ptr)
{
resource_ptr.reset(new some_resource); // 只有初始化过程需要保护
}
lk.unlock();
resource_ptr->do_something();
}

这段代码相当常见了,也足够表现出没必要的线程化问题,很多人能想出更好的一些的办法来做这件事,包括声名狼藉的双重检查锁模式:

1
2
3
4
5
6
7
8
9
10
11
12
void undefined_behaviour_with_double_checked_locking()
{
if(!resource_ptr) // 1
{
std::lock_guard<std::mutex> lk(resource_mutex);
if(!resource_ptr) // 2
{
resource_ptr.reset(new some_resource); // 3
}
}
resource_ptr->do_something(); // 4
}

指针第一次读取数据不需要获取锁①,并且只有在指针为NULL时才需要获取锁。然后,当获取锁之后,指针会被再次检查一遍② (这就是双重检查的部分),避免另一的线程在第一次检查后再做初始化,并且让当前线程获取锁。

这个模式为什么声名狼藉呢?因为这里有潜在的条件竞争,未被锁保护的读取操作①没有与其他线程里被锁保护的写入操作③进行同步。因此就会产生条件竞争,这个条件竞争不仅覆盖指针本身,还会影响到其指向的对象;即使一个线程知道另一个线程完成对指针进行写入,它可能没有看到新创建的some_resource实例,然后调用do_something()④后,得到不正确的结果。这个例子是在一种典型的条件竞争——数据竞争,C++标准中这就会被指定为“未定义行为”。这种竞争肯定是可以避免的。

C++标准委员会也认为条件竞争的处理很重要,所以C++标准库提供了std::once_flagstd::call_once来处理这种情况。比起锁住互斥量,并显式的检查指针,每个线程只需要使用std::call_once,在std::call_once的结束时,就能安全的知道指针已经被其他的线程初始化了。使用std::call_once比显式使用互斥量消耗的资源更少,特别是当初始化完成后。在这种情况下,初始化通过调用函数完成,同样这样操作使用类中的函数操作符来实现同样很简单。如同大多数在标准库中的函数一样,或作为函数被调用,或作为参数被传递,std::call_once可以和任何函数或可调用对象一起使用。

1
2
3
4
5
6
7
8
9
10
11
std::shared_ptr<some_resource> resource_ptr;
std::once_flag resource_flag; // 1
void init_resource()
{
resource_ptr.reset(new some_resource);
}
void foo()
{
std::call_once(resource_flag,init_resource); // 可以完整的进行一次初始化
resource_ptr->do_something();
}

在这个例子中,std::once_flag①和初始化好的数据都是命名空间区域的对象,但是std::call_once()可仅作为延迟初始化的类型成员,如同下面的例子一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class X
{
private:
connection_info connection_details;
connection_handle connection;
std::once_flag connection_init_flag;
void open_connection()
{
connection=connection_manager.open(connection_details);
}
public:
X(connection_info const& connection_details_):
connection_details(connection_details_)
{}
void send_data(data_packet const& data) // 1
{
std::call_once(connection_init_flag,&X::open_connection,this); // 2
connection.send_data(data);
}
data_packet receive_data() // 3
{
std::call_once(connection_init_flag,&X::open_connection,this); // 2
return connection.receive_data();
}
};

例子中第一个调用send_data()①或receive_data()③的线程完成初始化过程。使用成员函数open_connection()去初始化数据,也需要将this指针传进去。和其在在标准库中的函数一样,其接受可调用对象,比如std::thread的构造函数和std::bind(),通过向std::call_once()②传递一个额外的参数来完成这个操作。

值得注意的是,std::mutexstd::once_flag的实例就不能拷贝和移动,所以当你使用它们作为类成员函数,如果你需要用到他们,你就得显示定义这些特殊的成员函数。

还有一种情形的初始化过程中潜存着条件竞争:其中一个局部变量被声明为static类型。这种变量的在声明后就已经完成初始化;对于多线程调用的函数,这就意味着这里有条件竞争——抢着去定义这个变量。在C++11标准中,初始化及定义完全在一个线程中发生,并且没有其他线程可在初始化完成前对其进行处理,条件竞争终止于初始化阶段,这样比在之后再去处理好的多。在只需要一个全局实例情况下,这里提供一个std::call_once的替代方案

1
2
3
4
5
6
class my_class;
my_class& get_my_class_instance()
{
static my_class instance; // 线程安全的初始化过程
return instance;
}

多线程可以安全的调用get_my_class_instance()①函数,不用为数据竞争而担心。

对于很少有更新的数据结构来说,只在初始化时保护数据。在大多数情况下,这种数据结构是只读的,并且多线程对其并发的读取也是很愉快的,不过一旦数据结构需要更新,就会产生竞争。

保护很少更新的数据结构

比起使用std::mutex实例进行同步,不如使用boost::shared_mutex来做同步。对于更新操作,可以使用std::lock_guard<boost::shared_mutex>std::unique_lock<boost::shared_mutex>上锁。作为std::mutex的替代方案,这就能保证更新线程的独占访问。因为其他线程不需要去修改数据结构,所以其可以使用boost::shared_lock<boost::shared_mutex>获取访问权。这与使用std::unique_lock一样,除非多线程要在同时获取同一个boost::shared_mutex上有共享锁。唯一的限制:当任一线程拥有一个共享锁时,这个线程就会尝试获取一个独占锁,直到其他线程放弃他们的锁;同样的,当任一线程拥有一个独占锁时,其他线程就无法获得共享锁或独占锁,直到第一个线程放弃其拥有的锁。

如同之前描述的那样,下面的代码清单展示了一个简单的DNS缓存,使用std::map持有缓存数据,使用boost::shared_mutex进行保护。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <map>
#include <string>
#include <mutex>
#include <boost/thread/shared_mutex.hpp>
class dns_entry;
class dns_cache
{
std::map<std::string,dns_entry> entries;
mutable boost::shared_mutex entry_mutex;
public:
dns_entry find_entry(std::string const& domain) const
{
boost::shared_lock<boost::shared_mutex> lk(entry_mutex); // 1
std::map<std::string,dns_entry>::const_iterator const it=
entries.find(domain);
return (it==entries.end())?dns_entry():it->second;
}
void update_or_add_entry(std::string const& domain,
dns_entry const& dns_details)
{
std::lock_guard<boost::shared_mutex> lk(entry_mutex); // 2
entries[domain]=dns_details;
}
};

find_entry()使用boost::shared_lock<>来保护共享和只读权限①;这就使得多线程可以同时调用find_entry(),且不会出错。另一方面,update_or_add_entry()使用std::lock_guard<>实例,当表格需要更新时②,为其提供独占访问权限;update_or_add_entry()函数调用时,独占锁会阻止其他线程对数据结构进行修改,并且阻止线程调用find_entry()

嵌套锁

当一个线程已经获取一个std::mutex时(已经上锁),并对其再次上锁,这个操作就是错误的,并且继续尝试这样做的话,就会产生未定义行为。然而,在某些情况下,一个线程尝试获取同一个互斥量多次,而没有对其进行一次释放是可以的。之所以可以,是因为C++标准库提供了std::recursive_mutex类。其功能与std::mutex类似,除了你可以从同一线程的单个实例上获取多个锁。互斥量锁住其他线程前,你必须释放你拥有的所有锁,所以当你调用lock()三次时,你也必须调用unlock()三次。正确使用std::lock_guard<std::recursive_mutex>std::unique_lock<std::recursice_mutex>可以帮你处理这些问题。

同步并发操作

等待一个事件或其他条件

当一个线程等待另一个线程完成任务时,它可以持续的检查共享数据标志(用于做保护工作的互斥量),直到另一线程完成工作时对这个标志进行重设。不过,就是一种浪费:线程消耗宝贵的执行时间持续的检查对应标志,并且当互斥量被等待线程上锁后,其他线程就没有办法获取锁,这样线程就会持续等待。

第二个选择是在等待线程在检查间隙,使用std::this_thread::sleep_for()进行周期性的间歇:

1
2
3
4
5
6
7
8
9
10
11
12
bool flag;
std::mutex m;
void wait_for_flag()
{
std::unique_lock<std::mutex> lk(m);
while(!flag)
{
lk.unlock(); // 1 解锁互斥量
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 2 休眠100ms
lk.lock(); // 3 再锁互斥量
}
}

这个循环中,在休眠前②,函数对互斥量进行解锁①,并且在休眠结束后再对互斥量进行上锁,所以另外的线程就有机会获取锁并设置标识。

第三个选择(也是优先的选择)是,使用C++标准库提供的工具去等待事件的发生。通过另一线程触发等待事件的机制是最基本的唤醒方式,这种机制就称为“条件变量”。从概念上来说,一个条件变量会与多个事件或其他条件相关,并且一个或多个线程会等待条件的达成。当某些线程被终止时,为了唤醒等待线程(允许等待线程继续执行)终止的线程将会向等待着的线程广播“条件达成”的信息。

等待条件达成

C++标准库对条件变量有两套实现:std::condition_variablestd::condition_variable_any。这两个实现都包含在<condition_variable>头文件的声明中。两者都需要与一个互斥量一起才能工作(互斥量是为了同步);前者仅限于与std::mutex一起工作,而后者可以和任何满足最低标准的互斥量一起工作,从而加上了_any的后缀。因为std::condition_variable_any更加通用,这就可能从体积、性能,以及系统资源的使用方面产生额外的开销,所以std::condition_variable一般作为首选的类型,当对灵活性有硬性要求时,我们才会去考虑std::condition_variable_any

所以,如何使用std::condition_variable去处理之前提到的情况——当有数据需要处理时,如何唤醒休眠中的线程对其进行处理?以下清单展示了一种使用条件变量做唤醒的方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
std::mutex mut;
std::queue<data_chunk> data_queue; // 1
std::condition_variable data_cond;
void data_preparation_thread()
{
while(more_data_to_prepare())
{
data_chunk const data=prepare_data();
std::lock_guard<std::mutex> lk(mut);
data_queue.push(data); // 2
data_cond.notify_one(); // 3
}
}
void data_processing_thread()
{
while(true)
{
std::unique_lock<std::mutex> lk(mut); // 4
data_cond.wait(
lk,[]{return !data_queue.empty();}); // 5
data_chunk data=data_queue.front();
data_queue.pop();
lk.unlock(); // 6
process(data);
if(is_last_chunk(data))
break;
}
}

首先,你拥有一个用来在两个线程之间传递数据的队列①。当数据准备好时,使用std::lock_guard对队列上锁,将准备好的数据压入队列中②,之后线程会对队列中的数据上锁。然后调用std::condition_variablenotify_one()成员函数,对等待的线程(如果有等待线程)进行通知③。

在另外一侧,你有一个正在处理数据的线程,这个线程首先对互斥量上锁,但在这里std::unique_lock要比std::lock_guard④更加合适——且听我细细道来。线程之后会调用std::condition_variable的成员函数wait(),传递一个锁和一个lambda函数表达式(作为等待的条件⑤)。

wait()会去检查这些条件,如果条件不满足,wait()函数将解锁互斥量,并且将这个线程置于阻塞或等待状态。当准备数据的线程调用notify_one()通知条件变量时,处理数据的线程从睡眠状态中苏醒,重新获取互斥锁,并且对条件再次检查,在条件满足的情况下,从wait()返回并继续持有锁。当条件不满足时,线程将对互斥量解锁,并且重新开始等待。

在调用wait()的过程中,一个条件变量可能会去检查给定条件若干次;然而,它总是在互斥量被锁定时这样做,当且仅当提供测试条件的函数返回true时,它就会立即返回。当等待线程重新获取互斥量并检查条件时,如果它并非直接响应另一个线程的通知,这就是所谓的伪唤醒(spurious wakeup)。

使用条件变量构建线程安全队列

当使用队列在多个线程中传递数据时,接收线程通常需要等待数据的压入。这里我们提供pop()函数的两个变种:try_pop()wait_and_pop()try_pop()尝试从队列中弹出数据,总会直接返回(当有失败时),即使没有值可检索;wait_and_pop(),将会等待有值可检索的时候才返回。当你使用之前栈的方式来实现你的队列,你实现的队列接口就可能会是下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <memory> // 为了使用std::shared_ptr
template<typename T>
class threadsafe_queue
{
public:
threadsafe_queue();
threadsafe_queue(const threadsafe_queue&);
threadsafe_queue& operator=(
const threadsafe_queue&) = delete; // 不允许简单的赋值
void push(T new_value);
bool try_pop(T& value); // 1
std::shared_ptr<T> try_pop(); // 2
void wait_and_pop(T& value);
std::shared_ptr<T> wait_and_pop();
bool empty() const;
};

就像之前对栈做的那样,在这里你将很多构造函数剪掉了,并且禁止了对队列的简单赋值。和之前一样,你也需要提供两个版本的try_pop()wait_for_pop()。第一个重载的try_pop()①在引用变量中存储着检索值,所以它可以用来返回队列中值的状态;当检索到一个变量时,他将返回true,否则将返回false。第二个重载②就不能做这样了,因为它是用来直接返回检索值的。当没有值可检索时,这个函数可以返回NULL指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <queue>
#include <mutex>
#include <condition_variable>
template<typename T>
class threadsafe_queue
{
private:
std::mutex mut;
std::queue<T> data_queue;
std::condition_variable data_cond;
public:
void push(T new_value)
{
std::lock_guard<std::mutex> lk(mut);
data_queue.push(new_value);
data_cond.notify_one();
}
void wait_and_pop(T& value)
{
std::unique_lock<std::mutex> lk(mut);
data_cond.wait(lk,[this]{return !data_queue.empty();});
value=data_queue.front();
data_queue.pop();
}
};

threadsafe_queue<data_chunk> data_queue; // 1
void data_preparation_thread()
{
while(more_data_to_prepare())
{
data_chunk const data=prepare_data();
data_queue.push(data); // 2
}
}
void data_processing_thread()
{
while(true)
{
data_chunk data;
data_queue.wait_and_pop(data); // 3
process(data);
if(is_last_chunk(data))
break;
}
}

线程队列的实例中包含有互斥量和条件变量,所以独立的变量就不需要了①,并且调用push()也不需要外部同步②。当然,wait_and_pop()还要兼顾条件变量的等待③。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#include <queue>
#include <memory>
#include <mutex>
#include <condition_variable>
template<typename T>
class threadsafe_queue
{
private:
mutable std::mutex mut; // 1 互斥量必须是可变的
std::queue<T> data_queue;
std::condition_variable data_cond;
public:
threadsafe_queue()
{}
threadsafe_queue(threadsafe_queue const& other)
{
std::lock_guard<std::mutex> lk(other.mut);
data_queue=other.data_queue;
}
void push(T new_value)
{
std::lock_guard<std::mutex> lk(mut);
data_queue.push(new_value);
data_cond.notify_one();
}
void wait_and_pop(T& value)
{
std::unique_lock<std::mutex> lk(mut);
data_cond.wait(lk,[this]{return !data_queue.empty();});
value=data_queue.front();
data_queue.pop();
}
std::shared_ptr<T> wait_and_pop()
{
std::unique_lock<std::mutex> lk(mut);
data_cond.wait(lk,[this]{return !data_queue.empty();});
std::shared_ptr<T> res(std::make_shared<T>(data_queue.front()));
data_queue.pop();
return res;
}
bool try_pop(T& value)
{
std::lock_guard<std::mutex> lk(mut);
if(data_queue.empty())
return false;
value=data_queue.front();
data_queue.pop();
return true;
}
std::shared_ptr<T> try_pop()
{
std::lock_guard<std::mutex> lk(mut);
if(data_queue.empty())
return std::shared_ptr<T>();
std::shared_ptr<T> res(std::make_shared<T>(data_queue.front()));
data_queue.pop();
return res;
}
bool empty() const
{
std::lock_guard<std::mutex> lk(mut);
return data_queue.empty();
}
};

empty()是一个const成员函数,并且传入拷贝构造函数的other形参是一个const引用;因为其他线程可能有这个类型的非const引用对象,并调用变种成员函数,所以这里有必要对互斥量上锁。如果锁住互斥量是一个可变操作,那么这个互斥量对象就会标记为可变的①,之后他就可以在empty()和拷贝构造函数中上锁了。

条件变量在多个线程等待同一个事件时,也是很有用的。当线程用来分解工作负载,并且只有一个线程可以对通知做出反应;运行多个数据实例——处理线程(processing thread)。当新的数据准备完成,调用notify_one()将会触发一个正在执行wait()的线程,去检查条件和wait()函数的返回状态(因为你仅是向data_queue添加一个数据项)。 这里不保证线程一定会被通知到,即使只有一个等待线程被通知时,所有处线程也有可能都在处理数据。

另一种可能是,很多线程等待同一事件,对于通知他们都需要做出回应。这会发生在共享数据正在初始化的时候,当处理线程可以使用同一数据时,就要等待数据被初始化,或等待共享数据的更新,比如,定期重新初始化(periodic reinitialization)。在这些情况下,准备线程准备数据数据时,就会通过条件变量调用notify_all()成员函数,而非直接调用notify_one()函数。

当等待线程只等待一次,当条件为true时,它就不会再等待条件变量了,所以一个条件变量可能并非同步机制的最好选择。尤其是,条件在等待一组可用的数据块时。在这样的情况下,期望(future)就是一个适合的选择。

使用期望等待一次性事件

当一个线程需要等待一个特定的一次性事件时,在某种程度上来说它就需要知道这个事件在未来的表现形式。之后,这个线程会周期性的检查事件是否触发;在检查期间也会执行其他任务。另外,在等待任务期间它可以先执行另外一些任务,直到对应的任务触发,而后等待期望的状态会变为就绪(ready)。一个“期望”可能是数据相关的。

在C++标准库中,有两种“期望”,使用两种类型模板实现,声明在头文件中: 唯一期望(unique futures)(std::future<>)和共享期望(shared futures)(std::shared_future<>)。这是仿照std::unique_ptrstd::shared_ptrstd::future的实例只能与一个指定事件相关联,而std::shared_future的实例就能关联多个事件。后者的实现中,所有实例会在同时变为就绪状态,并且他们可以访问与事件相关的任何数据。这种数据关联与模板有关,比如std::unique_ptrstd::shared_ptr的模板参数就是相关联的数据类型。在与数据无关的地方,可以使用std::future<void>std::shared_future<void>的特化模板。虽然,我希望用于线程间的通讯,但是“期望”对象本身并不提供同步访问。当多个线程需要访问一个独立“期望”对象时,他们必须使用互斥量或类似同步机制对访问进行保护。

带返回值的后台任务

当任务的结果你不着急要时,你可以使用std::async启动一个异步任务。与std::thread对象等待的方式不同,std::async会返回一个std::future对象,这个对象持有最终计算出来的结果。当你需要这个值时,你只需要调用这个对象的get()成员函数;并且会阻塞线程直到“期望”状态为就绪为止;之后,返回计算结果。下面清单中代码就是一个简单的例子。

1
2
3
4
5
6
7
8
9
10
#include <future>
#include <iostream>
int find_the_answer_to_ltuae();
void do_other_stuff();
int main()
{
std::future<int> the_answer=std::async(find_the_answer_to_ltuae);
do_other_stuff();
std::cout<<"The answer is "<<the_answer.get()<<std::endl;
}

std::thread做的方式一样,std::async允许你通过添加额外的调用参数,向函数传递额外的参数。当第一个参数是一个指向成员函数的指针,第二个参数提供有这个函数成员类的具体对象,剩余的参数可作为成员函数的参数传入。否则,第二个和随后的参数将作为函数的参数,或作为指定可调用对象的第一个参数。就如std::thread,当参数为右值(rvalues)时,拷贝操作将使用移动的方式转移原始数据。这就允许使用“只移动”类型作为函数对象和参数。来看一下下面的程序清单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <string>
#include <future>
struct X
{
void foo(int,std::string const&);
std::string bar(std::string const&);
};
X x;
auto f1=std::async(&X::foo,&x,42,"hello"); // 调用p->foo(42, "hello"),p是指向x的指针
auto f2=std::async(&X::bar,x,"goodbye"); // 调用tmpx.bar("goodbye"), tmpx是x的拷贝副本
struct Y
{
double operator()(double);
};
Y y;
auto f3=std::async(Y(),3.141); // 调用tmpy(3.141),tmpy通过Y的移动构造函数得到
auto f4=std::async(std::ref(y),2.718); // 调用y(2.718)
X baz(X&);
std::async(baz,std::ref(x)); // 调用baz(x)
class move_only
{
public:
move_only();
move_only(move_only&&)
move_only(move_only const&) = delete;
move_only& operator=(move_only&&);
move_only& operator=(move_only const&) = delete;
void operator()();
};
auto f5=std::async(move_only()); // 调用tmp(),tmp是通过std::move(move_only())构造得到

在默认情况下,“期望”是否进行等待取决于std::async是否启动一个线程,或是否有任务正在进行同步。你也可以在函数调用之前,向std::async传递一个额外参数。这个参数的类型是std::launch,还可以是std::launch::defered,用来表明函数调用被延迟到wait()get()函数调用时才执行,std::launch::async表明函数必须在其所在的独立线程上执行,std::launch::deferred | std::launch::async表明实现可以选择这两种方式的一种。最后一个选项是默认的。当函数调用被延迟,它可能不会在运行了。如下所示:

1
2
3
4
5
6
7
auto f6=std::async(std::launch::async,Y(),1.2);  // 在新线程上执行
auto f7=std::async(std::launch::deferred,baz,std::ref(x)); // 在wait()或get()调用时执行
auto f8=std::async(
std::launch::deferred | std::launch::async,
baz,std::ref(x)); // 实现选择执行方式
auto f9=std::async(baz,std::ref(x));
f7.wait(); // 调用延迟函数

任务与期望

std::packaged_task<>对一个函数或可调用对象,绑定一个期望。当std::packaged_task<>对象被调用,它就会调用相关函数或可调用对象,将期望状态置为就绪,返回值也会被存储为相关数据。这可以用在构建线程池的结构单元,或用于其他任务的管理,比如在任务所在线程上运行任务,或将它们顺序的运行在一个特殊的后台线程上。当一个粒度较大的操作可以被分解为独立的子任务时,其中每个子任务就可以包含在一个std::packaged_task<>实例中,之后这个实例将传递到任务调度器或线程池中。对任务的细节进行抽象,调度器仅处理std::packaged_task<>实例,而非处理单独的函数。

std::packaged_task<>的模板参数是一个函数签名,比如void()就是一个没有参数也没有返回值的函数,或int(std::string&, double*)就是有一个非const引用的std::string和一个指向double类型的指针,并且返回类型是int。当你构造出一个std::packaged_task<>实例时,你必须传入一个函数或可调用对象,这个函数或可调用的对象需要能接收指定的参数和返回可转换为指定返回类型的值。类型可以不完全匹配;你可以用一个int类型的参数和返回一个float类型的函数,来构建std::packaged_task<double(double)>的实例,因为在这里,类型可以隐式转换。

指定函数签名的返回类型可以用来标识,从get_future()返回的std::future<>的类型,不过函数签名的参数列表,可用来指定“打包任务”的函数调用操作符。例如,模板偏特化std::packaged_task<std::string(std::vector<char>*,int)>将在下面的代码清单中使用。

1
2
3
4
5
6
7
8
9
template<>
class packaged_task<std::string(std::vector<char>*,int)>
{
public:
template<typename Callable>
explicit packaged_task(Callable&& f);
std::future<std::string> get_future();
void operator()(std::vector<char>*,int);
};

这里的std::packaged_task对象是一个可调用对象,并且它可以包含在一个std::function对象中,传递到std::thread对象中,就可作为线程函数;传递另一个函数中,就作为可调用对象,或可以直接进行调用。当std::packaged_task作为一个函数调用时,可为函数调用操作符提供所需的参数,并且返回值作为异步结果存储在std::future,可通过get_future()获取。你可以把一个任务包含入std::packaged_task,并且在检索期望之前,需要将std::packaged_task对象传入,以便调用时能及时的找到。

当你需要异步任务的返回值时,你可以等待期望的状态变为“就绪”。下面的代码就是这么个情况。

很多图形架构需要特定的线程去更新界面,所以当一个线程需要界面的更新时,它需要发出一条信息给正确的线程,让特定的线程来做界面更新。std::packaged_task提供了完成这种功能的一种方法,且不需要发送一条自定义信息给图形界面相关线程。下面来看看代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <deque>
#include <mutex>
#include <future>
#include <thread>
#include <utility>
std::mutex m;
std::deque<std::packaged_task<void()> > tasks;
bool gui_shutdown_message_received();
void get_and_process_gui_message();
void gui_thread() // 1
{
while(!gui_shutdown_message_received()) // 2
{
get_and_process_gui_message(); // 3
std::packaged_task<void()> task;
{
std::lock_guard<std::mutex> lk(m);
if(tasks.empty()) // 4
continue;
task=std::move(tasks.front()); // 5
tasks.pop_front();
}
task(); // 6
}
}
std::thread gui_bg_thread(gui_thread);

template<typename Func>
std::future<void> post_task_for_gui_thread(Func f)
{
std::packaged_task<void()> task(f); // 7
std::future<void> res=task.get_future(); // 8
std::lock_guard<std::mutex> lk(m); // 9
tasks.push_back(std::move(task)); // 10
return res;
}

这段代码十分简单:图形界面线程①循环直到收到一条关闭图形界面的信息后关闭②,进行轮询界面消息处理③,例如用户点击,和执行在队列中的任务。当队列中没有任务④,它将再次循环;除非,他能在队列中提取出一个任务⑤,然后释放队列上的锁,并且执行任务⑥。这里,“期望”与任务相关,当任务执行完成时,其状态会被置为“就绪”状态。

将一个任务传入队列,也很简单:提供的函数⑦可以提供一个打包好的任务,可以通过这个任务⑧调用get_future()成员函数获取“期望”对象,并且在任务被推入列表⑨之前,“期望”将返回调用函数⑩。当需要知道线程执行完任务时,向图形界面线程发布消息的代码,会等待“期望”改变状态;否则,则会丢弃这个“期望”。

这个例子使用std::packaged_task<void()>创建任务,其包含了一个无参数无返回值的函数或可调用对象(如果当这个调用有返回值时,返回值会被丢弃)。这可能是最简单的任务,如你之前所见,std::packaged_task也可以用于一些复杂的情况——通过指定一个不同的函数签名作为模板参数,你不仅可以改变其返回类型(因此该类型的数据会存在期望相关的状态中),而且也可以改变函数操作符的参数类型。这个例子可以简单的扩展成允许任务运行在图形界面线程上,且接受传参,还有通过std::future返回值,而不仅仅是完成一个指标。

这些任务能作为一个简单的函数调用来表达吗?还有,这些任务的结果能从很多地方得到吗?这些情况可以使用第三种方法创建“期望”来解决:使用std::promise对值进行显示设置。

使用std::promises

std::promise<T>提供设定值的方式(类型为T),这个类型会和后面看到的std::future<T>对象相关联。一对std::promise/std::future会为这种方式提供一个可行的机制;在期望上可以阻塞等待线程,同时,提供数据的线程可以使用组合中的“承诺”来对相关值进行设置,以及将“期望”的状态置为“就绪”。

可以通过get_future()成员函数来获取与一个给定的std::promise相关的std::future对象,就像是与std::packaged_task相关。当“承诺”的值已经设置完毕(使用set_value()成员函数),对应“期望”的状态变为“就绪”,并且可用于检索已存储的值。当你在设置值之前销毁std::promise,将会存储一个异常。

在这个例子中,你可以使用一对std::promise<bool>/std::future<bool>找出一块传出成功的数据块;与“期望”相关值只是一个简单的“成功/失败”标识。对于传入包,与“期望”相关的数据就是数据包的有效负载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <future>
void process_connections(connection_set& connections)
{
while(!done(connections)) // 1
{
for(connection_iterator connection=connections.begin(),end=connections.end(); // 2
connection!=end;
++connection)
{
if(connection->has_incoming_data()) // 3
{
data_packet data=connection->incoming();
std::promise<payload_type>& p=
connection->get_promise(data.id); // 4
p.set_value(data.payload);
}
if(connection->has_outgoing_data()) // 5
{
outgoing_packet data=
connection->top_of_outgoing_queue();
connection->send(data.payload);
data.promise.set_value(true); // 6
}
}
}
}

函数process_connections()中,直到done()返回true①为止。每一次循环,程序都会依次的检查每一个连接②,检索是否有数据③或正在发送已入队的传出数据⑤。这里假设输入数据包是具有ID和有效负载的(有实际的数在其中)。一个ID映射到一个std::promise,并且值是设置在包的有效负载中的。对于传出包,包是从传出队列中进行检索的,实际上从接口直接发送出去。当发送完成,与传出数据相关的“承诺”将置为true,来表明传输成功⑥。这是否能映射到实际网络协议上,取决于网络所用协议;这里的“承诺/期望”组合方式可能会在特殊的情况下无法工作,但是它与一些操作系统支持的异步输入/输出结构类似。

为“期望”存储“异常”

看完下面短小的代码段,思考一下,当你传递-1到square_root()中时,它将抛出一个异常,并且这个异常将会被调用者看到:

1
2
3
4
5
6
7
8
double square_root(double x)
{
if(x<0)
{
throw std::out_of_range(“x<0”);
}
return sqrt(x);
}

假设调用square_root()函数不是当前线程,

1
double y=square_root(-1);

你将这样的调用改为异步调用:

1
2
std::future<double> f=std::async(square_root,-1);
double y=f.get();

如果行为是完全相同的时候,其结果是理想的;在任何情况下,y获得函数调用的结果,当线程调用f.get()时,就能再看到异常了,即使在一个单线程例子中。

好吧,事实的确如此:函数作为std::async的一部分时,当在调用时抛出一个异常,那么这个异常就会存储到“期望”的结果数据中,之后“期望”的状态被置为“就绪”,之后调用get()会抛出这个存储的异常。(注意:标准级别没有指定重新抛出的这个异常是原始的异常对象,还是一个拷贝;不同的编译器和库将会在这方面做出不同的选择)。当你将函数打包入std::packaged_task任务包中后,在这个任务被调用时,同样的事情也会发生;当打包函数抛出一个异常,这个异常将被存储在“期望”的结果中,准备在调用get()再次抛出。

当然,通过函数的显式调用,std::promise也能提供同样的功能。当你希望存入的是一个异常而非一个数值时,你就需要调用set_exception()成员函数,而非set_value()。这通常是用在一个catch块中,并作为算法的一部分,为了捕获异常,使用异常填充“承诺”:

1
2
3
4
5
6
7
8
9
extern std::promise<double> some_promise;
try
{
some_promise.set_value(calculate_value());
}
catch(...)
{
some_promise.set_exception(std::current_exception());
}

这里使用了std::current_exception()来检索抛出的异常;可用std::copy_exception()作为一个替换方案,std::copy_exception()会直接存储一个新的异常而不抛出:

1
some_promise.set_exception(std::copy_exception(std::logic_error("foo ")));

这就比使用try/catch块更加清晰,当异常类型是已知的,它就应该优先被使用;不是因为代码实现简单,而是它给编译器提供了极大的代码优化空间。

另一种向“期望”中存储异常的方式是,在没有调用“承诺”上的任何设置函数前,或正在调用包装好的任务时,销毁与std::promisestd::packaged_task相关的“期望”对象。在这任何情况下,当“期望”的状态还不是“就绪”时,调用std::promisestd::packaged_task的析构函数,将会存储一个与std::future_errc::broken_promise错误状态相关的std::future_error异常;通过创建一个“期望”,你可以构造一个“承诺”为其提供值或异常;你可以通过销毁值和异常源,去违背“承诺”。在这种情况下,编译器没有在“期望”中存储任何东西,等待线程可能会永远的等下去。

直到现在,所有例子都在用std::future。不过,std::future也有局限性,在很多线程在等待的时候,只有一个线程能获取等待结果。当多个线程需要等待相同的事件的结果,你就需要使用std::shared_future来替代std::future了。

多个线程的等待

虽然std::future可以处理所有在线程间数据转移的必要同步,但是调用某一特殊std::future对象的成员函数,就会让这个线程的数据和其他线程的数据不同步。当多线程在没有额外同步的情况下,访问一个独立的std::future对象时,就会有数据竞争和未定义的行为。这是因为:std::future模型独享同步结果的所有权,并且通过调用get()函数,一次性的获取数据,这就让并发访问变的毫无意义——只有一个线程可以获取结果值,因为在第一次调用get()后,就没有值可以再获取了。

如果你的并行代码没有办法让多个线程等待同一个事件,先别太失落;std::shared_future可以来帮你解决。因为std::future是只移动的,所以其所有权可以在不同的实例中互相传递,但是只有一个实例可以获得特定的同步结果;而std::shared_future实例是可拷贝的,所以多个对象可以引用同一关联“期望”的结果。

在每一个std::shared_future的独立对象上成员函数调用返回的结果还是不同步的,所以为了在多个线程访问一个独立对象时,避免数据竞争,必须使用锁来对访问进行保护。优先使用的办法:为了替代只有一个拷贝对象的情况,可以让每个线程都拥有自己对应的拷贝对象。这样,当每个线程都通过自己拥有的std::shared_future对象获取结果,那么多个线程访问共享同步结果就是安全的。

有可能会使用std::shared_future的地方,例如,实现类似于复杂的电子表格的并行执行;每一个单元格有单一的终值,这个终值可能是有其他单元格中的数据通过公式计算得到的。公式计算得到的结果依赖于其他单元格,然后可以使用一个std::shared_future对象引用第一个单元格的数据。当每个单元格内的所有公式并行执行后,这些任务会以期望的方式完成工作;不过,当其中有计算需要依赖其他单元格的值,那么它就会被阻塞,直到依赖单元格的数据准备就绪。这将让系统在最大程度上使用可用的硬件并发。

std::shared_future的实例同步std::future实例的状态。当std::future对象没有与其他对象共享同步状态所有权,那么所有权必须使用std::move将所有权传递到std::shared_future,其默认构造函数如下:

1
2
3
4
5
6
std::promise<int> p;
std::future<int> f(p.get_future());
assert(f.valid()); // 1 "期望" f 是合法的
std::shared_future<int> sf(std::move(f));
assert(!f.valid()); // 2 "期望" f 现在是不合法的
assert(sf.valid()); // 3 sf 现在是合法的

这里,“期望”f开始是合法的①,因为它引用的是“承诺”p的同步状态,但是在转移sf的状态后,f就不合法了②,而sf就是合法的了③。

如其他可移动对象一样,转移所有权是对右值的隐式操作,所以你可以通过std::promise对象的成员函数get_future()的返回值,直接构造一个std::shared_future对象,例如:

1
2
std::promise<std::string> p;
std::shared_future<std::string> sf(p.get_future()); // 1 隐式转移所有权

这里转移所有权是隐式的;用一个右值构造std::shared_future<>,得到std::future<std::string>类型的实例①。

std::future的这种特性,可促进std::shared_future的使用,容器可以自动的对类型进行推断,从而初始化这个类型的变量。std::future有一个share()成员函数,可用来创建新的std::shared_future,并且可以直接转移“期望”的所有权。这样也就能保存很多类型,并且使得代码易于修改:

1
2
3
std::promise< std::map< SomeIndexType, SomeDataType, SomeComparator,
SomeAllocator>::iterator> p;
auto sf=p.get_future().share();

在这个例子中,sf的类型推到为std::shared_future<std::map<SomeIndexType, SomeDataType, SomeComparator, SomeAllocator>::iterator>,一口气还真的很难念完。当比较器或分配器有所改动,你只需要对“承诺”的类型进行修改即可;“期望”的类型会自动更新,与“承诺”的修改进行匹配。

有时候你需要限定等待一个事件的时间,不论是因为你在时间上有硬性规定(一段指定的代码需要在某段时间内完成),还是因为在事件没有很快的触发时,有其他必要的工作需要特定线程来完成。为了处理这种情况,很多等待函数具有用于指定超时的变量。

限定等待时间

之前介绍过的所有阻塞调用,将会阻塞一段不确定的时间,将线程挂起直到等待的事件发生。在很多情况下,这样的方式很不错,但是在其他一些情况下,你就需要限制一下线程等待的时间了。

介绍两种可能是你希望指定的超时方式:一种是“时延”的超时方式,另一种是“绝对”超时方式。第一种方式,需要指定一段时间;第二种方式,就是指定一个时间点。多数等待函数提供变量,对两种超时方式进行处理。处理持续时间的变量以“_for”作为后缀,处理绝对时间的变量以”_until”作为后缀。

所以,当std::condition_variable的两个成员函数wait_for()wait_until()成员函数分别有两个负载,这两个负载都与wait()成员函数的负载相关——其中一个负载只是等待信号触发,或时间超期,亦或是一个虚假的唤醒,并且醒来时,会检查锁提供的谓词,并且只有在检查为true时才会返回(这时条件变量的条件达成),或直接而超时。

时钟

对于C++标准库来说,时钟就是时间信息源。特别是,时钟是一个类,提供了四种不同的信息:

  • 现在时间
  • 时间类型
  • 时钟节拍
  • 通过时钟节拍的分布,判断时钟是否稳定

时钟的当前时间可以通过调用静态成员函数now()从时钟类中获取;例如,std::chrono::system_clock::now()是将返回系统时钟的当前时间。特定的时间点类型可以通过time_point的数据typedef成员来指定,所以some_clock::now()的类型就是some_clock::time_point

时钟节拍被指定为1/x(x在不同硬件上有不同的值)秒,这是由时间周期所决定——一个时钟一秒有25个节拍,因此一个周期为std::ratio<1, 25>,当一个时钟的时钟节拍每2.5秒一次,周期就可以表示为std::ratio<5, 2>。当时钟节拍直到运行时都无法知晓,可以使用一个给定的应用程序运行多次,周期可以用执行的平均时间求出,其中最短的时间可能就是时钟节拍。这就不保证在给定应用中观察到的节拍周期与指定的时钟周期相匹配。

当时钟节拍均匀分布(无论是否与周期匹配),并且不可调整,这种时钟就称为稳定时钟。当is_steady静态数据成员为true时,表明这个时钟就是稳定的,否则,就是不稳定的。通常情况下,std::chrono::system_clock是不稳定的,因为时钟是可调的,即是这种是完全自动适应本地账户的调节。这种调节可能造成的是,首次调用now()返回的时间要早于上次调用now()所返回的时间,这就违反了节拍频率的均匀分布。稳定闹钟对于超时的计算很重要,所以C++标准库提供一个稳定时钟std::chrono::steady_clock。C++标准库提供的其他时钟可表示为std::chrono::system_clock,它代表了系统时钟的“实际时间”,并且提供了函数可将时间点转化为time_t类型的值;std::chrono::high_resolution_clock可能是标准库中提供的具有最小节拍周期的时钟。它实际上是typedef的另一种时钟,这些时钟和其他与时间相关的工具,都被定义在库头文件中。

时延

时延是时间部分最简单的;std::chrono::duration<>函数模板能够对时延进行处理。第一个模板参数是一个类型表示(比如,int,long或double),第二个模板参数是制定部分,表示每一个单元所用秒数。例如,当几分钟的时间要存在short类型中时,可以写成std::chrono::duration<short, std::ratio<60, 1>>,因为60秒是才是1分钟,所以第二个参数写成std::ratio<60, 1>。另一方面,当需要将毫秒级计数存在double类型中时,可以写成std::chrono::duration<double, std::ratio<1, 1000>>,因为1秒等于1000毫秒。

标准库在std::chrono命名空间内,为延时变量提供一系列预定义类型:nanoseconds[纳秒] , microseconds[微秒] , milliseconds[毫秒] , seconds[秒] , minutes[分]和hours[时]。比如,你要在一个合适的单元表示一段超过500年的时延,预定义类型可充分利用了大整型,来表示所要表示的时间类型。

显式转换可以由std::chrono::duration_cast<>来完成。

1
2
3
std::chrono::milliseconds ms(54802);
std::chrono::seconds s=
std::chrono::duration_cast<std::chrono::seconds>(ms);

这里的结果就是截断的,而不是进行了舍入,所以s最后的值将为54。

基于时延的等待可由std::chrono::duration<>来完成。例如,你等待一个“期望”状态变为就绪已经35毫秒:

1
2
3
std::future<int> f=std::async(some_task);
if(f.wait_for(std::chrono::milliseconds(35))==std::future_status::ready)
do_something_with(f.get());

等待函数会返回一个状态值,来表示等待是超时,还是继续等待。在这种情况下,你可以等待一个“期望”,所以当函数等待超时时,会返回std::future_status::timeout;当“期望”状态改变,函数会返回std::future_status::ready;当“期望”的任务延迟了,函数会返回std::future_status::deferred

时间点

时钟的时间点可以用std::chrono::time_point<>的类型模板实例来表示,实例的第一个参数用来指定所要使用的时钟,第二个函数参数用来表示时间的计量单位(特化的std::chrono::duration<>)。一个时间点的值就是时间的长度(在指定时间的倍数内),例如,指定“unix时间戳”(epoch)为一个时间点。时钟可能共享一个时间戳,或具有独立的时间戳。当两个时钟共享一个时间戳时,其中一个time_point类型可以与另一个时钟类型中的time_point相关联。可以通过对指定time_point类型使用time_since_epoch()来获取时间戳。

例如,你可能指定了一个时间点std::chrono::time_point<std::chrono::system_clock, std::chrono::minutes>

你可以通过std::chrono::time_point<>实例来加/减时延,来获得一个新的时间点,所以std::chrono::hight_resolution_clock::now() + std::chrono::nanoseconds(500)将得到500纳秒后的时间。当你知道一块代码的最大时延时,这对于计算绝对时间的超时是一个好消息,当等待时间内,等待函数进行多次调用;或,非等待函数且占用了等待函数时延中的时间。

你也可以减去一个时间点(二者需要共享同一个时钟)。结果是两个时间点的时间差。这对于代码块的计时是很有用的,例如:

1
2
3
4
5
6
auto start=std::chrono::high_resolution_clock::now();
do_something();
auto stop=std::chrono::high_resolution_clock::now();
std::cout<<”do_something() took “
<<std::chrono::duration<double,std::chrono::seconds>(stop-start).count()
<<” seconds”<<std::endl;

std::chrono::time_point<>实例的时钟参数可不仅是能够指定unix时间戳的。当你想一个等待函数(绝对时间超时的方式)传递时间点时,时间点的时钟参数就被用来测量时间。当时钟变更时,会产生严重的后果,因为等待轨迹随着时钟的改变而改变,并且知道调用时钟的now()成员函数时,才能返回一个超过超时时间的值。当时钟向前调整,这就有可能减小等待时间的总长度(与稳定时钟的测量相比);当时钟向后调整,就有可能增加等待时间的总长度。

如你期望的那样,后缀为_unitl的(等待函数的)变量会使用时间点。通常是使用某些时钟的::now()作为偏移,虽然时间点与系统时钟有关,可以使用std::chrono::system_clock::to_time_point()静态成员函数,在用户可视时间点上进行调度操作。例如,当你有一个对多等待500毫秒的,且与条件变量相关的事件,你可以参考如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <condition_variable>
#include <mutex>
#include <chrono>
std::condition_variable cv;
bool done;
std::mutex m;
bool wait_loop()
{
auto const timeout= std::chrono::steady_clock::now()+
std::chrono::milliseconds(500);
std::unique_lock<std::mutex> lk(m);
while(!done)
{
if(cv.wait_until(lk,timeout)==std::cv_status::timeout)
break;
}
return done;
}

这种方式是我们推荐的,当你没有什么事情可以等待时,可在一定时限中等待条件变量。在这种方式中,循环的整体长度是有限的。当使用条件变量(且无事可待)时,你就需要使用循环,这是为了处理假唤醒。当你在循环中使用wait_for()时,你可能在等待了足够长的时间后结束等待(在假唤醒之前),且下一次等待又开始了。这可能重复很多次,使得等待时间无边无际。

到此,有关时间点超时的基本知识你已经了解了。现在,让我们来了解一下如何在函数中使用超时。

具有超时功能的函数

使用超时的最简单方式就是,对一个特定线程添加一个延迟处理;当这个线程无所事事时,就不会占用可供其他线程处理的时间。

两个处理函数分别是std::this_thread::sleep_for()std::this_thread::sleep_until()。他们的工作就像一个简单的闹钟:当线程因为指定时延而进入睡眠时,可使用sleep_for()唤醒;或因指定时间点睡眠的,可使用sleep_until唤醒。sleep_until()允许在某个特定时间点将调度线程唤醒。

当然,休眠只是超时处理的一种形式;你已经看到了,超时可以配合条件变量和“期望”一起使用。超时甚至可以在尝试获取一个互斥锁时使用。std::mutexstd::recursive_mutex都不支持超时锁,但是std::timed_mutexstd::recursive_timed_mutex支持。这两种类型也有try_lock_for()try_lock_until()成员函数,可以在一段时期内尝试,或在指定时间点前获取互斥锁。

C++内存模型和原子类型操作

C++中的原子操作和原子类型

原子操作是不可分割的操作。在系统的所有线程中,你是不可能观察到原子操作完成了一半这种情况的;它要么就是做了,要么就是没做,只有这两种可能。

另一方面,非原子操作可能会被另一个线程观察到只完成一半。如果这个操作是一个存储操作,那么其他线程看到的值,可能既不是存储前的值,也不是存储的值,而是别的什么值。

在C++中,多数时候你需要一个原子类型来得到原子的操作,我们来看一下这些类型。

标准原子类型

标准原子类型定义在头文件<atomic>中。这些类型上的所有操作都是原子的,在语言定义中只有这些类型的操作是原子的,不过你可以用互斥锁来模拟原子操作。实际上,标准原子类型自己的实现就可能是这样模拟出来的:它们(几乎)都有一个is_lock_free()成员函数,这个函数让用户可以查询某原子类型的操作是直接用的原子指令(x.is_lock_free()返回true),还是编译器和库内部用了一个锁(x.is_lock_free()返回false)。

只用std::atomic_flag类型不提供is_lock_free()成员函数。这个类型是一个简单的布尔标志,并且在这种类型上的操作都需要是无锁的;当你有一个简单无锁的布尔标志时,你可以使用其实现一个简单的锁,并且实现其他基础的原子类型。

剩下的原子类型都可以通过特化std::atomic<>类型模板而访问到,并且拥有更多的功能,但可能不都是无锁的。在最流行的平台上,期望原子变量都是无锁的内置类型(例如std::atomic<int>std::atomic<void*>),但这没有必要。

除了直接使用std::atomic<>类型模板外,你可以使用在表中所示的原子类型集。

原子类型 相关特化类
atomic_bool std::atomic
atomic_char std::atomic
atomic_schar std::atomic
atomic_uchar std::atomic
atomic_int std::atomic
atomic_uint std::atomic
atomic_short std::atomic
atomic_ushort std::atomic
atomic_long std::atomic
atomic_ulong std::atomic
atomic_llong std::atomic
atomic_ullong std::atomic
atomic_char16_t std::atomic
atomic_char32_t std::atomic
atomic_wchar_t std::atomic

C++标准库不仅提供基本原子类型,还定义了与原子类型对应的非原子类型,就如同标准库中的std::size_t。如表所示这些类型:

原子类型定义 标准库中相关类型定义
atomic_int_least8_t int_least8_t
atomic_uint_least8_t uint_least8_t
atomic_int_least16_t int_least16_t
atomic_uint_least16_t uint_least16_t
atomic_int_least32_t int_least32_t
atomic_uint_least32_t uint_least32_t
atomic_int_least64_t int_least64_t
atomic_uint_least64_t uint_least64_t
atomic_int_fast8_t int_fast8_t
atomic_uint_fast8_t uint_fast8_t
atomic_int_fast16_t int_fast16_t
atomic_uint_fast16_t uint_fast16_t
atomic_int_fast32_t int_fast32_t
atomic_uint_fast32_t uint_fast32_t
atomic_int_fast64_t int_fast64_t
atomic_uint_fast64_t uint_fast64_t
atomic_intptr_t intptr_t
atomic_uintptr_t uintptr_t
atomic_size_t size_t
atomic_ptrdiff_t ptrdiff_t
atomic_intmax_t intmax_t
atomic_uintmax_t uintmax_t

对于标准类型进行typedef T,相关的原子类型就在原来的类型名前加上atomic_的前缀:atomic_T。除了signed类型的缩写是sunsigned的缩写是u,和long long的缩写是llong之外,这种方式也同样适用于内置类型。对于std::atomic<T>模板,使用对应的T类型去特化模板的方式,要好于使用别名的方式。

通常,标准原子类型是不能拷贝和赋值,他们没有拷贝构造函数和拷贝赋值操作。但是,因为可以隐式转化成对应的内置类型,所以这些类型依旧支持赋值,可以使用load()store()成员函数,exchange()compare_exchange_weak()compare_exchange_strong()。它们都支持复合赋值符:+=, -=, *=, |= 等等。并且使用整型和指针的特化类型还支持 ++ 和 —。当然,这些操作也有功能相同的成员函数所对应:fetch_add(),fetch_or()等等。赋值操作和成员函数的返回值要么是被存储的值(赋值操作),要么是操作前的值(命名函数)。这就能避免赋值操作符返回引用。为了获取存储在引用的的值,代码需要执行单独的读操作,从而允许另一个线程在赋值和读取进行的同时修改这个值,这也就为条件竞争打开了大门。

std::atomic<>类模板不仅仅一套特化的类型,其作为一个原发模板也可以使用用户定义类型创建对应的原子变量。因为,它是一个通用类模板,操作被限制为load()store()(赋值和转换为用户类型),exchange()compare_exchange_weak()compare_exchange_strong()

每种函数类型的操作都有一个可选内存排序参数,这个参数可以用来指定所需存储的顺序。

  • Store操作,可选如下顺序:memory_order_relaxed, memory_order_release, memory_order_seq_cst。
  • Load操作,可选如下顺序:memory_order_relaxed, memory_order_consume, memory_order_acquire, memory_order_seq_cst。
  • Read-modify-write(读-改-写)操作,可选如下顺序:memory_order_relaxed, memory_order_consume, memory_order_acquire, memory_order_release, memory_order_acq_rel, memory_order_seq_cst。

所有操作的默认顺序都是memory_order_seq_cst。
现在,让我们来看一下每个标准原子类型进行的操作,就从std::atomic_flag开始吧。

std::atomic_flag的相关操作

std::atomic_flag是最简单的标准原子类型,它表示了一个布尔标志。这个类型的对象可以在两个状态间切换:设置和清除。它就是那么的简单,只作为一个构建块存在。我从未期待这个类型被使用,除非在十分特别的情况下。

std::atomic_flag类型的对象必须被ATOMIC_FLAG_INIT初始化。初始化标志位是“清除”状态。这里没得选择;这个标志总是初始化为“清除”:

1
std::atomic_flag f = ATOMIC_FLAG_INIT;

这适用于任何对象的声明,并且可在任意范围内。它是唯一需要以如此特殊的方式初始化的原子类型,但它也是唯一保证无锁的类型。如果std::atomic_flag是静态存储的,那么就的保证其是静态初始化的,也就意味着没有初始化顺序问题;在首次使用时,其都需要初始化。

当你的标志对象已初始化,那么你只能做三件事情:销毁,清除或设置(查询之前的值)。这些事情对应的函数分别是:clear()成员函数,和test_and_set()成员函数。clear()test_and_set()成员函数可以指定好内存顺序。clear()是一个存储操作,所以不能有memory_order_acquirememory_order_acq_rel语义,但是test_and_set()是一个“读-改-写”操作,所有可以应用于任何内存顺序标签。每一个原子操作,默认的内存顺序都是memory_order_seq_cst。例如:

1
2
f.clear(std::memory_order_release);  // 1
bool x=f.test_and_set(); // 2

这里,调用clear()①明确要求,使用释放语义清除标志,当调用test_and_set()②使用默认内存顺序设置表示,并且检索旧值。

你不能拷贝构造另一个std::atomic_flag对象;并且,你不能将一个对象赋予另一个std::atomic_flag对象。这并不是std::atomic_flag特有的,而是所有原子类型共有的。一个原子类型的所有操作都是原子的,因赋值和拷贝调用了两个对象,这就就破坏了操作的原子性。在这样的情况下,拷贝构造和拷贝赋值都会将第一个对象的值进行读取,然后再写入另外一个。对于两个独立的对象,这里就有两个独立的操作了,合并这两个操作必定是不原子的。因此,操作就不被允许。

有限的特性集使得std::atomic_flag非常适合于作自旋互斥锁。初始化标志是“清除”,并且互斥量处于解锁状态。为了锁上互斥量,循环运行test_and_set()直到旧值为false,就意味着这个线程已经被设置为true了。解锁互斥量是一件很简单的事情,将标志清除即可。实现如下面的程序清单所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class spinlock_mutex
{
std::atomic_flag flag;
public:
spinlock_mutex():
flag(ATOMIC_FLAG_INIT)
{}
void lock()
{
while(flag.test_and_set(std::memory_order_acquire));
}
void unlock()
{
flag.clear(std::memory_order_release);
}
};

这样的互斥量是最最基本的,但是它已经足够std::lock_guard<>使用了。其本质就是在lock()中等待,所以这里几乎不可能有竞争的存在,并且可以确保互斥。当我们看到内存顺序语义时,你将会看到它们是如何对一个互斥锁保证必要的强制顺序的。

由于std::atomic_flag局限性太强,因为它没有非修改查询操作,它甚至不能像普通的布尔标志那样使用。所以,你最好使用std::atomic<bool>

std::atomic的相关操作

最基本的原子整型类型就是std::atomic<bool>。如你所料,它有着比std::atomic_flag更加齐全的布尔标志特性。虽然它依旧不能拷贝构造和拷贝赋值,但是你可以使用一个非原子的bool类型构造它,所以它可以被初始化为true或false,并且你也可以从一个非原子bool变量赋值给std::atomic<bool>的实例:

1
2
std::atomic<bool> b(true);
b=false;

另一件需要注意的事情时,非原子bool类型的赋值操作不同于通常的操作(转换成对应类型的引用,再赋给对应的对象):它返回一个bool值来代替指定对象。这是在原子类型中,另一种常见的模式:赋值操作通过返回值(返回相关的非原子类型)完成,而非返回引用。如果一个原子变量的引用被返回了,任何依赖与这个赋值结果的代码都需要显式加载这个值。通过使用返回非原子值进行赋值的方式,你可以避免这些多余的加载过程,并且得到的值就是实际存储的值。

虽然有内存顺序语义指定,但是使用store()去写入(true或false)还是好于std::atomic_flag中限制性很强的clear()。同样的,test_and_set()函数也可以被更加通用的exchange()成员函数所替换,exchange()成员函数允许你使用你新选的值替换已存储的值,并且自动的检索原始值。std::atomic<bool>也支持对值的普通(不可修改)查找,其会将对象隐式的转换为一个普通的bool值,或显示的调用load()来完成。如你预期,store()是一个存储操作,而load()是一个加载操作。exchange()是一个“读-改-写”操作:

1
2
3
4
std::atomic<bool> b;
bool x=b.load(std::memory_order_acquire);
b.store(true);
x=b.exchange(false, std::memory_order_acq_rel);

std::atomic<bool>提供的exchange(),不仅仅是一个“读-改-写”的操作;它还介绍了一种新的存储方式:当当前值与预期值一致时,存储新值的操作。

这是一种新型操作,叫做“比较/交换”,它的形式表现为compare_exchange_weak()compare_exchange_strong()成员函数。“比较/交换”操作是原子类型编程的基石;它比较原子变量的当前值和一个期望值,当两值相等时,存储提供值。当两值不等,期望值就会被更新为原子变量中的值。“比较/交换”函数值是一个bool变量,当返回true时执行存储操作,当false则更新期望值。

对于compare_exchange_weak()函数,当原始值与预期值一致时,存储也可能会不成功;在这个例子中变量的值不会发生改变,并且compare_exchange_weak()的返回是false。这可能发生在缺少独立“比较-交换”指令的机器上,当处理器不能保证这个操作能够自动的完成——可能是因为线程的操作将指令队列从中间关闭,并且另一个线程安排的指令将会被操作系统所替换(这里线程数多于处理器数量)。这被称为“伪失败”(spurious failure),因为造成这种情况的原因是时间,而不是变量值。

因为compare_exchange_weak()可以“伪失败”,所以这里通常使用一个循环:

1
2
3
bool expected=false;
extern atomic<bool> b; // 设置些什么
while(!b.compare_exchange_weak(expected,true) && !expected);

在这个例子中,循环中expected的值始终是false,表示compare_exchange_weak()会莫名的失败。

另一方面,如果实际值与期望值不符,compare_exchange_strong()就能保证值返回false。这就能消除对循环的需要,就可以知道是否成功的改变了一个变量,或已让另一个线程完成。

如果你想要改变变量值,且无论初始值是什么(可能是根据当前值更新了的值),更新后的期望值将会变更有用;经历每次循环的时候,期望值都会重新加载,所以当没有其他线程同时修改期望时,循环中对compare_exchange_weak()compare_exchange_strong()的调用都会在下一次(第二次)成功。如果值的计算很容易存储,那么使用compare_exchange_weak()能更好的避免一个双重循环的执行,即使compare_exchange_weak()可能会“伪失败”(因此compare_exchange_strong()包含一个循环)。另一方面,如果值计算的存储本身是耗时的,那么当期望值不变时,使用compare_exchange_strong()可以避免对值的重复计算。对于std::atomic<bool>这些都不重要——毕竟只可能有两种值——但是对于其他的原子类型就有较大的影响了。

“比较/交换”函数很少对两个拥有内存顺序的参数进行操作,这就就允许内存顺序语义在成功和失败的例子中有所不同;其可能是对memory_order_acq_rel语义的一次成功调用,而对memory_order_relaxed语义的一次失败的调动。一次失败的“比较/交换”将不会进行存储,所以“比较/交换”操作不能拥有memeory_order_release或memory_order_acq_rel语义。因此,这里不保证提供的这些值能作为失败的顺序。你也不能提供比成功顺序更加严格的失败内存顺序;当你需要memory_order_acquire或memory_order_seq_cst作为失败语序,那必须要如同“指定它们是成功语序”那样去做。

如果你没有指定失败的语序,那就假设和成功的顺序是一样的,除了release部分的顺序:memory_order_release变成memory_order_relaxed,并且memoyr_order_acq_rel变成memory_order_acquire。如果你都不指定,他们默认顺序将为memory_order_seq_cst,这个顺序提供了对成功和失败的全排序。下面对compare_exchange_weak()的两次调用是等价的:

1
2
3
4
std::atomic<bool> b;
bool expected;
b.compare_exchange_weak(expected,true,memory_order_acq_rel,memory_order_acquire);
b.compare_exchange_weak(expected,true,memory_order_acq_rel);

std::atomic<bool>std::atomic_flag的不同之处在于,std::atomic<bool>不是无锁的;为了保证操作的原子性,其实现中需要一个内置的互斥量。当处于特殊情况时,你可以使用is_lock_free()成员函数,去检查std::atomic<bool>上的操作是否无锁。这是另一个,除了std::atomic_flag之外,所有原子类型都拥有的特征。

第二简单的原子类型就是特化原子指针——std::atomic<T*>,接下来就看看它是如何工作的吧。

std::atomic:指针运算

原子指针类型,可以使用内置类型或自定义类型T,通过特化std::atomic<T*>进行定义,就如同使用bool类型定义std::atomic<bool>类型一样。虽然接口几乎一致,但是它的操作是对于相关的类型的指针,而非bool值本身。就像std::atomic<bool>,虽然它既不能拷贝构造,也不能拷贝赋值,但是他可以通过合适的类型指针进行构造和赋值。如同成员函数is_lock_free()一样,std::atomic<T*>也有load(), store(), exchange(), compare_exchange_weak()compare_exchage_strong()成员函数,与std::atomic<bool>的语义相同,获取与返回的类型都是T*,而不是bool。

std::atomic<T*>为指针运算提供新的操作。基本操作有fetch_add()fetch_sub()提供,它们在存储地址上做原子加法和减法,为+=, -=, ++和—提供简易的封装。对于内置类型的操作,如你所预期:如果x是std::atomic<Foo*>类型的数组的首地址,然后x+=3让其偏移到第四个元素的地址,并且返回一个普通的Foo*类型值,这个指针值是指向数组中第四个元素。fetch_add()fetch_sub()的返回值略有不同(所以x.ftech_add(3)让x指向第四个元素,并且函数返回指向第一个元素的地址)。这种操作也被称为“交换-相加”,并且这是一个原子的“读-改-写”操作,如同exchange()compare_exchange_weak()/compare_exchange_strong()一样。正像其他操作那样,返回值是一个普通的T*值,而非是std::atomic<T*>对象的引用,所以调用代码可以基于之前的值进行操作:

1
2
3
4
5
6
7
8
9
class Foo{};
Foo some_array[5];
std::atomic<Foo*> p(some_array);
Foo* x=p.fetch_add(2); // p加2,并返回原始值
assert(x==some_array);
assert(p.load()==&some_array[2]);
x=(p-=1); // p减1,并返回原始值
assert(x==&some_array[1]);
assert(p.load()==&some_array[1]);

函数也允许内存顺序语义作为给定函数的参数:

1
p.fetch_add(3,std::memory_order_release);

因为fetch_add()fetch_sub()都是“读-改-写”操作,它们可以拥有任意的内存顺序标签,以及加入到一个释放序列中。指定的语序不可能是操作符的形式,因为没办法提供必要的信息:这些形式都具有memory_order_seq_cst语义。

标准的原子整型的相关操作

如同普通的操作集合一样(load(), store()exchange()compare_exchange_weak(),和compare_exchange_strong()),在std::atomic<int>std::atomic<unsigned long long>也是有一套完整的操作可以供使用:fetch_add()fetch_sub()fetch_and()fetch_or()fetch_xor(),还有复合赋值方式((+=, -=, &=, |=和^=),以及++和—(++x, x++, —x和x—)。虽然对于普通的整型来说,这些复合赋值方式还不完全,但也十分接近完整了:只有除法、乘法和移位操作不在其中。因为,整型原子值通常用来作计数器,或者是掩码,所以以上操作的缺失显得不是那么重要;如果需要,额外的操作可以将compare_exchange_weak()放入循环中完成。

对于std::atomic<T*>类型紧密相关的两个函数就是fetch_add()fetch_sub();函数原子化操作,并且返回旧值,而符合赋值运算会返回新值。前缀加减和后缀加减与普通用法一样:++x对变量进行自加,并且返回新值;而x++对变量自加,返回旧值。正如你预期的那样,在这两个例子中,结果都是相关整型的一个值。

我们已经看过所有基本原子类型;剩下的就是std::atomic<>类型模板,而非其特化类型。那么接下来让我们来了解一下std::atomic<>类型模板。

std::atomic<>主要类的模板

主模板的存在,在除了标准原子类型之外,允许用户使用自定义类型创建一个原子变量。不是任何自定义类型都可以使用std::atomic<>的:需要满足一定的标准才行。为了使用std::atomic<UDT>(UDT是用户定义类型),这个类型必须有拷贝赋值运算符。这就意味着这个类型不能有任何虚函数或虚基类,以及必须使用编译器创建的拷贝赋值操作。不仅仅是这些,自定义类型中所有的基类和非静态数据成员也都需要支持拷贝赋值操作。这(基本上)就允许编译器使用memcpy(),或赋值操作的等价操作,因为它们的实现中没有用户代码。

最后,这个类型必须是“位可比的”(bitwise equality comparable)。这与对赋值的要求差不多;你不仅需要确定,一个UDT类型对象可以使用memcpy()进行拷贝,还要确定其对象可以使用memcmp()对位进行比较。之所以要求这么多,是为了保证“比较/交换”操作能正常的工作。

以上严格的限制都是依据第3章中的一个建议:不要将锁定区域内的数据,以引用或指针的形式,作为参数传递给用户提供的函数。通常情况下,编译器不会为std::atomic<UDT>类型生成无锁代码,所以它将对所有操作使用一个内部锁。如果用户提供的拷贝赋值或比较操作被允许,那么这就需要传递保护数据的引用作为一个参数,这就有悖于指导意见了。当原子操作需要时,运行库也可自由的使用单锁,并且运行库允许用户提供函数持有锁,这样就有可能产生死锁(或因为做一个比较操作,而阻塞了其他的线程)。最终,因为这些限制可以让编译器将用户定义的类型看作为一组原始字节,所以编译器可以对std::atomic<UDT>直接使用原子指令(因此实例化一个特殊无锁结构)。

注意,虽然使用std::atomic<float>std::atomic<double>(内置浮点类型满足使用memcpy和memcmp的标准),但是它们在compare_exchange_strong函数中的表现可能会令人惊讶。当存储的值与当前值相等时,这个操作也可能失败,可能因为旧值是一个不同的表达式。这就不是对浮点数的原子计算操作了。在使用compare_exchange_strong函数的过程中,你可能会遇到相同的结果,如果你使用std::atomic<>特化一个用户自定义类型,且这个类型定义了比较操作,而这个比较操作与memcmp又有不同——操作可能会失败,因为两个相等的值用有不同的表达式。

如果你的UDT类型的大小如同(或小于)一个int或void*类型时,大多数平台将会对std::atomic<UDT>使用原子指令。有些平台可能会对用户自定义类型(两倍于int或void*的大小)特化的std::atmic<>使用原子指令。这些平台通常支持所谓的“双字节比较和交换”(double-word-compare-and-swap,DWCAS)指令,这个指令与compare_exchange_xxx相关联着。这种指令的支持,对于写无锁代码是有很大的帮助。

以上的限制也意味着有些事情你不能做,比如,创建一个std::atomic<std::vector<int>>类型。这里不能使用包含有计数器,标志指针和简单数组的类型,作为特化类型。虽然这不会导致任何问题,但是,越是复杂的数据结构,就有越多的操作要去做,而非只有赋值和比较。如果这种情况发生了,你最好使用std::mutex保证数据能被必要的操作所保护。

当使用用户定义类型T进行实例化时,std::atomic<T>的可用接口就只有: load()store()exchange()compare_exchange_weak()compare_exchange_strong()和赋值操作,以及向类型T转换的操作。表5.3列举了每一个原子类型所能使用的操作。

原子操作的释放函数

直到现在,我都还没有去描述成员函数对原子类型操作的形式。但是,在不同的原子类型中也有等价的非成员函数存在。大多数非成员函数的命名与对应成员函数有关,但是需要atomic_作为前缀(比如,std::atomic_load())。这些函数都会被不同的原子类型所重载。在指定一个内存序列标签时,他们会分成两种:一种没有标签,另一种将_explicit作为后缀,并且需要一个额外的参数,或将内存顺序作为标签,亦或只有标签(例如,std::atomic_store(&atomic_var,new_value)std::atomic_store_explicit(&atomic_var,new_value,std::memory_order_release)。不过,原子对象被成员函数隐式引用,所有释放函数都持有一个指向原子对象的指针(作为第一个参数)。

例如,std::atomic_is_lock_free()只有一种类型(虽然会被其他类型所重载),并且对于同一个对象astd::atomic_is_lock_free(&a)返回值与a.is_lock_free()相同。同样的,std::atomic_load(&a)a.load()的作用一样,但需要注意的是,与a.load(std::memory_order_acquire)等价的操作是std::atomic_load_explicit(&a, std::memory_order_acquire)

释放函数的设计是为了要与C语言兼容,在C中只能使用指针,而不能使用引用。例如,compare_exchange_weak()compare_exchange_strong()成员函数的第一个参数(期望值)是一个引用,而std::atomic_compare_exchange_weak()(第一个参数是指向对象的指针)的第二个参数是一个指针。std::atomic_compare_exchange_weak_explicit()也需要指定成功和失败的内存序列,而“比较/交换”成员函数都有一个单内存序列形式(默认是std::memory_order_seq_cst),重载函数可以分别获取成功和失败内存序列。

std::atomic_flag的操作是“反潮流”的,在那些操作中它们“标志”的名称为:std::atomic_flag_test_and_set()std::atomic_flag_clear(),但是以_explicit为后缀的额外操作也能够指定内存顺序:std::atomic_flag_test_and_set_explicit()std::atomic_flag_clear_explicit()

C++标准库也对在一个原子类型中的std::shared_ptr<>智能指针类型提供释放函数。这打破了“只有原子类型,才能提供原子操作”的原则,这里std::shared_ptr<>肯定不是原子类型。但是,C++标准委员会感觉对此提供额外的函数是很重要的。可使用的原子操作有:load, store, exchange和compare/exchange,这些操作重载了标准原子类型的操作,并且获取一个std::shared_ptr<>*作为第一个参数:

1
2
3
4
5
6
7
8
9
10
11
std::shared_ptr<my_data> p;
void process_global_data()
{
std::shared_ptr<my_data> local=std::atomic_load(&p);
process_data(local);
}
void update_global_data()
{
std::shared_ptr<my_data> local(new my_data);
std::atomic_store(&p,local);
}

作为和原子操作一同使用的其他类型,也提供“_explicit”变量,允许你指定所需的内存顺序,并且std::atomic_is_lock_free()函数可以用来确定实现是否使用锁,来保证原子性。

如之前的描述,标准原子类型不仅仅是为了避免数据竞争所造成的未定义操作,它们还允许用户对不同线程上的操作进行强制排序。这种强制排序是数据保护和同步操作的基础,例如,std::mutex和std::future<>。所以,让我继续了解本章的真实意义:内存模型在并发方面的细节,如何使用原子操作同步数据和强制排序。

同步操作和强制排序

假设你有两个线程,一个向数据结构中填充数据,另一个读取数据结构中的数据。为了避免恶性条件竞争,第一个线程设置一个标志,用来表明数据已经准备就绪,并且第二个线程在这个标志设置前不能读取数据。下面的程序清单就是这样的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <vector>
#include <atomic>
#include <iostream>
std::vector<int> data;
std::atomic<bool> data_ready(false);
void reader_thread()
{
while(!data_ready.load()) // 1
{
std::this_thread::sleep(std::milliseconds(1));
}
std::cout<<"The answer="<<data[0]<<"\m"; // 2
}
void writer_thread()
{
data.push_back(42); // 3
data_ready=true; // 4
}

先把等待数据的低效循环①放在一边。你已经知道,当非原子读②和写③对同一数据结构进行无序访问时,将会导致未定义行为的发生,因此这个循环就是确保访问循序被严格的遵守的。

强制访问顺序是由对std::atomic<bool>类型的data_ready变量进行操作完成的;这些操作通过先行发生(happens-before)和同步发生(synchronizes-with)确定必要的顺序。写入数据③的操作,在写入data_ready标志④的操作前发生,并且读取标志①发生在读取数据②之前。当data_ready①为true,写操作就会与读操作同步,建立一个“先行发生”关系。因为“先行发生”是可传递的,所以写入数据③先行于写入标志④,这两个行为又先行于读取标志的操作①,之前的操作都先行于读取数据②,这样你就拥有了强制顺序:写入数据先行于读取数据,其他没问题了。

所有事情看起来非常直观:对一个值来说,写操作必然先于读操作!在默认它们都是原子操作的时候,这无疑是正确的(这就是原子操作为默认属性的原因),不过这里需要详细说明:原子操作对于排序要求,也有其他的选项,会在稍后进行详述。

同步发生

“同步发生”只能在原子类型之间进行操作。例如对一个数据结构进行操作(对互斥量上锁),如果数据结构包含有原子类型,并且操作内部执行了一定的原子操作,那么这些操作就是同步发生关系。从根本上说,这种关系只能来源于对原子类型的操作。

“同步发生”的基本想法是:在变量x进行适当标记的原子写操作W,同步与对x进行适当标记的原子读操作,读取的是W操作写入的内容;或是在W之后,同一线程上的原子写操作对x写入的值;亦或是任意线程对x的一系列原子读-改-写操作(例如,fetch_add()compare_exchange_weak())。这里,第一个线程读取到的值是W操作写入的。

先将“适当的标记”放在一边,因为所有对原子类型的操作,默认都是适当标记的。这实际上就是:如果线程A存储了一个值,并且线程B读取了这个值,线程A的存储操作与线程B的载入操作就是同步发生的关系。

先行发生

“先行发生”关系是一个程序中,基本构建块的操作顺序;它指定了某个操作去影响另一个操作。对于单线程来说,就简单了:当一个操作排在另一个之后,那么这个操作就是先行执行的。这意味着,如果源码中操作A发生在操作B之前,那么A就先行于B发生。如果操作在同时发生,因为操作间无序执行,通常情况下,它们就没有先行关系了。这就是另一种排序未被指定的情况。

原子操作的内存顺序

这里有六个内存序列选项可应用于对原子类型的操作:memory_order_relaxed, memory_order_consume, memory_order_acquire, memory_order_release, memory_order_acq_rel, 以及memory_order_seq_cst。除非你为特定的操作指定一个序列选项,要不内存序列选项对于所有原子类型默认都是memory_order_seq_cst。虽然有六个选项,但是它们仅代表三种内存模型:排序一致序列(sequentially consistent),获取-释放序列(memory_order_consume, memory_order_acquire, memory_order_release和memory_order_acq_rel),和自由序列(memory_order_relaxed)。

这些不同的内存序列模型,在不同的CPU架构下,功耗是不一样的。例如,基于处理器架构的可视化精细操作的系统,比起其他系统,添加的同步指令可被排序一致序列使用(在获取-释放序列和自由序列之前),或被获取-释放序列调用(在自由序列之前)。如果这些系统有多个处理器,这些额外添加的同步指令可能会消耗大量的时间,从而降低系统整体的性能。另一方面,CPU使用的是x86或x86-64架构(例如,使用Intel或AMD处理器的台式电脑),使用这种架构的CPU不需要任何对获取-释放序列添加额外的指令(没有保证原子性的必要了),并且,即使是排序一致序列,对于加载操作也不需要任何特殊的处理,不过在进行存储时,有点额外的消耗。

不同种类的内存序列模型,允许专家利用其提升与更细粒度排序相关操作的性能。当默认使用排序一致序列(相较于其他序列,它是最简单的)时,对于在那些不大重要的情况下是有利的。

选择使用哪个模型,或为了了解与序列相关的代码,为什么选择不同的内存模型,是需要了解一个重要的前提,那就是不同模型是如何影响程序的行为。让我们来看一下选择每个操作序列和同步相关的结果。

默认序列命名为排序一致,是因为程序中的行为从任意角度去看,序列顺序都保持一致。如果原子类型实例上的所有操作都是序列一致的,那么一个多线程程序的行为,就以某种特殊的排序执行,好像单线程那样。这是目前来看,最容易理解的内存序列,这也就是将其设置为默认的原因:所有线程都必须了解,不同的操作也遵守相同的顺序。因为其简单的行为,可以使用原子变量进行编写。通过不同的线程,你可以写出所有序列上可能的操作,这样就可以消除那些不一致,以及验证你代码的行为是否与预期相符。这也就意味着,所有操作都不能重排序;如果你的代码,在一个线程中,将一个操作放在另一个操作前面,那么这个顺序就必须让其他所有的线程所了解。

从同步的角度看,对于同一变量,排序一致的存储操作同步相关于同步一致的载入操作。这就提供了一种对两个(以上)线程操作的排序约束,但是排序一致的功能要比排序约束大的多。所以,对于使用排序一致原子操作的系统上的任一排序一致的原子操作,都会在对值进行存储以后,再进行加载。这种约束不是线程在自由内存序列中使用原子操作;这些线程依旧可以知道,操作以不同顺序排列,所以你必须使用排序一致操作,去保证在多线的情况下有加速的效果。

不过,简单是要付出代价的。在一个多核若排序的机器上,它会加强对性能的惩罚,因为整个序列中的操作都必须在多个处理器上保持一致,可能需要对处理器间的同步操作进行扩展(代价很昂贵!)。即便如此,一些处理器架构(比如通用x86和x86-64架构)就提供了相对廉价的序列一致,所以你需要考虑使用序列一致对性能的影响,这就需要你去查阅你目标处理器的架构文档,进行更多的了解。

以下清单展示了序列一致的行为,对于x和y的加载和存储都显示标注为memory_order_seq_cst,不过在这段代码中,标签可能会忽略,因为其是默认项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <atomic>
#include <thread>
#include <assert.h>
std::atomic<bool> x,y;
std::atomic<int> z;
void write_x()
{
x.store(true,std::memory_order_seq_cst); // 1
}
void write_y()
{
y.store(true,std::memory_order_seq_cst); // 2
}
void read_x_then_y()
{
while(!x.load(std::memory_order_seq_cst));
if(y.load(std::memory_order_seq_cst)) // 3
++z;
}
void read_y_then_x()
{
while(!y.load(std::memory_order_seq_cst));
if(x.load(std::memory_order_seq_cst)) // 4
++z;
}
int main()
{
x=false;
y=false;
z=0;
std::thread a(write_x);
std::thread b(write_y);
std::thread c(read_x_then_y);
std::thread d(read_y_then_x);
a.join();
b.join();
c.join();
d.join();
assert(z.load()!=0); // 5
}

assert⑤语句是永远不会触发的,因为不是存储x的操作①发生,就是存储y的操作②发生。如果在read_x_then_y中加载y③返回false,那是因为存储x的操作肯定发生在存储y的操作之前,那么在这种情况下在read_y_then_x中加载x④必定会返回true,因为while循环能保证在某一时刻y是true。因为memory_order_seq_cst的语义需要一个单全序将所有操作都标记为memory_order_seq_cst,这就暗示着“加载y并返回false③”与“存储y①”的操作,有一个确定的顺序。只有一个全序时,如果一个线程看到x==true,随后又看到y==false,这就意味着在总序列中存储x的操作发生在存储y的操作之前。

释放队列与同步

通过其他线程,即使有(有序的)多个“读-改-写”操作(所有操作都已经做了适当的标记)在存储和加载操作之间,你依旧可以获取原子变量存储与加载的同步关系。现在,我已经讨论所有可能使用到的内存序列“标签”,我在这里可以做一个简单的概述。当存储操作被标记为memory_order_release,memory_order_acq_rel或memory_order_seq_cst,加载被标记为memory_order_consum,memory_order_acquire或memory_order_sqy_cst,并且操作链上的每一加载操作都会读取之前操作写入的值,因此链上的操作构成了一个释放序列(release sequence),并且初始化存储同步(对应memory_order_acquire或memory_order_seq_cst)或是前序依赖(对应memory_order_consume)的最终加载。操作链上的任何原子“读-改-写”操作可以拥有任意个存储序列(甚至是memory_order_relaxed)。

为了了解这些操作意味着什么,以及其重要性,考虑一个atomic用作对一个共享队列的元素进行计数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <atomic>
#include <thread>

std::vector<int> queue_data;
std::atomic<int> count;

void populate_queue()
{
unsigned const number_of_items=20;
queue_data.clear();
for(unsigned i=0;i<number_of_items;++i)
{
queue_data.push_back(i);
}

count.store(number_of_items,std::memory_order_release); // 1 初始化存储
}

void consume_queue_items()
{
while(true)
{
int item_index;
if((item_index=count.fetch_sub(1,std::memory_order_acquire))<=0) // 2 一个“读-改-写”操作
{
wait_for_more_items(); // 3 等待更多元素
continue;
}
process(queue_data[item_index-1]); // 4 安全读取queue_data
}
}

int main()
{
std::thread a(populate_queue);
std::thread b(consume_queue_items);
std::thread c(consume_queue_items);
a.join();
b.join();
c.join();
}

一种处理方式是让线程产生数据,并存储到一个共享缓存中,而后调用count.store(number_of_items, memory_order_release)①让其他线程知道数据是可用的。线程群消耗着队列中的元素,之后可能调用count.fetch_sub(1, memory_order_acquire)②向队列索取一个元素,不过在这之前,需要对共享缓存进行完整的读取④。一旦count归零,那么队列中就没有更多的元素了,当元素耗尽时线程必须等待③。

当有一个消费者线程时还好,fetch_sub()是一个带有memory_order_acquire的读取操作,并且存储操作是带有memory_order_release语义,所以这里存储与加载同步,线程是可以从缓存中读取元素的。当有两个读取线程时,第二个fetch_sub()操作将看到被第一个线程修改的值,且没有值通过store写入其中。先不管释放序列的规则,这里第二个线程与第一个线程不存在先行关系,并且其对共享缓存中值的读取也不安全,除非第一个fetch_sub()是带有memory_order_release语义的,这个语义为两个消费者线程间建立了不必要的同步。无论是释放序列的规则,还是带有memory_order_release语义的fetch_sub操作,第二个消费者看到的是一个空的queue_data,无法从其获取任何数据,并且这里还会产生条件竞争。幸运的是,第一个fetch_sub()对释放顺序做了一些事情,所以store()能同步与第二个fetch_sub()操作。这里,两个消费者线程间不需要同步关系。

操作链中可以有任意数量的链接,但是提供的都是“读-改-写”操作,比如fetch_sub(),store(),每一个都会与使用memory_order_acquire语义的操作进行同步。在这里例子中,所有链接都是一样的,并且都是获取操作,但它们可由不同内存序列语义组成的操作混合。(译者:也就是不是单纯的获取操作)

虽然,大多数同步关系,是对原子变量的操作应用了内存序列,但这里依旧有必要额外介绍一个对排序的约束——栅栏(fences)。

栅栏

如果原子操作库缺少了栅栏,那么这个库就是不完整的。栅栏操作会对内存序列进行约束,使其无法对任何数据进行修改,典型的做法是与使用memory_order_relaxed约束序的原子操作一起使用。栅栏属于全局操作,执行栅栏操作可以影响到在线程中的其他原子操作。因为这类操作就像画了一条任何代码都无法跨越的线一样,所以栅栏操作通常也被称为内存栅栏(memory barriers)。不过,栅栏操作就会限制这种自由,并且会介绍之前没有介绍到的“先行”和“同步”关系。

我们给在不同线程上的两个原子操作中添加一个栅栏,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <atomic>
#include <thread>
#include <assert.h>

std::atomic<bool> x,y;
std::atomic<int> z;

void write_x_then_y()
{
x.store(true,std::memory_order_relaxed); // 1
std::atomic_thread_fence(std::memory_order_release); // 2
y.store(true,std::memory_order_relaxed); // 3
}

void read_y_then_x()
{
while(!y.load(std::memory_order_relaxed)); // 4
std::atomic_thread_fence(std::memory_order_acquire); // 5
if(x.load(std::memory_order_relaxed)) // 6
++z;
}

int main()
{
x=false;
y=false;
z=0;
std::thread a(write_x_then_y);
std::thread b(read_y_then_x);
a.join();
b.join();
assert(z.load()!=0); // 7
}

释放栅栏②与获取栅栏⑤同步,这是因为加载y的操作④读取的是在③处存储的值。所以,在①处存储x先行于⑥处加载x,最后x读取出来必为true,并且断言不会被触发⑦。原先不带栅栏的存储和加载x都是无序的,并且断言是可能会触发的。需要注意的是,这两个栅栏都是必要的:你需要在一个线程中进行释放,然后在另一个线程中进行获取,这样才能构建出同步关系。

在这个例子中,如果存储y的操作③标记为memory_order_release,而非memory_order_relaxed的话,释放栅栏②也会对这个操作产生影响。同样的,当加载y的操作④标记为memory_order_acquire时,获取栅栏⑤也会对之产生影响。使用栅栏的一般想法是:当一个获取操作能看到释放栅栏操作后的存储结果,那么这个栅栏就与获取操作同步;并且,当加载操作在获取栅栏操作前,看到一个释放操作的结果,那么这个释放操作同步于获取栅栏。当然,你也可以使用双边栅栏操作,举一个简单的例子,当一个加载操作在获取栅栏前,看到一个值有存储操作写入,且这个存储操作发生在释放栅栏后,那么释放栅栏与获取栅栏是同步的。

虽然,栅栏同步依赖于读取/写入的操作发生于栅栏之前/后,但是这里有一点很重要:同步点,就是栅栏本身。当你执行write_x_then_y,并且在栅栏操作之后对x进行写入,就像下面的代码一样。这里,触发断言的条件就不保证一定为true了,尽管写入x的操作在写入y的操作之前发生。

1
2
3
4
5
6
void write_x_then_y()
{
std::atomic_thread_fence(std::memory_order_release);
x.store(true,std::memory_order_relaxed);
y.store(true,std::memory_order_relaxed);
}

这里里的两个操作,就不会被栅栏分开,并且也不再有序。只有当栅栏出现在存储x和存储y操作之间,这个顺序是硬性的。当然,栅栏是否存在不会影响任何拥有先行关系的执行序列,这种情况是因为一些其他原子操作。

基于锁的并发数据结构设计

基于锁的并发数据结构

基于锁的并发数据结构设计,需要确保访问线程持有锁的时间最短。对于只有一个互斥量的数据结构来说,这十分困难。需要保证数据不被锁之外的操作所访问到,并且还要保证不会在固有结构上产生条件竞争(如第3章所述)。当你使用多个互斥量来保护数据结构中不同的区域时,问题会暴露的更加明显,当操作需要获取多个互斥锁时,就有可能产生死锁。所以,在设计时,使用多个互斥量时需要格外小心。

栈是一个十分简单的数据结构,它只使用了一个互斥量。但是,这个结构是线程安全的吗?它离真正的并发访问又有多远呢?

线程安全栈——使用锁

我们先把第3章中线程安全的栈拿过来看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <exception>
struct empty_stack: std::exception
{
const char* what() const throw();
};
template<typename T>
class threadsafe_stack
{
private:
std::stack<T> data;
mutable std::mutex m;
public:
threadsafe_stack(){}
threadsafe_stack(const threadsafe_stack& other)
{
std::lock_guard<std::mutex> lock(other.m);
data=other.data;
}
threadsafe_stack& operator=(const threadsafe_stack&) = delete;
void push(T new_value)
{
std::lock_guard<std::mutex> lock(m);
data.push(std::move(new_value)); // 1
}
std::shared_ptr<T> pop()
{
std::lock_guard<std::mutex> lock(m);
if(data.empty()) throw empty_stack(); // 2
std::shared_ptr<T> const res(
std::make_shared<T>(std::move(data.top()))); // 3
data.pop(); // 4
return res;
}
void pop(T& value)
{
std::lock_guard<std::mutex> lock(m);
if(data.empty()) throw empty_stack();
value=std::move(data.top()); // 5
data.pop(); // 6
}
bool empty() const
{
std::lock_guard<std::mutex> lock(m);
return data.empty();
}
};

首先,互斥量m能保证基本的线程安全,那就是对每个成员函数进行加锁保护。这就保证在同一时间内,只有一个线程可以访问到数据,所以能够保证,数据结构的“不变量”被破坏时,不会被其他线程看到。

其次,在empty()pop()成员函数之间会存在潜在的竞争,不过代码会在pop()函数上锁时,显式的查询栈是否为空,所以这里的竞争是非恶性的。pop()通过对弹出值的直接返回,就可避免std::stack<>top()pop()两成员函数之间的潜在竞争。

再次,这个类中也有一些异常源。对互斥量上锁可能会抛出异常,因为上锁操作是每个成员函数所做的第一个操作,所以这是极其罕见的。因无数据修改,所以其是安全的。因解锁一个互斥量是不会失败的,所以段代码很安全,并且使用std::lock_guard<>也能保证互斥量上锁的状态。

data.push()①的调用可能会抛出一个异常,不是拷贝/移动数据值时,就是内存不足的时候。不管是哪种,std::stack<>都能保证其实安全的,所以这里也没有问题。

在第一个重载pop()中,代码可能会抛出一个empty_stack的异常②,不过数据没有被修改,所以其是安全的。对于res的创建③,也可能会抛出一个异常,这有两方面的原因:对std::make_shared的调用,可能无法分配出足够的内存去创建新的对象,并且内部数据需要对新对象进行引用;或者,在拷贝或移动构造到新分配的内存中返回时抛出异常。两种情况下,c++运行库和标准库能确保这里不会出现内存泄露,并且新创建的对象(如果有的话)都能被正确销毁。因为没有对栈进行任何修改,所以这里也不会有问题。当调用data.pop()④时,其能确保不抛出异常,并且返回结果,所以这个重载pop()函数“异常-安全”。

第二个重载pop()类似,除了在拷贝赋值或移动赋值的时候会抛出异常⑤,当构造一个新对象和一个std::shared_ptr实例时都不会抛出异常。同样,在调用data.pop()⑥(这个成员函数保证不会抛出异常)之前,依旧没有对数据结构进行修改,所以这个函数也为“异常-安全”。

最后,empty()也不会修改任何数据,所以也是“异常-安全”函数。

当调用持有一个锁的用户代码时,这里有两个地方可能会产生死锁:进行拷贝构造或移动构造(①,③)和在对数据项进行拷贝赋值或移动赋值操作⑤的时候;还有一个潜在死锁的地方在于用户定义的操作符new。当这些函数,无论是以直接调用栈的成员函数的方式,还是在成员函数进行操作时,对已经插入或删除的数据进行操作的方式,对锁进行获取,都可能造成死锁。不过,用户要对栈负责,当栈未对一个数据进行拷贝或分配时,用户就不能想当然的将其添加到栈中。

所有成员函数都使用st::lack_guard<>来保护数据,所以栈的成员函数能有“线程安全”的表现。当然,构造与析构函数不是“线程安全”的,不过这也不成问题,因为对实例的构造与析构只能有一次。调用一个不完全构造对象或是已销毁对象的成员函数,无论在那种编程方式下,都不可取。所以,用户就要保证在栈对象完成构建前,其他线程无法对其进行访问;并且,一定要保证在栈对象销毁后,所有线程都要停止对其进行访问。

即使在多线程情况下,并发的调用成员函数是安全的(因为使用锁),也要保证在单线程的情况下,数据结构做出正确反应。序列化线程会隐性的限制程序性能,这就是栈争议声最大的地方:当一个线程在等待锁时,它就会无所事事。同样的,对于栈来说,等待添加元素也是没有意义的,所以当一个线程需要等待时,其会定期检查empty()或pop(),以及对empty_stack异常进行关注。这样的现实会限制栈的实现的方式,在线程等待的时候,会浪费宝贵的资源去检查数据,或是要求用户写写外部等待和提示代码(例如,使用条件变量),这就使内部锁失去存在的意义——这就意味着资源的浪费。

无锁并发数据结构设计

定义和意义

使用互斥量、条件变量,以及“期望”来同步阻塞数据的算法和数据结构。应用调用库函数,将会挂起一个执行线程,直到其他线程完成某个特定的动作。库函数将调用阻塞操作来对线程进行阻塞,在阻塞移除前,线程无法继续自己的任务。通常,操作系统会完全挂起一个阻塞线程(并将其时间片交给其他线程),直到其被其他线程“解阻塞”;“解阻塞”的方式很多,比如解锁一个互斥锁、通知条件变量达成,或让“期望”就绪。

不使用阻塞库的数据结构和算法,被称为无阻塞结构。不过,无阻塞的数据结构并非都是无锁的,那么就让我们见识一下各种各样的无阻塞数据结构吧!

非阻塞数据结构

在第5章中,我们使用std::atomic_flag实现了一个简单的自旋锁。一起回顾一下这段代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class spinlock_mutex
{
std::atomic_flag flag;
public:
spinlock_mutex():
flag(ATOMIC_FLAG_INIT)
{}
void lock()
{
while(flag.test_and_set(std::memory_order_acquire));
}
void unlock()
{
flag.clear(std::memory_order_release);
}
};

这段代码没有调用任何阻塞函数,lock()只是让循环持续调用test_and_set(),并返回false。这就是为什么取名为“自旋锁”的原因——代码“自旋”于循环当中。所以,这里没有阻塞调用,任意代码使用互斥量来保护共享数据都是非阻塞的。不过,自旋锁并不是无锁结构。这里用了一个锁,并且一次能锁住一个线程。让我们来看一下无锁结构的具体定义,这将有助于你判断哪些类型的数据结构是无锁的。

无锁数据结构

作为无锁结构,就意味着线程可以并发的访问这个数据结构。线程不能做相同的操作;一个无锁队列可能允许一个线程进行压入数据,另一个线程弹出数据,当有两个线程同时尝试添加元素时,这个数据结构将被破坏。不仅如此,当其中一个访问线程被调度器中途挂起时,其他线程必须能够继续完成自己的工作,而无需等待挂起线程。

具有“比较/交换”操作的数据结构,通常在“比较/交换”实现中都有一个循环。使用“比较/交换”操作的原因:当有其他线程同时对指定数据的修改时,代码将尝试恢复数据。当其他线程被挂起时,“比较/交换”操作执行成功,那么这样的代码就是无锁的。当执行失败时,就需要一个自旋锁了,且这个结构就是“非阻塞-有锁”的结构。

无锁算法中的循环会让一些线程处于“饥饿”状态。如有线程在“错误”时间执行,那么第一个线程将会不停得尝试自己所要完成的操作(其他程序继续执行)。“无锁-无等待”数据结构,就为了避免这种问题存在的。

无等待数据结构

无等待数据结构就是:首先,是无锁数据结构;并且,每个线程都能在有限的步数内完成操作,暂且不管其他线程是如何工作的。由于会和别的线程产生冲突,所以算法可以进行无数次尝试,因此并不是无等待的。

正确实现一个无锁的结构是十分困难的。因为,要保证每一个线程都能在有限步骤里完成操作,就需要保证每一个操作可以被一次性执行完成;当有线程执行某个操作时,不会让其他线程的操作失败。这就会让算法中所使用到的操作变的相当复杂。

考虑到获取无锁或无等待的数据结构所有权都很困难,那么就有理由来写一个数据结构了;需要保证的是,所要得获益要大于实现成本。那么,就先来找一下实现成本和所得获益的平衡点吧!

无锁数据结构的利与弊

使用无锁结构的主要原因:将并发最大化。使用基于锁的容器,会让线程阻塞或等待;互斥锁削弱了结构的并发性。在无锁数据结构中,某些线程可以逐步执行。在无等待数据结构中,无论其他线程当时在做什么,每一个线程都可以转发进度。这种理想的方式实现起来很难。结构太简单,反而不容易写,因为其就是一个自旋锁。

使用无锁数据结构的第二个原因就是鲁棒性。当一个线程在获取一个锁时被杀死,那么数据结构将被永久性的破坏。不过,当线程在无锁数据结构上执行操作,在执行到一半死亡时,数据结构上的数据没有丢失(除了线程本身的数据),其他线程依旧可以正常执行。

另一方面,当不能限制访问数据结构的线程数量时,就需要注意不变量的状态,或选择替代品来保持不变量的状态。同时,还需要注意操作的顺序约束。为了避免未定义行为,及相关的数据竞争,就必须使用原子操作对修改操作进行限制。不过,仅使用原子操作时不够的;需要确定被其他线程看到的修改,是遵循正确的顺序。

因为,没有任何锁(有可能存在活锁),死锁问题不会困扰无锁数据结构。活锁的产生是,两个线程同时尝试修改数据结构,但每个线程所做的修改操作都会让另一个线程重启,所以两个线程就会陷入循环,多次的尝试完成自己的操作。试想有两个人要过独木桥,当两个人从两头向中间走的时候,他们会在中间碰到,然后不得不再走回出发的地方,再次尝试过独木桥。这里,要打破僵局,除非有人先到独木桥的另一端(或是商量好了,或是走的快,或纯粹是运气),要不这个循环将一直重复下去。不过活锁的存在时间并不久,因为其依赖于线程调度。所以其只是对性能有所消耗,而不是一个长期的问题;但这个问题仍需要关注。根据定义,无等待的代码不会被活锁所困扰,因其操作执行步骤是有上限的。换个角度,无等待的算法要比等待算法的复杂度高,且即使没有其他线程访问数据结构,也可能需要更多步骤来完成对应操作。

这就是“无锁-无等待”代码的缺点:虽然提高了并发访问的能力,减少了单个线程的等待时间,但是其可能会将整体性能拉低。首先,原子操作的无锁代码要慢于无原子操作的代码,原子操作就相当于无锁数据结构中的锁。不仅如此,硬件必须通过同一个原子变量对线程间的数据进行同步。在第8章,你将看到与“乒乓”缓存相关的原子变量(多个线程访问同时进行访问),将会成为一个明显的性能瓶颈。在提交代码之前,无论是基于锁的数据结构,还是无锁的数据结构,对性能的检查是很重要的(最坏的等待时间,平均等待时间,整体执行时间,或者其他指标)。

无锁数据结构的例子

为了演示一些在设计无锁数据结构中所使用到的技术,我们将看到一些无锁实现的简单数据结构。这里不仅要在每个例子中描述一个有用的数据结构实现,还将使用这些例子的某些特别之处来阐述对于无锁数据结构的设计。

如之前所提到的,无锁结构依赖与原子操作和内存序及相关保证,以确保多线程以正确的顺序访问数据结构。最初,所有原子操作默认使用的是memory_order_seq_cst内存序;因为简单,所以使用(所有memory_order_seq_cst都遵循一种顺序)。不过,在后面的例子中,我们将会降低内存序的要求,使用memory_order_acquire, memory_order_release, 甚至memory_order_relaxed。虽然这个例子中没有直接的使用锁,但需要注意的是对std::atomic_flag的使用。一些平台上的无锁结构实现,使用了内部锁。

写一个无锁的线程安全栈

栈的要求很简单:查询顺序是添加顺序的逆序——先入后出(LIFO)。所以,要确保一个值安全的添加入栈就十分重要,因为很可能在添加后,马上被其他线程索引,同时确保只有一个线程能索引到给定值也是很重要。最简单的栈就是链表,head指针指向第一个节点(可能是下一个被索引到的节点),并且每个节点依次指向下一个节点。

在这样的情况下,添加一个节点相对来说很简单:

  • 创建一个新节点。
  • 将当新节点的next指针指向当前的head节点。
  • 让head节点指向新节点。

至关重要的是,当有两个线程同时添加节点的时候,在第2步和第3步的时候会产生条件竞争:一个线程可能在修改head的值时,另一个线程正在执行第2步,并且在第3步中对head进行更新。

OK,那如何应对讨厌的条件竞争呢?答案就是:在第3步的时候使用一个原子“比较/交换”操作,来保证当步骤2对head进行读取时,不会对head进行修改。当有修改时,可以循环“比较/交换”操作。下面的代码就展示了,不用锁来实现线程安全的push()函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template<typename T>
class lock_free_stack
{
private:
struct node
{
T data;
node* next;
node(T const& data_): // 1
data(data_)
{}
};
std::atomic<node*> head;
public:
void push(T const& data)
{
node* const new_node=new node(data); // 2
new_node->next=head.load(); // 3
while(!head.compare_exchange_weak(new_node->next,new_node)); // 4
}
};

上面代码近乎能匹配之前所说的三个步骤:创建一个新节点②,设置新节点的next指针指向当前head③,并设置head指针指向新节点④。node结构用其自身的构造函数来进行数据填充①,必须保证节点在构造完成后随时能被弹出。之后需要使用compare_exchange_weak()来保证在被存储到new_node->next的head指针和之前的一样③。代码的亮点是使用“比较/交换”操作:当其返回false时,因为比较失败(例如,head被其他线程锁修改),new_node->next作为操作的第一个参数,将会更新head。循环中不需要每次都重新加载head指针,因为编译器会帮你完成这件事。同样,因为循环可能直接就失败了,所以这里使用compare_exchange_weak要好于使用compare_exchange_strong。

所以,这里暂时不需要pop()操作,可以先快速检查一下push()的实现是否有违指导意见。这里唯一一个能抛出异常的地方就构造新node的时候①,不过其会自行处理,且链表中的内容没有被修改,所以这里是安全的。因为在构建数据的时候,是将其作为node的一部分作为存储的,并且使用compare_exchange_weak()来更新head指针,所以这里没有恶性的条件竞争。“比较/交换”成功时,节点已经准备就绪,且随时可以提取。因为这里没有锁,所以就不存在死锁的情况,这里的push()函数实现的很成功。

那么,你现在已经有往栈中添加数据的方法了,现在需要删除数据的方法。其步骤如下,也很简单:

  • 读取当前head指针的值。
  • 读取head->next。
  • 设置head到head->next。
  • 通过索引node,返回data数据。
  • 删除索引节点。

但在多线程环境下,就不像看起来那么简单了。当有两个线程要从栈中移除数据,两个线程可能在步骤1中读取到同一个head(值相同)。当其中一个线程处理到步骤5,而另一个线程还在处理步骤2时,这个还在处理步骤2的线程将会解引用一个悬空指针。这只是写无锁代码所遇到的最大问题之一,所以现在只能跳过步骤5,让节点泄露。

另一个问题就是:当两个线程读取到同一个head值,他们将返回同一个节点。这就违反了栈结构的意图,所以你需要避免这样的问题产生。你可以像在push()函数中解决条件竞争那样来解决这个问题:使用“比较/交换”操作更新head。当“比较/交换”操作失败时,不是一个新节点已被推入,就是其他线程已经弹出了想要弹出的节点。无论是那种情况,都得返回步骤1(“比较/交换”操作将会重新读取head)。

当“比较/交换”成功,就可以确定当前线程是弹出给定节点的唯一线程,之后就可以放心的执行步骤4了。这里先看一下pop()的雏形:

1
2
3
4
5
6
7
8
9
10
11
template<typename T>
class lock_free_stack
{
public:
void pop(T& result)
{
node* old_head=head.load();
while(!head.compare_exchange_weak(old_head,old_head->next));
result=old_head->data;
}
};

虽然这段代码很优雅,但这里还有两个节点泄露的问题。首先,这段代码在空链表的时候不工作:当head指针式一个空指针时,当要访问next指针时,将引起未定义行为。这很容易通过对nullptr的检查进行修复(在while循环中),要不对空栈抛出一个异常,要不返回一个bool值来表明成功与否。

第二个问题就是异常安全问题。当在第3章中介绍栈结构时,了解了在返回值的时候会出现异常安全问题:当有异常被抛出时,复制的值将丢失。在这种情况下,传入引用是一种可以接受的解决方案;因为这样就能保证,当有异常抛出时,栈上的数据不会丢失。不幸的是,不能这样做;只能在单一线程对值进行返回的时候,才进行拷贝,以确保拷贝操作的安全性,这就意味着在拷贝结束后这个节点就被删除了。因此,通过引用获取返回值的方式就没有任何优势:直接返回也是可以的。若想要安全的返回值,你必须使用第3章中的其他方法:返回指向数据值的(智能)指针。

当返回的是智能指针时,就能返回nullptr以表明没有值可返回,但是要求在堆上对智能指针进行内存分配。将分配过程做为pop()的一部分时(也没有更好的选择了),堆分配时可能会抛出一个异常。与此相反,可以在push()操作中对内存进行分配——无论怎样,都得对node进行内存分配。返回一个std::shared_ptr<>不会抛出异常,所以在pop()中进行分配就是安全的。将上面的观点放在一起,就能看到如下的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
template<typename T>
class lock_free_stack
{
private:
struct node
{
std::shared_ptr<T> data; // 1 指针获取数据
node* next;
node(T const& data_):
data(std::make_shared<T>(data_)) // 2 让std::shared_ptr指向新分配出来的T
{}
};
std::atomic<node*> head;
public:
void push(T const& data)
{
node* const new_node=new node(data);
new_node->next=head.load();
while(!head.compare_exchange_weak(new_node->next,new_node));
}
std::shared_ptr<T> pop()
{
node* old_head=head.load();
while(old_head && // 3 在解引用前检查old_head是否为空指针
!head.compare_exchange_weak(old_head,old_head->next));
return old_head ? old_head->data : std::shared_ptr<T>(); // 4
}
};

智能指针指向当前数据①,这里必须在堆上为数据分配内存(在node结构体中)②。而后,在compare_exchage_weak()循环中③,需要在old_head指针前,检查指针是否为空。最终,如果存在相关节点,那么将会返回相关节点的值;当不存在时,将返回一个空指针④。注意,结构是无锁的,但并不是无等待的,因为在push()pop()函数中都有while循环,当compare_exchange_weak()总是失败的时候,循环将会无限循环下去。

停止内存泄露:使用无锁数据结构管理内存

第一次了解pop()时,为了避免条件竞争选择了带有内存泄露的节点。但是,不论什么样的C++程序,存在内存泄露都不可接受。所以,现在来解决这个问题!

基本问题在于,当要释放一个节点时,需要确认其他线程没有持有这个节点。当只有一个线程调用pop(),就可以放心的进行释放。当节点添加入栈后,push()就不会与节点有任何的关系了,所以只有调用pop()函数的线程与已加入节点有关,并且能够安全的将节点删除。

另一方面,当栈同时处理多线程对pop()的调用时,就需要知道节点在什么时候被删除。这实际上就需要你写一个节点专用的垃圾收集器。这听起来有些可怖,同时也相当棘手,不过并不是多么糟糕:这里需要检查节点,并且检查哪些节点被pop()访问。不需要对push()中的节点有所担心,因为这些节点推到栈上以后,才能被访问到,而多线程只能通过pop()访问同一节点。

当没有线程调用pop()时,这时可以删除栈上的任意节点。因此,当添加节点到“可删除”列表中时,就能从中提取数据了。而后,当没有线程通过pop()访问节点时,就可以安全的删除这些节点了。那怎么知道没有线程调用pop()了呢?很简单——计数即可。当计数器数值增加时,就是有节点推入;当减少时,就是有节点被删除。这样从“可删除”列表中删除节点就很安全了,直到计数器的值为0为止。当然,这个计数器必须是原子的,这样它才能在多线程的情况下正确的进行计数。下面的清单中,展示了修改后的pop()函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template<typename T>
class lock_free_stack
{
private:
std::atomic<unsigned> threads_in_pop; // 1 原子变量
void try_reclaim(node* old_head);
public:
std::shared_ptr<T> pop()
{
++threads_in_pop; // 2 在做事之前,计数值加1
node* old_head=head.load();
while(old_head &&
!head.compare_exchange_weak(old_head,old_head->next));
std::shared_ptr<T> res;
if(old_head)
{
res.swap(old_head->data); // 3 回收删除的节点
}
try_reclaim(old_head); // 4 从节点中直接提取数据,而非拷贝指针
return res;
}
};

threads_in_pop①原子变量用来记录有多少线程试图弹出栈中的元素。当pop()②函数调用的时候,计数器加一;当调用try_reclaim()时,计数器减一,当这个函数被节点调用时,说明这个节点已经被删除④。因为暂时不需要将节点删除,可以通过swap()函数来删除节点上的数据③(而非只是拷贝指针),当不再需要这些数据的时候,这些数据会自动删除,而不是持续存在着。接下来看一下try_reclaim()是如何实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
template<typename T>
class lock_free_stack
{
private:
std::atomic<node*> to_be_deleted;
static void delete_nodes(node* nodes)
{
while(nodes)
{
node* next=nodes->next;
delete nodes;
nodes=next;
}
}
void try_reclaim(node* old_head)
{
if(threads_in_pop==1) // 1
{
node* nodes_to_delete=to_be_deleted.exchange(nullptr); // 2 声明“可删除”列表
if(!--threads_in_pop) // 3 是否只有一个线程调用pop()?
{
delete_nodes(nodes_to_delete); // 4
}
else if(nodes_to_delete) // 5
{
chain_pending_nodes(nodes_to_delete); // 6
}
delete old_head; // 7
}
else
{
chain_pending_node(old_head); // 8
--threads_in_pop;
}
}
void chain_pending_nodes(node* nodes)
{
node* last=nodes;
while(node* const next=last->next) // 9 让next指针指向链表的末尾
{
last=next;
}
chain_pending_nodes(nodes,last);
}
void chain_pending_nodes(node* first,node* last)
{
last->next=to_be_deleted; // 10
while(!to_be_deleted.compare_exchange_weak( // 11 用循环来保证last->next的正确性
last->next,first));
}
void chain_pending_node(node* n)
{
chain_pending_nodes(n,n); // 12
}
};

回收节点时①,threads_in_pop的数值是1,也就是当前线程正在对pop()进行访问,这时就可以安全的将节点进行删除了⑦(将等待节点删除也是安全的)。当数值不是1时,删除任何节点都不安全,所以需要向等待列表中继续添加节点⑧。

假设在某一时刻,threads_in_pop的值为1。那就可以尝试回收等待列表,如果不回收,节点就会继续等待,直到整个栈被销毁。要做到回收,首先要通过一个原子exchange操作声明②删除列表,并将计数器减一③。如果之后计数的值为0,就意味着没有其他线程访问等待节点链表。出现新的等待节点时,不必为其烦恼,因为它们将被安全的回收。而后,可以使用delete_nodes对链表进行迭代,并将其删除④。

当计数值在减后不为0,回收节点就不安全;所以如果存在⑤,就需要将其挂在等待删除链表之后⑥,这种情况会发生在多个线程同时访问数据结构的时候。一些线程在第一次测试threads_in_pop①和对“回收”链表的声明②操作间调用pop(),这可能新填入一个已经被线程访问的节点到链表中。在图7.1中,线程C添加节点Y到to_be_deleted链表中,即使线程B仍将其引用作为old_head,之后会尝试访问其next指针。在线程A删除节点的时候,会造成线程B发生未定义的行为。

为了将等待删除的节点添加入等待删除链表,需要复用节点的next指针将等待删除节点链接在一起。在这种情况下,将已存在的链表链接到删除链表后面,通过遍历的方式找到链表的末尾⑨,将最后一个节点的next指针替换为当前to_be_deleted指针⑩,并且将链表中的第一个节点作为新的to_be_deleted指针进行存储⑪。这里需要在循环中使用compare_exchange_weak来保证,通过其他线程添加进来的节点不会发生内存泄露。这样,在链表发生改变时,更新next指针很方便。添加单个节点是一种特殊情况,因为这需要将这个节点作为第一个节点,同时也是最后一个节点进行添加⑫。

在低负荷的情况下,这种方式没有问题,因为在没有线程访问pop(),有一个合适的静态指针。不过,这只是一个瞬时的状态,也就是为什么在回收前,需要检查threads_in_pop计数为0③的原因;同样也是删除节点⑦前进行对计数器检查的原因。删除节点是一项耗时的工作,并且希望其他线程能对链表做的修改越小越好。从第一次发现threads_in_pop是1,到尝试删除节点,会用很长的时间,这样就会让线程有机会调用pop(),会让threads_in_pop不为0,阻止节点的删除操作。

检测使用风险指针(不可回收)的节点

因为删除一个节点可能会让其他引用其的线程处于危险之中。当其他线程持有这个删除的节点的指针,并且解引用进行操作的时候,将会出现未定义行为。这里的基本观点就是,当有线程去访问要被(其他线程)删除的对象时,会先设置对这个对象设置一个风险指针,而后通知其他线程,删除这个指针是一个危险的行为。一旦这个对象不再被需要,那么就可以清除风险指针了。

当线程想要删除一个对象,那么它就必须检查系统中其他线程是否持有风险指针。当没有风险指针的时候,那么它就可以安全删除对象。否则,它就必须等待风险指针的消失了。这样,线程就得周期性的检查其想要删除的对象是否能安全删除。

首先,需要一个地点能存储指向访问对象的指针,这个地点就是风险指针。这个地点必须能让所有线程看到,需要其中一些线程可以对数据结构进行访问。如何正确和高效的分配这些线程,的确是一个挑战,所以这个问题可以放在后面解决,而后假设你有一个get_hazard_pointer_for_current_thread()的函数,这个函数可以返回风险指针的引用。当你读取一个指针,并且想要解引用它的时候,你就需要这个函数——在这种情况下head数值源于下面的列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
std::shared_ptr<T> pop()
{
std::atomic<void*>& hp=get_hazard_pointer_for_current_thread();
node* old_head=head.load(); // 1
node* temp;
do
{
temp=old_head;
hp.store(old_head); // 2
old_head=head.load();
} while(old_head!=temp); // 3
// ...
}

在while循环中就能保证node不会在读取旧head指针①时,以及在设置风险指针的时被删除了。这种模式下,其他线程不知道有线程对这个给定的节点进行了访问。幸运的是,当旧head节点要被删除时,head本身是要改变的,所以需要对head进行检查,并持续循环,直到head指针中的值与风险指针中的值相同③。使用风险指针,如同依赖对已删除对象的引用。当使用默认的new和delete操作对风险指针进行操作时,会出现未定义行为,所以需要确定实现是否支持这样的操作,或使用自定义分配器来保证这种用法的正确性。

现在已经设置了风险指针,那就可以对pop()进行处理了,基于现在了解到的安全知识,这里不会有其他线程来删除节点。啊哈!这里每一次重新加载old_head时,解引用刚刚读取到的指针时,就需要更新风险指针。当从链表中提取一个节点时,就可以将风险指针清除了。如果没有其他风险指针引用节点,就可以安全的删除节点了;否则,就需要将其添加到链表中,之后再将其删除。下面的代码就是对该方案的完整实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
std::shared_ptr<T> pop()
{
std::atomic<void*>& hp=get_hazard_pointer_for_current_thread();
node* old_head=head.load();
do
{
node* temp;
do // 1 直到将风险指针设为head指针
{
temp=old_head;
hp.store(old_head);
old_head=head.load();
} while(old_head!=temp);
}
while(old_head &&
!head.compare_exchange_strong(old_head,old_head->next));
hp.store(nullptr); // 2 当声明完成,清除风险指针
std::shared_ptr<T> res;
if(old_head)
{
res.swap(old_head->data);
if(outstanding_hazard_pointers_for(old_head)) // 3 在删除之前对风险指针引用的节点进行检查
{
reclaim_later(old_head); // 4
}
else
{
delete old_head; // 5
}
delete_nodes_with_no_hazards(); // 6
}
return res;
}

首先,循环内部会对风险指针进行设置,在当“比较/交换”操作失败会重载old_head,再次进行设置①。使用compare_exchange_strong(),是因为需要在循环内部做一些实际的工作:当compare_exchange_weak()伪失败后,风险指针将被重置(没有必要)。这个过程能保证风险指针在解引用(old_head)之前,能被正确的设置。当已声明了一个风险指针,那么就可以将其清除了②。如果想要获取一个节点,就需要检查其他线程上的风险指针,检查是否有其他指针引用该节点③。如果有,就不能删除节点,只能将其放在链表中,之后再进行回收④;如果没有,就能直接将这个节点删除了⑤。最后,如果需要对任意节点进行检查,可以调用reclaim_later()。如果链表上没有任何风险指针引用节点,就可以安全的删除这些节点⑥。当有节点持有风险指针,就只能让下一个调用pop()的线程离开。

当然,这些函数——get_hazard_pointer_for_current_thread(), reclaim_later(), outstanding_hazard_pointers_for(), 和delete_nodes_with_no_hazards()——的实现细节我们还没有看到,先来看看它们是如何工作的。

为线程分配风险指针实例的具体方案:使用get_hazard_pointer_for_current_thread()与程序逻辑的关系并不大(不过会影响效率,接下会看到具体的情况)。可以使用一个简单的结构体:固定长度的“线程ID-指针”数组。get_hazard_pointer_for_curent_thread()就可以通过这个数据来找到第一个释放槽,并将当前线程的ID放入到这个槽中。当线程退出时,槽就再次置空,可以通过默认构造std::thread::id()将线程ID放入槽中。这个实现就如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
unsigned const max_hazard_pointers=100;
struct hazard_pointer
{
std::atomic<std::thread::id> id;
std::atomic<void*> pointer;
};
hazard_pointer hazard_pointers[max_hazard_pointers];
class hp_owner
{
hazard_pointer* hp;
public:
hp_owner(hp_owner const&)=delete;
hp_owner operator=(hp_owner const&)=delete;
hp_owner():
hp(nullptr)
{
for(unsigned i=0;i<max_hazard_pointers;++i)
{
std::thread::id old_id;
if(hazard_pointers[i].id.compare_exchange_strong( // 6 尝试声明风险指针的所有权
old_id,std::this_thread::get_id()))
{
hp=&hazard_pointers[i];
break; // 7
}
}
if(!hp) // 1
{
throw std::runtime_error("No hazard pointers available");
}
}
std::atomic<void*>& get_pointer()
{
return hp->pointer;
}
~hp_owner() // 2
{
hp->pointer.store(nullptr); // 8
hp->id.store(std::thread::id()); // 9
}
};
std::atomic<void*>& get_hazard_pointer_for_current_thread() // 3
{
thread_local static hp_owner hazard; // 4 每个线程都有自己的风险指针
return hazard.get_pointer(); // 5
}

get_hazard_pointer_for_current_thread()的实现看起来很简单③:一个hp_owner④类型的thread_local(本线程所有)变量,用来存储当前线程的风险指针,可以返回这个变量所持有的指针⑤。之后的工作:第一次有线程调用这个函数时,新hp_owner实例就被创建。这个实例的构造函数⑥,会通过查询“所有者/指针”表,寻找没有所有者的记录。其用compare_exchange_strong()来检查某个记录是否有所有者,并进行析构②。当compare_exchange_strong()失败,其他线程的拥有这个记录,所以可以继续执行下去。当交换成功,当前线程就拥有了这条记录,而后对其进行存储,并停止搜索⑦。当遍历了列表也没有找到物所有权的记录①,就说明有很多线程在使用风险指针,所以这里将抛出一个异常。

一旦hp_owner实例被一个给定的线程所创建,那么之后的访问将会很快,因为指针在缓存中,所以表不需要再次遍历。

当线程退出时,hp_owner的实例将会被销毁。析构函数会在std::thread::id()设置拥有者ID前,将指针重置为nullptr,这样就允许其他线程对这条记录进行复用⑧⑨。

实现get_hazard_pointer_for_current_thread()后,outstanding_hazard_pointer_for()实现就简单了:只需要对风险指针表进行搜索,就可以找到对应记录。

1
2
3
4
5
6
7
8
9
10
11
bool outstanding_hazard_pointers_for(void* p)
{
for(unsigned i=0;i<max_hazard_pointers;++i)
{
if(hazard_pointers[i].pointer.load()==p)
{
return true;
}
}
return false;
}

实现都不需要对记录的所有者进行验证:没有所有者的记录会是一个空指针,所以比较代码将总返回false,通过这种方式将代码简化。

reclaim_later()delete_nodes_with_no_hazards()可以对简单的链表进行操作;reclaim_later()只是将节点添加到列表中,delete_nodes_with_no_hazards()就是搜索整个列表,并将无风险指针的记录进行删除。下面将展示它们的具体实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
template<typename T>
void do_delete(void* p)
{
delete static_cast<T*>(p);
}

struct data_to_reclaim
{
void* data;
std::function<void(void*)> deleter;
data_to_reclaim* next;

template<typename T>
data_to_reclaim(T* p): // 1
data(p),
deleter(&do_delete<T>),
next(0)
{}

~data_to_reclaim()
{
deleter(data); // 2
}
};

std::atomic<data_to_reclaim*> nodes_to_reclaim;

void add_to_reclaim_list(data_to_reclaim* node) // 3
{
node->next=nodes_to_reclaim.load();
while(!nodes_to_reclaim.compare_exchange_weak(node->next,node));
}

template<typename T>
void reclaim_later(T* data) // 4
{
add_to_reclaim_list(new data_to_reclaim(data)); // 5
}

void delete_nodes_with_no_hazards()
{
data_to_reclaim* current=nodes_to_reclaim.exchange(nullptr); // 6
while(current)
{
data_to_reclaim* const next=current->next;
if(!outstanding_hazard_pointers_for(current->data)) // 7
{
delete current; // 8
}
else
{
add_to_reclaim_list(current); // 9
}
current=next;
}
}

首先,reclaim_later()是一个函数模板④。因为风险指针是一个通用解决方案,所以这里就不能将栈节点的类型写死。使用std::atomic<void*>对风险指针进行存储。需要对任意类型的指针进行处理,不过不能使用void*形式,因为当要删除数据项时,delete操作只能对实际类型指针进行操作。data_to_reclaim的构造函数处理的就很优雅:reclaim_later()只是为指针创建一个data_to_reclaim的实例,并且将实例添加到回收链表中⑤。add_to_reclaim_list()③就是使用compare_exchange_weak()循环来访问链表头(就如你之前看到的那样)。

当将节点添加入链表时,data_to_reclaim的析构函数不会被调用;析构函数会在没有风险指针指向节点的时候调用,这也就是delete_nodes_with_no_hazards()的作用。

delete_nodes_with_no_hazards()将已声明的链表节点进行回收,使用的是exchange()函数⑥(这个步骤简单且关键,是为了保证只有一个线程回收这些节点)。这样,其他线程就能自由将节点添加到链表中,或在不影响回收指定节点线程的情况下,对节点进行回收。

只要有节点存在于链表中,就需要检查每个节点,查看节点是否被风险指针所指向⑦。如果没有风险指针,那么就可以安全的将记录删除(并且清除存储的数据)⑧。否则,就只能将这个节点添加到链表的后面,再进行回收⑨。

虽然这个实现很简单,也的确安全的回收了被删除的节点,不过这个过程增加了很多开销。遍历风险指针数组需要检查max_hazard_pointers原子变量,并且每次pop()调用时,都需要再检查一遍。原子操作很耗时——在台式CPU上,100次原子操作要比100次非原子操作慢——所以,这里pop()成为了性能瓶颈。这种方式,不仅需要遍历节点的风险指针链表,还要遍历等待链表上的每一个节点。显然,这种方式很糟糕。当有max_hazard_pointers在链表中,那么就需要检查max_hazard_pointers多个已存储的风险指针。

对风险指针(较好)的回收策略

当然有更好的办法。这里只展示一个风险指针的简单实现,来帮助解释技术问题。首先,要考虑的是内存性能。比起对回收链表上的每个节点进行检查都要调用pop(),除非有超过max_hazard_pointer数量的节点存在于链表之上,要不就不需要尝试回收任何节点。这样就能保证至少有一个节点能够回收,如果只是等待链表中的节点数量达到max_hazard_pointers+1,那比之前的方案也没好到哪里去。当获取了max_hazard_pointers数量的节点时,可以调用pop()对节点进行回收,所以这样也不是很好。不过,当有2max_hazard_pointers个节点在列表中时,就能保证至少有max_hazard_pointers可以被回收,在再次尝试回收任意节点前,至少会对pop()max_hazard_pointers次调用。这就很不错了。比起检查max_hazard_pointers个节点就调用max_hazard_pointerspop()(而且还不一定能回收节点),当检查2max_hazard_pointers个节点时,每max_hazard_pointers次对pop()的调用,就会有max_hazard_pointers个节点能被回收。这就意味着,对两个节点检查调用pop(),其中就有一个节点能被回收。

这个方法有个缺点(有增加内存使用的情况):就是得对回收链表上的节点进行计数,这就意味着要使用原子变量,并且还有很多线程争相对回收链表进行访问。如果还有多余的内存,可以增加内存的使用来实现更好的回收策略:每个线程中的都拥有其自己的回收链表,作为线程的本地变量。这样就不需要原子变量进行计数了。这样的话,就需要分配max_hazard_pointers x max_hazard_pointers个节点。所有节点被回收完毕前时,有线程退出,那么其本地链表可以像之前一样保存在全局中,并且添加到下一个线程的回收链表中,让下一个线程对这些节点进行回收。

应用于无锁栈上的内存模型

在修改内存序之前,需要检查一下操作之间的依赖关系。而后,再去确定适合这种需求关系的最小内存序。为了保证这种方式能够工作,需要在从线程的视角进行观察。其中最简单的视角就是,向栈中推入一个数据项,之后让其他线程从栈中弹出这个数据。

即使在简单的例子中,都需要三个重要的数据参与。1、counted_node_ptr转移的数据head。2、head引用的node。3、节点所指向的数据项。

push()的线程,会先构造数据项和节点,再设置head。做pop()的线程,会先加载head的值,再做在循环中对head做“比较/交换”操作,并增加引用计数,再读取对应的node节点,获取next的指向的值,现在就可以看到一组需求关系。next的值是普通的非原子对象,所以为了保证读取安全,这里必须确定存储(推送线程)和加载(弹出线程)的先行关系。因为唯一的原子操作就是push()函数中的compare_exchange_weak(),这里需要释放操作来获取两个线程间的先行关系,这里compare_exchange_weak()必须是std::memory_order_release或更严格的内存序。当compare_exchange_weak()调用失败,什么都不会改变,并且可以持续循环下去,所以使用std::memory_order_relaxed就足够了。

1
2
3
4
5
6
7
8
9
void push(T const& data)
{
counted_node_ptr new_node;
new_node.ptr=new node(data);
new_node.external_count=1;
new_node.ptr->next=head.load(std::memory_order_relaxed)
while(!head.compare_exchange_weak(new_node.ptr->next,new_node,
std::memory_order_release,std::memory_order_relaxed));
}

pop()的实现呢?为了确定先行关系,必须在访问next值之前使用std::memory_order_acquire或更严格内存序的操作。因为,在increase_head_count()中使用compare_exchange_strong()就获取next指针指向的旧值,所以想要其获取成功就需要确定内存序。如同调用push()那样,当交换失败,循环会继续,所以在失败的时候使用松散的内存序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void increase_head_count(counted_node_ptr& old_counter)
{
counted_node_ptr new_counter;

do
{
new_counter=old_counter;
++new_counter.external_count;
}
while(!head.compare_exchange_strong(old_counter,new_counter,
std::memory_order_acquire,std::memory_order_relaxed));

old_counter.external_count=new_counter.external_count;
}

compare_exchange_strong()调用成功,那么ptr中的值就被存到old_counter中。存储操作是push()中的一个释放操作,并且compare_exchange_strong()操作是一个获取操作,现在存储同步于加载,并且能够获取先行关系。因此,在push()中存储ptr的值,要先行于在pop()中对ptr->next的访问。现在的操作就安全了。

要注意的是,内存序对head.load()的初始化并不妨碍分析,所以现在就可以使用std::memory_order_relaxed

接下来,compare_exchange_strong()old_head.ptr->next设置为head。是否需要做什么来保证操作线程中的数据完整性呢?当交换成功,你就能访问ptr->data,所以这里需要保证在push()线程中已经对ptr->data进行了存储(在加载之前)。在increase_head_count()中的获取操作,能保证与push()线程中的存储和“比较/交换”同步。这里的先行关系是:在push()线程中存储数据,先行于存储head指针;调用increase_head_count()先行于对ptr->data的加载。即使,pop()中的“比较/交换”操作使用std::memory_order_relaxed,这些操作还是能正常运行。唯一不同的地方就是,调用swap()ptr->data有所变化,且没有其他线程可以对同一节点进行操作(这就是“比较/交换”操作的作用)。

compare_exchange_strong()失败,那么新值就不会去更新old_head,继续循环。这里,已确定在increase_head_count()中使用std::memory_order_acquire内存序的可行性,所以这里使用std::memory_order_relaxed也可以。

其他线程呢?是否需要设置一些更为严格的内存序来保证其他线程的安全呢?回答是“不用”。因为,head只会因“比较/交换”操作有所改变;对于“读-改-写”操作来说,push()中的“比较/交换”操作是构成释放序列的一部分。因此,即使有很多线程在同一时间对head进行修改,push()中的compare_exchange_weak()increase_head_count()(读取已存储的值)中的compare_exchange_strong()也是同步的。

剩余操作就可以用来处理fetch_add()操作(用来改变引用计数的操作),因为已知其他线程不可能对该节点的数据进行修改,所以从节点中返回数据的线程可以继续执行。不过,当线程获取其他线程修改后的值时,就代表操作失败(swap()是用来提取数据项的引用)。那么,为了避免数据竞争,需要保证swap()先行于delete操作。一种简单的解决办法,在“成功返回”分支中对fetch_add()使用std::memory_order_release内存序,在“再次循环”分支中对fetch_add()使用std::memory_order_qcquire内存序。不过,这就有点矫枉过正:只有一个线程做delete操作(将引用计数设置为0的线程),所以只有这个线程需要获取操作。幸运的是,因为fetch_add()是一个“读-改-写”操作,是释放序列的一部分,所以可以使用一个额外的load()做获取。当“再次循环”分支将引用计数减为0时,fetch_add()可以重载引用计数,这里使用std::memory_order_acquire为了保持需求的同步关系;并且,fetch_add()本身可以使用std::memory_order_relaxed。使用新pop()的栈实现如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
template<typename T>
class lock_free_stack
{
private:
struct node;
struct counted_node_ptr
{
int external_count;
node* ptr;
};

struct node
{
std::shared_ptr<T> data;
std::atomic<int> internal_count;
counted_node_ptr next;

node(T const& data_):
data(std::make_shared<T>(data_)),
internal_count(0)
{}
};

std::atomic<counted_node_ptr> head;

void increase_head_count(counted_node_ptr& old_counter)
{
counted_node_ptr new_counter;

do
{
new_counter=old_counter;
++new_counter.external_count;
}
while(!head.compare_exchange_strong(old_counter,new_counter,
std::memory_order_acquire,
std::memory_order_relaxed));
old_counter.external_count=new_counter.external_count;
}
public:
~lock_free_stack()
{
while(pop());
}

void push(T const& data)
{
counted_node_ptr new_node;
new_node.ptr=new node(data);
new_node.external_count=1;
new_node.ptr->next=head.load(std::memory_order_relaxed)
while(!head.compare_exchange_weak(new_node.ptr->next,new_node,
std::memory_order_release,
std::memory_order_relaxed));
}
std::shared_ptr<T> pop()
{
counted_node_ptr old_head=
head.load(std::memory_order_relaxed);
for(;;)
{
increase_head_count(old_head);
node* const ptr=old_head.ptr;
if(!ptr)
{
return std::shared_ptr<T>();
}
if(head.compare_exchange_strong(old_head,ptr->next,
std::memory_order_relaxed))
{
std::shared_ptr<T> res;
res.swap(ptr->data);

int const count_increase=old_head.external_count-2;

if(ptr->internal_count.fetch_add(count_increase,
std::memory_order_release)==-count_increase)
{
delete ptr;
}

return res;
}
else if(ptr->internal_count.fetch_add(-1,
std::memory_order_relaxed)==1)
{
ptr->internal_count.load(std::memory_order_acquire);
delete ptr;
}
}
}
};

并发代码设计

线程间划分工作的技术

递归划分

快速排序有两个最基本的步骤:将数据划分到中枢元素之前或之后,然后对中枢元素之前和之后的两半数组再次进行快速排序。这里不能通过对数据的简单划分达到并行,因为,只有在一次排序结束后,才能知道哪些项在中枢元素之前和之后。当要对这种算法进行并行化,很自然的会想到使用递归。每一级的递归都会多次调用quick_sort函数,因为需要知道哪些元素在中枢元素之前和之后。递归调用是完全独立的,因为其访问的是不同的数据集,并且每次迭代都能并发执行。比起对大于和小于的数据块递归调用函数,使用std::async()可以为每一级生成小于数据块的异步任务。使用std::async()时,C++线程库就能决定何时让一个新线程执行任务,以及同步执行任务。

重要的是:对一个很大的数据集进行排序时,当每层递归都产生一个新线程,最后就会产生大量的线程。你会看到其对性能的影响,如果有太多的线程存在,那么你的应用将会运行的很慢。如果数据集过于庞大,会将线程耗尽。那么在递归的基础上进行任务的划分,就是一个不错的主意;你只需要将一定数量的数据打包后,交给线程即可。std::async()可以出里这种简单的情况,不过这不是唯一的选择。

另一种选择是使用std::thread::hardware_concurrency()函数来确定线程的数量。然后,你可以将已排序的数据推到线程安全的栈上。当线程无所事事,不是已经完成对自己数据块的梳理,就是在等待一组排序数据的产生;线程可以从栈上获取这组数据,并且对其排序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
template<typename T>
struct sorter // 1
{
struct chunk_to_sort
{
std::list<T> data;
std::promise<std::list<T> > promise;
};
thread_safe_stack<chunk_to_sort> chunks; // 2
std::vector<std::thread> threads; // 3
unsigned const max_thread_count;
std::atomic<bool> end_of_data;
sorter():
max_thread_count(std::thread::hardware_concurrency()-1),
end_of_data(false)
{}
~sorter() // 4
{
end_of_data=true; // 5
for(unsigned i=0;i<threads.size();++i)
{
threads[i].join(); // 6
}
}
void try_sort_chunk()
{
boost::shared_ptr<chunk_to_sort > chunk=chunks.pop(); // 7
if(chunk)
{
sort_chunk(chunk); // 8
}
}
std::list<T> do_sort(std::list<T>& chunk_data) // 9
{
if(chunk_data.empty())
{
return chunk_data;
}
std::list<T> result;
result.splice(result.begin(),chunk_data,chunk_data.begin());
T const& partition_val=*result.begin();
typename std::list<T>::iterator divide_point= // 10
std::partition(chunk_data.begin(),chunk_data.end(),
[&](T const& val){return val<partition_val;});
chunk_to_sort new_lower_chunk;
new_lower_chunk.data.splice(new_lower_chunk.data.end(),
chunk_data,chunk_data.begin(),
divide_point);
std::future<std::list<T> > new_lower=
new_lower_chunk.promise.get_future();
chunks.push(std::move(new_lower_chunk)); // 11
if(threads.size()<max_thread_count) // 12
{
threads.push_back(std::thread(&sorter<T>::sort_thread,this));
}
std::list<T> new_higher(do_sort(chunk_data));
result.splice(result.end(),new_higher);
while(new_lower.wait_for(std::chrono::seconds(0)) !=
std::future_status::ready) // 13
{
try_sort_chunk(); // 14
}
result.splice(result.begin(),new_lower.get());
return result;
}
void sort_chunk(boost::shared_ptr<chunk_to_sort> const& chunk)
{
chunk->promise.set_value(do_sort(chunk->data)); // 15
}
void sort_thread()
{
while(!end_of_data) // 16
{
try_sort_chunk(); // 17
std::this_thread::yield(); // 18
}
}
};
template<typename T>
std::list<T> parallel_quick_sort(std::list<T> input) // 19
{
if(input.empty())
{
return input;
}
sorter<T> s;
return s.do_sort(input); // 20
}

这里,parallel_quick_sort函数⑲代表了sorter类①的功能,其支持在栈上简单的存储无序数据块②,并且对线程进行设置③。do_sort成员函数⑨主要做的就是对数据进行划分⑩。相较于对每一个数据块产生一个新的线程,这次会将这些数据块推到栈上⑪;并在有备用处理器⑫的时候,产生新线程。因为小于部分的数据块可能由其他线程进行处理,那么就得等待这个线程完成⑬。为了让所有事情顺利进行,当线程处于等待状态时⑭,就让当前线程尝试处理栈上的数据。try_sort_chunk只是从栈上弹出一个数据块⑦,并且对其进行排序⑧,将结果存在promise中,让线程对已经存在于栈上的数据块进行提取⑮。

end_of_data没有被设置时⑯,新生成的线程还在尝试从栈上获取需要排序的数据块⑰。在循环检查中,也要给其他线程机会⑱,可以从栈上取下数据块进行更多的操作。这里的实现依赖于sorter类④对线程的清理。当所有数据都已经排序完成,do_sort将会返回(即使还有工作线程在运行),所以主线程将会从parallel_quick_sort⑳中返回,在这之后会销毁sorter对象。析构函数会设置end_of_data标志⑤,以及等待所有线程完成工作⑥。标志的设置将终止线程函数内部的循环⑯。

影响并发代码性能的因素

有多少个处理器?

处理器个数是影响多线程应用的首要因素。一个单核16芯的处理器和四核双芯或十六核单芯的处理器相同:在任何系统上,都能运行16个并发线程。当线程数量少于16个时,会有处理器处于空闲状态。另一方面,当多于16个线程在运行的时候(都没有阻塞或等待),应用将会浪费处理器的运算时间在线程间进行切换。

为了扩展应用线程的数量,与硬件所支持的并发线程数量一致,C++标准线程库提供了std::thread::hardware_concurrency()。使用这个函数就能知道在给定硬件上可以扩展的线程数量了。

需要谨慎使用std::thread::hardware_concurrency(),因为代码不会考虑有其他运行在系统上的线程(除非已经将系统信息进行共享)。最坏的情况就是,多线程同时调用std::thread::hardware_concurrency()函数来对线程数量进行扩展,这样将导致庞大的超额认购。

数据争用与乒乓缓存

当两个线程并发的在不同处理器上执行,并且对同一数据进行读取,通常不会出现问题;因为数据将会拷贝到每个线程的缓存中,并且可以让两个处理器同时进行处理。不过,当有线程对数据进行修改的时候,这个修改需要更新到其他核芯的缓存中去,就要耗费一定的时间。

思考下面简短的代码段:

1
2
3
4
5
6
7
8
std::atomic<unsigned long> counter(0);
void processing_loop()
{
while(counter.fetch_add(1,std::memory_order_relaxed)<100000000)
{
do_something();
}
}

counter变量是全局的,所以任何线程都能调用processing_loop()去修改同一个变量。因此,当新增加的处理器时,counter变量必须要在缓存内做一份拷贝,再改变自己的值,或其他线程以发布的方式对缓存中的拷贝副本进行更新。即使用std::memory_order_relaxed,编译器不会为任何数据做同步操作,fetch_add是一个“读-改-写”操作,因此就要对最新的值进行检索。如果另一个线程在另一个处理器上执行同样的代码,counter的数据需要在两个处理器之间进行传递,那么这两个处理器的缓存中间就存有counter的最新值(当counter的值增加时)。

如果do_something()足够短,或有很多处理器来对这段代码进行处理时,处理器将会互相等待;一个处理器准备更新这个值,另一个处理器正在修改这个值,所以该处理器就不得不等待第二个处理器更新完成,并且完成更新传递时,才能执行更新。这种情况被称为高竞争(high contention)。如果处理器很少需要互相等待,那么这种情况就是低竞争(low contention)。

在这个循环中,counter的数据将在每个缓存中传递若干次。这就叫做乒乓缓存(cache ping-pong),这种情况会对应用的性能有着重大的影响。当一个处理器因为等待缓存转移而停止运行时。

互斥量的竞争通常不同于原子操作的竞争,最简单的原因是,互斥量通常使用操作系统级别的序列化线程,而非处理器级别的。如果有足够的线程去执行任务,当有线程在等待互斥量时,操作系统会安排其他线程来执行任务,而处理器只会在其他线程运行在目标处理器上时,让该处理器停止工作。不过,对互斥量的竞争,将会影响这些线程的性能;毕竟,只能让一个线程在同一时间运行。

伪共享

处理器缓存通常不会用来处理在单个存储位置,但其会用来处理称为缓存行(cache lines)的内存块。内存块通常大小为32或64字节,实际大小需要由正在使用着的处理器模型来决定。因为硬件缓存进处理缓存行大小的内存块,较小的数据项就在同一内存行的相邻内存位置上。

每当线程访问0号数据项,并对其值进行更新时,缓存行的所有权就需要转移给执行该线程的处理器,这仅是为了让更新1号数据项的线程获取1号线程的所有权。缓存行是共享的(即使没有数据存在),因此使用伪共享来称呼这种方式。这个问题的解决办法就是对数据进行构造,让同一线程访问的数据项存在临近的内存中(就像是放在同一缓存行中),这样那些能被独立线程访问的数据将分布在相距很远的地方,并且可能是存储在不同的缓存行中。

如何让数据紧凑?

伪共享发生的原因:某个线程所要访问的数据过于接近另一线程的数据,另一个是与数据布局相关的陷阱会直接影响单线程的性能。问题在于数据过于接近:当数据能被单线程访问时,那么数据就已经在内存中展开,就像是分布在不同的缓存行上。

现在,对于单线程代码来说就很关键了,何至于此呢?原因就是任务切换(task switching)。如果系统中的线程数量要比核芯多,每个核上都要运行多个线程。这就会增加缓存的压力,为了避免伪共享,努力让不同线程访问不同缓存行。因此,当处理器切换线程的时候,就要对不同内存行上的数据进行重新加载,而非对缓存中的数据保持原样(当线程中的数据都在同一缓存行时)。

如果线程数量多于内核或处理器数量,操作系统可能也会选择将一个线程安排给这个核芯一段时间,之后再安排给另一个核芯一段时间。因此就需要将缓存行从一个内核上,转移到另一个内核上;这样的话,就需要转移很多缓存行,也就意味着要耗费很多时间。虽然,操作系统通常避免这样的情况发生,不过当其发生的时候,对性能就会有很大的影响。

超额认购和频繁的任务切换

多线程系统中,通常线程的数量要多于处理的数量。不过,线程经常会花费时间来等待外部I/O完成,或被互斥量阻塞,或等待条件变量,等等;所以等待不是问题。应用使用额外的线程来完成有用的工作,而非让线程在处理器处以闲置状态时继续等待。

这也并非长久之计,如果有很多额外线程,就会有很多线程准备执行,而且数量远远大于可用处理器的数量,不过操作系统就会忙于在任务间切换,以确保每个任务都有时间运行。

如果只是简单的通过数据划分生成多个线程,那可以限定工作线程的数量。如果超额认购是对工作的天然划分而产生,那么不同的划分方式对这种问题就没有太多益处了。

其他因素也会影响多线程代码的性能。即使CPU类型和时钟周期相同,乒乓缓存的开销可以让程序在两个单核处理器和在一个双核处理器上,产生巨大的性能差,不过这只是那些对性能影响可见的因素。接下来,让我们看一下这些因素如何影响代码与数据结构的设计。

为多线程性能设计数据结构

为多线程性能而设计数据结构的时候,需要考虑竞争(contention),伪共享(false sharing)和数据距离(data proximity)。这三个因素对于性能都有着重大的影响,并且你通常可以改善的是数据布局,或者将赋予其他线程的数据元素进行修改。首先,让我们来看一个轻松方案:线程间划分数组元素。

为复杂操作划分数组元素

线程间划分工作是有很多种方式的。假设矩阵的行或列数量大于处理器的数量,可以让每个线程计算出结果矩阵列上的元素,或是行上的元素,亦或计算一个子矩阵。

对于一个数组来说,访问连续的元素是最好的方式,因为这将会减少缓存的使用,并且降低伪共享的概率。如果要让每个线程处理几行,线程需要读取第一个矩阵中的每一个元素,并且读取第二个矩阵上的相关行上的数据。给定的两个矩阵是以行连续的方式存储,这就意味着当你访问第一个矩阵的第一行的前N个元素,然后是第二行的前N个元素,以此类推(N是列的数量)。其他线程会访问每行的的其他元素;很明显的,应该访问相邻的列,所以从行上读取的N个元素也是连续的,这将最大程度的降低伪共享的几率。

另一方面,当每个线程处理一组行,就需要读取第二个矩阵上的每一个数据,还要读取第一个矩阵中的相关行上的值,不过这里只需要对行上的值进行写入。因为矩阵是以行连续的方式存储,那么现在可以以N行的方式访问所有的元素。如果再次选择相邻行,这就意味着线程现在只能写入N行,这里就有不能被其他线程所访问的连续内存块。

第三个选择——将矩阵分成小矩阵块?这可以看作先对列进行划分,再对行进行划分。因此,划分列的时候,同样有伪共享的问题存在。如果你可以选择内存块所拥有行的数量,就可以有效的避免伪共享;将大矩阵划分为小块,对于读取来说是有好处的:就不再需要读取整个源矩阵了。这里,只需要读取目标矩形里面相关行列的值就可以了。具体的来看,考虑1,000行和1,000列的两个矩阵相乘。就会有1百万个元素。如果有100个处理器,这样就可以每次处理10行的数据,也就是10,000个元素。不过,为了计算10,000个元素,就需要对第二个矩阵中的全部内容进行访问(1百万个元素),再加上10,000个相关行(第一个矩阵)上的元素,大概就要访问1,010,000个元素。另外,硬件能处理100x100的数据块(总共10,000个元素),这就需要对第一个矩阵中的100行进行访问(100x1,000=100,000个元素),还有第二个矩阵中的100列(另外100,000个)。这才只有200,000个元素,就需要五轮读取才能完成。如果这里读取的元素少一些,缓存缺失的情况就会少一些,对于性能来说就好一些。

因此,将矩阵分成小块或正方形的块,要比使用单线程来处理少量的列好的多。当然,可以根据源矩阵的大小和处理器的数量,在运行时对块的大小进行调整。和之前一样,当性能是很重要的指标,就需要对目标架构上的各项指标进行测量。

其他数据结构中的数据访问模式

根本上讲,同样的考虑适用于想要优化数据结构的数据访问模式,就像优化对数组的访问:

  • 尝试调整数据在线程间的分布,就能让同一线程中的数据紧密联系在一起。
  • 尝试减少线程上所需的数据量。
  • 尝试让不同线程访问不同的存储位置,以避免伪共享。

假设你有一个简单的类,包含一些数据项和一个用于保护数据的互斥量(在多线程环境下)。如果互斥量和数据项在内存中很接近,对一个需要获取互斥量的线程来说是很理想的情况;需要的数据可能早已存入处理器的缓存中了,因为在之前为了对互斥量进行修改,已经加载了需要的数据。不过,这还有一个缺点:当其他线程尝试锁住互斥量时(第一个线程还没有是释放),线程就能对对应的数据项进行访问。互斥锁是当做一个“读-改-写”原子操作实现的,对于相同位置的操作都需要先获取互斥量,如果互斥量已锁,那就会调用系统内核。这种“读-改-写”操作,可能会让数据存储在缓存中,让线程获取的互斥量变得毫无作用。从目前互斥量的发展来看,这并不是个问题;线程不会直到互斥量解锁,才接触互斥量。不过,当互斥量共享同一缓存行时,其中存储的是线程已使用的数据,这时拥有互斥量的线程将会遭受到性能打击,因为其他线程也在尝试锁住互斥量。

一种测试伪共享问题的方法是:对大量的数据块填充数据,让不同线程并发的进行访问。比如,你可以使用:

1
2
3
4
5
6
struct protected_data
{
std::mutex m;
char padding[65536]; // 65536字节已经超过一个缓存行的数量级
my_data data_to_protect;
};

用来测试互斥量竞争或

1
2
3
4
5
6
7
struct my_data
{
data_item1 d1;
data_item2 d2;
char padding[65536];
};
my_data some_array[256];

用来测试数组数据中的伪共享。如果这样能够提高性能,你就能知道伪共享在这里的确存在。

设计并发代码的注意事项

虽然,非扩展性代码依旧可以正常工作——单线程应用就无法扩展——例如,异常安全是一个正确性问题。如果你的代码不是异常安全的,最终会破坏不变量,或是造成条件竞争,亦或是你的应用意外终止,因为某个操作会抛出异常。有了这个想法,我们就率先来看一下异常安全的问题。

并行算法中的异常安全

异常安全是衡量C++代码一个很重要的指标,并发代码也不例外。实际上,相较于串行算法,并行算法常会格外要求注意异常问题。当一个操作在串行算法中抛出一个异常,算法只需要考虑对其本身进行处理,以避免资源泄露和损坏不变量;这里可以允许异常传递给调用者,由调用者对异常进行处理。通过对比,在并行算法中很多操作要运行在独立的线程上。在这种情况下,异常就不再允许被传播,因为这将会使调用堆栈出现问题。如果一个函数在创建一个新线程后带着异常退出,那么这个应用将会终止。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
template<typename Iterator,typename T>
struct accumulate_block
{
void operator()(Iterator first,Iterator last,T& result)
{
result=std::accumulate(first,last,result); // 1
}
};
template<typename Iterator,typename T>
T parallel_accumulate(Iterator first,Iterator last,T init)
{
unsigned long const length=std::distance(first,last); // 2
if(!length)
return init;
unsigned long const min_per_thread=25;
unsigned long const max_threads=
(length+min_per_thread-1)/min_per_thread;
unsigned long const hardware_threads=
std::thread::hardware_concurrency();
unsigned long const num_threads=
std::min(hardware_threads!=0?hardware_threads:2,max_threads);
unsigned long const block_size=length/num_threads;
std::vector<T> results(num_threads); // 3
std::vector<std::thread> threads(num_threads-1); // 4
Iterator block_start=first; // 5
for(unsigned long i=0;i<(num_threads-1);++i)
{
Iterator block_end=block_start; // 6
std::advance(block_end,block_size);
threads[i]=std::thread( // 7
accumulate_block<Iterator,T>(),
block_start,block_end,std::ref(results[i]));
block_start=block_end; // 8
}
accumulate_block()(block_start,last,results[num_threads-1]); // 9
std::for_each(threads.begin(),threads.end(),
std::mem_fn(&std::thread::join));
return std::accumulate(results.begin(),results.end(),init); // 10
}

现在让我们来看一下异常要在哪抛出:基本上就是在调用函数的地方抛出异常,或在用户定义类型上执行某个操作时可能抛出异常。

首先,需要调用distance②,其会对用户定义的迭代器类型进行操作。因为,这时还没有做任何事情,所以对于调用线程来说,所有事情都没问题。接下来,就需要分配results③和threads④。再后,调用线程依旧没有做任何事情,或产生新的线程,所以到这里也是没有问题的。当然,如果在构造threads抛出异常,那么对已经分配的results将会被清理,析构函数会帮你打理好一切。

跳过block_start⑤的初始化(因为也是安全的),来到了产生新线程的循环⑥⑦⑧。当在⑦处创建了第一个线程,如果再抛出异常,就会出问题的;对于新的std::thread对象将会销毁,程序将调用std::terminate来中断程序的运行。使用std::terminate的地方,可不是什么好地方。

accumulate_block⑨的调用就可能抛出异常,就会产生和上面类似的结果;线程对象将会被销毁,并且调用std::terminate。另一方面,最终调用std::accumulate⑩可能会抛出异常,不过处理起来没什么难度,因为所有的线程在这里已经汇聚回主线程了。

上面只是对于主线程来说的,不过还有很多地方会抛出异常:对于调用accumulate_block的新线程来说就会抛出异常①。没有任何catch块,所以这个异常不会被处理,并且当异常发生的时候会调用std::terminater()来终止应用的运行。

也许这里的异常问题并不明显,不过这段代码是非异常安全的。

如果你仔细的了解过新线程用来完成什么样的工作,要返回一个计算的结果的同时,允许代码产生异常。这可以将std::packaged_taskstd::future相结合,来解决这个问题。如果使用std::packaged_task重新构造代码,代码可能会是如下模样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
template<typename Iterator,typename T>
struct accumulate_block
{
T operator()(Iterator first,Iterator last) // 1
{
return std::accumulate(first,last,T()); // 2
}
};
template<typename Iterator,typename T>
T parallel_accumulate(Iterator first,Iterator last,T init)
{
unsigned long const length=std::distance(first,last);
if(!length)
return init;
unsigned long const min_per_thread=25;
unsigned long const max_threads=
(length+min_per_thread-1)/min_per_thread;
unsigned long const hardware_threads=
std::thread::hardware_concurrency();
unsigned long const num_threads=
std::min(hardware_threads!=0?hardware_threads:2,max_threads);
unsigned long const block_size=length/num_threads;
std::vector<std::future<T> > futures(num_threads-1); // 3
std::vector<std::thread> threads(num_threads-1);
Iterator block_start=first;
for(unsigned long i=0;i<(num_threads-1);++i)
{
Iterator block_end=block_start;
std::advance(block_end,block_size);
std::packaged_task<T(Iterator,Iterator)> task( // 4
accumulate_block<Iterator,T>());
futures[i]=task.get_future(); // 5
threads[i]=std::thread(std::move(task),block_start,block_end); // 6
block_start=block_end;
}
T last_result=accumulate_block()(block_start,last); // 7
std::for_each(threads.begin(),threads.end(),
std::mem_fn(&std::thread::join));
T result=init; // 8
for(unsigned long i=0;i<(num_threads-1);++i)
{
result+=futures[i].get(); // 9
}
result += last_result; // 10
return result;
}

第一个修改就是调用accumulate_block的操作现在就是直接将结果返回,而非使用引用将结果存储在某个地方①。使用std::packaged_taskstd::future是线程安全的,所以你可以使用它们来对结果进行转移。当调用std::accumulate②时,需要你显示传入T的默认构造函数,而非复用result的值,不过这只是一个小改动。

下一个改动就是,不用向量来存储结果,而使用futures向量为每个新生线程存储std::future<T>③。在新线程生成循环中,首先要为accumulate_block创建一个任务④。std::packaged_task<T(Iterator,Iterator)>声明,需要操作的两个Iterators和一个想要获取的T。然后,从任务中获取future⑤,再将需要处理的数据块的开始和结束信息传入⑥,让新线程去执行这个任务。当任务执行时,future将会获取对应的结果,以及任何抛出的异常。

使用future,就不能获得到一组结果数组,所以需要将最终数据块的结果赋给一个变量进行保存⑦,而非对一个数组进行填槽。同样,因为需要从future中获取结果,使用简单的for循环,就要比使用std::accumulate好的多;循环从提供的初始值开始⑧,并且将每个future上的值进行累加⑨。如果相关任务抛出一个异常,那么异常就会被future捕捉到,并且使用get()的时候获取数据时,这个异常会再次抛出。最后,在返回结果给调用者之前,将最后一个数据块上的结果添加入结果中⑩。

这样,一个问题就已经解决:在工作线程上抛出的异常,可以在主线程上抛出。如果不止一个工作线程抛出异常,那么只有一个能在主线程中抛出,不过这不会有产生太大的问题。如果这个问题很重要,你可以使用类似std::nested_exception来对所有抛出的异常进行捕捉。

剩下的问题就是,当生成第一个新线程和当所有线程都汇入主线程时,抛出异常;这样会让线程产生泄露。最简单的方法就是捕获所有抛出的线程,汇入的线程依旧是joinable()的,并且会再次抛出异常:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
try
{
for(unsigned long i=0;i<(num_threads-1);++i)
{
// ... as before
}
T last_result=accumulate_block()(block_start,last);
std::for_each(threads.begin(),threads.end(),
std::mem_fn(&std::thread::join));
}
catch(...)
{
for(unsigned long i=0;i<(num_thread-1);++i)
{
if(threads[i].joinable())
thread[i].join();
}
throw;
}

现在好了,无论线程如何离开这段代码,所有线程都可以被汇入。不过,try-catch很不美观,并且这里有重复代码。可以将“正常”控制流上的线程在catch块上执行的线程进行汇入。重复代码是没有必要的,因为这就意味着更多的地方需要改变。不过,现在让我们来提取一个对象的析构函数;毕竟,析构函数是C++中处理资源的惯用方式。看一下你的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class join_threads
{
std::vector<std::thread>& threads;
public:
explicit join_threads(std::vector<std::thread>& threads_):
threads(threads_)
{}
~join_threads()
{
for(unsigned long i=0;i<threads.size();++i)
{
if(threads[i].joinable())
threads[i].join();
}
}
};

当创建了线程容器,就对新类型创建了一个实例①,可让退出线程进行汇入。然后,可以再显式的汇入循环中将线程删除,在原理上来说是安全的:因为线程,无论怎么样退出,都需要汇入主线程。注意这里对futures[i].get()②的调用,将会阻塞线程,直到结果准备就绪,所以这里不需要显式的将线程进行汇入。和清单8.2中的原始代码不同:原始代码中,你需要将线程汇入,以确保results向量被正确填充。不仅需要异常安全的代码,还需要较短的函数实现,因为这里已经将汇入部分的代码放到新(可复用)类型中去了。

现在,你已经了解了,当需要显式管理线程的时候,需要代码是异常安全的。那现在让我们来看一下使用std::async()是怎么样完成异常安全的。在本例中,标准库对线程进行了较好的管理,并且当“期望”处以就绪状态的时候,就能生成一个新的线程。对于异常安全,还需要注意一件事,如果在没有等待的情况下对“期望”实例进行销毁,析构函数会等待对应线程执行完毕后才执行。这就能桥面的必过线程泄露的问题,因为线程还在执行,且持有数据的引用。下面的代码将展示使用std::async()完成异常安全的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template<typename Iterator,typename T>
T parallel_accumulate(Iterator first,Iterator last,T init)
{
unsigned long const length=std::distance(first,last); // 1
unsigned long const max_chunk_size=25;
if(length<=max_chunk_size)
{
return std::accumulate(first,last,init); // 2
}
else
{
Iterator mid_point=first;
std::advance(mid_point,length/2); // 3
std::future<T> first_half_result=
std::async(parallel_accumulate<Iterator,T>, // 4
first,mid_point,init);
T second_half_result=parallel_accumulate(mid_point,last,T()); // 5
return first_half_result.get()+second_half_result; // 6
}
}

这个版本对数据进行递归划分,而非在预计算后对数据进行分块;因此,这个版本要比之前的版本简单很多,并且这个版本也是异常安全的。和之前一样,一开始要确定序列的长度①,如果其长度小于数据块包含数据的最大数量,那么可以直接调用std::accumulate②。如果元素的数量超出了数据块包含数据的最大数量,那么就需要找到数量中点③,将这个数据块分成两部分,然后再生成一个异步任务对另一半数据进行处理④。第二半的数据是通过直接的递归调用来处理的⑤,之后将两个块的结果加和到一起⑥。标准库能保证std::async的调用能够充分的利用硬件线程,并且不会产生线程的超额认购,一些“异步”调用是在调用get()⑥后同步执行的。

优雅的地方,不仅在于利用硬件并发的优势,并且还能保证异常安全。如果有异常在递归调用⑤中抛出,通过调用std::async④所产生的“期望”,将会在异常传播时被销毁。这就需要依次等待异步任务的完成,因此也能避免悬空线程的出现。另外,当异步任务抛出异常,且被future所捕获,在对get()⑥调用的时候,future中存储的异常,会再次抛出。

在实践中设计并发代码

并行实现:std::for_each

std::for_each的原理很简单:其对某个范围中的元素,依次调用用户提供的函数。并行和串行调用的最大区别就是函数的调用顺序。std::for_each是对范围中的第一个元素调用用户函数,接着是第二个,以此类推,而在并行实现中对于每个元素的处理顺序就不能保证了,并且它们可能(我们希望如此)被并发的处理。

为了实现这个函数的并行版本,需要对每个线程上处理的元素进行划分。你事先知道元素数量,所以可以处理前对数据进行划分。假设只有并行任务运行,就可以使用std::thread::hardware_concurrency()来决定线程的数量。同样,这些元素都能被独立的处理,所以可以使用连续的数据块来避免伪共享。

这里的算法有点类似于并行版的std::accumulate,不过比起计算每一个元素的加和,这里对每个元素仅仅使用了一个指定功能的函数。因为不需要返回结果,可以假设这可能会对简化代码,不过想要将异常传递给调用者,就需要使用std::packaged_taskstd::future机制对线程中的异常进行转移。这里展示一个样本实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
template<typename Iterator,typename Func>
void parallel_for_each(Iterator first,Iterator last,Func f)
{
unsigned long const length=std::distance(first,last);
if(!length)
return;
unsigned long const min_per_thread=25;
unsigned long const max_threads=
(length+min_per_thread-1)/min_per_thread;
unsigned long const hardware_threads=
std::thread::hardware_concurrency();
unsigned long const num_threads=
std::min(hardware_threads!=0?hardware_threads:2,max_threads);
unsigned long const block_size=length/num_threads;
std::vector<std::future<void> > futures(num_threads-1); // 1
std::vector<std::thread> threads(num_threads-1);
join_threads joiner(threads);
Iterator block_start=first;
for(unsigned long i=0;i<(num_threads-1);++i)
{
Iterator block_end=block_start;
std::advance(block_end,block_size);
std::packaged_task<void(void)> task( // 2
[=]()
{
std::for_each(block_start,block_end,f);
});
futures[i]=task.get_future();
threads[i]=std::thread(std::move(task)); // 3
block_start=block_end;
}
std::for_each(block_start,last,f);
for(unsigned long i=0;i<(num_threads-1);++i)
{
futures[i].get(); // 4
}
}

最重要的不同在于futures向量对std::future<void>类型①变量进行存储,因为工作线程不会返回值,并且简单的lambda函数会对block_start到block_end上的任务②执行f函数。这是为了避免传入线程的构造函数③。当工作线程不需要返回一个值时,调用futures[i].get()④只是提供检索工作线程异常的方法;如果不想把异常传递出去,就可以省略这一步。

实现并行std::accumulate的时候,使用std::async会简化代码;同样,parallel_for_each也可以使用std::async。实现如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template<typename Iterator,typename Func>
void parallel_for_each(Iterator first,Iterator last,Func f)
{
unsigned long const length=std::distance(first,last);
if(!length)
return;
unsigned long const min_per_thread=25;
if(length<(2*min_per_thread))
{
std::for_each(first,last,f); // 1
}
else
{
Iterator const mid_point=first+length/2;
std::future<void> first_half= // 2
std::async(&parallel_for_each<Iterator,Func>,
first,mid_point,f);
parallel_for_each(mid_point,last,f); // 3
first_half.get(); // 4
}
}

和基于std::asyncparallel_accumulate一样,是在运行时对数据进行迭代划分的,而非在执行前划分好,这是因为你不知道你的库需要使用多少个线程。像之前一样,当你将每一级的数据分成两部分,异步执行另外一部分②,剩下的部分就不能再进行划分了,所以直接运行这一部分③;这样就可以直接对std::for_each①进行使用了。这里再次使用std::asyncstd::futureget()成员函数④来提供对异常的传播。

并行实现:std::find

接下来是std::find算法,因为这是一种不需要对数据元素做任何处理的算法。比如,当第一个元素就满足查找标准,那就没有必要对其他元素进行搜索了。将会看到,算法属性对于性能具有很大的影响,并且对并行实现的设计有着直接的影响。这个算法是一个很特别的例子,数据访问模式都会对代码的设计产生影响。该类中的另一些算法包括std::equalstd::any_of

如果不中断其他线程,那么串行版本的性能可能会超越并行版,因为串行算法可以在找到匹配元素的时候,停止搜索并返回。如果系统能支持四个并发线程,那么每个线程就可以对总数据量的1/4进行检查,并且在我们的实现只需要单核完成的1/4的时间,就能完成对所有元素的查找。如果匹配的元素在第一个1/4块中,串行算法将会返回第一个,因为算法不需要对剩下的元素进行处理了。

一种办法,中断其他线程的一个办法就是使用一个原子变量作为一个标识,在处理过每一个元素后就对这个标识进行检查。如果标识被设置,那么就有线程找到了匹配元素,所以算法就可以停止并返回了。用这种方式来中断线程,就可以将那些没有处理的数据保持原样,并且在更多的情况下,相较于串行方式,性能能提升很多。缺点就是,加载原子变量是一个很慢的操作,会阻碍每个线程的运行。

如何返回值和传播异常呢?现在你有两个选择。你可以使用一个future数组,使用std::packaged_task来转移值和异常,在主线程上对返回值和异常进行处理;或者使用std::promise对工作线程上的最终结果直接进行设置。这完全依赖于你想怎么样处理工作线程上的异常。如果想停止第一个异常(即使还没有对所有元素进行处理),就可以使用std::promise对异常和最终值进行设置。另外,如果想要让其他工作线程继续查找,可以使用std::packaged_task来存储所有的异常,当线程没有找到匹配元素时,异常将再次抛出。

这种情况下,我会选择std::promise,因为其行为和std::find更为接近。这里需要注意一下搜索的元素是不是在提供的搜索范围内。因此,在所有线程结束前,获取future上的结果。如果被future阻塞住,所要查找的值不在范围内,就会持续的等待下去。实现代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
template<typename Iterator,typename MatchType>
Iterator parallel_find(Iterator first,Iterator last,MatchType match)
{
struct find_element // 1
{
void operator()(Iterator begin,Iterator end,
MatchType match,
std::promise<Iterator>* result,
std::atomic<bool>* done_flag)
{
try
{
for(;(begin!=end) && !done_flag->load();++begin) // 2
{
if(*begin==match)
{
result->set_value(begin); // 3
done_flag->store(true); // 4
return;
}
}
}
catch(...) // 5
{
try
{
result->set_exception(std::current_exception()); // 6
done_flag->store(true);
}
catch(...) // 7
{}
}
}
};
unsigned long const length=std::distance(first,last);
if(!length)
return last;
unsigned long const min_per_thread=25;
unsigned long const max_threads=
(length+min_per_thread-1)/min_per_thread;
unsigned long const hardware_threads=
std::thread::hardware_concurrency();
unsigned long const num_threads=
std::min(hardware_threads!=0?hardware_threads:2,max_threads);
unsigned long const block_size=length/num_threads;
std::promise<Iterator> result; // 8
std::atomic<bool> done_flag(false); // 9
std::vector<std::thread> threads(num_threads-1);
{ // 10
join_threads joiner(threads);
Iterator block_start=first;
for(unsigned long i=0;i<(num_threads-1);++i)
{
Iterator block_end=block_start;
std::advance(block_end,block_size);
threads[i]=std::thread(find_element(), // 11
block_start,block_end,match,
&result,&done_flag);
block_start=block_end;
}
find_element()(block_start,last,match,&result,&done_flag); // 12
}
if(!done_flag.load()) //13
{
return last;
}
return result.get_future().get(); // 14
}

函数主体与之前的例子相似。这次,由find_element类①的函数调用操作实现,来完成查找工作的。循环通过在给定数据块中的元素,检查每一步上的标识②。如果匹配的元素被找到,就将最终的结果设置到promise③当中,并且在返回前对done_flag④进行设置。

如果有一个异常被抛出,那么它就会被通用处理代码⑤捕获,并且在promise⑥尝中试存储前,对done_flag进行设置。如果对应promise已经被设置,设置在promise上的值可能会抛出一个异常,所以这里⑦发生的任何异常,都可以捕获并丢弃。

这意味着,当线程调用find_element查询一个值,或者抛出一个异常时,如果其他线程看到done_flag被设置,那么其他线程将会终止。如果多线程同时找到匹配值或抛出异常,它们将会对promise产生竞争。不过,这是良性的条件竞争;因为,成功的竞争者会作为“第一个”返回线程,因此这个结果可以接受。

回到parallel_find函数本身,其拥有用来停止搜索的promise⑧和标识⑨;随着对范围内的元素的查找⑪,promise和标识会传递到新线程中。主线程也使用find_element来对剩下的元素进行查找⑫。像之前提到的,需要在全部线程结束前,对结果进行检查,因为结果可能是任意位置上的匹配元素。这里将“启动-汇入”代码放在一个块中⑩,所以所有线程都会在找到匹配元素时⑬进行汇入。如果找到匹配元素,就可以调用std::future<Iterator>的成员函数get()来获取返回值或异常。

不过,这里假设你会使用硬件上所有可用的的并发线程,或使用其他机制对线程上的任务进行提前划分。就像之前一样,可以使用std::async,以及递归数据划分的方式来简化实现(同时使用C++标准库中提供的自动缩放工具)。使用std::asyncparallel_find实现如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
template<typename Iterator,typename MatchType>  // 1
Iterator parallel_find_impl(Iterator first,Iterator last,MatchType match,
std::atomic<bool>& done)
{
try
{
unsigned long const length=std::distance(first,last);
unsigned long const min_per_thread=25; // 2
if(length<(2*min_per_thread)) // 3
{
for(;(first!=last) && !done.load();++first) // 4
{
if(*first==match)
{
done=true; // 5
return first;
}
}
return last; // 6
}
else
{
Iterator const mid_point=first+(length/2); // 7
std::future<Iterator> async_result=
std::async(&parallel_find_impl<Iterator,MatchType>, // 8
mid_point,last,match,std::ref(done));
Iterator const direct_result=
parallel_find_impl(first,mid_point,match,done); // 9
return (direct_result==mid_point)?
async_result.get():direct_result; // 10
}
}
catch(...)
{
done=true; // 11
throw;
}
}
template<typename Iterator,typename MatchType>
Iterator parallel_find(Iterator first,Iterator last,MatchType match)
{
std::atomic<bool> done(false);
return parallel_find_impl(first,last,match,done); // 12
}

如果想要在找到匹配项时结束,就需要在线程之间设置一个标识来表明匹配项已经被找到。因此,需要将这个标识递归的传递。通过函数①的方式来实现是最简单的办法,只需要增加一个参数——一个done标识的引用,这个表示通过程序的主入口点传入⑫。

核心实现和之前的代码一样。通常函数的实现中,会让单个线程处理最少的数据项②;如果数据块大小不足于分成两半,就要让当前线程完成所有的工作了③。实际算法在一个简单的循环当中(给定范围),直到在循环到指定范围中的最后一个,或找到匹配项,并对标识进行设置④。如果找到匹配项,标识done就会在返回前进行设置⑤。无论是因为已经查找到最后一个,还是因为其他线程对done进行了设置,都会停止查找。如果没有找到,会将最后一个元素last进行返回⑥。

如果给定范围可以进行划分,首先要在std::async在对第二部分进行查找⑧前,要找数据中点⑦,而且需要使用std::ref将done以引用的方式传递。同时,可以通过对第一部分直接进行递归查找。两部分都是异步的,并且在原始范围过大时,直接递归查找的部分可能会再细化。

如果直接查找返回的是mid_point,这就意味着没有找到匹配项,所以就要从异步查找中获取结果。如果在另一半中没有匹配项的话,返回的结果就一定是last,这个值的返回就代表了没有找到匹配的元素⑩。如果“异步”调用被延迟(非真正的异步),那么实际上这里会运行get();这种情况下,如果对下半部分的元素搜索成功,那么就不会执行对上半部分元素的搜索了。如果异步查找真实的运行在其他线程上,那么async_result变量的析构函数将会等待该线程完成,所以这里不会有线程泄露。

像之前一样,std::async可以用来提供“异常-安全”和“异常-传播”特性。如果直接递归抛出异常,future的析构函数就能让异步执行的线程提前结束;如果异步调用抛出异常,那么这个异常将会通过对get()成员函数的调用进行传播⑩。使用try/catch块只能捕捉在done发生的异常,并且当有异常抛出⑪时,所有线程都能很快的终止运行。不过,不使用try/catch的实现依旧没问题,不同的就是要等待所有线程的工作是否完成。

实现中一个重要的特性就是,不能保证所有数据都能被std::find串行处理。其他并行算法可以借鉴这个特性,因为要让一个算法并行起来这是必须具有的特性。如果有顺序问题,元素就不能并发的处理了。如果每个元素独立,虽然对于parallel_for_each不是很重要,不过对于parallel_find,即使在开始部分已经找到了匹配元素,也有可能返回范围中最后一个元素;如果在知道结果的前提下,这样的结果会让人很惊讶。

并行实现:std::partial_sum

std::partial_sum会计算给定范围中的每个元素,并用计算后的结果将原始序列中的值替换掉。比如,有一个序列[1,2,3,4,5],在执行该算法后会成为:[1,3(1+2),6(1+2+3),10(1+2+3+4),15(1+2+3+4+5)]。让这样一个算法并行起来会很有趣,因为这里不能讲任务分块,对每一块进行独立的计算。比如,原始序列中的第一个元素需要加到后面的一个元素中去。

将原始数据分割成块,加上之前块的部分和就能够并行了。如果每个块中的末尾元素都是第一个被更新的,那么块中其他的元素就能被其他线程所更新,同时另一个线程对下一块进行更新,等等。当处理的元素比处理核心的个数多的时候,这样完成工作没问题,因为每一个核芯在每一个阶段都有合适的数据可以进行处理。

比起将数据块中的最后一个元素的结果向后面的元素块传递,可以对部分结果进行传播:第一次与相邻的元素(距离为1)相加和(和之前一样),之后和距离为2的元素相加,在后来和距离为4的元素相加,以此类推。比如,初始序列为[1,2,3,4,5,6,7,8,9],第一次后为[1,3,5,7,9,11,13,15,17],第二次后为[1,3,6,10,14,18, 22,26,30],下一次就要隔4个元素了。第三次后[1, 3, 6, 10, 15, 21, 28, 36, 44],下一次就要隔8个元素了。第四次后[1, 3, 6, 10, 15, 21, 28, 36, 45],这就是最终的结果。虽然,比起第一种方法多了很多步骤,不过在可并发平台下,这种方法提高了并行的可行性;每个处理器可在每一步中处理一个数据项。

总体来说,当有N个操作时(每步使用一个处理器)第二种方法需要log(N)步;在本节中,N就相当于数据链表的长度。比起第一种,每个线程对分配块做N/k个操作,然后在做N/k次结果传递(这里的k是线程的数量)。因此,第一种方法的时间复杂度为O(N),不过第二种方法的时间复杂度为Q(Nlog(N))。当数据量和处理器数量相近时,第二种方法需要每个处理器上log(N)个操作,第一种方法中每个处理器上执行的操作数会随着k的增加而增多,因为需要对结果进行传递。对于处理单元较少的情况,第一种方法会比较合适;对于大规模并行系统,第二种方法比较合适。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
template<typename Iterator>
void parallel_partial_sum(Iterator first,Iterator last)
{
typedef typename Iterator::value_type value_type;
struct process_chunk // 1
{
void operator()(Iterator begin,Iterator last,
std::future<value_type>* previous_end_value,
std::promise<value_type>* end_value)
{
try
{
Iterator end=last;
++end;
std::partial_sum(begin,end,begin); // 2
if(previous_end_value) // 3
{
value_type& addend=previous_end_value->get(); // 4
*last+=addend; // 5
if(end_value)
{
end_value->set_value(*last); // 6
}
std::for_each(begin,last,[addend](value_type& item) // 7
{
item+=addend;
});
}
else if(end_value)
{
end_value->set_value(*last); // 8
}
}
catch(...) // 9
{
if(end_value)
{
end_value->set_exception(std::current_exception()); // 10
}
else
{
throw; // 11
}
}
}
};

unsigned long const length=std::distance(first,last);
if(!length)
return last;
unsigned long const min_per_thread=25; // 12
unsigned long const max_threads=
(length+min_per_thread-1)/min_per_thread;
unsigned long const hardware_threads=
std::thread::hardware_concurrency();
unsigned long const num_threads=
std::min(hardware_threads!=0?hardware_threads:2,max_threads);
unsigned long const block_size=length/num_threads;
typedef typename Iterator::value_type value_type;
std::vector<std::thread> threads(num_threads-1); // 13
std::vector<std::promise<value_type> >
end_values(num_threads-1); // 14
std::vector<std::future<value_type> >
previous_end_values; // 15
previous_end_values.reserve(num_threads-1); // 16
join_threads joiner(threads);
Iterator block_start=first;
for(unsigned long i=0;i<(num_threads-1);++i)
{
Iterator block_last=block_start;
std::advance(block_last,block_size-1); // 17
threads[i]=std::thread(process_chunk(), // 18
block_start,block_last,
(i!=0)?&previous_end_values[i-1]:0,
&end_values[i]);
block_start=block_last;
++block_start; // 19
previous_end_values.push_back(end_values[i].get_future()); // 20
}
Iterator final_element=block_start;
std::advance(final_element,std::distance(block_start,last)-1); // 21
process_chunk()(block_start,final_element, // 22
(num_threads>1)?&previous_end_values.back():0,
0);
}

这个实现中,使用的结构体和之前算法中的一样,将问题进行分块解决,每个线程处理最小的数据块⑫。其中,有一组线程⑬和一组promise⑭,用来存储每块中的最后一个值;并且实现中还有一组future⑮,用来对前一块中的最后一个值进行检索。可以为future⑯做些储备,以避免生成新线程时,再分配内存。

主循环和之前一样,不过这次是让迭代器指向了每个数据块的最后一个元素,而不是作为一个普通值传递到最后⑰,这样就方便向其他块传递当前块的最后一个元素了。实际处理是在process_chunk函数对象中完成的,这个结构体看上去不是很长;当前块的开始和结束迭代器和前块中最后一个值的future一起,作为参数进行传递,并且promise用来保留当前范围内最后一个值的原始值⑱。

生成新的线程后,就对开始块的ID进行更新,别忘了传递最后一个元素⑲,并且将当前块的最后一个元素存储到future,上面的数据将在循环中再次使用到⑳。

在处理最后一个数据块前,需要获取之前数据块中最后一个元素的迭代器(21),这样就可以将其作为参数传入process_chunk(22)中了。std::partial_sum不会返回一个值,所以在最后一个数据块被处理后,就不用再做任何事情了。当所有线程的操作完成时,求部分和的操作也就算完成了。

OK,现在来看一下process_chunk函数对象①。对于整块的处理是始于对std::partial_sum的调用,包括对于最后一个值的处理②,不过得要知道当前块是否是第一块③。如果当前块不是第一块,就会有一个previous_end_value值从前面的块传过来,所以这里需要等待这个值的产生④。为了将算法最大程度的并行,首先需要对最后一个元素进行更新⑤,这样你就能将这个值传递给下一个数据块(如果有下一个数据块的话)⑥。当完成这个操作,就可以使用std::for_each和简单的lambda函数⑦对剩余的数据项进行更新。

如果previous_end_value值为空,当前数据块就是第一个数据块,所以只需要为下一个数据块更新end_value⑧(如果有下一个数据块的话——当前数据块可能是唯一的数据块)。

最后,如果有任意一个操作抛出异常,就可以将其捕获⑨,并且存入promise⑩,如果下一个数据块尝试获取前一个数据块的最后一个值④时,异常会再次抛出。处理最后一个数据块时,异常会全部重新抛出⑪,因为抛出动作一定会在主线程上进行。

因为线程间需要同步,这里的代码就不容易使用std::async重写。任务等待会让线程中途去执行其他的任务,所以所有的任务必须同时执行。

基于块,以传递末尾元素值的方法就介绍到这里,让我们来看一下第二种计算方式。

实现以2的幂级数为距离部分和算法

第二种算法通过增加距离的方式,让更多的处理器充分发挥作用。在这种情况下,没有进一步同步的必要了,因为所有中间结果都直接传递到下一个处理器上去了。不过,在实际中我们很少见到,单个处理器处理对一定数量的元素执行同一条指令,这种方式成为单指令-多数据流(SIMD)。因此,代码必须能处理通用情况,并且需要在每步上对线程进行显式同步。

完成这种功能的一种方式是使用栅栏(barrier)——一种同步机制:只有所有线程都到达栅栏处,才能进行之后的操作;先到达的线程必须等待未到达的线程。C++11标准库没有直接提供这样的工具,所以你得自行设计一个。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class barrier
{
unsigned const count;
std::atomic<unsigned> spaces;
std::atomic<unsigned> generation;
public:
explicit barrier(unsigned count_): // 1
count(count_),spaces(count),generation(0)
{}
void wait()
{
unsigned const my_generation=generation; // 2
if(!--spaces) // 3
{
spaces=count; // 4
++generation; // 5
}
else
{
while(generation==my_generation) // 6
std::this_thread::yield(); // 7
}
}
};

这个实现中,用一定数量的“座位”构造了一个barrier①,这个数量将会存储count变量中。起初,栅栏中的spaces与count数量相当。当有线程都在等待时,spaces的数量就会减少③。当spaces的数量减到0时,spaces的值将会重置为count④,并且generation变量会增加,以向线程发出信号,让这些等待线程能够继续运行⑤。如果spaces没有到达0,那么线程会继续等待。这个实现使用了一个简单的自旋锁⑥,对generation的检查会在wait()开始的时候进行②。因为generation只会在所有线程都到达栅栏的时候更新⑤,在等待的时候使用yield()⑦就不会让CPU处于忙等待的状态。

这个实现比较“简单”的真实意义:使用自旋等待的情况下,如果让线程等待很长时间就不会很理想,并且如果超过count数量的线程对wait()进行调用,这个实现就没有办法工作了。如果想要很好的处理这样的情况,必须使用一个更加健壮(更加复杂)的实现。我依旧坚持对原子变量操作顺序的一致性,因为这会让事情更加简单,不过有时还是需要放松这样的约束。全局同步对于大规模并行架构来说是消耗巨大的,因为相关处理器会穿梭于存储栅栏状态的缓存行中,所以需要格外的小心,来确保使用的是最佳同步方法。

不论怎么样,这些都需要你考虑到;需要有固定数量的线程执行同步循环。好吧,大多数情况下线程数量都是固定的。你可能还记得,代码起始部分的几个数据项,只需要几步就能得到其最终值。这就意味着,无论是让所有线程循环处理范围内的所有元素,还是让栅栏来同步线程,都会递减count的值。我会选择后者,因为其能避免线程做不必要的工作,仅仅是等待最终步骤完成。

这意味着你要将count改为一个原子变量,这样在多线程对其进行更新的时候,就不需要添加额外的同步:

1
std::atomic<unsigned> count;

初始化保持不变,不过当spaces的值被重置后,你需要显式的对count进行load()操作:

1
spaces=count.load();

这就是要对wait()函数的改动;现在需要一个新的成员函数来递减count。这个函数命名为done_waiting(),因为当一个线程完成其工作,并在等待的时候,才能对其进行调用它:

1
2
3
4
5
6
7
8
9
void done_waiting()
{
--count; // 1
if(!--spaces) // 2
{
spaces=count.load(); // 3
++generation;
}
}

实现中,首先要减少count①,所以下一次spaces将会被重置为一个较小的数。然后,需要递减spaces的值②。如果不做这些操作,有些线程将会持续等待,因为spaces被旧的count初始化,大于期望值。一组当中最后一个线程需要对计数器进行重置,并且递增generation的值③,就像在wait()里面做的那样。最重要的区别:最后一个线程不需要等待。当最后一个线程结束,整个等待也就随之结束!

现在就准备开始写部分和的第二个实现吧。在每一步中,每一个线程都在栅栏出调用wait(),来保证线程所处步骤一致,并且当所有线程都结束,那么最后一个线程会调用done_waiting()来减少count的值。如果使用两个缓存对原始数据进行保存,栅栏也可以提供你所需要的同步。每一步中,线程都会从原始数据或是缓存中读取数据,并且将新值写入对应位置。如果有线程先从原始数据处获取数据,那下一步就从缓存上获取数据(或相反)。这就能保证在读与写都是由独立线程完成,并不存在条件竞争。当线程结束等待循环,就能保证正确的值最终被写入到原始数据当中。下面的代码就是这样的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
struct barrier
{
std::atomic<unsigned> count;
std::atomic<unsigned> spaces;
std::atomic<unsigned> generation;
barrier(unsigned count_):
count(count_),spaces(count_),generation(0)
{}
void wait()
{
unsigned const gen=generation.load();
if(!--spaces)
{
spaces=count.load();
++generation;
}
else
{
while(generation.load()==gen)
{
std::this_thread::yield();
}
}
}
void done_waiting()
{
--count;
if(!--spaces)
{
spaces=count.load();
++generation;
}
}
};
template<typename Iterator>
void parallel_partial_sum(Iterator first,Iterator last)
{
typedef typename Iterator::value_type value_type;
struct process_element // 1
{
void operator()(Iterator first,Iterator last,
std::vector<value_type>& buffer,
unsigned i,barrier& b)
{
value_type& ith_element=*(first+i);
bool update_source=false;
for(unsigned step=0,stride=1;stride<=i;++step,stride*=2)
{
value_type const& source=(step%2)? // 2
buffer[i]:ith_element;
value_type& dest=(step%2)?
ith_element:buffer[i];
value_type const& addend=(step%2)? // 3
buffer[i-stride]:*(first+i-stride);
dest=source+addend; // 4
update_source=!(step%2);
b.wait(); // 5
}
if(update_source) // 6
{
ith_element=buffer[i];
}
b.done_waiting(); // 7
}
};
unsigned long const length=std::distance(first,last);
if(length<=1)
return;
std::vector<value_type> buffer(length);
barrier b(length);
std::vector<std::thread> threads(length-1); // 8
join_threads joiner(threads);
Iterator block_start=first;
for(unsigned long i=0;i<(length-1);++i)
{
threads[i]=std::thread(process_element(),first,last, // 9
std::ref(buffer),i,std::ref(b));
}
process_element()(first,last,buffer,length-1,b); // 10
}

代码的整体结构应该不用说了。process_element类有函数调用操作可以用来做具体的工作①,就是运行一组线程⑨,并将线程存储到vector中⑧,同样还需要在主线程中对其进行调用⑩。这里与之前最大的区别就是,线程的数量是根据列表中的数据量来定的,而非根据std::thread::hardware_concurrency。如我之前所说,除非你使用的是一个大规模并行的机器,因为这上面的线程都十分廉价(虽然这样的方式并不是很好),还能为我们展示了其整体结构。这个结构在有较少线程的时候,每一个线程只能处理源数据中的部分数据,当没有足够的线程支持该结构时,效率要比传递算法低。

不管怎样,主要的工作都是调用process_element的函数操作符来完成的。每一步,都会从原始数据或缓存中获取第i个元素②,并且将获取到的元素加到指定stride的元素中去③,如果从原始数据开始读取的元素,加和后的数需要存储在缓存中④。然后,在开始下一步前,会在栅栏处等待⑤。当stride超出了给定数据的范围,当最终结果已经存在缓存中时,就需要更新原始数据中的数据,同样这也意味着本次加和结束。最后,在调用栅栏中的done_waiting()函数⑦。

注意这个解决方案并不是异常安全的。如果某个线程在process_element执行时抛出一个异常,其就会终止整个应用。这里可以使用一个std::promise来存储异常,就像在清单8.9中parallel_find的实现,或仅使用一个被互斥量保护的std::exception_ptr即可。

高级线程管理

线程池

最简单的线程池

作为最简单的线程池,其拥有固定数量的工作线程(通常工作线程数量与std::thread::hardware_concurrency()相同)。当工作需要完成时,可以调用函数将任务挂在任务队列中。每个工作线程都会从任务队列上获取任务,然后执行这个任务,执行完成后再回来获取新的任务。在最简单的线程池中,线程就不需要等待其他线程完成对应任务了。如果需要等待,就需要对同步进行管理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class thread_pool
{
std::atomic_bool done;
thread_safe_queue<std::function<void()> > work_queue; // 1
std::vector<std::thread> threads; // 2
join_threads joiner; // 3
void worker_thread()
{
while(!done) // 4
{
std::function<void()> task;
if(work_queue.try_pop(task)) // 5
{
task(); // 6
}
else
{
std::this_thread::yield(); // 7
}
}
}
public:
thread_pool():
done(false),joiner(threads)
{
unsigned const thread_count=std::thread::hardware_concurrency(); // 8
try
{
for(unsigned i=0;i<thread_count;++i)
{
threads.push_back(
std::thread(&thread_pool::worker_thread,this)); // 9
}
}
catch(...)
{
done=true; // 10
throw;
}
}
~thread_pool()
{
done=true; // 11
}
template<typename FunctionType>
void submit(FunctionType f)
{
work_queue.push(std::function<void()>(f)); // 12
}
};

实现中有一组工作线程②,并且使用了一个线程安全队列①来管理任务队列。这种情况下,用户不用等待任务,并且任务不需要返回任何值,所以可以使用std::function<void()>对任务进行封装。submit()函数会将函数或可调用对象包装成一个std::function<void()>实例,并将其推入队列中⑫。

线程始于构造函数:使用std::thread::hardware_concurrency()来获取硬件支持多少个并发线程⑧,这些线程会在worker_thread()成员函数中执行⑨。

当有异常抛出时,线程启动就会失败,所以需要保证任何已启动的线程都能停止,并且能在这种情况下清理干净。当有异常抛出时,通过使用try-catch来设置done标志⑩,还有join_threads类的实例③用来汇聚所有线程。当然也需要析构函数:仅设置done标志⑪,并且join_threads确保所有线程在线程池销毁前全部执行完成。注意成员声明的顺序很重要:done标志和worker_queue必须在threads数组之前声明,而数据必须在joiner前声明。这就能确保成员能以正确的顺序销毁;比如,所有线程都停止运行时,队列就可以安全的销毁了。

worker_thread函数很简单:从任务队列上获取任务⑤,以及同时执行这些任务⑥,执行一个循环直到done标志被设置④。如果任务队列上没有任务,函数会调用std::this_thread::yield()让线程休息⑦,并且给予其他线程向任务队列上推送任务的机会。

等待提交到线程池中的任务

使用线程池,就需要等待任务提交到线程池中,而非直接提交给单个线程。虽然,会增加代码的复杂度,不过,要比直接对任务进行等待的方式好很多。

通过增加线程池的复杂度,可以直接等待任务完成。使用submit()函数返回一个对任务描述的句柄,用来等待任务的完成。任务句柄会用条件变量或future进行包装,这样能使用线程池来简化代码。

一种特殊的情况是,执行任务的线程需要返回一个结果到主线程上进行处理。下边展示了对简单线程池的修改,通过修改就能等待任务完成,以及在工作线程完成后,返回一个结果到等待线程中去,不过std::packaged_task<>实例是不可拷贝的,仅是可移动的,所以不能再使用std::function<>来实现任务队列,因为std::function<>需要存储可复制构造的函数对象。包装一个自定义函数,用来处理只可移动的类型。这就是一个带有函数操作符的类型擦除类。只需要处理那些没有函数和无返回的函数,所以这是一个简单的虚函数调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
class function_wrapper
{
struct impl_base {
virtual void call()=0;
virtual ~impl_base() {}
};
std::unique_ptr<impl_base> impl;
template<typename F>
struct impl_type: impl_base
{
F f;
impl_type(F&& f_): f(std::move(f_)) {}
void call() { f(); }
};
public:
template<typename F>
function_wrapper(F&& f):
impl(new impl_type<F>(std::move(f)))
{}
void operator()() { impl->call(); }
function_wrapper() = default;
function_wrapper(function_wrapper&& other):
impl(std::move(other.impl))
{}
function_wrapper& operator=(function_wrapper&& other)
{
impl=std::move(other.impl);
return *this;
}
function_wrapper(const function_wrapper&)=delete;
function_wrapper(function_wrapper&)=delete;
function_wrapper& operator=(const function_wrapper&)=delete;
};
class thread_pool
{
thread_safe_queue<function_wrapper> work_queue; // 使用function_wrapper,而非使用std::function
void worker_thread()
{
while(!done)
{
function_wrapper task;
if(work_queue.try_pop(task))
{
task();
}
else
{
std::this_thread::yield();
}
}
}
public:
template<typename FunctionType>
std::future<typename std::result_of<FunctionType()>::type> // 1
submit(FunctionType f)
{
typedef typename std::result_of<FunctionType()>::type
result_type; // 2
std::packaged_task<result_type()> task(std::move(f)); // 3
std::future<result_type> res(task.get_future()); // 4
work_queue.push(std::move(task)); // 5
return res; // 6
}
// 休息一下
};

首先,修改的是submit()函数①返回一个std::future<>保存任务的返回值,并且允许调用者等待任务完全结束。因为需要知道提供函数f的返回类型,所以使用std::result_of<>std::result_of<FunctionType()>::typeFunctionType类型的引用实例(如,f),并且没有参数。同样,函数中可以对result_type typedef②使用std::result_of<>

然后,将f包装入std::packaged_task<result_type()>③,因为f是一个无参数的函数或是可调用对象,能够返回result_type类型的实例。向任务队列推送任务⑤和返回future⑥前,就可以从std::packaged_task<>中获取future④。注意,要将任务推送到任务队列中时,只能使用std::move(),因为std::packaged_task<>是不可拷贝的。为了对任务进行处理,队列里面存的就是function_wrapper对象,而非std::function<void()>对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
template<typename Iterator,typename T>
T parallel_accumulate(Iterator first,Iterator last,T init)
{
unsigned long const length=std::distance(first,last);
if(!length)
return init;
unsigned long const block_size=25;
unsigned long const num_blocks=(length+block_size-1)/block_size; // 1
std::vector<std::future<T> > futures(num_blocks-1);
thread_pool pool;
Iterator block_start=first;
for(unsigned long i=0;i<(num_blocks-1);++i)
{
Iterator block_end=block_start;
std::advance(block_end,block_size);
futures[i]=pool.submit(accumulate_block<Iterator,T>()); // 2
block_start=block_end;
}
T last_result=accumulate_block<Iterator,T>()(block_start,last);
T result=init;
for(unsigned long i=0;i<(num_blocks-1);++i)
{
result+=futures[i].get();
}
result += last_result;
return result;
}

首先,工作量是依据使用的块数(num_blocks①),而不是线程的数量。为了利用线程池的最大化可扩展性,需要将工作块划分为最小工作块。当线程池中线程不多时,每个线程将会处理多个工作块,不过随着硬件可用线程数量的增长,会有越来越多的工作块并发执行。

当你选择“因为能并发执行,最小工作块值的一试”时,就需要谨慎了。向线程池提交一个任务有一定的开销;让工作线程执行这个任务,并且将返回值保存在std::future<>中,对于太小的任务,这样的开销不划算。如果任务块太小,使用线程池的速度可能都不及单线程。

假设,任务块的大小合理,就不用为这些事而担心:打包任务、获取future或存储之后要汇入的std::thread对象;使用线程池的时候,这些都需要注意。之后,就是调用submit()来提交任务②。

线程池也需要注意异常安全。任何异常都会通过submit()返回给future,并在获取future的结果时,抛出异常。如果函数因为异常退出,线程池的析构函数会丢掉那些没有完成的任务,等待线程池中的工作线程完成工作。

在简单的例子中,这个线程池工作的还算不错,因为这里的任务都是相互独立的。不过,当任务队列中的任务有依赖关系时,这个线程池就不能胜任了。

等待依赖任务

快速排序算法为例,原理很简单:数据与中轴数据项比较,在中轴项两侧分为大于和小于的两个序列,然后再对这两组序列进行排序。这两组序列会递归排序,最后会整合成一个全排序序列。要将这个算法写成并发模式,需要保证递归调用能够使用硬件的并发能力。

回到第4章,第一次接触这个例子,我们使用std::async来执行每一层的调用,让标准库来选择,是在新线程上执行这个任务,还是当对应get()调用时,进行同步执行。运行起来很不错,因为每一个任务都在其自己的线程上执行,或当需要的时候进行调用。

在这样的情况下,使用了栈来挂起要排序的数据块。当每个线程在为一个数据块排序前,会向数据栈上添加一组要排序的数据,然后对当前数据块排序结束后,接着对另一块进行排序。这里,等待其他线程完成排序,可能会造成死锁,因为这会消耗有限的线程。有一种情况很可能会出现,就是所有线程都在等某一个数据块被排序,不过没有线程在做排序。通过拉取栈上数据块的线程,对数据块进行排序,来解决这个问题;因为,已处理的指定数据块,就是其他线程都在等待排序的数据块。

最简单的方法就是在thread_pool中添加一个新函数,来执行任务队列上的任务,并对线程池进行管理。高级线程池的实现可能会在等待函数中添加逻辑,或等待其他函数来处理这个任务,优先的任务会让其他的任务进行等待。下面清单中的实现,就展示了一个新run_pending_task()函数,对于快速排序的修改将会在清单9.5中展示。

1
2
3
4
5
6
7
8
9
10
11
12
void thread_pool::run_pending_task()
{
function_wrapper task;
if(work_queue.try_pop(task))
{
task();
}
else
{
std::this_thread::yield();
}
}

run_pending_task()的实现去掉了在worker_thread()函数的主循环。函数任务队列中有任务的时候,执行任务;要是没有的话,就会让操作系统对线程进行重新分配。

下面快速排序算法的实现要简单许多,因为所有线程管理逻辑都被移入到线程池。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
template<typename T>
struct sorter // 1
{
thread_pool pool; // 2
std::list<T> do_sort(std::list<T>& chunk_data)
{
if(chunk_data.empty())
{
return chunk_data;
}
std::list<T> result;
result.splice(result.begin(),chunk_data,chunk_data.begin());
T const& partition_val=*result.begin();
typename std::list<T>::iterator divide_point=
std::partition(chunk_data.begin(),chunk_data.end(),
[&](T const& val){return val<partition_val;});
std::list<T> new_lower_chunk;
new_lower_chunk.splice(new_lower_chunk.end(),
chunk_data,chunk_data.begin(),
divide_point);
std::future<std::list<T> > new_lower= // 3
pool.submit(std::bind(&sorter::do_sort,this,
std::move(new_lower_chunk)));
std::list<T> new_higher(do_sort(chunk_data));
result.splice(result.end(),new_higher);
while(!new_lower.wait_for(std::chrono::seconds(0)) ==
std::future_status::timeout)
{
pool.run_pending_task(); // 4
}
result.splice(result.begin(),new_lower.get());
return result;
}
};
template<typename T>
std::list<T> parallel_quick_sort(std::list<T> input)
{
if(input.empty())
{
return input;
}
sorter<T> s;
return s.do_sort(input);
}

这里将实际工作放在sorter类模板的do_sort()成员函数中执行①,即使例子中仅对thread_pool实例进行包装②。

线程和任务管理,在线程等待的时候,就会少向线程池中提交一个任务③,并且执行任务队列上未完成的任务④。需要显式的管理线程和栈上要排序的数据块。当有任务提交到线程池中,可以使用std::bind()绑定this指针到do_sort()上,绑定是为了让数据块进行排序。这种情况下,需要对new_lower_chunk使用std::move()将其传入函数,数据移动要比拷贝的方式开销少。

虽然,使用等待其他任务的方式,解决了死锁问题,这个线程池距离理想的线程池很远。

首先,每次对submit()的调用和对run_pending_task()的调用,访问的都是同一个队列。

避免队列中的任务竞争

线程每次调用线程池的submit()函数,都会推送一个任务到工作队列中。就像工作线程为了执行任务,从任务队列中获取任务一样。这意味着随着处理器的增加,在任务队列上就会有很多的竞争,这会让性能下降。使用无锁队列会让任务没有明显的等待,但是乒乓缓存会消耗大量的时间。

为了避免乒乓缓存,每个线程建立独立的任务队列。这样,每个线程就会将新任务放在自己的任务队列上,并且当线程上的任务队列没有任务时,去全局的任务列表中取任务。下面列表中的实现,使用了一个thread_local变量,来保证每个线程都拥有自己的任务列表(如全局列表那样)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class thread_pool
{
thread_safe_queue<function_wrapper> pool_work_queue;
typedef std::queue<function_wrapper> local_queue_type; // 1
static thread_local std::unique_ptr<local_queue_type>
local_work_queue; // 2
void worker_thread()
{
local_work_queue.reset(new local_queue_type); // 3
while(!done)
{
run_pending_task();
}
}
public:
template<typename FunctionType>
std::future<typename std::result_of<FunctionType()>::type>
submit(FunctionType f)
{
typedef typename std::result_of<FunctionType()>::type result_type;
std::packaged_task<result_type()> task(f);
std::future<result_type> res(task.get_future());
if(local_work_queue) // 4
{
local_work_queue->push(std::move(task));
}
else
{
pool_work_queue.push(std::move(task)); // 5
}
return res;
}
void run_pending_task()
{
function_wrapper task;
if(local_work_queue && !local_work_queue->empty()) // 6
{
task=std::move(local_work_queue->front());
local_work_queue->pop();
task();
}
else if(pool_work_queue.try_pop(task)) // 7
{
task();
}
else
{
std::this_thread::yield();
}
}
// rest as before
};

因为不希望非线程池中的线程也拥有一个任务队列,使用std::unique_ptr<>指向线程本地的工作队列②;这个指针在worker_thread()中进行初始化③。std:unique_ptr<>的析构函数会保证在线程退出的时候,工作队列被销毁。

submit()会检查当前线程是否具有一个工作队列④。如果有,就是线程池中的线程,可以将任务放入线程的本地队列中;否者,就像之前一样将这个任务放在线程池中的全局队列中⑤。

run_pending_task()⑥中的检查和之前类似,只是要对是否存在本地任务队列进行检查。如果存在,就会从队列中的第一个任务开始处理;注意本地任务队列可以是一个普通的std::queue<>①,因为这个队列只能被一个线程所访问,就不存在竞争。如果本地线程上没有任务,就会从全局工作列表上获取任务⑦。

这样就能有效避免竞争,不过当任务分配不均时,造成的结果就是:某个线程本地队列中有很多任务的同时,其他线程无所事事。例如:举一个快速排序的例子,只有一开始的数据块能在线程池上被处理,因为剩余部分会放在工作线程的本地队列上进行处理,这样的使用方式也违背使用线程池的初衷。

幸好,这个问题是有解:本地工作队列和全局工作队列上没有任务时,可从别的线程队列中窃取任务。

窃取任务

为了让没有任务的线程能从其他线程的任务队列中获取任务,就需要本地任务列表可以进行访问,这样才能让run_pending_tasks()窃取任务。需要每个线程在线程池队列上进行注册,或由线程池指定一个线程。同样,还需要保证数据队列中的任务适当的被同步和保护,这样队列的不变量就不会被破坏。

实现一个无锁队列,让其拥有线程在其他线程窃取任务的时候,能够推送和弹出一个任务是可能的;不过,这个队列的实现就超出了本书的讨论范围。为了证明这种方法的可行性,将使用一个互斥量来保护队列中的数据。我们希望任务窃取是一个不常见的现象,这样就会减少对互斥量的竞争,并且使得简单队列的开销最小。下面,实现了一个简单的基于锁的任务窃取队列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class work_stealing_queue
{
private:
typedef function_wrapper data_type;
std::deque<data_type> the_queue; // 1
mutable std::mutex the_mutex;
public:
work_stealing_queue()
{}
work_stealing_queue(const work_stealing_queue& other)=delete;
work_stealing_queue& operator=(
const work_stealing_queue& other)=delete;
void push(data_type data) // 2
{
std::lock_guard<std::mutex> lock(the_mutex);
the_queue.push_front(std::move(data));
}
bool empty() const
{
std::lock_guard<std::mutex> lock(the_mutex);
return the_queue.empty();
}
bool try_pop(data_type& res) // 3
{
std::lock_guard<std::mutex> lock(the_mutex);
if(the_queue.empty())
{
return false;
}
res=std::move(the_queue.front());
the_queue.pop_front();
return true;
}
bool try_steal(data_type& res) // 4
{
std::lock_guard<std::mutex> lock(the_mutex);
if(the_queue.empty())
{
return false;
}
res=std::move(the_queue.back());
the_queue.pop_back();
return true;
}
};

这个队列对std::deque<fuction_wrapper>进行了简单的包装①,就能通过一个互斥锁来对所有访问进行控制了。push()②和try_pop()③对队列的前端进行操作,try_steal()④对队列的后端进行操作。

这就说明每个线程中的“队列”是一个后进先出的栈,最新推入的任务将会第一个执行。从缓存角度来看,这将对性能有所提升,因为任务相关的数据一直存于缓存中,要比提前将任务相关数据推送到栈上好。同样,这种方式很好的映射到某个算法上,例如:快速排序。之前的实现中,每次调用do_sort()都会推送一个任务到栈上,并且等待这个任务执行完毕。通过对最新推入任务的处理,就可以保证在将当前所需数据块处理完成前,其他任务是否需要这些数据块,从而可以减少活动任务的数量和栈的使用次数。try_steal()从队列末尾获取任务,为了减少与try_pop()之间的竞争。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
class thread_pool
{
typedef function_wrapper task_type;

std::atomic_bool done;
thread_safe_queue<task_type> pool_work_queue;
std::vector<std::unique_ptr<work_stealing_queue> > queues; // 1
std::vector<std::thread> threads;
join_threads joiner;

static thread_local work_stealing_queue* local_work_queue; // 2
static thread_local unsigned my_index;

void worker_thread(unsigned my_index_)
{
my_index=my_index_;
local_work_queue=queues[my_index].get(); // 3
while(!done)
{
run_pending_task();
}
}

bool pop_task_from_local_queue(task_type& task)
{
return local_work_queue && local_work_queue->try_pop(task);
}

bool pop_task_from_pool_queue(task_type& task)
{
return pool_work_queue.try_pop(task);
}

bool pop_task_from_other_thread_queue(task_type& task) // 4
{
for(unsigned i=0;i<queues.size();++i)
{
unsigned const index=(my_index+i+1)%queues.size(); // 5
if(queues[index]->try_steal(task))
{
return true;
}
}
return false;
}

public:
thread_pool():
done(false),joiner(threads)
{
unsigned const thread_count=std::thread::hardware_concurrency();

try
{
for(unsigned i=0;i<thread_count;++i)
{
queues.push_back(std::unique_ptr<work_stealing_queue>( // 6
new work_stealing_queue));
threads.push_back(
std::thread(&thread_pool::worker_thread,this,i));
}
}
catch(...)
{
done=true;
throw;
}
}

~thread_pool()
{
done=true;
}

template<typename FunctionType>
std::future<typename std::result_of<FunctionType()>::type> submit(
FunctionType f)
{
typedef typename std::result_of<FunctionType()>::type result_type;
std::packaged_task<result_type()> task(f);
std::future<result_type> res(task.get_future());
if(local_work_queue)
{
local_work_queue->push(std::move(task));
}
else
{
pool_work_queue.push(std::move(task));
}
return res;
}

void run_pending_task()
{
task_type task;
if(pop_task_from_local_queue(task) || // 7
pop_task_from_pool_queue(task) || // 8
pop_task_from_other_thread_queue(task)) // 9
{
task();
}
else
{
std::this_thread::yield();
}
}
};

中断线程

启动和中断线程

先看一下外部接口,需要从可中断线程上获取些什么?最起码需要和std::thread相同的接口,还要多加一个interrupt()函数:

1
2
3
4
5
6
7
8
9
10
class interruptible_thread
{
public:
template<typename FunctionType>
interruptible_thread(FunctionType f);
void join();
void detach();
bool joinable() const;
void interrupt();
};

类内部可以使用std::thread来管理线程,并且使用一些自定义数据结构来处理中断。现在,从线程的角度能看到什么呢?“能用这个类来中断线程”——需要一个断点(interruption point)。在不添加多余的数据的前提下,为了使断点能够正常使用,就需要使用一个没有参数的函数:interruption_point()。这意味着中断数据结构可以访问thread_local变量,并在线程运行时,对变量进行设置,因此当线程调用interruption_point()函数时,就会去检查当前运行线程的数据结构。我们将在后面看到interruption_point()的具体实现。

thread_local标志是不能使用普通的std::thread管理线程的主要原因;需要使用一种方法分配出一个可访问的interruptible_thread实例,就像新启动一个线程一样。在使用已提供函数来做这件事情前,需要将interruptible_thread实例传递给std::thread的构造函数,创建一个能够执行的线程,就像下面的代码清单所实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class interrupt_flag
{
public:
void set();
bool is_set() const;
};
thread_local interrupt_flag this_thread_interrupt_flag; // 1
class interruptible_thread
{
std::thread internal_thread;
interrupt_flag* flag;
public:
template<typename FunctionType>
interruptible_thread(FunctionType f)
{
std::promise<interrupt_flag*> p; // 2
internal_thread=std::thread([f,&p]{ // 3
p.set_value(&this_thread_interrupt_flag);
f(); // 4
});
flag=p.get_future().get(); // 5
}
void interrupt()
{
if(flag)
{
flag->set(); // 6
}
}
};

提供函数f是包装了一个lambda函数③,线程将会持有f副本和本地promise变量(p)的引用②。在新线程中,lambda函数设置promise变量的值到this_thread_interrupt_flag(在thread_local①中声明)的地址中,为的是让线程能够调用提供函数的副本④。调用线程会等待与其future相关的promise就绪,并且将结果存入到flag成员变量中⑤。注意,即使lambda函数在新线程上执行,对本地变量p进行悬空引用,都没有问题,因为在新线程返回之前,interruptible_thread构造函数会等待变量p,直到变量p不被引用。实现没有考虑处理汇入线程,或分离线程。所以,需要flag变量在线程退出或分离前已经声明,这样就能避免悬空问题。

interrupt()函数相对简单:需要一个线程去做中断时,需要一个合法指针作为一个中断标志,所以可以仅对标志进行设置⑥。

检查线程是否中断

现在就可以设置中断标志了,不过不检查线程是否被中断,这样的意义就不大了。使用interruption_point()函数最简单的情况;可以在一个安全的地方调用这个函数,如果标志已经设置,就可以抛出一个thread_interrupted异常:

1
2
3
4
5
6
7
void interruption_point()
{
if(this_thread_interrupt_flag.is_set())
{
throw thread_interrupted();
}
}

代码中可以在适当的地方使用这个函数:

1
2
3
4
5
6
7
8
void foo()
{
while(!done)
{
interruption_point();
process_next_item();
}
}

使用std::condition_variable_any中断等待

std::condition_variable_anystd::condition_variable的不同在于,std::condition_variable_any可以使用任意类型的锁,而不仅有std::unique_lock<std::mutex>。可以让事情做起来更加简单,并且std::condition_variable_any可以比std::condition_variable做的更好。因为能与任意类型的锁一起工作,就可以设计自己的锁,上锁/解锁interrupt_flag的内部互斥量set_clear_mutex,并且锁也支持等待调用,就像下面的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
class interrupt_flag
{
std::atomic<bool> flag;
std::condition_variable* thread_cond;
std::condition_variable_any* thread_cond_any;
std::mutex set_clear_mutex;
public:
interrupt_flag():
thread_cond(0),thread_cond_any(0)
{}
void set()
{
flag.store(true,std::memory_order_relaxed);
std::lock_guard<std::mutex> lk(set_clear_mutex);
if(thread_cond)
{
thread_cond->notify_all();
}
else if(thread_cond_any)
{
thread_cond_any->notify_all();
}
}
template<typename Lockable>
void wait(std::condition_variable_any& cv,Lockable& lk)
{
struct custom_lock
{
interrupt_flag* self;
Lockable& lk;
custom_lock(interrupt_flag* self_,
std::condition_variable_any& cond,
Lockable& lk_):
self(self_),lk(lk_)
{
self->set_clear_mutex.lock(); // 1
self->thread_cond_any=&cond; // 2
}
void unlock() // 3
{
lk.unlock();
self->set_clear_mutex.unlock();
}
void lock()
{
std::lock(self->set_clear_mutex,lk); // 4
}
~custom_lock()
{
self->thread_cond_any=0; // 5
self->set_clear_mutex.unlock();
}
};
custom_lock cl(this,cv,lk);
interruption_point();
cv.wait(cl);
interruption_point();
}
// rest as before
};
template<typename Lockable>
void interruptible_wait(std::condition_variable_any& cv,
Lockable& lk)
{
this_thread_interrupt_flag.wait(cv,lk);
}

自定义的锁类型在构造的时候,需要所锁住内部set_clear_mutex①,对thread_cond_any指针进行设置,并引用std::condition_variable_any传入锁的构造函数中②。Lockable引用将会在之后进行存储,其变量必须被锁住。现在可以安心的检查中断,不用担心竞争了。如果这时中断标志已经设置,那么标志一定是在锁住set_clear_mutex时设置的。当条件变量调用自定义锁的unlock()函数中的wait()时,就会对Lockable对象和set_clear_mutex进行解锁③。这就允许线程可以尝试中断其他线程获取set_clear_mutex锁;以及在内部wait()调用之后,检查thread_cond_any指针。这就是在替换std::condition_variable后,所拥有的功能(不包括管理)。当wait()结束等待(因为等待,或因为伪苏醒),因为线程将会调用lock()函数,这里依旧要求锁住内部set_clear_mutex,并且锁住Lockable对象④。现在,在wait()调用时,custom_lock的析构函数中⑤清理thread_cond_any指针(同样会解锁set_clear_mutex)之前,可以再次对中断进行检查。

中断其他阻塞调用

这次轮到中断条件变量的等待了,不过其他阻塞情况,比如:互斥锁,等待future等等,该怎么办呢?通常情况下,可以使用std::condition_variable的超时选项,因为在实际运行中不可能很快的将条件变量的等待终止(不访问内部互斥量或future的话)。不过,在某些情况下,你知道知道你在等待什么,这样就可以让循环在interruptible_wait()函数中运行。作为一个例子,这里为std::future<>重载了interruptible_wait()的实现:

1
2
3
4
5
6
7
8
9
10
11
template<typename T>
void interruptible_wait(std::future<T>& uf)
{
while(!this_thread_interrupt_flag.is_set())
{
if(uf.wait_for(lk,std::chrono::milliseconds(1)==
std::future_status::ready)
break;
}
interruption_point();
}

等待会在中断标志设置好的时候,或future准备就绪的时候停止,不过实现中每次等待future的时间只有1ms。这就意味着,中断请求被确定前,平均等待的时间为0.5ms(这里假设存在一个高精度的时钟)。通常wait_for至少会等待一个时钟周期,所以如果时钟周期为15ms,那么结束等待的时间将会是15ms,而不是1ms。接受与不接受这种情况,都得视情况而定。

处理中断

从中断线程的角度看,中断就是thread_interrupted异常,因此能像处理其他异常那样进行处理。

特别是使用标准catch块对其进行捕获:

1
2
3
4
5
6
7
8
try
{
do_something();
}
catch(thread_interrupted&)
{
handle_interruption();
}

捕获中断,进行处理。其他线程再次调用interrupt()时,线程将会再次被中断,这就被称为断点(interruption point)。如果线程执行的是一系列独立的任务,就会需要断点;中断一个任务,就意味着这个任务被丢弃,并且该线程就会执行任务列表中的其他任务。

因为thread_interrupted是一个异常,在能够被中断的代码中,之前线程安全的注意事项都是适用的,就是为了确保资源不会泄露,并在数据结构中留下对应的退出状态。通常,让线程中断是可行的,所以只需要让异常传播即可。不过,当异常传入std::thread的析构函数时,std::terminate()将会调用,并且整个程序将会终止。为了避免这种情况,需要在每个将interruptible_thread变量作为参数传入的函数中放置catch(thread_interrupted)处理块,可以将catch块包装进interrupt_flag的初始化过程中。因为异常将会终止独立进程,就能保证未处理的中断是异常安全的。interruptible_thread构造函数中对线程的初始化,实现如下:

1
2
3
4
5
6
7
8
9
internal_thread=std::thread([f,&p]{
p.set_value(&this_thread_interrupt_flag);
try
{
f();
}
catch(thread_interrupted const&)
{}
});

下面,我们来看个更加复杂的例子。

应用退出时中断后台任务

试想,在桌面上查找一个应用。这就需要与用户互动,应用的状态需要能在显示器上显示,就能看出应用有什么改变。为了避免影响GUI的响应时间,通常会将处理线程放在后台运行。后台进程需要一直执行,直到应用退出;后台线程会作为应用启动的一部分被启动,并且在应用终止的时候停止运行。通常这样的应用只有在机器关闭时,才会退出,因为应用需要更新应用最新的状态,就需要全时间运行。在某些情况下,当应用被关闭,需要使用有序的方式将后台线程关闭,其中一种方式就是中断。

下面清单中为一个系统实现了简单的线程管理部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
std::mutex config_mutex;
std::vector<interruptible_thread> background_threads;
void background_thread(int disk_id)
{
while(true)
{
interruption_point(); // 1
fs_change fsc=get_fs_changes(disk_id); // 2
if(fsc.has_changes())
{
update_index(fsc); // 3
}
}
}
void start_background_processing()
{
background_threads.push_back(
interruptible_thread(background_thread,disk_1));
background_threads.push_back(
interruptible_thread(background_thread,disk_2));
}
int main()
{
start_background_processing(); // 4
process_gui_until_exit(); // 5
std::unique_lock<std::mutex> lk(config_mutex);
for(unsigned i=0;i<background_threads.size();++i)
{
background_threads[i].interrupt(); // 6
}
for(unsigned i=0;i<background_threads.size();++i)
{
background_threads[i].join(); // 7
}
}

启动时,后台线程就已经启动④。之后,对应线程将会处理GUI⑤。当用户要求进程退出时,后台进程将会被中断⑥,并且主线程会等待每一个后台线程结束后才退出⑦。后台线程运行在一个循环中,并时刻检查磁盘的变化②,对其序号进行更新③。调用interruption_point()函数,可以在循环中对中断进行检查。

头文件

<condition_variable>头文件提供了条件变量的定义。其作为基本同步机制,允许被阻塞的线程在某些条件达成或超时时,解除阻塞继续执行。

头文件内容

1
2
3
4
5
6
namespace std
{
enum class cv_status { timeout, no_timeout };
class condition_variable;
class condition_variable_any;
}

std::condition_variable类

std::condition_variable允许阻塞一个线程,直到条件达成。

std::condition_variable实例不支持CopyAssignable(拷贝赋值), CopyConstructible(拷贝构造), MoveAssignable(移动赋值)和 MoveConstructible(移动构造)。

类型定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class condition_variable
{
public:
condition_variable();
~condition_variable();
condition_variable(condition_variable const& ) = delete;
condition_variable& operator=(condition_variable const& ) = delete;
void notify_one() noexcept;
void notify_all() noexcept;
void wait(std::unique_lock<std::mutex>& lock);
template <typename Predicate>
void wait(std::unique_lock<std::mutex>& lock,Predicate pred);
template <typename Clock, typename Duration>
cv_status wait_until(
std::unique_lock<std::mutex>& lock,
const std::chrono::time_point<Clock, Duration>& absolute_time);
template <typename Clock, typename Duration, typename Predicate>
bool wait_until(
std::unique_lock<std::mutex>& lock,
const std::chrono::time_point<Clock, Duration>& absolute_time,
Predicate pred);
template <typename Rep, typename Period>
cv_status wait_for(
std::unique_lock<std::mutex>& lock,
const std::chrono::duration<Rep, Period>& relative_time);
template <typename Rep, typename Period, typename Predicate>
bool wait_for(
std::unique_lock<std::mutex>& lock,
const std::chrono::duration<Rep, Period>& relative_time,
Predicate pred);
};
void notify_all_at_thread_exit(condition_variable&,unique_lock<mutex>);

std::condition_variable_any类

std::condition_variable_any类允许线程等待某一条件为true的时候继续运行。不过std::condition_variable只能和std::unique_lock<std::mutex>一起使用,std::condition_variable_any可以和任意可上锁(Lockable)类型一起使用。

std::condition_variable_any实例不能进行拷贝赋值(CopyAssignable)、拷贝构造(CopyConstructible)、移动赋值(MoveAssignable)或移动构造(MoveConstructible)。

类型定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class condition_variable_any
{
public:
condition_variable_any();
~condition_variable_any();
condition_variable_any(
condition_variable_any const& ) = delete;
condition_variable_any& operator=(
condition_variable_any const& ) = delete;
void notify_one() noexcept;
void notify_all() noexcept;
template<typename Lockable>
void wait(Lockable& lock);
template <typename Lockable, typename Predicate>
void wait(Lockable& lock, Predicate pred);
template <typename Lockable, typename Clock,typename Duration>
std::cv_status wait_until(
Lockable& lock,
const std::chrono::time_point<Clock, Duration>& absolute_time);
template <
typename Lockable, typename Clock,
typename Duration, typename Predicate>
bool wait_until(
Lockable& lock,
const std::chrono::time_point<Clock, Duration>& absolute_time,
Predicate pred);
template <typename Lockable, typename Rep, typename Period>
std::cv_status wait_for(
Lockable& lock,
const std::chrono::duration<Rep, Period>& relative_time);
template <
typename Lockable, typename Rep,
typename Period, typename Predicate>
bool wait_for(
Lockable& lock,
const std::chrono::duration<Rep, Period>& relative_time,
Predicate pred);
};

第 2 章 语言可用性的强化

当我们声明、定义一个变量或者常量,对代码进行流程控制、面向对象的功能、模板编程等这些都是运行时之前,可能发生在编写代码或编译器编译代码时的行为。为此,我们通常谈及语言可用性,是指那些发生在运行时之前的语言行为。

2.1 常量

nullptr

nullptr 出现的目的是为了替代 NULL。在某种意义上来说,传统 C++ 会把 NULL0 视为同一种东西,这取决于编译器如何定义 NULL,有些编译器会将 NULL 定义为 ((void*)0),有些则会直接将其定义为 0

C++ 不允许直接将 void * 隐式转换到其他类型。但如果编译器尝试把 NULL 定义为 ((void*)0),那么在下面这句代码中:

1
char *ch = NULL;

没有了 void * 隐式转换的 C++ 只好将 NULL 定义为 0。而这依然会产生新的问题,将 NULL 定义成 0 将导致 C++ 中重载特性发生混乱。考虑下面这两个 foo 函数:

1
2
void foo(char*);
void foo(int);

那么 foo(NULL); 这个语句将会去调用 foo(int),从而导致代码违反直觉。

为了解决这个问题,C++11 引入了 nullptr 关键字,专门用来区分空指针、0。而 nullptr 的类型为 nullptr_t,能够隐式的转换为任何指针或成员指针的类型,也能和他们进行相等或者不等的比较。

你可以尝试使用 clang++ 编译下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>
#include <type_traits>

void foo(char *);
void foo(int);

int main() {
if (std::is_same<decltype(NULL), decltype(0)>::value)
std::cout << "NULL == 0" << std::endl;
if (std::is_same<decltype(NULL), decltype((void*)0)>::value)
std::cout << "NULL == (void *)0" << std::endl;
if (std::is_same<decltype(NULL), std::nullptr_t>::value)
std::cout << "NULL == nullptr" << std::endl;

foo(0); // 调用 foo(int)
// foo(NULL); // 该行不能通过编译
foo(nullptr); // 调用 foo(char*)
return 0;
}

void foo(char *) {
std::cout << "foo(char*) is called" << std::endl;
}
void foo(int i) {
std::cout << "foo(int) is called" << std::endl;
}

将输出:

1
2
foo(int) is called
foo(char*) is called

从输出中我们可以看出,NULL 不同于 0nullptr。所以,请养成直接使用 nullptr的习惯。

此外,在上面的代码中,我们使用了 decltypestd::is_same 这两个属于现代 C++ 的语法,简单来说,decltype 用于类型推导,而 std::is_same 用于比较两个类型是否相同,我们会在后面 decltype 一节中详细讨论。

constexpr

C++ 本身已经具备了常量表达式的概念,比如 1+2, 3*4 这种表达式总是会产生相同的结果并且没有任何副作用。如果编译器能够在编译时就把这些表达式直接优化并植入到程序运行时,将能增加程序的性能。一个非常明显的例子就是在数组的定义阶段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>
#define LEN 10

int len_foo() {
int i = 2;
return i;
}
constexpr int len_foo_constexpr() {
return 5;
}

constexpr int fibonacci(const int n) {
return n == 1 || n == 2 ? 1 : fibonacci(n-1)+fibonacci(n-2);
}

int main() {
char arr_1[10]; // 合法
char arr_2[LEN]; // 合法

int len = 10;
// char arr_3[len]; // 非法

const int len_2 = len + 1;
constexpr int len_2_constexpr = 1 + 2 + 3;
// char arr_4[len_2]; // 非法
char arr_4[len_2_constexpr]; // 合法

// char arr_5[len_foo()+5]; // 非法
char arr_6[len_foo_constexpr() + 1]; // 合法

std::cout << fibonacci(10) << std::endl;
// 1, 1, 2, 3, 5, 8, 13, 21, 34, 55
std::cout << fibonacci(10) << std::endl;
return 0;
}

上面的例子中,char arr_4[len_2] 可能比较令人困惑,因为 len_2 已经被定义为了常量。为什么 char arr_4[len_2] 仍然是非法的呢?这是因为 C++ 标准中数组的长度必须是一个常量表达式,而对于 len_2 而言,这是一个 const 常数,而不是一个常量表达式,因此(即便这种行为在大部分编译器中都支持,但是)它是一个非法的行为,我们需要使用接下来即将介绍的 C++11 引入的 constexpr 特性来解决这个问题;而对于 arr_5 来说,C++98 之前的编译器无法得知 len_foo() 在运行期实际上是返回一个常数,这也就导致了非法的产生。

注意,现在大部分编译器其实都带有自身编译优化,很多非法行为在编译器优化的加持下会变得合法,若需重现编译报错的现象需要使用老版本的编译器。

C++11 提供了 constexpr 让用户显式的声明函数或对象构造函数在编译期会成为常量表达式,这个关键字明确的告诉编译器应该去验证 len_foo 在编译期就应该是一个常量表达式。

此外,constexpr 修饰的函数可以使用递归:

1
2
3
constexpr int fibonacci(const int n) {
return n == 1 || n == 2 ? 1 : fibonacci(n-1)+fibonacci(n-2);
}

从 C++14 开始,constexpr 函数可以在内部使用局部变量、循环和分支等简单语句,例如下面的代码在 C++11 的标准下是不能够通过编译的:

1
2
3
4
5
constexpr int fibonacci(const int n) {
if(n == 1) return 1;
if(n == 2) return 1;
return fibonacci(n-1) + fibonacci(n-2);
}

为此,我们可以写出下面这类简化的版本来使得函数从 C++11 开始即可用:

1
2
3
constexpr int fibonacci(const int n) {
return n == 1 || n == 2 ? 1 : fibonacci(n-1) + fibonacci(n-2);
}

2.2 变量及其初始化

if/switch 变量声明强化

在传统 C++ 中,变量的声明虽然能够位于任何位置,甚至于 for 语句内能够声明一个临时变量 int,但始终没有办法在 ifswitch 语句中声明一个临时的变量。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>
#include <vector>
#include <algorithm>

int main() {
std::vector<int> vec = {1, 2, 3, 4};

// 在 c++17 之前
const std::vector<int>::iterator itr = std::find(vec.begin(), vec.end(), 2);
if (itr != vec.end()) {
*itr = 3;
}

// 需要重新定义一个新的变量
const std::vector<int>::iterator itr2 = std::find(vec.begin(), vec.end(), 3);
if (itr2 != vec.end()) {
*itr2 = 4;
}

// 将输出 1, 4, 3, 4
for (std::vector<int>::iterator element = vec.begin(); element != vec.end();
++element)
std::cout << *element << std::endl;
}

在上面的代码中,我们可以看到 itr 这一变量是定义在整个 main() 的作用域内的,这导致当我们需要再次遍历整个 std::vectors 时,需要重新命名另一个变量。C++17 消除了这一限制,使得我们可以在 if(或 switch)中完成这一操作:

1
2
3
4
5
// 将临时变量放到 if 语句内
if (const std::vector<int>::iterator itr = std::find(vec.begin(), vec.end(), 3);
itr != vec.end()) {
*itr = 4;
}

怎么样,是不是和 Go 语言很像?

初始化列表

初始化是一个非常重要的语言特性,最常见的就是在对象进行初始化时进行使用。在传统 C++ 中,不同的对象有着不同的初始化方法,例如普通数组、POD (Plain Old Data,即没有构造、析构和虚函数的类或结构体)类型都可以使用 {} 进行初始化,也就是我们所说的初始化列表。而对于类对象的初始化,要么需要通过拷贝构造、要么就需要使用 () 进行。这些不同方法都针对各自对象,不能通用。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
#include <vector>

class Foo {
public:
int value_a;
int value_b;
Foo(int a, int b) : value_a(a), value_b(b) {}
};

int main() {
// before C++11
int arr[3] = {1, 2, 3};
Foo foo(1, 2);
std::vector<int> vec = {1, 2, 3, 4, 5};

std::cout << "arr[0]: " << arr[0] << std::endl;
std::cout << "foo:" << foo.value_a << ", " << foo.value_b << std::endl;
for (std::vector<int>::iterator it = vec.begin(); it != vec.end(); ++it) {
std::cout << *it << std::endl;
}
return 0;
}

为解决这个问题,C++11 首先把初始化列表的概念绑定到类型上,称其为 std::initializer_list,允许构造函数或其他函数像参数一样使用初始化列表,这就为类对象的初始化与普通数组和 POD 的初始化方法提供了统一的桥梁,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <initializer_list>
#include <vector>
#include <iostream>

class MagicFoo {
public:
std::vector<int> vec;
MagicFoo(std::initializer_list<int> list) {
for (std::initializer_list<int>::iterator it = list.begin();
it != list.end(); ++it)
vec.push_back(*it);
}
};
int main() {
// after C++11
MagicFoo magicFoo = {1, 2, 3, 4, 5};

std::cout << "magicFoo: ";
for (std::vector<int>::iterator it = magicFoo.vec.begin();
it != magicFoo.vec.end(); ++it)
std::cout << *it << std::endl;
}

这种构造函数被叫做初始化列表构造函数,具有这种构造函数的类型将在初始化时被特殊关照。

初始化列表除了用在对象构造上,还能将其作为普通函数的形参,例如:

1
2
3
4
5
6
7
public:
void foo(std::initializer_list<int> list) {
for (std::initializer_list<int>::iterator it = list.begin();
it != list.end(); ++it) vec.push_back(*it);
}

magicFoo.foo({6,7,8,9});

其次,C++11 还提供了统一的语法来初始化任意的对象,例如:

1
Foo foo2 {3, 4};

结构化绑定

结构化绑定提供了类似其他语言中提供的多返回值的功能。在容器一章中,我们会学到 C++11 新增了 std::tuple 容器用于构造一个元组,进而囊括多个返回值。但缺陷是,C++11/14 并没有提供一种简单的方法直接从元组中拿到并定义元组中的元素,尽管我们可以使用 std::tie 对元组进行拆包,但我们依然必须非常清楚这个元组包含多少个对象,各个对象是什么类型,非常麻烦。

C++17 完善了这一设定,给出的结构化绑定可以让我们写出这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>
#include <tuple>

std::tuple<int, double, std::string> f() {
return std::make_tuple(1, 2.3, "456");
}

int main() {
auto [x, y, z] = f();
std::cout << x << ", " << y << ", " << z << std::endl;
return 0;
}

关于 auto 类型推导会在 auto 类型推导一节中进行介绍。

2.3 类型推导

在传统 C 和 C++ 中,参数的类型都必须明确定义,这其实对我们快速进行编码没有任何帮助,尤其是当我们面对一大堆复杂的模板类型时,必须明确的指出变量的类型才能进行后续的编码,这不仅拖慢我们的开发效率,也让代码变得又臭又长。

C++11 引入了 autodecltype 这两个关键字实现了类型推导,让编译器来操心变量的类型。这使得 C++ 也具有了和其他现代编程语言一样,某种意义上提供了无需操心变量类型的使用习惯。

auto

auto 在很早以前就已经进入了 C++,但是他始终作为一个存储类型的指示符存在,与 register 并存。在传统 C++ 中,如果一个变量没有声明为 register 变量,将自动被视为一个 auto 变量。而随着 register 被弃用(在 C++17 中作为保留关键字,以后使用,目前不具备实际意义),对 auto 的语义变更也就非常自然了。

使用 auto 进行类型推导的一个最为常见而且显著的例子就是迭代器。你应该在前面的小节里看到了传统 C++ 中冗长的迭代写法:

1
2
3
4
// 在 C++11 之前
// 由于 cbegin() 将返回 vector<int>::const_iterator
// 所以 itr 也应该是 vector<int>::const_iterator 类型
for(vector<int>::const_iterator it = vec.cbegin(); itr != vec.cend(); ++it)

而有了 auto 之后可以:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

#include <initializer_list>
#include <vector>
#include <iostream>

class MagicFoo {
public:
std::vector<int> vec;
MagicFoo(std::initializer_list<int> list) {
// 从 C++11 起, 使用 auto 关键字进行类型推导
for (auto it = list.begin(); it != list.end(); ++it) {
vec.push_back(*it);
}
}
};
int main() {
MagicFoo magicFoo = {1, 2, 3, 4, 5};
std::cout << "magicFoo: ";
for (auto it = magicFoo.vec.begin(); it != magicFoo.vec.end(); ++it) {
std::cout << *it << ", ";
}
std::cout << std::endl;
return 0;
}

一些其他的常见用法:

1
2
auto i = 5;              // i 被推导为 int
auto arr = new auto(10); // arr 被推导为 int *

从 C++ 20 起,auto 甚至能用于函数传参,考虑下面的例子:

1
2
3
4
5
6
7
int add(auto x, auto y) {
return x+y;
}

auto i = 5; // 被推导为 int
auto j = 6; // 被推导为 int
std::cout << add(i, j) << std::endl;

注意auto 还不能用于推导数组类型:

1
2
3
4
auto auto_arr2[10] = {arr}; // 错误, 无法推导数组元素类型

2.6.auto.cpp:30:19: error: 'auto_arr2' declared as array of 'auto'
auto auto_arr2[10] = {arr};

decltype

decltype 关键字是为了解决 auto 关键字只能对变量进行类型推导的缺陷而出现的。它的用法和 typeof 很相似:

1
decltype(表达式)

有时候,我们可能需要计算某个表达式的类型,例如:

1
2
3
auto x = 1;
auto y = 2;
decltype(x+y) z;

你已经在前面的例子中看到 decltype 用于推断类型的用法,下面这个例子就是判断上面的变量 x, y, z 是否是同一类型:

1
2
3
4
5
6
if (std::is_same<decltype(x), int>::value)
std::cout << "type x == int" << std::endl;
if (std::is_same<decltype(x), float>::value)
std::cout << "type x == float" << std::endl;
if (std::is_same<decltype(x), decltype(z)>::value)
std::cout << "type z == type x" << std::endl;

其中,std::is_same<T, U> 用于判断 TU 这两个类型是否相等。输出结果为:

1
2
type x == int
type z == type x

尾返回类型推导

你可能会思考,在介绍 auto 时,我们已经提过 auto 不能用于函数形参进行类型推导,那么 auto 能不能用于推导函数的返回类型呢?还是考虑一个加法函数的例子,在传统 C++ 中我们必须这么写:

1
2
3
4
template<typename R, typename T, typename U>
R add(T x, U y) {
return x+y;
}

注意:typename 和 class 在模板参数列表中没有区别,在 typename 这个关键字出现之前,都是使用 class 来定义模板参数的。但在模板中定义有嵌套依赖类型的变量时,需要用 typename 消除歧义

这样的代码其实变得很丑陋,因为程序员在使用这个模板函数的时候,必须明确指出返回类型。但事实上我们并不知道 add() 这个函数会做什么样的操作,以及获得一个什么样的返回类型。

在 C++11 中这个问题得到解决。虽然你可能马上会反应出来使用 decltype 推导 x+y 的类型,写出这样的代码:

1
decltype(x+y) add(T x, U y)

但事实上这样的写法并不能通过编译。这是因为在编译器读到 decltype(x+y) 时,xy 尚未被定义。为了解决这个问题,C++11 还引入了一个叫做尾返回类型(trailing return type),利用 auto 关键字将返回类型后置:

1
2
3
4
template<typename T, typename U>
auto add2(T x, U y) -> decltype(x+y){
return x + y;
}

令人欣慰的是从 C++14 开始是可以直接让普通函数具备返回值推导,因此下面的写法变得合法:

1
2
3
4
template<typename T, typename U>
auto add3(T x, U y){
return x + y;
}

可以检查一下类型推导是否正确:

1
2
3
4
5
6
7
8
9
10
// after c++11
auto w = add2<int, double>(1, 2.0);
if (std::is_same<decltype(w), double>::value) {
std::cout << "w is double: ";
}
std::cout << w << std::endl;

// after c++14
auto q = add3<double, int>(1.0, 2);
std::cout << "q: " << q << std::endl;

decltype(auto)

decltype(auto) 是 C++14 开始提供的一个略微复杂的用法。

要理解它你需要知道 C++ 中参数转发的概念,我们会在语言运行时强化一章中详细介绍,你可以到时再回来看这一小节的内容。

简单来说,decltype(auto) 主要用于对转发函数或封装的返回类型进行推导,它使我们无需显式的指定 decltype 的参数表达式。考虑看下面的例子,当我们需要对下面两个函数进行封装时:

1
2
std::string  lookup1();
std::string& lookup2();

在 C++11 中,封装实现是如下形式:

1
2
3
4
5
6
std::string look_up_a_string_1() {
return lookup1();
}
std::string& look_up_a_string_2() {
return lookup2();
}

而有了 decltype(auto),我们可以让编译器完成这一件烦人的参数转发:

1
2
3
4
5
6
decltype(auto) look_up_a_string_1() {
return lookup1();
}
decltype(auto) look_up_a_string_2() {
return lookup2();
}

2.4 控制流

if constexpr

正如本章开头出,我们知道了 C++11 引入了 constexpr 关键字,它将表达式或函数编译为常量结果。一个很自然的想法是,如果我们把这一特性引入到条件判断中去,让代码在编译时就完成分支判断,岂不是能让程序效率更高?C++17 将 constexpr 这个关键字引入到 if 语句中,允许在代码中声明常量表达式的判断条件,考虑下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>

template<typename T>
auto print_type_info(const T& t) {
if constexpr (std::is_integral<T>::value) {
return t + 1;
} else {
return t + 0.001;
}
}
int main() {
std::cout << print_type_info(5) << std::endl;
std::cout << print_type_info(3.14) << std::endl;
}

在编译时,实际代码就会表现为如下:

1
2
3
4
5
6
7
8
9
10
int print_type_info(const int& t) {
return t + 1;
}
double print_type_info(const double& t) {
return t + 0.001;
}
int main() {
std::cout << print_type_info(5) << std::endl;
std::cout << print_type_info(3.14) << std::endl;
}

区间 for 迭代

终于,C++11 引入了基于范围的迭代写法,我们拥有了能够写出像 Python 一样简洁的循环语句,我们可以进一步简化前面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
#include <vector>
#include <algorithm>

int main() {
std::vector<int> vec = {1, 2, 3, 4};
if (auto itr = std::find(vec.begin(), vec.end(), 3); itr != vec.end()) *itr = 4;
for (auto element : vec)
std::cout << element << std::endl; // read only
for (auto &element : vec) {
element += 1; // writeable
}
for (auto element : vec)
std::cout << element << std::endl; // read only
}

2.5 模板

C++ 的模板一直是这门语言的一种特殊的艺术,模板甚至可以独立作为一门新的语言来进行使用。模板的哲学在于将一切能够在编译期处理的问题丢到编译期进行处理,仅在运行时处理那些最核心的动态服务,进而大幅优化运行期的性能。因此模板也被很多人视作 C++ 的黑魔法之一。

外部模板

传统 C++ 中,模板只有在使用时才会被编译器实例化。换句话说,只要在每个编译单元(文件)中编译的代码中遇到了被完整定义的模板,都会实例化。这就产生了重复实例化而导致的编译时间的增加。并且,我们没有办法通知编译器不要触发模板的实例化。

为此,C++11 引入了外部模板,扩充了原来的强制编译器在特定位置实例化模板的语法,使我们能够显式的通知编译器何时进行模板的实例化:

1
2
template class std::vector<bool>;          // 强行实例化
extern template class std::vector<double>; // 不在该当前编译文件中实例化模板

尖括号 “>”

在传统 C++ 的编译器中,>>一律被当做右移运算符来进行处理。但实际上我们很容易就写出了嵌套模板的代码:

1
std::vector<std::vector<int>> matrix;

这在传统 C++ 编译器下是不能够被编译的,而 C++11 开始,连续的右尖括号将变得合法,并且能够顺利通过编译。甚至于像下面这种写法都能够通过编译:

1
2
3
4
5
6
7
template<bool T>
class MagicType {
bool magic = T;
};

// in main function:
std::vector<MagicType<(1>2)>> magic; // 合法, 但不建议写出这样的代码

类型别名模板

在了解类型别名模板之前,需要理解『模板』和『类型』之间的不同。仔细体会这句话:**模板是用来产生类型的。**在传统 C++ 中,typedef 可以为类型定义一个新的名称,但是却没有办法为模板定义一个新的名称。因为,模板不是类型。例如:

1
2
3
4
5
6
7
8
9
10
template<typename T, typename U>
class MagicType {
public:
T dark;
U magic;
};

// 不合法
template<typename T>
typedef MagicType<std::vector<T>, std::string> FakeDarkMagic;

C++11 使用 using 引入了下面这种形式的写法,并且同时支持对传统 typedef 相同的功效:

通常我们使用 typedef 定义别名的语法是:typedef 原名称 新名称;,但是对函数指针等别名的定义语法却不相同,这通常给直接阅读造成了一定程度的困难。

1
2
3
4
5
6
7
8
typedef int (*process)(void *);
using NewProcess = int(*)(void *);
template<typename T>
using TrueDarkMagic = MagicType<std::vector<T>, std::string>;

int main() {
TrueDarkMagic<bool> you;
}

变长参数模板

模板一直是 C++ 所独有的黑魔法(一起念:Dark Magic)之一。
在 C++11 之前,无论是类模板还是函数模板,都只能按其指定的样子,
接受一组固定数量的模板参数;而 C++11 加入了新的表示方法,
允许任意个数、任意类别的模板参数,同时也不需要在定义时将参数的个数固定。

1
template<typename... Ts> class Magic;

模板类 Magic 的对象,能够接受不受限制个数的 typename 作为模板的形式参数,例如下面的定义:

1
2
3
4
class Magic<int,
std::vector<int>,
std::map<std::string,
std::vector<int>>> darkMagic;

既然是任意形式,所以个数为 0 的模板参数也是可以的:class Magic<> nothing;

如果不希望产生的模板参数个数为 0,可以手动的定义至少一个模板参数:

1
template<typename Require, typename... Args> class Magic;

变长参数模板也能被直接调整到到模板函数上。传统 C 中的 printf 函数,
虽然也能达成不定个数的形参的调用,但其并非类别安全。
而 C++11 除了能定义类别安全的变长参数函数外,
还可以使类似 printf 的函数能自然地处理非自带类别的对象。
除了在模板参数中能使用 ... 表示不定长模板参数外,
函数参数也使用同样的表示法代表不定长参数,
这也就为我们简单编写变长参数函数提供了便捷的手段,例如:

1
template<typename... Args> void printf(const std::string &str, Args... args);

那么我们定义了变长的模板参数,如何对参数进行解包呢?

首先,我们可以使用 sizeof... 来计算参数的个数,:

1
2
3
4
template<typename... Ts>
void magic(Ts... args) {
std::cout << sizeof...(args) << std::endl;
}

我们可以传递任意个参数给 magic 函数:

1
2
3
magic(); // 输出0
magic(1); // 输出1
magic(1, ""); // 输出2

其次,对参数进行解包,到目前为止还没有一种简单的方法能够处理参数包,但有两种经典的处理手法:

1. 递归模板函数

递归是非常容易想到的一种手段,也是最经典的处理方法。这种方法不断递归地向函数传递模板参数,进而达到递归遍历所有模板参数的目的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
template<typename T0>
void printf1(T0 value) {
std::cout << value << std::endl;
}
template<typename T, typename... Ts>
void printf1(T value, Ts... args) {
std::cout << value << std::endl;
printf1(args...);
}
int main() {
printf1(1, 2, "123", 1.1);
return 0;
}

2. 变参模板展开

你应该感受到了这很繁琐,在 C++17 中增加了变参模板展开的支持,于是你可以在一个函数中完成 printf 的编写:

1
2
3
4
5
template<typename T0, typename... T>
void printf2(T0 t0, T... t) {
std::cout << t0 << std::endl;
if constexpr (sizeof...(t) > 0) printf2(t...);
}

事实上,有时候我们虽然使用了变参模板,却不一定需要对参数做逐个遍历,我们可以利用 std::bind 及完美转发等特性实现对函数和参数的绑定,从而达到成功调用的目的。

3. 初始化列表展开

递归模板函数是一种标准的做法,但缺点显而易见的在于必须定义一个终止递归的函数。

这里介绍一种使用初始化列表展开的黑魔法:

1
2
3
4
5
6
7
template<typename T, typename... Ts>
auto printf3(T value, Ts... args) {
std::cout << value << std::endl;
(void) std::initializer_list<T>{([&args] {
std::cout << args << std::endl;
}(), value)...};
}

在这个代码中,额外使用了 C++11 中提供的初始化列表以及 Lambda 表达式的特性(下一节中将提到)。

通过初始化列表,(lambda 表达式, value)... 将会被展开。由于逗号表达式的出现,首先会执行前面的 lambda 表达式,完成参数的输出。
为了避免编译器警告,我们可以将 std::initializer_list 显式的转为 void

折叠表达式

C++ 17 中将变长参数这种特性进一步带给了表达式,考虑下面这个例子:

1
2
3
4
5
6
7
8
#include <iostream>
template<typename ... T>
auto sum(T ... t) {
return (t + ...);
}
int main() {
std::cout << sum(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) << std::endl;
}

非类型模板参数推导

前面我们主要提及的是模板参数的一种形式:类型模板参数。

1
2
3
4
template <typename T, typename U>
auto add(T t, U u) {
return t+u;
}

其中模板的参数 TU 为具体的类型。
但还有一种常见模板参数形式可以让不同字面量成为模板参数,即非类型模板参数:

1
2
3
4
5
6
7
8
9
10
template <typename T, int BufSize>
class buffer_t {
public:
T& alloc();
void free(T& item);
private:
T data[BufSize];
}

buffer_t<int, 100> buf; // 100 作为模板参数

在这种模板参数形式下,我们可以将 100 作为模板的参数进行传递。
在 C++11 引入了类型推导这一特性后,我们会很自然的问,既然此处的模板参数
以具体的字面量进行传递,能否让编译器辅助我们进行类型推导,
通过使用占位符 auto 从而不再需要明确指明类型?
幸运的是,C++17 引入了这一特性,我们的确可以 auto 关键字,让编译器辅助完成具体类型的推导,
例如:

1
2
3
4
5
6
7
8
template <auto value> void foo() {
std::cout << value << std::endl;
return;
}

int main() {
foo<10>(); // value 被推导为 int 类型
}

2.6 面向对象

委托构造

C++11 引入了委托构造的概念,这使得构造函数可以在同一个类中一个构造函数调用另一个构造函数,从而达到简化代码的目的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
class Base {
public:
int value1;
int value2;
Base() {
value1 = 1;
}
Base(int value) : Base() { // 委托 Base() 构造函数
value2 = value;
}
};

int main() {
Base b(2);
std::cout << b.value1 << std::endl;
std::cout << b.value2 << std::endl;
}

继承构造

在传统 C++ 中,构造函数如果需要继承是需要将参数一一传递的,这将导致效率低下。C++11 利用关键字 using 引入了继承构造函数的概念:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
class Base {
public:
int value1;
int value2;
Base() {
value1 = 1;
}
Base(int value) : Base() { // 委托 Base() 构造函数
value2 = value;
}
};
class Subclass : public Base {
public:
using Base::Base; // 继承构造
};
int main() {
Subclass s(3);
std::cout << s.value1 << std::endl;
std::cout << s.value2 << std::endl;
}

显式虚函数重载

在传统 C++ 中,经常容易发生意外重载虚函数的事情。例如:

1
2
3
4
5
6
struct Base {
virtual void foo();
};
struct SubClass: Base {
void foo();
};

SubClass::foo 可能并不是程序员尝试重载虚函数,只是恰好加入了一个具有相同名字的函数。另一个可能的情形是,当基类的虚函数被删除后,子类拥有旧的函数就不再重载该虚拟函数并摇身一变成为了一个普通的类方法,这将造成灾难性的后果。

C++11 引入了 overridefinal 这两个关键字来防止上述情形的发生。

override

当重载虚函数时,引入 override 关键字将显式的告知编译器进行重载,编译器将检查基函数是否存在这样的虚函数,否则将无法通过编译:

1
2
3
4
5
6
7
struct Base {
virtual void foo(int);
};
struct SubClass: Base {
virtual void foo(int) override; // 合法
virtual void foo(float) override; // 非法, 父类没有此虚函数
};

final

final 则是为了防止类被继续继承以及终止虚函数继续重载引入的。

1
2
3
4
5
6
7
8
9
10
11
12
struct Base {
virtual void foo() final;
};
struct SubClass1 final: Base {
}; // 合法

struct SubClass2 : SubClass1 {
}; // 非法, SubClass1 已 final

struct SubClass3: Base {
void foo(); // 非法, foo 已 final
};

显式禁用默认函数

在传统 C++ 中,如果程序员没有提供,编译器会默认为对象生成默认构造函数、
复制构造、赋值算符以及析构函数。
另外,C++ 也为所有类定义了诸如 new delete 这样的运算符。
当程序员有需要时,可以重载这部分函数。

这就引发了一些需求:无法精确控制默认函数的生成行为。
例如禁止类的拷贝时,必须将复制构造函数与赋值算符声明为 private
尝试使用这些未定义的函数将导致编译或链接错误,则是一种非常不优雅的方式。

并且,编译器产生的默认构造函数与用户定义的构造函数无法同时存在。
若用户定义了任何构造函数,编译器将不再生成默认构造函数,
但有时候我们却希望同时拥有这两种构造函数,这就造成了尴尬。

C++11 提供了上述需求的解决方案,允许显式的声明采用或拒绝编译器自带的函数。
例如:

1
2
3
4
5
6
class Magic {
public:
Magic() = default; // 显式声明使用编译器生成的构造
Magic& operator=(const Magic&) = delete; // 显式声明拒绝编译器生成构造
Magic(int magic_number);
}

强类型枚举

在传统 C++中,枚举类型并非类型安全,枚举类型会被视作整数,则会让两种完全不同的枚举类型可以进行直接的比较(虽然编译器给出了检查,但并非所有),甚至同一个命名空间中的不同枚举类型的枚举值名字不能相同,这通常不是我们希望看到的结果。

C++11 引入了枚举类(enumeration class),并使用 enum class 的语法进行声明:

1
2
3
4
5
6
enum class new_enum : unsigned int {
value1,
value2,
value3 = 100,
value4 = 100
};

这样定义的枚举实现了类型安全,首先他不能够被隐式的转换为整数,同时也不能够将其与整数数字进行比较,
更不可能对不同的枚举类型的枚举值进行比较。但相同枚举值之间如果指定的值相同,那么可以进行比较:

1
2
3
4
if (new_enum::value3 == new_enum::value4) {
// 会输出
std::cout << "new_enum::value3 == new_enum::value4" << std::endl;
}

在这个语法中,枚举类型后面使用了冒号及类型关键字来指定枚举中枚举值的类型,这使得我们能够为枚举赋值(未指定时将默认使用 int)。

而我们希望获得枚举值的值时,将必须显式的进行类型转换,不过我们可以通过重载 << 这个算符来进行输出,可以收藏下面这个代码段:

1
2
3
4
5
6
7
8
#include <iostream>
template<typename T>
std::ostream& operator<<(
typename std::enable_if<std::is_enum<T>::value,
std::ostream>::type& stream, const T& e)
{
return stream << static_cast<typename std::underlying_type<T>::type>(e);
}

这时,下面的代码将能够被编译:

1
std::cout << new_enum::value3 << std::endl

总结

本节介绍了现代 C++ 中对语言可用性的增强,其中笔者认为最为重要的几个特性是几乎所有人都需要了解并熟练使用的:

  1. auto 类型推导
  2. 范围 for 迭代
  3. 初始化列表
  4. 变参模板

第 3 章 语言运行期的强化

3.1 Lambda 表达式

Lambda 表达式是现代 C++ 中最重要的特性之一,而 Lambda 表达式,实际上就是提供了一个类似匿名函数的特性,
而匿名函数则是在需要一个函数,但是又不想费力去命名一个函数的情况下去使用的。这样的场景其实有很多很多,
所以匿名函数几乎是现代编程语言的标配。

基础

Lambda 表达式的基本语法如下:

1
2
3
[捕获列表](参数列表) mutable(可选) 异常属性 -> 返回类型 {
// 函数体
}

上面的语法规则除了 [捕获列表] 内的东西外,其他部分都很好理解,只是一般函数的函数名被略去,
返回值使用了一个 -> 的形式进行(我们在上一节前面的尾返回类型已经提到过这种写法了)。

所谓捕获列表,其实可以理解为参数的一种类型,Lambda 表达式内部函数体在默认情况下是不能够使用函数体外部的变量的,
这时候捕获列表可以起到传递外部数据的作用。根据传递的行为,捕获列表也分为以下几种:

1. 值捕获

与参数传值类似,值捕获的前提是变量可以拷贝,不同之处则在于,被捕获的变量在 Lambda 表达式被创建时拷贝,
而非调用时才拷贝:

1
2
3
4
5
6
7
8
9
10
11
void lambda_value_capture() {
int value = 1;
auto copy_value = [value] {
return value;
};
value = 100;
auto stored_value = copy_value();
std::cout << "stored_value = " << stored_value << std::endl;
// 这时, stored_value == 1, 而 value == 100.
// 因为 copy_value 在创建时就保存了一份 value 的拷贝
}

2. 引用捕获

与引用传参类似,引用捕获保存的是引用,值会发生变化。

1
2
3
4
5
6
7
8
9
10
11
void lambda_reference_capture() {
int value = 1;
auto copy_value = [&value] {
return value;
};
value = 100;
auto stored_value = copy_value();
std::cout << "stored_value = " << stored_value << std::endl;
// 这时, stored_value == 100, value == 100.
// 因为 copy_value 保存的是引用
}

3. 隐式捕获

手动书写捕获列表有时候是非常复杂的,这种机械性的工作可以交给编译器来处理,这时候可以在捕获列表中写一个
&= 向编译器声明采用引用捕获或者值捕获.

总结一下,捕获提供了 Lambda 表达式对外部值进行使用的功能,捕获列表的最常用的四种形式可以是:

  • [] 空捕获列表
  • [name1, name2, …] 捕获一系列变量
  • [&] 引用捕获, 让编译器自行推导引用列表
  • [=] 值捕获, 让编译器自行推导值捕获列表

4. 表达式捕获

这部分内容需要了解后面马上要提到的右值引用以及智能指针

上面提到的值捕获、引用捕获都是已经在外层作用域声明的变量,因此这些捕获方式捕获的均为左值,而不能捕获右值。

C++14 给与了我们方便,允许捕获的成员用任意的表达式进行初始化,这就允许了右值的捕获,
被声明的捕获变量类型会根据表达式进行判断,判断方式与使用 auto 本质上是相同的:

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
#include <memory> // std::make_unique
#include <utility> // std::move

void lambda_expression_capture() {
auto important = std::make_unique<int>(1);
auto add = [v1 = 1, v2 = std::move(important)](int x, int y) -> int {
return x+y+v1+(*v2);
};
std::cout << add(3,4) << std::endl;
}

在上面的代码中,important 是一个独占指针,是不能够被 “=” 值捕获到,这时候我们可以将其转移为右值,在表达式中初始化。

泛型 Lambda

上一节中我们提到了 auto 关键字不能够用在参数表里,这是因为这样的写法会与模板的功能产生冲突。
但是 Lambda 表达式并不是普通函数,所以在没有明确指明参数表类型的情况下,Lambda 表达式并不能够模板化。
幸运的是,这种麻烦只存在于 C++11 中,从 C++14 开始,Lambda 函数的形式参数可以使用 auto
关键字来产生意义上的泛型:

1
2
3
4
5
6
auto add = [](auto x, auto y) {
return x+y;
};

add(1, 2);
add(1.1, 2.2);

3.2 函数对象包装器

这部分内容虽然属于标准库的一部分,但是从本质上来看,它却增强了 C++ 语言运行时的能力,
这部分内容也相当重要,所以放到这里来进行介绍。

std::function

Lambda 表达式的本质是一个和函数对象类型相似的类类型(称为闭包类型)的对象(称为闭包对象),
当 Lambda 表达式的捕获列表为空时,闭包对象还能够转换为函数指针值进行传递,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>

using foo = void(int); // 定义函数类型, using 的使用见上一节中的别名语法
void functional(foo f) { // 参数列表中定义的函数类型 foo 被视为退化后的函数指针类型 foo*
f(1); // 通过函数指针调用函数
}

int main() {
auto f = [](int value) {
std::cout << value << std::endl;
};
functional(f); // 传递闭包对象,隐式转换为 foo* 类型的函数指针值
f(1); // lambda 表达式调用
return 0;
}

上面的代码给出了两种不同的调用形式,一种是将 Lambda 作为函数类型传递进行调用,
而另一种则是直接调用 Lambda 表达式,在 C++11 中,统一了这些概念,将能够被调用的对象的类型,
统一称之为可调用类型。而这种类型,便是通过 std::function 引入的。

C++11 std::function 是一种通用、多态的函数封装,
它的实例可以对任何可以调用的目标实体进行存储、复制和调用操作,
它也是对 C++ 中现有的可调用实体的一种类型安全的包裹(相对来说,函数指针的调用不是类型安全的),
换句话说,就是函数的容器。当我们有了函数的容器之后便能够更加方便的将函数、函数指针作为对象进行处理。
例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <functional>
#include <iostream>

int foo(int para) {
return para;
}

int main() {
// std::function 包装了一个返回值为 int, 参数为 int 的函数
std::function<int(int)> func = foo;

int important = 10;
std::function<int(int)> func2 = [&](int value) -> int {
return 1+value+important;
};
std::cout << func(10) << std::endl;
std::cout << func2(10) << std::endl;
}

std::bindstd::placeholder

std::bind 则是用来绑定函数调用的参数的,
它解决的需求是我们有时候可能并不一定能够一次性获得调用某个函数的全部参数,通过这个函数,
我们可以将部分调用参数提前绑定到函数身上成为一个新的对象,然后在参数齐全后,完成调用。
例如:

1
2
3
4
5
6
7
8
9
10
int foo(int a, int b, int c) {
;
}
int main() {
// 将参数1,2绑定到函数 foo 上,
// 但使用 std::placeholders::_1 来对第一个参数进行占位
auto bindFoo = std::bind(foo, std::placeholders::_1, 1,2);
// 这时调用 bindFoo 时,只需要提供第一个参数即可
bindFoo(1);
}

**提示:**注意 auto 关键字的妙用。有时候我们可能不太熟悉一个函数的返回值类型,
但是我们却可以通过 auto 的使用来规避这一问题的出现。

3.3 右值引用

右值引用是 C++11 引入的与 Lambda 表达式齐名的重要特性之一。它的引入解决了 C++ 中大量的历史遗留问题,
消除了诸如 std::vectorstd::string 之类的额外开销,
也才使得函数对象容器 std::function 成为了可能。

左值、右值的纯右值、将亡值、右值

要弄明白右值引用到底是怎么一回事,必须要对左值和右值做一个明确的理解。

左值 (lvalue, left value),顾名思义就是赋值符号左边的值。准确来说,
左值是表达式(不一定是赋值表达式)后依然存在的持久对象。

右值 (rvalue, right value),右边的值,是指表达式结束后就不再存在的临时对象。

而 C++11 中为了引入强大的右值引用,将右值的概念进行了进一步的划分,分为:纯右值、将亡值。

纯右值 (prvalue, pure rvalue),纯粹的右值,要么是纯粹的字面量,例如 10, true
要么是求值结果相当于字面量或匿名临时对象,例如 1+2。非引用返回的临时变量、运算表达式产生的临时变量、
原始字面量、Lambda 表达式都属于纯右值。

需要注意的是,字面量除了字符串字面量以外,均为纯右值。而字符串字面量是一个左值,类型为 const char 数组。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <type_traits>

int main() {
// 正确,"01234" 类型为 const char [6],因此是左值
const char (&left)[6] = "01234";

// 断言正确,确实是 const char [6] 类型,注意 decltype(expr) 在 expr 是左值
// 且非无括号包裹的 id 表达式与类成员表达式时,会返回左值引用
static_assert(std::is_same<decltype("01234"), const char(&)[6]>::value, "");

// 错误,"01234" 是左值,不可被右值引用
// const char (&&right)[6] = "01234";
}

但是注意,数组可以被隐式转换成相对应的指针类型,而转换表达式的结果(如果不是左值引用)则一定是个右值(右值引用为将亡值,否则为纯右值)。例如:

1
2
3
const char*   p   = "01234";  // 正确,"01234" 被隐式转换为 const char*
const char*&& pr = "01234"; // 正确,"01234" 被隐式转换为 const char*,该转换的结果是纯右值
// const char*& pl = "01234"; // 错误,此处不存在 const char* 类型的左值

将亡值(xvalue, expiring value),是 C++11 为了引入右值引用而提出的概念(因此在传统 C++ 中,
纯右值和右值是同一个概念),也就是即将被销毁、却能够被移动的值。

将亡值可能稍有些难以理解,我们来看这样的代码:

1
2
3
4
5
6
std::vector<int> foo() {
std::vector<int> temp = {1, 2, 3, 4};
return temp;
}

std::vector<int> v = foo();

在这样的代码中,就传统的理解而言,函数 foo 的返回值 temp 在内部创建然后被赋值给 v
然而 v 获得这个对象时,会将整个 temp 拷贝一份,然后把 temp 销毁,如果这个 temp 非常大,
这将造成大量额外的开销(这也就是传统 C++ 一直被诟病的问题)。在最后一行中,v 是左值、
foo() 返回的值就是右值(也是纯右值)。但是,v 可以被别的变量捕获到,
foo() 产生的那个返回值作为一个临时值,一旦被 v 复制后,将立即被销毁,无法获取、也不能修改。
而将亡值就定义了这样一种行为:临时的值能够被识别、同时又能够被移动。

在 C++11 之后,编译器为我们做了一些工作,此处的左值 temp 会被进行此隐式右值转换,
等价于 static_cast<std::vector<int> &&>(temp),进而此处的 v 会将 foo 局部返回的值进行移动。
也就是后面我们将会提到的移动语义。

右值引用和左值引用

要拿到一个将亡值,就需要用到右值引用:T &&,其中 T 是类型。
右值引用的声明让这个临时值的生命周期得以延长、只要变量还活着,那么将亡值将继续存活。

C++11 提供了 std::move 这个方法将左值参数无条件的转换为右值,
有了它我们就能够方便的获得一个右值临时对象,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>
#include <string>

void reference(std::string& str) {
std::cout << "左值" << std::endl;
}
void reference(std::string&& str) {
std::cout << "右值" << std::endl;
}

int main()
{
std::string lv1 = "string,"; // lv1 是一个左值
// std::string&& r1 = lv1; // 非法, 右值引用不能引用左值
std::string&& rv1 = std::move(lv1); // 合法, std::move可以将左值转移为右值
std::cout << rv1 << std::endl; // string,

const std::string& lv2 = lv1 + lv1; // 合法, 常量左值引用能够延长临时变量的生命周期
// lv2 += "Test"; // 非法, 常量引用无法被修改
std::cout << lv2 << std::endl; // string,string,

std::string&& rv2 = lv1 + lv2; // 合法, 右值引用延长临时对象生命周期
rv2 += "Test"; // 合法, 非常量引用能够修改临时变量
std::cout << rv2 << std::endl; // string,string,string,Test

reference(rv2); // 输出左值

return 0;
}

rv2 虽然引用了一个右值,但由于它是一个引用,所以 rv2 依然是一个左值。

注意,这里有一个很有趣的历史遗留问题,我们先看下面的代码:

1
2
3
4
5
6
7
8
#include <iostream>

int main() {
// int &a = std::move(1); // 不合法,非常量左引用无法引用右值
const int &b = std::move(1); // 合法, 常量左引用允许引用右值

std::cout << a << b << std::endl;
}

第一个问题,为什么不允许非常量引用绑定到非左值?这是因为这种做法存在逻辑错误:

1
2
3
4
5
6
7
void increase(int & v) {
v++;
}
void foo() {
double s = 1;
increase(s);
}

由于 int& 不能引用 double 类型的参数,因此必须产生一个临时值来保存 s 的值,
从而当 increase() 修改这个临时值时,调用完成后 s 本身并没有被修改。

第二个问题,为什么常量引用允许绑定到非左值?原因很简单,因为 Fortran 需要。

移动语义

传统 C++ 通过拷贝构造函数和赋值操作符为类对象设计了拷贝/复制的概念,但为了实现对资源的移动操作,
调用者必须使用先复制、再析构的方式,否则就需要自己实现移动对象的接口。
试想,搬家的时候是把家里的东西直接搬到新家去,而不是将所有东西复制一份(重买)再放到新家、
再把原来的东西全部扔掉(销毁),这是非常反人类的一件事情。

传统的 C++ 没有区分『移动』和『拷贝』的概念,造成了大量的数据拷贝,浪费时间和空间。
右值引用的出现恰好就解决了这两个概念的混淆问题,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>
class A {
public:
int *pointer;
A():pointer(new int(1)) {
std::cout << "构造" << pointer << std::endl;
}
A(A& a):pointer(new int(*a.pointer)) {
std::cout << "拷贝" << pointer << std::endl;
} // 无意义的对象拷贝
A(A&& a):pointer(a.pointer) {
a.pointer = nullptr;
std::cout << "移动" << pointer << std::endl;
}
~A(){
std::cout << "析构" << pointer << std::endl;
delete pointer;
}
};
// 防止编译器优化
A return_rvalue(bool test) {
A a,b;
if(test) return a; // 等价于 static_cast<A&&>(a);
else return b; // 等价于 static_cast<A&&>(b);
}
int main() {
A obj = return_rvalue(false);
std::cout << "obj:" << std::endl;
std::cout << obj.pointer << std::endl;
std::cout << *obj.pointer << std::endl;
return 0;
}

在上面的代码中:

  1. 首先会在 return_rvalue 内部构造两个 A 对象,于是获得两个构造函数的输出;
  2. 函数返回后,产生一个将亡值,被 A 的移动构造(A(A&&))引用,从而延长生命周期,并将这个右值中的指针拿到,保存到了 obj 中,而将亡值的指针被设置为 nullptr,防止了这块内存区域被销毁。

从而避免了无意义的拷贝构造,加强了性能。再来看看涉及标准库的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream> // std::cout
#include <utility> // std::move
#include <vector> // std::vector
#include <string> // std::string

int main() {

std::string str = "Hello world.";
std::vector<std::string> v;

// 将使用 push_back(const T&), 即产生拷贝行为
v.push_back(str);
// 将输出 "str: Hello world."
std::cout << "str: " << str << std::endl;

// 将使用 push_back(const T&&), 不会出现拷贝行为
// 而整个字符串会被移动到 vector 中,所以有时候 std::move 会用来减少拷贝出现的开销
// 这步操作后, str 中的值会变为空
v.push_back(std::move(str));
// 将输出 "str: "
std::cout << "str: " << str << std::endl;

return 0;
}

完美转发

前面我们提到了,一个声明的右值引用其实是一个左值。这就为我们进行参数转发(传递)造成了问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void reference(int& v) {
std::cout << "左值" << std::endl;
}
void reference(int&& v) {
std::cout << "右值" << std::endl;
}
template <typename T>
void pass(T&& v) {
std::cout << "普通传参:";
reference(v); // 始终调用 reference(int&)
}
int main() {
std::cout << "传递右值:" << std::endl;
pass(1); // 1是右值, 但输出是左值

std::cout << "传递左值:" << std::endl;
int l = 1;
pass(l); // l 是左值, 输出左值

return 0;
}

对于 pass(1) 来说,虽然传递的是右值,但由于 v 是一个引用,所以同时也是左值。
因此 reference(v) 会调用 reference(int&),输出『左值』。
而对于pass(l)而言,l是一个左值,为什么会成功传递给 pass(T&&) 呢?

这是基于引用坍缩规则的:在传统 C++ 中,我们不能够对一个引用类型继续进行引用,
但 C++ 由于右值引用的出现而放宽了这一做法,从而产生了引用坍缩规则,允许我们对引用进行引用,
既能左引用,又能右引用。但是却遵循如下规则:

函数形参类型 实参参数类型 推导后函数形参类型
T& 左引用 T&
T& 右引用 T&
T&& 左引用 T&
T&& 右引用 T&&

因此,模板函数中使用 T&& 不一定能进行右值引用,当传入左值时,此函数的引用将被推导为左值。
更准确的讲,无论模板参数是什么类型的引用,当且仅当实参类型为右引用时,模板参数才能被推导为右引用类型
这才使得 v 作为左值的成功传递。

完美转发就是基于上述规律产生的。所谓完美转发,就是为了让我们在传递参数的时候,
保持原来的参数类型(左引用保持左引用,右引用保持右引用)。
为了解决这个问题,我们应该使用 std::forward 来进行参数的转发(传递):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>
#include <utility>
void reference(int& v) {
std::cout << "左值引用" << std::endl;
}
void reference(int&& v) {
std::cout << "右值引用" << std::endl;
}
template <typename T>
void pass(T&& v) {
std::cout << " 普通传参: ";
reference(v);
std::cout << " std::move 传参: ";
reference(std::move(v));
std::cout << " std::forward 传参: ";
reference(std::forward<T>(v));
std::cout << "static_cast<T&&> 传参: ";
reference(static_cast<T&&>(v));
}
int main() {
std::cout << "传递右值:" << std::endl;
pass(1);

std::cout << "传递左值:" << std::endl;
int v = 1;
pass(v);

return 0;
}

输出结果为:

1
2
3
4
5
6
7
8
9
10
传递右值:
普通传参: 左值引用
std::move 传参: 右值引用
std::forward 传参: 右值引用
static_cast<T&&> 传参: 右值引用
传递左值:
普通传参: 左值引用
std::move 传参: 右值引用
std::forward 传参: 左值引用
static_cast<T&&> 传参: 左值引用

无论传递参数为左值还是右值,普通传参都会将参数作为左值进行转发,
所以 std::move 总会接受到一个左值,从而转发调用了reference(int&&) 输出右值引用。

唯独 std::forward 即没有造成任何多余的拷贝,同时完美转发(传递)了函数的实参给了内部调用的其他函数。

std::forwardstd::move 一样,没有做任何事情,std::move 单纯的将左值转化为右值,
std::forward 也只是单纯的将参数做了一个类型的转换,从现象上来看,
std::forward<T>(v)static_cast<T&&>(v) 是完全一样的。

读者可能会好奇,为何一条语句能够针对两种类型的返回对应的值,
我们再简单看一看 std::forward 的具体实现机制,std::forward 包含两个重载:

1
2
3
4
5
6
7
8
9
10
11
template<typename _Tp>
constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type& __t) noexcept
{ return static_cast<_Tp&&>(__t); }

template<typename _Tp>
constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type&& __t) noexcept
{
static_assert(!std::is_lvalue_reference<_Tp>::value, "template argument"
" substituting _Tp is an lvalue reference type");
return static_cast<_Tp&&>(__t);
}

在这份实现中,std::remove_reference 的功能是消除类型中的引用,
std::is_lvalue_reference 则用于检查类型推导是否正确,在 std::forward 的第二个实现中
检查了接收到的值确实是一个左值,进而体现了坍缩规则。

std::forward 接受左值时,_Tp 被推导为左值,所以返回值为左值;而当其接受右值时,
_Tp 被推导为 右值引用,则基于坍缩规则,返回值便成为了 && + && 的右值。
可见 std::forward 的原理在于巧妙的利用了模板类型推导中产生的差异。

这时我们能回答这样一个问题:为什么在使用循环语句的过程中,auto&& 是最安全的方式?
因为当 auto 被推导为不同的左右引用时,与 && 的坍缩组合是完美转发。

总结

本章介绍了现代 C++ 中最为重要的几个语言运行时的增强,其中笔者认为本节中提到的所有特性都是值得掌握的:

  1. Lambda 表达式
  2. 函数对象容器 std::function
  3. 右值引用

第 4 章 容器

4.1 线性容器

std::array

看到这个容器的时候肯定会出现这样的问题:

  1. 为什么要引入 std::array 而不是直接使用 std::vector
  2. 已经有了传统数组,为什么要用 std::array?

先回答第一个问题,与 std::vector 不同,std::array 对象的大小是固定的,如果容器大小是固定的,那么可以优先考虑使用 std::array 容器。
另外由于 std::vector 是自动扩容的,当存入大量的数据后,并且对容器进行了删除操作,
容器并不会自动归还被删除元素相应的内存,这时候就需要手动运行 shrink_to_fit() 释放这部分内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
std::vector<int> v;
std::cout << "size:" << v.size() << std::endl; // 输出 0
std::cout << "capacity:" << v.capacity() << std::endl; // 输出 0

// 如下可看出 std::vector 的存储是自动管理的,按需自动扩张
// 但是如果空间不足,需要重新分配更多内存,而重分配内存通常是性能上有开销的操作
v.push_back(1);
v.push_back(2);
v.push_back(3);
std::cout << "size:" << v.size() << std::endl; // 输出 3
std::cout << "capacity:" << v.capacity() << std::endl; // 输出 4

// 这里的自动扩张逻辑与 Golang 的 slice 很像
v.push_back(4);
v.push_back(5);
std::cout << "size:" << v.size() << std::endl; // 输出 5
std::cout << "capacity:" << v.capacity() << std::endl; // 输出 8

// 如下可看出容器虽然清空了元素,但是被清空元素的内存并没有归还
v.clear();
std::cout << "size:" << v.size() << std::endl; // 输出 0
std::cout << "capacity:" << v.capacity() << std::endl; // 输出 8

// 额外内存可通过 shrink_to_fit() 调用返回给系统
v.shrink_to_fit();
std::cout << "size:" << v.size() << std::endl; // 输出 0
std::cout << "capacity:" << v.capacity() << std::endl; // 输出 0

而第二个问题就更加简单,使用 std::array 能够让代码变得更加“现代化”,而且封装了一些操作函数,比如获取数组大小以及检查是否非空,同时还能够友好的使用标准库中的容器算法,比如 std::sort

使用 std::array 很简单,只需指定其类型和大小即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
std::array<int, 4> arr = {1, 2, 3, 4};

arr.empty(); // 检查容器是否为空
arr.size(); // 返回容纳的元素数

// 迭代器支持
for (auto &i : arr)
{
// ...
}

// 用 lambda 表达式排序
std::sort(arr.begin(), arr.end(), [](int a, int b) {
return b < a;
});

// 数组大小参数必须是常量表达式
constexpr int len = 4;
std::array<int, len> arr = {1, 2, 3, 4};

// 非法,不同于 C 风格数组,std::array 不会自动退化成 T*
// int *arr_p = arr;

当我们开始用上了 std::array 时,难免会遇到要将其兼容 C 风格的接口,这里有三种做法:

1
2
3
4
5
6
7
8
9
10
11
12
13
void foo(int *p, int len) {
return;
}

std::array<int, 4> arr = {1,2,3,4};

// C 风格接口传参
// foo(arr, arr.size()); // 非法, 无法隐式转换
foo(&arr[0], arr.size());
foo(arr.data(), arr.size());

// 使用 `std::sort`
std::sort(arr.begin(), arr.end());

std::forward_list

std::forward_list 是一个列表容器,使用方法和 std::list 基本类似,因此我们就不花费篇幅进行介绍了。

需要知道的是,和 std::list 的双向链表的实现不同,std::forward_list 使用单向链表进行实现,
提供了 O(1) 复杂度的元素插入,不支持快速随机访问(这也是链表的特点),
也是标准库容器中唯一一个不提供 size() 方法的容器。当不需要双向迭代时,具有比 std::list 更高的空间利用率。

4.2 无序容器

我们已经熟知了传统 C++ 中的有序容器 std::map/std::set,这些元素内部通过红黑树进行实现,
插入和搜索的平均复杂度均为 O(log(size))。在插入元素时候,会根据 < 操作符比较元素大小并判断元素是否相同,
并选择合适的位置插入到容器中。当对这个容器中的元素进行遍历时,输出结果会按照 < 操作符的顺序来逐个遍历。

而无序容器中的元素是不进行排序的,内部通过 Hash 表实现,插入和搜索元素的平均复杂度为 O(constant)
在不关心容器内部元素顺序时,能够获得显著的性能提升。

C++11 引入了的两组无序容器分别是:std::unordered_map/std::unordered_multimap
std::unordered_set/std::unordered_multiset

它们的用法和原有的 std::map/std::multimap/std::set/set::multiset 基本类似,
由于这些容器我们已经很熟悉了,便不一一举例,我们直接来比较一下std::mapstd::unordered_map

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
#include <string>
#include <unordered_map>
#include <map>

int main() {
// 两组结构按同样的顺序初始化
std::unordered_map<int, std::string> u = {
{1, "1"},
{3, "3"},
{2, "2"}
};
std::map<int, std::string> v = {
{1, "1"},
{3, "3"},
{2, "2"}
};

// 分别对两组结构进行遍历
std::cout << "std::unordered_map" << std::endl;
for( const auto & n : u)
std::cout << "Key:[" << n.first << "] Value:[" << n.second << "]\n";

std::cout << std::endl;
std::cout << "std::map" << std::endl;
for( const auto & n : v)
std::cout << "Key:[" << n.first << "] Value:[" << n.second << "]\n";
}

最终的输出结果为:

1
2
3
4
5
6
7
8
9
std::unordered_map
Key:[2] Value:[2]
Key:[3] Value:[3]
Key:[1] Value:[1]

std::map
Key:[1] Value:[1]
Key:[2] Value:[2]
Key:[3] Value:[3]

4.3 元组

了解过 Python 的程序员应该知道元组的概念,纵观传统 C++ 中的容器,除了 std::pair 外,
似乎没有现成的结构能够用来存放不同类型的数据(通常我们会自己定义结构)。
std::pair 的缺陷是显而易见的,只能保存两个元素。

元组基本操作

关于元组的使用有三个核心的函数:

  1. std::make_tuple: 构造元组
  2. std::get: 获得元组某个位置的值
  3. std::tie: 元组拆包
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <tuple>
#include <iostream>

auto get_student(int id)
{
// 返回类型被推断为 std::tuple<double, char, std::string>

if (id == 0)
return std::make_tuple(3.8, 'A', "张三");
if (id == 1)
return std::make_tuple(2.9, 'C', "李四");
if (id == 2)
return std::make_tuple(1.7, 'D', "王五");
return std::make_tuple(0.0, 'D', "null");
// 如果只写 0 会出现推断错误, 编译失败
}

int main()
{
auto student = get_student(0);
std::cout << "ID: 0, "
<< "GPA: " << std::get<0>(student) << ", "
<< "成绩: " << std::get<1>(student) << ", "
<< "姓名: " << std::get<2>(student) << '\n';

double gpa;
char grade;
std::string name;

// 元组进行拆包
std::tie(gpa, grade, name) = get_student(1);
std::cout << "ID: 1, "
<< "GPA: " << gpa << ", "
<< "成绩: " << grade << ", "
<< "姓名: " << name << '\n';
}

std::get 除了使用常量获取元组对象外,C++14 增加了使用类型来获取元组中的对象:

1
2
3
4
std::tuple<std::string, double, double, int> t("123", 4.5, 6.7, 8);
std::cout << std::get<std::string>(t) << std::endl;
std::cout << std::get<double>(t) << std::endl; // 非法, 引发编译期错误
std::cout << std::get<3>(t) << std::endl;

运行期索引

如果你仔细思考一下可能就会发现上面代码的问题,std::get<> 依赖一个编译期的常量,所以下面的方式是不合法的:

1
2
int index = 1;
std::get<index>(t);

那么要怎么处理?答案是,使用 std::variant<>(C++ 17 引入),提供给 variant<> 的类型模板参数
可以让一个 variant<> 从而容纳提供的几种类型的变量(在其他语言,例如 Python/JavaScript 等,表现为动态类型):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <variant>
template <size_t n, typename... T>
constexpr std::variant<T...> _tuple_index(const std::tuple<T...>& tpl, size_t i) {
if constexpr (n >= sizeof...(T))
throw std::out_of_range("越界.");
if (i == n)
return std::variant<T...>{ std::in_place_index<n>, std::get<n>(tpl) };
return _tuple_index<(n < sizeof...(T)-1 ? n+1 : 0)>(tpl, i);
}
template <typename... T>
constexpr std::variant<T...> tuple_index(const std::tuple<T...>& tpl, size_t i) {
return _tuple_index<0>(tpl, i);
}
template <typename T0, typename ... Ts>
std::ostream & operator<< (std::ostream & s, std::variant<T0, Ts...> const & v) {
std::visit([&](auto && x){ s << x;}, v);
return s;
}

这样我们就能:

1
2
int i = 1;
std::cout << tuple_index(t, i) << std::endl;

元组合并与遍历

还有一个常见的需求就是合并两个元组,这可以通过 std::tuple_cat 来实现:

1
auto new_tuple = std::tuple_cat(get_student(1), std::move(t));

马上就能够发现,应该如何快速遍历一个元组?但是我们刚才介绍了如何在运行期通过非常数索引一个 tuple 那么遍历就变得简单了,
首先我们需要知道一个元组的长度,可以:

1
2
3
4
template <typename T>
auto tuple_len(T &tpl) {
return std::tuple_size<T>::value;
}

这样就能够对元组进行迭代了:

1
2
3
4
// 迭代
for(int i = 0; i != tuple_len(new_tuple); ++i)
// 运行期索引
std::cout << tuple_index(new_tuple, i) << std::endl;

总结

本章简单介绍了现代 C++ 中新增的容器,它们的用法和传统 C++ 中已有的容器类似,相对简单,可以根据实际场景丰富的选择需要使用的容器,从而获得更好的性能。

std::tuple 虽然有效,但是标准库提供的功能有限,没办法满足运行期索引和迭代的需求,好在我们还有其他的方法可以自行实现。

第 5 章 智能指针与内存管理

5.1 RAII 与引用计数

了解 Objective-C/Swift 的程序员应该知道引用计数的概念。引用计数这种计数是为了防止内存泄露而产生的。
基本想法是对于动态分配的对象,进行引用计数,每当增加一次对同一个对象的引用,那么引用对象的引用计数就会增加一次,
每删除一次引用,引用计数就会减一,当一个对象的引用计数减为零时,就自动删除指向的堆内存。

在传统 C++ 中,『记得』手动释放资源,总不是最佳实践。因为我们很有可能就忘记了去释放资源而导致泄露。
所以通常的做法是对于一个对象而言,我们在构造函数的时候申请空间,而在析构函数(在离开作用域时调用)的时候释放空间,
也就是我们常说的 RAII 资源获取即初始化技术。

凡事都有例外,我们总会有需要将对象在自由存储上分配的需求,在传统 C++ 里我们只好使用 newdelete
『记得』对资源进行释放。而 C++11 引入了智能指针的概念,使用了引用计数的想法,让程序员不再需要关心手动释放内存。
这些智能指针包括 std::shared_ptr/std::unique_ptr/std::weak_ptr,使用它们需要包含头文件 <memory>

注意:引用计数不是垃圾回收,引用计数能够尽快收回不再被使用的对象,同时在回收的过程中也不会造成长时间的等待,
更能够清晰明确的表明资源的生命周期。

5.2 std::shared_ptr

std::shared_ptr 是一种智能指针,它能够记录多少个 shared_ptr 共同指向一个对象,从而消除显式的调用
delete,当引用计数变为零的时候就会将对象自动删除。

但还不够,因为使用 std::shared_ptr 仍然需要使用 new 来调用,这使得代码出现了某种程度上的不对称。

std::make_shared 就能够用来消除显式的使用 new,所以std::make_shared 会分配创建传入参数中的对象,
并返回这个对象类型的std::shared_ptr指针。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
#include <memory>
void foo(std::shared_ptr<int> i) {
(*i)++;
}
int main() {
// auto pointer = new int(10); // illegal, no direct assignment
// Constructed a std::shared_ptr
auto pointer = std::make_shared<int>(10);
foo(pointer);
std::cout << *pointer << std::endl; // 11
// The shared_ptr will be destructed before leaving the scope
return 0;
}

std::shared_ptr 可以通过 get() 方法来获取原始指针,通过 reset() 来减少一个引用计数,
并通过use_count()来查看一个对象的引用计数。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
auto pointer = std::make_shared<int>(10);
auto pointer2 = pointer; // 引用计数+1
auto pointer3 = pointer; // 引用计数+1
int *p = pointer.get(); // 这样不会增加引用计数
std::cout << "pointer.use_count() = " << pointer.use_count() << std::endl; // 3
std::cout << "pointer2.use_count() = " << pointer2.use_count() << std::endl; // 3
std::cout << "pointer3.use_count() = " << pointer3.use_count() << std::endl; // 3

pointer2.reset();
std::cout << "reset pointer2:" << std::endl;
std::cout << "pointer.use_count() = " << pointer.use_count() << std::endl; // 2
std::cout << "pointer2.use_count() = "
<< pointer2.use_count() << std::endl; // pointer2 已 reset; 0
std::cout << "pointer3.use_count() = " << pointer3.use_count() << std::endl; // 2
pointer3.reset();
std::cout << "reset pointer3:" << std::endl;
std::cout << "pointer.use_count() = " << pointer.use_count() << std::endl; // 1
std::cout << "pointer2.use_count() = " << pointer2.use_count() << std::endl; // 0
std::cout << "pointer3.use_count() = "
<< pointer3.use_count() << std::endl; // pointer3 已 reset; 0

5.3 std::unique_ptr

std::unique_ptr 是一种独占的智能指针,它禁止其他智能指针与其共享同一个对象,从而保证代码的安全:

1
2
std::unique_ptr<int> pointer = std::make_unique<int>(10); // make_unique 从 C++14 引入
std::unique_ptr<int> pointer2 = pointer; // 非法

make_unique 并不复杂,C++11 没有提供 std::make_unique,可以自行实现:

1
2
3
4
template<typename T, typename ...Args>
std::unique_ptr<T> make_unique( Args&& ...args ) {
return std::unique_ptr<T>( new T( std::forward<Args>(args)... ) );
}

至于为什么没有提供,C++ 标准委员会主席 Herb Sutter 在他的博客中提到原因是因为『被他们忘记了』。

既然是独占,换句话说就是不可复制。但是,我们可以利用 std::move 将其转移给其他的 unique_ptr,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>
#include <memory>

struct Foo {
Foo() { std::cout << "Foo::Foo" << std::endl; }
~Foo() { std::cout << "Foo::~Foo" << std::endl; }
void foo() { std::cout << "Foo::foo" << std::endl; }
};

void f(const Foo &) {
std::cout << "f(const Foo&)" << std::endl;
}

int main() {
std::unique_ptr<Foo> p1(std::make_unique<Foo>());
// p1 不空, 输出
if (p1) p1->foo();
{
std::unique_ptr<Foo> p2(std::move(p1));
// p2 不空, 输出
f(*p2);
// p2 不空, 输出
if(p2) p2->foo();
// p1 为空, 无输出
if(p1) p1->foo();
p1 = std::move(p2);
// p2 为空, 无输出
if(p2) p2->foo();
std::cout << "p2 被销毁" << std::endl;
}
// p1 不空, 输出
if (p1) p1->foo();
// Foo 的实例会在离开作用域时被销毁
}

5.4 std::weak_ptr

如果你仔细思考 std::shared_ptr 就会发现依然存在着资源无法释放的问题。看下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct A;
struct B;

struct A {
std::shared_ptr<B> pointer;
~A() {
std::cout << "A 被销毁" << std::endl;
}
};
struct B {
std::shared_ptr<A> pointer;
~B() {
std::cout << "B 被销毁" << std::endl;
}
};
int main() {
auto a = std::make_shared<A>();
auto b = std::make_shared<B>();
a->pointer = b;
b->pointer = a;
}

运行结果是 A, B 都不会被销毁,这是因为 a,b 内部的 pointer 同时又引用了 a,b,这使得 a,b 的引用计数均变为了 2,而离开作用域时,a,b 智能指针被析构,却只能造成这块区域的引用计数减一,这样就导致了 a,b 对象指向的内存区域引用计数不为零,而外部已经没有办法找到这块区域了,也就造成了内存泄露,如图 5.1:

图 5.1

解决这个问题的办法就是使用弱引用指针 std::weak_ptrstd::weak_ptr是一种弱引用(相比较而言 std::shared_ptr 就是一种强引用)。弱引用不会引起引用计数增加,当换用弱引用时候,最终的释放流程如图 5.2 所示:

图 5.2

在上图中,最后一步只剩下 B,而 B 并没有任何智能指针引用它,因此这块内存资源也会被释放。

std::weak_ptr 没有 * 运算符和 -> 运算符,所以不能够对资源进行操作,它可以用于检查 std::shared_ptr 是否存在,其 expired() 方法能在资源未被释放时,会返回 false,否则返回 true;除此之外,它也可以用于获取指向原始对象的 std::shared_ptr 指针,其 lock() 方法在原始对象未被释放时,返回一个指向原始对象的 std::shared_ptr 指针,进而访问原始对象的资源,否则返回nullptr

总结

智能指针这种技术并不新奇,在很多语言中都是一种常见的技术,现代 C++ 将这项技术引进,在一定程度上消除了 new/delete 的滥用,是一种更加成熟的编程范式。

第 6 章 正则表达式

6.1 正则表达式简介

正则表达式不是 C++ 语言的一部分,这里仅做简单的介绍。

正则表达式描述了一种字符串匹配的模式。一般使用正则表达式主要是实现下面三个需求:

  1. 检查一个串是否包含某种形式的子串;
  2. 将匹配的子串替换;
  3. 从某个串中取出符合条件的子串。

正则表达式是由普通字符(例如 a 到 z)以及特殊字符组成的文字模式。模式描述在搜索文本时要匹配的一个或多个字符串。
正则表达式作为一个模板,将某个字符模式与所搜索的字符串进行匹配。

普通字符

普通字符包括没有显式指定为元字符的所有可打印和不可打印字符。这包括所有大写和小写字母、所有数字、所有标点符号和一些其他符号。

特殊字符

特殊字符是正则表达式里有特殊含义的字符,也是正则表达式的核心匹配语法。参见下表:

特别字符 描述
$ 匹配输入字符串的结尾位置。
(,) 标记一个子表达式的开始和结束位置。子表达式可以获取供以后使用。
* 匹配前面的子表达式零次或多次。
+ 匹配前面的子表达式一次或多次。
. 匹配除换行符 \n 之外的任何单字符。
[ 标记一个中括号表达式的开始。
? 匹配前面的子表达式零次或一次,或指明一个非贪婪限定符。
\ 将下一个字符标记为或特殊字符、或原义字符、或向后引用、或八进制转义符。例如, n 匹配字符 n\n 匹配换行符。序列 \\ 匹配 '\' 字符,而 \( 则匹配 '(' 字符。
^ 匹配输入字符串的开始位置,除非在方括号表达式中使用,此时它表示不接受该字符集合。
{ 标记限定符表达式的开始。
| 指明两项之间的一个选择。

限定符

限定符用来指定正则表达式的一个给定的组件必须要出现多少次才能满足匹配。见下表:

字符 描述
* 匹配前面的子表达式零次或多次。例如,foo* 能匹配 fo 以及 foooo* 等价于{0,}
+ 匹配前面的子表达式一次或多次。例如,foo+ 能匹配 foo 以及 foooo,但不能匹配 fo+ 等价于 {1,}
? 匹配前面的子表达式零次或一次。例如,Your(s)? 可以匹配 YourYours 中的Your? 等价于 {0,1}
{n} n 是一个非负整数。匹配确定的 n 次。例如,o{2} 不能匹配 for 中的 o,但是能匹配 foo 中的两个 o
{n,} n 是一个非负整数。至少匹配 n 次。例如,o{2,} 不能匹配 for 中的 o,但能匹配 foooooo 中的所有 oo{1,} 等价于 o+o{0,} 则等价于 o*
{n,m} mn 均为非负整数,其中 n 小于等于 m。最少匹配 n 次且最多匹配 m 次。例如,o{1,3} 将匹配 foooooo 中的前三个 oo{0,1} 等价于 o?。注意,在逗号和两个数之间不能有空格。

有了这两张表,我们通常就能够读懂几乎所有的正则表达式了。

6.2 std::regex 及其相关

对字符串内容进行匹配的最常见手段就是使用正则表达式。
可惜在传统 C++ 中正则表达式一直没有得到语言层面的支持,没有纳入标准库,
而 C++ 作为一门高性能语言,在后台服务的开发中,对 URL 资源链接进行判断时,
使用正则表达式也是工业界最为成熟的普遍做法。

一般的解决方案就是使用 boost 的正则表达式库。
而 C++11 正式将正则表达式的的处理方法纳入标准库的行列,从语言级上提供了标准的支持,
不再依赖第三方。

C++11 提供的正则表达式库操作 std::string 对象,
模式 std::regex (本质是 std::basic_regex)进行初始化,
通过 std::regex_match 进行匹配,
从而产生 std::smatch (本质是 std::match_results 对象)。

我们通过一个简单的例子来简单介绍这个库的使用。考虑下面的正则表达式:

  • [a-z]+\.txt: 在这个正则表达式中, [a-z] 表示匹配一个小写字母, + 可以使前面的表达式匹配多次,
    因此 [a-z]+ 能够匹配一个小写字母组成的字符串。
    在正则表达式中一个 . 表示匹配任意字符,而 \. 则表示匹配字符 .
    最后的 txt 表示严格匹配 txt 则三个字母。因此这个正则表达式的所要匹配的内容就是由纯小写字母组成的文本文件。

std::regex_match 用于匹配字符串和正则表达式,有很多不同的重载形式。
最简单的一个形式就是传入 std::string 以及一个 std::regex 进行匹配,
当匹配成功时,会返回 true,否则返回 false。例如:

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>
#include <string>
#include <regex>

int main() {
std::string fnames[] = {"foo.txt", "bar.txt", "test", "a0.txt", "AAA.txt"};
// 在 C++ 中 \ 会被作为字符串内的转义符,
// 为使 \. 作为正则表达式传递进去生效,需要对 \ 进行二次转义,从而有 \\.
std::regex txt_regex("[a-z]+\\.txt");
for (const auto &fname: fnames)
std::cout << fname << ": " << std::regex_match(fname, txt_regex) << std::endl;
}

另一种常用的形式就是依次传入 std::string/std::smatch/std::regex 三个参数,
其中 std::smatch 的本质其实是 std::match_results
故而在标准库的实现中, std::smatch 被定义为了 std::match_results<std::string::const_iterator>
也就是一个子串迭代器类型的 match_results
使用 std::smatch 可以方便的对匹配的结果进行获取,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
std::regex base_regex("([a-z]+)\\.txt");
std::smatch base_match;
for(const auto &fname: fnames) {
if (std::regex_match(fname, base_match, base_regex)) {
// std::smatch 的第一个元素匹配整个字符串
// std::smatch 的第二个元素匹配了第一个括号表达式
if (base_match.size() == 2) {
std::string base = base_match[1].str();
std::cout << "sub-match[0]: " << base_match[0].str() << std::endl;
std::cout << fname << " sub-match[1]: " << base << std::endl;
}
}
}

以上两个代码段的输出结果为:

1
2
3
4
5
6
7
8
9
foo.txt: 1
bar.txt: 1
test: 0
a0.txt: 0
AAA.txt: 0
sub-match[0]: foo.txt
foo.txt sub-match[1]: foo
sub-match[0]: bar.txt
bar.txt sub-match[1]: bar

总结

本节简单介绍了正则表达式本身,然后根据使用正则表达式的主要需求,通过一个实际的例子介绍了正则表达式库的使用。

第 7 章 并行与并发

7.1 并行基础

std::thread 用于创建一个执行的线程实例,所以它是一切并发编程的基础,使用时需要包含 <thread> 头文件,
它提供了很多基本的线程操作,例如 get_id() 来获取所创建线程的线程 ID,使用 join() 来加入一个线程等等,例如:

1
2
3
4
5
6
7
8
9
10
#include <iostream>
#include <thread>

int main() {
std::thread t([](){
std::cout << "hello world." << std::endl;
});
t.join();
return 0;
}

7.2 互斥量与临界区

我们在操作系统、亦或是数据库的相关知识中已经了解过了有关并发技术的基本知识,mutex 就是其中的核心之一。
C++11 引入了 mutex 相关的类,其所有相关的函数都放在 <mutex> 头文件中。

std::mutex 是 C++11 中最基本的 mutex 类,通过实例化 std::mutex 可以创建互斥量,
而通过其成员函数 lock() 可以进行上锁,unlock() 可以进行解锁。
但是在实际编写代码的过程中,最好不去直接调用成员函数,
因为调用成员函数就需要在每个临界区的出口处调用 unlock(),当然,还包括异常。
这时候 C++11 还为互斥量提供了一个 RAII 语法的模板类 std::lock_guard
RAII 在不失代码简洁性的同时,很好的保证了代码的异常安全性。

在 RAII 用法下,对于临界区的互斥量的创建只需要在作用域的开始部分,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>
#include <mutex>
#include <thread>

int v = 1;

void critical_section(int change_v) {
static std::mutex mtx;
std::lock_guard<std::mutex> lock(mtx);

// 执行竞争操作
v = change_v;

// 离开此作用域后 mtx 会被释放
}

int main() {
std::thread t1(critical_section, 2), t2(critical_section, 3);
t1.join();
t2.join();

std::cout << v << std::endl;
return 0;
}

由于 C++ 保证了所有栈对象在生命周期结束时会被销毁,所以这样的代码也是异常安全的。
无论 critical_section() 正常返回、还是在中途抛出异常,都会引发堆栈回退,也就自动调用了 unlock()

std::unique_lock 则是相对于 std::lock_guard 出现的,std::unique_lock 更加灵活,
std::unique_lock 的对象会以独占所有权(没有其他的 unique_lock 对象同时拥有某个 mutex 对象的所有权)
的方式管理 mutex 对象上的上锁和解锁的操作。所以在并发编程中,推荐使用 std::unique_lock

std::lock_guard 不能显式的调用 lockunlock, 而 std::unique_lock 可以在声明后的任意位置调用,
可以缩小锁的作用范围,提供更高的并发度。

如果你用到了条件变量 std::condition_variable::wait 则必须使用 std::unique_lock 作为参数。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>
#include <mutex>
#include <thread>

int v = 1;

void critical_section(int change_v) {
static std::mutex mtx;
std::unique_lock<std::mutex> lock(mtx);
// 执行竞争操作
v = change_v;
std::cout << v << std::endl;
// 将锁进行释放
lock.unlock();

// 在此期间,任何人都可以抢夺 v 的持有权

// 开始另一组竞争操作,再次加锁
lock.lock();
v += 1;
std::cout << v << std::endl;
}

int main() {
std::thread t1(critical_section, 2), t2(critical_section, 3);
t1.join();
t2.join();
return 0;
}

7.3 期物

期物(Future)表现为 std::future,它提供了一个访问异步操作结果的途径,这句话很不好理解。
为了理解这个特性,我们需要先理解一下在 C++11 之前的多线程行为。

试想,如果我们的主线程 A 希望新开辟一个线程 B 去执行某个我们预期的任务,并返回我一个结果。
而这时候,线程 A 可能正在忙其他的事情,无暇顾及 B 的结果,
所以我们会很自然的希望能够在某个特定的时间获得线程 B 的结果。

在 C++11 的 std::future 被引入之前,通常的做法是:
创建一个线程 A,在线程 A 里启动任务 B,当准备完毕后发送一个事件,并将结果保存在全局变量中。
而主函数线程 A 里正在做其他的事情,当需要结果的时候,调用一个线程等待函数来获得执行的结果。

而 C++11 提供的 std::future 简化了这个流程,可以用来获取异步任务的结果。
自然地,我们很容易能够想象到把它作为一种简单的线程同步手段,即屏障(barrier)。

为了看一个例子,我们这里额外使用 std::packaged_task,它可以用来封装任何可以调用的目标,从而用于实现异步的调用。
举例来说:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
#include <future>
#include <thread>

int main() {
// 将一个返回值为7的 lambda 表达式封装到 task 中
// std::packaged_task 的模板参数为要封装函数的类型
std::packaged_task<int()> task([](){return 7;});
// 获得 task 的期物
std::future<int> result = task.get_future(); // 在一个线程中执行 task
std::thread(std::move(task)).detach();
std::cout << "waiting...";
result.wait(); // 在此设置屏障,阻塞到期物的完成
// 输出执行结果
std::cout << "done!" << std:: endl << "future result is "
<< result.get() << std::endl;
return 0;
}

在封装好要调用的目标后,可以使用 get_future() 来获得一个 std::future 对象,以便之后实施线程同步。

7.4 条件变量

条件变量 std::condition_variable 是为了解决死锁而生,当互斥操作不够用而引入的。
比如,线程可能需要等待某个条件为真才能继续执行,
而一个忙等待循环中可能会导致所有其他线程都无法进入临界区使得条件为真时,就会发生死锁。
所以,condition_variable 实例被创建出现主要就是用于唤醒等待线程从而避免死锁。
std::condition_variablenotify_one() 用于唤醒一个线程;
notify_all() 则是通知所有线程。下面是一个生产者和消费者模型的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#include <queue>
#include <chrono>
#include <mutex>
#include <thread>
#include <iostream>
#include <condition_variable>


int main() {
std::queue<int> produced_nums;
std::mutex mtx;
std::condition_variable cv;
bool notified = false; // 通知信号

// 生产者
auto producer = [&]() {
for (int i = 0; ; i++) {
std::this_thread::sleep_for(std::chrono::milliseconds(900));
std::unique_lock<std::mutex> lock(mtx);
std::cout << "producing " << i << std::endl;
produced_nums.push(i);
notified = true;
cv.notify_all(); // 此处也可以使用 notify_one
}
};
// 消费者
auto consumer = [&]() {
while (true) {
std::unique_lock<std::mutex> lock(mtx);
while (!notified) { // 避免虚假唤醒
cv.wait(lock);
}
// 短暂取消锁,使得生产者有机会在消费者消费空前继续生产
lock.unlock();
// 消费者慢于生产者
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
lock.lock();
while (!produced_nums.empty()) {
std::cout << "consuming " << produced_nums.front() << std::endl;
produced_nums.pop();
}
notified = false;
}
};

// 分别在不同的线程中运行
std::thread p(producer);
std::thread cs[2];
for (int i = 0; i < 2; ++i) {
cs[i] = std::thread(consumer);
}
p.join();
for (int i = 0; i < 2; ++i) {
cs[i].join();
}
return 0;
}

值得一提的是,在生产者中我们虽然可以使用 notify_one(),但实际上并不建议在此处使用,
因为在多消费者的情况下,我们的消费者实现中简单放弃了锁的持有,这使得可能让其他消费者
争夺此锁,从而更好的利用多个消费者之间的并发。话虽如此,但实际上因为 std::mutex 的排他性,
我们根本无法期待多个消费者能真正意义上的并行消费队列的中生产的内容,我们仍需要粒度更细的手段。

7.5 原子操作与内存模型

细心的读者可能会对前一小节中生产者消费者模型的例子可能存在编译器优化导致程序出错的情况产生疑惑。
例如,布尔值 notified 没有被 volatile 修饰,编译器可能对此变量存在优化,例如将其作为一个寄存器的值,
从而导致消费者线程永远无法观察到此值的变化。这是一个好问题,为了解释清楚这个问题,我们需要进一步讨论
从 C++ 11 起引入的内存模型这一概念。我们首先来看一个问题,下面这段代码输出结果是多少?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <thread>
#include <iostream>

int main() {
int a = 0;
int flag = 0;

std::thread t1([&]() {
while (flag != 1);

int b = a;
std::cout << "b = " << b << std::endl;
});

std::thread t2([&]() {
a = 5;
flag = 1;
});

t1.join();
t2.join();
return 0;
}

从直观上看,t2a = 5; 这一条语句似乎总在 flag = 1; 之前得到执行,而 t1while (flag != 1)
似乎保证了 std::cout << "b = " << b << std::endl; 不会再标记被改变前执行。从逻辑上看,似乎 b 的值应该等于 5。
但实际情况远比此复杂得多,或者说这段代码本身属于未定义的行为,因为对于 aflag 而言,他们在两个并行的线程中被读写,
出现了竞争。除此之外,即便我们忽略竞争读写,仍然可能受 CPU 的乱序执行,编译器对指令的重排的影响,
导致 a = 5 发生在 flag = 1 之后。从而 b 可能输出 0。

原子操作

std::mutex 可以解决上面出现的并发读写的问题,但互斥锁是操作系统级的功能,
这是因为一个互斥锁的实现通常包含两条基本原理:

  1. 提供线程间自动的状态转换,即『锁住』这个状态
  2. 保障在互斥锁操作期间,所操作变量的内存与临界区外进行隔离

这是一组非常强的同步条件,换句话说当最终编译为 CPU 指令时会表现为非常多的指令(我们之后再来看如何实现一个简单的互斥锁)。
这对于一个仅需原子级操作(没有中间态)的变量,似乎太苛刻了。

关于同步条件的研究有着非常久远的历史,我们在这里不进行赘述。读者应该明白,现代 CPU 体系结构提供了 CPU 指令级的原子操作,
因此在 C++11 中多线程下共享变量的读写这一问题上,还引入了 std::atomic 模板,使得我们实例化一个原子类型,将一个
原子类型读写操作从一组指令,最小化到单个 CPU 指令。例如:

1
std::atomic<int> counter;

并为整数或浮点数的原子类型提供了基本的数值成员函数,举例来说,
包括 fetch_add, fetch_sub 等,同时通过重载方便的提供了对应的 +- 版本。
比如下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <atomic>
#include <thread>
#include <iostream>

std::atomic<int> count = {0};

int main() {
std::thread t1([](){
count.fetch_add(1);
});
std::thread t2([](){
count++; // 等价于 fetch_add
count += 1; // 等价于 fetch_add
});
t1.join();
t2.join();
std::cout << count << std::endl;
return 0;
}

当然,并非所有的类型都能提供原子操作,这是因为原子操作的可行性取决于具体的 CPU 架构,以及所实例化的类型结构是否能够满足该 CPU 架构对内存对齐
条件的要求,因而我们总是可以通过 std::atomic<T>::is_lock_free 来检查该原子类型是否需支持原子操作,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <atomic>
#include <iostream>

struct A {
float x;
int y;
long long z;
};

int main() {
std::atomic<A> a;
std::cout << std::boolalpha << a.is_lock_free() << std::endl;
return 0;
}

一致性模型

并行执行的多个线程,从某种宏观层面上讨论,可以粗略的视为一种分布式系统。
在分布式系统中,任何通信乃至本地操作都需要消耗一定时间,甚至出现不可靠的通信。

如果我们强行将一个变量 v 在多个线程之间的操作设为原子操作,即任何一个线程在操作完 v 后,
其他线程均能同步感知到 v 的变化,则对于变量 v 而言,表现为顺序执行的程序,它并没有由于引入多线程
而得到任何效率上的收益。对此有什么办法能够适当的加速呢?答案便是削弱原子操作的在进程间的同步条件。

从原理上看,每个线程可以对应为一个集群节点,而线程间的通信也几乎等价于集群节点间的通信。
削弱进程间的同步条件,通常我们会考虑四种不同的一致性模型:

  1. 线性一致性:又称强一致性或原子一致性。它要求任何一次读操作都能读到某个数据的最近一次写的数据,并且所有线程的操作顺序与全局时钟下的顺序是一致的。

    1
    2
    3
    4
    5
    6
            x.store(1)      x.load()
    T1 ---------+----------------+------>


    T2 -------------------+------------->
    x.store(2)

    在这种情况下线程 T1, T2x 的两次写操作是原子的,且 x.store(1) 是严格的发生在 x.store(2) 之前,x.store(2) 严格的发生在 x.load() 之前。
    值得一提的是,线性一致性对全局时钟的要求是难以实现的,这也是人们不断研究比这个一致性更弱条件下其他一致性的算法的原因。

  2. 顺序一致性:同样要求任何一次读操作都能读到数据最近一次写入的数据,但未要求与全局时钟的顺序一致。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
            x.store(1)  x.store(3)   x.load()
    T1 ---------+-----------+----------+----->


    T2 ---------------+---------------------->
    x.store(2)

    或者

    x.store(1) x.store(3) x.load()
    T1 ---------+-----------+----------+----->


    T2 ------+------------------------------->
    x.store(2)

    在顺序一致性的要求下,x.load() 必须读到最近一次写入的数据,因此 x.store(2)x.store(1) 并无任何先后保障,即 只要 T2x.store(2) 发生在 x.store(3) 之前即可。

  3. 因果一致性:它的要求进一步降低,只需要有因果关系的操作顺序得到保障,而非因果关系的操作顺序则不做要求。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
          a = 1      b = 2
    T1 ----+-----------+---------------------------->


    T2 ------+--------------------+--------+-------->
    x.store(3) c = a + b y.load()

    或者

    a = 1 b = 2
    T1 ----+-----------+---------------------------->


    T2 ------+--------------------+--------+-------->
    x.store(3) y.load() c = a + b

    亦或者

    b = 2 a = 1
    T1 ----+-----------+---------------------------->


    T2 ------+--------------------+--------+-------->
    y.load() c = a + b x.store(3)

    上面给出的三种例子都是属于因果一致的,因为整个过程中,只有 cab 产生依赖,而 xy
    在此例子中表现为没有关系(但实际情况中我们需要更详细的信息才能确定 xy 确实无关)

  4. 最终一致性:是最弱的一致性要求,它只保障某个操作在未来的某个时间节点上会被观察到,但并未要求被观察到的时间。因此我们甚至可以对此条件稍作加强,例如规定某个操作被观察到的时间总是有界的。当然这已经不在我们的讨论范围之内了。

    1
    2
    3
    4
    5
    6
        x.store(3)  x.store(4)
    T1 ----+-----------+-------------------------------------------->


    T2 ---------+------------+--------------------+--------+-------->
    x.read x.read() x.read() x.read()

    在上面的情况中,如果我们假设 x 的初始值为 0,则 T2 中四次 x.read() 结果可能但不限于以下情况:

    1
    2
    3
    4
    5
    3 4 4 4 // x 的写操作被很快观察到
    0 3 3 4 // x 的写操作被观察到的时间存在一定延迟
    0 0 0 4 // 最后一次读操作读到了 x 的最终值,但此前的变化并未观察到
    0 0 0 0 // 在当前时间段内 x 的写操作均未被观察到,
    // 但未来某个时间点上一定能观察到 x 为 4 的情况

内存顺序

为了追求极致的性能,实现各种强度要求的一致性,C++11 为原子操作定义了六种不同的内存顺序 std::memory_order 的选项,表达了四种多线程间的同步模型:

  1. 宽松模型:在此模型下,单个线程内的原子操作都是顺序执行的,不允许指令重排,但不同线程间原子操作的顺序是任意的。类型通过 std::memory_order_relaxed 指定。我们来看一个例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    std::atomic<int> counter = {0};
    std::vector<std::thread> vt;
    for (int i = 0; i < 100; ++i) {
    vt.emplace_back([&](){
    counter.fetch_add(1, std::memory_order_relaxed);
    });
    }

    for (auto& t : vt) {
    t.join();
    }
    std::cout << "current counter:" << counter << std::endl;
  2. 释放/消费模型:在此模型中,我们开始限制进程间的操作顺序,如果某个线程需要修改某个值,但另一个线程会对该值的某次操作产生依赖,即后者依赖前者。具体而言,线程 A 完成了三次对 x 的写操作,线程 B 仅依赖其中第三次 x 的写操作,与 x 的前两次写行为无关,则当 A 主动 x.release() 时候(即使用 std::memory_order_release),选项 std::memory_order_consume 能够确保 B 在调用 x.load() 时候观察到 A 中第三次对 x 的写操作。我们来看一个例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // 初始化为 nullptr 防止 consumer 线程从野指针进行读取
    std::atomic<int*> ptr(nullptr);
    int v;
    std::thread producer([&]() {
    int* p = new int(42);
    v = 1024;
    ptr.store(p, std::memory_order_release);
    });
    std::thread consumer([&]() {
    int* p;
    while(!(p = ptr.load(std::memory_order_consume)));

    std::cout << "p: " << *p << std::endl;
    std::cout << "v: " << v << std::endl;
    });
    producer.join();
    consumer.join();
  3. 释放/获取模型:在此模型下,我们可以进一步加紧对不同线程间原子操作的顺序的限制,在释放 std::memory_order_release 和获取 std::memory_order_acquire 之间规定时序,即发生在释放(release)操作之前的所有写操作,对其他线程的任何获取(acquire)操作都是可见的,亦即发生顺序(happens-before)。

    可以看到,std::memory_order_release 确保了它之前的写操作不会发生在释放操作之后,是一个向后的屏障(backward),而 std::memory_order_acquire 确保了它之前的写行为不会发生在该获取操作之后,是一个向前的屏障(forward)。对于选项 std::memory_order_acq_rel 而言,则结合了这两者的特点,唯一确定了一个内存屏障,使得当前线程对内存的读写不会被重排并越过此操作的前后:

    我们来看一个例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    std::vector<int> v;
    std::atomic<int> flag = {0};
    std::thread release([&]() {
    v.push_back(42);
    flag.store(1, std::memory_order_release);
    });
    std::thread acqrel([&]() {
    int expected = 1; // must before compare_exchange_strong
    while(!flag.compare_exchange_strong(expected, 2, std::memory_order_acq_rel))
    expected = 1; // must after compare_exchange_strong
    // flag has changed to 2
    });
    std::thread acquire([&]() {
    while(flag.load(std::memory_order_acquire) < 2);

    std::cout << v.at(0) << std::endl; // must be 42
    });
    release.join();
    acqrel.join();
    acquire.join();

    在此例中我们使用了 compare_exchange_strong 比较交换原语(Compare-and-swap primitive),它有一个更弱的版本,即 compare_exchange_weak,它允许即便交换成功,也仍然返回 false 失败。其原因是因为在某些平台上虚假故障导致的,具体而言,当 CPU 进行上下文切换时,另一线程加载同一地址产生的不一致。除此之外,compare_exchange_strong 的性能可能稍差于 compare_exchange_weak,但大部分情况下,鉴于其使用的复杂度而言,compare_exchange_weak 应该被有限考虑。

  4. 顺序一致模型:在此模型下,原子操作满足顺序一致性,进而可能对性能产生损耗。可显式的通过 std::memory_order_seq_cst 进行指定。最后来看一个例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    std::atomic<int> counter = {0};
    std::vector<std::thread> vt;
    for (int i = 0; i < 100; ++i) {
    vt.emplace_back([&](){
    counter.fetch_add(1, std::memory_order_seq_cst);
    });
    }

    for (auto& t : vt) {
    t.join();
    }
    std::cout << "current counter:" << counter << std::endl;

    这个例子与第一个宽松模型的例子本质上没有区别,仅仅只是将原子操作的内存顺序修改为了 memory_order_seq_cst,有兴趣的读者可以自行编写程序测量这两种不同内存顺序导致的性能差异。

总结

C++11 语言层提供了并发编程的相关支持,本节简单的介绍了 std::thread, std::mutex, std::future 这些并发编程中不可回避的重要工具。
除此之外,我们还介绍了 C++11 最重要的几个特性之一的『内存模型』,
它们为 C++ 在标准化高性能计算中提供了重要的基础。

Gloo 是一个集合通信库。它带有许多对机器学习应用有用的集体算法。参与机器之间的数据传输是抽象的,因此可以随时使用 IP,或者在可用时使用 InifiniBand(或 RoCE)。 在后一种情况下,如果使用 InfiniBand 传输,GPUDirect可用于加速跨机器 GPU 到 GPU 的内存传输。

在适用的情况下,算法具有一种适用于系统内存缓冲区的实现,以及一种适用于 NVIDIA GPU 内存缓冲区的实现。 在后一种情况下,主机和设备之间不需要复制内存; 这是由算法实现处理的。

aligned_allocator

将分配的内存区域对齐到32字节。使用了using和模板。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// Align buffers to 32 bytes to support vectorized code
const size_t kBufferAlignment = 32;

template <typename T, int ALIGNMENT = kBufferAlignment>
class aligned_allocator {
static_assert(
!(ALIGNMENT & (ALIGNMENT - 1)),
"alignment must be a power of 2");

public:
using value_type = T;
using pointer = value_type*;
using const_pointer = const value_type*;
using reference = value_type&;
using const_reference = const value_type&;
using size_type = std::size_t;
using difference_type = std::ptrdiff_t;

template <typename U>
struct rebind {
using other = aligned_allocator<U, ALIGNMENT>;
};

inline explicit aligned_allocator() = default;
inline ~aligned_allocator() = default;
inline explicit aligned_allocator(const aligned_allocator& a) = default;

inline pointer address(reference r) {
return &r;
}

inline const_pointer address(const_reference r) {
return &r;
}

inline pointer allocate(size_type sz) {
pointer p;
if (posix_memalign(
reinterpret_cast<void**>(&p), ALIGNMENT, sizeof(T) * sz)) {
abort();
}
return p;
}

void deallocate(pointer p, size_type /*sz*/) {
free(p);
}
};

调用posix_memalign(void **memptr, size_t alignment, size_t size)成功时会返回size字节的动态内存,并且这块内存的地址是alignment的倍数。参数alignment必须是2的幂,还是void指针的大小的倍数。返回的内存块的地址放在了memptr里面,函数返回值是0。调用失败时,没有内存会被分配,memptr的值没有被定义,返回如下错误码之一:

  • EINVAL:参数不是2的幂,或者不是void指针的倍数。
  • ENOMEM:没有足够的内存去满足函数的请求。

transport

一个样例是这样调用mpi的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main(int /*argc*/, char** /*argv*/) {
// We'll use the TCP transport in this example
auto dev = gloo::transport::tcp::CreateDevice("localhost");

// Create Gloo context and delegate management of MPI_Init/MPI_Finalize
auto context = gloo::mpi::Context::createManaged();
context->connectFullMesh(dev);

// Create and run simple allreduce
int rank = context->rank;
gloo::AllreduceRing<int> allreduce(context, {&rank}, 1);
allreduce.run();
std::cout << "Result: " << rank << std::endl;

return 0;
}

transport共有三种通信方式:ibverbs、tcp、uv。

ibverbs

应该是RDMA方式。context应该是上下文,记录通信设备和rank相关信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
namespace ibverbs {

Context::Context(std::shared_ptr<Device> device, int rank, int size)
: ::gloo::transport::Context(rank, size), device_(device) {}

Context::~Context() {}

std::unique_ptr<transport::Pair>& Context::createPair(int rank) {
pairs_[rank] = std::unique_ptr<transport::Pair>(
new ibverbs::Pair(device_, getTimeout()));
return pairs_[rank];
}

std::unique_ptr<transport::UnboundBuffer> Context::createUnboundBuffer(
void* ptr,
size_t size) {
GLOO_THROW_INVALID_OPERATION_EXCEPTION(
"Unbound buffers not supported yet for ibverbs transport");
return std::unique_ptr<transport::UnboundBuffer>();
}

} // namespace ibverbs

这个pair是不能复制和赋值,避免出现内存问题,感觉应该是通信双方组成一个pair:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
// Forward declaration
class Buffer;

class Pair : public ::gloo::transport::Pair {
static constexpr int kMaxBuffers = 8;
static constexpr auto kRecvCompletionQueueCapacity = kMaxBuffers;
static constexpr auto kSendCompletionQueueCapacity = kMaxBuffers;
static constexpr auto kCompletionQueueCapacity =
kRecvCompletionQueueCapacity + kSendCompletionQueueCapacity;

// The ibv_req_notify(3) function takes an argument called
// 'solicited_only' which makes it only trigger a notification for
// work requests that are flagged as solicited. Every completion
// should trigger a notification, so always pass 0.
static constexpr auto kNotifyOnAnyCompletion = 0;

public:
explicit Pair(
const std::shared_ptr<Device>& dev,
std::chrono::milliseconds timeout);

virtual ~Pair();

Pair(const Pair& that) = delete;

Pair& operator=(const Pair& that) = delete;

virtual const Address& address() const override;
virtual void connect(const std::vector<char>& bytes) override;
virtual void setSync(bool enable, bool busyPoll) override;

virtual std::unique_ptr<::gloo::transport::Buffer>
createSendBuffer(int slot, void* ptr, size_t size) override;

virtual std::unique_ptr<::gloo::transport::Buffer>
createRecvBuffer(int slot, void* ptr, size_t size) override;

// Send from the specified buffer to remote side of pair.
virtual void send(
transport::UnboundBuffer* tbuf,
uint64_t tag,
size_t offset,
size_t nbytes) override;

// Receive into the specified buffer from the remote side of pair.
virtual void recv(
transport::UnboundBuffer* tbuf,
uint64_t tag,
size_t offset,
size_t nbytes) override;

void handleCompletionEvent();

void pollCompletions();

void handleCompletion(struct ibv_wc* wc);

void send(Buffer* buf, size_t offset, size_t length, size_t roffset);

void close() override;

protected:
std::shared_ptr<Device> dev_;

// Whether or not this pair is running in sync mode.
std::atomic<bool> sync_;

// Whether or not this pair is busy polling in sync mode.
std::atomic<bool> busyPoll_;

const std::chrono::milliseconds timeout_;

// 该pair的完成队列处理的完成事件数。在销毁完成队列之前,需要确认这么多事件。否则,销毁将挂起。
int completionEventsHandled_;

Address self_;
Address peer_;

struct ibv_cq* cq_;
struct ibv_qp* qp_;

std::mutex m_;
std::condition_variable cv_;

// For us to copy the remote peer's ibv_mr into.
std::map<int, struct ibv_mr> peerMemoryRegions_;

// 这些字段存储pair的远程端可以发送到的内存区域以及pair的本地端可以从中发送的内存区域。
// 注册接收缓冲区时,本地 ibv_mr 被发送到pair的远程端,并且相应的 MemoryRegion 实例保留在 mappedSendRegions_ 列表中,直到发送操作完成。
// 为了允许pair的远程端发送其内存区域,我们在 mappedRecvRegions_ 中保留了固定数量的 MemoryRegion 实例。
// 对于每个发布的接收工作请求,这些区域都会被循环引用。
std::map<int, std::unique_ptr<MemoryRegion> > mappedSendRegions_;
std::array<std::unique_ptr<MemoryRegion>, kMaxBuffers> mappedRecvRegions_;

// 跟踪发布和完成的请求工作请求的数量。 在发布 WR 和完成 WR 时,都需要对 mappedRecvRegions_ 数组进行索引。
uint64_t recvPosted_;

// Completions on behalf of buffers need to be forwarded to those buffers.
std::map<int, Buffer*> sendCompletionHandlers_;
std::map<int, Buffer*> recvCompletionHandlers_;

void sendMemoryRegion(struct ibv_mr* mr, int slot);
const struct ibv_mr* getMemoryRegion(int slot);

void postReceive();

std::chrono::milliseconds getTimeout() const {
return timeout_;
}

const Address& peer() const {
return peer_;
}

private:
std::exception_ptr ex_;
bool closed_ = false;

// Used to signal IO exceptions from one thread and propagate onto others.
void signalIoFailure(const std::string& msg);
void checkErrorState();

friend class Buffer;
};

以下是逐个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
Pair::Pair(
const std::shared_ptr<Device>& dev,
std::chrono::milliseconds timeout)
: dev_(dev),
sync_(false),
busyPoll_(false),
timeout_(timeout),
completionEventsHandled_(0),
recvPosted_(0),
ex_(nullptr) {
int rv;

// Create completion queue
{
// 必须向设备的完成通道注册此完成队列以支持异步完成处理。
// Pairs 默认使用异步完成处理,因此我们调用 ibv_req_notify_cq(3) 来请求第一个通知。
cq_ = ibv_create_cq(
dev_->context_,
kCompletionQueueCapacity,
this,
dev_->comp_channel_,
0);
GLOO_ENFORCE(cq_);

// 在完成队列 (CQ) 上请求Completion Notification(完成通知)。
rv = ibv_req_notify_cq(cq_, kNotifyOnAnyCompletion);
GLOO_ENFORCE_EQ(rv, 0);
}

// Create queue pair
{
struct ibv_qp_init_attr attr;
memset(&attr, 0, sizeof(struct ibv_qp_init_attr));
attr.send_cq = cq_;
attr.recv_cq = cq_;
attr.cap.max_send_wr = Pair::kSendCompletionQueueCapacity;
attr.cap.max_recv_wr = Pair::kRecvCompletionQueueCapacity;
attr.cap.max_send_sge = 1;
attr.cap.max_recv_sge = 1;
attr.qp_type = IBV_QPT_RC;
qp_ = ibv_create_qp(dev->pd_, &attr);
// 创建queue pair
GLOO_ENFORCE(qp_);
}

// Init queue pair
{
struct ibv_qp_attr attr;
memset(&attr, 0, sizeof(struct ibv_qp_attr));
attr.qp_state = IBV_QPS_INIT;
attr.pkey_index = 0;
attr.port_num = dev_->attr_.port;
attr.qp_access_flags = IBV_ACCESS_LOCAL_WRITE | IBV_ACCESS_REMOTE_WRITE;
rv = ibv_modify_qp(
qp_,
&attr,
IBV_QP_STATE | IBV_QP_PKEY_INDEX | IBV_QP_PORT | IBV_QP_ACCESS_FLAGS);
GLOO_ENFORCE_EQ(rv, 0);
}

// Populate local address.
// The Packet Sequence Number field (PSN) is random which makes that
// the remote end of this pair needs to have the contents of the
// full address struct in order to connect, and vice versa.
{
struct ibv_port_attr attr;
memset(&attr, 0, sizeof(struct ibv_port_attr));
rv = ibv_query_port(dev_->context_, dev_->attr_.port, &attr);
GLOO_ENFORCE_EQ(rv, 0);
rv = ibv_query_gid(
dev_->context_,
dev_->attr_.port,
dev_->attr_.index,
&self_.addr_.ibv_gid);
GLOO_ENFORCE_EQ(rv, 0);
self_.addr_.lid = attr.lid;
self_.addr_.qpn = qp_->qp_num;
self_.addr_.psn = rand() & 0xffffff;
}

// 在连接之前发布接收请求。
// 每当这pair的远程端注册接收缓冲区时,就会触发它们的内存注册被发送到这一端。
// 由于这些发送是单方面的,我们总是需要一整套接收工作请求。
// 内存区域接收可以与常规缓冲区写入交错,因此我们主动在每个接收工作请求中包含一个内存区域。
for (int i = 0; i < kMaxBuffers; ++i) {
mappedRecvRegions_[i] = make_unique<MemoryRegion>(dev_->pd_);
postReceive();
}
}

Pair::~Pair() {
int rv;

// Acknowledge number of completion events handled by this
// pair's completion queue (also see ibv_get_cq_event(3)).
ibv_ack_cq_events(cq_, completionEventsHandled_);

rv = ibv_destroy_qp(qp_);
GLOO_ENFORCE_EQ(rv, 0);

rv = ibv_destroy_cq(cq_);
GLOO_ENFORCE_EQ(rv, 0);
}

void Pair::close() {
if (closed_) {
// TODO: add proper handling of duplicate closes T21171834
return;
}

closed_ = true;
}

const Address& Pair::address() const {
return self_;
}

连接函数先获取到对方的地址,更新attr结构体,使用ibv_modify_qp函数修改RDMA通信所需的结构体。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
void Pair::connect(const std::vector<char>& bytes) {
struct ibv_qp_attr attr;
int rv;
checkErrorState();

peer_ = Address(bytes);

memset(&attr, 0, sizeof(attr));
attr.qp_state = IBV_QPS_RTR;
attr.path_mtu = IBV_MTU_1024;
attr.dest_qp_num = peer_.addr_.qpn;
attr.rq_psn = peer_.addr_.psn;
attr.max_dest_rd_atomic = 1;
attr.min_rnr_timer = 20;
attr.ah_attr.is_global = 0;
attr.ah_attr.dlid = peer_.addr_.lid;
attr.ah_attr.port_num = dev_->attr_.port;
if (peer_.addr_.ibv_gid.global.interface_id) {
attr.ah_attr.is_global = 1;
attr.ah_attr.grh.hop_limit = 1;
attr.ah_attr.grh.dgid = peer_.addr_.ibv_gid;
attr.ah_attr.grh.sgid_index = dev_->attr_.index;
}

// ibv_modify_qp()修改队列对的属性。更改的属性描述了QP的发送和接收属性。
// ibv_create_qp仅仅分配了资源,要通过这个modify来让硬件进入工作状态。
// Move to Ready To Receive (RTR) state
rv = ibv_modify_qp(
qp_,
&attr,
IBV_QP_STATE | IBV_QP_PATH_MTU | IBV_QP_DEST_QPN | IBV_QP_RQ_PSN |
IBV_QP_AV | IBV_QP_MAX_DEST_RD_ATOMIC | IBV_QP_MIN_RNR_TIMER);
GLOO_ENFORCE_EQ(rv, 0);

memset(&attr, 0, sizeof(attr));
attr.qp_state = IBV_QPS_RTS;
attr.sq_psn = self_.addr_.psn;
attr.ah_attr.is_global = 1;
attr.timeout = 14;
attr.retry_cnt = 7;
attr.rnr_retry = 7; /* infinite */
attr.max_rd_atomic = 1;

// Move to Ready To Send (RTS) state
rv = ibv_modify_qp(
qp_,
&attr,
IBV_QP_STATE | IBV_QP_TIMEOUT | IBV_QP_RETRY_CNT | IBV_QP_RNR_RETRY |
IBV_QP_SQ_PSN | IBV_QP_MAX_QP_RD_ATOMIC);
GLOO_ENFORCE_EQ(rv, 0);
}

// Switches the pair into synchronous mode.
//
// Note: busy polling is NOT optional. Currently, since all pairs
// share a single completion channel, busy polling is mandatory
// through ibv_poll_cq(3). If a use case comes up for supporting
// synchronous mode where the calling thread should be suspended, this
// can be revisited and we can add a completion channel per pair.
//
void Pair::setSync(bool sync, bool busyPoll) {
checkErrorState();
if (!sync) {
GLOO_THROW_INVALID_OPERATION_EXCEPTION("Can only switch to sync mode");
}
if (!busyPoll) {
GLOO_THROW_INVALID_OPERATION_EXCEPTION(
"The ibverbs transport only supports busy polling in sync mode");
}

// The notification mechanism for this pair's completion queue is
// still armed. This means the device thread will still call
// handleCompletions() one more time, but this is ignored.
//
// No need to lock a mutex; these are atomics.
//
sync_ = true;
busyPoll_ = true;
}

使用ibv_post_send函数发送,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void Pair::sendMemoryRegion(struct ibv_mr* src, int slot) {
auto mr = make_unique<MemoryRegion>(dev_->pd_, src);
struct ibv_sge list = mr->sge();
struct ibv_send_wr wr;
memset(&wr, 0, sizeof(wr));
wr.wr_id = slot;
wr.sg_list = &list;
wr.num_sge = 1;
wr.opcode = IBV_WR_SEND_WITH_IMM;
wr.send_flags = IBV_SEND_SIGNALED;
wr.imm_data = slot;

// 工作请求被序列化并发送到驱动程序,因此它不需要在 ibv_post_send 调用后有效。
// ibv_post_send和recv用于发送verb,verb承载在一个称为ibv_send_wr或者ibv_recv_wr的数据结构中,里面是verb类型和mr的相关细节。
struct ibv_send_wr* bad_wr = nullptr;
int rv = ibv_post_send(qp_, &wr, &bad_wr);
if (rv != 0) {
signalIoFailure(GLOO_ERROR_MSG("ibv_post_send: ", rv));
}

GLOO_ENFORCE_EQ(mappedSendRegions_.count(slot), 0);
mappedSendRegions_[slot] = std::move(mr);
}

先获取到锁,如果是异步的,需要检查是不是超时了;否则就等待。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
const struct ibv_mr* Pair::getMemoryRegion(int slot) {
std::unique_lock<std::mutex> lock(m_);
if (sync_) {
auto it = peerMemoryRegions_.find(slot);
auto start = std::chrono::steady_clock::now();
while (it == peerMemoryRegions_.end()) {
lock.unlock();
pollCompletions();
lock.lock();
if (timeout_ != kNoTimeout &&
(std::chrono::steady_clock::now() - start) >= timeout_) {
lock.unlock();
signalIoFailure(
GLOO_ERROR_MSG(
"Timeout waiting for memory region from ",
peer_.str()));
GLOO_ENFORCE(false, "Unexpected code path");
}
it = peerMemoryRegions_.find(slot);
}
return &it->second;
} else {
auto pred = [&]{
return peerMemoryRegions_.find(slot) != peerMemoryRegions_.end();
};
if (timeout_ == kNoTimeout) {
// No timeout set. Wait for read to complete.
cv_.wait(lock, pred);
} else {
auto done = cv_.wait_for(lock, timeout_, pred);
if (!done) {
signalIoFailure(
GLOO_ERROR_MSG(
"Timeout waiting for memory region from ",
peer_.str()));
GLOO_ENFORCE(false, "Unexpected code path");
}
}
auto it = peerMemoryRegions_.find(slot);
GLOO_ENFORCE(it != peerMemoryRegions_.end());
return &it->second;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
void Pair::postReceive() {
const auto& mr = mappedRecvRegions_[recvPosted_++ % kMaxBuffers];
struct ibv_sge list = mr->sge();
struct ibv_recv_wr wr;
memset(&wr, 0, sizeof(wr));
wr.sg_list = &list;
wr.num_sge = 1;

// 工作请求被序列化并发送到驱动程序,因此它不需要在 ibv_post_recv 调用后有效。
struct ibv_recv_wr* bad_wr = nullptr;
auto rv = ibv_post_recv(qp_, &wr, &bad_wr);
if (rv != 0) {
signalIoFailure(GLOO_ERROR_MSG("ibv_post_recv: ", rv));
}
}

std::unique_ptr<::gloo::transport::Buffer>
Pair::createSendBuffer(int slot, void* ptr, size_t size) {
// 创建一个buffer
std::unique_lock<std::mutex> lock(m_);
GLOO_ENFORCE_EQ(sendCompletionHandlers_.count(slot), 0);
auto buffer = new Buffer(this, slot, ptr, size);
sendCompletionHandlers_[slot] = buffer;
return std::unique_ptr<::gloo::transport::Buffer>(buffer);
}

std::unique_ptr<::gloo::transport::Buffer>
Pair::createRecvBuffer(int slot, void* ptr, size_t size) {
std::unique_lock<std::mutex> lock(m_);
GLOO_ENFORCE_EQ(recvCompletionHandlers_.count(slot), 0);
auto buffer = new Buffer(this, slot, ptr, size);
recvCompletionHandlers_[slot] = buffer;
sendMemoryRegion(buffer->mr_, buffer->slot_);
return std::unique_ptr<::gloo::transport::Buffer>(buffer);
}

// Send from the specified buffer to remote side of pair.
void Pair::send(
transport::UnboundBuffer* tbuf,
uint64_t /* unused */,
size_t /* unused */,
size_t /* unused */) {
GLOO_THROW_INVALID_OPERATION_EXCEPTION(
"Unbound buffers not supported yet for ibverbs transport");
}

// Receive into the specified buffer from the remote side of pair.
void Pair::recv(
transport::UnboundBuffer* tbuf,
uint64_t /* unused */,
size_t /* unused */,
size_t /* unused */) {
GLOO_THROW_INVALID_OPERATION_EXCEPTION(
"Unbound buffers not supported yet for ibverbs transport");
}

// handleCompletionEvent is called by the device thread when it
// received an event for this pair's completion queue on its
// completion channel.
void Pair::handleCompletionEvent() {
int rv;

completionEventsHandled_++;

// If in sync mode, the pair was just switched and this is
// the last notification from the device thread because
// the notification mechanism is not re-armed below.
if (sync_) {
return;
}

try {
checkErrorState();

// Arm notification mechanism for completion queue.
rv = ibv_req_notify_cq(cq_, kNotifyOnAnyCompletion);
GLOO_ENFORCE_EQ(rv, 0);

// Now poll for work completions to drain the completion queue.
std::unique_lock<std::mutex> lock(m_);
pollCompletions();
} catch (const ::gloo::IoException&) {
// Catch IO exceptions on the event handling thread. The exception has
// already been saved and user threads signaled.
}
}

轮询这pair的完成队列以获取工作完成情况。当从设备线程调用时,这对的互斥锁已经被获取。从用户线程调用时,不会获取互斥锁(因为只有一个线程使用这对)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
void Pair::pollCompletions() {
std::array<struct ibv_wc, kCompletionQueueCapacity> wc;

// Invoke handler for every work completion.
for (;;) {
auto nwc = ibv_poll_cq(cq_, wc.size(), wc.data());
GLOO_ENFORCE_GE(nwc, 0);

// Handle work completions
for (int i = 0; i < nwc; i++) {
checkErrorState();
handleCompletion(&wc[i]);
}

// Break unless wc was filled
if (nwc == 0 || nwc < wc.size()) {
break;
}
}
}

void Pair::handleCompletion(struct ibv_wc* wc) {
if (wc->opcode == IBV_WC_RECV_RDMA_WITH_IMM) {
// Incoming RDMA write completed.
// Slot is encoded in immediate data on receive work completion.
// It is set in the Pair::send function.
auto slot = wc->imm_data;
GLOO_ENFORCE_EQ( wc->status, IBV_WC_SUCCESS, "Recv for slot ", slot, ": ", ibv_wc_status_str(wc->status));

GLOO_ENFORCE(recvCompletionHandlers_[slot] != nullptr);
recvCompletionHandlers_[slot]->handleCompletion(wc);

// Backfill receive work requests.
postReceive();
} else if (wc->opcode == IBV_WC_RDMA_WRITE) {
// Outbound RDMA write completed.
// Slot is encoded in wr_id fields on send work request. Unlike
// the receive work completions, the immediate data field on send
// work requests are not pass to the respective work completion.
auto slot = wc->wr_id;
GLOO_ENFORCE_EQ( wc->status, IBV_WC_SUCCESS, "Send for slot ", slot, ": ", ibv_wc_status_str(wc->status));

GLOO_ENFORCE(sendCompletionHandlers_[slot] != nullptr);
sendCompletionHandlers_[slot]->handleCompletion(wc);
} else if (wc->opcode == IBV_WC_RECV) {
// 内存区域 recv 完成。
// 仅由pair的远程端用于传递 ibv_mr。
// 它们以 FIFO 顺序写入,因此我们可以在映射的接收区域列表中选择并使用第一个 MemoryRegion 实例。
// 尝试写入此插槽的缓冲区可能正在等待该对的另一端发送其内存区域。
// 锁定访问权限,然后通知任何等待的人。
// 时隙在接收工作完成后立即编码为数据。 它在 Pair::sendMemoryRegion 函数中设置。
auto slot = wc->imm_data;
GLOO_ENFORCE_EQ(
wc->status,
IBV_WC_SUCCESS,
"Memory region recv for slot ",
slot,
": ",
ibv_wc_status_str(wc->status));

// Move ibv_mr from memory region 'inbox' to final slot.
const auto& mr = mappedRecvRegions_[recvPosted_ % kMaxBuffers];
peerMemoryRegions_[slot] = mr->mr();

// Notify any buffer waiting for the details of its remote peer.
cv_.notify_all();

// Backfill receive work requests.
postReceive();
} else if (wc->opcode == IBV_WC_SEND) {
// Memory region send completed.
auto slot = wc->wr_id;
GLOO_ENFORCE_EQ(
wc->status,
IBV_WC_SUCCESS,
"Memory region send for slot ",
slot,
": ",
ibv_wc_status_str(wc->status));

GLOO_ENFORCE_GT(mappedSendRegions_.size(), 0);
GLOO_ENFORCE_EQ(mappedSendRegions_.count(slot), 1);
mappedSendRegions_.erase(slot);
} else {
GLOO_ENFORCE(false, "Unexpected completion with opcode: ", wc->opcode);
}
}

使用一些信息填充结构体,获取到内存区域后发送

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
void Pair::send(Buffer* buffer, size_t offset, size_t length, size_t roffset) {
struct ibv_sge list;
list.addr = (uint64_t)buffer->ptr_ + offset;
list.length = length;
list.lkey = buffer->mr_->lkey;

struct ibv_send_wr wr;
memset(&wr, 0, sizeof(wr));
wr.wr_id = buffer->slot_;
wr.sg_list = &list;
wr.num_sge = 1;
wr.opcode = IBV_WR_RDMA_WRITE_WITH_IMM;
wr.send_flags = IBV_SEND_SIGNALED;
wr.imm_data = buffer->slot_;

const struct ibv_mr* peer = getMemoryRegion(buffer->slot_);
GLOO_ENFORCE_NE(peer, (const struct ibv_mr*)nullptr);
wr.wr.rdma.remote_addr = (uint64_t)peer->addr + roffset;
wr.wr.rdma.rkey = peer->rkey;

struct ibv_send_wr* bad_wr;
auto rv = ibv_post_send(qp_, &wr, &bad_wr);
if (rv != 0) {
signalIoFailure(GLOO_ERROR_MSG("ibv_post_send: ", rv));
}
}

void Pair::signalIoFailure(const std::string& msg) {
std::lock_guard<std::mutex> lock(m_);
auto ex = ::gloo::IoException(msg);
if (ex_ == nullptr) {
// If we haven't seen an error yet, store the exception to throw on future calling threads.
ex_ = std::make_exception_ptr(ex);
// Loop through the completion handlers and signal that an error has
// occurred.
for (auto& it : recvCompletionHandlers_) {
GLOO_ENFORCE(it.second != nullptr);
it.second->signalError(ex_);
}
for (auto& it : sendCompletionHandlers_) {
GLOO_ENFORCE(it.second != nullptr);
it.second->signalError(ex_);
}
}
// Finally, throw the exception on this thread.
throw ex;
};

void Pair::checkErrorState() {
// If we previously encountered an error, rethrow here.
if (ex_ != nullptr) {
std::rethrow_exception(ex_);
}
}

device 保存了设备信息,以下是构建一个设备

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Device::Device(const struct attr& attr, ibv_context* context)
: attr_(attr),
pciBusID_(infinibandToBusID(attr.name)),
hasNvPeerMem_(kernelModules().count("nv_peer_mem") > 0),
context_(context) {
int rv;

// Query and store device attributes
rv = ibv_query_device(context_, &deviceAttr_);
GLOO_ENFORCE_EQ(rv, 0, "ibv_query_device: ", strerror(errno));

// Query and store port attributes
rv = ibv_query_port(context_, attr_.port, &portAttr_);
GLOO_ENFORCE_EQ(rv, 0, "ibv_query_port: ", strerror(errno));

// Protection domain
pd_ = ibv_alloc_pd(context_);
GLOO_ENFORCE(pd_);

// Completion channel
comp_channel_ = ibv_create_comp_channel(context_);
GLOO_ENFORCE(comp_channel_);

// Start thread to poll completion queue and dispatch
// completions for completed work requests.
done_ = false;
loop_.reset(new std::thread(&Device::loop, this));
}

buffer分配一个缓冲区

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
Buffer::Buffer(Pair* pair, int slot, void* ptr, size_t size)
: ::gloo::transport::Buffer(slot, ptr, size),
pair_(pair),
recvCompletions_(0),
sendCompletions_(0),
sendPending_(0),
ex_(nullptr) {
// 注册一个memory region
mr_ = ibv_reg_mr(
pair_->dev_->pd_,
ptr_,
size_,
IBV_ACCESS_LOCAL_WRITE | IBV_ACCESS_REMOTE_WRITE);

// Provide hint if the error is EFAULT and nv_peer_mem is not loaded
if (mr_ == nullptr && errno == EFAULT) {
if (!pair->dev_->hasNvPeerMem_) {
GLOO_ENFORCE(
mr_ != nullptr,
"ibv_reg_mr: ",
strerror(errno),
" (kernel module 'nv_peer_mem' not loaded;"
" did you specify a pointer to GPU memory?)");
}
}

// Provide hint if the error is ENOMEM
if (mr_ == nullptr && errno == ENOMEM) {
GLOO_ENFORCE(
mr_ != nullptr,
"ibv_reg_mr: ",
strerror(errno),
" (did you run into the locked memory limit?)");
}

GLOO_ENFORCE(mr_ != nullptr, "ibv_reg_mr: ", strerror(errno));
}

等待接收操作完成。根据是不是异步判断是否需要等待

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
void Buffer::waitRecv() {
// 如果该pair处于同步模式,则当前线程负责轮询工作完成情况。
// 由于单个pair可能为多个缓冲区提供服务,因此完成可能旨在用于另一个缓冲区。
auto timeout = pair_->getTimeout();
if (pair_->sync_) {
auto start = std::chrono::steady_clock::now();
// We can assume a single pair is never used by more than one
// thread, so there is no need to acquire the mutex here.
while (recvCompletions_ == 0) {
pair_->pollCompletions();
if (timeout != kNoTimeout &&
(std::chrono::steady_clock::now() - start) >= timeout) {
pair_->signalIoFailure(
GLOO_ERROR_MSG("Read timeout ", pair_->peer().str()));
GLOO_ENFORCE(false, "Unexpected code path");
}
}
recvCompletions_--;
} else {
// The device thread will signal completion. If the completion
// hasn't arrived yet, wait until it does.
auto pred = [&]{
checkErrorState();
return recvCompletions_ > 0;
};
std::unique_lock<std::mutex> lock(m_);
if (timeout == kNoTimeout) {
// No timeout set. Wait for read to complete.
recvCv_.wait(lock, pred);
} else {
auto done = recvCv_.wait_for(lock, timeout, pred);
if (!done) {
// Release the mutex before calling into the pair to avoid deadlock.
// Calling signalIoFailure() will throw, so no need to
// reacquire.
lock.unlock();
pair_->signalIoFailure(
GLOO_ERROR_MSG("Read timeout ", pair_->peer().str()));
GLOO_ENFORCE(false, "Unexpected code path");
}
}
recvCompletions_--;
}
}

等待发送操作完成。根据是不是异步判断是否需要等待

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
// Wait for the previous send operation to finish.
void Buffer::waitSend() {
// 如果该pair处于同步模式,则当前线程负责轮询工作完成情况。
auto timeout = pair_->getTimeout();
if (pair_->sync_) {
// We can assume a single pair is never used by more than one
// thread, so there is no need to acquire the mutex here.
if (sendCompletions_ == 0) {
GLOO_ENFORCE_GT(sendPending_, 0, "No send to wait for");
auto start = std::chrono::steady_clock::now();
// We can assume a single pair is never used by more than one
// thread, so there is no need to acquire the mutex here.
while (sendCompletions_ == 0) {
pair_->pollCompletions();
if (timeout != kNoTimeout &&
(std::chrono::steady_clock::now() - start) >= timeout) {
pair_->signalIoFailure(
GLOO_ERROR_MSG("Send timeout ", pair_->peer().str()));
GLOO_ENFORCE(false, "Unexpected code path");
}
}
}
sendCompletions_--;
} else {
// The device thread will signal completion. If the completion
// hasn't arrived yet, wait until it does.
std::unique_lock<std::mutex> lock(m_);
checkErrorState();
if (sendCompletions_ == 0) {
GLOO_ENFORCE_GT(sendPending_, 0, "No send to wait for");
auto pred = [&]{
checkErrorState();
return sendCompletions_ > 0;
};
if (timeout == kNoTimeout) {
// No timeout set. Wait for read to complete.
sendCv_.wait(lock, pred);
} else {
auto done = sendCv_.wait_for(lock, timeout, pred);
if (!done) {
// Release the mutex before calling into the pair to avoid deadlock.
// Calling signalIoFailure() will throw, so no need to
// reacquire.
lock.unlock();
pair_->signalIoFailure(
GLOO_ERROR_MSG("Send timeout ", pair_->peer().str()));
GLOO_ENFORCE(false, "Unexpected code path");
}
}
}
sendCompletions_--;
}
}

void Buffer::send(size_t offset, size_t length, size_t roffset) {
int rv;

// Can't assert on roffset, since we don't know the size of
// the remote buffer. Refactor of initialization code needed
// to support this.
GLOO_ENFORCE_LE(offset + length, size_);

{
std::unique_lock<std::mutex> lock(m_);
checkErrorState();
}

if (debug_) {
std::cout << "[" << getpid() << "] ";
std::cout << "send " << length << " bytes";
std::cout << std::endl;
}

// Increment number of sends in flight
sendPending_++;

pair_->send(this, offset, length, roffset);
}

tcp

TCP中包含一个tls层。TLS(Transport Layer Security,安全传输层),TLS是建立在传输层TCP协议之上的协议,服务于应用层,它的前身是SSL(Secure Socket Layer,安全套接字层),它实现了将应用层的报文进行加密后再交由TCP进行传输的功能。

tls

上下文:

1
2
3
4
5
6
7
8
9
Context::Context(std::shared_ptr<Device> device, int rank, int size)
: ::gloo::transport::tcp::Context(
std::dynamic_pointer_cast<::gloo::transport::tcp::Device>(device),
rank, size),
ssl_ctx_(create_ssl_ctx(c_str_or_null(device->getPKeyFile()),
c_str_or_null(device->getCertFile()),
c_str_or_null(device->getCAFile()),
c_str_or_null(device->getCAPath())),
[](::SSL_CTX *x) { ::_glootls::SSL_CTX_free(x); }) {}

真正创建ssl context的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
SSL_CTX *Context::create_ssl_ctx(const char *pkey, const char *cert,
const char *ca_file, const char *ca_path) {
GLOO_ENFORCE(pkey != nullptr && cert != nullptr,
"Private key and certificate location must be specified");
GLOO_ENFORCE(ca_file != nullptr || ca_path != nullptr,
"CAfile or CApath must be specified");
static std::once_flag ssl_ctx_init_;
std::call_once(ssl_ctx_init_, [] {
// SSL_load_error_strings();
// SSL_library_init();
_glootls::OPENSSL_init_ssl(
OPENSSL_INIT_LOAD_SSL_STRINGS | OPENSSL_INIT_LOAD_CRYPTO_STRINGS, NULL);
_glootls::OPENSSL_init_ssl(0, NULL);
});
SSL_CTX *ssl_ctx = _glootls::SSL_CTX_new(_glootls::TLS_method());
GLOO_ENFORCE(ssl_ctx != nullptr, getSSLErrorMessage());
GLOO_ENFORCE(
_glootls::SSL_CTX_set_min_proto_version(ssl_ctx, TLS_MAX_VERSION) == 1,
getSSLErrorMessage());

// As we don't need to handle legacy clients,
// let's remove support for legacy renegotiation:
_glootls::SSL_CTX_clear_options(ssl_ctx, SSL_OP_LEGACY_SERVER_CONNECT);

_glootls::SSL_CTX_set_verify_depth(ssl_ctx, 1);

// To enforcing a higher security level, set it to 3.
//
// 2级
// 安全级别设置为 112 位安全。 因此,禁止使用短于 2048 位的 RSA、DSA 和 DH 密钥以及短于 224 位的 ECC 密钥。
// 除了 1 级排除之外,还禁止使用任何使用 RC4 的密码套件。 SSL 版本 3 也是不允许的。 压缩被禁用。
//
// Level 3
// 安全级别设置为 128 位安全。
// 因此,禁止使用小于 3072 位的 RSA、DSA 和 DHkey 以及小于 256 位的 ECC 密钥。
// 除了 2 级排除之外,禁止使用不提供前向保密的密码套件。 不允许使用低于 1.1 的 TLS 版本。 会话票证被禁用。
//
// TODO: should be 3, but it doesn't work yet :(
_glootls::SSL_CTX_set_security_level(ssl_ctx, 2);

GLOO_ENFORCE(
_glootls::SSL_CTX_load_verify_locations(ssl_ctx, ca_file, ca_path) == 1,
getSSLErrorMessage());
GLOO_ENFORCE(_glootls::SSL_CTX_use_certificate_chain_file(ssl_ctx, cert) == 1,
getSSLErrorMessage());
GLOO_ENFORCE(_glootls::SSL_CTX_use_PrivateKey_file(ssl_ctx, pkey,
SSL_FILETYPE_PEM) == 1,
getSSLErrorMessage());
GLOO_ENFORCE(_glootls::SSL_CTX_check_private_key(ssl_ctx) == 1,
getSSLErrorMessage());
// SSL_VERIFY_PEER
//
// 服务器模式:服务器向客户端发送客户端证书请求。
// 检查返回的证书(如果有)。 如果验证过程失败,TLS/SSL 握手会立即终止,并发出一条包含验证失败原因的警报消息。
// 该行为可以通过附加的 SSL_VERIFY_FAIL_IF_NO_PEER_CERT 和 SSL_VERIFY_CLIENT_ONCE 标志来控制。
//
// 客户端模式:验证服务器证书。
// 如果验证过程失败,TLS/SSL 握手会立即终止,并发出一条包含验证失败原因的警报消息。
// 如果没有发送服务器证书,因为使用了匿名密码,SSL_VERIFY_PEER 将被忽略。
_glootls::SSL_CTX_set_verify(ssl_ctx,
SSL_VERIFY_PEER |
SSL_VERIFY_FAIL_IF_NO_PEER_CERT |
SSL_VERIFY_CLIENT_ONCE,
nullptr);
return ssl_ctx;
}

正经tcp

同样需要创建通信对,缓冲区,以及处理通信等。

1
2
3
4
5
6
7
8
9
10
11
12
std::unique_ptr<transport::Pair>& Context::createPair(int rank) {
pairs_[rank] = std::unique_ptr<transport::Pair>(
new tcp::Pair(this, device_.get(), rank, getTimeout()));
return pairs_[rank];
}

std::unique_ptr<transport::UnboundBuffer> Context::createUnboundBuffer(
void* ptr,
size_t size) {
auto buf = new tcp::UnboundBuffer(shared_from_this(), ptr, size);
return std::unique_ptr<transport::UnboundBuffer>(buf);
}

以下是处理通信的过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
void Context::recvFromAny(
UnboundBuffer* buf,
uint64_t slot,
size_t offset,
size_t nbytes,
std::vector<int> srcRanks) {
for (;;) {
// Find rank of pair we can attempt a recv from
auto rank = recvFromAnyFindRank(buf, slot, offset, nbytes, srcRanks);
if (rank == -1) {
return;
}
// Try recv from returned rank
auto ptr = pairs_[rank].get();
GLOO_ENFORCE(ptr != nullptr);
auto pair = dynamic_cast<Pair*>(ptr);
GLOO_ENFORCE(pair != nullptr);
if (pair->tryRecv(buf, slot, offset, nbytes)) {
return;
}
}
}

int Context::recvFromAnyFindRank(
UnboundBuffer* buf,
uint64_t slot,
size_t offset,
size_t nbytes,
const std::vector<int>& srcRanks) {
std::unique_lock<std::mutex> lock(mutex_);

// See if there is a remote pending send that can fulfill this recv.
auto it = findPendingOperations(slot);
if (it != pendingOperations_.end()) {
auto& pendingOperation = *it;

// Out of all remote pending sends, find the first one
// that exists in the set of eligible ranks.
for (const auto rank : pendingOperation.getSendList()) {
for (const auto srcRank : srcRanks) {
if (rank == srcRank) {
// 我们找到了一个可以满足这个recv的等级。
// 此函数的调用者将尝试进行recv,如果该远程挂起发送操作仍然存在,它将删除它。
//
return rank;
}
}
}
}

// No candidates; register buffer for recv
pendingRecv_[slot].emplace_back(
buf->getWeakNonOwningPtr(),
offset,
nbytes,
std::unordered_set<int>(srcRanks.begin(), srcRanks.end()));
return -1;
}

// Allowed to be called only by ContextMutator::findRecvFromAny,
// where the context lock is already held.
bool Context::findRecvFromAny(
uint64_t slot,
int rank,
WeakNonOwningPtr<UnboundBuffer>* buf,
size_t* offset,
size_t* nbytes) {
// See if there is a pending recv for this slot.
auto pit = pendingRecv_.find(slot);
if (pit != pendingRecv_.end()) {
auto& recvs = pit->second;

// Iterate over available buffers to find a match.
for (auto rit = recvs.begin(); rit != recvs.end(); rit++) {
const auto& ranks = std::get<3>(*rit);

// 如果此对等点的rank在此插槽的可接受rank集中,我们可以继续并将缓冲区返回到 recv 中。
if (ranks.count(rank) > 0) {
// Capture values to return.
*buf = std::get<0>(*rit);
*offset = std::get<1>(*rit);
*nbytes = std::get<2>(*rit);
// Cleanup.
recvs.erase(rit);
if (recvs.empty()) {
pendingRecv_.erase(pit);
}
return true;
}
}
}

return false;
}

loop

其中包含了epoll的使用方法,单独拿出来

创建一个epoll

1
2
3
4
5
Loop::Loop() {
fd_ = epoll_create(1);
GLOO_ENFORCE_NE(fd_, -1, "epoll_create: ", strerror(errno));
loop_.reset(new std::thread(&Loop::run, this));
}

epoll_ctl,用于操作epoll函数所生成的实例。

1
2
#include <sys / epoll.h>
int epoll_ctl(int epfd,int op,int fd,struct epoll_event * event);

该系统调用对文件描述符epfd引用的epoll实例执行控制操作。它要求操作op对目标文件描述符fd执行。op参数的有效值为:

  • EPOLL_CTL_ADD:在文件描述符epfd所引用的epoll实例上注册目标文件描述符fd,并将事件事件与内部文件链接到fd。
  • EPOLL_CTL_MOD:更改与目标文件描述符fd相关联的事件事件。
  • EPOLL_CTL_DEL:从epfd引用的epoll实例中删除(注销)目标文件描述符fd。该事件将被忽略,并且可以为NULL。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void Loop::registerDescriptor(int fd, int events, Handler* h) {
struct epoll_event ev;
ev.events = events;
ev.data.ptr = h;

auto rv = epoll_ctl(fd_, EPOLL_CTL_ADD, fd, &ev);
if (rv == -1 && errno == EEXIST) {
rv = epoll_ctl(fd_, EPOLL_CTL_MOD, fd, &ev);
}
GLOO_ENFORCE_NE(rv, -1, "epoll_ctl: ", strerror(errno));
}

void Loop::unregisterDescriptor(int fd, Handler* h) {
auto rv = epoll_ctl(fd_, EPOLL_CTL_DEL, fd, nullptr);
GLOO_ENFORCE_NE(rv, -1, "epoll_ctl: ", strerror(errno));

// Wait for loop to tick before returning, to make sure the handler
// for this fd is not called once this function returns.
if (std::this_thread::get_id() != loop_->get_id()) {
std::unique_lock<std::mutex> lock(m_);
cv_.wait(lock);
TSAN_ANNOTATE_HAPPENS_AFTER(h);
}
}

等待某个epoll完成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void Loop::run() {
std::array<struct epoll_event, capacity_> events;
int nfds;

while (!done_) {
// Wakeup everyone waiting for a loop tick to finish.
cv_.notify_all();

// Wait for something to happen
nfds = epoll_wait(fd_, events.data(), events.size(), 10);
if (nfds == 0) {
continue;
}
if (nfds == -1 && errno == EINTR) {
continue;
}

GLOO_ENFORCE_NE(nfds, -1);

for (int i = 0; i < nfds; i++) {
Handler* h = reinterpret_cast<Handler*>(events[i].data.ptr);
h->handleEvents(events[i].events);
TSAN_ANNOTATE_HAPPENS_BEFORE(h);
}
}
}

mpi

mpi相关的通信操作,MPIScope应该是MPI的上下文什么的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
std::shared_ptr<MPIScope> getMPIScope() {
static std::once_flag once;

// Use weak pointer so that the initializer is destructed when the
// last context referring to it is destructed, not when statics
// are destructed on program termination.
static std::weak_ptr<MPIScope> wptr;
std::shared_ptr<MPIScope> sptr;

// Create MPIScope only once
std::call_once(once, [&]() {
sptr = std::make_shared<MPIScope>();
wptr = sptr;
});

// Create shared_ptr<MPIScope> from weak_ptr
sptr = wptr.lock();
GLOO_ENFORCE(sptr, "Cannot create MPI context after MPI_Finalize()");
return sptr;
}

返回MPI上下文(通信域)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
std::shared_ptr<Context> Context::createManaged() {
auto mpiScope = getMPIScope();
auto context = std::make_shared<Context>(MPI_COMM_WORLD);
context->mpiScope_ = std::move(mpiScope);
return context;
}

Context::Context(const MPI_Comm& comm)
: ::gloo::Context(MPICommRank(comm), MPICommSize(comm)) {
auto error = MPI_Comm_dup(comm, &comm_);
GLOO_ENFORCE(error == MPI_SUCCESS, "MPI_Comm_dup: ", error);
}

Context::~Context() {
MPI_Comm_free(&comm_);
}

为本进程和其他进程创建通信对pair和缓冲区

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
void Context::connectFullMesh(std::shared_ptr<transport::Device>& dev) {
std::vector<std::vector<char>> addresses(size);
unsigned long maxLength = 0;
int rv;

// Create pair to connect to every other node in the collective
auto transportContext = dev->createContext(rank, size);
transportContext->setTimeout(getTimeout());
for (int i = 0; i < size; i++) {
if (i == rank) {
continue;
}

auto& pair = transportContext->createPair(i);

// Store address for pair for this rank
auto address = pair->address().bytes();
maxLength = std::max(maxLength, address.size());
addresses[i] = std::move(address);
}

// Agree on maximum length so we can prepare buffers
rv = MPI_Allreduce(
MPI_IN_PLACE, &maxLength, 1, MPI_UNSIGNED_LONG, MPI_MAX, comm_);
if (rv != MPI_SUCCESS) {
GLOO_THROW_IO_EXCEPTION("MPI_Allreduce: ", rv);
}

// Prepare input and output
std::vector<char> in(size * maxLength);
std::vector<char> out(size * size * maxLength);
for (int i = 0; i < size; i++) {
if (i == rank) {
continue;
}

auto& address = addresses[i];
memcpy(in.data() + (i * maxLength), address.data(), address.size());
}

// Allgather to collect all addresses of all pairs
rv = MPI_Allgather(
in.data(), in.size(), MPI_BYTE, out.data(), in.size(), MPI_BYTE, comm_);
if (rv != MPI_SUCCESS) {
GLOO_THROW_IO_EXCEPTION("MPI_Allgather: ", rv);
}

// Connect every pair
for (int i = 0; i < size; i++) {
if (i == rank) {
continue;
}

auto offset = (rank + i * size) * maxLength;
std::vector<char> address(maxLength);
memcpy(address.data(), out.data() + offset, maxLength);
transportContext->getPair(i)->connect(address);
}

device_ = dev;
transportContext_ = std::move(transportContext);
}

通信

首先得获取本进程的左方和右方

1
2
3
4
5
6
7
8
9
10
11
12
13
// Helper for ring algorithms
std::unique_ptr<transport::Pair>& Algorithm::getLeftPair() {
auto rank = (context_->size + context_->rank - 1) % context_->size;
GLOO_ENFORCE(context_->getPair(rank), "pair missing (index ", rank, ")");
return context_->getPair(rank);
}

// Helper for ring algorithms
std::unique_ptr<transport::Pair>& Algorithm::getRightPair() {
auto rank = (context_->rank + 1) % context_->size;
GLOO_ENFORCE(context_->getPair(rank), "pair missing (index ", rank, ")");
return context_->getPair(rank);
}

gloo支持reduce的一些操作,对于一些reduce时自定义的方法,gloo也做了兼容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// Type of reduction function.
// 如果reduce类型是内置类型之一,则算法实现可以使用加速版本(如果可用)。
// 例如,如果将 ReductionType 等于 SUM 的 ReductionFunction 传递给 CUDA 感知的 Allreduce,它知道它可以使用 NCCL 实现而不是指定的函数。
//
enum ReductionType {
SUM = 1,
PRODUCT = 2,
MAX = 3,
MIN = 4,

// Use larger number so we have plenty of room to add built-ins
CUSTOM = 1000,
};

template <typename T>
class ReductionFunction {
public:
using Function = void(T*, const T*, size_t n);

static const ReductionFunction<T>* sum;
static const ReductionFunction<T>* product;
static const ReductionFunction<T>* min;
static const ReductionFunction<T>* max;

ReductionFunction(ReductionType type, Function* fn)
: type_(type), fn_(fn) {}

ReductionType type() const {
return type_;
}

void call(T* x, const T* y, size_t n) const {
fn_(x, y, n);
}

protected:
ReductionType type_;
Function* fn_;
};

template <typename T>
const ReductionFunction<T>* ReductionFunction<T>::sum =
new ReductionFunction<T>(SUM, &::gloo::sum<T>);
template <typename T>
const ReductionFunction<T>* ReductionFunction<T>::product =
new ReductionFunction<T>(PRODUCT, &::gloo::product<T>);
template <typename T>
const ReductionFunction<T>* ReductionFunction<T>::min =
new ReductionFunction<T>(MIN, &::gloo::min<T>);
template <typename T>
const ReductionFunction<T>* ReductionFunction<T>::max =
new ReductionFunction<T>(MAX, &::gloo::max<T>);

// Local operation.
// If an algorithm uses multiple local pointers, local operations
// can be used for local reduction, broadcast, gathering, etc.
template <typename T>
class LocalOp {
public:
virtual ~LocalOp() noexcept(false) {}
virtual void runAsync() = 0;
virtual void wait() = 0;

// Synchronous run is equal to asynchronous run and wait.
inline void run() {
runAsync();
wait();
}
};

allgather

AllgatherRing 类似于 MPI_Allgather,所有进程都从所有其他进程接收缓冲区(inPtrs)。 调用者需要传递一个预先分配的接收缓冲区 (outPtr),其大小等于[ 上下文大小 x 发送缓冲区的总大小] (inPtrs),其中 rank = k 的进程的发送缓冲区将被写入 outPtr[k * 输入缓冲区数量 * count] 连续。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
template <typename T>
class AllgatherRing : public Algorithm {
public:
AllgatherRing(
const std::shared_ptr<Context>& context,
const std::vector<const T*>& inPtrs,
T* outPtr,
int count)
: Algorithm(context),
inPtrs_(inPtrs),
outPtr_(outPtr),
count_(count),
bytes_(count * sizeof(T)),
inputStride_(count_ * inPtrs_.size()),
leftPair_(this->getLeftPair()),
rightPair_(this->getRightPair()) {
auto slot = this->context_->nextSlot();

// std::unique_ptr<transport::Buffer>
sendDataBuf_ = rightPair_->createSendBuffer(
slot, outPtr_, inPtrs_.size() * context_->size * bytes_);
recvDataBuf_ = leftPair_->createRecvBuffer(
slot, outPtr_, inPtrs_.size() * context_->size * bytes_);

auto notificationSlot = this->context_->nextSlot();

// std::unique_ptr<transport::Buffer>
sendNotificationBuf_ =
leftPair_->createSendBuffer(notificationSlot, &dummy_, sizeof(dummy_));
recvNotificationBuf_ =
rightPair_->createRecvBuffer(notificationSlot, &dummy_, sizeof(dummy_));
}

真正运行的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
  void run() {
const int rank = this->contextRank_;
const int numRounds = this->contextSize_ - 1;

// Copy local buffers.
for (int i = 0; i < inPtrs_.size(); i++) {
memcpy(outPtr_ + rank * inputStride_ + i * count_, inPtrs_[i], bytes_);
}

// We send input buffers in order.
for (int i = 0; i < inPtrs_.size(); i++) {
// We start every iteration by sending local buffer.
int inRank = rank;

// 10个进程,就是9个round。1号进程,第一个round给0,第二个round给9,第三个round给8...
for (int round = 0; round < numRounds; round++) {
const int sendOffset = inRank * inputStride_ + i * count_;
sendDataBuf_->send(
sendOffset * sizeof(T), bytes_, sendOffset * sizeof(T));
recvDataBuf_->waitRecv();

// Nodes receive data from the left node in every round and forward it
// to the right node.
inRank = (numRounds - round + rank) % this->contextSize_;

// Send notification to node on the left that this node is ready for an
// inbox write.
sendNotificationBuf_->send();

// Wait for notification from node on the right.
recvNotificationBuf_->waitRecv();
}
}
}

private:
const std::vector<const T*> inPtrs_;
T* outPtr_;
const int count_;
const int bytes_;
const int inputStride_;

std::unique_ptr<transport::Pair>& leftPair_;
std::unique_ptr<transport::Pair>& rightPair_;

std::unique_ptr<transport::Buffer> sendDataBuf_;
std::unique_ptr<transport::Buffer> recvDataBuf_;

int dummy_;

std::unique_ptr<transport::Buffer> sendNotificationBuf_;
std::unique_ptr<transport::Buffer> recvNotificationBuf_;
};

一般的allgather

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
void allgather(AllgatherOptions& opts) {
const auto& context = opts.context;
transport::UnboundBuffer* in = opts.in.get();
transport::UnboundBuffer* out = opts.out.get();
const auto slot = Slot::build(kAllgatherSlotPrefix, opts.tag);

const auto recvRank = (context->size + context->rank - 1) % context->size;
const auto sendRank = (context->size + context->rank + 1) % context->size;

const size_t inBytes = out->size / context->size;
const size_t outBytes = out->size;

// If the input buffer is specified, this is NOT an in place operation,
// and the output buffer needs to be primed with the input.
if (in != nullptr) {
memcpy(
static_cast<uint8_t*>(out->ptr) + context->rank * in->size,
static_cast<uint8_t*>(in->ptr),
in->size);
}

// Short circuit if there is only a single process.
if (context->size == 1) {
return;
}

// The chunk size may not be divisible by 2; use dynamic lookup.
std::array<size_t, 2> chunkSize;
chunkSize[0] = inBytes / 2;
chunkSize[1] = inBytes - chunkSize[0];
std::array<size_t, 2> chunkOffset;
chunkOffset[0] = 0;
chunkOffset[1] = chunkSize[0];

// 10个进程,1号进程
// send to 2,recv from 0
// send seg = 11 11 10 10 9 9 8 8 ...
// recv seg = 10 10 9 9 8 8 7 7 ...
// i & 0x1 = 0 1 0 1 0 1 0 1 ...

for (auto i = 0; i < (context->size - 1) * 2; i++) {
const size_t sendSegment = context->size + context->rank - (i / 2);
const size_t recvSegment = sendSegment - 1;
size_t sendOffset =
((sendSegment * inBytes) + chunkOffset[i & 0x1]) % outBytes;
size_t recvOffset =
((recvSegment * inBytes) + chunkOffset[i & 0x1]) % outBytes;
size_t size = chunkSize[i & 0x1];
if (i < 2) {
out->send(sendRank, slot, sendOffset, size);
out->recv(recvRank, slot, recvOffset, size);
continue;
}

// Wait for pending operations to complete to synchronize with the
// previous iteration. Because we kick off two operations before
// getting here we always wait for the next-to-last operation.
out->waitSend(opts.timeout);
out->waitRecv(opts.timeout);
out->send(sendRank, slot, sendOffset, size);
out->recv(recvRank, slot, recvOffset, size);
}

// Wait for completes
for (auto i = 0; i < 2; i++) {
out->waitSend(opts.timeout);
out->waitRecv(opts.timeout);
}
}

allgatherv

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
void allgatherv(AllgathervOptions& opts) {
const auto& context = opts.context;
transport::UnboundBuffer* in = opts.in.get();
transport::UnboundBuffer* out = opts.out.get();
const auto slot = Slot::build(kAllgatherSlotPrefix, opts.tag);

const auto recvRank = (context->size + context->rank - 1) % context->size;
const auto sendRank = (context->size + context->rank + 1) % context->size;

// 计算每个进程对应的长度和偏移
std::vector<size_t> byteCounts;
std::vector<size_t> byteOffsets;
byteCounts.reserve(context->size);
byteOffsets.reserve(context->size);
size_t offset = 0;
for (const auto& elements : opts.elements) {
const auto bytes = elements * opts.elementSize;
byteCounts.push_back(bytes);
byteOffsets.push_back(offset);
offset += bytes;
}

// 如果指定了输入缓冲区,则需要准备输出缓冲区。
if (in != nullptr) {
GLOO_ENFORCE_EQ(byteCounts[context->rank], in->size);
if (byteCounts[context->rank] > 0) {
memcpy(
static_cast<uint8_t*>(out->ptr) + byteOffsets[context->rank],
static_cast<uint8_t*>(in->ptr),
in->size);
}
}

// Short circuit if there is only a single process.
if (context->size == 1) {
return;
}

const auto baseIndex = context->size + context->rank;
for (auto i = 0; i < context->size - 1; i++) {
const size_t sendIndex = (baseIndex - i) % context->size;
const size_t recvIndex = (baseIndex - i - 1) % context->size;

if (i == 0) {
out->send(sendRank, slot, byteOffsets[sendIndex], byteCounts[sendIndex]);
out->recv(recvRank, slot, byteOffsets[recvIndex], byteCounts[recvIndex]);
continue;
}

// Wait for previous operations to complete before kicking off new ones.
out->waitSend(opts.timeout);
out->waitRecv(opts.timeout);
out->send(sendRank, slot, byteOffsets[sendIndex], byteCounts[sendIndex]);
out->recv(recvRank, slot, byteOffsets[recvIndex], byteCounts[recvIndex]);
}

// Wait for final operations to complete.
out->waitSend(opts.timeout);
out->waitRecv(opts.timeout);
}

allreduce

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
using BufferVector = std::vector<std::unique_ptr<transport::UnboundBuffer>>;
using ReductionFunction = AllreduceOptions::Func;
using ReduceRangeFunction = std::function<void(size_t, size_t)>;
using BroadcastRangeFunction = std::function<void(size_t, size_t)>;

// Forward declaration of ring algorithm implementation.
void ring(
const detail::AllreduceOptionsImpl& opts,
ReduceRangeFunction reduceInputs,
BroadcastRangeFunction broadcastOutputs);

// Forward declaration of bcube algorithm implementation.
void bcube(
const detail::AllreduceOptionsImpl& opts,
ReduceRangeFunction reduceInputs,
BroadcastRangeFunction broadcastOutputs);

// ReductionFunction type describes the function to use for element wise reduction.
//
// Its arguments are:
// 1. non-const output pointer
// 2. const input pointer 1 (may be equal to 1)
// 3. const input pointer 2 (may be equal to 1)
// 4. number of elements to reduce.
//
// 请注意,此函数不是严格类型的,并且采用 void 指针。
// 这样做是为了避免需要模板化选项类和模板化算法实现。
// 我们发现这对编译时间和代码大小的增加几乎没有任何价值。s

// 返回计算输入的局部reduce并将其存储在这些缓冲区中给定范围的输出中的函数。
// 这是在向邻居发送区域或reduce从邻居接收的区域之前完成的。
ReduceRangeFunction genLocalReduceFunction(
const BufferVector& in, // UnboundBuffer的unique_ptr的vector
const BufferVector& out,
size_t elementSize,
ReductionFunction fn) {
// 根据传进来的buffer长度,执行reduce函数
if (in.size() > 0) {
if (in.size() == 1) {
return [&in, &out](size_t offset, size_t length) {
memcpy(
static_cast<uint8_t*>(out[0]->ptr) + offset,
static_cast<const uint8_t*>(in[0]->ptr) + offset,
length);
};
} else {
return [&in, &out, elementSize, fn](size_t offset, size_t length) {
fn(static_cast<uint8_t*>(out[0]->ptr) + offset,
static_cast<const uint8_t*>(in[0]->ptr) + offset,
static_cast<const uint8_t*>(in[1]->ptr) + offset,
length / elementSize);
for (size_t i = 2; i < in.size(); i++) {
fn(static_cast<uint8_t*>(out[0]->ptr) + offset,
static_cast<const uint8_t*>(out[0]->ptr) + offset,
static_cast<const uint8_t*>(in[i]->ptr) + offset,
length / elementSize);
}
};
}
} else {
return [&out, elementSize, fn](size_t offset, size_t length) {
for (size_t i = 1; i < out.size(); i++) {
fn(static_cast<uint8_t*>(out[0]->ptr) + offset,
static_cast<const uint8_t*>(out[0]->ptr) + offset,
static_cast<const uint8_t*>(out[i]->ptr) + offset,
length / elementSize);
}
};
}
}

// 返回对缓冲区中给定范围的输出执行本地广播的函数。 这是在接收到每个全局reduce的块之后执行的。
BroadcastRangeFunction genLocalBroadcastFunction(const BufferVector& out) {
return [&out](size_t offset, size_t length) {
for (size_t i = 1; i < out.size(); i++) {
memcpy(
static_cast<uint8_t*>(out[i]->ptr) + offset,
static_cast<const uint8_t*>(out[0]->ptr) + offset,
length);
}
};
}

void allreduce(const detail::AllreduceOptionsImpl& opts) {
if (opts.elements == 0) {
return;
}

const auto& context = opts.context;
const std::vector<std::unique_ptr<transport::UnboundBuffer>>& in = opts.in;
const std::vector<std::unique_ptr<transport::UnboundBuffer>>& out = opts.out;
const auto slot = Slot::build(kAllreduceSlotPrefix, opts.tag);

// 初始化本地归约和广播功能。
// 请注意,如果仅指定单个输出并将其用作输入和输出,则这些是无操作的。
const auto reduceInputs =
genLocalReduceFunction(in, out, opts.elementSize, opts.reduce);
const auto broadcastOutputs = genLocalBroadcastFunction(out);

// Simple circuit if there is only a single process.
if (context->size == 1) {
reduceInputs(0, totalBytes);
broadcastOutputs(0, totalBytes);
return;
}

switch (opts.algorithm) {
case detail::AllreduceOptionsImpl::UNSPECIFIED:
case detail::AllreduceOptionsImpl::RING:
ring(opts, reduceInputs, broadcastOutputs);
break;
case detail::AllreduceOptionsImpl::BCUBE:
bcube(opts, reduceInputs, broadcastOutputs);
break;
default:
GLOO_ENFORCE(false, "Algorithm not handled.");
}
}

allreduce的ring方法

给定的输入被分成与进程数相等的块数。 算法完成后,每个进程按顺序托管一个reduction输出块(rank 0 具有块 0,rank 1 具有块 1,等等)。由于输入可能不能被进程数整除,因此最终的块有部分输出或可能为空。

当一个块沿着环传递并且包含连续更多rank的reduction时,我们必须在为该块执行 I/O 和计算接收到的块和本地块之间的reduction之间交替。为了避免这种交替模式,我们将一个块分成多个段(> = 2),并确保我们有一个段在运行,同时计算另一个段的reduction。

段大小有一个上限,以最大限度地减少内存使用并避免不良的缓存行为。 这意味着在处理非常大的输入时,每个块可能有很多段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
void ring(
const detail::AllreduceOptionsImpl& opts,
ReduceRangeFunction reduceInputs,
BroadcastRangeFunction broadcastOutputs) {
const auto& context = opts.context;
const std::vector<std::unique_ptr<transport::UnboundBuffer>>& out = opts.out;
const auto slot = Slot::build(kAllreduceSlotPrefix, opts.tag);
const size_t totalBytes = opts.elements * opts.elementSize;

// Note: context->size > 1
const auto recvRank = (context->size + context->rank + 1) % context->size;
const auto sendRank = (context->size + context->rank - 1) % context->size;

// 确保最大段大小是元素大小的倍数。
// 否则,在向上舍入到元素大小的最接近倍数后,段大小可能会超过最大段大小。
// 例如,如果maxSegmentSize = 10,而elementSize = 4,则向上取整后:segmentSize = 12;
const size_t maxSegmentBytes = opts.elementSize *
std::max((size_t)1, opts.maxSegmentSize / opts.elementSize);

// Compute how many segments make up the input buffer.
//
// 向上舍入到上下文大小的最接近的倍数,以便每个进程有相同数量的段,并且跨进程的执行是对称的。
// 最小值是上下文大小的两倍,因为下面的算法将发送/接收一个段与计算另一个段的reduction。
const size_t numSegments = roundUp(
std::max(
(totalBytes + (maxSegmentBytes - 1)) / maxSegmentBytes,
(size_t)context->size * 2),
(size_t)context->size);

const size_t numSegmentsPerRank = numSegments / context->size;
const size_t segmentBytes =
roundUp((totalBytes + numSegments - 1) / numSegments, opts.elementSize);

// Allocate scratch space to hold two chunks
std::unique_ptr<uint8_t[]> tmpAllocation(new uint8_t[segmentBytes * 2]);
std::unique_ptr<transport::UnboundBuffer> tmpBuffer =
context->createUnboundBuffer(tmpAllocation.get(), segmentBytes * 2);
transport::UnboundBuffer* tmp = tmpBuffer.get();

// 使用动态查找临时缓冲区中的块偏移量。
// 在进行两个操作时,我们需要两个偏移量。 可以使用循环计数器对它们进行索引。
std::array<size_t, 2> segmentOffset;
segmentOffset[0] = 0;
segmentOffset[1] = segmentBytes;

// 计算在reduce/scatter为给定迭代发送和接收的段的偏移量和长度。
auto computeReduceScatterOffsets = [&](size_t i) {
struct {
size_t sendOffset;
size_t recvOffset;
ssize_t sendLength;
ssize_t recvLength;
} result;

// 计算要发送的段索引(到 rank-1)和要接收的段索引(从 rank+1)。
// 乘以块中的字节数以获得偏移量。
// 允许偏移量超出范围(>=totalBytes),计算相关长度时会考虑到这一点。
result.sendOffset =
((((context->rank + 1) * numSegmentsPerRank) + i) * segmentBytes) %
(numSegments * segmentBytes);
result.recvOffset =
((((context->rank + 2) * numSegmentsPerRank) + i) * segmentBytes) %
(numSegments * segmentBytes);

// If the segment is entirely in range, the following statement is
// equal to segmentBytes. If it isn't, it will be less, or even
// negative. This is why the ssize_t typecasts are needed.
result.sendLength = std::min(
(ssize_t)segmentBytes,
(ssize_t)totalBytes - (ssize_t)result.sendOffset);
result.recvLength = std::min(
(ssize_t)segmentBytes,
(ssize_t)totalBytes - (ssize_t)result.recvOffset);

return result;
};

// Ring reduce/scatter.
//
// 迭代次数计算如下:
// - 使用 `numSegments` 作为段的总数,
// - 减去 `numSegmentsPerRank`,因为最终段包含部分结果,在此阶段不得转发。
// - 添加 2,因为我们通过管道发送和接收操作(我们在迭代 0 和 1 上发出发送/接收操作并等待它们在迭代 2 和 3 上完成)。
//
for (auto i = 0; i < (numSegments - numSegmentsPerRank + 2); i++) {
if (i >= 2) {
// 计算两次迭代前的发送和接收偏移量和长度。
// 需要这样我们知道何时等待操作以及何时忽略(当偏移量超出范围时),并知道在哪里减少临时缓冲区的内容。
auto prev = computeReduceScatterOffsets(i - 2);
if (prev.recvLength > 0) {
// Prepare out[0]->ptr to hold the local reduction
reduceInputs(prev.recvOffset, prev.recvLength);
// Wait for segment from neighbor.
tmp->waitRecv(opts.timeout);
// 对收到的段进行reduce
opts.reduce(
static_cast<uint8_t*>(out[0]->ptr) + prev.recvOffset,
static_cast<const uint8_t*>(out[0]->ptr) + prev.recvOffset,
static_cast<const uint8_t*>(tmp->ptr) + segmentOffset[i & 0x1],
prev.recvLength / opts.elementSize);
}
if (prev.sendLength > 0) {
out[0]->waitSend(opts.timeout);
}
}

// 在最后两次迭代之外的所有迭代中发出新的发送和接收操作。
// 那时我们已经发送了我们需要的所有数据,只需要等待最终的段被reduce到输出中。
if (i < (numSegments - numSegmentsPerRank)) {
// Compute send and receive offsets and lengths for this iteration.
auto cur = computeReduceScatterOffsets(i);
if (cur.recvLength > 0) {
tmp->recv(recvRank, slot, segmentOffset[i & 0x1], cur.recvLength);
}
if (cur.sendLength > 0) {
// Prepare out[0]->ptr to hold the local reduction for this segment
if (i < numSegmentsPerRank) {
reduceInputs(cur.sendOffset, cur.sendLength);
}
out[0]->send(sendRank, slot, cur.sendOffset, cur.sendLength);
}
}
}

// Function computes the offsets and lengths of the segments to be
// sent and received for a given iteration during allgather.
auto computeAllgatherOffsets = [&](size_t i) {
struct {
size_t sendOffset;
size_t recvOffset;
ssize_t sendLength;
ssize_t recvLength;
} result;

result.sendOffset =
((((context->rank) * numSegmentsPerRank) + i) * segmentBytes) %
(numSegments * segmentBytes);
result.recvOffset =
((((context->rank + 1) * numSegmentsPerRank) + i) * segmentBytes) %
(numSegments * segmentBytes);

// If the segment is entirely in range, the following statement is
// equal to segmentBytes. If it isn't, it will be less, or even
// negative. This is why the ssize_t typecasts are needed.
result.sendLength = std::min(
(ssize_t)segmentBytes,
(ssize_t)totalBytes - (ssize_t)result.sendOffset);
result.recvLength = std::min(
(ssize_t)segmentBytes,
(ssize_t)totalBytes - (ssize_t)result.recvOffset);

return result;
};

// Ring allgather.
//
// 注意:totalBytes <= (numSegments * segmentBytes),
// 这与在进程间贡献相同的通用 allgather 算法不兼容。
//
for (auto i = 0; i < (numSegments - numSegmentsPerRank + 2); i++) {
if (i >= 2) {
auto prev = computeAllgatherOffsets(i - 2);
if (prev.recvLength > 0) {
out[0]->waitRecv(opts.timeout);
// Broadcast received segments to output buffers.
broadcastOutputs(prev.recvOffset, prev.recvLength);
}
if (prev.sendLength > 0) {
out[0]->waitSend(opts.timeout);
}
}

// 在最后两次迭代之外的所有迭代中发出新的发送和接收操作。
// 那时我们已经发送了我们需要的所有数据,只需要等待最终的段被发送到输出。
if (i < (numSegments - numSegmentsPerRank)) {
auto cur = computeAllgatherOffsets(i);
if (cur.recvLength > 0) {
out[0]->recv(recvRank, slot, cur.recvOffset, cur.recvLength);
}
if (cur.sendLength > 0) {
out[0]->send(sendRank, slot, cur.sendOffset, cur.sendLength);
// Broadcast first segments to outputs buffers.
if (i < numSegmentsPerRank) {
broadcastOutputs(cur.sendOffset, cur.sendLength);
}
}
}
}
}

// 对于给定的上下文大小和所需的组大小,计算每步的实际组大小。
// 请注意,对于所有步骤,每一步的组大小为 n,仅当 n^(#steps) == 大小时。
// 否则,最终组大小为 != n。
std::vector<size_t> computeGroupSizePerStep(size_t size, const size_t n) {
std::vector<size_t> result;
GLOO_ENFORCE_GT(n, 1);
while (size % n == 0) {
result.push_back(n);
size /= n;
}
if (size > 1) {
result.push_back(size);
}
return result;
}

bcube 算法

bcube 算法实现了一种类似超立方体的reduce策略。约束是进程的数量可以分解。如果分解中的最小分量为 2,并且进程数等于 2 的幂,则该算法与递归减半/加倍相同。

分解中的元素数量决定了算法的步数。分解的每个元素决定了每个进程在算法的特定步骤中与之通信的进程数。如果进程数不可分解,则该算法与直接reduce-scatter 后allgather 相同。

例如,如果#processes == 8,并且我们将其分解为 4 * 2,则算法分 2 步运行。在第一步中,2 组 4 个进程之间交换数据,以使所有进程具有部分结果的 1/4。第二步,4组2个进程交换它们的部分结果,使得所有进程都有1/8的结果。然后,反向执行相同的分解以执行 allgather。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
void bcube(
const detail::AllreduceOptionsImpl& opts,
ReduceRangeFunction reduceInputs,
BroadcastRangeFunction broadcastOutputs) {
const auto& context = opts.context;
const auto slot = Slot::build(kAllreduceSlotPrefix, opts.tag);
const auto elementSize = opts.elementSize;
auto& out = opts.out[0];

constexpr auto n = 2;

// 算出这个算法的步数。
const auto groupSizePerStep = computeGroupSizePerStep(context->size, n);

struct group {
// Distance between peers in this group.
size_t peerDistance;

// Segment that this group is responsible for reducing.
size_t bufferOffset;
size_t bufferLength;

// The process ranks that are a member of this group.
std::vector<size_t> ranks;

// Upper bound of the length of the chunk that each process has the
// reduced values for by the end of the reduction for this group.
size_t chunkLength;

// Chunk within the segment that this process is responsible for reducing.
size_t myChunkOffset;
size_t myChunkLength;
};

// 在每个算法步骤计算组的详细信息。
// 我们将它保存在一个向量中,因为我们在reduce/scatter阶段以正序迭代它,在全聚集阶段以反向顺序迭代它。
std::vector<struct group> groups;
{
struct group group;
group.peerDistance = 1;
group.bufferOffset = 0;
group.bufferLength = opts.elements;
for (const size_t groupSize : groupSizePerStep) {
const size_t groupRank = (context->rank / group.peerDistance) % groupSize;
const size_t baseRank = context->rank - (groupRank * group.peerDistance);
group.ranks.reserve(groupSize);
for (size_t i = 0; i < groupSize; i++) {
group.ranks.push_back(baseRank + i * group.peerDistance);
}
// 每隔groupSize个进程是一组,也就是说一个组内的rank都隔着groupSize

// Compute the length of the chunk we're exchanging at this step.
group.chunkLength = ((group.bufferLength + (groupSize - 1)) / groupSize);

// 此过程正在计算当前段中位于 <rank>/<size> 的块的减少量。
//
group.myChunkOffset =
group.bufferOffset + (groupRank * group.chunkLength);
group.myChunkLength = std::min(
size_t(group.chunkLength),
size_t(std::max(
int64_t(0),
int64_t(group.bufferLength) -
int64_t(groupRank * group.chunkLength))));

// Store a const copy of this group in the vector.
groups.push_back(group);

// 使用更新的对等距离和段偏移和长度进行初始化。
struct group nextGroup;
nextGroup.peerDistance = group.peerDistance * groupSize;
nextGroup.bufferOffset = group.myChunkOffset;
nextGroup.bufferLength = group.myChunkLength;
std::swap(group, nextGroup);
}
}

// 块长度向上取整,因此我们需要的最大暂存空间可能大于输出缓冲区的大小。 计算最大值
size_t bufferLength = opts.elements;
for (const auto& group : groups) {
bufferLength =
std::max(bufferLength, group.ranks.size() * group.chunkLength);
}

// 分配暂存空间以从对等方接收数据。
const size_t bufferSize = bufferLength * elementSize;
std::unique_ptr<uint8_t[]> buffer(new uint8_t[bufferSize]);
std::unique_ptr<transport::UnboundBuffer> tmp =
context->createUnboundBuffer(buffer.get(), bufferSize);

// Reduce/scatter.
for (size_t step = 0; step < groups.size(); step++) {
const auto& group = groups[step];

// 从对等点发出块的接收操作。
for (size_t i = 0; i < group.ranks.size(); i++) {
const auto src = group.ranks[i];
if (src == context->rank) {
continue;
}
tmp->recv(
src,
slot,
i * group.chunkLength * elementSize,
group.myChunkLength * elementSize);
}

// 向对等方发出本地块的发送操作。
for (size_t i = 0; i < group.ranks.size(); i++) {
const auto dst = group.ranks[i];
if (dst == context->rank) {
continue;
}
const size_t currentChunkOffset =
group.bufferOffset + i * group.chunkLength;
const size_t currentChunkLength = std::min(
size_t(group.chunkLength),
size_t(std::max(
int64_t(0),
int64_t(group.bufferLength) - int64_t(i * group.chunkLength))));
// 仅在算法的第一步中计算局部reduce。
// 在随后的步骤中,我们已经得到了部分reduce的结果。
if (step == 0) {
reduceInputs(
currentChunkOffset * elementSize, currentChunkLength * elementSize);
}
out->send(
dst,
slot,
currentChunkOffset * elementSize,
currentChunkLength * elementSize);
}

// Wait for send and receive operations to complete.
for (size_t i = 0; i < group.ranks.size(); i++) {
const auto peer = group.ranks[i];
if (peer == context->rank) {
continue;
}
tmp->waitRecv();
out->waitSend();
}

// 第一步,准备这个进程负责的chunk
// 使用其输入的简化版本(如果指定了多个)。
if (step == 0) {
reduceInputs(
group.myChunkOffset * elementSize, group.myChunkLength * elementSize);
}

// Reduce chunks from peers.
for (size_t i = 0; i < group.ranks.size(); i++) {
const auto src = group.ranks[i];
if (src == context->rank) {
continue;
}
opts.reduce(
static_cast<uint8_t*>(out->ptr) + (group.myChunkOffset * elementSize),
static_cast<const uint8_t*>(out->ptr) +
(group.myChunkOffset * elementSize),
static_cast<const uint8_t*>(tmp->ptr) +
(i * group.chunkLength * elementSize),
group.myChunkLength);
}
}

// 有一个块包含最终结果,并且该块已经可以在本地广播到 out[1..N](如果适用)。
// 这样做意味着我们只需要在本地广播到 out[1..N] 所有块,因为我们在 allgather 阶段从对等方接收到它们。
{
const auto& group = groups.back();
broadcastOutputs(
group.myChunkOffset * elementSize, group.myChunkLength * elementSize);
}

// Allgather.
for (auto it = groups.rbegin(); it != groups.rend(); it++) {
const auto& group = *it;

// Issue receive operations for reduced chunks from peers.
for (size_t i = 0; i < group.ranks.size(); i++) {
const auto src = group.ranks[i];
if (src == context->rank) {
continue;
}
const size_t currentChunkOffset =
group.bufferOffset + i * group.chunkLength;
const size_t currentChunkLength = std::min(
size_t(group.chunkLength),
size_t(std::max(
int64_t(0),
int64_t(group.bufferLength) - int64_t(i * group.chunkLength))));
out->recv(
src,
slot,
currentChunkOffset * elementSize,
currentChunkLength * elementSize);
}

// Issue send operations for reduced chunk to peers.
for (size_t i = 0; i < group.ranks.size(); i++) {
const auto dst = group.ranks[i];
if (dst == context->rank) {
continue;
}
out->send(
dst,
slot,
group.myChunkOffset * elementSize,
group.myChunkLength * elementSize);
}

// Wait for operations to complete.
for (size_t i = 0; i < group.ranks.size(); i++) {
const auto peer = group.ranks[i];
if (peer == context->rank) {
continue;
}
out->waitRecv();
out->waitSend();
}

// Broadcast result to multiple output buffers, if applicable.
for (size_t i = 0; i < group.ranks.size(); i++) {
const auto peer = group.ranks[i];
if (peer == context->rank) {
continue;
}
const size_t currentChunkOffset =
group.bufferOffset + i * group.chunkLength;
const size_t currentChunkLength = std::min(
size_t(group.chunkLength),
size_t(std::max(
int64_t(0),
int64_t(group.bufferLength) - int64_t(i * group.chunkLength))));
broadcastOutputs(
currentChunkOffset * elementSize, currentChunkLength * elementSize);
}
}
}

alltoallv

同alltoall一样,只不过alltoallv的实现多了offset,没有什么高深的算法,只是在send-recv

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
void alltoallv(AlltoallvOptions& opts) {
const auto& context = opts.context;
transport::UnboundBuffer* in = opts.in.get();
transport::UnboundBuffer* out = opts.out.get();
std::vector<size_t>& inOffsetPerRank = opts.inOffsetPerRank;
std::vector<size_t>& inLengthPerRank = opts.inLengthPerRank;
std::vector<size_t>& outOffsetPerRank = opts.outOffsetPerRank;
std::vector<size_t>& outLengthPerRank = opts.outLengthPerRank;
const auto slot = Slot::build(kAlltoallSlotPrefix, opts.tag);

int myRank = context->rank;
int worldSize = context->size;

// Local copy.
GLOO_ENFORCE(inLengthPerRank[myRank] == outLengthPerRank[myRank]);
size_t myInOffset = inOffsetPerRank[myRank];
size_t myOutOffset = outOffsetPerRank[myRank];
size_t myChunkSize = inLengthPerRank[myRank];
memcpy(
static_cast<char*>(out->ptr) + myOutOffset,
static_cast<char*>(in->ptr) + myInOffset,
myChunkSize);

// Remote copy.
for (int i = 1; i < worldSize; i++) {
int sendRank = (myRank + i) % worldSize;
int recvRank = (myRank + worldSize - i) % worldSize;
in->send(
sendRank, slot, inOffsetPerRank[sendRank], inLengthPerRank[sendRank]);
out->recv(
recvRank, slot, outOffsetPerRank[recvRank], outLengthPerRank[recvRank]);
}

for (int i = 1; i < worldSize; i++) {
in->waitSend(opts.timeout);
out->waitRecv(opts.timeout);
}
}

barrier

如果是共有16进程的话,

0号进程会与15 1,14 2,12 4,8 8进程recv send

1号进程会与0 2,15 3,13 5,9 9进程recv send

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void barrier(BarrierOptions& opts) {
const auto& context = opts.context;
auto& buffer = opts.buffer;
const auto slot = Slot::build(kBarrierSlotPrefix, opts.tag);

// Below implements a dissemination barrier, described in "Two algorithms
// for barrier synchronization (1988)" by Hensgen, Finkel and Manber.
// PDF: https://www.inf.ed.ac.uk/teaching/courses/ppls/BarrierPaper.pdf
// DOI: 10.1007/BF01379320

// Instead of iterating over i up to log2(context->size), we immediately
// compute 2^i and compare with context->size.
for (size_t d = 1; d < context->size; d <<= 1) {
buffer->recv((context->size + context->rank - d) % context->size, slot);
buffer->send((context->size + context->rank + d) % context->size, slot);
buffer->waitRecv(opts.timeout);
buffer->waitSend(opts.timeout);
}
}

broadcast

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
void broadcast(BroadcastOptions& opts) {
const auto& context = opts.context;
transport::UnboundBuffer* in = opts.in.get();
transport::UnboundBuffer* out = opts.out.get();
const auto slot = Slot::build(kBroadcastSlotPrefix, opts.tag);

if (context->rank == opts.root) {
in = out;
}

// 将rank映射到根进程rank为 0 的新rank。
const size_t vsize = context->size;
const size_t vrank = (context->rank + vsize - opts.root) % vsize;
const size_t dim = log2ceil(vsize);

// 跟踪未决发送操作的数量。
// 发送操作可以异步完成,因为迭代之间存在依赖关系。
// 这与必须在任何发送操作排队之前完成的 recv 操作不同。
size_t numSends = 0;

// 创建全为 1 的掩码,我们从 LSB 开始逐步将位设置为 0。
// 当应用于虚拟rank的掩码等于 0 时,我们知道该进程必须参与。
// 这导致从虚拟rank 0 和 1 开始的指数级参与.
size_t mask = (1 << dim) - 1;

for (size_t i = 0; i < dim; i++) {
// Clear bit `i`. 在第一次迭代中,虚拟rank 0 和 1 参与。
// 在第二次迭代中,0、1、2 和 3 参与,依此类推。
mask ^= (1 << i);
if ((vrank & mask) != 0) {
continue;
}

// The virtual rank of the peer in this iteration has opposite bit `i`.
auto vpeer = vrank ^ (1 << i);
if (vpeer >= vsize) {
continue;
}

// Map virtual rank of peer to actual rank of peer.
auto peer = (vpeer + opts.root) % vsize;
if ((vrank & (1 << i)) == 0) {
in->send(peer, slot);
numSends++;
} else {
out->recv(peer, slot);
out->waitRecv(opts.timeout);
}
}

// Copy local input to output if applicable.
if (context->rank == opts.root && in != out) {
memcpy(out->ptr, in->ptr, out->size);
}

// Wait on pending sends.
for (auto i = 0; i < numSends; i++) {
in->waitSend(opts.timeout);
}
}

avx优化

一些reduce函数使用了avx。_mm256_cvtph_ps将八个半精度(16 位)浮点值转换为单精度浮点值。_mm256_cvtps_ph将八个单精度浮点值转换为半精度(16 位)浮点值。_mm_storeu_si128将计算结果等SSE暂存器的数据保存到内存中。_mm256_mul_ps对第一个源向量 m1 中的八个压缩单精度浮点元素(float32 元素)与第二个源向量 m2 中的八个 float32 元素执行 SIMD 乘法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73

//假设 x 和 y 要么都对齐到 32 字节,要么未对齐相同的偏移量,就像在对齐缓冲区内的偏移量处减少时会发生的那样
template <>
void sum<float16>(void* c_, const void* a_, const void* b_, size_t n) {
float16* c = static_cast<float16*>(c_);
const float16* a = static_cast<const float16*>(a_);
const float16* b = static_cast<const float16*>(b_);
size_t i;
for (i = 0; i < (n / 8) * 8; i += 8) {
__m256 va32 = _mm256_cvtph_ps(_mm_loadu_si128((__m128i*)(&a[i])));
__m256 vb32 = _mm256_cvtph_ps(_mm_loadu_si128((__m128i*)(&b[i])));
__m128i vc16 = _mm256_cvtps_ph(_mm256_add_ps(va32, vb32), 0);
_mm_storeu_si128((__m128i*)(&c[i]), vc16);
}
// Leftovers
for (; i < n; i++) {
c[i] = a[i] + b[i];
}
}

template <>
void product<float16>(void* c_, const void* a_, const void* b_, size_t n) {
float16* c = static_cast<float16*>(c_);
const float16* a = static_cast<const float16*>(a_);
const float16* b = static_cast<const float16*>(b_);
size_t i;
for (i = 0; i < (n / 8) * 8; i += 8) {
__m256 va32 = _mm256_cvtph_ps(_mm_loadu_si128((__m128i*)(&a[i])));
__m256 vb32 = _mm256_cvtph_ps(_mm_loadu_si128((__m128i*)(&b[i])));
__m128i vc16 = _mm256_cvtps_ph(_mm256_mul_ps(va32, vb32), 0);
_mm_storeu_si128((__m128i*)(&c[i]), vc16);
}
// Leftovers
for (; i < n; i++) {
c[i] = a[i] * b[i];
}
}

template <>
void max<float16>(void* c_, const void* a_, const void* b_, size_t n) {
float16* c = static_cast<float16*>(c_);
const float16* a = static_cast<const float16*>(a_);
const float16* b = static_cast<const float16*>(b_);
size_t i;
for (i = 0; i < (n / 8) * 8; i += 8) {
__m256 va32 = _mm256_cvtph_ps(_mm_loadu_si128((__m128i*)(&a[i])));
__m256 vb32 = _mm256_cvtph_ps(_mm_loadu_si128((__m128i*)(&b[i])));
__m128i vc16 = _mm256_cvtps_ph(_mm256_max_ps(va32, vb32), 0);
_mm_storeu_si128((__m128i*)(&c[i]), vc16);
}
// Leftovers
for (; i < n; i++) {
c[i] = std::max(a[i], b[i]);
}
}

template <>
void min<float16>(void* c_, const void* a_, const void* b_, size_t n) {
float16* c = static_cast<float16*>(c_);
const float16* a = static_cast<const float16*>(a_);
const float16* b = static_cast<const float16*>(b_);
size_t i;
for (i = 0; i < (n / 8) * 8; i += 8) {
__m256 va32 = _mm256_cvtph_ps(_mm_loadu_si128((__m128i*)(&a[i])));
__m256 vb32 = _mm256_cvtph_ps(_mm_loadu_si128((__m128i*)(&b[i])));
__m128i vc16 = _mm256_cvtps_ph(_mm256_min_ps(va32, vb32), 0);
_mm_storeu_si128((__m128i*)(&c[i]), vc16);
}
// Leftovers
for (; i < n; i++) {
c[i] = std::min(a[i], b[i]);
}
}

reduce

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
void reduce(ReduceOptions& opts) {
if (opts.elements == 0) {
return;
}
const auto& context = opts.context;
transport::UnboundBuffer* in = opts.in.get();
transport::UnboundBuffer* out = opts.out.get();
const auto slot = Slot::build(kReduceSlotPrefix, opts.tag);

const auto recvRank = (context->size + context->rank + 1) % context->size;
const auto sendRank = (context->size + context->rank - 1) % context->size;

// If input buffer is not specified, the output is also the input
if (in == nullptr) {
in = out;
}

// 如果只有一个进程,则短路。
if (context->size == 1) {
if (in != out) {
memcpy(out->ptr, in->ptr, opts.elements * opts.elementSize);
}
return;
}

// The ring algorithm works as follows.
//
// 给定的输入被分成与进程数相等的块数。
// 算法完成后,每个进程按顺序托管一个reduce输出块(rank 0 具有块 0,rank 1 具有块 1,等等)。
// 由于输入可能不能被进程数整除,因此最终的块可能有部分输出或可能为空。
//
// 当一个块沿着环传递并包含连续更多rank的reduce时,我们必须在为该块执行 I/O 和计算接收到的块和本地块之间的减少之间交替。
// 为了避免这种交替模式,我们将一个块分成多个段(> = 2),并确保我们有一个段在运行,同时计算另一个段的reduce。
// 段大小有一个上限,以最大限度地减少内存使用并避免不良的缓存行为。这意味着在处理非常大的输入时,每个块可能有很多段。
//
// 这里的命名法反映在下面的变量命名中(每个rank一个块,每个块多个段)。
//
const size_t totalBytes = opts.elements * opts.elementSize;

// 确保最大段大小是元素大小的倍数。 否则,在向上舍入到元素大小的最接近倍数后,段大小可能会超过最大段大小。 例如,如果maxSegmentSize = 10,而elementSize = 4,则向上取整后:segmentSize = 12;
const size_t maxSegmentSize =
opts.elementSize * (opts.maxSegmentSize / opts.elementSize);

// 每个段的字节数必须是每个元素的字节数的倍数才能进行缩减; 必要时四舍五入。
const size_t segmentBytes = roundUp(
std::min(
// Rounded division to have >= 2 segments per chunk.
(totalBytes + (context->size * 2 - 1)) / (context->size * 2),
// Configurable segment size limit
maxSegmentSize),
opts.elementSize);

// Compute how many segments make up the input buffer.
//
// 向上舍入到上下文大小的最接近的倍数,以便每个进程有相同数量的段,并且跨进程的执行是对称的。
// 最小值是上下文大小的两倍,因为下面的算法将发送/接收一个段与计算另一个段的缩减重叠。
//
const size_t numSegments = roundUp(
std::max(
(totalBytes + (segmentBytes - 1)) / segmentBytes,
(size_t)context->size * 2),
(size_t)context->size);
const size_t numSegmentsPerRank = numSegments / context->size;
const size_t chunkBytes = numSegmentsPerRank * segmentBytes;

// 分配暂存空间以容纳两个块
std::unique_ptr<uint8_t[]> tmpAllocation(new uint8_t[segmentBytes * 2]);
std::unique_ptr<transport::UnboundBuffer> tmpBuffer =
context->createUnboundBuffer(tmpAllocation.get(), segmentBytes * 2);
transport::UnboundBuffer* tmp = tmpBuffer.get();

// 使用动态查找临时缓冲区中的块偏移量。
// 在进行两个操作时,我们需要两个偏移量。
// 可以使用循环计数器对它们进行索引。
std::array<size_t, 2> segmentOffset;
segmentOffset[0] = 0;
segmentOffset[1] = segmentBytes;

// 函数计算给定块迭代要发送和接收的块的偏移量和长度。
auto computeReduceScatterOffsets = [&](size_t i) {
struct {
size_t sendOffset;
size_t recvOffset;
ssize_t sendLength;
ssize_t recvLength;
} result;

// 计算要发送的段索引(到 rank - 1)和要接收的段索引(从 rank + 1)。
// 乘以块中的字节数以获得偏移量。
// 允许偏移量超出范围(>=totalBytes),计算相关长度时会考虑到这一点。
result.sendOffset =
((((context->rank + 1) * numSegmentsPerRank) + i) * segmentBytes) %
(numSegments * segmentBytes);
result.recvOffset =
((((context->rank + 2) * numSegmentsPerRank) + i) * segmentBytes) %
(numSegments * segmentBytes);

// 如果段完全在范围内,则以下语句等于段字节。
// 如果不是,它会更少,甚至是负面的。 这就是需要 ssize_t 类型转换的原因。
result.sendLength = std::min(
(ssize_t)segmentBytes,
(ssize_t)totalBytes - (ssize_t)result.sendOffset);
result.recvLength = std::min(
(ssize_t)segmentBytes,
(ssize_t)totalBytes - (ssize_t)result.recvOffset);

return result;
};

for (auto i = 0; i < numSegments; i++) {
if (i >= 2) {
// 计算两次迭代前的发送和接收偏移量和长度。
// 需要这样我们知道何时等待操作以及何时忽略(当偏移量超出范围时),
// 并知道在哪里减少临时缓冲区的内容。
auto prev = computeReduceScatterOffsets(i - 2);
if (prev.recvLength > 0) {
tmp->waitRecv(opts.timeout);
opts.reduce(
static_cast<uint8_t*>(out->ptr) + prev.recvOffset,
static_cast<const uint8_t*>(in->ptr) + prev.recvOffset,
static_cast<const uint8_t*>(tmp->ptr) + segmentOffset[i & 0x1],
prev.recvLength / opts.elementSize);
}
if (prev.sendLength > 0) {
if ((i - 2) < numSegmentsPerRank) {
in->waitSend(opts.timeout);
} else {
out->waitSend(opts.timeout);
}
}
}

// 在最后两次迭代之外的所有迭代中发出新的发送和接收操作。
// 那时我们已经发送了我们需要的所有数据,只需要等待最终的段被减少到输出中。
if (i < (numSegments - 2)) {
// Compute send and receive offsets and lengths for this iteration.
auto cur = computeReduceScatterOffsets(i);
if (cur.recvLength > 0) {
tmp->recv(recvRank, slot, segmentOffset[i & 0x1], cur.recvLength);
}
if (cur.sendLength > 0) {
if (i < numSegmentsPerRank) {
in->send(sendRank, slot, cur.sendOffset, cur.sendLength);
} else {
out->send(sendRank, slot, cur.sendOffset, cur.sendLength);
}
}
}
}

// Gather to root rank.
// 注意:totalBytes <= (numSegments * segmentBytes),
// 这与在进程间贡献相同的通用聚集算法不兼容。
if (context->rank == opts.root) {
size_t numRecv = 0;
for (size_t rank = 0; rank < context->size; rank++) {
if (rank == context->rank) {
continue;
}
size_t recvOffset = rank * numSegmentsPerRank * segmentBytes;
ssize_t recvLength = std::min(
(ssize_t)chunkBytes, (ssize_t)totalBytes - (ssize_t)recvOffset);
if (recvLength > 0) {
out->recv(rank, slot, recvOffset, recvLength);
numRecv++;
}
}
for (size_t i = 0; i < numRecv; i++) {
out->waitRecv(opts.timeout);
}
} else {
size_t sendOffset = context->rank * numSegmentsPerRank * segmentBytes;
ssize_t sendLength = std::min(
(ssize_t)chunkBytes, (ssize_t)totalBytes - (ssize_t)sendOffset);
if (sendLength > 0) {
out->send(opts.root, slot, sendOffset, sendLength);
out->waitSend(opts.timeout);
}
}
}
0%