Dav*_*han 61 c performance clang compiler-optimization
我是一名 R 开发人员,使用 C 来实现算法目的,并且有一个问题:为什么看起来很慢的 C 循环实际上比其他方法更快。
在 R 中,我们的布尔类型实际上可以有三个值:true、false和,并且我们在 C 级别na使用 an 来表示。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
Run Code Online (Sandbox Code Playgroud)
请注意,它的工作方式&&与 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;
}
}
Run Code Online (Sandbox Code Playgroud)
另一种选择是:
// 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;
}
}
Run Code Online (Sandbox Code Playgroud)
我有点期望第二个选项更快,因为它可以避免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
Run Code Online (Sandbox Code Playgroud)
这里发生了什么?我最好的猜测是,这与选项 1 中始终以线性方式v_x[i]访问这一事实有关,这是非常快的。但在选项 2 中,本质上是随机访问的(某种程度上),因为它可能访问,但随后不需要从直到 开始的另一个元素,并且由于该访问不是线性的,因此可能会慢得多。v_x[i]v_x[10]v_xv_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;
}
Run Code Online (Sandbox Code Playgroud)
根据评论中的一些问题,这里为未来的读者澄清几点:
我使用的是2018 款15 英寸MacBook Pro,配备 2.9 GHz 6 核 Intel 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 方法之间的区别。也就是说,一些答案表明无分支方法可以更快,我非常感谢这些用户提供的解释!这极大地影响了我正在开发的实现的最终版本。
Pet*_*des 70
如果您想要快速矢量化代码,请不要进行短路评估,并且一般不要分支。 您希望编译器能够使用 8 位元素通过SIMD操作一次处理 16 或 32 个元素。(if如果无条件地安全地完成工作(包括取消引用)并且没有副作用,编译器可以将 s 优化为无分支代码。这称为if-conversion,通常对于此类代码的自动矢量化是必需的。)
而且您不希望编译器担心它不允许接触某些内存,因为 C 抽象机不允许。例如,如果所有v_out[i]元素都为 false,则v_x可能是 NULL 指针,而不会导致UB!因此,编译器无法发明对 C 逻辑根本不读取的对象的读取访问权限。
如果v_x确实是一个数组,而不仅仅是一个指针,编译器就会知道它是可读的,并且可以通过将短路逻辑进行 if 转换为无分支来发明对它的访问。但如果它的成本启发法没有看到真正大的好处(如自动矢量化),它可能会选择不这样做。在实践中,分支代码通常会因 true 和 false(以及 NA)的随机混合而变慢。
正如您在编译器的汇编输出( Compiler Explorer 上的 Clang 15 -O2 )中看到的,选项 1 使用 SIMD 自动矢量化,无分支地并行处理 4 个可选布尔(仅使用SSE2,更多使用-march=native)。(感谢评论中的 Richard制作了 Compiler Explorer 链接;它可能反映了 Apple Clang 将对您的真实代码执行的操作main。)
支持 NA 状态的 3 状态布尔值可以用 2 位来实现,以按位 AND 执行操作的方式&&。 您可以将其数组存储为每个 1 个数组unsigned char,或每个字符打包 4 个数组,以使矢量化操作的吞吐量增加四倍,但代价是访问速度较慢。(或者一般来说CHAR_BIT/2,char但在 x86 的主流 C 实现上是 4。)
F = 00N = 10 (二进制,所以 C0b10又名2)T = 11bool与val & 1.boolwith或某些东西进行转换0b11 * b以将低位广播到两个位置。F & anything = 0因为 F 是全零位。 N&N == N; 对于任何位模式来说都是如此。“聪明”的部分是N&T = T&N = N,因为 中的设置位T是 中的设置位的超集N。
这也适用于||按位|:
F|N == N和F|T == Tbecause 0|x == x。对于x|x == x任何相同的输入,我们仍然没问题。
N = 0b10OR 运算时不会设置低位,但 AND 运算时会将其清除。
我忘了你说的是 C 而不是 C++,所以这个简单的类包装器(仅足以演示一些测试调用者)可能不相关,但在纯 C 中执行的循环会以完全相同的方式自动矢量化。c1[i] &= c2[i];unsigned char *c1, *c2
struct NBool{ // Nullable bool, should probably rename to optional bool
unsigned char val;
static const unsigned char F = 0b00;
static const unsigned char T = 0b11;
static const unsigned char N = 0b10; // N&T = N; N&N = N; N&F = F
auto operator &=(NBool rhs){ // define && the same way if you want, as non-short-circuiting
val &= rhs.val;
return *this;
}
operator bool() { return val & 1; }
constexpr NBool(unsigned char x) : val(x) {};
constexpr NBool& operator=(const NBool &) = default;
};
#include <stdint.h>
#include <stdlib.h>
bool test(NBool a){
return a;
}
bool test2(NBool a){
NBool b = NBool::F;
return a &= b; // return false
}
void foo(size_t len, NBool *a1, NBool *a2 )
{
for (std::size_t i = 0 ; i < len ; i++){
a1[i] &= a2[i];
}
}
Run Code Online (Sandbox Code Playgroud)
(我认为“Nullable”对于可能为NaN / NA的东西来说并不是真正正确的术语;它总是可以安全地阅读,而且它首先不是一个引用。也许可选_bool,就像 C++ 一样,它std::optional是一个可能或可能不存在。)
这可以使用 GCC 和 Clang 在 Compiler Explorer上进行编译。Clang 通过展开循环很好地自动矢量化vandps。(clang 的选择有点奇怪; on -march=haswell,vpand具有更好的吞吐量。)但无论如何它仍然受到 1/clock store 和 2/clock load 的限制;即使 L1d 缓存中的数据很热,这在计算强度如此低的情况下也会成为加载/存储的瓶颈。
(英特尔的优化手册称,尽管Skylake的峰值 L1d 带宽为每个时钟 2 次加载 + 1 次存储(96 字节,32 字节向量),但持续带宽更像是每个时钟 84 字节。)
使用AVX仍然可以相对接近每个时钟周期 32 个字节的 AND 运算。因此,&如果每个字节打包 4 个 NBool,则为 32 个 NBool 操作,或者每个时钟 128 个操作。
可以使用pslld xmm, 7/来将 NBool 打包为 1 位布尔值的打包位图pmovmskb,以提取每个字节的低位(在将其移至高位之后)。
如果每个字节存储 4 个,则某些 SIMD 位操作是为了打包为布尔值,也许vpshufb作为 4 位LUT将 NBools 对打包为半字节底部的一对布尔值,然后组合?或者,如果您使用的是Zen 3或Haswell或更高版本,则可以使用标量 BMI2pext从 64 位中提取每隔一位,以实现快速。pext
Joh*_*ger 23
为什么这个看似较慢的 C 循环实际上是其他方式的两倍?
从较高的层面来看,这是您所使用的编译器和执行环境的一个怪癖。v_x除非声明了数组,否则编译器可以以完全相同volatile的方式自由解释代码中的两个变体。
我有点期望第二个选项更快,因为它可以避免
v_x[i]在不需要时进行访问。
如果编译器的优化器判断这是真的,那么它可以利用该判断有条件地避免v_x[i]与第一个代码一起读取。
但在较低级别,如果编译器生成的代码确实有条件地避免读取v_x[i]选项 2 而不是选项 1,那么您可能会观察到选项 2 情况下分支错误预测的影响。完全合理的是,平均而言v_x[i],无条件读取比遭受涉及是否应该读取的大量分支错误预测惩罚更便宜。
要点之一是,在现代硬件上,分支可能比人们预期的要昂贵得多,尤其是当 CPU 难以预测分支时。如果可以通过无分支方法执行相同的计算,则在实践中可能会带来性能提升,但通常会牺牲源代码的清晰度。 @KarlKnechtel 的答案提出了您尝试执行的计算可能存在的无分支(但用于测试for循环条件,这是相当可预测的)变化。
Kar*_*tel 18
请注意,它的工作方式类似于 C 中的 &&,只不过 na 值在与除 false 之外的任何值组合时传播,在这种情况下,我们“知道”&& 永远不会为 true,因此我们返回 false。
2不要将值表示为严格的枚举,而是允许使用 或的数值3来表示na(您可以在显示时检查此项,或者在所有数字处理之后进行标准化步骤)。这样,就不需要条件逻辑(因此不需要昂贵的分支预测):我们只需对 2 处的位进行逻辑或(无论运算符如何),并对 1 处的位进行逻辑与(或任何运算符) 。
int is_na(int value) {
return value & 2;
}
void r_and_into(unsigned* v_out, unsigned* v_x, int size) {
for (int i = 0; i < size; ++i) {
unsigned elt_out = v_out[i];
unsigned elt_x = v_x[i];
// this can probably be micro-optimized somehow....
v_out[i] = (elt_out & elt_x & 1) | ((elt_out | elt_x) & 2);
}
}
Run Code Online (Sandbox Code Playgroud)
如果我们被迫使用INT_MINN/A 值来表示,我们可以首先观察它在二进制补码中的样子:它恰好有一位集(符号位,在无符号值中最重要)。因此,我们可以使用该位值来代替2相同类型的无条件逻辑,然后将任何 ( INT_MIN | 1) 结果更正为INT_MIN:
const unsigned MSB_FLAG = (unsigned)INT_MIN;
void r_and_into(int* v_out, int* v_x, int size) {
for (int i = 0; i < size; ++i) {
unsigned elt_out = (unsigned)v_out[i];
unsigned elt_x = (unsigned)v_x[i];
elt_out = (elt_out & elt_x & 1) | ((elt_out | elt_x) & MSB_FLAG);
// if the high bit is set, clear the low bit
// I.E.: AND the low bit with the negation of the high bit.
v_out[i] = (int)(elt_out & ~(elt_out >> 31));
}
}
Run Code Online (Sandbox Code Playgroud)
(所有这些强制转换可能不是必需的,但我认为使用无符号类型进行按位操作是一个很好的做法。无论如何,它们都应该得到优化。)
Dav*_*lor 12
让\xe2\x80\x99s 看看这些代码示例在 Clang 15.0.0 上编译成什么-std=c17 -O3 -march=x86-64-v3。其他编译器会生成略有不同的代码;它\xe2\x80\x99s 很挑剔。
将代码片段分解为函数,我们得到
\n#include <limits.h>\n#include <stddef.h>\n\n#define na INT_MIN\n\nint* filter1( const size_t size,\n int v_out[size],\n const int v_x[size]\n )\n{\n for ( size_t i = 0; i < size; ++i) {\n int elt_out = v_out[i];\n int elt_x = v_x[i];\n\n if (elt_out == 0) {\n // Done\n } else if (elt_x == 0) {\n v_out[i] = 0;\n } else if (elt_out == na) {\n // Done\n } else if (elt_x == na) {\n v_out[i] = na;\n }\n }\n return v_out;\n}\n\n\nint* filter2( const size_t size,\n int v_out[size],\n const int v_x[size]\n )\n{\nfor (int i = 0; i < size; ++i) {\n int elt_out = v_out[i];\n\n if (elt_out == 0) {\n continue;\n }\n\n int elt_x = v_x[i];\n\n if (elt_x == 0) {\n v_out[i] = 0;\n } else if (elt_out == na) {\n // Done\n } else if (elt_x == na) {\n v_out[i] = na;\n }\n}\n return v_out;\n}\nRun Code Online (Sandbox Code Playgroud)\n此处的选项 1filter1在 Clang 15 上编译为矢量化循环。(GCC 12 对此有问题。)此处的循环体编译为:
.LBB0_8: # =>This Inner Loop Header: Depth=1\n vmovdqu ymm3, ymmword ptr [r10 + 4*rsi - 32]\n vmovdqu ymm4, ymmword ptr [rdx + 4*rsi]\n vpcmpeqd ymm5, ymm3, ymm0\n vpcmpeqd ymm6, ymm4, ymm0\n vpxor ymm7, ymm6, ymm1\n vpcmpgtd ymm3, ymm3, ymm2\n vpcmpeqd ymm4, ymm4, ymm2\n vpand ymm3, ymm3, ymm4\n vpandn ymm4, ymm5, ymm6\n vpandn ymm5, ymm5, ymm7\n vpand ymm3, ymm5, ymm3\n vpand ymm5, ymm3, ymm2\n vpor ymm3, ymm3, ymm4\n vpmaskmovd ymmword ptr [r10 + 4*rsi - 32], ymm3, ymm5\n vmovdqu ymm3, ymmword ptr [r10 + 4*rsi]\n vmovdqu ymm4, ymmword ptr [rdx + 4*rsi + 32]\n vpcmpeqd ymm5, ymm3, ymm0\n vpcmpeqd ymm6, ymm4, ymm0\n vpxor ymm7, ymm6, ymm1\n vpcmpgtd ymm3, ymm3, ymm2\n vpcmpeqd ymm4, ymm4, ymm2\n vpand ymm3, ymm3, ymm4\n vpandn ymm4, ymm5, ymm6\n vpandn ymm5, ymm5, ymm7\n vpand ymm3, ymm5, ymm3\n vpand ymm5, ymm3, ymm2\n vpor ymm3, ymm3, ymm4\n vpmaskmovd ymmword ptr [r10 + 4*rsi], ymm3, ymm5\n add rsi, 16\n add r9, -2\n jne .LBB0_8\nRun Code Online (Sandbox Code Playgroud)\n因此,我们看到编译器将循环优化为一系列SIMD比较(vpcmpeqd指令),以生成一个位掩码,然后使用该位掩码进行条件移动vpmaskmovd。这看起来比实际情况更复杂,因为它\xe2\x80\x99s部分展开以在每次迭代中进行两次连续更新。
您会注意到,除了循环底部测试我们是否位于数组末尾之外,没有任何分支。然而,由于条件移动,我们有时会在加载或存储时出现缓存未命中。这就是我认为在我的测试中有时会发生的情况。
\n现在让\xe2\x80\x99s 看看选项2:
\n.LBB1_8: # =>This Inner Loop Header: Depth=1\n vmovdqu ymm3, ymmword ptr [r10 + 4*rsi - 32]\n vpcmpeqd ymm4, ymm3, ymm0\n vpxor ymm5, ymm4, ymm1\n vpmaskmovd ymm5, ymm5, ymmword ptr [r11 + 4*rsi - 32]\n vpcmpeqd ymm6, ymm5, ymm0\n vpxor ymm7, ymm6, ymm1\n vpcmpgtd ymm3, ymm3, ymm2\n vpcmpeqd ymm5, ymm5, ymm2\n vpand ymm3, ymm3, ymm5\n vpandn ymm5, ymm4, ymm6\n vpandn ymm4, ymm4, ymm7\n vpand ymm3, ymm4, ymm3\n vpand ymm4, ymm3, ymm2\n vpor ymm3, ymm3, ymm5\n vpmaskmovd ymmword ptr [r10 + 4*rsi - 32], ymm3, ymm4\n vmovdqu ymm3, ymmword ptr [r10 + 4*rsi]\n vpcmpeqd ymm4, ymm3, ymm0\n vpxor ymm5, ymm4, ymm1\n vpmaskmovd ymm5, ymm5, ymmword ptr [r11 + 4*rsi]\n vpcmpeqd ymm6, ymm5, ymm0\n vpxor ymm7, ymm6, ymm1\n vpcmpgtd ymm3, ymm3, ymm2\n vpcmpeqd ymm5, ymm5, ymm2\n vpand ymm3, ymm3, ymm5\n vpandn ymm5, ymm4, ymm6\n vpandn ymm4, ymm4, ymm7\n vpand ymm3, ymm4, ymm3\n vpand ymm4, ymm3, ymm2\n vpor ymm3, ymm3, ymm5\n vpmaskmovd ymmword ptr [r10 + 4*rsi], ymm3, ymm4\n add rsi, 16\n add r9, -2\n jne .LBB1_8\nRun Code Online (Sandbox Code Playgroud)\n该编译器上的代码类似,但稍长一些。一个区别是向量的条件移动v_x。
不过,那是有了-march=x86-64-v3。如果你不告诉它\xe2\x80\x99s允许使用AVX2指令,例如vpmaskmovd,Clang 15.0.0 将完全放弃对该版本算法的向量化。
为了进行比较,我们可以重构此代码,利用更新后的值v_out[i]始终等于 或 的v_out[i]事实v_x[i]:
int* filter3( const size_t size,\n int v_out[size],\n const int v_x[size]\n )\n{\n for ( size_t i = 0; i < size; ++i) {\n const int elt_out = v_out[i];\n const int elt_x = v_x[i];\n\n v_out[i] = (elt_out == 0) ? elt_out :\n (elt_x == 0) ? elt_x :\n (elt_out == na) ? elt_out :\n (elt_x == na) ? elt_x :\n elt_out;\n }\n return v_out;\n}\nRun Code Online (Sandbox Code Playgroud)\n这给我们带来了一些非常不同的代码:
\n.LBB2_7: # =>This Inner Loop Header: Depth=1\n vmovdqu ymm6, ymmword ptr [rax + 4*rsi]\n vmovdqu ymm4, ymmword ptr [rax + 4*rsi + 32]\n vmovdqu ymm3, ymmword ptr [rax + 4*rsi + 64]\n vmovdqu ymm2, ymmword ptr [rax + 4*rsi + 96]\n vmovdqu ymm7, ymmword ptr [rdx + 4*rsi]\n vmovdqu ymm8, ymmword ptr [rdx + 4*rsi + 32]\n vmovdqu ymm9, ymmword ptr [rdx + 4*rsi + 64]\n vmovdqu ymm5, ymmword ptr [rdx + 4*rsi + 96]\n vpcmpeqd ymm10, ymm6, ymm0\n vpcmpeqd ymm11, ymm4, ymm0\n vpcmpeqd ymm12, ymm3, ymm0\n vpcmpeqd ymm13, ymm2, ymm0\n vpcmpeqd ymm14, ymm7, ymm0\n vpor ymm10, ymm10, ymm14\n vpcmpeqd ymm14, ymm8, ymm0\n vpor ymm11, ymm11, ymm14\n vpcmpeqd ymm14, ymm9, ymm0\n vpor ymm12, ymm12, ymm14\n vpcmpeqd ymm14, ymm5, ymm0\n vpcmpeqd ymm7, ymm7, ymm1\n vblendvps ymm7, ymm6, ymm1, ymm7\n vpor ymm13, ymm13, ymm14\n vpcmpeqd ymm6, ymm6, ymm1\n vpandn ymm6, ymm10, ymm6\n vpandn ymm7, ymm10, ymm7\n vpcmpeqd ymm8, ymm8, ymm1\n vblendvps ymm8, ymm4, ymm1, ymm8\n vpcmpeqd ymm4, ymm4, ymm1\n vpcmpeqd ymm9, ymm9, ymm1\n vblendvps ymm9, ymm3, ymm1, ymm9\n vpandn ymm4, ymm11, ymm4\n vpandn ymm8, ymm11, ymm8\n vpcmpeqd ymm3, ymm3, ymm1\n vpandn ymm3, ymm12, ymm3\n vpandn ymm9, ymm12, ymm9\n vpcmpeqd ymm5, ymm5, ymm1\n vblendvps ymm5, ymm2, ymm1, ymm5\n vpcmpeqd ymm2, ymm2, ymm1\n vpandn ymm2, ymm13, ymm2\n vpandn ymm5, ymm13, ymm5\n vblendvps ymm6, ymm7, ymm1, ymm6\n vblendvps ymm4, ymm8, ymm1, ymm4\n vblendvps ymm3, ymm9, ymm1, ymm3\n vblendvps ymm2, ymm5, ymm1, ymm2\n vmovups ymmword ptr [rax + 4*rsi], ymm6\n vmovups ymmword ptr [rax + 4*rsi + 32], ymm4\n vmovups ymmword ptr [rax + 4*rsi + 64], ymm3\n vmovups ymmword ptr [rax + 4*rsi + 96], ymm2\n add rsi, 32\n cmp r11, rsi\n jne .LBB2_7\nRun Code Online (Sandbox Code Playgroud)\n虽然这看起来更长,但每次迭代都会更新四个向量,并且实际上将v_out和v_x向量与位掩码混合。该循环的 GCC 12.2 版本遵循类似的逻辑,每次迭代更新一次,因此更加简洁:
.L172:\n vmovdqu ymm3, YMMWORD PTR [rcx+rax]\n vpcmpeqd ymm0, ymm2, YMMWORD PTR [rsi+rax]\n vpcmpeqd ymm1, ymm3, ymm2\n vpcmpeqd ymm6, ymm3, ymm4\n vpcmpeqd ymm0, ymm0, ymm2\n vpcmpeqd ymm1, ymm1, ymm2\n vpand ymm0, ymm0, ymm1\n vpcmpeqd ymm1, ymm4, YMMWORD PTR [rsi+rax]\n vpor ymm1, ymm1, ymm6\n vpand ymm6, ymm0, ymm1\n vpandn ymm1, ymm1, ymm0\n vpxor ymm0, ymm0, ymm5\n vpblendvb ymm0, ymm3, ymm2, ymm0\n vpblendvb ymm0, ymm0, ymm3, ymm1\n vpblendvb ymm0, ymm0, ymm4, ymm6\n vmovdqu YMMWORD PTR [rcx+rax], ymm0\n add rax, 32\n cmp rdx, rax\n jne .L172\nRun Code Online (Sandbox Code Playgroud)\n正如您所看到的,这与每次迭代执行一次更新的 1 和 3 的汇总版本大致一样严格,但某些优化器似乎在这方面遇到的问题较少。类似的版本,其代码主要不同之处在于寄存器分配,将是:
\nint* filter4( const size_t size,\n int v_out[size],\n const int v_x[size]\n )\n{\n for ( size_t i = 0; i < size; ++i) {\n const int elt_out = v_out[i];\n const int elt_x = v_x[i];\n\n v_out[i] = (elt_out == 0) ? 0 :\n (elt_x == 0) ? 0 :\n (elt_out == na) ? na :\n (elt_x == na) ? na :\n elt_out;\n }\n return v_out;\n}\nRun Code Online (Sandbox Code Playgroud)\n似乎发生的情况是,您的编译器能够根据您使用的设置对版本 1 进行矢量化,但不能对版本 2 进行矢量化。如果它可以对两者进行矢量化,那么它们的性能类似。
\n到 2022 年,具有积极优化设置的编译器可以将任何这些循环转换为矢量化无分支代码,至少在启用 AVX2 的情况下是如此。如果这样做,第二个版本就如您所想,能够v_x有条件地加载。(当你初始化为全零时,这确实会导致一个很大的可观察到的差异v_out。)2022 年的编译器似乎在使用版本 3 和 4 的单个赋值语句方面比版本 1 和 2 的块做得更好。if它们在某些目标和设置上进行矢量化其中 1 和 2 没有,即使所有四个都这样做,Clang 15.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\xe2\x80\x99 和 Karl Knechtel\ 的答案中所示。在这里,我想进行比较。
\n在我的测试中,哪个版本更快很大程度上取决于输入值的初始化值。使用相同的随机种子,filter1比其他三个稍快,但使用真正随机的数据,四个中的任何一个都可能更快。