为什么这个看似速度较慢的C循环实际上比其他方法快两倍?

61
我是一名使用C语言进行算法编程的R开发者,有一个关于为什么一个看起来会很慢的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语言中的&&,但与除false以外的任何内容组合时,na值会传播。在这种情况下,我们“知道”&&永远不可能为真,因此返回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_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核Intel i9-8950HK(6核Coffee Lake)。

  • 我测试使用的特定Clang版本是Apple clang version 13.1.6 (clang-1316.0.21.2.5),目标为x86_64-apple-darwin21.6.0

  • R限制了我使用int作为数据类型(即使有更有效的选项),并且以下编码:false = 0true = 1na = INT_MIN。我提供的可重现示例遵守此规定。

  • 原始问题实际上并不是要求使代码运行更快。我只是想了解我的两种if/else方法之间的差异。尽管如此,一些答案表明无分支方法可以更快,我非常感谢那些用户提供的解释!这极大地影响了我正在工作的实现的最终版本。


1
评论不适合进行深入的讨论;这个对话已经被移动到聊天室了(https://chat.stackoverflow.com/rooms/248366/discussion-on-question-by-davis-vaughan-why-is-this-seemingly-slower-c-loop-actu)。 - Samuel Liew
5个回答

70
如果你想要快速的向量化代码,就不要使用短路运算并且尽量避免使用分支语句。你需要让编译器能够使用SIMD操作一次处理16或32个元素,使用8位元素。(如果可以安全地无条件执行工作(包括解引用),并且没有副作用,则编译器可以将if转换为无分支代码。这称为if-conversion,对于这种代码的自动向量化通常是必要的。)
同时,你也不希望编译器担心不能访问某些内存,因为C抽象机制不允许。例如,如果所有v_out[i]元素都为false,则v_x可能是一个空指针,而不会导致UB!所以编译器不能发明读取C逻辑根本没有读取的对象的访问。
如果v_x真的是一个数组,而不仅仅是一个指针,那么编译器将知道它是可读的,并且将被允许通过将短路逻辑进行if-conversion转换为无分支来发明访问。但是,如果其成本启发式算法看不到真正的好处(如自动向量化),它可能选择不这样做。在实践中,混合真值和假值(以及NAs)的随机分支代码通常会更慢。
正如你可以在编译器的汇编输出(Clang 15 -O2 on Compiler Explorer)中看到的那样,选项1使用SIMD自动向量化,以无分支方式并行处理4个可选布尔值(仅使用SSE2,使用-march=native将有更多选择)。(感谢评论中的Richard提供Compiler Explorer链接;这可能反映了Apple Clang对于main中实际代码的处理。)
你的3状态布尔类型可以支持NA状态,可以用2位实现,在位运算AND操作时使用。 您可以将其存储为每个unsigned char的一种数组,或者每个字符中压缩4个以使向量化操作的吞吐量增加四倍,但访问速度会变慢。(或通常情况下是每个charCHAR_BIT / 2,但在x86的主流C实现中,这是4)。
  • F = 00
  • N = 10(二进制,所以C中的0b10也就是2
  • T = 11
  • 使用val & 1将其转换为bool
  • 使用0b11 * b将其从bool转换,或者使用其他方法广播低位到两个位置。

F & anything = 0,因为F是所有零位。 N&N == N;对于任何位模式,这都是显然的。 “聪明”的部分在于N&T = T&N = N,因为T中的集合位是N的超集。

这也适用于使用按位||| F|N == NF|T == T,因为0|x == x。对于任何相同的输入,x|x == x,所以我们仍然可以正常工作。

N = 0b10在OR时不会设置低位,但在AND时会清除它。


我忘记你说的是C而不是C ++,所以这个简单的类包装(只足以演示一些测试调用程序)可能不相关,但是对于unsigned char * c1,* c2 的纯C中执行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次数据,即使数据已经热缓存在L1d缓存中,低计算密度仍然严重受限于加载/存储操作。

(英特尔的优化手册指出,尽管Skylake的峰值L1d带宽为每个时钟周期2个加载和1个存储(32字节矢量化为96字节),但持续带宽更接近于每个时钟周期84字节。)

即使使用AVX,它仍然可以相对接近每个时钟周期32字节的AND运算。 因此,这是32个NBool &操作,如果每个字节打包4个NBools,则每个时钟周期为128个。

将NBools打包到1位bools的打包位图中可以使用pslld xmm, 7 / pmovmskb来提取每个字节的低位(在将其移位到高位后)。

如果每个字节存储4个,则需要进行一些SIMD位操作才能将其打包到bools中,例如使用vpshufb作为4位LUT,将一对NBools打包到半字节底部的一对bools中,然后组合?或者使用标量BMI2 pext从64位中提取每个其他位,如果您使用Zen 3Haswell或更高版本,则可快速使用pext


@KarlKnechtel:谢谢。不幸的是,我错过了您评论中的细节,即它们有一个由R指定的存储格式,仍然在调用R数据结构上进行此操作,而不是完全在C中进行计算,因此2位选择可能不那么容易。如果他们在从C函数返回之前有多个步骤的数组处理,我猜他们可以将其压缩为字节,最后一步使用无符号饱和度(vpackuswb),使INT_MIN变为UCHAR_MAX,全1,然后可能使用AND和vpshufb进行转换以映射到/从此。 - Peter Cordes
1
@Lundin:我发帖后就考虑过这个问题,但最终决定不这样做。实际问题的答案是前半部分。问题根本没有要求代码,而在C中,这个想法完全是微不足道的,只需对“unsigned char”元素进行&操作即可,无论你如何操作,所以从文本中已经很清楚了。正如我的回答所说,如果您用C编写等效的内容,它将优化相同。如果OP使用R的格式,这甚至可能对他们没有用处,因为它具有巨大的32位布尔值和固定格式的0x80000000,以及低字节中通常的“bool”对象表示。 - Peter Cordes
3
"Option 2 can't be vectorized" 是正确的答案。请在汇编代码中观察:https://godbolt.org/z/dd7aaKxTY - Richard
3
@PeterCordes 这是一个很棒的答案,谢谢你抽出时间来写这个。很抱歉我没有清楚地说明我只能使用int类型表示true = 1false = 0na = INT_MIN。尽管如此,我从中学到了很多! - Davis Vaughan
1
只有在没有启用 AVX 编译代码时,选项 2 才无法进行矢量化。当针对 AVX 架构编译时,clang 可以使用 vpmaskmovd 指令,从而使第二个函数能够进行矢量化,而不会遇到触及内存的问题,因为 vpmaskmovd 会有条件地触及内存。因此,如果启用了 AVX,则性能将大不相同。 - user1937198
显示剩余8条评论

23
为什么这个看起来比较慢的C语言循环实际上比另一种方式快了一倍?
从高层次来看,这是由于您使用的编译器和执行环境的怪异之处。除非数组v_x被声明为volatile,否则编译器可以完全按照相同的方式解释代码的两种变体。
我原本以为第二种选择会更快,因为它避免了在不需要时访问v_x[i]。如果编译器的优化程序判断为真,则它可以利用该判断条件在与第一段代码一起有条件地避免读取v_x[i]。
但是在低层次上,如果编译器生成的代码确实在选项2中有条件地避免读取v_x[i]但在选项1中没有,则您可能观察到选项2中分支预测错误的影响。完全有可能无条件读取v_x[i]比遭受大量关于是否应该读取v_x[i]的分支预测惩罚要更便宜。

其中一个要点是,在现代硬件上,分支可能比人们预期的要昂贵得多,特别是当CPU难以预测分支时。如果可以通过无分支方法执行相同的计算,那么在实践中可能会产生性能上的优势,但通常会牺牲源代码的清晰度。 @KarlKnechtel的答案提供了一个可能的无分支(但用于测试循环条件for非常可预测)的方案来执行您正在尝试执行的计算。


2
我接受了这个答案,因为我认为它涵盖了问题的精神,但我也感谢你和@KarlKnechtel在他的答案中提供的其他关于如何通过完全删除分支来进一步优化此代码的评论!谢谢! - Davis Vaughan
5
如果编译器熟悉 malloc 函数,那么它可以自由地将你代码中的两种变化解释成完全相同的方式。但如果编译器不熟悉 malloc 函数,尽管它可以删除无用的内存访问,但它无法添加它们。请注意,这句话只有在编译器熟悉 malloc 函数的情况下才是完全正确的。 - plugwash
4
如果v_out全部为0且v_x长度为0,那么引入对v_x的读取是否会导致未定义行为呢? - Aaron Dufour
1
@JohnBollinger 对于未定义行为的确切含义的讲座完全错过了我的观点。在您的评论中,您声称访问对象是否是可观察行为的一部分,但是越界读取肯定是可观察的吧?我现在怀疑您只是想说删除读取是可以的,但这不是您所说的。 - Aaron Dufour
1
换句话说,程序中的越界读取不是可观察行为。请参考C17语言规范第5.1.2.3/6段,其中列出了(相当短的)可观察行为列表。如果程序具有产生越界访问的抽象机器语义,则程序的行为是未定义的,但这在此情况下并不相关。 - John Bollinger
显示剩余4条评论

18
请注意,它的工作方式类似于C语言的&&,但是当与除false以外的任何内容组合时,na值会传播,这种情况下我们“知道”&&永远不能为真,因此返回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);
  }
}

