Hao Yu's blog

The program monkey was eaten by the siege lion.

0%

CPU优化

为什么需要高性能编程

简单谈一下我们为什么需要高性能编程。其实原因很简单,那就是我们希望计算尽量快。

在机器学习领域,模型训练和推理过程中都有大量的计算,尤其是大量的向量和矩阵计算。比如矩阵乘法(GEMM),卷积(Conv),类似Polling/Filter的算子等,这些计算有一个共同的特点就是可以利用并行编程加块计算的速度。

img

机器学习中常见的计算,都可以利用并行计算加速

举一个最简单的例子。如果我们要对一张图片(简化为一个矩阵)进行模糊处理(Blur),最简单的一种算法就是均值模糊,又叫标准盒式过滤器(Normalized Box Filter),其方法就是对每一个元素和围绕它周围的元素求均值,周围的元素可以组成一个“盒子”或者叫“核”。

这个“盒子”的选择方法一般有两种,要么当前元素作为盒子的左上角元素(如图中浅蓝色的盒子);要么作为盒子的中间元素(如图中灰色的盒子)。在实际编程中,还要考虑处理边界的情况(如同中深蓝色的盒子)。选择哪一种方法这里并不重要,为了简化编程,我们在接下来的编程中选择第一种。

其实,均值模糊在实现中还可以利用深度神经网络中很常用的卷积(Conv)操作,即如果我们用一个值全都是1的卷积核对每个元素进行卷积操作,可以得到相同的结果。或者从另外一个角度,卷积就是一种Filter。本文为了简单,我们接下来还是用最朴素的求和方法。

img

对图片进行均值模糊

算法的朴素实现

对于上面这个问题,最简单的实现就是一个两层嵌套的循环,对每一个元素分别进行计算。这个代码相信计算机专业大学一年级的同学就能很快的写出来,这里不多解释了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void blur_mat_original(const vector<vector<float>> &input,
vector<vector<float>> &output) {
int height = input.size();
int width = input[0].size();
int right, right_right, below, below_below;
for (int x = 0; x < width; ++x) {
right = x + 1 >= width ? width - 1 : x + 1;
right_right = x + 2 >= width ? width - 1 : x + 2;
for (int y = 0; y < height; ++y) {
below = y + 1 >= height ? height - 1 : y + 1;
below_below = y + 2 >= height ? height - 1 : y + 2;
output[y][x] =
((input[y][x] + input[y][right] + input[y][right_right]) +
(input[below][x] + input[below][right] + input[below][right_right]) +
(input[below_below][x] + input[below_below][right] +
input[below_below][right_right])) / 9;
}
}
}

我们选择的测试矩阵大小为 81924096,核大小为33,数据类型为float32。 使用gcc编译并运行,这段代码在我的机器上的执行时间是3914ms

1
$ g++ cpu.cpp -O0 -o cpu -std=c++11 && ./cpu

消除重复计算

如果这是一道程序员面试题,上面的这个答案显然不能拿到高分,因为这里面有肉眼可见的重复计算。由于我们是遍历每一个元素,对其“盒子”内的元素求均值时,我们计算了9个元素的和,在遍历过程中,由于盒子间会有重叠,因此有些元素的和被重复计算了。如下图中浅绿色和深绿色的两个元素的计算,橙色部分的计算就是重复的。

img

深绿色和浅绿色两个元素的计算过程中存在重复计算

如何优化呢?

我们可以把这个计算分成两个阶段,两次遍历。第一次遍历,如左图,对每一个元素和其右边两个元素求均值并保存下来,如左图的黄色部分。等所有元素都变成黄色之后,我们进行第二次遍历,对每一个元素和其下方的两个元素求和并求均值,从而得到最终的解。

img

两阶段计算,消除重复计算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void blur_mat_redup(const vector<vector<float>> &input,
vector<vector<float>> &output) {
int height = input.size();
int width = input[0].size();
int right, right_right, below, below_below;
for (int x = 0; x < width; ++x) {
for (int y = 0; y < height; ++y) {
right = x + 1 >= width ? width - 1 : x + 1;
right_right = x + 2 >= width ? width - 1 : x + 2;
output[y][x] =
(input[y][x] + input[y][right] + input[y][right_right]) / 3;
}
}

for (int x = 0; x < width; ++x) {
for (int y = 0; y < height; ++y) {
below = y + 1 >= height ? height - 1 : y + 1;
below_below = y + 2 >= height ? height - 1 : y + 2;
output[y][x] =
(output[y][x] + output[below][x] + output[below_below][x]) / 3;
}
}
}

上面是具体的代码实现,就是把原来的一个循环改成了两个循环,并充分利用了output变量的空间缓存中间结果,是一种典型的“空间换时间”的方法。再使用同样的参数和硬件配置,编译并运行后,性能果然有所提升,总时间从3914ms下降到3281ms

如果有应届生同学在面试中写出了上面的答案,我觉得有比较大的概率会通过第一轮面试:)

内存优化

上面的优化我们通过减少重复计算从而提升了代码的性能。但是,了解计算机组成原理和体系结构的同学会知道,如今的CPU的主频非常高,制约计算机性能的主要方面往往是IO的性能,尤其是内存的性能。大多数时候CPU都在等内存的数据,因此我们才会有L1缓存,L2缓存等硬件和非阻塞机制来减少CPU等待IO的时间。下图展示了从CPU到寄存器、缓存、物理内存、固态硬盘、机械硬盘等不同硬件的速度。基本上每差一级,速度都有数量级上的降低。

img

那么从内存的角度,我们能优化哪些?首先我们总结几个知识点:

  • IO远远慢于计算,数量级上的慢
  • 内存的随机读写远远慢于顺序读写,即便是RAM
  • 在大多数编程语言中,都是“行优先”。也就是说我们代码中如果用了二维数组,其在内存中的真实分布是一行一行顺序拼起来的

这三个知识点跟上面的代码有什么关系?如果你观察的足够仔细就会发现,由于我们编程的习惯问题,我们的两层循环中,外层循环是X,内层循环是Y。虽然这样写代码看起来“很舒服”,但是我们的内存就“很不舒服”了,这会导致比较严重的内存随机读写的问题,如下图左边所示。

img

其优化方法也非常简单,我们只需要把两层循环交换一下位置。

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 blur_mat_locality(const vector<vector<float>> &input,
vector<vector<float>> &output) {
int height = input.size();
int width = input[0].size();
int right, right_right, below, below_below;
// 交换位置
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
right = x + 1 >= width ? width - 1 : x + 1;
right_right = x + 2 >= width ? width - 1 : x + 2;
output[y][x] =
(input[y][x] + input[y][right] + input[y][right_right]) / 3;
}
}
// 交换位置
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
below = y + 1 >= height ? height - 1 : y + 1;
below_below = y + 2 >= height ? height - 1 : y + 2;
output[y][x] =
(output[y][x] + output[below][x] + output[below_below][x]) / 3;
}
}
}

这么一个简单的操作,会有多大的性能提升呢?这次的运行时间从3281ms大幅下降到2057ms, 性能提升了37%,相较于最原始的做法,几乎提升了一倍!

如果一个应届生同学的面试能优化到这个程度,应该可以拿到Offer了吧:)

注意:为了简化编程,代码中用vector<vector<float>>表示矩阵,因此,矩阵的两行数据之前,在内存中可能并不是顺序排列,这更加剧了内存随机访问的开销

CPU并行

其实到上面,我们还没进入并行编程的部分。那么如何利用并行编程对上面的代码继续优化?我们在遍历和计算每个元素时,他们之间是“独立”的,也就是说我们可以利用CPU的多个核心,同时对多个元素进行计算,这就是并行编程最朴素思想。

img

利用多线程同时处理

实际的编程中,我们可以利用多线程机制。如果CPU有16个核,那么我们可以同时启动16个线程,每个线程负责矩阵中的一列或者一行数据的计算(应该优先用列还是行?大家可以思考一下)。多线程编程不是本文的重点,我们直接介绍一种更简单的方法:OpenMP。

OpenMP是一个开源的并行计算模型和接口,通过提供一系列的编译级别的指令,大大简化了CPU上并行编程的难度。关于OpenMP的详细介绍,大家可以关注官网或者其他资料,这里不再赘述。回到我们的问题本身,我们只需要在代码中增加一行,就可以利用CPU的多核实现并行计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void blur_mat_parallel(const vector<vector<float>> &input,
vector<vector<float>> &output) {
int height = input.size();
int width = input[0].size();
#pragma omp parallel for
for (int y = 0; y < height; ++y) {
int below = y + 1 >= height ? height - 1 : y + 1;
int below_below = y + 2 >= height ? height - 1 : y + 2;
for (int x = 0; x < width; ++x) {
int right = x + 1 >= width ? width - 1 : x + 1;
int right_right = x + 2 >= width ? width - 1 : x + 2;
output[y][x] =
((input[y][x] + input[y][right] + input[y][right_right]) +
(input[below][x] + input[below][right] + input[below][right_right]) +
(input[below_below][x] + input[below_below][right] +
input[below_below][right_right])) / 9;
}
}
}

这里我们使用了OpenMP中的指令#pragma omp parallel for,这条指令告诉编译器,接下来的这个for循环,请帮我使用多线程进行并行计算。由于使用了编译指令,因此gcc在编译这段代码的时候,对会循环中y的每一个值在一个线程中运行从而实现并行加速。当然,OpenMP假设接下来的这些循环之间是“独立”的,且不会保证多个循环之间的执行顺序问题,这些都需要程序员自己去保证。

为了能成功编译,需要额外添加一个编译选项-fopenmp。

1
$ g++ cpu.cpp -O0 -o cpu -std=c++11 -fopenmp && ./cpu

经过这样的优化,我们这段代码的运行时间是多少呢?182ms!相较于最朴素的实现,我们整整提升了21.5倍,这就是并行编程的威力。

细心的同学可能发现了,上面的这段代码中仍然有重复计算的部分,我们为什么没有把第三章中的优化方法也用上呢?我们试试看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void blur_mat_parallel_redup(const vector<vector<float>> &input,
vector<vector<float>> &output) {
int height = input.size();
int width = input[0].size();
#pragma omp parallel for
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
int right = x + 1 >= width ? width - 1 : x + 1;
int right_right = x + 2 >= width ? width - 1 : x + 2;
output[y][x] =
(input[y][x] + input[y][right] + input[y][right_right]) / 3;
}
}
// can not parallel here !!!
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
int below = y + 1 >= height ? height - 1 : y + 1;
int below_below = y + 2 >= height ? height - 1 : y + 2;
output[y][x] =
(output[y][x] + output[below][x] + output[below_below][x]) / 3;
}
}
}

由于把一个循环变成两个循环,我们需要用两次OpenMP的编译指令。但是仔细观察就会发现,第二个循环体在计算过程中,需要同时读output和写output矩阵且多个线程之间的读写操作存在交集。当多个线程同时且随机地对output进行操作时,由于这里没有锁机制,会出现严重的不一致问题,从而影响计算结果的正确性。那上面的这种写法实际效率如何?大约在1115ms左右,远远慢于刚刚的182ms。

如果一个应届生同学能回答到这个程度,应该会拿到一个不错的 Offer了:)

分片执行

那有没有什么方法,可以同时利用多线程加速并且消除重复计算呢?肯定有,那就是Tiling分片计算方法。上一章节的实现之所以会出现错误主要有两个因素。一是因为存在了两个循环,因此不得不两次使用OpenMP的编译指令;二则是因为output变量在多个线程之前存在了同时读写的问题。Tilling的中文含义是“瓷砖”,也就是说,我们把原始数据像铺瓷砖一样“一大片一大片”的处理。

img

分片计算Tiling

如上图所示,我们可以把原始数据分片成四份,四个分片可以并行,分片内部也可以按照之前的方法并行。看代码:

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 blur_mat_tiling_parallel(const vector<vector<float>> &input,
vector<vector<float>> &output, int tile_width, int tile_height) {
int height = input.size();
int width = input[0].size();
#pragma omp parallel for
for (int tile_y = 0; tile_y < height / tile_height; ++tile_y) {
int t_y = tile_y * tile_height;
for (int tile_x = 0; tile_x < width / tile_width; ++tile_x) {
int t_x = tile_x * tile_width;
vector<vector<float>> tile_tmp(tile_height, vector<float>(tile_width, 0));
for (int y = 0; y < tile_height; ++y) {
int target_y = t_y + y;
for (int x = 0; x < tile_width; ++x) {
int target_x = t_x + x;
int right = target_x + 1 >= width ? width - 1 : target_x + 1;
int right_right = target_x + 2 >= width ? width - 1 : target_x + 2;
tile_tmp[y][x] = (input[target_y][target_x] + input[target_y][right] +
input[target_y][right_right]) / 3;
}
}

for (int y = 0; y < tile_height; ++y) {
int target_y = t_y + y;
int below = y + 1 >= tile_height ? tile_height - 1 : y + 1;
int below_below = y + 2 >= tile_height ? tile_height - 1 : y + 2;
for (int x = 0; x < tile_width; ++x) {
int target_x = t_x + x;
output[target_y][target_x] =
(tile_tmp[y][x] + tile_tmp[below][x] + tile_tmp[below_below][x]) / 3;
}
}
}
}
}

现在,代码貌似变得复杂了一点,别慌。改动就两点:

  1. 把原来的两次循环改成了四层循环,从而可以在分片维度实现并行,比如在第5行,我们可以在最外层的循环使用OpenMP的编译指令
  2. 在第10行,我们为每个分片增加了一个临时存储空间,在减少重复计算的同时,避免上一章中提到的多线程同步的问题

经过这样的优化,性能是多少呢?同样的参数和硬件配置下,这个实现是231ms,貌似并没有比之前的更好。至于为什么,我没做深入的profile所以不好确认。我猜测是更复杂的循环和临时空间的使用,都影响了实际执行的性能。这也告诉我们性能优化在很多时候并不是把“十八般武艺”都用上就会更好。

img

不同优化方法的性能对比

到这里,我们从消除重复计算;内存连续性和并行计算三个方面对代码进行了优化,执行时间从3914ms大幅下降到200ms一下,提升了近20倍。

指令集优化SIMD

除了上面提到的三种优化,还有没有其他手段?该轮到大杀器“指令集优化”出场了。

我们知道,CPU的计算都被抽象成了一系列的CPU指令集,有些指令集负责从内存加载数据到寄存器,有些指令集则从寄存器读取数据执行具体的计算操作,然后再由另外一些指令集把寄存器中的数据更新回内存。不同的CPU架构和指令集能操作的数据大小是不同的,从16bit,32bit到64bit。在同样的计算精度下,能操作的数据量越大就代表了计算的“吞吐”越大,也就表示更快的计算速度。这正是SIMD的初衷。

Single Instruction Multiple Data指的就是CPU在硬件上,支持一个指令读写一个向量(128bit),更重要的是,可以对两个向量同时执行计算且计算是可分割的。也就是说可以把128bit看成4个32bit的float分别对4个float执行同样的计算。举个例子,如果我们要计算四对数的和:

img

单指令集和SIMD的区别

在没有用SIMD时,我们首先需要八次LD操作用来从内存把数据加载到寄存器;然后用四次ADD操作执行加法计算;最后再用四次ST操作,把计算结果存储到内存中,故共需要16次CPU指令操作。

如果用SIMD,我们首先需要一次LD操作一次性把四个数据加载到寄存器;然后执行一次ADD操作完成加法计算;最后用一次ST操作把数据更新回内存,这样只需要3次CPU指令操作。相较于第一种方法性能提升了5倍之多,下图则从另外两个角度展示了SIMD的优势。

img

SIMD可同时操作一个向量,从而大大减少指令数量

目前绝大多数的CPU都支持SIMD,那如何使用SIMD能力?不同的CPU架构和厂商提供了不同的SIMD指令集来支持,以我们常用的x86架构来说,我们可以通过SSE指令集来使用x86架构下的SIMD能力。

SSE优化

SSE指令集介绍

目前,绝大多数的CPU都支持SIMD,不同的CPU架构和厂商提供了不同的SIMD指令集来支持,以常用的x86架构来说,我们可以通过SSE指令集来使用x86架构下的SIMD能力。

img

以SSE为代表的多种SIMD指令集

SSE指令集是对普通指令集的扩充,其使用方法可以归纳为:“接-化-发”,即:

  1. 使用SSE专门的LOAD指令从内存加载一个向量到寄存器。
  2. 使用SSE专门的OP指令对两个向量进行某种计算
  3. 使用SSE专门的STORE指令把计算结果从寄存机写回到内存

在实际写代码中,我们可以直接使用汇编调用SSE相关的指令,但是更常见的方式还是用Intel提供的C/C++的指令集内联函数intrinsics,详细的文档见:Intel® Intrinsics Guide。比如跟LOAD指令相关的内联函数就有这么多,主要功能是从内存地址mem_addr处,加载128bit的数据到寄存器。

img

Intel intrinsics中与load相关的指令集函数

之所以有这么多种,首先由于支持的数据类型不同。其中m128表示128bit的单精度浮点数;m128h表示半精度;128d表示128bit的双精度浮点数;m128i表示128bit的整数型。

其次,函数签名不同。SSE指令的函数从命名上,主要分成三部分,以_mm_loadu_pd为例:

  1. 第一部分均以_mm开头,表示属于SSE指令集;
  2. 第二部分表明操作类型,比如load,add,store等。但部分指令后面跟有[l|h|u|r]等字母,比如u表示mem_addr不需要内存对齐,r表示反向读取等;
  3. 第三部分主要包括两个字母,大部分以[p|s]开头,p表示packed即对128bits的数据全部执行相同的操作,s表示scalar,只对128bit中的第一组数据执行操作,如下图所示。而第二个字母往往和数据类型相关,比如[d|h|s]等,分别表示双精度、半精度、单精度等。

img

由于SSE指令集发展多年,有SSE、SSE2、SSE3、SSEE3、SSE4.1、AVX等众多版本,但是命名上主要遵循上面的规则。在真正使用时可以查阅Intel® Intrinsics Guide 了解细节。

SSE指令集优化

接下来尝试使用SSE指令集对原来的代码进行优化。由于SSE指令集操作的单位都是128bit,即可同时操作四个32bit的单精度float数据,为了编程方便,我们修改blur操作的kernel大小,从33修改为34,代码执行的流程如下图:

img

利用SSE指令集修改计算流程

  1. 通过三次_mm_loadu_ps操作,分别内存加载3*4个元素到寄存器。
  2. 通过两次_mm_add_ps操作,对寄存器中的数据执行packed加法操作,执行完成后寄存器中每一小部分都累积了结果。(类似于Ring AllReduce中的ScatterReduce操作)
  3. 接下来通过两次_mm_hadd_ps操作把集群器中的四个结果再次累加,_mm_hadd_ps操作比较特别,它会把相邻的两个数相加,然后把结果写到最高两位。(类似于Tree Reduce操作)
  4. 最后通过一次_mm_store_ss操作,把结果写回内存。由于寄存器中的四个元素的值都是最终的结果,因此只需要执行一次scalar操作即可。

此外,我们仍然可以用openmp,在循环的最外层进行并行计算加速,详细代码如下:

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
// 导入SSE指令集的头文件
#include <pmmintrin.h>

void blur_mat_sse(const vector<vector<float>> &input,
vector<vector<float>> &output) {
int height = input.size();
int width = input[0].size();
#pragma omp parallel for
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
int below = y + 1 >= height ? height - 1 : y + 1;
int below_below = y + 2 >= height ? height - 1 : y + 2;
// 三次数据加载
__m128 vdata_1 = _mm_loadu_ps(&input[y][x]);
__m128 vdata_2 = _mm_loadu_ps(&input[below][x]);
__m128 vdata_3 = _mm_loadu_ps(&input[below_below][x]);
// 两次逐元素相加,packed操作
__m128 vres = _mm_add_ps(vdata_1, vdata_2);
vres = _mm_add_ps(vres, vdata_3);
// 两次相邻元素相加,packed操作
vres = _mm_hadd_ps(vres, vres);
vres = _mm_hadd_ps(vres, vres);
// 写回内存,scalar操作
_mm_store_ss(&output[y][x], vres);
output[y][x] /= 12;
}
}
}

看一下动图:

动图封面

实际编译运行看一下效果。由于使用了SSE指令集,在编译时需要显示指定编译选项-msse3:

1
$ g++ cpu.cpp -O0 -o cpu -std=c++11 -fopenmp -msse3 && ./cpu

经过SSE指令集的优化,我们代码的性能进一步提升到了130ms,相较于最原始的版本,加速了30倍。

img

小结

这篇文章里,我们通过实现一个blur滤波器,把原来需要执行3.9秒的一段代码逐步优化到只需要0.13秒,加速比超过了30倍。其背后的原理并不复杂,主要是

  • 消除重复计算
  • 考虑内存/缓存的本地性
  • 利用多核CPU并行计算
  • 利用Tiling机制
  • 使用SIMD SSE指令

等几个技术。其中加速效果最好,显而易见的,就是多核并行计算和SIMD机制。

在机器学习、深度学习应用中,主要的计算类型包括GEMM(通用矩阵乘法)、Conv(卷积)、Pooling、Activation等,这些计算本质上都满足并行计算和SIMD的前提,因此基于并行计算的优化方法被大量应用到机器学习领域中。

另一方面,如今的通用CPU发展越来越受制于物理定律的限制,即CPU的核数、L1/L2的缓存大小都难以有数量级上的增长,而计算的需求却在指数级增长,单纯靠CPU已经难以满足计算的需求。此时,以GPGPU为代表的专用加速器以拯救者的身姿出现在我们面前,成为推动深度学习发展的功臣。如何利用GPGPU继续优化我们的代码,我们放到下一篇中介绍。

GPU和CUDA优化

GPGPU简介

在上两篇中,我们使用多种技术提高了Blur算子的速度,效果最好的技术是利用多核和SIMD指令集,通过并行计算来提升速度。这给我们一个很大的启发,如果我们的CPU能扩展到数百数千甚至数万的核,不就能进一步加速了吗?事实上,传统超算就是这么干的。但是传统超算的做法是一种分布式计算的思路,即用数十个机柜,每个机柜内放置数百个计算单元(看成一台服务器),每个计算单元有几个CPU,以此组成一个巨大的CPU集群。集群间通过高速网络,配合上分布式的编程来使用整个集群的数以万记的CPU执行计算任务。

之所以用分布式计算的思路提高整个系统的并行计算的能力,主要原因是CPU的发展已面临物理定律的限制,人们无法在有限的成本下创造出核心数更多的CPU。此时人们发现,原本用来做图形渲染的专用硬件,GPU(Graphic Processing Unit),非常适合用于并行计算。

