为什么这种看似较慢的C循环实际上是另一种方式的两倍快?

u2nhd7ah  于 2023-01-16  发布在  其他
关注(0)|答案(5)|浏览(150)

我是一名R开发人员,使用C语言进行算法开发,我有一个问题,为什么一个看起来很慢的C循环实际上比另一种方法快。
在R中,我们的布尔类型实际上可以有三个值,truefalsena,我们在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_outv_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_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;
}

基于评论中的几个问题,这里有几点需要澄清,以供未来读者参考:

  • 我在一台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 = 0true = 1na = INT_MIN。我提供的可重现示例考虑到了这一点。
  • 最初的问题实际上并不是要求代码运行得更快。我只是想了解一下我的两种if/else方法之间的区别。也就是说,一些答案表明 branchless 方法可以更快,我真的很感谢那些用户提供的解释!这极大地影响了我正在工作的实现的最终版本。
pdsfdshx

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(二进制,因此C 0b10又称为2
  • T = 11
  • 转换为具有val & 1bool
  • 转换 * 从 * bool0b11 * b或其他东西,以将低位广播到两个位置。

F & anything = 0,因为F是全零位。这对于任何位模式都是成立的,“聪明”的部分是N&T = T&N = N,因为T中的set位是N中的set位的超集。

这也适用于||和按位|F|N == NF|T == T,因为0|x == x。对于任何相同的输入,也是x|x == x,所以我们仍然可以在那里使用。

N = 0b10在执行“或”运算时不会设置低位,但在执行“与”运算时会将其清除。
我忘了你说的是C而不是C++,所以这个基本的类 Package 器(只够演示几个测试调用者)可能不相关,但是在纯C中为unsigned char *c1, *c2执行c1[i] &= c2[i];的循环将以完全相同的方式自动矢量化。

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];
    }
}

(我认为“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 2 pext从64位中每隔一位提取一次,如果您使用Zen 3Haswell或更高版本,用于快速pext

6l7fqoea

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循环条件,这是非常可预测的)变体。

hsvhsicv

hsvhsicv3#

注意,它的工作原理与C中的&&类似,只是na值在与除false之外的任何值组合时都会传播,在这种情况下,我们“知道”&&永远不会为true,所以我们返回false。
与其将值表示为严格的枚举,不如使用23的数值来表示na(您可以在显示时检查这一点,也可以在所有的数字运算之后使用规范化步骤)。这样,就不需要条件逻辑(因此也就不需要昂贵的分支预测):我们简单地对2s位置中的比特进行逻辑OR(与运算符无关),以及对1 s位置中的比特进行逻辑AND(或任何运算符)。

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

如果我们被迫使用INT_MIN来表示N/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));
  }
}

(All这些强制类型转换可能不是必需的,但是我认为使用无符号类型进行位操作是一个很好的实践。2无论如何,它们都应该被优化掉。

suzh9iv8

suzh9iv84#

让我们看看这些代码示例在Clang15.0.0上编译成什么样的代码,其他编译器生成的代码会略有不同;很挑剔。
将代码片段分解为函数,我们得到

#include <limits.h>
#include <stddef.h>

#define na INT_MIN

int* filter1( const size_t size,
              int v_out[size],
              const int v_x[size]
            )
{
  for ( size_t 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;
    }
  }
  return v_out;
}

int* filter2( const size_t size,
              int v_out[size],
              const int v_x[size]
            )
{
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;
  }
}
  return v_out;
}

您的选项1(这里是filter1)在Clang 15上编译为矢量化循环(GCC 12在这方面有问题)。

.LBB0_8:                                # =>This Inner Loop Header: Depth=1
        vmovdqu ymm3, ymmword ptr [r10 + 4*rsi - 32]
        vmovdqu ymm4, ymmword ptr [rdx + 4*rsi]
        vpcmpeqd        ymm5, ymm3, ymm0
        vpcmpeqd        ymm6, ymm4, ymm0
        vpxor   ymm7, ymm6, ymm1
        vpcmpgtd        ymm3, ymm3, ymm2
        vpcmpeqd        ymm4, ymm4, ymm2
        vpand   ymm3, ymm3, ymm4
        vpandn  ymm4, ymm5, ymm6
        vpandn  ymm5, ymm5, ymm7
        vpand   ymm3, ymm5, ymm3
        vpand   ymm5, ymm3, ymm2
        vpor    ymm3, ymm3, ymm4
        vpmaskmovd      ymmword ptr [r10 + 4*rsi - 32], ymm3, ymm5
        vmovdqu ymm3, ymmword ptr [r10 + 4*rsi]
        vmovdqu ymm4, ymmword ptr [rdx + 4*rsi + 32]
        vpcmpeqd        ymm5, ymm3, ymm0
        vpcmpeqd        ymm6, ymm4, ymm0
        vpxor   ymm7, ymm6, ymm1
        vpcmpgtd        ymm3, ymm3, ymm2
        vpcmpeqd        ymm4, ymm4, ymm2
        vpand   ymm3, ymm3, ymm4
        vpandn  ymm4, ymm5, ymm6
        vpandn  ymm5, ymm5, ymm7
        vpand   ymm3, ymm5, ymm3
        vpand   ymm5, ymm3, ymm2
        vpor    ymm3, ymm3, ymm4
        vpmaskmovd      ymmword ptr [r10 + 4*rsi], ymm3, ymm5
        add     rsi, 16
        add     r9, -2
        jne     .LBB0_8