如果我们被迫使用INT_MIN来表示N/A值,我们可以先观察其在二进制补码中的形式:只有一个位被设置为1(符号位,在无符号值中是最高位)。因此,我们可以使用该位的值代替 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));
  }
}

所有这些强制类型转换可能并不是必要的,但我认为在进行位操作时使用无符号类型是一个好的习惯。它们最终都应该被优化掉。


1
我被R强制使用0 = false1 = trueINT_MIN = na。这样还能正常工作吗? - Davis Vaughan
请注意,我真的是指逻辑与(&&),而不是按位与(&),特别是在乘法版本中。 - John Bollinger
1
好的,@DavisVaughan,我之前写的是针对NA表示为-1(或实际上任何奇数而不是1)。这个更乱的版本应该适用于NA表示为INT_MIN,就像实际情况一样:v_out[i] = (elt_out && elt_x) * ((elt_out & elt_x) + !(elt_out & elt_x) * INT_MIN); - John Bollinger
1
我进行了编辑以尝试适应这个。 - Karl Knechtel
2
@KarlKnechtel:我认为F=00T=0b11N=0b10更有效率,只需要一个按位与。我刚刚发布了这个答案。它也适用于||作为|的情况,其中F | N == NF | T == T且可交换,并且对于任何相同的输入,x | x == x。此外,在int中存储一个布尔值是巨大的空间浪费(因此会影响内存带宽和SIMD ALU吞吐量)。 - Peter Cordes
显示剩余7条评论