我个人认为原因有三点:首先,GPU在渲染画面时需要同时渲染数以百万记的顶点或三角形,因此GPU硬件设计时就利用多种技术优化并行计算/渲染的效率;第二,GPU作为专用的硬件,只负责并行计算/并行渲染这一类任务,不承担复杂的逻辑控制和分时切换等任务,可更专注于计算本身,通过专用指令集和流水线等技术,优化计算效率和吞吐;最后,GPU作为一类外插设备,在尺寸、功率、散热、兼容性等方面的限制远远小于CPU,让GPU可以有更大的缓存、显存和带宽,可以放置更多的计算核心,而这进一步体现到成本上的优势(成本对于技术的普及非常重要)。

img

CPU和GPU对比示意图

上图是CPU和GPU的一个对比示意图。由于CPU除了计算任务外,还需要负责大量的逻辑控制,分时调度,兼容多种指令集等等“包袱”,真正用来计算的部分其实很少。而GPU则不然,GPU可以在更大的自由度下,放置更多的计算核心。

其实从最本质的角度来讲,GPU之所以适合并行计算场景,是因为GPU使用的是SIMT(Single Instruction Multiple Threads)模型。SIMT可以看成是SIMD(Single Instruction Multiple Data)模型的一种增强。

Nvidia公司在2000年推出第一款GPU,但是直到2006年才把GPU用于通用计算任务,其转折点就是CUDA(Compute Unified Device Architecture)的出现。CUDA是一整套软件技术,把Nvidia GPU的计算能力封装成一套编程规范和编程语言接口,同时提供相应的编译器、调试器等工具,这使得程序员能方便的使用GPU并行计算能力,执行通用的计算任务,因此又被称为GPGPU(General-purpose computing on graphics processing units)。在CUDA出现6年之后,Alex Krizhevsky和其老师Geoffrey Hinton在CUDA的帮助下成功地实现了AlexNet深度神经网络,一举打开了此轮人工智能浪潮的大门。AlexNet论文中提到:模型的深度对于提高算法性能至关重要,但是其计算成本很高,GPU的使用让深度神经网络的训练具有可行性。

甚至可以武断地说,如果没有GPU和CUDA,尤其是后者,以深度神经网络为主要代表的人工智能算法很可能是另外一种命运。当然,Nvidia并不是唯一一家生产GPU和提供上层编程框架的公司,除了Nvidia GPU和CUDA之外,Intel、AMD和最近几年爆火的如寒武纪、海思等公司,也推出了不同的硬件和软件平台,但是无论完善度、丰富性尤其是生态上,Nvidia仍然是毫无疑问的王者。因此这篇文章介绍的还是Nvidia的GPU,下文中提到的GPU,没有特殊说明都代指Nvidia GPU。

img

Nvidia的软硬件生态

上图是Nvidia提供的一整套软硬件平台。Nvidia针对嵌入式端,桌面级消费卡、专业工作站和数据中心提供了不同的硬件产品,但是在软件层,通过CUDA抽象成了统一的编程接口并提供C/C++/Python/Java等多种编程语言的支持。CUDA的这一层抽象非常重要,因为有了这层抽象,向下开发者的代码可在不同的硬件平台上快速迁移;向上,在CUDA基础上封装了用于科学计算的cuBLAS(兼容BLAS接口),用于深度学习的cuDNN等中间件和代码库。这些中间件对于Tensorflow,PyTorch这一类的深度学习框架非常重要,可以更容易地使用CUDA和底层硬件进行机器学习的计算任务。

GPU硬件架构

对于一般开发者而言,大多数情况下接触到的都是CUDA层。但是由于GPU的特殊性,为了能正确高效使用CUDA,非常有必要学习一下GPU的硬件架构。由于Nvidia的GPU发展非常迅速,平均1-2年就会推出一款新GPU或核心架构,因此这里简单介绍下,不涉及硬件细节(主要是我也不太懂硬件)。

img

Nvidia A100 GPU的硬件架构

我们以Nvidia 2020年推出的Ampere架构和A100 GPU为例(A10、A30、RTX3090等型号也是Ampere架构)为例。上图是整个A100的硬件架构,从上到下,其主要组成部分:

  • PCIe接口层,目前大部分GPU都是通过PCIe以外部设备的方式集成到服务器上;
  • GigaThread Engine with MIG是GPU用于调度资源的引擎,Ampere架构还支持了MIG(Multiple Instance GPU)控制即允许对GPU进行硬件隔离切分;
  • 中间占据面积最大的绿色部分是GPU的核心计算单元,共有6912个CUDA Cores,这是重点部分,我们在后面详细介绍;
  • 中间蓝色部分是L2缓存,在GPU层面提供大于40MB的共享缓存;
  • 最下层的NVLink是用于多个GPU间快速通信的模块(在分布式训练中非常重要);
  • 两侧的HBM2(High Bandwidth Memory)即我们通常说的显存。A100提供了高达1.56TB带宽,40GB/80GB的显存;

其中最重要的绿色部分是计算核心,在A100中这些计算单元又进一步被“分组”成了GPC(Graphic Processing Cluster)- TPC(Texture Processing Cluster) - SM(Streaming Multiprocessor)几个概念。最需要了解的是SM的架构,把其中一个SM放大:

img

Nvidia A100的SM硬件架构

SM可以看成是GPU计算调度的一个基本单位,只有对其架构和硬件规格有一定认识,才能更高效地利用GPU的硬件能力。SM中自上而下分别有:

  • L1 Shared Memory,在SM层面提供192KB的共享缓存
  • 接下来是四个PB(Process Block),每个PB内部分别有
    • L0缓存
      • Warp Scheduler用于计算任务调度
      • Dispatch Unit用于计算任务发射
      • Register File寄存器
      • 16个专用于INT32计算的核心
      • 16个专用于FP32计算的核心
      • 8个专用于FP64计算的核心
      • 4个Tensor Core,这是受Google TPU的影响,专门设计用于执行A*B+C这类矩阵计算的硬件单元,甚至还支持稀疏矩阵计算,Tensor Core对深度学习的加速非常明显
      • 8个用于LOAD/STORE数据的硬件单元
      • 4个SFU(Special Function Unit)用于执行超越函数、插值等特殊计算

因此一个SM内就有64个用于计算的核心,Nvidia称之为CUDA Core(但是我没搞明白64是怎么算出来的)。而一整块A100 GPU则密密麻麻地放置了108个SM,总计6912个CUDA Core,可以简单理解成这是一个拥有6912个核心的巨型CPU!

至此,我们应该能搞懂为什么GPU比CPU更适合执行并行计算了,就是“专业人干专业事,大力就会出奇迹“嘛。

CUDA基本概念

为了方便地使用GPU的硬件能力,Nvidia在软件层做了抽象,形成软件上的一系列逻辑概念,这些逻辑概念跟GPU的硬件有一定的对应关系。

img

GPU软件和硬件的对应关系

如上图,CUDA编程中的最小单元称之为Thread,可以简单认为一个软件Thread会在一个硬件CUDA Core中执行,而Thread中执行的内容或函数称之为Kernel。多个相同的Thread组成一个Thread Block,软件Thread Block会被调度到一个硬件SM上执行,同一个Thread Block内的多个Thread执行相同的kernel并共享SM内的硬件资源。而多个Thread Block又可以进一步组成一个Grid,一个软件Grid可以看成一次GPU的计算任务,被提交到一整个GPU硬件中执行。这几个概念非常重要,简单总结下:

  • kernel:Thread执行的内容/代码/函数
  • Thread:执行kernel的最小单元,被SM调度到CUDA Core中执行(其实还有一个Warp的概念,为了简单,这里先略过)
  • Thread Block:多个Thread组合,GPU任务调度的最小单元(这个描述不太准确,应该是Warp,为了简单暂时先不细究),被调度到SM中执行。一个SM可以同时执行多个Thread Block,但是一个Thread Block只能被调度到一个SM上。
  • Grid:多个Thread Block的组合,被调度到整个GPU中执行

同时,Thread、Thread Block和Grid由于所处层次不同,他们可以访问的存储资源也不同。如Thread只能访问自身的寄存器,Thread Block可以访问SM中的L1缓存,而Grid则可以访问L2缓存和更大的HBM显存。在第一篇文章中我们就介绍过不同层次的存储其访问速度往往是数量级的差别,GPU也不例外,在后续的文章中我们会看到,针对CUDA的优化很大一部分就是如何正确高效的使用GPU中的多级存储来提高GPU的方寸比,从而进一步提高GPU的计算效率。

img

Nvidia GPU的内存模型

到底如何使用CUDA?由于GPU是作为一类外挂设备,通过PCIe之类的接口插到服务器上,因此在CUDA编程中,称GPU为Device,而服务器上的CPU和内存统称为Host。在编写CUDA代码时,最主要的工作就是编写kernel函数,然后利用CUDA提供的接口,把kernel函数从Host发射到Device中执行,Device则从 Grid → Thread Block → Warp → Thread 一层层的调度下最终完成kernel的计算。

在计算开始前,还需要用CUDA接口把计算要用到的数据从Host拷贝到Device中。Device完成计算后,则需要利用CUDA接口把计算结果从Device拷贝回Host中,因此总结下来,CUDA编程分为三步:

  1. 从Host拷贝数据到Device
  2. 把需要Device执行的kernel函数发射给Device
  3. 从Device拷贝计算结果到Host

如果有点抽象,我们看个例子。

CUDA HelloWorld

CUDA HelloWorld代码很简单,求两个数组A和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
#include <cuda.h>

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

// Host端申请内存
float* h_A = (float*)malloc(size);
float* h_B = (float*)malloc(size);
float* h_C = (float*)malloc(size);

// Device端申请显存,分别用来存放A、B和结果C
float* d_A, d_B, d_C;
cudaMalloc(&d_A, size);
cudaMalloc(&d_B, size);
cudaMalloc(&d_C, size);

// 把需要计算的结果从Host拷贝到Device
cudaMemcpy(d_A, h_A, size, cudaMemcpyHostToDevice);
cudaMemcpy(d_B, h_B, size, cudaMemcpyHostToDevice);

// 执行kernel,需要指定两个参数
int threadsPerBlock = 256;
int blocksPerGrid = (N + threadsPerBlock - 1) / threadsPerBlock;
VecAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, N);

// 把计算结果拷贝回Host
cudaMemcpy(h_C, d_C, size, cudaMemcpyDeviceToHost);

// 释放Device中的显存
cudaFree(d_A);
cudaFree(d_B);
cudaFree(d_C);

// 释放Host的内存
...
}

代码比较直白,其中cuda开头的函数都是CUDA库中提供的,比如cudaMalloc会在Device中申请一段显存,cudaMemcpy则可以在Host和Device之间进行数据拷贝等,不再赘述。

核心是kernel函数部分,先看看kernel函数的实现:

1
2
3
4
__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];
}

Kernel函数只有两行并不复杂,但是需要好好解释。

这个kernel函数把数组A和数组B逐元素相加,结果写到数组C中。即C[i] = A[i] + B[i]。如果在CPU上编程,一个循环遍历就搞定了。但是当用CUDA编程时,我们希望利用GPU中数千个计算核心同时计算,每个核心只负责A和B的一个元素。但是,如何分配任务?

如果GPU中的每个核心都有唯一的ID,比如0号核心执行C[0] = A[0] + B[0],127号核心执行C[127] = A[127] + B[127]就好了。但是如果我们要操作的数据大于GPU的核心数怎么办?因此CUDA做了一层逻辑抽象。当一个计算任务在GPU中执行时,这个任务相关的Grid、Thread Block和Thread都有一系列的身份标识,即几个全局变量:

  • blockDim:表明kernel所在的Thread Block的尺寸,包含x, y, z三个维度
  • blockIdx:表明kernel所在的Thread Block的index,包含x, y, z三个维度
  • threadIdx:表明kernel在在的Thead在Thread Block内的idx,包括x, y, z三个维度

只要引入了cuda.h的头文件,在代码中可以直接使用上面几个全局变量,这几个全局变量的值会在执行时由CUDA自动维护更新。因此,kernel函数 i 的计算方法就是用blockDim.x乘以blockIdx.x再加上threadIdx.x,就可以算出当前一个唯一的、逻辑ID被当前的thread使用。那为什么只用到了x维度,没有用到y和z维度?

答案在于Host端代码中,在启动kernel时,代码中指定了两个模板类参数:blocksPerGrid和threadsPerBlock。其含义通过名字就能看懂。这两个参数被我们指定成两个int型数字。

1
2
3
4
int threadsPerBlock = 256;
int blocksPerGrid = (N + threadsPerBlock - 1) / threadsPerBlock;

VecAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, N);

其实这两个参数还可以是dim2或dim3类型。如果是dim2类型,那么逻辑ID可以通过逻辑x和逻辑y唯一确定,其计算方法如下。

1
2
3
int y = blockIdx.y * blockDim.y + threadIdx.y;
int x = blockIdx.x * blockDim.x + threadIdx.x;
int i = blockDim.y * y + x;

img

原理可以参考上图。这与二维数组索引转换成一维数组索引的过程是类似的,相信有基本编程经验的同学很快就能搞懂。

Preface

在进入具体学习前,文中先提到了APOD(Assess, Parallelize, Optimize, Deploy,即评估、并行、优化、部署)这样一种思路去帮助开发人员快速识别它们的代码中哪些部分可以从GPU加速中受益,并尽快开发获得这种好处,最终尽早将其运用到产品中。

APOD是这样一种循环处理(像是一种迭代开发方式):得到最初的加速效果,测试,加入最小的优化,识别更多的可优化的地方,观察更多的加速效果,将更快的应用部署到产品中。

下面先分别介绍了A、P、O、D。

img

Assess

对于已经存在的项目,首先要评估应用代码的哪些部分在运行时消耗时间最长,这样开发者才能进一步考虑需要用并行化和GPU加速解决哪些现存的瓶颈。

通过理解终端用户的需求和客观限制(这里提到了Amdahl(阿姆达尔定律)和 Gustafson定律(古斯塔夫森定律) ,这两个定律从不同的角度诠释了加速比与系统串行化程度、cpu核心数之间的关系,它们是我们在做高并发程序设计时的理论依据。),开发者可以确定通过加速代码中的瓶颈部分可以得到的性能提升上限。

Parallelize

在识别瓶颈和设置优化目标之后,开发者接下来需要把代码并行化。根据现有代码的情况,这一步可以简单的调用现有的GPU优化库(比如cuBLAS, cuFFT, or Thrust),或者只是简单地加一些预处理指令让编译器去做并行化处理。

一些应用程序需要一定程度的重构才能做并行化。就像运行在CPU架构上的程序设计时需要考虑并行化以提升串行的应用程序的性能那样,CUDA并行编程家族(比如CUDA C++, CUDA Fortran)在让支持CUDA的GPU能尽量做到最大并行运算量的同时,也在尝试使并行化的表达尽量简单。

Optimize

在每一轮的应用程序并行化完成后,开发者需要进一步的优化程序的执行以提升性能。因为有很多潜在的优化方法,更好的了解应用程序的需求(以选择合适的方法)可以让这一步更顺利。但是,因为APOD是一个整体,所以程序的优化是一个迭代的过程(找到可优化的点,优化并测试,验证性能的提升,然后再重复),这意味着程序员不需要提前记住所有可能的优化策略,而是可以边学边用。

在很多层面可以做优化,比如使数据的传输和计算同时进行、调优浮点操作顺序等。Profiling工具在这个过程中作用相当大,它们能告知开发者下一步应该优化哪里,并且给本指南提供了一些参考。

Deploy

在使用GPU加速了应用程序的某些组件/部分后,可以去和原有的应用比较一把。想一想当初做第一步(access)的时候,预估的性能提升上限,看是否达到了目标。

在处理器其他瓶颈以进一步提升总体速率之前,开发者需要考虑先把部分并行化的执行放入产品中。这样做是有原因的:比如用户可以从中尽早获利,小步迭代比革命性的更改风险更小等。

在进入真正的优化细节之前,作者给出了一个建议:把所有可以做的优化点分优先级,对性能有较大影响或影响范围较广的为高优先级,先花时间去做高优先级的工作。

1 评估你的应用程序(Assessing Your Application)

无论是在超级计算机还是在手机上,处理器都越来越依赖并行化以提供更高性能,其提供了多份核心计算单元(包括控制、计算、寄存器、cache,其实就是多核的意思)。这些核都通过一个总线和内存相连。要充分利用这些计算能力,就需要代码能在不同核上并行执行。

随着处理器不断向软件开发人员开放更细颗粒度的并行机制(fine-grained parallelism),现存的一些代码就变得太串行化或太粗颗粒度的并行了(就是在并行度上没跟上处理器的发展)。

为了更好的利用现代处理器架构(包括GPU),我们需要做的第一步就是评估应用程序,找到关键瓶颈,看它们能否并行化,理解现在和将来的相关工作量

2 异构计算(Heterogeneous Computing)

CUDA编程会将代码同时跑在两个不同的平台上:一个带一个或多个CPUs的host系统(服务器/电脑主机)和一个或多个支持CUDA的NVIDIA GPUs。

NVIDIA的GPUs不仅可以做图像相关的工作,还可以支持大规模的并行数学计算。这使得它们特别适合于并行运算。

但是由于设备是独立于host系统的,为了更有效的使用CUDA,所以需要理解这种异构架构以及这种架构是如何影响CUDA应用程序的性能的。

2.1 主机和设备的不同(Differences between Host and Device)

最主要的不同是线程模型和分离的物理内存。

线程资源(这里的线程资源在CPU上指的是硬件的软核,不是操作系统线程的概念)

主机系统的执行流水线可以支持有限数量的并发线程。比如说,一个由32个处理器核(这里指硬核)的服务器只可以同时并发64个线程(因为一般一个硬核有2个软核,这里的意思应该是CPU上只有64个软核)。相比之下,CUDA设备上一个最小的并行执行单元就可能含32个线程。现代NVIDIA GPUs上能有80个并行执行单元(多处理器),每个多处理器上能支持2048个并发线程,也就是总共可以支持16万个线程并发。

线程(这里指操作系统级的软件线程)

CPU上的线程是重量级的实体。操作系统为了提供多线程的能力必须把线程在CPU执行通路上不停的切入切出。当两个线程的上下文在一个核上做切换时,会比较耗时。相比之下,GPUs上的线程是非常轻量级的。在一个典型的系统中,可能有几千个线程在排队(在32个并行执行单元上,指GPU上)。如果GPU必须等待一个执行单元,它可以简单的直接使用另外一个去运行。因为每个线程的寄存器都是单独分配的,GPU线程上做任务更换不需要切换上下文(寄存器或其他状态)。每个线程在完成执行前会独占资源。简而言之,CPU核是为小批量的线程低延时运行设计的,GPU是为处理大量并行、轻量级线程以获取最大吞吐量而设计的(这么说来,分配给GPU的任务并发量越大越好,它的优势不是单个运算执行速度,而是并发量)。

内存

主机和GPU设备都有自己独立的物理内存。它们之间需要通讯来交互数据。

关于并行编程,主机端的CPU和GPU设备主要的不同在硬件上。应用程序的开发者需要有这种意识在异构系统上去处理这些不同点,让每个处理单元去做它们最擅长的工作:主机CPU做串行工作,设备做并行工作。

2.2 使能了CUDA的设备上在运行什么?(What Runs on a CUDA-Enabled Device?)

在决定让应用程序的哪些部分跑在设备上时,需要考虑下面几个问题:

设备上最适合的是那种可以同时对很多数据元素并行进行计算的场合。比较典型的是对一个大数据集(比如矩阵)的子元素同时进行那种几千到几百万个相同类型的数学计算。这需要用好CUDA以提供高性能:软件必须使用大量的并行线程。设备中使用的是上面描述的那种轻量级的线程(就是最多可达16万个的那种)。

要使用CUDA,需要从主机向设备(指GPU)传输数据,需要考虑如何把这种传输操作消耗的时间最小化以避免影响性能。

计算的复杂度(其实是复杂度对应的计算时间)决定了把数据在主机和设备间搬来搬去是否值得。如果只使用GPU上小量的线程去做简单计算,是得不到什么好处的(指的是还不如在CPU上算)。理想的场景是需要GPU上的很多线程执行大量的计算工作。

比如说,传输两个矩阵到设备上去执行矩阵加法操作然后把结果再传回主机就得不到太多好处。问题在于计算操作的数量和数据元素的传输的数量的比值。在前面说的矩阵加法这个例子中,假设矩阵的大小为NxN,就会有N2加法操作和3N2个(传两次源数据再传回一次结果数据)元素的传输,所以操作和元素的比为1:3或O(1)。当这个比例比较大时,使用GPU运算才能得到好处。比如相同大小的矩阵乘法需要N3次(乘-加)计算,所以计算操作和元素的比为O(N),矩阵越大获得的好处也越多。总之需要考虑数据在主机和设备间传输的消耗来决定一个操作是在主机执行还是在设备执行。

数据应该在设备上维持尽可能长时间。因为要尽可能降低数据传输的时间消耗,在多个kernel上(应该是指GPU计算单元)基于相同数据做运算的程序应该在kernel调用间充分利用设备上的数据,而不是先把结果传回主机然后再做后面的运算时又把数据传到设备。还是拿之前的例子来说, 如果两个矩阵相加后的结果将要用于随后的计算,那这个加法结果就应该留在设备上。即使某一步的计算在主机上执行的更快,也应该使用这个方法,因为避免了一步或多步数据传输可以达到更好的总体性能。“主机和设备间的数据传输”这一节提供了更多细节,包括主机和设备间的带宽计算,以及和把数据维持在设备内的对比。

为了获取更好的性能,需要考虑设备上相邻线程间的内存访问的连贯性(应该指数据的地址尽可能连在一起,比如充分利用cache的预取功能)。某些内存访问方式使得硬件可以把一组对多个数据的读/写操作合并成一个操作执行。数据的排列如果无法使能这种合并操作,或者不能有效利用cache,将会降低GPU运算速度。一个值得注意的例外是完全随机的内存访问方式。一般情况下需要避免这种情况,因为通常处理这种模式效率比较低。但是和基于cache的架构相比(比如CPU),延迟隐藏架构(latency hiding architectures,比如GPU)更擅长处理这种完全随机内存访问模式。

3 应用程序剖析(Application Profiling)

3.1 剖析(Profile)

很多程序中完成重要工作的代码只占它所有代码的一小部分(意思是如果能优化这一小部分,就能实现整体性能的大幅改善)。使用性能剖析器,开发者可以定位这部分热点代码,并以此为基础做下一步的并行优化。

