我是一名R开发人员,使用C语言进行算法开发,我有一个问题,为什么一个看起来很慢的C循环实际上比另一种方法快。
在R中,我们的布尔类型实际上可以有三个值,true
、false
和na
,我们在C级别上使用int
来表示。
我正在研究一个向量化的&&
操作(是的,我们在R中已经有了这个操作,请耐心等待),它也可以处理na
的情况。
F && F == F
F && T == F
F && N == F
T && F == F
T && T == T
T && N == N
N && F == F
N && T == N
N && N == N
注意,它的工作原理类似于C中的&&
,只是na
值在与false
以外的任何值组合时传播,在这种情况下,我们“知道”&&
永远不会为真,所以我们返回false
。
现在来看实现。假设我们有两个向量,v_out
和v_x
,我们想对它们执行向量化&&
。我们可以用结果覆盖v_out
。一个选项是:
// Option 1
for (int i = 0; i < size; ++i) {
int elt_out = v_out[i];
int elt_x = v_x[i];
if (elt_out == 0) {
// Done
} else if (elt_x == 0) {
v_out[i] = 0;
} else if (elt_out == na) {
// Done
} else if (elt_x == na) {
v_out[i] = na;
}
}
另一个选择是:
// Option 2
for (int i = 0; i < size; ++i) {
int elt_out = v_out[i];
if (elt_out == 0) {
continue;
}
int elt_x = v_x[i];
if (elt_x == 0) {
v_out[i] = 0;
} else if (elt_out == na) {
// Done
} else if (elt_x == na) {
v_out[i] = na;
}
}
我希望第二个选项更快,因为它避免了在不需要时访问v_x[i]
,但事实上,使用-O2
编译时,速度要慢两倍!
在下面的脚本中,我得到了下面的计时结果。注意,我使用的是Mac,编译时使用的是Clang。
It seems reasonable with O0. They are about the same.
2x faster with O2 with Option 1!
Option 1, `clang -O0`
0.110560
Option 2, `clang -O0`
0.107710
Option 1, `clang -O2`
0.032223
Option 2, `clang -O2`
0.070557
这是怎么回事?我最好的猜测是,这与选项1中v_x[i]
总是被“线性”访问的事实有关,这是非常快的。但在选项2中,v_x[i]
基本上是被"随机“访问的。(某种程度上),因为它可能访问v_x[10]
,但随后不需要从v_x
到v_x[120]
的另一个元素,并且因为该访问不是线性的,所以可能要慢得多。
可复制脚本:
#include <stdlib.h>
#include <stdio.h>
#include <limits.h>
#include <time.h>
int main() {
srand(123);
int size = 1e7;
int na = INT_MIN;
int* v_out = (int*) malloc(size * sizeof(int));
int* v_x = (int*) malloc(size * sizeof(int));
// Generate random numbers between 1-3
// 1 -> false
// 2 -> true
// 3 -> na
for (int i = 0; i < size; ++i) {
int elt_out = rand() % 3 + 1;
if (elt_out == 1) {
v_out[i] = 0;
} else if (elt_out == 2) {
v_out[i] = 1;
} else {
v_out[i] = na;
}
int elt_x = rand() % 3 + 1;
if (elt_x == 1) {
v_x[i] = 0;
} else if (elt_x == 2) {
v_x[i] = 1;
} else {
v_x[i] = na;
}
}
clock_t start = clock();
// Option 1
for (int i = 0; i < size; ++i) {
int elt_out = v_out[i];
int elt_x = v_x[i];
if (elt_out == 0) {
// Done
} else if (elt_x == 0) {
v_out[i] = 0;
} else if (elt_out == na) {
// Done
} else if (elt_x == na) {
v_out[i] = na;
}
}
// // Option 2
// for (int i = 0; i < size; ++i) {
// int elt_out = v_out[i];
//
// if (elt_out == 0) {
// continue;
// }
//
// int elt_x = v_x[i];
//
// if (elt_x == 0) {
// v_out[i] = 0;
// } else if (elt_out == na) {
// // Done
// } else if (elt_x == na) {
// v_out[i] = na;
// }
// }
clock_t end = clock();
double time = (double) (end - start) / CLOCKS_PER_SEC;
free(v_out);
free(v_x);
printf("%f\n", time);
return 0;
}
基于评论中的几个问题,这里有几点需要澄清,以供未来读者参考:
- 我在一台2018年的15英寸MacBook Pro上,配备了2. 9 GHz的6核英特尔i9-8950HK(6核Coffee Lake)
- 我测试的特定Clang版本是
Apple clang version 13.1.6 (clang-1316.0.21.2.5)
和Target: x86_64-apple-darwin21.6.0
- 我受到R的限制,只能使用
int
作为数据类型(尽管有更有效的选项),并使用以下编码:false = 0
、true = 1
、na = INT_MIN
。我提供的可重现示例考虑到了这一点。 - 最初的问题实际上并不是要求代码运行得更快。我只是想了解一下我的两种if/else方法之间的区别。也就是说,一些答案表明 branchless 方法可以更快,我真的很感谢那些用户提供的解释!这极大地影响了我正在工作的实现的最终版本。
5条答案
按热度按时间pdsfdshx1#
**如果您希望快速矢量化代码,一般不要执行短路求值,也不要执行分支。**您希望编译器能够使用8位元素,通过SIMD运算一次处理16或32个元素。(编译器可以将
if
优化为无分支代码,前提是无条件地执行这项工作是安全的,包括解引用,而且没有副作用,这被称为 if-conversion,通常对于像这样的代码的自动矢量化是必要的。)你也不想让编译器担心它不被允许访问一些内存,因为C抽象机不允许。例如,如果所有的
v_out[i]
元素都是false,v_x
可能是一个NULL指针,而不会导致UB!所以编译器 * 不能 * 发明对C逻辑根本不读的对象的读访问。如果
v_x
真的是一个数组,而不仅仅是一个指针,那么编译器就会知道它是可读的,并且可以通过将短路逻辑转换为无分支来发明对它的访问。(如自动矢量化),它可能选择不这样做。分支代码在实践中经常会因为真假(和NA)的随机混合而变慢。正如您在编译器的汇编输出(Clang 15 -O2 on Compiler Explorer)中所看到的,选项1使用SIMD进行自动矢量化,并行地无分支处理4个可选布尔值(仅使用SSE2,更多使用
-march=native
)。它可能反映了Apple Clang将对main
中的真实的代码执行的操作。)**支持NA状态的3态布尔值可以用2位实现,其方式与按位AND执行
&&
运算的方式相同。**您可以将其数组存储为每个unsigned char
一个数组,或每个字符压缩4个数组,从而将矢量化运算的吞吐量提高四倍,但代价是访问速度变慢。(或者通常每个char
对应CHAR_BIT/2
,但在主流的x86 C实现中,这是4。)F = 00
N = 10
(二进制,因此C0b10
又称为2
)T = 11
val & 1
的bool
。bool
到0b11 * b
或其他东西,以将低位广播到两个位置。F & anything = 0
,因为F是全零位。这对于任何位模式都是成立的,“聪明”的部分是N&T = T&N = N
,因为T
中的set位是N
中的set位的超集。这也适用于
||
和按位|
:F|N == N
和F|T == T
,因为0|x == x
。对于任何相同的输入,也是x|x == x
,所以我们仍然可以在那里使用。N = 0b10
在执行“或”运算时不会设置低位,但在执行“与”运算时会将其清除。我忘了你说的是C而不是C++,所以这个基本的类 Package 器(只够演示几个测试调用者)可能不相关,但是在纯C中为
unsigned char *c1, *c2
执行c1[i] &= c2[i];
的循环将以完全相同的方式自动矢量化。(我认为“Nullable”并不是一个真正正确的术语,它可以是NaN / NA;读起来总是安全的,而且它不是一个引用,可能是optional_bool,就像C++
std::optional
,它是一个值,可能存在,也可能不存在。这个代码是在编译器资源管理器上用GCC和Clang编译的,Clang自动矢量化非常好,用一个展开的循环执行
vandps
。在-march=haswell
上,vpand
具有更好的吞吐量。)但是无论如何它仍然受到1/时钟存储和2/时钟负载的限制;即使数据在L1 d高速缓存中是热的,这也会在具有如此低的计算强度的加载/存储上形成很多瓶颈。(英特尔的优化手册指出,即使Skylake的峰值L1 d带宽是每时钟2个负载+1个存储(96字节,32字节向量),持续带宽更像是每时钟84字节。)
它仍然可以相对接近于每个时钟周期32字节的AND,AVX,所以这是32个NBool
&
操作,或者如果每个字节封装4个NBool,每个时钟128个。可以使用
pslld xmm, 7
/pmovmskb
将NBools压缩为1位bool的压缩位图,以提取每个字节的低位(在将其移位到高位之后)。如果每个字节存储4个字节,则某些SIMD位操作是为了打包成布尔值,可能是将
vpshufb
打包成4位LUT,以便将NBool对打包成半字节底部的一对布尔值,然后合并?或者使用标量BMI 2pext
从64位中每隔一位提取一次,如果您使用Zen 3或Haswell或更高版本,用于快速pext
。6l7fqoea2#
为什么这种看似较慢的C循环实际上是另一种方式的两倍快?
v_x
声明为volatile
,否则编译器可以自由地以完全相同的方式解释代码中的两个变体。我有点希望第二个选项更快,因为它避免了在不需要时访问
v_x[i]
。如果编译器的优化器判断这是真的,那么它可以利用这个判断来有条件地避免结合第一个代码读取
v_x[i]
。但是在较低级别,如果编译器生成的代码确实有条件地避免在选项2中读取
v_x[i]
而不是在选项1中读取v_x[i]
,那么您可能正在观察选项2情况下分支预测失误的影响。平均而言,无条件读取v_x[i]
要比遭受大量的分支预测错误惩罚(包括是否无条件读取v_x[i]
应予以阅读。其中一个要点是,在现代硬件上,分支可能比预期的要昂贵得多,特别是当分支对CPU来说很难预测时。如果可以通过无分支方法执行相同的计算,则在实践中可能会获得性能优势,通常,@KarlKnechtel的答案给出了您尝试执行的计算的一个可能的无分支(但用于测试
for
循环条件,这是非常可预测的)变体。hsvhsicv3#
注意,它的工作原理与C中的&&类似,只是na值在与除false之外的任何值组合时都会传播,在这种情况下,我们“知道”&&永远不会为true,所以我们返回false。
与其将值表示为严格的枚举,不如使用
2
或3
的数值来表示na
(您可以在显示时检查这一点,也可以在所有的数字运算之后使用规范化步骤)。这样,就不需要条件逻辑(因此也就不需要昂贵的分支预测):我们简单地对2s位置中的比特进行逻辑OR(与运算符无关),以及对1 s位置中的比特进行逻辑AND(或任何运算符)。如果我们被迫使用
INT_MIN
来表示N/A值,我们可以从观察二进制补码的情况开始:它只有一个位(符号位,在无符号值中最有效),因此,我们可以使用该位值代替2
,并使用相同类型的无条件逻辑,然后将任何(INT_MIN | 1
)结果更正为INT_MIN
:(All这些强制类型转换可能不是必需的,但是我认为使用无符号类型进行位操作是一个很好的实践。2无论如何,它们都应该被优化掉。
suzh9iv84#
让我们看看这些代码示例在Clang15.0.0上编译成什么样的代码,其他编译器生成的代码会略有不同;很挑剔。
将代码片段分解为函数,我们得到
您的选项1(这里是
filter1
)在Clang 15上编译为矢量化循环(GCC 12在这方面有问题)。我们可以看到编译器优化了循环,通过一系列SIMD比较(
vpcmpeqd
指令)生成位掩码,然后使用vpmaskmovd
执行条件移动。这看起来比实际情况复杂,因为它部分展开,以便每次迭代执行两次连续更新。你会注意到,除了循环底部测试我们是否在数组末尾之外,没有分支,然而,由于条件移动,我们有时会在加载或存储时得到缓存缺失,这就是我认为在我的测试中有时会发生的情况。
现在我们来看选项2:
在这个编译器上类似的代码,但是稍微长一些。一个区别是从
v_x
向量的条件移动。然而,这是
-march=x86-64-v3
的情况。如果你不告诉它允许使用AVX 2指令,比如vpmaskmovd
,Clang 15.0.0将完全给予矢量化这个版本的算法。为了进行比较,我们可以利用
v_out[i]
的更新值将始终等于v_out[i]
或v_x[i]
这一事实来重构此代码:这会得到一些非常不同的代码:
虽然看起来比较长,但每次迭代更新四个向量,实际上是用位掩码混合
v_out
和v_x
向量。GCC 12.2版本的循环遵循类似的逻辑,每次迭代更新一次,因此更简洁:正如你所看到的,这大概和1和3的汇总版本一样紧凑,每个迭代更新一次,但是一些优化器似乎没有那么麻烦。一个类似的版本,其代码主要在寄存器分配上有所不同,应该是:
外卖
看起来发生的事情是你的编译器能够在你使用的设置上向量化你的版本1,而不是你的版本2。如果它都能向量化,它们的表现是相似的。
在2022年,具有积极优化设置的编译器可以将这些循环中的任何一个转换为矢量化的无分支代码,至少在启用AVX 2的情况下是这样。如果启用了AVX 2,那么第二个版本就能够如您所想,有条件地从
v_x
加载。(当您将v_out
初始化为全零时,这确实会导致很大的可观察差异。)2022年的编译器似乎使用版本3和版本4的单赋值语句比使用1和2的if
块做得更好。它们在1和2没有的一些目标和设置上进行矢量化,即使所有4个都这样做,Clang15.0.0也会比1和2更积极地展开3和4。在启用AVX-512指令的情况下,编译器可以将所有四个版本优化为类似的无分支代码,并且在性能上没有任何显著差异。对于其他目标(特别是
-O3 -march=x86-64-v2
和-O3 -march=x86-64-v3
),Clang 15.0.0在版本3和版本4上的表现明显优于版本1和版本2。然而,如果你愿意改变函数对某些输入的行为,你可以删除比较和条件移动来进一步加速,就像Peter Cordes和Karl Knechtel的答案一样。
在我的测试中,哪个版本更快在很大程度上取决于输入值初始化为什么。使用相同的随机种子,
filter1
比其他三个版本稍快,但使用真正随机化的数据,四个版本中的任何一个都可能更快。xmq68pz95#
几乎可以肯定,因为硬件预取器正好在循环1中工作,而不是在循环2中。
如果您使用代码分析器,您可能会在某处看到内存延迟。
存储器访问的延迟比访问本身更昂贵。