johnysswlab 作者谈自动向量化¶
标题:Making A Program Faster - Multithreading & Automatic Compiler Vectorization
日期:2025/10/10
作者:Ivica Bogosavljević
链接:https://www.youtube.com/watch?v=GTAE_znTvuk
注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。
备注一:这个演讲是有两部分的,前半部分讲 OpenMP 我不想看就砍掉了,后半部分讲自动向量化。
备注二:我一直以为 Johnny’s Software Lab 网站作者的名字叫 Johnny……
欢迎大家。这次演讲是关于多线程和自动编译器的,所以我们在这里会重点关注性能。我是一名性能工程师,这也是我工作的一部分。所以这次演讲非常注重实践。这不是一个新的演讲,虽然这里是 C++ Now,很多人都期待着最新的东西。但在性能方面,你通常会坚持使用那些经过考验的老方法,尤其是 C++ 新标准在性能方面并没有太多发展。
所以,这次演讲分为两个部分。第一部分是关于多线程的,我会讲到 OpenMP 和 OpenMP 编程接口(API)。第二部分是关于单线程优化。
(跳过了不需要的第一个演讲。)
好的。下一个演讲。所以在下一个演讲的最后,我们会做一个非常有趣的实验,你会看到发生了什么。
所以,演讲的第二部分是关于编译器自动向量化(compiler auto-vectorization)。现在,有多少人知道什么是向量化(vectorization)?一、二、三。好的。好的。那么你们对这个会比较熟悉。
现代 CPU 可以在一条指令中处理多于一个数据。所以,它们不是处理一个浮点数,而是可以处理四个浮点数。你可以看到左边这里有一个循环。
/* 生成代码,仔细甄别 */
A[i] = b[i] + c[i];
在右边,你会看到类似这样的东西。原始循环的增量是 1,而自动向量化后的循环增量是 4,因为我们可以在一条指令中处理四个浮点数。所以我在这里,从地址 b + i 加载了四个连续的浮点数。我从地址 c + i 加载了四个连续的浮点数。然后我把它们四个加起来。也就是四个 b 和四个 c。我用这个 add four 指令将它们相加,产生四个结果。最后,我将这四个结果存回地址 a + i。然后 i 增加 4。这就是向量化的主要思想。
现在,所有的 CPU 都支持向量化。服务器、桌面、所有嵌入式设备。比如说,嵌入式的 Cortex-A A 系列就支持它们。实际上,我认为现在一些 M 系列芯片也支持了,它被称为 Helium 扩展。是的,它非常、非常、非常普遍,而且越来越普遍。
这是一个工作原理的例子。我这里有 4 7 1 1,我把它加载到这个 CPU 寄存器。我还有 3 2 8 0,我把它加载到这个寄存器。然后我把它们加在一起,得到 4 + 3 = 7,7 + 2 = 9,1 + 8 = 9,以及 1 + 0 = 1。然后我把所有这些结果存回内存。
对于向量化,你有两个选择。假设你有一个问题,你想使用 CPU 的向量指令。你可以选择使用编译器自动向量化,让编译器为你生成这些指令;或者你也可以手动编写这些东西,可以使用编译器内联函数(compiler intrinsics)。编译器内联函数只是 CPU 向量指令的包装器,或者你也可以直接用汇编语言。
在这次演讲中,我们只关注编译器自动向量化,因为它比较简单,我们可以在一个小时内讲完。手动向量化是一个复杂得多的过程,但它能让你做更多的事情。是的。昨天我连续做了两个小时的演讲,讲的都是手动向量化的东西。
观众: 那个在 YouTube 上吗?Dennis,他写了 STD-SimD 还是 STDE,它叫什么来着?
演讲者: 不,我做的是不同的东西。但是……
观众(Dennis): 哦,说吧。说说。我写了 EVE 库。
演讲者: 是的。EVE 库,它是一个并行实现,是各种算法的 SIMD 实现。
观众(Dennis): 它现在是标准的一部分了吗?
演讲者: 不,不,不。那是 standard SIMD,是不同的人做的。
观众(Dennis): 啊哈,好的。好的。我之前不知道这个。抱歉。我昨天很晚才到。我没来得及去……我本来想去的。
是的。那么你如何启用自动向量化呢?你需要给 GCC 传递 -O3 或者给 Clang 传递 -O2。还有一些其他的标志你可以使用。并且你需要传递正确的架构标志,比如 -mAVX2,如果你是在像我这台这样的笔记本电脑上工作。没有它,编译器要么不会进行向量化,要么它没有进行向量化所必需的指令。编译器会尝试使用这些指令来加速计算。
你可以通过编译器标志来检查一个循环是否被向量化了。在 GCC 上,你用这个,这个是在 Clang 上的,还有第三个,QVAC report 是用于 MSVC 的。所以实际上所有编译器都以某种方式支持这个功能。
哦,这是 Clang 的报告。这是一个报告示例。你可以看到,对于这个在第 8 行的循环(我这里没有显示代码,但我们稍后会看到),它说:“成本模型表明向量化没有好处(The cost model indicates that vectorization is not beneficial)。”但是对于另一个循环,它说:“备注:已向量化的循环(remark vectorized loop)”,并且它给出了向量化宽度、交错计数等信息,这些不重要。所以你可以看到 Clang 报告了这类信息。向量化报告有点粗糙,就像是编译器输出它自己的调试信息,看起来不太友好,但很有用。我们会逐一分析,你就会明白了。
是的。当你看到这个消息时,编译器会模糊地提示你可能是什么问题,但它不会确切地告诉你如何修复。在这次演讲中,我们将讨论如何识别常见的向量化抑制因素(vectorization inhibitors),也就是那些阻止编译器自动向量化的东西,以及如何修复其中至少一部分。
是的。我已经提到了向量化抑制因素。在编译器向量化中,这些抑制因素包括:你的数据不是顺序访问的。想象一下你有一个向量,但你不是按顺序访问,比如 0, 1, 2, 3, 4, 5。而是访问 0, 5, 10, 15,或者你在数组中随机跳跃。所以,要让编译器自动向量化起作用,其中一件事情就是你需要有顺序的数据访问。
然后,如果你有循环携带依赖(loop carry dependencies),我们之前讨论过。也就是你依赖于上一次迭代的数据。对于这种情况,编译器自动向量化通常不起作用。
接着,如果你的循环迭代次数很少(small trip count),比如说一个循环只有三次迭代。在这种情况下,编译器无法自动向量化这个循环,因为它至少需要四次或八次迭代。
还有,循环中有很多条件语句(conditionals)。如果你有一个循环,里面有 if-else,if-else 等等,这些是条件语句,它们向量化效果不好。它们可以被向量化,但向量化的工作方式决定了它们会降低向量化的效率。所以效果不是很好。我们会讨论这些。其中一些可以修复,一些则不能。让我们来看看。
所以你运行你的 Clang 报告,它报告说:“成本模型表明向量化没有好处。”看,Clang 有一个成本模型,它说:“我不会向量化这个,这对你没好处。”
成本模型这么说的一个原因可能是你的内存访问模式很糟糕。现在,在每个程序中,我们有四种类型的内存访问。你有 常量(constant) 访问,想象你有一个向量,其中一种类型是常量。比如说你总是在一个循环里访问同一个内存地址:A[0], A[0], A[0], A[0], A[0], A[0]。这就是常量内存访问模式。
然后你有 顺序(sequential) 访问:A[0], A[1], A[2], A[3], A[4], A[5] 等等。这是顺序的。
你还有 跨步(strided) 访问。程序以一个固定的步长访问内存地址。比如说 A[0], A[5], A[10], A[15], A[20]。差值总是 5,但你跳过了一些元素。这被称为跨步访问。
最后,你有最糟糕的一种内存访问:随机(random) 访问。你只是在一个数组里随机地访问。A[0], A[5], A[3], A[12], A[4] 等等。
为了实现高效的编译器自动向量化,你的大部分内存访问应该是常量或顺序的。为了做到这一点,我们需要进行分析,为了解决我们的问题,我们需要知道如何分析内存访问模式。
这里右边有一个循环。它是一个嵌套循环。问题是,当我们把内层循环的 j 增加 1 时,会发生什么?j 增加 1,我们把它加 1。那么 out[i * n + j] 会发生什么?当 j 增加 1 时,我们只是把指针移动到下一个元素。所以这是一个顺序访问。对于 in 指针,当 j 增加 1 时,我们增加了 n。所以步长是 n。我们跳过了 n 个元素。这是一个跨步访问。index[j],这是一个顺序访问。当 j 增加 1 时,我们只是移动到另一个位置。最后,hist[index[j]],这是一个随机访问。因为我们不知道 index[j] 的值,所以它可能会到处跳。它不一定真的会,但我们认为它是一个随机访问。
这就是内存访问模式分析。你总是从最内层循环的角度来做分析,而不是从外层循环。你看这个迭代器是如何变化的,以及这个指针是如何变化的。
现在,当你有这个之后,问题是,我们能改变这个内存访问模式吗?实际上我们可以。在这个特定的案例中,有一种循环变换,叫做循环交换(loop interchange)。我们把外层循环和内层循环的位置交换一下。所以你看到这里,内层循环现在是 i,外层循环是 j。让我们再做一次分析。
当 i 增加 1 时,这个 out 现在是一个跨步访问。这个 in 现在是一个顺序访问。index[j] 是一个常量访问,因为它不依赖于 i。因此,hist[index[j]] 也是一个常量访问。所以,之前我们有一个随机访问、两个顺序访问和一个跨步访问。现在我们有两个常量访问、一个跨步访问和一个顺序访问。这个第二个循环有更好的内存访问模式。
观众: 这是假设 i 和 j 都非常大,对吗?
演讲者: 嗯,它们是合理的,不一定非常大。比如 1000 也可以。
观众: 好的。所以这是一个 1000x1000 的矩阵。
演讲者: 是一个巨大的矩阵。它有 100 万个元素。
所以,是的,这就是内存访问模式分析。现在,你能做一下吗?这是左边的。这是一个矩阵乘法。它是三个嵌套循环。这里我们有 C[i][j] = C[i][j] + A[i][k] * B[k][j]。然后我做了这个循环交换。让我们来分析一下。
C[i][j],最内层循环是 k。C[i][j] 的内存访问模式是什么?
观众: 常量。
演讲者: 常量。好的。A[i][k]呢?
观众: 顺序。
演讲者: 是的。这个呢?
观众: 跨步。
演讲者: 跨步。是的。
让我们来做这个。所以我们有一个常量,一个顺序和一个跨步。这个呢?
观众: 常量。
演讲者: 不,不。内层循环是j。
观众: 顺序。
演讲者: 顺序。顺序。这个在这里是?
观众: 常量。
演讲者: 这个呢?
观众: 也是顺序。
演讲者: 是的。所以我们这里有两个顺序访问和一个常量访问。而这里我们有一个常量,一个顺序,一个跨步。所以就内存性能而言,这个循环实际上更好。
实际上我们做了编译器自动向量化。如果你有加载和存储指令,最高效的那些只处理连续的数据。所以它们处理顺序访问。对于跨步访问,进行向量化是可能的,但更复杂。而这个,这个循环编译器可以自动向量化,但这个它会抱怨成本分析的问题。
观众: 如果变量不是 volatile,编译器能通过执行循环交换来进行优化吗? 演讲者: 编译器原则上可以做到,但它们从来不这么做。我唯一见过的是某个科学计算的编译器,它做到了。但是当我们加上
pragma omp时,它又把它改回去了。然后我们的并行版本比单线程版本还要慢。当时我就想,这到底是怎么回事?完全没道理。 观众: 是不是可能是因为别名(aliasing)问题?比如输入和输出之间?编译器不自动这么做的原因是…… 演讲者: 是的,我们马上会讲到那个。我们会讲到的。
是的。如果你有一个足够大的矩阵,这段代码可以比左边的快三到四倍,甚至十倍。是的。而这只是一个起点。你可以使用其他技术来进一步加速,比如循环分块(loop tiling)等内存优化,但内容太多了。我在这里无法涵盖。
好的。这是循环交换,当你有一个完美嵌套的循环时,也就是内外循环之间没有任何其他语句。现在的问题是,你能对这个循环进行循环交换吗?
/* 生成代码,仔细甄别 */
// 原始循环
for (int w = 0; w < width; ++w) {
float sum = 0;
for (int h = 0; h < height; ++h) {
sum += image[h * width + w];
}
out[w] = sum / height;
}
你有一个 sum 和这个外层的 w 相关的操作,这让你无法直接翻转它们。你不能交换内外层循环。那样是行不通的。那么你怎么做呢?
观众: 你能直接把
image的值加到out上吗?
演讲者: 嗯,可以。我的意思是,你可以……啊,这里有除法。抱歉。
是的,这里有除法,但你可以。你可以不用 sum,而是直接用 out[w],先把它置为零。然后你做这个求和,最后再把 out[w] 除以 height,就是这样。你可以那么做,但这还不够。你需要做什么?
观众: 维护一个
sums数组。
演讲者: 是的,那是一样的。你提议用一个sums数组,他提议直接用out[w]代替sum。这是同一回事。你需要把这些语句移动到其他循环里去。第一个和最后一个。
这是怎么做的。我做了和 Dennis 提议的一样的事情。我用 out[w] 代替了 sum。我在第一个循环里初始化它。然后在这里我做循环交换。最后,我做除法的事情。现在看这里。在这个循环里,image 的访问模式是什么?
观众: 跨步。
演讲者: 跨步。这个呢?
观众: 顺序。
演讲者: 是的。所以这个可以向量化,而那个不行。
/* 生成代码,仔细甄别 */
// 优化后的代码
for (int w = 0; w < width; ++w) {
out[w] = 0;
}
for (int h = 0; h < height; ++h) {
for (int w = 0; w < width; ++w) {
out[w] += image[h * width + w];
}
}
for (int w = 0; w < width; ++w) {
out[w] /= height;
}
是的。有什么问题吗?
观众: 这里的实际性能提升是多少? 演讲者: 通常来说,修复内存访问模式会带来最大的性能提升。可能会是两倍、三倍、五倍、十倍。我见过十倍速的,特别是当你有非常大的数据结构时。对于小数据结构,影响不大,但对于大的数据结构,影响很大。
是的。而且即使没有发生向量化,它也仍然更好。内存子系统变得如此……我在公司有了一台新笔记本电脑,我测试了其中一些例子,发现做加法和做除法没有任何区别。而加法是一个周期,除法是 25 个周期。这仅仅是因为内存,当你从内存获取数据时,它就是这么慢。
好的。有问题吗?
好的。下一件事是非顺序内存访问。我这里有一个结构体。这是关于类的,在面向对象编程中充满了类。所以你有这个结构体,float x, y, z,然后我有另一个结构体叫 bar,它有两个 foo 的实例。
/* 生成代码,仔细甄别 */
struct foo { float x, y, z; };
struct bar { foo a, b; };
bar my_bar_array[1024];
我的 bar 数组,它有 1024 个 bar_t 实例,它的布局是怎样的?它在内存中是如何存储的?
观众: 三元组的配对。
演讲者: 是的,三元组的配对。是的,三元组的配对。所以是foo[0].x,foo[0].y,foo[0].z,foo[1].x,foo[1].y,foo[1].z…(译者注:此处演讲者口误,应该是bar[0].a.x,bar[0].a.y,bar[0].a.z,bar[0].b.x,bar[0].b.y,bar[0].b.z,bar[1].a.x, …)
这对向量化,对编译器自动向量化来说不是一件好事。Dennis 可以作证。
观众(Dennis): 不好。
演讲者: 不好,是的。这不是我瞎想的,对吧?
所以,这种布局被称为结构体数组(Array of Structures, AoS)。结构体数组在面向对象编程中非常常见。它很难被向量化。在这个特定的案例中,可能可以做到,但总的来说不行。因为假设我想对 x 做这种处理,对 y 做另一种处理,对 z 做又一种处理。比如说,我想对 x 做加法,对 y 做乘法,对 z 做除法。这是不可能的,我没有一条指令可以在一个寄存器的四个值上,对第一个值做加法,对第二个值做乘法,对第三个值做除法,这种指令不存在。
所以,这个问题的解决方案是把内存布局改成数组结构体(Struct of Arrays, SoA)。它是这样工作的。我原来有这个 FU X Y Z,现在我有一个 FU_SOA(数组结构体),它是一个指向 x 的指针。所以所有的 x 都存储在一个数组里,所有的 y 存储在另一个数组里,所有的 z 存储在再一个数组里,等等。这对于向量化帮助很大,因为你一次性获取四个 x,四个 y,四个 z,然后对它们都执行同样的操作。
/* 生成代码,仔细甄别 */
// AoS
struct Foo { float x, y, z; };
Foo my_array[N];
// SoA
struct FooSoA {
float* x;
float* y;
float* z;
};
所以,是的,这使得向量化变得相当简单。有问题吗?
观众(Dennis): 在 Eve 和其他一些实现中,有一些东西可以从元组(Tuple)为你生成 SoA 布局。所以,你可以编写代码,假装它还是旧的代码,然后我们会帮你做这件事。
演讲者: 它们有代码可以自动转换一个部分?
观众(Dennis): 是的,你编写的代码接收一个元组,不是一个结构体,而是一个元组。
演讲者: 好的,那是一回事。
观众(Dennis): 不,结构体有好的名字。但是……
演讲者: 我能像……反过来那样进行类型转换吗,你知道,转换成你知道内存布局相同的元组?
观众(Dennis): 你可以做类似那样的事情。是的。
演讲者: 好的,那应该可行。
是的?
观众: 反射(Reflection)也会让库能够通过元编程(metaprogramming)来做这件事。
演讲者: 什么时候?我意思是,那要等很久才会发生。
是的?
观众: 只是一个评论,这让我想起了我那本关于数据导向编程的书。
演讲者: 好的,好的。
我们有一个,我曾经和一个客户合作,我们必须做这个彻底的改变。他们有这些嵌套的结构体,对于向量化来说简直是灾难,我们必须完全把它们拆开。而这种转换并不简单,因为它……你知道,你需要改变大量的代码才能让它工作。像循环交换,你只需要改变一个循环。但这个要复杂得多。
好的,下一个向量化,你看到的下一条消息是:“成本模型表明向量化没有好处。”又是成本模型,和之前一样,但没有告诉你哪里错了。这通常发生在你有太多条件语句时。所以条件语句是向量化的一个问题。问题在于,如果你有一个 if 和一个 else,if 体和 else 体里的内容,两者都必须被求值。好的。这就产生了一个问题。因为你在运行同样的代码,你在运行两段代码,你把指令运行了两遍,然后有一条指令会从 if 或 else 的结果中选择一个。这就是向量化的工作方式。我们想避免这个。如果你能设法去掉条件或者减少条件的数量,通常这会提高向量化的质量。
好的,我根据条件类型对条件进行了分类,第一个我称之为循环不变量条件(loop invariant conditions)。看这个循环。它有 a 和 b,然后在循环内部我有 if (b),然后 b[i] += 1。但这里的问题是什么?b 的值在循环运行时不会改变。它要么是 null,要么不是 null。那么我们该如何修复这个?
观众: 两个循环。
演讲者: 两个循环,是的。一个循环用于b为真的情况,另一个循环用于b为假的情况。
实际上,这是一个相当常见的优化,编译器会做的。它被称为循环判断外提(loop unswitching)。编译器只是创建了同一个循环的两个副本。一个用于条件为真的情况,另一个用于条件为假的情况。但是,这种转换会导致代码爆炸。想象一下你有四个参数。你需要有 2 的 4 次方,也就是 16 个版本的循环。到某个时候,编译器就会说这个代码太大了。不要这么做。所以如果你用 -Os(为大小优化)编译,这个优化就不会启用。
观众: 分支预测不会让它几乎和另一个一样快吗?
演讲者: 不会。你有一条指令进入了流水线。它正在被处理。它是一条快速的指令,但它仍然是一条指令。
观众: 而且没有向量化。
演讲者: 是的。特别是它会阻止向量化。它甚至不是……它会完全停止向量化。没有向量化。编译器说成本模型……等等。
观众: 我只是想说,我不知道有编译器会这么做。我一直都得手动做。
演讲者: 它们能,它们必须……
观众: 我从没见过这个被自动完成。我一直都得手动做。
演讲者: 我见过它自动完成,因为当我手动做的时候,性能没有改变。所以有些编译器是会做的。比如 Clang 以循环展开而闻名。每次你看到一个……
观众: 我就在用 Clang。
演讲者: 啊,好的,好的。你也许可以把那个例子扔到 Godbolt 里。如果那个例子在 Godbolt 里不这么做,那我们就没希望了。
好的。第二种循环不变量条件被称为迭代器依赖(iterator dependent)。在这里,你看这个条件,它只依赖于迭代器 i 和 j。它不依赖于数据。这意味着这个条件是可预测的。你知道它什么时候会为真,什么时候会为假。然后你可以在这个循环开始前就对它进行求值。
那么我们怎么做呢?这里有一种方法。在这种情况下,我移除了条件。我说,我复制,我把 else 分支里的东西拿出来,放在这里。然后在循环外面,我设置 b[i][i] = 0。另一种方法是,循环 j 从 0 到 i,然后它进行复制。然后我设置 b[i][i] = 0。然后另一个循环从 i + 1 到 n,它也复制和之前一样的东西。所以这是两种方法。你把循环拆分,把东西挪动一下,然后你就去掉了条件。
好的。最后,简化循环体。如果你有好的循环体,if 和 else 部分,但它们有共同的东西,你就把那个共同的东西移到循环外面去。这个例子可能不是最好的,但比如说,比如说,好吧,没关系。我有一个 a[i] + a[i],第二种情况是 a[i] * a[i]。所以这是一个加法和一个乘法作为操作。现在,加法是什么?加法就是乘以数字 2。好的。其中一种做法是,如果 a[i] > 0,我把 2 加载到 c,否则我加载 a[i]。然后我再乘以 c。所以原来我有一个加法和一个乘法,现在我有一个乘法和条件。这被称为……
观众: Cmove。
演讲者: 是的,Cmove,条件移动或类似的东西。是的。是的。
好的。所以你把……这不仅适用于自动向量化,也适用于常规编码。你把分支中所有共同的东西都移到分支外面去。
观众: 这真的更快吗?我期望……我期望这个能被完美向量化,然后速度和右边的差不多。
演讲者: 这个特定的代码可能不会快那么多。也许速度一样,但它是一个很好的例子来说明这个技术。好吗?因为我不能在幻灯片上放 1000 行代码。
下一件事是迭代次数少的循环(loops with low trip count)。迭代次数少的循环,你看这里的内层循环,它只有三次迭代。编译器就是无法向量化它,因为没有足够的迭代次数。虽然因为外层循环,它可能会被执行很多次,但内层循环是问题所在。
修复它的方法之一叫做循环合并(loop collapsing)。你把外层和内层循环合并成一个循环。所以你看到这里 A[i * 3 + j],然后我的 ij 从 0 到 3 * n。这个循环是完全可以向量化的,没什么特别的。现在,循环合并的坏处是很多时候它是不可能的。实际上,当涉及到迭代次数少的循环时,如果你想向量化这样的循环,你可能需要使用某种手动向量化。不总是,但……
观众: 我很惊讶。我认为编译器的循环展开(unrolling)大多数时候处理不了这个。这个,我期望这个循环,内层循环,编译器会完全展开它。然后它只会向量化外层循环。那可能会发生。
演讲者: 你多常遇到内层循环迭代次数少,但你知道,它不是 3,而是 k,然后 k 很小的情况?
演讲者: 是的,我遇到过,用的是高斯平滑(Gaussian smoothing)。这是一个高斯平滑核。这个核的大小可能是 5 或 3 或 11,类似这样。它不是 2 的幂,所以不适合向量长度。处理这里的低迭代次数循环,实际上我们是用循环交换来做的。所以我们交换了这个循环和这个循环,然后我们得到了这段代码。这段代码的迭代次数就很大了。我们也没有因此引入任何坏的内存访问模式,效果很好。
好的,还有人想问问题吗?
观众: 在前一个例子里。
演讲者: 是的?
观众:memset能用吗?
演讲者:map check?不。memset能在这里用吗?是的,这是一个memset。
观众: 好的,所以……
演讲者: 所以聪明的编译器实际上会把这个转换成memset。可能 Clang 会这么做,哦,我发现这是个memset,就直接放那个。是的。
好的。有问题吗?我们还有多少时间?
助理: 16 分钟。 演讲者: 16 分钟,好的。
接下来的问题是循环携带依赖(loop carry dependencies)。我已经提到过,循环携带依赖通常是任何类型并行化的问题,无论是 GPU 运行还是你尝试做的任何事情,这都会是个问题。因为循环携带依赖……它们需要顺序执行,从头到尾。
所以,这个 copy_if 算法,如果你还记得,我们对 j 有来自上一次迭代的依赖。所以它向量化效果不好。
观众(Dennis): 但这家伙会告诉你不是这样。
演讲者: 真的吗?
观众(Dennis): 是的。如果你看了我昨天的演讲,我们向量化了那个。
演讲者: 是的。而且向量化是可能的,但是……不是通过编译器自动向量化,而是通过某种……
观众(Dennis): 是的。如果你用if,有一个copy_if,你可以向量化。
演讲者: 是的。是的。
现在,还有这个前缀和(prefix sum),它也不可向量化,至少不是以一种简单的方式。
观众(Dennis): 如果你用
if,那是可以向量化的。
演讲者:if是解决一切的方案。
观众(Dennis): 是的。
好的。所以对于循环携带依赖,有一些技巧,或者有一些框架可以向量化一些东西,但不是全部。你需要……通常都是些技巧。所以它不是总能奏效。我不知道。没有一个通用的方法。但向量化的好处是,它是从头到尾执行的。所以它……你能向量化的东西比你能并行化的东西要多。
你可以做的一件事是,你可以移除向量化抑制因素。让我们看看这里的代码。我这里有一个复杂的计算。然后我对 j 有一个循环携带依赖。所以这个总的循环不能被向量化。但是,我可以用 循环分裂(loop fission) 把循环分成两个。第一个循环是完全可以向量化的。第二个循环不可向量化。但我们不在乎,因为……因为……是的。我们希望这个循环的向量化能成为那个复杂的计算。因为它会带来改变。
是的。所以你可以对任何类型的向量化问题做这个。比如在这个案例中,我把这个低效的内存访问移到一个可向量化的循环里。我在这里引入了一个临时的 B_index 数组。我做了这个复杂的计算。然后,我有一个不可向量化的部分,我只是用这段代码把它复制过去。所以你可以移除各种东西,把循环分成可向量化和不可向量化的部分。但这种方法的问题是它会稍微降低一点性能。相比于你只有一个向量化循环,当你现在有两个循环时。
是的。还有,最后一件事是这些编译器的怪癖(compiler’s idiosyncrasies)。它可能会告诉你,“循环不可向量化。我无法识别数组边界。”或者,“循环不可向量化。我无法证明重排浮点运算是安全的。”所以,这些编译器的怪癖之所以存在,是因为 C++ 标准要求编译器以某种特定的方式行事。你需要产生正确的结果。这就是它们存在的原因。
观众: 在第二种情况下,
FFastMath会促进向量化吗?
演讲者: 是的,我们马上会讲到。
好的。其中一个问题是指针别名(pointer aliasing)。编译器因为指针别名而无法进行向量化。指针别名发生在当你有指针指向同一个地方时。你有两个指针,比如说 A 和 B,但它们指向同一个内存。一个内存位置可以通过两个不同的指针访问,同一个内存位置。它们可以部分重叠或完全重叠,没关系。当这种情况发生时,编译器就会给你类似“无法识别数组边界”这样的信息。
看这个循环。我有一个循环,它有六个指针。但现在,如果我这样调用它:test(A, A + 1, A - 1, B, B + 1, B - 1)。编译器需要确保即使你用这些参数调用,循环的行为也是正确的。在这种情况下,它可能会放弃自动编译器向量化。
所以,如果你确定没有指针别名,你可以使用 restrict 关键字。所以, вместо int* A,你将会有 int* restrict A, int* restrict B 等等。
好的。最后是这个你提到的。浮点数。看这个循环:sum += A[i]。如果你有浮点运算,并且你在累加它们,累加的顺序是很重要的。而向量化会改变累加的顺序。它可能会产生稍微不同的结果。现在,当你用普通的标准编译标志如 -O3 编译时,编译器不会向量化这个东西。你能做的是,要么给编译器传递 -fassociative-math 或 -ffast-math,然后它就会向量化它。但你会得到一点点不同的结果。或者,你可以只用编译器 pragma 来向量化单个循环,比如 pragma clang loop vectorize(enable) 或者 pragma omp simd。
观众: 当我尝试
loop vectorize(enable)时,它没用。有用的是在我加法上用pragma associative floating point。
演讲者:pragma associative?我不知道。有一个 pragma 说这个特定的加法是满足结合律的?我不知道那个。好的。好的。
是的。所以这个话题结束了编译器自动向量化的话题。下一步就是手动向量化了。有些循环编译器就是无法向量化它们。它们可以,特别是如果你有这些抑制因素,比如循环携带依赖,太多的 if 等等。编译器不会向量化它们。while 和 do-while 循环也是可以向量化的,但不是用自动编译器向量化。
观众(Dennis):
std里有很多东西是向量化的。你是手动向量化的吗?
演讲者: 我在std里什么都没有。有一个库。
观众(Dennis): 我以为std是标准的一部分。
演讲者: 有一个standard cmd被合并了。它没有算法。
观众(Dennis): 啊,好的。有一个standard par and seq。它有算法,但大多数时候不太好。还有一些额外的库简直糟透了。只是在 github 上。
演讲者: 好的,好的。
是的。所以,手动向量化是使用编译器内联函数。你可以向量化那种东西。我实际上提供关于 AVX 和 NEON 手动向量化的课程。所以,我是在做点宣传,但你们会原谅我的。
好的。所以,这里的事情是,让我们一起做一个练习。我们有这个叫做 stripe_color 的循环。想象你有一张图片,你在给它上色,但是是垂直的,像条纹一样。红、黄、红、白、红、白、红、白等等。所以你想给它上色。
/* 生成代码,仔细甄别 */
// 初始代码
void stripe_color(uint8_t* image, int n) {
for (int i = 0; i < n; ++i) {
uint8_t color_even[] = {255, 0, 0}; // Red
uint8_t color_odd[] = {255, 255, 255}; // White
for (int j = 0; j < n; ++j) {
if (i % 2 == 0) {
image[(j * n + i) * 3 + 0] = color_even[0];
image[(j * n + i) * 3 + 1] = color_even[1];
image[(j * n + i) * 3 + 2] = color_even[2];
} else {
image[(j * n + i) * 3 + 0] = color_odd[0];
image[(j * n + i) * 3 + 1] = color_odd[1];
image[(j * n + i) * 3 + 2] = color_odd[2];
}
}
}
}
问题是,这里有什么问题?第一个是内存访问模式。让我们来分析内存。这个的内存访问模式是什么?
观众: 跨步。
演讲者: 跨步。跨步的。我们能做,我们能做,我们能做,我们能修复它吗?
观众: 循环交换。
演讲者: 是的。但让我们这样做,让我们这样做。这是我的代码。让我们这样做。我将,我将,让我们先测量这个循环的运行时间,然后我们加一个 pragma。首先做一个并行化的版本。好的。我加上这个pragma omp parallel for,这里的共享变量是什么?n和image。
观众:n,image。我猜是color_odd和color_even。
演讲者:color_odd。n。color_even。好的。是的。
让我们运行它们。
观众:
n也应该在里面吗?
演讲者:n。n,这里没有n。啊,抱歉。是的。让我们加上default(none)。好的。看看会发生什么。
现在的问题是,在我的环境里,OpenMP 在 Clang 上不工作。所以我要用 GCC。这就像,它只是抱怨,抱怨……哦,抱歉。所以我要用 GCC。这就像,它只是抱怨,抱怨……哦,抱歉。所以这是在用四个线程运行它。让我们看看运行时间。好的。好的。看看会发生什么。现在的问题是,在我的环境里,OpenMP 在 Clang 上不工作。所以我要用 GCC。这就像,它只是抱怨,抱怨……哦,抱歉。所以这是在用四个线程运行它。让我们看看运行时间。
图片是,我不知道,可能是 1000x1000。我一遍又一遍地重复这些事情。所以,第一个,标量版本耗时 5.15。向量版本 4.94。这有点问题。让我看看。什么……不,抱歉。出问题了。天哪。这里启用 OpenMP 了吗?天哪。哦,没关系。
当我早些时候运行这个实验时,它快了大约四倍。从 5.15 秒降到了大概 1.5 秒左右。我不知道这里出了什么问题。这应该是……
观众: 现场演示的风险。
观众: 我们都经历过。
演讲者: 我不知道发生了什么。
观众: 你设置了你说的那个环境变量吗?
演讲者: 不,它们不需要环境变量。
但让我们做循环交换吧。好吗?就为了让你们看看这个。看看这个区别。所以,我该如何在这里做循环交换?我做不了,因为有这个语句。
观众: 把循环上移两行。
演讲者: 抱歉?
观众: 把循环拿出来,上移两行。
演讲者: 好的。所以放到里面。是的。然后我……交换它们。
是的,我知道发生什么了。我重命名了那些文件。这是一个旧版本。
(演示代码运行成功)
所以,原始版本是 4.95 秒,这个是 1.69 秒。这是并行化的版本。让我们做循环交换。好的。所以这个,这个循环进去。哇。
/* 生成代码,仔细甄别 */
// 循环交换后
void stripe_color_interchanged(uint8_t* image, int n) {
for (int j = 0; j < n; ++j) {
for (int i = 0; i < n; ++i) {
uint8_t color_even[] = {255, 0, 0}; // Red
uint8_t color_odd[] = {255, 255, 255}; // White
if (i % 2 == 0) {
// ... write color_even
} else {
// ... write color_odd
}
}
}
}
现在你期望发生什么?
观众: 我觉得会变慢,因为你现在在循环里做颜色那件事了。
演讲者: 啊,你没有信心,Thomas。
好的。它从 4.95 秒降到了 0.37 秒。哇。是的。所以这就是……这张图片的大小是……3840 x 3840。所以这是一张非常大的图片。但这向你展示了内存访问模式对性能有多大的影响。
观众: 好的。有一点要指出,你在循环里用
int64作为索引。你做i % 2 == 0。我不知道它实际上是否还在那里,或者它是否设法把它弄掉了,但是如果int64还在,你会显著减少你得到的向量化程度,因为int64比你实际需要的大得多。
演讲者: 但这是大小。这是大小。
观众: 但是当你计算颜色时,你做color_even。啊哈。所以你做i % 2。
演讲者: 但这个,这个只是变成了……i mod 2。它不像一个模运算。它只是……
观众: 不,我的意思是,好的。好的。好的。我明白了。是的。是的。如果我是int32,可能会更好,因为我的大小是一样的……没关系。
好的。让我们做第二件事。让我们移除条件。这个条件,这是哪种条件?它是基于索引的。所以它是一个迭代器依赖的条件,依赖于迭代器。你知道它会如何发展。迭代 0,会是 color_even,迭代 1,是 color_odd。迭代 2,color_even,迭代 3,color_odd。所以我可以在这里改变它。我可以不用这个,而是放上 color_odd, color_even。然后我可以放,哦,抱歉。
/* 生成代码,仔细甄别 */
// 移除条件后
for (int j = 0; j < n; ++j) {
for (int i = 0; i < n; i += 2) {
// ... write color_even to image at index i
// ... write color_odd to image at index i+1
}
}
好的。好的。这里应该还有一行,你知道,处理剩下的情况,以防图片的大小不是……是个奇数,但我们这里不需要。所以让我们看看现在会发生什么。你认为会发生什么?
观众: 可能会减少一半的开销。
演讲者: 让我们看看。
所以它从……发生什么了?我为什么把这个移除了?所以这里,它之前是 0.30 多。现在是 0.09。我不小心把它复制到这里了。所以现在是空的。但你的意思是,这是三倍的提升,因为你移除了条件。
好的。然后我们可以在这段代码上使用并行化。它不是从 5 到 1.5,你会发现并行化在这里帮助不大。在这一点上,你不能移除嵌套循环,把它展平成一个循环吗?不,不,因为你在图片的末尾,你有不同的行为。可能可以,但你只需要……你需要为奇数大小的图片,奇数宽度和偶数宽度,分别设置循环。
让我们把它并行化。所以是和这里一样的 pragma。所以用四个线程,它从 0.09 秒降到了 0.08 秒。发生的事情是,我假设现在这完全是内存问题了。这里没有计算。这只是内存复制。你把数据从一个地方复制到另一个地方。在这种情况下,对于非常高效的循环,并行化帮助不大。尽管在第一个例子里,像这里,它很重要。从 4.96 秒(译者注:口误,应为 4.95)到 1.47 秒(译者注:口误,应为 1.69)。
好的。有什么问题吗?没有?
好的,我们到 12:30 了。非常感谢大家的到来。并且度过了愉快的时光。非常感谢你们度过了愉快的时光。非常感谢你们度过了愉快的时光。谢谢。