3.1.1 创建剖析(Creating the Profile)

有很多方法去剖析代码,但最终目标都是相同的:找到程序中消耗执行时间最长的一个或多个函数。

注:高优先级:剖析应用程序找到关键点和瓶颈,最大化开发者的生产力。

剖析行为最重要的是先确保(识别出的关键点的)工作负载的真实性,比如说从测试和相关分析中得到的信息和真实情况是相关的。使用不真实的工作负载会误导开发者去优化没有实际用途的size问题或错误的函数,从而得到次优结果并浪费人力。

剖析工具有很多。下面的例子使用了gprof,一个Linux上的开源剖析器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ gcc -O2 -g -pg myprog.c
$ gprof ./a.out > profile.txt
Each sample counts as 0.01 seconds.
% cumulative self self total
time seconds seconds calls ms/call ms/call name
33.34 0.02 0.02 7208 0.00 0.00 genTimeStep
16.67 0.03 0.01 240 0.04 0.12 calcStats
16.67 0.04 0.01 8 1.25 1.25 calcSummaryData
16.67 0.05 0.01 7 1.43 1.43 write
16.67 0.06 0.01 mcount
0.00 0.06 0.00 236 0.00 0.00 tzset
0.00 0.06 0.00 192 0.00 0.00 tolower
0.00 0.06 0.00 47 0.00 0.00 strlen
0.00 0.06 0.00 45 0.00 0.00 strchr
0.00 0.06 0.00 1 0.00 50.00 main
0.00 0.06 0.00 1 0.00 0.00 memcpy
0.00 0.06 0.00 1 0.00 10.11 print
0.00 0.06 0.00 1 0.00 0.00 profil
0.00 0.06 0.00 1 0.00 50.00 report

3.1.2 识别关键点(Identifying Hotspots)

在上面的例子中,我们可以很清楚地看到genTimeStep()这个函数消耗了1/3的运行时间。这是我们第一个可以去优化的备选函数。下面的“理解加速比”一节讨论了我们期望从这种并行化中得到的性能提升。

值得注意的是,上面例子中的其他几个函数也占用了相当比例的运行时间,比如说calcStats()和calcSummaryData()。并行化这些函数也可以获得潜在的速度提升。但是,因为APOD是一个循环处理(是指一轮一轮不停的进行),我们可以在随后的APOD过程中去并行化这几个函数。

3.1.3.理解可扩展性( Understanding Scaling)

应用程序通过在CUDA上运行而获得的性能优势完全取决于它可以并行化的程度。无法充分并行化的代码应该在主机上运行,除非这样做会导致主机和设备之间的过度传输。

注意:高优先级:要从CUDA中获得最大的好处,请首先关注如何并行化顺序代码。

通过了解应用程序如何扩展,可以设置期望值并规划增量并行化策略。

3.1.3 理解加速比(Understanding Scaling)

一个应用程序使用CUDA可以获得的性能提升完全取决于它被并行化的程度。那些不能被充分并行化的代码应该跑在主机上,除非这样做会导致主机和设备间多余的数据交换。

注:高优先级:为了从CUDA上获取最大的好处,首先集中精力去把顺序执行的代码并行化。

下面提到了高并发程序设计中有两个非常重要的定律(Amdahl’s Law 和Gustafson’s Law),为便于理解,从网上找到下面这段描述:

在高并发程序设计中有两个非常重要的定律:

  • Amdahl(阿姆达尔定律)
  • Gustafson定律(古斯塔夫森定律)

这两个定律从不同的角度诠释了加速比与系统串行化程度、cpu核心数之间的关系,它们是我们在做高并发程序设计时的理论依据。

  • 加速比

“加速比”是个什么鬼?先来看张图:

img

串行程序为什么需要并行化,显然是为了提升系统的处理能力,即性能。并行化的过程,也可以称作系统优化的过程。上图中,在优化前,系统是完全串行的,步骤1至步骤5依次执行,共花费了500ms的时间;我们将步骤2与步骤5进行优化,使其分别用两个线程执行,每个线程各花费50ms,这样步骤2与5的执行时间就由优化前的100ms变为了优化后的50ms,那么整个程序在优化后的执行时间就缩短至400毫秒了,相当于系统的性能提升了20%。这个性能的提升可以用“加速比”来反应:

加速比=优化前系统耗时/优化后系统耗时

在上面的例子中,加速比=500/400=1.25,它是衡量系统优化程度的一个指标。

那么什么是阿姆达尔定律呢?

Amdahl(阿姆达尔定律)

阿姆达尔定律定义了串行系统并行化后加速比的计算公式与理论上限。

先来看优化后耗时与优化前耗时之间的关系,其公式为:

img

其中定义n为处理器个数,T1为单核处理器时系统耗时即优化前系统耗时,Tn为n核心处理器系统时系统耗时即优化后系统耗时,F为串行比例,那么1-F就是并行比例了。

由前面的介绍可知:加速比=优化前系统耗时/优化后系统耗时

用T1与Tn来表示,“加速比”的计算公式可变为:加速比=T1/Tn

将前面Tn的计算公式代入:

img

这就是加速比的计算公式,从公式可以看出增加处理器的数量(提升n的值)并不一定能有效地提高加速比,如果系统的并行化程序不高,即F的值接近100%,就算n无穷大,加速比也是趋近于1的,并不会对系统的性能优化起到什么作用,而成本却无限增加了。

所以,我们可以从“加速比”的公式中看出,单纯地增加cup处理器的数量并不一定可以有效地提高系统的性能,只有在提高系统内并行化模块比重的前提下,同时合理增加处理器的数量,才能以最小的投入得到最大的加速比,这就是阿姆达尔定律要告诉我们的核心思想,它很直观地反应了加速比与处理器个数、系统串行比例之间的关系。

使用加速比的公式,我们同样可以计算出前方例子中的加速比是1.25,如下:

img

Gustafson定律(古斯塔夫森定律)

Gustafson定律也是说明处理器个数、串行比例和加速比之前的关系,只不过它的侧重角度有所不同。

我们定义a为系统串行执行时间,b为系统并行执行时间,n为处理器个数,F为串行比例,那么系统执行时间(串行时间+并行时间)可以表示为a+ba+b,系统总执行时间(串行时间)可以表示为a+nba+nb,所以有如下公式推演:

执行时间=a+b

总执行时间=a+nb

img

其中,串行比例 F=a/(a+b),将其代入上面的公司,可得到:

img

最终的公式为:加速比= n−F(n−1);

从公式中可以看出,F(串行化程度)足够小,也即并行化足够高,那么加速比和cpu个数成正比。

3.1.3.1. Strong Scaling and Amdahl’s Law

强大的扩展性是衡量在固定的总体问题大小下,随着系统中添加更多处理器,解决问题的时间如何减少的一个指标。呈现线性强扩展性的应用程序的加速比等于使用的处理器数量。

强可扩展性通常等同于Amdahl定律,该定律规定了串行程序部分并行化所能预期的最大加速。本质上,它指出程序的最大加速比S为:

img

这里P是可并行化的代码部分所花费的总串行执行时间的一部分,N是运行代码并行部分的处理器数量。

N越大(即处理器数量越大),P/N分数越小。将N视为一个非常大的数字可能更简单,这基本上将方程转换为S=1/(1−P)。现在,如果序列程序运行时间的3/4被并行化,则串行代码的最大加速比为1(1-3/4)=4。

实际上,大多数应用程序并没有表现出完美的线性强可扩展,即使它们确实表现出某种程度的强可扩展性。对于大多数目的来说,关键点是可并行化部分P越大,潜在的加速能力就越大。相反,如果P是一个小数字(意味着应用程序基本上不可并行化),那么增加处理器数量N对提高性能几乎没有作用。因此,为了在固定的问题大小下获得最大的加速,有必要努力增加P,最大化可并行化的代码量。

3.1.3.1 强加速比和阿姆达尔定律(Strong Scaling and Amdahl’s Law)

强加速比是这样一种度量方式:对于一个总size固定的模型,使用更多的处理器可以多大程度的降低计算时间。一个有着强加速比的应用程序可提升的速度倍数与使用的处理器数量相等。

强加速比经常被等同于阿姆达尔定律,它指出了通过并行化一个串行执行程序的某些部分所能得到的最大速度提升,即最大加速比。它的公式如下(这个公式和上面网上找到的那个看起来不同,是因为这里的P等于上面那个的1-F,上面那个F指串行比例,所以这里的P指并行比例):

img

P是指代码中可以被并行化的部分(占全部串行运行时的时间)的比例,N是并行部分代码可以运行的处理器个数。N越大,即处理器越多,P/N越小。如果把N看做一个很大的值,公式可以被简化为S=1/(1−P)。那么,如果一个串行运行程序的3/4可以被并行化,最大的加速比可以达1 (1 - 3/4) = 4。

实际上,大多数应用程序不能呈现出完美线性的强加速比,即使它们看起来有某种程度的强加速比。对大多数实际场合来说,关键点是并行比例P越大,潜在的加速比越高。相反,如果P比较小,增加处理器的数量N几乎不会提升性能。因此,要为一个固定size的模型获取更大的加速比,需要花时间去提升P,最大化的使代码并行执行。

3.1.3.2 弱加速比和古斯塔夫森定律(Weak Scaling and Gustafson’s Law)

弱加速比是这样一种度量方式:假设每个处理器上运行的模型的size是固定的,加入更多的处理器对总体时间有什么影响。

弱加速比经常被等同于古斯塔夫森定律,运算模型size的大小和处理器的数量成正比。因此,一个程序的最大加速比S为:

S=N+(1−P)(1−N)

其中P是并行比例,N是并行部分代码运行的处理器数量(也代表了总体运算规模,因为这里假设每个处理器上处理的数据size固定)。

古斯塔夫森定律假设并行比例为恒定,反应的是当处理更大规模计算的时候所增加的消耗。

3.1.3.3 强弱加速比的应用(Applying Strong and Weak Scaling)

理解哪种加速比对一个应用程序更实用是性能评估很重要的一部分。

对一些应用程序来说总数据size是恒定的,因此强加速比更适合。一个例子是,当分子的size是固定的时候,这两个分子是如何互相影响的。

对其它一些应用程序,问题size是随着处理器的数量而增加的。比如将流体或结构建模为网格,以及一些蒙特卡罗模拟,其中增加问题大小可提高精度。

剖析应用程序后,开发者需要理解数据size是如何随着计算性能的改变而改变的,然后从阿姆达尔定律和古斯塔夫森定律中挑选一个来确定加速的上限。

4 并行化你的应用程序(Parallelizing Your Application)

在识别了关键点和设置优化目标后,开发者可以去并行化代码了。基于原始代码的情况,这一步可以简单的调用已有的GPU优化库,比如cuBLAS、cuFFT或Thrust,也可以加一些预处理执行给并行化编译器。

另一方面,一些应用程序需要一定程度的重构才能做被并行化。就像运行在CPU架构上的程序设计时需要考虑并行化以提升串行的应用程序的性能那样,CUDA并行编程家族(比如CUDA C++, CUDA Fortran)在让支持CUDA的GPU能尽量做到最大并行运算量的同时,也在尝试使并行化的表达尽量简单。

5 开始优化(Getting Started)

并行化串行代码有几个关键策略。如何在某个应用程序上使用这些策略是一个复杂和专门的课题,这里列出的主题并不局限于把并行化后的代码运行在哪里(多CPUs或CUDA GPUs都可以)。

5.1 并行库(Parallel Libraries)

让一个应用程序并行化的最直接的方法是已有的利用了并行架构的库。CUDA工具包包含了很多这样的已经为NVIDIA CUDA GPUs做了优化的库,比如cuBLAS、cuFFT。

这里的关键是库要和应用程序的需求相匹配。已经使用了其它BLAS库的应用程序一般可以比较容易的切换到cuBLAS。例如,如果应用程序几乎不做线性代数,那么cuBLAS就没有什么用处。其他CUDA工具包库也是如此:比如cuFFT有一个类似于FFTW的接口。

还有Thrust库,这是一个类似于C++标准模板库的并行C++模板库。Thrust提供了丰富的数据并行原语,比如扫描、排序和归集,这些原语可以组合在一起,用简洁易读的源代码实现复杂的算法。通过用这些高级抽象描述你的计算,Thrust可以自由地帮你自动选择最有效的实现。因此,Thrust可用于CUDA应用程序的快速原型设计,它提高了程序员的生产力,也保证了代码的鲁棒性和绝对性能。

5.2 并行化编译器(Parallelizing Compilers)

另一个并行化的方式是利用并行化编译器。通常这意味着使用基于指令的方法,程序员不是调整已有的代码本身,而是使用注释pragma或标记等让编译器知道哪里可以被并行化。随后编译器自己会把计算映射到并行架构上。

OpenACC标准提供了一组编译指令,可用于指明C、C++和Fortran代码中的哪些片段可以从主机CPU上移到CUDA GPU上执行。具体对设备的操作细节由使能了OpenACC的编译器管理和执行。

5.3 编码使并行化(Coding to Expose Parallelism)

如果现有的库和并行化编译器都搞不定,应用程序还需要另外的功能或性能提升,就需要使用并行编程语言,比如CUDA C++,并无缝衔接现有的串行代码。

在使用剖析器得到关键点和确定需要重写代码后,我们可以使用CUDA C++,把代码中的可以并行的部分当做一个CUDA kernel。我们可以在GPU上运行kernel并获取结果,而不用大幅重写代码的其它部分。

当我们程序的运行时间主要消耗在一些相对隔离的部分的时候,这种方法(即直接重写那部分耗时的代码)是最直接的。

比较难并行化的是那种非常扁平的应用程序,即时间广泛地消耗在代码的很多部分。对于这种情况,就需要进行某种程度的重构,把可以被并行化的地方暴露出来。将来的架构都将在这种重构中获利,所以这么做是值得的。

6 保证结果正确(Getting the Right Answer)

获取到正确的结果是所有计算的原则。在并行系统上,可能会遇到在传统的串行导向的编程中不常见的困难。这些问题包括线程问题、浮点值计算方式导致的意外值,以及CPU和GPU处理器操作方式差异带来的挑战。本章将分析可能影响返回数据正确性的一些问题,并给出适当的解决方案。

6.1 验证(Verification)

6.1.1 参考比较(Reference Comparison)

对任何现有程序进行修改,并验证其正确性的一个关键是建立某种机制:对某些有代表性的输入,用以前(修改前)良好的输出结果与新结果进行比较。每次更改后,确保无论对当前算法使用任何标准,结果都是匹配的。某些人会期望得到所有位都相同的结果,这并不总是可行的,特别是在涉及浮点运算的情况下;有关数值精度,请参见“数值精度和精确”一节。对于其他算法,如果运算结果与参考目标只有很小的差异(比如小于某个很小的数的范围),则可以认为是正确的。

上面提到的用于验证数值结果的方法可以很容易地扩展到验证性能结果。我们想要保证做过的每一项改变都是正确的并且可以(在预期程度上)提升性能。作为周期性APOD过程的一个组成部分,经常检查这些事情将有助于确保我们尽快达到预期的结果。

6.1.2. Unit Testing

6.1.2 单元测试(Unit Testing)

与上面描述的“参考比较”策略对应的一个有用的方法就是在把代码做成单元级可验证的方式。比如我们可以在写CUDA kernel时多用短的device函数,而不是大的global函数。所有设备函数在被连接在一起之前都可以单独测试。

例如,许多内核除了实际的计算之外,还有复杂的寻址逻辑来访问内存。如果我们在引入大量计算之前单独验证寻址逻辑,就将简化以后的调试工作。(请注意,CUDA编译器将任何不向全局内存写入数据的设备代码视为需要消除的死代码,因此我们必须向写入全局内存写点东西当做寻址逻辑的结果,以便成功应用此策略。)

更进一步地说,如果大多数函数被定义为host__device函数而不仅仅是device__ 函数,那么这些函数可以在CPU和GPU上都进行测试,从而增加我们对函数正确性和结果不会有任何意外差异的信心。如果存在差异,那么这些差异将在早期被发现,并且可以在简单函数的上下文中被理解(指不用以后再到复杂逻辑中去排查)。

还有一个有用的副作用,如果我们希望在应用程序中同时包含CPU和GPU执行路径,此策略将允许我们减少代码重复:如果我们的CUDA内核的大部分工作是在 host__device__函数中完成的,我们可以轻松地从主机代码和设备代码调用这些函数,而无需重复写这些函数。

6.2. 调试

CUDA-GDB是在Linux和Mac上运行的GNU调试器的一个端口。https://developer.nvidia.com/cuda-gdb

还有几个第三方的调试器支持CUDA调试。可参考https://developer.nvidia.com/debugging-solutionshttps://developer.nvidia.com/nsight-visual-studio-edition。

6.3 数值精度和精确(Numerical Accuracy and Precision)

不正确或意外的结果主要由浮点值的计算和存储方式引起的浮点精度问题引发。以下各节解释了需要主要关注的地方。

6.3.1 单精度和双精度(Single vs. Double Precision)

计算能力1.3(NVIDIA对自己GPU的计算能力的一种打分)和以上的设备支持双精度浮点运算(即64位宽的值)。由于双精度算法的精度更高以及四舍五入问题,即使执行相同的操作,使用双精度算法通常与使用单精度算法获得的结果不同。因此,重要的是对类似精度的值做比较,并在一定公差范围内考虑结果是否正确,而不是期望它们是完全精确的。

6.3.2 浮点计算不符合结合律(Floating Point Math Is not Associative)

每个浮点算术运算操作都会有一定程度的四舍五入。因此执行算术运算的顺序很重要。比如A、B、C都是浮点数,(A+B)+C不能像符号数学中那样保证等于A+(B+C)。并行计算时,可能会更改操作顺序,因此并行计算的结果可能与顺序计算的结果不匹配。这一限制并不是CUDA特有的,而是浮点数并行计算的固有特点。

6.3.3 IEEE 754规范(IEEE 754 Compliance)

除了一些小的例外,所有CUDA计算设备都遵循IEEE 754二进制浮点表示标准。这些例外,在CUDA C++编程指南的特征和技术规范中有详细说明,可以导致与在主机系统上计算的结果不同。

其中一个关键区别是fused multiply add(FMA)指令,它将乘加(multiply-add)操作组合到单个指令执行中。其结果通常与分别执行这两个操作所获得的结果略有不同。

6.3.4 x86 80位 计算

x86在做浮点运算时可以使用80位双扩展精度计算。其计算结果经常与在CUDA设备上执行纯64位计算不同。如果想让结果尽可能相近,需要去设置x86处理器,使其使用常规的双单精度(分别为64位和32位)。这是通过FLDCW x86汇编指令或等效的操作系统API完成的。

7 优化CUDA应用程序

在每一轮应用程序并行化完成后,开发人员可以着手优化具体实现以提高性能。由于可以考虑的优化方法很多,充分了解应用程序的需求有助于使优化过程尽可能顺利。但是,像APOD是一个整体那样,程序优化是一个迭代过程(确定优化机会,应用并测试优化,验证实现的加速效果,重复),这意味着在获得良好的加速成果之前,程序员不必花费大量时间来记忆所有可能的优化策略。相反,策略可以边学边用。

优化可以应用到各个层次,从“计算和数据传输并行”到“微调浮点操作顺序”。可用的剖析工具在此过程中是非常珍贵的,它们可以为开发人员的优化工作提供下一个最佳的行动方案的建议,并为本指南优化部分的相关内容提供参考。

8 性能指标(Performance Metrics)

在尝试优化CUDA代码时,先了解如何准确测量性能以及理解带宽在性能测量中的作用是值得的。本章讨论如何使用CPU计时器和CUDA事件正确测量性能。然后探讨带宽如何影响性能指标,以及如何缓解带宽带来的一些挑战。

8.1 计时(Timing)

CUDA调用和内核执行可以使用CPU或GPU定时器进行计时。本节将介绍这两种方法的功能、优点和缺点。

8.1.1 使用CPU定时器(Using CPU Timers)

任何CPU时钟都可以用来测量CUDA调用和kernel执行消耗的时间。CPU计时方法的细节不在本文讨论范围内,但是开发者需要有计时精度的意识。

使用CPU计时器时,很关键的一点是要记住很多CUDA API函数是异步的,也就是它们在完成工作之间就返回调用它们的CPU线程了。所有kernel启动函数都是异步的,名称上带有Async后缀的内存复制函数也是如此。因此,要精确的测量某一调用的时间消耗,必须在开始和停止CPU定时器时立即调用cudaDeviceSynchronize(),同步CPU线程和GPU。cudaDeviceSynchronize()会阻塞调用它的CPU线程,直到这个线程之前发起的CUDA调用全部执行完成。

虽然也可以将CPU线程与GPU上的特定流或事件进行同步,但这些同步函数不适用于对默认流以外的流中的代码进行计时。cudaStreamSynchronize()将阻塞CPU线程,直到之前向给特定流发出的所有CUDA调用完成。cudaEventSynchronize()也会阻塞CPU线程,直到GPU记录了特定流中的给定事件。因为驱动可以交错执行来自其他非默认流的CUDA调用,所以计时中可能包括了其他流中的调用。

由于默认流(流0)显示设备上工作的串行行为(默认流中的操作只能在其他任意流中的所有之前的调用全部完成后开始;任何流中的后续操作在完成之前都不能开始),因此这些函数可以可靠地用于在默认流中计时。

请注意,CPU到GPU的同步点(如本节中提到的同步点)意味着GPU处理流水线中的暂停,因此应谨慎使用,以将其性能影响降至最低。

8.1.2 使用CUDA GPU定时器(Using CUDA GPU Timers)

CUDA事件API提供了用于创建和销毁事件、记录事件(包括时间戳)和将时间戳差异转换为浮点值(以毫秒为单位)的调用。下面这一小节阐明了它们的用法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
cudaEvent_t start, stop;
float time;

cudaEventCreate(&start);
cudaEventCreate(&stop);

cudaEventRecord( start, 0 );
kernel<<<grid,threads>>> ( d_odata, d_idata, size_x, size_y,
NUM_REPS);
cudaEventRecord( stop, 0 );
cudaEventSynchronize( stop );

cudaEventElapsedTime( &time, start, stop );
cudaEventDestroy( start );
cudaEventDestroy( stop );

这里,cudaEventRecord()用于将开始和停止事件放入默认流(即流0)中。当设备在流中到达该事件时,将记录该事件的时间戳。cudaEventElapsedTime() 函数的作用是:返回开始记录和停止记录事件之间经过的时间。该值以毫秒为单位,分辨率约为半微秒。与本代码段中的其他调用一样,CUDA工具包参考手册中描述了它们的具体操作、参数和返回值。请注意,计时是在GPU时钟上测量的,因此计时分辨率与操作系统无关。