我们可以看到编译器优化了循环,通过一系列SIMD比较(vpcmpeqd指令)生成位掩码,然后使用vpmaskmovd执行条件移动。这看起来比实际情况复杂,因为它部分展开,以便每次迭代执行两次连续更新。
你会注意到,除了循环底部测试我们是否在数组末尾之外,没有分支,然而,由于条件移动,我们有时会在加载或存储时得到缓存缺失,这就是我认为在我的测试中有时会发生的情况。
现在我们来看选项2:

.LBB1_8:                                # =>This Inner Loop Header: Depth=1
        vmovdqu ymm3, ymmword ptr [r10 + 4*rsi - 32]
        vpcmpeqd        ymm4, ymm3, ymm0
        vpxor   ymm5, ymm4, ymm1
        vpmaskmovd      ymm5, ymm5, ymmword ptr [r11 + 4*rsi - 32]
        vpcmpeqd        ymm6, ymm5, ymm0
        vpxor   ymm7, ymm6, ymm1
        vpcmpgtd        ymm3, ymm3, ymm2
        vpcmpeqd        ymm5, ymm5, ymm2
        vpand   ymm3, ymm3, ymm5
        vpandn  ymm5, ymm4, ymm6
        vpandn  ymm4, ymm4, ymm7
        vpand   ymm3, ymm4, ymm3
        vpand   ymm4, ymm3, ymm2
        vpor    ymm3, ymm3, ymm5
        vpmaskmovd      ymmword ptr [r10 + 4*rsi - 32], ymm3, ymm4
        vmovdqu ymm3, ymmword ptr [r10 + 4*rsi]
        vpcmpeqd        ymm4, ymm3, ymm0
        vpxor   ymm5, ymm4, ymm1
        vpmaskmovd      ymm5, ymm5, ymmword ptr [r11 + 4*rsi]
        vpcmpeqd        ymm6, ymm5, ymm0
        vpxor   ymm7, ymm6, ymm1
        vpcmpgtd        ymm3, ymm3, ymm2
        vpcmpeqd        ymm5, ymm5, ymm2
        vpand   ymm3, ymm3, ymm5
        vpandn  ymm5, ymm4, ymm6
        vpandn  ymm4, ymm4, ymm7
        vpand   ymm3, ymm4, ymm3
        vpand   ymm4, ymm3, ymm2
        vpor    ymm3, ymm3, ymm5
        vpmaskmovd      ymmword ptr [r10 + 4*rsi], ymm3, ymm4
        add     rsi, 16
        add     r9, -2
        jne     .LBB1_8

在这个编译器上类似的代码,但是稍微长一些。一个区别是从v_x向量的条件移动。

然而,这是-march=x86-64-v3的情况。如果你不告诉它允许使用AVX 2指令,比如vpmaskmovd,Clang 15.0.0将完全给予矢量化这个版本的算法。

为了进行比较,我们可以利用v_out[i]的更新值将始终等于v_out[i]v_x[i]这一事实来重构此代码:

int* filter3( const size_t size,
              int v_out[size],
              const int v_x[size]
            )
{
  for ( size_t i = 0; i < size; ++i) {
    const int elt_out = v_out[i];
    const int elt_x = v_x[i];

    v_out[i] = (elt_out == 0)  ? elt_out :
               (elt_x == 0)    ? elt_x :
               (elt_out == na) ? elt_out :
               (elt_x == na)   ? elt_x :
                                 elt_out;
  }
  return v_out;
}

这会得到一些非常不同的代码:

.LBB2_7:                                # =>This Inner Loop Header: Depth=1
        vmovdqu ymm6, ymmword ptr [rax + 4*rsi]
        vmovdqu ymm4, ymmword ptr [rax + 4*rsi + 32]
        vmovdqu ymm3, ymmword ptr [rax + 4*rsi + 64]
        vmovdqu ymm2, ymmword ptr [rax + 4*rsi + 96]
        vmovdqu ymm7, ymmword ptr [rdx + 4*rsi]
        vmovdqu ymm8, ymmword ptr [rdx + 4*rsi + 32]
        vmovdqu ymm9, ymmword ptr [rdx + 4*rsi + 64]
        vmovdqu ymm5, ymmword ptr [rdx + 4*rsi + 96]
        vpcmpeqd        ymm10, ymm6, ymm0
        vpcmpeqd        ymm11, ymm4, ymm0
        vpcmpeqd        ymm12, ymm3, ymm0
        vpcmpeqd        ymm13, ymm2, ymm0
        vpcmpeqd        ymm14, ymm7, ymm0
        vpor    ymm10, ymm10, ymm14
        vpcmpeqd        ymm14, ymm8, ymm0
        vpor    ymm11, ymm11, ymm14
        vpcmpeqd        ymm14, ymm9, ymm0
        vpor    ymm12, ymm12, ymm14
        vpcmpeqd        ymm14, ymm5, ymm0
        vpcmpeqd        ymm7, ymm7, ymm1
        vblendvps       ymm7, ymm6, ymm1, ymm7
        vpor    ymm13, ymm13, ymm14
        vpcmpeqd        ymm6, ymm6, ymm1
        vpandn  ymm6, ymm10, ymm6
        vpandn  ymm7, ymm10, ymm7
        vpcmpeqd        ymm8, ymm8, ymm1
        vblendvps       ymm8, ymm4, ymm1, ymm8
        vpcmpeqd        ymm4, ymm4, ymm1
        vpcmpeqd        ymm9, ymm9, ymm1
        vblendvps       ymm9, ymm3, ymm1, ymm9
        vpandn  ymm4, ymm11, ymm4
        vpandn  ymm8, ymm11, ymm8
        vpcmpeqd        ymm3, ymm3, ymm1
        vpandn  ymm3, ymm12, ymm3
        vpandn  ymm9, ymm12, ymm9
        vpcmpeqd        ymm5, ymm5, ymm1
        vblendvps       ymm5, ymm2, ymm1, ymm5
        vpcmpeqd        ymm2, ymm2, ymm1
        vpandn  ymm2, ymm13, ymm2
        vpandn  ymm5, ymm13, ymm5
        vblendvps       ymm6, ymm7, ymm1, ymm6
        vblendvps       ymm4, ymm8, ymm1, ymm4
        vblendvps       ymm3, ymm9, ymm1, ymm3
        vblendvps       ymm2, ymm5, ymm1, ymm2
        vmovups ymmword ptr [rax + 4*rsi], ymm6
        vmovups ymmword ptr [rax + 4*rsi + 32], ymm4
        vmovups ymmword ptr [rax + 4*rsi + 64], ymm3
        vmovups ymmword ptr [rax + 4*rsi + 96], ymm2
        add     rsi, 32
        cmp     r11, rsi
        jne     .LBB2_7

虽然看起来比较长,但每次迭代更新四个向量,实际上是用位掩码混合v_outv_x向量。GCC 12.2版本的循环遵循类似的逻辑,每次迭代更新一次,因此更简洁:

.L172:
        vmovdqu ymm3, YMMWORD PTR [rcx+rax]
        vpcmpeqd        ymm0, ymm2, YMMWORD PTR [rsi+rax]
        vpcmpeqd        ymm1, ymm3, ymm2
        vpcmpeqd        ymm6, ymm3, ymm4
        vpcmpeqd        ymm0, ymm0, ymm2
        vpcmpeqd        ymm1, ymm1, ymm2
        vpand   ymm0, ymm0, ymm1
        vpcmpeqd        ymm1, ymm4, YMMWORD PTR [rsi+rax]
        vpor    ymm1, ymm1, ymm6
        vpand   ymm6, ymm0, ymm1
        vpandn  ymm1, ymm1, ymm0
        vpxor   ymm0, ymm0, ymm5
        vpblendvb       ymm0, ymm3, ymm2, ymm0
        vpblendvb       ymm0, ymm0, ymm3, ymm1
        vpblendvb       ymm0, ymm0, ymm4, ymm6
        vmovdqu YMMWORD PTR [rcx+rax], ymm0
        add     rax, 32
        cmp     rdx, rax
        jne     .L172

正如你所看到的,这大概和1和3的汇总版本一样紧凑,每个迭代更新一次,但是一些优化器似乎没有那么麻烦。一个类似的版本,其代码主要在寄存器分配上有所不同,应该是:

int* filter4( const size_t size,
              int v_out[size],
              const int v_x[size]
            )
{
  for ( size_t i = 0; i < size; ++i) {
    const int elt_out = v_out[i];
    const int elt_x = v_x[i];

    v_out[i] = (elt_out == 0)  ? 0 :
               (elt_x == 0)    ? 0 :
               (elt_out == na) ? na :
               (elt_x == na)   ? na :
                                 elt_out;
  }
  return v_out;
}
外卖

看起来发生的事情是你的编译器能够在你使用的设置上向量化你的版本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比其他三个版本稍快,但使用真正随机化的数据,四个版本中的任何一个都可能更快。

xmq68pz9

xmq68pz95#

几乎可以肯定,因为硬件预取器正好在循环1中工作,而不是在循环2中。
如果您使用代码分析器,您可能会在某处看到内存延迟。
存储器访问的延迟比访问本身更昂贵。

相关问题