12

让我们来看看这些代码示例在Clang 15.0.0上使用-std=c17 -O3 -march=x86-64-v3编译后会生成什么。其他编译器会生成略微不同的代码;这很挑剔。

将您的代码片段分解成函数,我们得到

#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 的情况。如果你不告诉它可以使用 AVX2 指令,比如 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年,具有激进优化设置的编译器可以将这些循环中的任何一个转换为向量化无分支代码,至少如果启用AVX2的话。如果启用了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和Karl Knechtel的答案一样。在这里,我想进行类似比较。

在我的测试中,哪个版本更快很大程度上取决于输入值的初始化方式。使用与你相同的随机种子,filter1略快于其他三个版本,但对于真正随机化的数据,任何一个版本都可能更快。


1
从汇编代码中了解clang的自动向量化策略,使用-O3 -fno-unroll-loops可能会有所帮助。然后您可以看到SIMD循环体的一个迭代。(Clang的展开选择通常对于性能来说似乎相当合理,尽管在一些不会成为前端吞吐量瓶颈的循环中可能会过于激进,并且只有更少的循环开销才能更好地支持超线程。但是将微小的循环展开4次似乎非常好。) - Peter Cordes
1
@PeterCordes 谢谢,好建议。我的理解是,一个积极的现代优化器已经可以将所有这些循环转换为无分支代码,但是2022年的编译器似乎更善于处理3和4中的单个赋值,而不是if块。 3和4适用于更多的目标,并且展开得更加优化。我没有针对Karl Knechtel版本进行测试,部分原因是它具有不同的行为,但他的版本可能更快。大的收益似乎来自于优化为矢量化的无分支指令,超过该点的微调收益非常有限。 - Davislor
@PeterCordes 很好。我认为你在回答中已经很好地涵盖了这种优化,我没有什么可补充的。我从测试这些变化中学到了一些东西,希望其他人也能从中获得启发。 - Davislor
1
我的答案还没有一个可用于R的版本,可以自动向量化为pcmpgtd / por / pand,这只是在这里的评论中,我还没有在Godbolt上测试过。我的答案只显示了一个不兼容的版本,使用不同的位模式来避免pcmp/por。(因为这是我首先想到的;为R的位模式设计高效的东西更难。) 但是,是的,我的答案确实涵盖了为什么无分支很好,并使优化器的工作变得容易的原因。 - Peter Cordes
@PeterMortensen 我复制并粘贴了输出结果,没有重新格式化它。这样阅读起来会更容易吗? - Davislor
显示剩余7条评论

-4

几乎可以确定,因为硬件预取器在循环1中运作正常,而不是在循环2中。

如果您使用代码分析器,您可能会看到某个位置存在内存延迟。

内存访问延迟比访问本身更昂贵。


1
欢迎来到stackoverflow!请在您的答案中添加代码和解释,以使其更有帮助。https://stackoverflow.com/help/how-to-answer https://stackoverflow.com/tour - ethry
1
请回答问题或使用注释。 - ethry

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接