8.2 带宽(Bandwidth)

带宽——数据传输的速率——是性能最重要的关键因素之一。几乎对代码的所有更改都应该考虑它们如何影响带宽。如本指南的“内存优化”一节中所述,带宽可能会受到存储数据的内存选择、数据布局和访问顺序以及其他因素的显著影响。

为了准确测量性能,需要计算理论带宽和有效带宽。当后者比前者低得多时,设计或实现细节可能降低了带宽,增加带宽应该是后续优化工作的主要目标。

注意:高优先级:在评估性能和优化效果时,使用计算的有效带宽(只考虑了有效数据和时间)作为衡量标准。

8.2.1 理论带宽的计算(Theoretical Bandwidth Calculation)

理论带宽可以使用产品文献中提供的硬件规格进行计算。例如,NVIDIA TESLA V100使用HBM2(双数据速率)RAM,存储器时钟速率为877 MHz和4096位宽的存储器接口。

使用这些数据项,NVIDIA TESLA V100的峰值理论存储器带宽为898 Gb/s:

(0.877×109×(4096/8)×2)÷109=898GB/s⁡

在该计算中,内存时钟速率转换为Hz,乘以接口宽度(除以8,将位转换为字节),再乘以2(由于数据速率加倍)。最后,将该乘积除以109,将结果转换为GB/s。

注:某些计算使用10243而不是109进行最终计算(这里应该是指公式里后面那个109)。在这种情况下,带宽将为836.4 GiB/s。在计算理论带宽和有效带宽时,使用相同的除数很重要,这样比较才有效。

注:在启用ECC的GDDR内存的GPU上,可用DRAM减少6.25%,以允许存储ECC位。与禁用ECC的相同GPU相比,为每次内存传输获取ECC位也会将有效带宽减少约20%,尽管ECC对带宽的确切影响可能更高,并且取决于内存访问模式。另一方面,HBM2存储器提供专用ECC资源,允许无开销ECC保护(即不会影响有效带宽)。

8.2.2 有效带宽的计算(Effective Bandwidth Calculation)

有效带宽是通过为特定的程序活动计时和了解程序如何访问数据来计算的。需使用以下等式:

有效带宽=((Br+Bw)÷109)÷时间

这里,有效带宽以GB/s为单位,Br为每个kernel读取的字节数,Bw为每个kernel写入的字节数,时间以秒为单位。

例如,要计算2048 x 2048矩阵拷贝的有效带宽,可以使用以下公式:

有效带宽=((20482×4×2)÷109)÷时间

元素数乘以每个元素的大小(浮点为4字节),再乘以2(读和写为两步),再除以109(或10243),即可获得所传输的GB内存。此数字除以以秒为单位的时间,得到GB/s。

8.2.3 Visual Profiler记录的吞吐量(Throughput Reported by Visual Profiler)

对于计算能力为2.0或更高的设备,可以用Visual Profiler收集几种不同的内存吞吐量。以下吞吐量指标可显示在详细信息或细节视图中:

  • (程序主动)请求的全局加载吞吐量
  • (程序主动)请求的全局保存吞吐量
  • (系统实际发生的)全局加载吞吐量
  • (系统实际发生的)全局保存吞吐量
  • DRAM读吞吐量
  • DRAM写吞吐量

请求的全局加载吞吐量和请求的全局保存吞吐量值指的是kernel请求的全局内存吞吐量,因此对应于“有效带宽计算”那一小节提到的方法获得的有效带宽。

由于最小内存访问的size大于大多数的字的size(这里指的是当访问某些以字为单位的数据时,由于总线的宽度一般大于一个字,每次访问的size又必须是一个总线宽度,所以经常会有无效的数据被访问。另外还有内存访问合并的考虑。),因此kernel所需的实际内存吞吐量可能包括内核未使用的数据传输。对于全局内存访问,实际吞吐量由全局加载吞吐量和全局保存吞吐量体现。

需要注意的是,这两个数字都很有用。实际内存吞吐量显示代码与硬件限制的接近程度,将有效或请求的带宽与实际带宽进行比较,可以很好地估计内存访问的次优合并会浪费多少带宽(请参阅对全局内存的合并访问一节)。对于全局内存访问,请求的内存带宽与实际内存带宽的比较由“全局内存加载效率”和“全局内存保存效率”来衡量。

9 内存优化(Memory Optimizations)

内存优化对性能来说是最重要的。其目标是通过带宽最大化来最大程度的使用硬件的能力。通过使用尽可能多的快速内存访问和尽可能少的慢速内存访问,得到最大的带宽。本章将讨论主机和设备上的各种内存,以及如何最好地设置数据项以有效使用内存。

9.1 主机和设备间的数据传输(Data Transfer Between Host and Device)

设备内存和GPU之间的峰值理论带宽(比如英伟达TESLA V100的898 Gb/s)要远高于主机内存和设备内存之间的峰值理论带宽(比如PCIe X16 GE3上的16 Gb/s)。因此,为了获得最佳的应用程序整体性能,将主机和设备之间的数据传输降至最低是很重要的,即使这意味着在GPU上运行kernel与在主机CPU上运行相同逻辑相比不会表现出任何速度优势。

注意:高优先级:尽量减少主机和设备之间的数据传输,即使这意味着在设备上运行一些kernel,与在主机CPU上运行这些kernel相比,它们的性能并没有提高。

中间(即非结果的)数据结构应在设备内存中创建,由设备操作,并在未映射或未复制到主机内存的情况下销毁。

此外,由于与每次传输相关的开销,将许多小的传输批处理成一个大的传输要比单独进行每次传输的性能好得多,即使这样做需要将数据从非连续的内存区域打包到连续的buffer中,然后在传输后解包。

最后,当使用页面锁定(或pinned)的内存时(一般意味着数据在这端内存中常驻,不会被swap到磁盘中),主机和设备之间的带宽更高,如CUDA C++编程指南和本文档的“ Pinned Memory ”(就是下面这里)部分中所介绍的那样。

9.1.1 Pinned内存

页锁定或pinned(它的一个重要特点是操作系统将不会对这块内存分页并交换到磁盘上,从而保证了内存始终驻留在物理内存中)内存传输可在主机和设备之间获得最高带宽。例如,在PCIe x16 Gen3卡上,pinned内存可以达到大约12 GB/s的传输速率。

pinned内存是使用运行时API中的cudaHostAlloc()函数分配的。bandwidthTest CUDA示例演示了如何使用这些函数以及如何测量内存传输性能。

对于已经提前分配好的系统内存区域,可以使用cudahostergister()去动态pin内存,而无需再分配新的缓冲区并将数据复制到里面。

不应过度使用pinned内存。过度使用会降低总体系统性能,因为pinned内存是一种稀缺资源,但是多少算多呢?很难事先知道。此外,与大多数正常的系统内存分配相比,系统内存的pin是一项重量级(指更耗时的)操作,因此需要像其他所有优化那样,测试应用程序及其运行的系统以获得最佳性能(意思是否则还不如不pin)。

9.1.2 数据传输和计算的异步和同时进行(Asynchronous and Overlapping Transfers with Computation)

使用cudaMemcpy()进行的主机和设备之间的数据传输是阻塞式的;也就是说,只有在数据传输完成后,控制才会返回到主机线程。cudamemcpysync()函数是cudaMemcpy()的一个非阻塞变体,其中控制权立即返回到主机线程。与cudaMemcpy()相反,异步传输版本需要pinned主机内存(否则啥时候去pin呢…),并且它包含一个附加参数,即流ID。流是在设备上按顺序执行的一系列操作。不同流中的操作可以交错执行,在某些情况下可以重叠(即同时执行)-这是一个可用于隐藏主机和设备之间的数据传输的属性(这里的意思是如果运算和数据传输同时执行,那就相当于数据传输没有占用计算时间了)。

异步传输以两种不同的方式实现数据传输与计算的同时执行。在所有支持CUDA的设备上,主机计算可能与异步数据传输和设备计算(三者)同时执行。例如,下面这部分描述演示了在将数据传输到设备并执行使用该设备的kernel时,如何在主机上执行cpuFunction()的计算。

同时进行计算和数据传输(Overlapping computation and data transfers)

1
2
3
cudaMemcpyAsync(a_d, a_h, size, cudaMemcpyHostToDevice, 0);
kernel<<<grid, block>>>(a_d);
cpuFunction();

cudaMemcpyAsync()函数的最后一个参数是流ID,在本例中,它使用默认的0号ID。kernel也使用默认流,在内存拷贝完成之前,它不会开始执行;因此,不需要显式同步。因为内存拷贝和kernel都会立即将控制权返回给主机,所以主机函数cpuFunction()的执行会和前两步(即内存拷贝和kernel执行,注:前两步自身不重叠)同时进行。

在上面这个例子中,内存拷贝和kernel执行是顺序进行的。在能够并发数据拷贝和计算的设备上,可以同时进行设备上的内核执行与主机和设备之间的数据传输。设备是否具有此功能由cudaDeviceProp结构的asyncEngineCount字段指示(或在deviceQuery CUDA示例的输出中列出)。在具有此功能的设备上,要做到同时执行,还是需要pinned主机内存,此外,数据传输和kernel必须使用不同的非默认流(具有非0流ID的流)。此重叠需要非默认流,因为使用默认流的内存复制、内存设置函数和kernel调用只有在设备(在任何流中)上的所有先前调用完成后才开始,并且设备(在任何流中)上的任何操作在完成之前也都不会开始。

下一小节对此做了基本演示。

并发拷贝和执行(Concurrent copy and execute)

1
2
3
4
cudaStreamCreate(&stream1);
cudaStreamCreate(&stream2);
cudaMemcpyAsync(a_d, a_h, size, cudaMemcpyHostToDevice, stream1);
kernel<<<grid, block, 0, stream2>>>(otherData_d);

在这段代码中,创建了两个流,并分别用于数据传输和kernel执行,正如cudaMemcpyAsync调用的最后一个参数和kernel的执行配置中指定的那样。

并发复制和执行演示了如何将内核执行与异步数据传输重叠。当数据依赖性不太强,使得数据可以被分割成多个块并在多个阶段中传输时,可以使用该技术,在每个块到达时启动多个kernel对其进行操作。顺序复制和执行以及分阶段并发复制和执行演示了这一点。它们产生了相同的结果。第一段显示了引用顺序实现,它在N个浮点数组上传输和操作(其中N被假设为可被nThreads整除)。

下面两小段描述即“顺序拷贝和执行”(Sequential copy and execute)以及“分段并发拷贝和执行”(Staged concurrent copy and execute)演示了这一点。它们产生了相同的结果。

第一段展示了参考顺序实现,其在N个浮点数组上传输和操作(其中N被假定为可被N个线程平均整除)。

1
2
cudaMemcpy(a_d, a_h, N*sizeof(float), dir);
kernel<<<N/nThreads, nThreads>>>(a_d);

“分段并发拷贝和执行”(Staged concurrent copy and execute),即下一小段,描述了数据的传输和kernel的执行可以被分成多个流的阶段。这种方法使得数据传输和kernel执行可以并行。

1
2
3
4
5
6
7
size=N*sizeof(float)/nStreams;
for (i=0; i<nStreams; i++) {
offset = i*N/nStreams;
cudaMemcpyAsync(a_d+offset, a_h+offset, size, dir, stream[i]);
kernel<<<N/(nThreads*nStreams), nThreads, 0,
stream[i]>>>(a_d+offset);
}

(在上面这段代码中,假设N可被n个线程*n个流整除。)因为流中的执行是顺序进行的,所以在各自流中的数据传输完成之前,不会启动任何kernel。当前的GPU可以同时处理异步数据传输和执行kernel。具有单个复制引擎的GPU可以执行一次异步数据传输并执行kernel,而具有两个复制引擎的GPU可以同时执行一次从主机到设备的异步数据传输、一次从设备到主机的异步数据传输以及执行kernel。GPU上复制引擎的数量由cudaDeviceProp结构的asyncEngineCount字段给出,该字段也可以在deviceQuery CUDA示例的输出中找到。(应该提到的是,不可能将阻塞传输与异步传输重叠,因为阻塞传输发生在默认流中,因此在所有以前的CUDA调用完成之前,它不会开始。在它自己完成之前,它也不会允许任何其他CUDA调用开始。)

9.1.3 零拷贝(Zero Copy)

零拷贝是CUDA工具包2.2版中添加的一项功能。它使GPU线程能够直接访问主机内存。为此,它需要映射的pinned(不可分页,即物理地址连续的)内存。在集成GPU(即CUDA设备属性结构的集成字段设置为1的GPU,这里可以理解为使用主机内存的集成显卡)上,映射固定内存始终可以获得性能增益,因为它避免了多余的拷贝,因为集成GPU和CPU内存在物理上是相同的。在和主机分离的GPU(即独立显卡)上,映射固定内存仅在某些情况下具有优势。由于数据未缓存在GPU上,映射的pinned内存应该只读取或写入一次,读取和写入内存的全局加载和保存应该合并。零拷贝可以用来代替流,因为源于kernel的数据传输会自动与kernel同时执行,而无需花费时间在设置和确定最佳流数上。

注意:低优先级:在CUDA Toolkit 2.2版及更高版本的集成GPU(集成显卡)上使用零拷贝操作(因为集成显卡和CPU共享主机物理内存)。

1
2
3
4
5
6
7
8
9
float *a_h, *a_map;
...
cudaGetDeviceProperties(&prop, 0);
if (!prop.canMapHostMemory)
exit(0);
cudaSetDeviceFlags(cudaDeviceMapHost);
cudaHostAlloc(&a_h, nBytes, cudaHostAllocMapped);
cudaHostGetDevicePointer(&a_map, a_h, 0);
kernel<<<gridSize, blockSize>>>(a_map);

在此代码中,cudaGetDeviceProperties()返回的结构的canMapHostMemory字段用于检查设备是否支持将主机内存映射到设备的地址空间。通过调用cudaSetDeviceFlags(cudaDeviceMapHost)使能页锁定的内存映射。请注意,必须在设置设备或做CUDA调用获取状态之前(本质上是在创建上下文之前)调用CUDASETDEVICELAGS()。使用cudaHostAlloc()分配页锁定的主机内存并映射,然后通过函数cudaHostGetDevicePointer()获取指向映射设备地址空间的指针。在上面的代码中,kernel()可以使用指针a_map来引用映射的pinned主机内存,就好像a_map指向的是设备内存一样。

注意:映射pinned主机内存允许CPU-GPU内存传输与计算同时进行,同时避免使用CUDA流。但是,由于对这种内存区域的任何重复访问将导致重复的CPU-GPU间传输,所以可以考虑在设备内存中创建第二区域以手动缓存先前从主机内存读取到的数据。

9.1.4 统一虚拟寻址(Unified Virtual Addressing)

计算能力2.0及更高版本的设备在使用TCC驱动模式时,在64位Linux、Mac OS和Windows上支持称为统一虚拟寻址(UVA)的特殊寻址模式。使用UVA,所有已安装的受支持设备的主机内存和设备内存共享一个虚拟地址空间。

在UVA之前,应用程序必须跟踪哪些指针指向设备内存(以及哪个设备),哪些指针指向主机内存(使用一个bit作为标记,或在程序中硬编码实现)。另一方面,使用UVA,指针指向的物理内存空间(是属于设备还是主机)可以通过使用CUDAPointerGetAttributes()检查指针的值来确定。

在UVA下,使用cudaHostAlloc()分配的pinned主机内存将具有相同的主机和设备指针,因此无需为其调用cudaHostGetDevicePointer()(去获取设备地址在主机端的对应地址)。但是,通过cudaHostRegister()分配然后又pinned的主机内存将继续具有与其主机指针不同的设备指针,因此在这种情况下,cudaHostGetDevicePointer()仍然是必需的。

UVA也是支持相关配置的GPU互相直接通过PCIe总线或NVLink(绕过主机内存)进行对等(P2P)数据传输的必要先决条件。

9.2 设备地址空间(Device Memory Spaces)

CUDA设备使用多个内存空间,这些内存空间具有不同的特性,反映了它们在CUDA应用程序中的不同用途。这些内存空间包括全局、局部、共享、纹理(Texture)和寄存器。

纹理内存是计算机内存的一个只读区域,专门用于快速访问计算机图形学中用作纹理表面的图像,通常是用于三维(3D)渲染。最有效的纹理存储器存在于视频卡上的专用存储芯片中,这种视频卡上的处理器独立于计算机中的主处理器。有时图形卡内存不足。在这些情况下,计算机的RAM甚至硬盘上的空间都可以用作虚拟纹理存储器,尽管在这些情况下性能会受到负面影响。可用的纹理内存量越大,存储在其中的图像就越大、越详细,从而提供更逼真的图形渲染。

在计算机屏幕上渲染三维图像的过程需要几个步骤。最后一个步骤是将纹理应用于正在被渲染的对象的几何体。此纹理是存储在内存中的二维(2D)图像,用于提供3D多边形对象表面的颜色、抛光和细节。将2D图像保留在纹理内存中可以快速访问,这有助于提高场景渲染的速度,从而实现平滑运动和动画。

如果只是用于GPU的本职工作——图像渲染,这段话的理解应该够了,把它理解成一段只读内存就行了,之所以起这个名字是因为它的用途——存储纹理数据。

但我们的工作并不仅限于图像渲染,还要用GPU来计算,于是又在网上找到这么一句话和下图。

纹理内存是DRAM上的内存,可以申请很大的空间,相比常量内存只能申请64kb来说,是一种很大空间的常量内存,而常量内存的好处是可以广播,当多个swap访问同一位置时,广播机制可以减少全局内存的访问,来提速。

综合来看纹理内存应该是有这样的特点:空间大、用于快速访问的只读(常量)数据。但下面又提到纹理内存访问延迟较大,上面也提到RAM甚至硬盘上的空间都可以用作虚拟纹理存储器,看来纹理内存只是一种组织结构,目的很好,但实际是否真的快还取决于具体情况(比如取决于纹理内存实际使用的物理内存在哪里)。

在这些不同的内存空间中,全局内存是最丰富的(应该是用的最多空间也最大的意思);CUDA C++编程指南的特征和技术规范这个文档中可以找到每个计算能力级别的GPU上在每种内存空间中可用的内存量。全局、本地和纹理(Texture)内存的访问延迟最大,其次是常量内存、共享内存和寄存器文件。

内存类型的各种主要特征如表1所示。

Memory Location on/off chip Cached Access Scope Lifetime
Register On n/a R/W 1 thread Thread
Local Off Yes†† R/W 1 thread Thread
Shared On n/a R/W All threads in block Block
Global Off R/W All threads + host Host allocation
Constant Off Yes R All threads + host Host allocation
Texture Off Yes R All threads + host Host allocation

† Cached in L1 and L2 by default on devices of compute capability 6.0 and 7.x; cached only in L2 by default on devices of lower compute capabilities, though some allow opt-in to caching in L1 as well via compilation flags.

†† Cached in L1 and L2 by default except on devices of compute capability 5.x; devices of compute capability 5.x cache locals only in L2.

在访问纹理(Texture)内存时,如果纹理引用绑定到全局内存中的线性数组,则设备代码可以写入底层数组。绑定到CUDA阵列的纹理引用可以通过表面写入(surface-write)操作写入,方法是将surface绑定到相同的底层CUDA阵列存储。应避免在同一kernel启动中写入其底层全局内存数组时读取纹理,因为纹理缓存是只读的,并且在修改关联的全局内存时不会被无效(意思是读取时不会和实际的底层数据同步)。

9.2.1 合并访问全局内存(Coalesced Access to Global Memory)

在为支持CUDA的GPU体系结构编程时,一个非常重要的性能考虑因素是合并全局内存访问。一个warp中的线程的全局内存加载和保存请求,由设备合并成尽可能少的(读写)事务。

注意:高优先级:确保尽可能合并全局内存访问。

合并访问请求依赖于设备的计算能力。

对于compute capability 6.0或更高版本的设备,可以很容易地总结出:一个warp中的线程的并发访问将合并成一系列事务,这些事务的数量等于为这个warp中的线程提供服务所需的32字节事务的数量。

对于计算能力为3.5、3.7和5.2的某些设备,可以选择启用全局内存访问的一级缓存。如果在这些设备上启用了L1缓存,则所需事务的数量等于所需的128字节对齐的段的数量。

注意:在compute capability 6.0或更高版本的设备上,L1缓存是默认使能的,但是无论全局负载是否缓存在L1中,数据访问事务的基本单位都是32字节。

在具有GDDR内存的设备上,当ECC打开时,以合并方式访问内存更为重要。分散访问会增加ECC内存传输开销,尤其是在将数据写入全局内存时。

以下简单示例说明了合并概念。这些示例假设计算能力为6.0或更高版本,除非另有说明,否则访问是针对4字节字的。

9.2.1.1 一个简单的访问模式(A Simple Access Pattern)

访问合并的第一个也是最简单的情况可以通过任何支持CUDA的计算能力为6.0或更高的设备实现:第k个线程访问32字节对齐数组中的第k个字。并非所有线程都需要参与。

例如,如果一个warp中的线程访问相邻的4字节字(例如,相邻的浮点值)(注:这里的一个warp中有32个线程,每个线程访问4字节字,总地址可达128字节),则四个合并的32字节事务将为该内存访问提供服务。

img

此访问模式产生四个32字节的事务,由红色矩形表示。

如果某次操作只想从四个32字节段中的任何一个中,请求获取它的一个或几个字(例如,如果多个线程访问了同一个字,或者如果一些线程没有参与访问),则无论如何都会获取整个段。此外,如果warp中的线程的访问已在段内或跨段进行了重新排序,则具有6.0或更高计算能力的设备仍将仅执行四个32字节的事务。

9.2.1.2 一种顺序但未对齐的访问模式(A Sequential but Misaligned Access Pattern)

如果一个warp中的线程按地址顺序访问内存,但地址并不是32字节对齐,则将请求5个32字节段,如图4所示。

img

通过CUDA运行时API(例如cudaMalloc())分配的内存保证至少256字节对齐(所以看来不需要程序员再主动对齐)。所以,选择合理的线程块大小,例如warp size(比如当前GPU上的32,意思是一个warp线程组中有32个线程)的倍数,有助于正确对齐的批量访问内存。(可以考虑这样一种情况,如果线程块大小不是warp size倍数,第二个、第三个和后续线程块访问的内存地址会发生什么情况?就对不齐了,会影响内存访问效率)

9.2.1.3 未对齐访问的影响(Effects of Misaligned Accesses)

使用一个简单的复制kernel(是说这个kernel要做数据复制,例如下面这段)来探索未对齐访问的后果是很容易的,而且获得的信息很有用。

1
2
3
4
5
__global__ void offsetCopy(float *odata, float* idata, int offset)
{
int xid = blockIdx.x * blockDim.x + threadIdx.x + offset;
odata[xid] = idata[xid];
}

在上面这段代码,数据从输入数组idata复制到输出数组,这两个数组都存在于全局内存中。做这个复制工作的kernel在主机代码中的循环执行,每次循环将参数offset从0到32之间做更改(图4的横轴对应于该offset的值)。NVIDIA Tesla V100(计算能力7.0)上对应各种offset的有效带宽如图5所示。

img

对于NVIDIA Tesla V100,地址没有偏移或为8的倍数偏移的全局内存访问引起的是4个32字节的事务。实现的带宽约为790 GB/s。否则,每个warp将加载5个32字节的段,这样简单算的话,和没有偏移的情况相比,有偏移的情况将获得大约4/5的内存吞吐量。

然而,在这个示例中,有偏移的内存吞吐量约为无偏移的9/10,这是因为相邻的warps(线程)会重用其邻居(线程)获取的缓存线。因此,尽管影响仍然明显,但并不像我们预期的那么大。如果相邻的warp(线程)没有高度重用cache line,情况会更糟。

这一小节告诉我们数据至少要8字节对齐。

9.2.1.4 跨步访问(Strided Accesses)

如上所述,在顺序访问未对齐的情况下,缓存有助于减轻其对性能的影响。但是,非单元跨步访问(non-unit-strided accesse,就是不连续地址访问,而是按某个步长跳着访问)的情况就不一样了,这是一种在处理多维数据或矩阵时经常出现的模式。因此,确保实际使用的每个缓存线中存在尽可能多的数据是内存访问性能优化的一个重要部分。

要说明跨步访问对有效带宽的影响,请参考下面这个例子——kernel strideCopy() 。它在线程之间从idata到odata以参数stride为步幅复制数据。

1
2
3
4
5
__global__ void strideCopy(float *odata, float* idata, int stride)
{
int xid = (blockIdx.x*blockDim.x + threadIdx.x)*stride;
odata[xid] = idata[xid];
}

图6说明了这种情况:一个warp中的线程以2为步长访问内存中的字。此操作将导致Tesla V100(计算能力7.0)上每个warp加载8个二级缓存段。

img

Figure 6. Adjacent threads accessing memory with a stride of 2

以2为步幅进行访问会导致50%的加载/存储效率,因为事务中有一半的元素未被使用,意味着带宽被浪费掉了。随着步幅的增加,有效带宽会降低,直到为warp中的32个线程加载32个32字节段为止(意思是随着步长越来越大,每个warp中的线程在访问数据时用的事务越来越多,直到32个。因为再多的话一个事务就不会再覆盖两次访问,每次访问都会对应一个事务,就不会再有性能降低了),如图7所示。

img

Figure 7. Performance of strideCopy kernel

如图7所示,应尽可能避免非单位跨步全局内存访问。一种方法是利用共享内存,将在下一节中讨论。

9.2.2. L2 Cache

从CUDA 11.0开始,计算能力为8.0及以上的设备能够影响二级缓存中数据的持久性。因为二级缓存是片上的,所以它有可能提供更高的带宽和更低的全局内存访问延迟。

9.2.2.1 L2 Cache 访问窗口(L2 Cache Access Window)

当CUDA内核重复访问全局内存中的数据区域时,可以认为这种数据访问是持续性的。另一方面,如果数据仅被访问一次,则此类数据访问可被视为流式访问。可以把二级缓存的一部分预留出来,用于对全局内存中的数据区域进行持续性访问。如果此预留部分没有被持续性访问使用,则流式或正常数据访问可以使用它。

下面的代码可在一定限制范围内调整二级缓存为持续性访问预留的缓存大小。

1
2
cudaGetDeviceProperties(&prop, device_id);
cudaDeviceSetLimit(cudaLimitPersistingL2CacheSize, prop.persistingL2CacheMaxSize); /* Set aside max possible size of L2 cache for persisting accesses */

用户数据到L2预留部分的映射可以使用CUDA流或CUDA图形kernel节点上的访问策略窗口进行控制。下面的示例显示如何在CUDA流上使用访问策略窗口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 persisting accesses.
// (Must be less than cudaDeviceProp::accessPolicyMaxWindowSize)
stream_attribute.accessPolicyWindow.hitRatio = 1.0;
// Hint for L2 cache hit ratio for persisting accesses in the num_bytes region
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);

访问策略窗口需要hitRatio和num_bytes的值。根据num_bytes参数的值和二级缓存的大小,可能需要调整hitRatio的值以避免L2 cache lines的抖动。

9.2.2.2 调整访问窗口的Hit率(Tuning the Access Window Hit-Ratio)

hitRatio参数可用于指定接收hitProp属性的访问的比例。例如,如果hitRatio值为0.6,则全局内存区域[ptr..ptr+num_bytes)中60%的内存访问具有持续性属性,40%的内存访问具有流式属性。为了了解hitRatio和num_bytes的影响,我们使用了滑动窗口微基准测试。

此微基准使用GPU全局内存中的1024 MB。首先,(像上一节描述的那样)我们使用CudDeviceSetLimit()预留出30 MB的二级缓存用于持续性访问。然后,如下图所示,我们指定对第一个freqSize*sizeof(int)字节数大小的内存区域的访问是持续性的,这个数据将使用L2预留的那部分(30 MB)。在我们的实验中,我们将这个持续性数据区域的大小从10 MB改变到60 MB,以模拟数据适合或超过可用L2预留部分(30 MB)的各种场景。NVIDIA TESLA A100 GPU拥有40 MB的L2高速缓存容量。对其他内存区域数据(即流数据)的访问被视为正常访问或流访问,因此其将使用未预留L2部分的剩余10 MB(除非L2预留部分的一部分未使用)。

img

Figure 8. Mapping Persistent data accesses to set-aside L2 in sliding window experiment

下面的kernel代码和访问窗口参数,就是本滑动窗口实验的实现方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
__global__ void kernel(int *data_persistent, int *data_streaming, int dataSize, int freqSize) { 
int tid = blockIdx.x * blockDim.x + threadIdx.x;
/*Each CUDA thread accesses one element in the persistent data section
and one element in the streaming data section.
Because the size of the persistent memory region (freqSize * sizeof(int) bytes) is much
smaller than the size of the streaming memory region (dataSize * sizeof(int) bytes), data
in the persistent region is accessed more frequently*/

data_persistent[tid % freqSize] = 2 * data_persistent[tid % freqSize];
data_streaming[tid % dataSize] = 2 * data_streaming[tid % dataSize];
}
stream_attribute.accessPolicyWindow.base_ptr = reinterpret_cast<void*>(data_persistent);
stream_attribute.accessPolicyWindow.num_bytes = freqSize * sizeof(int);
//Number of bytes for persisting accesses in range 10-60 MB
stream_attribute.accessPolicyWindow.hitRatio = 1.0;
//Hint for cache hit ratio. Fixed value 1.0

下面的图表显示了上述kernel的性能。当持续性数据区域与L2 cache的30 MB预留部分很好地匹配时(其实就是持续性数据区域的size小于/等于为其预留的L2 cache,并把Hint率设置为1),可以观察到的性能提高达50%。但是,一旦持续性数据区域的大小超过L2 cache预留部分的大小,就可以看到由于L2 cache lines的抖动而导致了大约10%的性能下降。

img

Figure 9. The performance of the sliding-window benchmark with fixed hit-ratio of 1.0

如下所示,为了优化性能,当持续性数据的大小大于预留二级缓存部分的大小时,我们在访问窗口中调整num_bytes和hitRatio参数。

1
2
3
stream_attribute.accessPolicyWindow.base_ptr  = reinterpret_cast<void*>(data_persistent); 
stream_attribute.accessPolicyWindow.num_bytes = 20*1024*1024; //20 MB
stream_attribute.accessPolicyWindow.hitRatio = (20*1024*1024)/((float)freqSize*sizeof(int)); //Such that up to 20MB of data is resident.

在上面这段代码中,我们将访问窗口中的num_bytes固定为20MB,并调整hitRatio,以使持续性数据中随机的20MB驻留在二级缓存的预留部分。这意味着将使用流属性访问此持续性数据的其余部分。这有助于减少缓存抖动。结果如下图所示,无论持续性数据是否适合二级缓存,我们都可以看到良好的性能。(我的理解是这样的:这其实是在让持续性内存空间灵活的共享预留的L2 cache,也就是说当持续性内存较大时,不要让其一部分固定使用一块预留的L2 cache,这将导致其它部分不得不使用非预留的L2 cache,从而引起cache line抖动影响性能。而是要设定一个比例,让所有的持续性内存以随机的方式共享这块预留的L2 cache)

img

Figure 10. The performance of the sliding-window benchmark with tuned hit-ratio

9.2.3 共享内存(Shared Memory)

因为共享内存是片上的,所以共享内存比本地和全局内存具有更高的带宽和更低的延迟,前提是线程之间没有bank冲突(即多线程没有同时使用内存上的同一个bank,bank的定义后面有,这玩意和主机DDR的bank很像,应该都是物理通路引出的概念)。

9.2.3.1 共享内存和内存banks(Shared Memory and Memory Banks

为了实现并发访问的高内存带宽,共享内存被划分为大小相等的内存模块(称为banks),这些banks可以被同时访问。因此,任何“跨越n个不同banks”的“n个地址”的内存加载或保存操作都可以同时进行,产生的有效带宽是单个内存bank带宽的n倍。

但是,如果一个内存请求的多个地址映射到了同一个内存bank,则访问会被序列化(指需要排队)。硬件需要将具有bank冲突的内存请求拆分为多个独立的无冲突请求,这种行为将降低有效带宽(降低的程度取决于拆分出来的独立内存请求的数量)。这里有一个例外,当一个warp中的多个线程访问共享内存中的相同地址时,会引发广播。在这种情况下,来自不同bank的多个广播会合并成一个(从目标共享内存到多线程的)多播。

为了最大限度地减少bank冲突,了解内存地址如何映射到bank以及如何最佳地调度内存请求非常重要。

在计算能力为5.x或更高版本的设备上,每个bank每个时钟周期的带宽为32位,连续的32位字分配给连续的banks。warp size为32个线程,banks数量也为32,因此warp中的任何线程之间都可能发生bank冲突。参见CUDA C++编程指南中的Compute Capability 5.x一节。

在计算能力为3.x的设备上,每个bank每个时钟周期的带宽为64位。有两种不同的bank模式:将连续的32位字(即32位模式)或连续的64位字(64位模式)分配给连续的bank。warp size为32个线程,而bank数也为32,因此warp中的任何线程之间都可能发生bank冲突。参见CUDA C++编程指南中的Compute Capability 3.x一节。

9.2.3.2 矩阵乘法中使用共享内存(C=AB)(Shared Memory in Matrix Multiplication (C=AB))

共享内存支持一个块中多线程之间的协作。当一个块中的多个线程使用全局内存中的相同数据时,共享内存只访问全局内存中的数据一次。共享内存还可以通过从全局内存以合并模式加载和保存数据,然后在共享内存中对其重新排序,来避免未合并的内存访问(这里有点像CPU中cache的行为)。除了bank冲突之外,一个warp中的线程们不会在使用共享内存时遇到非顺序或未对齐访问引起的问题。

下面通过矩阵乘法C=AB的简单示例说明共享内存的使用,其中A的维数为Mxw,B的维数为wxN,C的维数为MxN。为了使kernel简单,M和N是32的倍数,因为当前设备的warp size(w)是32。(这样一个warp中的线程就可以计算出C中一个tile中的一行,每个线程计算这一行的一个元素)

问题的自然分解是使用一个线程块(含wxw个线程,每个线程计算tile中的一个元素)计算每个size为wxw的tile (tile指图11中灰色的小格子)(它的意思是把源矩阵和结果矩阵像下图那样都分解成wxw大小的块即tile,然后使用wxw个线程组成一个线程组或线程块,每个线程组计算一个tile)。因此,就wxw维度的tiles而言,A是列矩阵,B是行矩阵,C是它们的外积;可参见图11。启动一个由N/w×M/w个块组成的网格,其中每个线程块(含wxw个线程)根据A中的单个 tile和B中的单个 tile计算C中相应的tile。

img

Figure 11. Block-column matrix multiplied by block-row matrix. Block-column matrix (A) multiplied by block-row matrix (B) with resulting product matrix (C).

下图这个名为simpleMultiply的kernel(未优化的矩阵乘法)计算了矩阵C中的一个tile。

1
2
3
4
5
6
7
8
9
10
11
__global__ void simpleMultiply(float *a, float* b, float *c,
int N)
{
int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
float sum = 0.0f;
for (int i = 0; i < TILE_DIM; i++) {
sum += a[row*TILE_DIM+i] * b[i*N+col];
}
c[row*N+col] = sum;
}

(注:由于内存中保存矩阵时是按一行一行顺序进行的,所以按行索引来计算某个元素的位置和按列索引的方法是不同的,这也是a[]和b[]中的算法不同的原因)

在上面这段代码中,a、b和c分别是指向矩阵A、B和C的全局内存的指针;blockDim.x、blockDim.y和TILE_DIM都等于w(加注:blockIdx.y是指当前的tile是矩阵A中的第几个,blockIdx.x是指当前的tile是矩阵B中的第几个,threadIdx.y是当前thread在wxw线程块中的行编号,threadIdx.x是当前thread在wxw线程块中的列编号)。wxw线程块中的每个线程计算C中一个tile中的一个元素。row和col是由特定线程计算的C中元素的行和列(注:其实也对应着A中元素的行,和B中元素的列)。基于i的for循环每次将A的一行乘以B的一列,然后将结果写入C。

在NVIDIA Tesla V100上,该kernel的有效带宽为119.9 GB/s。为了分析性能,有必要考虑这组warps的线程如何在for循环中访问全局内存。每个warp的线程计算C中一个tile的一行(这么算来,上面提到的wxw线程块包含了w个warp线程组,每个warp线程组又含w个线程),这会用到A中的一个行和B中的一个tile,如图12所示。

img

Figure 12. Computing a row of a tile. Computing a row of a tile in C using one row of A and an entire tile of B.

对于For循环的每次迭代i,一个warp中的所有线程(一个warp线程组处理一个行)都会读取B中一个tile的一个行(注:每个线程每次迭代都只读取tile中的一个元素,但某次迭代中所有线程合起来就是读了一行,又因为这一个warp中的所有线程是同时执行的,所以GPU可以把这些访问组合成一个业务去内存读取数据),所有计算能力的GPU都有这种顺序合并访问功能。

但是,对于每次迭代i,一个warp中的所有线程从全局内存中读取矩阵A的相同值,因为索引“row*TILE_DIM+i”在一个warp中是常量(因为这个warp中的所有线程是要计算矩阵C中的某一行数据的,这样对应矩阵A中的数据源也是同一行,也就是这个warp中的所有线程都会从矩阵A中读取同一行数据,每个迭代读取的位置也一样)。即使这样的访问只需要计算能力为2.0或更高的设备上的一个事务,事务中也会浪费带宽,因为32字节缓存段中8个字中只有一个4字节字(这里假设float为4字节,利用率为1/8)被使用。理论上我们可以在循环的后续迭代中重用这个cache line,最终我们将利用所有8个字;然而,当许多warps同时在同一个多核处理器上执行时,通常情况下,在迭代i和i+1之间,这个cache line可能很容易从缓存中被移出。

任何计算能力的设备上的性能都可以通过把A中的一个tile读取到共享内存中来提高,如下面的代码段所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
__global__ void coalescedMultiply(float *a, float* b, float *c,
int N)
{
__shared__ float aTile[TILE_DIM][TILE_DIM];
int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
float sum = 0.0f;
aTile[threadIdx.y][threadIdx.x] = a[row*TILE_DIM+threadIdx.x];
__syncwarp();
for (int i = 0; i < TILE_DIM; i++) {
sum += aTile[threadIdx.y][i]* b[i*N+col];
}
c[row*N+col] = sum;
}

在上面这段代码中,矩阵A中一个tile中的每个元素只从全局内存以完全合并的方式(没有浪费带宽)读取一次到共享内存。在for循环的每次迭代中,共享内存中的一个值将广播给warp中的所有线程。与_syncthreads()同步屏障调用不同,在将A中的tile读入共享内存后,_syncwarp()就足够了(应该是只warp内线程同步的意思),因为只有将数据写入共享内存的warp内的线程才会读取此数据。该kernel在NVIDIA Tesla V100上的有效带宽为144.4 GB/s。这说明了当硬件L1 cache逐出策略与应用程序的需要不匹配时,或者当L1 cache不用于从全局内存读取数据时,应考虑将共享内存用作用户管理的缓存。

以上面的代码为基础,在处理矩阵B时,可以做更进一步的改进。在计算矩阵C的一个tile的每一行时,读取B的整个tile。通过将B的tile读入共享内存一次,可以避免对它的重复读取(见下面的代码)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
__global__ void sharedABMultiply(float *a, float* b, float *c,
int N)
{
__shared__ float aTile[TILE_DIM][TILE_DIM],
bTile[TILE_DIM][TILE_DIM];
int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
float sum = 0.0f;
aTile[threadIdx.y][threadIdx.x] = a[row*TILE_DIM+threadIdx.x];
bTile[threadIdx.y][threadIdx.x] = b[threadIdx.y*N+col];
__syncthreads();
for (int i = 0; i < TILE_DIM; i++) {
sum += aTile[threadIdx.y][i]* bTile[i][threadIdx.x];
}
c[row*N+col] = sum;
}

在上面的代码中,在读取B的tile后需要调用_syncthreads(),因为一个warp中的线程会从共享内存中读取由不同warps的线程写入共享内存的数据(同A的tile中的行数据不同,B的tile中的列数据有可能是由别的warp中的线程读取出来的,因为一个warp对应着tile中的一行)。在NVIDIA Tesla V100上,此例程的有效带宽为195.5 GB/s。请注意,性能的提高并不是因为在这两种情况下都改进了数据访问合并,而是因为避免了对全局内存的冗余访问。

下表列出了上面几个不同优化的例子对应的性能测试结果。

Optimization NVIDIA Tesla V100
No optimization 119.9 GB/s
Coalesced using shared memory to store a tile of A 144.4 GB/s
Using shared memory to eliminate redundant reads of a tile of B 195.5 GB/s

9.2.3.3 矩阵乘法中使用共享内存(C = AAT)(Shared Memory in Matrix Multiplication (C = AAT))

矩阵乘法的一个变体可用于说明如何处理对全局内存的跨步访问和共享内存的bank冲突。这个变体只是使用A的转置来代替B,所以C=AAT。下面的代码是一个对其简单的实现。

1
2
3
4
5
6
7
8
9
10
__global__ void simpleMultiply(float *a, float *c, int M)
{
int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
float sum = 0.0f;
for (int i = 0; i < TILE_DIM; i++) {
sum += a[row*TILE_DIM+i] * a[col*TILE_DIM+i];
}
c[row*M+col] = sum;
}

在上面的代码中,C的第row行、第col列元素是A的第row行和第col行的点积。在NVIDIA Tesla V100上,该kernel的有效带宽为12.8 GB/s。这个性能结果大大低于C=AB kernel的相应测量结果。不同之处在于,对于每个迭代i,一半(为啥是一半warp线程呢?应该是一半读取操作)warp中的线程如何访问第二项中A的元素a[col*TILE_DIM+i]。对于一个warp中的线程(对应C中某个tile的一个行),col表示A的转置的连续列,因此col*TILE_DIM表示以w(32)为跨步访问全局内存,导致大量带宽浪费。

注:这里不太好理解,其实关键点在于a[row*TILE_DIM+i] * a[col*TILE_DIM+i];中的第二步a[col*TILE_DIM+i]。本来如果是两个独立的矩阵做乘法,这一步是b[i*N+col],这意味着warp中的每个kernel此时会分别读取矩阵B中不同列但同一行的元素,即它们要读取的数据是连续的,可以做合并访问。但由于现在B矩阵变成了AT,数据的存放不是连续的了,而是有了步长w,所以极大影响了带宽利用率,因为这里w=32,这相当于每读取一个float(4字节字)都需要一个事务。

避免跨步访问的方法是像之前一样使用共享内存,比如在这种情况下,一个warp将A中的一行读入共享内存作为一个tile的一列,下面的代码就展示了这样的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
__global__ void coalescedMultiply(float *a, float *c, int M)
{
__shared__ float aTile[TILE_DIM][TILE_DIM],
transposedTile[TILE_DIM][TILE_DIM];
int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
float sum = 0.0f;
aTile[threadIdx.y][threadIdx.x] = a[row*TILE_DIM+threadIdx.x];
transposedTile[threadIdx.x][threadIdx.y] =
a[(blockIdx.x*blockDim.x + threadIdx.y)*TILE_DIM +
threadIdx.x];
__syncthreads();
for (int i = 0; i < TILE_DIM; i++) {
sum += aTile[threadIdx.y][i]* transposedTile[i][threadIdx.x];
}
c[row*M+col] = sum;
}

上面的代码使用 transposedTile 来避免点积第二项中的非合并访问(由跨步访问引起),并使用上一示例中的共享aTile技术来避免第一项中的非合并访问(由字节数占事务比例过低并且cache长时间后失效引起)。在NVIDIA Tesla V100上,此kernel的有效带宽为140.2 GB/s。这些结果低于C=AB的最终kernel所获得的结果。造成差异的原因是共享内存bank冲突。

for循环中transposeSedTile中元素的读取不会发生冲突,因为每半个warp的线程都会跨tile的行读取(为啥是半个warp???本例中tile为wxw大小,w=32,每个warp的线程数也为32,毕竟w就是从warp size来的,这样每个warp跨一行才合理),从而导致跨banks的单位(应该是指单个bank,即4字节)跨步访问。问题发生在for循环之前,当将tile从全局内存复制到共享内存时,会发生bank冲突。为了能够合并对全局内存的加载操作,会按顺序从全局内存读取数据。但是,这需要以列的形式(应该指transposedTile[threadIdx.x][threadIdx.y]=XXX这句,横向读,竖向写,才能转置)写入共享内存,并且由于在共享内存中使用了wxw tiles,这会导致线程之间存在一个w banks的跨步——warp的每个线程都会命中同一bank(记住w为32)(另外我的理解是系统中有32个banks,随着地址的增加,每经过32个banks,就又会使用到同一个bank,每个bank为32 bits,正好是一个4字节字)。这些多路的bank冲突代价高昂。简单的补救方法是填充共享内存数组,使其具有一个额外的列,如下面的代码行所示。

1
__shared__ float transposedTile[TILE_DIM][TILE_DIM+1];

这种填充完全消除了冲突,因为现在线程之间的跨步是w+1个banks(对于当前设备为33个),由于使用了“取模运算”(modulo arithmetic)用于计算bank索引,这相当于一个单位跨步(相当于每个线程访问数据时都错开了一个bank)。经过此更改后,NVIDIA Tesla V100上的有效带宽为199.4 GB/s,与上一个C=AB kernel的结果相当。

表3总结了这些优化的结果。

Optimization NVIDIA Tesla V100
No optimization 12.8 GB/s
Using shared memory to coalesce global reads 140.2 GB/s
Removing bank conflicts 199.4 GB/s

这些结果应与表2中的结果进行比较。从这些表中可以看出,明智地使用共享内存可以显著提高性能。

本节中的示例说明了使用共享内存的三个原因:

  • 支持对全局内存的联合访问,特别是避免大的跨步(对于一般矩阵,跨步远大于32)
  • 从全局内存中消除(或减少)冗余加载操作
  • 避免浪费带宽

9.2.3.4 从全局内存到共享内存的异步复制(Asynchronous Copy from Global Memory to Shared Memory)

CUDA 11.0引入了异步复制功能,可在设备代码中使用该功能显式管理数据从全局内存到共享内存的异步复制。此功能使CUDA kernel能够将数据从全局内存复制到共享内存的操作与计算同时进行。它还避免了传统上存在于全局内存读取和共享内存写入之间的中间寄存器文件访问。

有关更多细节,请参见CUDA C++编程指南中的memcopy_async部分。

为了理解从全局内存到共享内存的数据同步复制和异步复制的性能差异,下面的微基准CUDA kernel代码用于演示同步和异步方法。对于NVIDIA A100 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
template <typename T>
__global__ void pipeline_kernel_sync(T *global, uint64_t *clock, size_t copy_count) {
extern __shared__ char s[];
T *shared = reinterpret_cast<T *>(s);

uint64_t clock_start = clock64();

for (size_t i = 0; i < copy_count; ++i) {
shared[blockDim.x * i + threadIdx.x] = global[blockDim.x * i + threadIdx.x];
}

uint64_t clock_end = clock64();

atomicAdd(reinterpret_cast<unsigned long long *>(clock),
clock_end - clock_start);
}

template <typename T>
__global__ void pipeline_kernel_async(T *global, uint64_t *clock, size_t copy_count) {
extern __shared__ char s[];
T *shared = reinterpret_cast<T *>(s);

uint64_t clock_start = clock64();

//pipeline pipe;
for (size_t i = 0; i < copy_count; ++i) {
__pipeline_memcpy_async(&shared[blockDim.x * i + threadIdx.x],
&global[blockDim.x * i + threadIdx.x], sizeof(T));
}
__pipeline_commit();
__pipeline_wait_prior(0);

uint64_t clock_end = clock64();

atomicAdd(reinterpret_cast<unsigned long long *>(clock),
clock_end - clock_start);
}

kernel的同步版本将元素从全局内存加载到中间寄存器,然后将中间寄存器值存储到共享内存。在kernel的异步版本中,只要调用_pipeline_memcpy_async()函数,就会发出从全局内存加载并直接存储到共享内存的指令。异步拷贝过程中使用的__pipeline_wait_prior(0)将一直等到指令流水线中的所有指令都执行完成。使用异步复制不使用任何中间寄存器,这有助于降低寄存器压力并增加kernel占用率。使用异步复制指令从全局内存复制到共享内存的数据可以缓存在L1 cache中,也可以选择绕过L1 cache。如果单个CUDA线程正在复制16字节的元素,则可以绕过L1 cache。这一差异如图13所示。

img

Figure 13. Comparing Synchronous vs Asynchronous Copy from Global Memory to Shared Memory

我们评估这两个(就是上面的同步和异步)kernel的性能时使用大小为4B、8B和16B的元素,比如可以使用int、int2和int4作为(C++)模板参数。我们调整kernel中的copy_count,使每个线程拷贝的数据从512字节到48MB变化。kernel的性能如图14所示。

image-20221224174833976

Figure 14. Comparing Performance of Synchronous vs Asynchronous Copy from Global Memory to Shared Memory

从上图中,我们可以观察到以下几点:

  1. 同步复制的性能,对于所有三种元素size,当copy_count参数是4的倍数时,达到最佳。编译器可以优化4组加载和保存指令。这从锯齿曲线中可以明显看出。
  2. 异步复制几乎在所有情况下都能实现(比同步复制)更好的性能。
  3. 异步复制不要求copy_count参数为4的倍数,以便通过编译器优化使性能最大化。
  4. 总的来说,使用大小为8或16字节的元素的异步拷贝可以获得最佳性能。

9.2.4 本地内存(Local Memory)

本地内存之所以如此命名,是因为它的作用域在线程本地(有点C语言中函数内局部变量的意思,后面说的原因也很像——寄存器不够保存函数内临时变量),而不是因为它的物理位置。实际上,本地内存是片外的。因此,访问本地内存与访问全局内存一样耗时。换句话说,local并不意味着更快的访问。

本地内存仅用于保存自动变量。这是由nvcc编译器在确定没有足够的寄存器空间来保存变量时才使用的。可能放在本地内存中的自动变量是大型结构或数组(会被动态索引),它们会占用太多的寄存器空间。

检查PTX汇编代码(通过nvcc编译时使用-ptx或-keep命令行选项获得)可以发现在第一个编译阶段是否在本地内存中放置了变量。如果有,编译器将使用.local助记符声明它,并使用ld.local和st.local助记符访问它。如果没有,后续的编译阶段可能仍然会做出相反的决定(也就是仍然会决定使用本地内存),如果他们发现变量在目标架构上占用了太多的寄存器空间。对于特定的变量,无法对此进行检查,但是当使用--ptxas options=-v选项运行时,编译器会报告每个kernel的总本地内存使用量(lmem)。

9.2.5 纹理内存

只读纹理内存(为啥叫纹理内存前面有描述)空间是被缓存的。因此,只有在缓存未命中时文本内存的获取才需要消耗一个设备内存的读取操作;否则,只需从纹理缓存读取就可以了。纹理缓存针对二维空间局部性进行了优化,因此读取相邻纹理地址的同一个warp中的线程将获得最佳性能。纹理内存也被设计用于具有恒定延迟的流式读取;也就是说,缓存命中可以减少DRAM带宽需求,但不会减少读取延迟(因为延时被设计成)。

在某些寻址情况下,通过纹理抓取(texture fetching)读取设备内存可能是从全局或常量内存读取设备内存的更好替代方法。

9.2.5.1 附加纹理功能(Additional Texture Capabilities)

如果使用tex1D()tex2D()tex3D()而不是tex1Dfetch()获取纹理,则硬件提供的其他功能可能对某些应用程序(如图像处理)有用,如表4所示。

Feature Use Caveat
Filtering Fast, low-precision interpolation between texels Valid only if the texture reference returns floating-point data
Normalized texture coordinates Resolution-independent coding None
Addressing modes Automatic handling of boundary cases1 Can be used only with normalized texture coordinates

1表4底行中边界情况的自动处理是指当纹理坐标超出有效寻址范围时,如何解析纹理坐标。有两种选择:夹紧和包裹。如果x是坐标,N是一维纹理的纹素数,则使用钳位,如果x<0,x将替换为0,如果1<x,则替换为1-1/N。使用wrap时,x被frac(x)替换,其中frac(x)=x-floor(x)。Floor返回小于或等于x的最大整数。因此,在N=1的钳位模式下,1.3的x被钳位为1.0;而在包裹模式下,它被转换为0.3

在kernel调用中,纹理缓存与全局内存写入并不保持一致,因此如果纹理读取操作从同一kernel写入过的全局地址获取数据,其返回的数据是未定义的。也就是说,如果某个内存位置已由以前的kernel调用或内存复制更新,则线程可以通过纹理安全地读取该内存位置,但如果该位置已被同一线程或同一kernel调用中的另一个线程更新,则该线程不能通过纹理安全地读取该内存位置。

9.2.6 常量内存(Constant Memory)

一个设备上总共有64 KB的常量内存。其内存空间是被缓存的。因此,只有在发生cache miss时,从常量内存读取数据才会消耗从设备内存读取的一个读取操作;否则,只需从常量缓存读取。一个warp内的线程对不同地址的访问是序列化的,因此其时间消耗与一个warp内所有线程读取的唯一地址数成线性关系。因此,当同一warp中的线程仅访问少量几个不同的位置时,最好使用常量缓存。如果一个warp的所有线程访问同一个位置,那么常量内存的访问速度可以与寄存器的访问速度一样快。

9.2.7 寄存器(Registers)

通常,指令访问寄存器不会消耗额外的时钟周期,但由于寄存器read-after-write依赖性和寄存器内存bank冲突,可能会出现延迟。

编译器和硬件线程调度器将尽可能优化地指令的调度,以避免寄存器内存bank冲突。应用程序无法直接控制这些bank冲突。

9.2.7.1 寄存器压力(Register Pressure)

当给定任务没有足够的寄存器可用时,就会出现寄存器压力。尽管每个多核处理器都包含数千个32位寄存器(参见CUDA C++编程指南的特性和技术规范部分),但这些寄存器是并发的线程共享的。为了防止编译器分配太多寄存器,使用maxrregcount=Nn编译器命令行选项或启动边界kernel定义限定符(参见CUDA C++编程指南的执行配置部分)来控制每个线程分配的最大寄存器数。

9.3 内存分配(Allocation)

通过cudaMalloc()cudaFree()分配和取消分配设备内存是很耗时的操作,因此应用程序应尽可能重用和子分配(应该指内部管理已经分配的)设备内存,以尽量减少分配操作对整体性能的影响。

9.4 NUMA最佳实践(NUMA Best Practices)

一些最新的Linux发行版默认启用自动NUMA平衡机制(或“AutoNUMA”,指的是自动的在不同的NUMA node上分配内存)。在某些情况下,由自动NUMA平衡机制执行的操作可能会降低在NVIDIA GPU上运行的应用程序的性能。为了获得最佳性能,用户应该手动调整其应用程序的NUMA特性。

最佳NUMA调整将取决于每个应用程序和节点的特性和所需的硬件亲和力,但在NVIDIA GPU上执行计算的一般应用程序中,建议选择禁用自动NUMA平衡的策略。例如,在IBM Newell POWER9节点(其中CPU对应于NUMA节点0和8)上,使用:numactl --membind=0,8将内存分配绑定到CPU。

我的理解是这样的:这段是在写分配内存时,选择的内存地址所在的物理内存芯片应该属于执行程序的CPU所在的NUMA node。如果为GPU分配主机内存也应当如此,因为GPU一般使用PCIE和主机CPU/内存相连,GPU本身也会属于某个NUMA node。所以最佳组合应该是CPU、内存、GPU都属于同一个NUMA node。

10 执行配置优化(Execution Configuration Optimizations)

良好性能的关键之一是使设备上的多处理器尽可能繁忙。如果多处理器之间的工作不均衡(那有些处理器就不忙),就得不到最优性能。因此,很重要的一点是,应用程序设计中使用线程和块(这里指线程块)时需要最大化地利用硬件,并尽量自由分配工作。其中的一个关键概念是占有率,将在以下章节中解释。

在某些情况下,通过设计应用程序,使多个独立内核可以同时执行,也可以提高硬件利用率。多个kernels同时执行称为并发kernels执行。并发内核执行将在下一小节介绍。

另一个重要概念是管理分配给特定任务的系统资源。本章最后几节将讨论如何管理资源利用率。

10. 1 占有率(Occupancy)

线程指令在CUDA中是顺序执行的,因此,在一个warp暂停或阻塞时执行其他warp是隐藏延迟和保持硬件繁忙的唯一方法。因此,与多处理器上活动warp数量相关的一些指标对于确定硬件是否繁忙非常重要。这个指标是占有率。

占有率是每个多处理器上的活动warp数与可能的最大活动warp数之比。(如果想确定后面的数字,请参阅deviceQuery CUDA示例或参考CUDA C++编程指南中的计算能力部分)另一种查看占有率的方法是正在使用的warps占硬件能力的百分比。

占有率越高并不总是意味着性能越高。额外(应该是无效的)占有率并不能提高性能。但是,占有率低总是会影响隐藏内存延迟的能力,从而导致性能下降。

CUDA内核所需的每个线程资源可能会以不必要的方式限制最大块的大小。为了保持与未来硬件和工具包的前向兼容性,并确保至少有一个线程块可以在SM上运行,开发人员需要在代码中包含单参数__launch_bounds__(maxThreadsPerBlock),该参数指定kernel将使用的最大块大小。否则可能导致“为启动请求的资源过多”错误(我之前在TensorFlow上运行时经常遇到这种问题)。在某些情况下,提供双参数版本的__launch_bounds__(maxThreadsPerBlock,minBlocksPerMultiprocessor)可以提高性能。minBlocksPerMultiprocessor的正确值应该在详细分析每个kernel后确定。

10.1.1 计算占有率(Calculating Occupancy)

决定占有率的几个因素之一是可用的寄存器资源。寄存器存储使线程能够将本地变量保留在其中,以便进行低延迟访问。但是,寄存器集(称为寄存器文件)是一种有限的资源,被多处理器上的所有线程所共享。寄存器一次被分配给整个(线程)块。因此,如果每个线程块使用许多寄存器,(由于寄存器资源有限)那么可以驻留在多处理器上的线程块的数量就会减少,从而降低多处理器的占有率。每个线程的最大寄存器数可以在编译时使用-maxrregcount选项在每个文件中手动设置,也可以使用__launch_bounds__ 限定符在每个kernel中手动设置(请参阅寄存器压力一节,在第9章)。

为了计算占有率,每个线程使用的寄存器数量是关键因素之一。例如,在计算能力为7.0的设备上,每个多处理器有65536个32位寄存器,最多可驻留2048个并发线程(64个warp 每个warp中32个线程)。这意味着在其中一个设备中,要使多处理器具有100%的占有率,每个线程最多可以使用32个寄存器。然而,这种评估寄存器数量如何影响占有率的方法没有考虑寄存器分配粒度(从后面的举例看,这句话的意思是由于寄存器是按块分配的,而设备的线程块数是有限制的,即使给每个块分配了足够的寄存器,总共使用的线程数即`活动的线程块数每块中的线程数并不一定达到最大并发线程数)。例如,在compute capability 7.0的设备上,一个kernel有128个线程块,每个线程使用37个寄存器,导致占有率为75%(注:12128/2048=0.75),每个多处理器(最多)有12个活动的128个线程块。而另一个kernel有320个线程块,每个线程使用相同的37个寄存器,结果占用率为63%(注:4320/2048=0.625`),因为一个多处理器上只能驻留4个320的线程块。此外,在计算能力为7.0的设备上,四舍五入后每个块分配到的寄存器接近256个。

可用寄存器的数量、驻留在每个多处理器上的并发线程的最大数量以及寄存器分配粒度因不同的计算能力而异。由于寄存器分配中存在这些细微差别,而且多处理器的共享内存也在驻留的线程块之间进行划分,因此很难确定寄存器使用和占有率之间的确切关系。nvcc的--ptxas options=v选项详细说明了每个kernel每个线程使用的寄存器数量。在CUDA C++程序指南的硬件多线程部分,有用于计算各种能力的设备的寄存器分配公式。在CUDA C++编程指南的特性和技术规范中,有这些设备上可用的寄存器总数。另外,NVIDIA以Excel电子表格的形式提供了占有率计算器,使开发人员能够推敲最佳平衡,并更轻松地测试不同的可能场景。该电子表格如图15所示,称为CUDA_Occupancy_Calculator.xls,位于CUDA Toolkit安装目录的tools子目录中。

Figure 15. Using the CUDA Occupancy Calculator to project GPU multiprocessor occupancy

除了电子表格计算器,使用NVIDIA Nsight Compute Profiler可以也确定占用率。占用率的详细信息显示在其占用率部分。

应用程序还可以使用CUDA运行时的占有率API,例如cudaOccupancyMaxActiveBlocksPerMultiprocessor,根据运行时参数动态选择启动配置。

10.2 隐藏寄存器依赖(Hiding Register Dependencies)

注意:中优先级:为了隐藏由寄存器依赖引起的延迟,请为每个多处理器保持足够数量的活动线程(即,足够的占有率)。

当指令使用前一条指令写入的寄存器中存储的结果时,就会产生寄存器依赖。在计算能力为7.0的设备上,大多数算术指令的延迟通常为4个周期。所以线程在使用算术结果之前必须等待大约4个周期。但是,(在这段时间内)通过执行其他warp中的线程,可以完全隐藏此延迟。有关详细信息,请参阅寄存器一节。

10.3 线程和块启发式(Thread and Block Heuristics)

When choosing the block size, it is important to remember that multiple concurrent blocks can reside on a multiprocessor, so occupancy is not determined by block size alone. In particular, a larger block size does not imply a higher occupancy.

注意:中优先级:每个线程块的线程数应该是32的倍数,因为这样可以提供最佳的计算效率并促进内存访问合并。

每个grid的块的维度和size以及每个块的线程的维度和size都是重要的因素。这些参数的多维度的那一方面使得将多维问题映射到CUDA更容易,并且不影响性能。因此,本节将讨论的是size,而不是维度。

延迟隐藏和占有率的情况取决于每个多处理器的活动warp数,它由执行参数以及资源(寄存器和共享内存)的限制隐式确定。选择执行参数是在延迟隐藏(占有率)和资源利用率之间做平衡的问题。

选择执行配置参数时应同时进行;但是,有一些特定的启发式方法可以分别应用于每个参数。选择第一个执行配置参数——每个grid的块数或grid size——主要考虑的是保持整个GPU繁忙。grid中的块数应大于多处理器的数量,以便所有多核处理器至少有一个块去执行。此外,每个多核处理器应该有多个活动块,这样没有等待_syncthreads()的块就可以让硬件保持忙碌。此建议依赖于有多少资源可用;因此,它还取决于第二个执行参数——每个块的线程数或块大小——和共享内存使用情况。考虑到向未来的设备扩展,每个kernel启动的块数应该是数千。

在选择块大小时,要谨记多处理器上可以驻留多个并发块,因此占有率不仅仅由块size决定。特别是,更大的块size并不意味着更高的占有率。

如占有率那一节中所述,占用率越高并不总是意味着性能越好。例如,将占有率从66%提高到100%通常不会转化为类似的性能提升。占有率较低的内核比占用率较高的内核在每个线程上有更多的可用寄存器,这可能会导致溢出到本地内存的寄存器更少;特别是,在某些情况下,使用高度的公开指令级并行(ILP)(应该也意味着占用更多寄存器),可以以较低的占用率完全覆盖延迟。

在选择块大小时有许多需要考虑的因素,不可避免地需要进行一些实验。但是,应遵循下面这样一些经验法则:

  • 每个块的线程数应为warp大小的倍数,以避免在未充分使用的warp上浪费计算,并便于访问合并。

  • 每个块至少应使用64个线程,并且仅当每个多处理器有多个并发块时(否则应该用更多线程?)。

  • 如果用实验来判断使用哪个块size更好,每个块128到256个线程是一个很好的初始设定范围。

  • 如果延迟影响性能,则每个多处理器使用几个较小的线程块,而不是一个较大的线程块。这对于经常调用__syncthreads()的内核尤其有益。

请注意,当线程块分配的寄存器多于多处理器上可用的寄存器时,kernel启动会失败,因为请求的共享内存或线程太多。

10.4 共享内存的影响(Effects of Shared Memory)

共享内存在几种情况下很有用,例如帮助合并或消除对全局内存的冗余访问。但是,它也一定程度上限制了占有率。在许多情况下,kernel所需的共享内存量与所选的块大小有关,但是线程到共享内存元素的映射不需要是一对一的(可以一对多)。例如,kernel中可能需要使用64x64元素的共享内存阵列,但由于每个块的最大线程数为1024,因此无法启动每个块具有64x64线程的kernel。在这种情况下,可以启动线程数为32x32或64x16的kernel,每个线程处理共享内存阵列的四个元素。即使没有每个块的线程数等限制问题,使用单个线程处理共享内存阵列的多个元素的方法也是有好处的。这是因为每个元素的一些公共操作可以由线程执行一次,从而将成本分摊到线程处理的共享内存元素的数量上(我的理解是一般一次性处理很多数据比多次处理这些数据花费的时间更少,比如减少了读取需要的某些公共数据的时间)。

确定性能对占有率的敏感度的一种有用技术是通过实验动态分配共享内存的量,如执行配置的第三个参数所指定的那样。通过简单地增加这个参数(不修改kernel),就可以有效地降低kernel的占有率并测量其对性能的影响。

10. 5 并发执行内核(Concurrent Kernel Execution)

如前文所述,CUDA流可用于将内核执行与数据传输重叠(同时执行)。在有能力并发kernel的设备上,还可以使用流同时执行多个kernel,以更充分地利用设备的多处理器。设备是否具有此功能可以去看cudaDeviceProp结构的concurrentKernels字段(或在deviceQuery CUDA示例的输出中有列出)。并发执行必须使用非默认流(0号流以外的流),因为使用默认流的kernel调用只有在设备(在任何流中)上的所有先前调用完成后才能开始,并且(意思是反之也是成立的)在设备(在任何流中)上的任何操作直到(默认流中的kernle调用)完成后才开始。

下面是一个基本示例。由于kernel1和kernel12在不同的非默认流中执行,因此一个有能力的设备可以同时执行这两个kernel。

1
2
3
4
cudaStreamCreate(&stream1);
cudaStreamCreate(&stream2);
kernel1<<<grid, block, 0, stream1>>>(data_1);
kernel2<<<grid, block, 0, stream2>>>(data_2);

10.6 多上下文(Multiple contexts)

CUDA工作在特定GPU的进程空间中,我们称之为上下文。上下文封装了该GPU的kernel启动和内存分配,以及其支持的结构,如页表。上下文在CUDA驱动API中是显式的,但在CUDA运行时API中是完全隐式的,CUDA运行时API自动创建和管理上下文。

使用CUDA驱动API,CUDA应用程序进程可能会为给定GPU创建多个上下文。如果多个CUDA应用程序进程同时访问同一GPU,这几乎总是意味着多个上下文,因为除非使用多进程服务,否则上下文与特定主机进程绑定。

虽然可以在给定GPU上同时分配多个上下文(及其相关资源,如全局内存分配),但在任何给定时刻,该GPU上只有一个上下文可以执行工作;共享同一GPU的上下文是采用的是时间片的方式。创建额外的上下文会导致每个上下文数据的内存开销和上下文切换的时间开销。此外,当多个上下文的工作可以并发执行时,上下文切换会降低利用率(见并发内核执行一节)。

因此,最好避免在同一CUDA应用程序中(每个GPU上)使用多个上下文。为了帮助实现这一点,CUDA驱动API提供了访问和管理每个GPU上称为“主上下文”的特殊上下文的方法。这些(驱动API指定的)上下文与CUDA运行时线程还没有当前上下文时隐式使用的上下文相同。

1
2
3
4
5
6
7
8
9
10
11
// When initializing the program/library
CUcontext ctx;
cuDevicePrimaryCtxRetain(&ctx, dev);

// When the program/library launches work
cuCtxPushCurrent(ctx);
kernel<<<...>>>(...);
cuCtxPopCurrent(&ctx);

// When the program/library is finished with the context
cuDevicePrimaryCtxRelease(dev);

注意:NVIDIA-SMI可用于将GPU配置为独占进程模式,这将把每个GPU的上下文数限制为一个。在创建过程中,可以根据需要将此上下文更新到任意多个线程,如果设备上已存在使用CUDA驱动API创建的非主上下文,则cuDevicePrimaryCtxRetain将失败。

11. 指令优化(Instruction Optimization)

了解指令的执行方式可以让我们对代码进行非常有用的底层优化,特别是在频繁运行的代码(程序中的所谓热点)中。本文建议在完成所有高层优化之后再做此类底层优化。

11.1 算术指令(Arithmetic Instructions)

单精度浮点数提供最佳性能,强烈建议使用。在CUDA C++编程指南中详细描述了单个算术运算的吞吐量。

11.1.1 除模运算(Division Modulo Operations)

注:低优先级:使用移位操作以避免耗时的除法和模计算。

整数除法和模运算特别耗时,应尽可能避免或用位运算代替:如果n是2的幂,那(i/n)就等于i≫log2(n),(i%n)就等同于(i&(n−1))

如果n是字面意义的(应该指不是算出来的,而是编译器可以直接理解n是2的幂),编译器将执行这些转换。

11.1.2 有符号vs无符号的循环计数器(Loop Counters Signed vs. Unsigned)

注意:中低优先级:使用有符号整数而不是无符号整数作为循环计数器。

在C语言标准中,无符号整数溢出语义定义良好,而有符号整数溢出会导致未定义的结果。因此,编译器在使用有符号算术时,和使用无符号算术比,可以进行更积极的优化(意思是语言标准的宽松使得编译器在优化时的操作余地增大)。对于循环计数器,这一点尤其值得注意:因为循环计数器的值通常都是正数,所以程序员可能很容易将计数器声明为无符号。但是,为了获得更好的性能,应该将它们声明为有符号数。

例如下面的代码:

1
2
3
for (i = 0; i < n; i++) {
out[i] = in[offset + stride*i];
}

在这里,中括号中的子表达式stride*i可能会使32位整数溢出,因此如果i被声明为无符号,溢出语义会阻止编译器使用一些可能的优化,例如强度降低(strength reduction)。相反,如果i被声明为signed,其溢出语义未被语法定义,那么编译器有更多的余地来使用这些优化。

这里插入网上找到的一段话,描述下什么是强度降低(strength reduction)。我的理解是:下面的代码中,第一段中在每次迭代中都要进行一个乘法操作。在计算结果不变的情况下,第二段中把乘法运算变成了加法运算,加法运算相比乘法运算指令花费的时间更少,可以看作是计算强度降低。

强度降低寻找包含循环不变量和归纳变量的表达式。其中一些表达式可以简化。例如,循环不变量c和归纳变量i的乘法

1
2
c = 8; 
for (i = 0; i < N; i++) { y[i] = c * i; }

可以用连续的较弱的加替换

1
2
c = 8; k = 0; 
for (i = 0; i < N; i++) { y[i] = k; k = k + c; }

11.1.3 倒数平方根(Reciprocal Square Root)

对于单精度,应始终以rsqrtf()显式调用倒数平方根,对于双精度,应以rsqrt()显式调用倒数平方根。只有在不违反IEEE-754语义的情况下,编译器才会将1.0f/sqrtf(x)优化为rsqrtf()

11.1.4 其他算数指令

注意:低优先级:避免自动将双精度转换为浮点。

编译器有时必须插入转换指令,导致引入额外的执行周期。以下两种情况就是这样:

  1. 操作char或short的函数,其操作数通常需要转换为int

  2. 双精度浮点常量(定义时没有任何类型后缀),用作单精度浮点计算的输入

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

对于单精度代码,强烈建议使用浮点类型和单精度数学函数。

还应注意,CUDA数学库中的互补误差函数erfcf(),使用完整的单精度时速度特别快。

11.1.5 带小分数参数的幂运算(Exponentiation With Small Fractional Arguments)

对于某些以分数为指数的幂运算,与使用pow()相比,通过使用平方根、立方根及其逆(应该指反函数),可以显著加快指数运算。对于那些指数不能精确表示为浮点数的指数,例如1/3,这也可以提供更精确的结果,因为pow()的使用会放大初始表示错误。

下表中的公式适用于x>=0x!=-0,即signbit(x) == 0的情况。

Computation Formula
x^1/9 r = rcbrt(rcbrt(x))
x^-1/9 r = cbrt(rcbrt(x))
x^1/6 r = rcbrt(rsqrt(x))
x^-1/6 r = rcbrt(sqrt(x))
x^1/4 r = rsqrt(rsqrt(x))
x^-1/4 r = sqrt(rsqrt(x))
x^1/3 r = cbrt(x)
x^-1/3 r = rcbrt(x)
x^1/2 r = sqrt(x)
x^-1/2 r = rsqrt(x)
x^2/3 r = cbrt(x); r = r*r
x^-2/3 r = rcbrt(x); r = r*r
x^3/4 r = sqrt(x); r = r*sqrt(r)
x^-3/4 r = rsqrt(x); r = r*sqrt(r)
x^7/6 r = x*rcbrt(rsqrt(x))
x^-7/6 r = (1/x) * rcbrt(sqrt(x))
x^5/4 r = x*rsqrt(rsqrt(x))
x^-5/4 r = (1/x)*sqrt(rsqrt(x))
x^4/3 r = x*cbrt(x)
x^-4/3 r = (1/x)*rcbrt(x)
x^3/2 r = x*sqrt(x)
x^-3/2 r = (1/x)*rsqrt(x)

11.1.6 数学计算库(Math Libraries)

注意:中优先级:只要对速度的要求超过精度,就使用快速数学库。

CUDA支持两种类型的运行时数学操作。它们可以通过名称来区分:一些名称中有带前缀的下划线,而其他名称则没有(例如,__functionName()functionName())。遵循__functionName()命名约定的函数直接映射到硬件级别。它们速度更快,但精度稍低(例如__sinf(x)__expf(x))。遵循functionName()命名约定的函数速度较慢,但精度较高(例如sinf(x)expf(x))。__sinf(x)__cosf(x)__expf(x)的吞吐量远远大于sinf(x)cosf(x)expf(x)。如果需要减小参数x的大小,则后者会变得更加耗时(大约慢一个数量级)。此外,在这种情况下,参数缩减代码使用本地内存,这可能会因为本地内存的高延迟而对性能产生更大的影响。更多的细节可在CUDA C++编程指南中获得。

还请注意,每当计算同一参数的正弦和余弦时,应使用sincos系列指令来优化性能:

  • __sincosf()用于单精度快速数学(见下一段)
  • sincosf()正则单精度运算
  • sincos()用于双精度运算

nvcc的-use_fast_math编译选项将每个functionName()调用强制改为等效的__functionName()调用。它还禁用单精度非规范化支持,通常会降低单精度除法的精度。这是一个比较激进的优化,它会降低数值精度,也可能改变某些特殊情况下的处理。一种更稳健的方法是,只有在性能提高更有价值并且可以容忍被改变行为的情况下,才有选择地引入对快速内在函数的调用。注意:此开关仅对单精度浮点有效。

注:中优先级:尽可能选择更快、更专业的数学函数,而不是更慢、更通用的函数。

对于小整数幂(例如,x^2或x^3),显式乘法几乎肯定比使用pow()等常规求幂例程快。虽然编译器优化改进不断寻求缩小这一差距,但显式乘法(或使用等效的专门构建的内联函数或宏)仍可能具有显著优势。当需要对相同的基求几个不同的幂时(例如,x^2和x^5的计算是紧密相邻的,x^2本身就是x^5的一个计算中间数),这种优势会增加,因为这有助于编译器进行公共子表达式消除(CSE)优化。

对于使用基数2或10的求幂运算,请使用函数exp2()expf2()exp10()expf10(),而不是函数pow()powf()pow()powf()在寄存器压力和指令计数方面都是重量级函数,这是它需要处理在一般的求幂运算中出现的许多特殊情况,并且很难在整个基数和指数范围内实现良好的精度。另一方面,函数exp2()exp2f()exp10()exp10f()在性能方面与exp()expf()类似,可以比pow()/powf()快十倍。

对于指数为1/3的求幂,请使用cbrt()cbrtf()函数,而不是通用的求幂函数pow()powf(),因为前者比后者快得多。同样,对于指数为-1/3的指数,请使用rcbrt()rcbrtf()

sinpi(<expr>)替换sinsin(π*<expr>),用cospi(<expr>)替换cos(π*<expr>),用sincospi(<expr>)替换sincos(π*<expr>)。这在准确性和性能方面都是有好处的。举个例子,要以度数而不是弧度计算正弦函数,即使用sinpi(x/180.0)。类似地,当函数参数的形式为π*<expr>时,应选择单精度函数sinpif()cospif()sincospif(),而不是sinf()cosf()sincosf()。(与sin()相比,sinpi()的性能优势在于简化了参数缩减(argument reduction);精度优势在于sinpi()仅隐式地乘以π,有效地使用了无限精确的数学π,而不是其单精度或双精度近似值。)

默认情况下,nvcc编译器生成符合IEEE标准的代码,但它也提供了一些选项来生成精度稍低但速度更快的代码:

  • -ftz=true(非规范化数字刷新为零)
  • -prec-div=false (精度较低的除法)
  • -prec-sqrt=false(精度较低的平方根)

另一个更激进的选项是-use_fast_math,它将每个functionName()强制为等效的__functionName()。这使得代码运行得更快,但代价是精度和准确性降低。

11.2 内存指令(Memory Instructions)

注意:高优先级:尽量减少全局内存的使用,尽可能访问共享内存。

内存指令包括读取或写入共享、本地或全局内存的所有指令。当访问未缓存的本地或全局内存时,会有有数百个时钟周期的内存访问延迟。

例如,以下示例代码中的赋值运算符具有高吞吐量,但关键是,从全局内存读取数据时存在数百个时钟周期的延迟:

1
2
3
__shared__ float shared[32];
__device__ float device[32];
shared[threadIdx.x] = device[threadIdx.x];

如果在等待全局内存访问完成时可以发出足够的独立的算术指令,那么线程调度器可以隐藏大部分全局内存延迟。但是,最好尽可能避免访问全局内存。

12. 流控(Control Flow)

12.1 跳转与分叉(Branching and Divergence)

注意:高优先级:在同一个warp中避免不同的执行路径。

流控指令(if、switch、do、for、while)会导致同一warp中的线程分叉(即不同的执行路径),从而显著影响指令吞吐量。在这种情况下,不同的线程必须分别执行不同的执行路径;这会增加此warp执行的指令总数。

为了在控制流依赖于线程ID的情况下获得最佳性能,应编写控制条件,以最小化发散warps的数量。

这是可能的,因为跨块的warps的分布是确定的,正如CUDA C++编程指南的SIMT架构部分所提到的。一个简单的例子是,控制条件仅依赖于threadIdx/WSIZE,其中WSIZE是warp大小。在这种情况下,没有warp分叉,因为控制条件与warps完全对齐。

对于只包含少量指令的分支,warp分叉通常会导致边际性能损失。例如,编译器可以使用预测来避免实际的分支。相反,所有指令都可以被调度,但每个线程的条件代码或预测控制哪些线程执行这些指令。带有错误预测的线程不会写入结果,也不会计算地址或读取操作数。

从Volta体系结构开始,独立线程调度允许warp在依赖于数据的条件块(conditional block)之外保持分叉。可以使用显式的 __syncwarp()来确保warp已重新聚合以用于后续指令。

12.2 分支预测(Branch Predication)

注意:低优先级:使编译器易于使用分支预测代替循环或控制语句。

有时,编译器可能通过使用分支预测展开循环或优化if或switch语句。在这种情况下,任何warp都不会分叉。

程序员还可以使用“#pragma unroll”,更多信息请参考CUDA C++编程指南。

使用分支预测时,不会跳过执行取决于控制条件的任何指令。相反,每个这样的指令都与根据控制条件设置为true或false的每线程条件代码或谓词相关联。尽管这些指令中的每一条都计划执行,但实际上只执行具有true预测的指令。带有false预测的指令不会写入结果,也不会计算地址或读取操作数。

仅当分支条件控制的指令数小于或等于某个阈值时,编译器才使用预测指令替换分支指令。

13 部署CUDA应用(Deploying CUDA Applications)

完成应用程序的一个或多个组件的GPU加速优化后,可以将结果与最开始的预期进行比较。最初的评估使得开发人员可以确定通过加速某些关键点可以实现的潜在加速的上限。

在解决其他热点以提高总性能之前,开发人员应考虑先把已实现的部分优化应用到实际的生产中。这一点很重要,原因有很多;例如,它使得用户尽早从他们的投资(劳动)中获利(虽然只是部分优化,但仍然是有价值的),并且它通过为应用程序提供一组渐进的而非革命性的更改,将开发人员和用户的风险降至最低。

14. 理解编程环境(Understanding the Programming Environment)

随着每一代新的NVIDIA处理器的出现,CUDA可以利用的GPU都增加了新功能。因此,了解体系结构的特征非常重要。

程序员应该知道两个版本号。第一个是计算能力,第二个是CUDA运行时和CUDA驱动API的版本号。

14.1 CUDA计算能力(CUDA Compute Capability)

计算能力描述了硬件的功能,并反映了设备支持的指令集以及其他规范,例如每个块的最大线程数和每个多处理器的寄存器数。较高的计算能力版本是较低(即较早)版本的超集,因此它们是向后兼容的。

可以通过编程方式查询设备中GPU的计算能力,如deviceQuery CUDA示例所示。该程序的输出如图16所示。通过调用cudaGetDeviceProperties()并访问它返回的结构中的信息,可以获得此信息。

img

Figure 16. Sample CUDA configuration data reported by deviceQuery

计算能力的主要和次要版本号如上图的第七行所示。图中该系统的设备0具有7.0的计算能力。

关于各种GPU的计算能力的更多细节可以参考CUDA C++编程指南。

开发人员应该特别注意设备上的多处理器数量、寄存器数量和可用内存量,以及设备的任何特殊功能。

14.2 附加硬件数据(Additional Hardware Data)

计算能力不描述某些硬件功能。例如,无论计算能力如何,在大多数但并非所有GPU上都可以使用主机和设备之间的异步数据传输来重叠(同时进行)kernel执行。在这种情况下,调用cudaGetDeviceProperties()以确定设备是否能够使用特定功能。例如,设备属性结构的asyncEngineCount字段指示是否可以同时进行kernel执行和数据传输(如果可以,可以进行多少并发传输);同样,canMapHostMemory字段指示是否可以执行零拷贝数据传输。

14.3 目标设备的有啥计算能力(Which Compute Capability Target)

要针对特定版本的NVIDIA硬件和CUDA软件做开发,请使用nvcc的-arch、-code和-gencode选项。例如,使用“warp shuffle”操作的代码编译时必须使用-arch=sm_30(或更高的计算能力)选项。

14.4 CUDA运行时组件(CUDA Runtime)

CUDA软件环境的主机运行时组件只能由主机功能使用。它提供了以下各项功能:

  • 设备管理
  • 上下文管理
  • 内存管理
  • 代码模块管理
  • 执行控制
  • 纹理参考管理
  • 与OpenGL和Direct3D的交互能力

与更底层的CUDA驱动API相比,CUDA运行时组件通过提供隐式初始化、上下文管理和设备代码模块管理,大大简化了设备管理。由nvcc生成的C++主机代码使用CUDA运行时组件,因此链接到该代码的应用程序将依赖于CUDA运行时组件;类似地,任何使用cuBLAS、cuFFT和其他CUDA工具包库的代码也将依赖于这些库内部使用的CUDA运行时组件。

CUDA工具包参考手册中解释了构成CUDA运行时API的函数。

CUDA运行时组件在kernel启动之前处理kernel加载、设置kernel参数和启动配置。隐式驱动版本检查、代码初始化、CUDA上下文管理、CUDA模块管理(cubin到函数映射)、kernel配置和参数传递都由CUDA运行时组件执行。

它包括两个主要部分:

  • 一个C风格的函数接口(cuda_runtime_api.h)。
  • C++风格的便利封装(cuda_runtime.h),但构建在C风格函数之上。

有关运行时API的更多信息,请参见CUDA C++编程指南的CUDA运行时组件部分。

15. CUDA兼容性开发指南(CUDA Compatibility Developer’s Guide)

CUDA工具包每月发布一次,以提供新功能、性能改进和关键缺陷修复。CUDA的兼容能力允许用户更新最新的CUDA工具包软件(包括编译器、库和工具),而无需更新整个驱动程序栈(这里应该是指CUDA驱动和显卡驱动)。

CUDA软件环境由三部分组成:

  1. CUDA工具包(库、CUDA运行时组件和开发者工具),即开发人员用于编译CUDA应用程序的SDK。

  2. CUDA驱动程序,用于运行CUDA应用程序的用户态驱动程序组件(例如Linux系统上的libcuda.so)。

  3. NVIDIA GPU设备驱动程序,即NVIDIA GPU的内核态驱动程序组件。

在Linux系统上,CUDA驱动程序和内核态组件被一起放在了NVIDIA显卡驱动程序包中。

CUDA编译器(nvcc)提供了一种处理CUDA和非CUDA代码的方法(通过拆分和控制编译),以及CUDA运行时组件(是CUDA编译器工具链的一部分)。CUDA运行时API为开发人员提供了用于管理设备、kernel执行等功能的高层C++接口,而CUDA驱动API为应用程序提供对NVIDIA硬件的底层编程接口。

在这些技术的基础上构建了CUDA库,其中一些库包含在CUDA工具包中,而cuDNN等其他库可能独立于CUDA工具包发布。

15.1 CUDA工具包版本(CUDA Toolkit Versioning)

从CUDA 11开始,工具包版本的定义基于行业标准语义版本控制方案:.X.Y.Z,其中:

  • .X代表主要版本-API已更改,二进制兼容性已中断。(需要使用新API重新编写、编译代码)

  • .Y代表次要版本-新API的引入、旧API的弃用和源代码兼容性可能会被破坏,但保持二进制兼容性。(新代码需要使用新API,但以前编译过的应该还能运行)

  • .Z代表发行版/修补程序版本-新的更新和修补程序将增加该版本。(基本不需要重新编写、编译代码)

CUDA平台的兼容能力旨在解决以下几种场景:

如果在企业或数据中心正在使用的GPU系统上升级驱动程序,可能很复杂,并且需要提前规划。延迟推出新的NVIDIA驱动程序可能意味着此类系统的用户可能无法获取CUDA新版本中提供的新功能。如果新CUDA版本不需要更新驱动程序,就意味着新版本的软件可以更快地提供给用户。

许多基于CUDA编译的软件库和应用程序(例如数学库或深度学习框架)并不直接依赖于CUDA运行时组件、编译器或驱动程序。在这种情况下,用户或开发人员仍然可以从使用这些库或框架中获益,而不用升级整个CUDA工具包或驱动程序。

升级依赖项容易出错且耗时,在某些极端情况下,甚至会更改程序的语义。不断地使用最新的CUDA工具包重新编译意味着对应用程序产品的最终客户强制要求升级。包管理器为这个过程提供了便利,但是意外的问题仍然会出现,如果发现错误,就需要重复上面的升级过程。

CUDA支持多种兼容性选择:

  • 首先在CUDA 10中引入CUDA正向兼容升级,旨在允许用户在使用旧的NVIDIA驱动的情况下,用新的CUDA版本编译和运行应用程序,以使用新的CUDA特征。
  • CUDA 11.1中首次引入了CUDA增强兼容性,它有两个好处:

    • 通过利用CUDA工具包中跨组件的语义版本控制,只要为一个CUDA小版本(例如11.1)构建应用程序,就可以跨大版本(即11.x)中的所有未来小版本工作。
    • CUDA运行时放宽了最低驱动程序版本检查,因此在迁移到新的小版本时不再需要升级驱动程序。
  • CUDA驱动程序确保编译后的CUDA应用程序保持向后二进制兼容。使用CUDA工具包(最老可到3.2版)编译的应用程序可以在更新的驱动程序上运行。

总结起来就是:老驱动可以支持新应用;新驱动可以支持老应用;只要CUDA工具包的大版本不变,相同代码的编译和运行就不会有问题。

15.2 源码兼容性(Compatibility)

源码兼容性就是库提供的一套保证,当安装了较新版本的SDK时,基于库的特定版本(使用SDK)编译的格式良好的应用程序将可以继续编译并运行,不会出现错误。

CUDA驱动和CUDA运行时组件(和库不一样)在跨不同的SDK版本时,并不是源码兼容。API可以被弃用和删除。因此,在较旧版本的工具包上成功编译的应用程序可能需要更改,以便针对较新版本的工具包进行编译。

开发人员将通过弃用和文档机制收到关于任何当前或即将发生的更改的通知。这并不意味着不再支持使用旧工具包编译的应用程序二进制文件。应用程序二进制依赖于CUDA驱动API接口,即使CUDA驱动程序API本身在不同的工具包版本中发生变化,CUDA也保证CUDA驱动API接口的二进制兼容性。(意思是想用新版本直接编老代码是不行的,但以前编好的老代码还能继续运行)

15.3 二进制兼容(Binary Compatibility)

我们将二进制兼容性定义为库提供的一套保证,即针对某个版本的库的应用程序在动态链接到库的不同版本时将继续工作。

CUDA驱动程序API有一个版本化的C风格ABI,它保证了针对旧驱动程序(例如CUDA 3.2)运行的应用程序仍然可以针对现代驱动程序(例如CUDA 11.0附带的驱动程序)正常运行。这意味着,即使应用程序源代码必须根据较新的CUDA工具包重新编译(甚至修改代码)才能使用较新的功能,但较新版本的驱动程序将始终支持现存的(以前编译好的)应用程序及其功能。

因此,CUDA驱动程序API是二进制兼容的(操作系统加载程序可以选择较新版本的驱动,应用程序可以继续工作),但不兼容源代码(用较新的SDK重编应用程序可能需要更改源代码)。

在继续讨论这个主题之前,开发人员必须了解最低驱动程序版本的概念以及这可能对他们产生的影响。

CUDA工具包(和运行时组件)的每个版本都有NVIDIA驱动程序的最低版本的要求。根据CUDA工具包版本编译的应用程序将仅在具有该工具包版本的指定最低驱动程序版本(当然还包括它以后更高的版本)上运行。在CUDA 11.0之前,工具包的最低驱动程序版本与CUDA工具包随附的驱动程序版本相同。

因此,当使用CUDA 11.0编译应用程序时,它只能在具有R450或更高版本驱动程序的系统上运行。如果此类应用程序在安装了R418驱动程序的系统上运行,CUDA初始化将返回一个错误。

15.3.1 CUDA二进制兼容性(CUDA Binary (cubin) Compatibility)

一个稍微相关但重要的主题是CUDA中GPU架构之间的应用程序二进制兼容性。

CUDA C++为熟悉C++编程语言的用户提供了一个简单的路径,以方便地编写程序在设备上执行。Kernel可以使用CUDA指令集体系结构(称为PTX)编写,该体系结构在PTX参考手册中有描述。然而,通常使用C++等高级编程语言效率更高。在这两种情况下,kernel必须由nvcc编译成二进制代码(称为cubins)才能在设备上执行。

cubins是特定架构相关的。cubins的二进制兼容性从一个计算能力小版本到下一个(新的)版本都有保证,但从一个计算能力小版本到上一个版本或跨计算能力主版本都不能保证兼容性。换句话说,为计算能力X.y生成的cubin对象将仅能在计算能力X.z(其中z≥y)的设备上执行。

要在具有特定计算能力的设备上执行代码,应用程序必须加载与此计算能力兼容的二进制或PTX代码。对于可移植性,即能够在具有更高计算能力的未来GPU架构上执行代码(现在还不能生成针对这种未来架构的二进制代码),应用程序必须加载英伟达驱动程序(当有了这些设备的时候)为这些未来设备编译的PTX代码(注:我认为这个步骤一般是将来使用新版本的CUDA工具包中的编译器编译代码的时候由编译器来做的)。

更多的关于cubins、PTX和应用兼容性的信息可以在CUDA C++编程指南中找到。

15.4 跨小版本的CUDA兼容性(CUDA Compatibility Across Minor Releases)

通过利用语义版本控制,从CUDA 11开始,CUDA工具包中的组件将在工具包跨小版本时保持二进制兼容。为了保持跨小版本的二进制兼容性,CUDA运行时组件不再增加每个小版本所需的最低驱动程序版本——仅在大版本发布时才会这样做。

新工具链需要新的最低版本驱动程序的主要原因之一是处理PTX代码的JIT编译和二进制代码的JIT链接。

15.4.1 CUDA小版本中的现有CUDA应用程序(Existing CUDA Applications within Minor Versions of CUDA)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ nvidia-smi

+-----------------------------------------------------------------------------+
| NVIDIA-SMI 450.80.02 Driver Version: 450.80.02 CUDA Version: 11.0 |
|-------------------------------+----------------------+----------------------+
| GPU Name Persistence-M| Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap| Memory-Usage | GPU-Util Compute M. |
| | | MIG M. |
|===============================+======================+======================|
| 0 Tesla T4 On | 00000000:00:1E.0 Off | 0 |
| N/A 39C P8 9W 70W | 0MiB 15109MiB | 0% Default |
| | | N/A |
+-------------------------------+----------------------+----------------------+

+-----------------------------------------------------------------------------+
| Processes: |
| GPU GI CI PID Type Process name GPU Memory |
| ID ID Usage |
|=============================================================================|
| No running processes found |
+-----------------------------------------------------------------------------+

当我们在系统上运行CUDA 11.1应用程序(即静态链接了cudart 11.1)时,我们发现即使驱动程序报告了(驱动本身属于)11.0版本(如上面nvidia-smi命令的输出),它也能成功运行,也就是说,不需要在系统上更新驱动程序或其他工具包组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ samples/bin/x86_64/linux/release/deviceQuery
samples/bin/x86_64/linux/release/deviceQuery Starting...

CUDA Device Query (Runtime API) version (CUDART static linking)

Detected 1 CUDA Capable device(s)

Device 0: "Tesla T4"
CUDA Driver Version Runtime Version 11.0 11.1
CUDA Capability Major/Minor version number: 7.5

...<snip>...

deviceQuery, CUDA Driver = CUDART, CUDA Driver Version = 11.0, CUDA Runtime Version = 11.1, NumDevs = 1
Result = PASS

通过使用新的CUDA版本,用户可以从新的CUDA编程模型API、编译器优化和数学库功能中收益。

以下各节讨论一些注意事项。

15.4.1.1 处理新的CUDA功能和驱动API(Handling New CUDA Features and Driver APIs)

CUDA API的一个子集不需要新的驱动程序,它们都可以在没有任何驱动程序依赖的情况下使用。例如,cuMemMap API或CUDA 11.0之前引入的任何API(如cudaDeviceSynchronize)不需要驱动程序升级。如果要使用小版本中引入的其他CUDA API(这些API依赖于新驱动程序),必须回退代码(应该是不再使用新API或者修改代码的意思)。这种情况与现状没有什么不同,开发人员使用宏在编译时把依赖于CUDA版本的特性排除在外。用户应参考CUDA头文件和文档,以了解版本中引入的新CUDA API。

当使用工具包小版本中公开的功能时,如果应用程序运行在较旧的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
30
31
32
33
34
35
36
static bool hostRegisterFeatureSupported = false;
static bool hostRegisterIsDeviceAddress = false;

static error_t cuFooFunction(int *ptr)
{
int *dptr = null;
if (hostRegisterFeatureSupported) {
cudaHostRegister(ptr, size, flags);
if (hostRegisterIsDeviceAddress) {
qptr = ptr;
}
else {
cudaHostGetDevicePointer(&qptr, ptr, 0);
}
}
else {
cudaMalloc();
cudaMemcpy();
}
gemm<<<1,1>>>(dptr);
cudaDeviceSynchronize();
}

int main()
{
rest of code here
cudaDeviceGetAttribute(
&hostRegisterFeatureSupported,
cudaDevAttrHostRegisterSupported,
0);
cudaDeviceGetAttribute(
&hostRegisterIsDeviceAddress,
cudaDevAttrCanUseHostPointerForRegisteredMem,
0);
cuFooFunction(/* malloced pointer */);
}

如果没有新的CUDA驱动程序,应用程序的调用的接口可能根本无法工作,最好立即返回错误:

1
2
3
4
5
6
7
8
9
10
#define MIN_VERSION 11010
cudaError_t foo()
{
int version = 0;
cudaGetDriverVersion(&version);
if (version < MIN_VERSION) {
return CUDA_ERROR_INSUFFICIENT_DRIVER;
}
proceed as normal
}

上面这种情况将添加一个新的错误码,以指示正在运行的驱动程序中缺少该功能:cudaErrorCallRequiresNewerDriver

15.4.1.2 使用PTX(Using PTX)

PTX为通用并行线程执行定义了虚拟机和ISA。PTX程序在加载时通过JIT编译器(CUDA驱动程序的一部分)转换为目标硬件指令集。由于PTX由CUDA驱动程序编译,新的工具链将生成与旧的CUDA驱动程序不兼容的PTX。当PTX用于和将来的设备兼容(最常见的情况)时,这不是问题,但在用于(当前的)运行时编译时可能会导致问题。

对于继续使用PTX的代码,为了支持在旧驱动程序上编译,必须首先通过静态ptxjit编译器库或NVRTC将代码转换为设备代码,并通过编译选项指定为某一架构(例如sm_80)而不是虚拟架构(例如compute_80)生成代码。对于这项工作,CUDA工具包附带了一个新的nvptxcompiler_static静态库。

我们可以在以下示例中看到这种用法:

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
char* compilePTXToNVElf()
{
nvPTXCompilerHandle compiler = NULL;
nvPTXCompileResult status;

size_t elfSize, infoSize, errorSize;
char *elf, *infoLog, *errorLog;
int minorVer, majorVer;

const char* compile_options[] = { "--gpu-name=sm_80",
"--device-debug"
};

nvPTXCompilerGetVersion(&majorVer, &minorVer);
nvPTXCompilerCreate(&compiler, (size_t)strlen(ptxCode), ptxCode);
status = nvPTXCompilerCompile(compiler, 2, compile_options);
if (status != NVPTXCOMPILE_SUCCESS) {
nvPTXCompilerGetErrorLogSize(compiler, (void*)&errorSize);

if (errorSize != 0) {
errorLog = (char*)malloc(errorSize+1);
nvPTXCompilerGetErrorLog(compiler, (void*)errorLog);
printf("Error log: %s\n", errorLog);
free(errorLog);
}
exit(1);
}

nvPTXCompilerGetCompiledProgramSize(compiler, &elfSize));
elf = (char*)malloc(elfSize);
nvPTXCompilerGetCompiledProgram(compiler, (void*)elf);
nvPTXCompilerGetInfoLogSize(compiler, (void*)&infoSize);

if (infoSize != 0) {
infoLog = (char*)malloc(infoSize+1);
nvPTXCompilerGetInfoLog(compiler, (void*)infoLog);
printf("Info log: %s\n", infoLog);
free(infoLog);
}

nvPTXCompilerDestroy(&compiler);
return elf;
}

15.4.1.3 生成动态代码(Dynamic Code Generation)

NVRTC是CUDA C++的运行时编译库。它接受字符串形式的CUDA C++源代码,并创建可用于获取PTX的句柄。NVRTC生成的PTX字符串可以由cuModuleLoadData和cuModuleLoadDataEx加载。

目前还不支持处理可重定位的对象,因此CUDA驱动程序中的cuLink API集不具备增强兼容性的能力。这些API当前需要与CUDA运行时组件的版本匹配的(升级后的)驱动程序。

如PTX部分所述,PTX到设备代码的编译与CUDA驱动程序紧密相关,因此生成的PTX可能比部署系统上的驱动程序支持的更新。使用NVRTC时,建议首先通过PTX用户工作流中描述的步骤将生成的PTX代码转换为最终设备代码。这确保了代码的兼容性。或者,NVRTC可以直接从CUDA 11.1开始生成cubins。使用新API的应用程序可以使用驱动程序API cuModuleLoadData和cuModuleLoadDataEx直接加载最终的设备代码。

NVRTC过去通过选项-arch只支持虚拟架构,因为它只生成PTX。它现在也将支持实际的架构并生成SASS。如果指定了实际的架构,则接口需要增加功能以判断和处理PTX或cubin。

下面的示例显示了如何调整现有示例以使用新功能,相关代码由USE_CUBIN宏保护:

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
#include <nvrtc.h>
#include <cuda.h>
#include <iostream>

void NVRTC_SAFE_CALL(nvrtcResult result) {
if (result != NVRTC_SUCCESS) {
std::cerr << "\nnvrtc error: " << nvrtcGetErrorString(result) << '\n';
std::exit(1);
}
}

void CUDA_SAFE_CALL(CUresult result) {
if (result != CUDA_SUCCESS) {
const char *msg;
cuGetErrorName(result, &msg);
std::cerr << "\ncuda error: " << msg << '\n';
std::exit(1);
}
}

const char *hello = " \n\
extern \"C\" __global__ void hello() { \n\
printf(\"hello world\\n\"); \n\
} \n";

int main()
{
nvrtcProgram prog;
NVRTC_SAFE_CALL(nvrtcCreateProgram(&prog, hello, "hello.cu", 0, NULL, NULL));
#ifdef USE_CUBIN
const char *opts[] = {"-arch=sm_70"};
#else
const char *opts[] = {"-arch=compute_70"};
#endif
nvrtcResult compileResult = nvrtcCompileProgram(prog, 1, opts);
size_t logSize;
NVRTC_SAFE_CALL(nvrtcGetProgramLogSize(prog, &logSize));
char *log = new char[logSize];
NVRTC_SAFE_CALL(nvrtcGetProgramLog(prog, log));
std::cout << log << '\n';
delete[] log;
if (compileResult != NVRTC_SUCCESS)
exit(1);
size_t codeSize;
#ifdef USE_CUBIN
NVRTC_SAFE_CALL(nvrtcGetCUBINSize(prog, &codeSize));
char *code = new char[codeSize];
NVRTC_SAFE_CALL(nvrtcGetCUBIN(prog, code));
#else
NVRTC_SAFE_CALL(nvrtcGetPTXSize(prog, &codeSize));
char *code = new char[codeSize];
NVRTC_SAFE_CALL(nvrtcGetPTX(prog, code));
#endif
NVRTC_SAFE_CALL(nvrtcDestroyProgram(&prog));
CUdevice cuDevice;
CUcontext context;
CUmodule module;
CUfunction kernel;
CUDA_SAFE_CALL(cuInit(0));
CUDA_SAFE_CALL(cuDeviceGet(&cuDevice, 0));
CUDA_SAFE_CALL(cuCtxCreate(&context, 0, cuDevice));
CUDA_SAFE_CALL(cuModuleLoadDataEx(&module, code, 0, 0, 0));
CUDA_SAFE_CALL(cuModuleGetFunction(&kernel, module, "hello"));
CUDA_SAFE_CALL(cuLaunchKernel(kernel, 1, 1, 1, 1, 1, 1, 0, NULL, NULL, 0));
CUDA_SAFE_CALL(cuCtxSynchronize());
CUDA_SAFE_CALL(cuModuleUnload(module));
CUDA_SAFE_CALL(cuCtxDestroy(context));
delete[] code;
}

15.4.1.4 编译小版本兼容库的建议(Recommendations for building a minor-version compatible library)

我们建议对CUDA运行时组件进行静态链接,以最小化依赖关系。需要验证你的库没有在已建立的ABI契约之外存在依赖项、breakages、命名空间等。

遵循库的soname的语义版本控制。拥有语义版本化的ABI意味着需要维护和版本化接口。当发生影响此ABI契约的更改时,应遵循语义规则并为库增加版本号。缺少依赖项也会中断二进制兼容性,因此你应该为依赖于这些接口的功能提供回退或保证。当存在破坏ABI的更改(如API弃用和修改)时,增加大版本。新的API可以添加到小版本中。

有条件地(即不要随便)使用功能,以保持与旧版驱动程序的兼容性。如果没有使用新功能(或者有条件地使用这些功能并提供回退功能),就能够保持兼容性。

不要向外暴露可能更改的ABI结构。指向某个size的结构的指针是更好的解决方案。

当从工具箱链接此动态库时,该库必须等于或高于应用程序链接中涉及的任何一个组件所需的库。例如,如果您链接CUDA 11.1动态运行时组件,并使用11.1中的功能,并且还链接了一个单独共享库(这个库链接了CUDA 11.2动态运行时组件(需要11.2功能)),则最后的链接步骤必须包括CUDA 11.2或更新的动态运行时组件。

15.4.1.5 在应用程序中利用小版本兼容性的建议(Recommendations for taking advantage of minor version compatibility in your application)

某些功能可能不可用,因此需要在合适的情况下进行查询。这在编译与GPU架构、平台和编译器无关的应用程序时很常见。然而,我们现在还要加入“底层驱动”因素。

与上一节一样,如果使用CUDA运行时组件,我们建议在编译应用程序时静态链接到CUDA运行时组件。当直接使用驱动程序API时,我们建议使用新驱动程序入口点访问API(cuGetProcAddress),可参考CUDA工具包文档。

当使用共享库或静态库时,请按照库的发行说明确定该库是否支持小版本兼容性。

16. 开发准备

17. 工具

17.1. 英伟达SMI

NVIDIA系统管理界面(NVIDIA-smi)是一个命令行实用程序,可帮助NVIDIA GPU设备的管理和监控。此实用程序允许管理员查询GPU设备状态,并使用适当的权限允许管理员修改GPU设备状态。nvidia smi针对特斯拉和某些Quadro GPU,但其他nvidia GPU也提供有限的支持。nvidia smi在Linux上附带nvidia GPU显示驱动程序,并附带64位Windows Server 2008 R2和Windows 7。nvidia-smi可以将查询的信息作为XML或人类可读的纯文本输出到标准输出或文件。有关详细信息,请参阅nvidia-smi文档。请注意,nvidia smi的新版本不能保证与以前的版本向后兼容。

17.1.1.可查询状态

  • ECC错误计数:报告了可纠正的单比特错误和可检测的双比特错误。提供了当前引导周期和GPU寿命的错误计数。
  • GPU利用率:报告GPU和内存接口的计算资源的当前利用率。
  • 主动计算过程:报告GPU上运行的活动进程列表,以及相应的进程名称/ID和分配的GPU内存。
  • 时钟和性能状态:报告了几个重要时钟域的最大和当前时钟速率,以及当前GPU性能状态(pstate)。
  • 温度和风扇转速:报告了当前GPU核心温度,以及主动冷却产品的风扇速度。
  • 电源管理:报告这些测量值的产品报告了当前板功率消耗和功率限制。
  • 识别:报告了各种动态和静态信息,包括板序列号、PCI设备ID、VBIOS/Inforom版本号和产品名称。

17.1.2.可修改状态

  • ECC模式:启用和禁用ECC报告。
  • ECC复位:清除单位和双位ECC错误计数。
  • 计算模式:指示计算进程是否可以在GPU上运行,以及它们是以独占方式运行还是与其他计算进程同时运行。
  • 持久性模式:指示当没有应用程序连接到GPU时,NVIDIA驱动程序是否保持加载状态。在大多数情况下,最好启用此选项。
  • GPU重置:通过辅助总线重置重新初始化GPU硬件和软件状态。

17.2 NVML

NVIDIA管理库(NVML)是一个基于C的界面,可直接访问通过NVIDIA smi公开的查询和命令,作为构建第三方系统管理应用程序的平台。NVML API随CUDA工具包(自8.0版起)一起提供,并且作为GPU部署工具包的一部分,也可以在NVIDIA开发者网站上单独提供,通过单个头文件附带PDF文档、存根库和示例应用程序。

为NVML API提供了一组额外的Perl和Python绑定。这些绑定公开了与基于C的接口相同的特性,并提供了向后兼容性。Perl绑定通过CPAN提供,Python绑定通过PyPI提供。所有这些产品(nvidia-smi、NVML和NVML语言绑定)都随每个新CUDA版本更新,并提供大致相同的功能。

17.3.群集管理工具

管理GPU群集将有助于实现最大的GPU利用率,并帮助您和您的用户获得最佳性能。许多业界最流行的集群管理工具通过NVML支持CUDA GPU。

17.4.编译器JIT缓存管理工具

应用程序在运行时加载的任何PTX设备代码都由设备驱动程序进一步编译为二进制代码。这被称为实时编译(JIT)。实时编译增加了应用程序加载时间,但允许应用程序从最新的编译器改进中受益。这也是应用程序在编译应用程序时不存在的设备上运行的唯一方法。

当使用PTX设备代码的JIT编译时,NVIDIA驱动程序将生成的二进制代码缓存在磁盘上。这种行为的某些方面,例如缓存位置和最大缓存大小,可以通过使用环境变量来控制;请参阅CUDA C++编程指南的实时编译。

17.5.可视设备

在CUDA应用程序启动之前,可以通过CUDA_visible_devices环境变量重新排列CUDA应用软件可见并枚举的已安装CUDA设备集合。应用程序可见的设备应以逗号分隔列表的形式包含在系统范围内的可枚举设备列表中。例如,要仅使用系统范围设备列表中的设备0和2,请在启动应用程序之前将CUDA_VISIBLE_devices设置为0,2。然后,应用程序将分别将这些设备枚举为设备0和设备1。

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 可以访问纹理和表面内存:

  • 所有设备都支持的纹理引用 API,
  • 仅在计算能力 3.x 及更高版本的设备上支持的纹理对象 API。
    纹理引用 API 具有纹理对象 API 没有的限制。 它们在 [[DEPRECATED]] 纹理引用 API 中被提及。

3.2.12.1纹理内存

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

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

  • 纹理,即提取的纹理内存。 纹理对象在运行时创建,并在创建纹理对象时指定纹理,如纹理对象 API 中所述。 纹理引用是在编译时创建的,纹理是在运行时通过 [[DEPRECATED]] 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

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 中描述

纹理提取在纹理提取中进行了描述。

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_bcol_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 接口。 通过包含 头文件,可以使用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 字节对齐来寻址时,它才是异步的。异步复制的数据只能在调用 waitwait_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 的调用都使用内存池的资源。在没有指定内存池的情况下,cudaMallocAsyncAPI 使用提供的流设备的当前内存池。设备的当前内存池可以使用 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 头文件,如 , ,同时前者也包含了c++头文件


使用 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,生成器表达式$作为参数。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 ,这需要在编译时,使用-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编译定义。注意$生成器表达式的使用:

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级目标上进行设置。它们实际上是在项目中构建目标时所需要的,其他的只与下游项目相关。此外,其中一些只有在项目安装之后才会相关联。这里使用了$和$生成器表达式。只有消息库外部的下游目标才需要这些,也就是说,只有在安装了目标之后,它们才会变得可见。我们的例子中,应用如下:

只有在项目中使用了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

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();

函数后,计时是从调用核函数开始,到核函数执行完并返回给主机的时间段,下面图大致描述了执行过程的不同时间节点:

我们可以大概分析下核函数启动到结束的过程:

  • 主机线程启动核函数
  • 核函数启动成功
  • 控制返回主机线程
  • 核函数执行完成
  • 主机同步函数侦测到核函数执行完

我们要测试的是2~4的时间,但是用CPU计时方法,只能测试1~5的时间,所以测试得到的时间偏长。

用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

上面蓝色表示全局内存,下面橙色是线程束要的数据,绿色就是我称为对齐的地址段。

  • 如果一个事务加载的数据分布在不一个对齐的地址段上,就会有以下两种情况:
    • 连续的,但是不在一个对齐的段上,比如,请求访问的数据分布在内存地址1~128,那么0~127和128~255这两段数据要传递两次到SM
    • 不连续的,也不在一个对齐的段上,比如,请求访问的数据分布在内存地址0~63和128~191上,明显这也需要两次加载。

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++ 在标准化高性能计算中提供了重要的基础。