当前位置: 首页 > 知识库问答 >
问题:

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

司徒运锋
2023-03-14

我是一名将C用于算法目的的R开发人员,我有一个问题,为什么看起来很慢的C循环实际上比其他方法更快。

在R中,我们的布尔类型实际上可以有3个值,truefalsena,我们在C级别使用int表示这一点。

我正在研究矢量化

 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

请注意,它的工作原理如下

现在到实现,假设我们有两个向量v_outv_x,我们想执行矢量化的

// 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上,并用叮当声编译。

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

更新:基于评论中的几个问题,以下是对未来读者的几点澄清:

> < li>

我使用的是2018款15英寸Macbook Pro,配有2.9 GHz 6核英特尔酷睿i9处理器。

我用Apple clang版本13.1.6(clang-1316.0.21.2.5)目标:x86_64-Apple-darwin21.6.0

我受到R的限制,只能使用int作为数据类型(即使有更有效的选项)和以下编码:false=0 na=int_MIN 。我提供的可复制示例尊重这一点。

最初的问题实际上并不是请求让代码运行得更快,我只是想知道我的两种if/else方法之间的区别。也就是说,一些答案表明无分支方法可以快得多,我真的很感谢那些用户提供的解释!这极大地影响了我正在开发的实现的最终版本。


共有3个答案

邵阳德
2023-03-14

请注意,它的工作原理如下

与其将值表示为严格的枚举,不如允许23的数值来表示na(您可以在显示时检查这一点,或者在所有数字处理之后进行规范化步骤)。这样,不需要条件逻辑(因此也不需要昂贵的分支预测):我们只是逻辑-或位于2s位置的位(无论运算符如何),以及逻辑-和(或任何运算符)位于1s位置的位。

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值,我们可以从观察2s补码中的情况开始:它只有一个位集(符号位,在无符号值中最重要)。因此,我们可以使用该位值而不是具有相同无条件逻辑的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));
  }
}

(所有这些强制转换可能不是必需的,但我认为使用无符号类型进行按位操作是一种很好的做法。无论如何,它们都应该得到优化。)

訾高飞
2023-03-14
匿名用户

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

从更高的层次上讲,这是您所使用的编译器和执行环境的一种怪癖。除非arrayv_x被声明为volatile,否则编译器可以以完全相同的方式解释代码的两种变体。

我有点希望第二个选项更快,因为它避免了在不需要时访问v_x[i]

如果编译器的优化器判断为真,那么它可以利用这一判断有条件地避免与第一个代码一起读取< code>v_x[i]。

但在较低的层次上,如果编译器生成的代码确实有条件地避免读取选项2中的v_x[i],而不是选项1中的,那么您可能在选项2中观察到了分支预测失误的影响。完全可能的是,无条件读取v_x[i]比遭受大量涉及是否应该读取的分支预测失误惩罚要便宜。

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

左丘智渊
2023-03-14
匿名用户

如果您想要快速矢量化代码,请不要进行短路评估,也不要进行分支。您希望编译器能够使用8位元素,通过SIMD操作一次处理16或32个元素。(ifs可以转换为无分支代码,前提是无条件执行工作是安全的,包括取消引用。)

你也不希望编译器担心不允许接触一些内存,因为C抽象机不允许。例如,如果所有< code>v_out[i]元素都为false,则< code>v_x可能是一个空指针,而不会导致UB!所以编译器不能发明对C逻辑根本不读的对象的读访问。

如果v_x真的是一个数组,而不仅仅是一个指针,编译器就会知道它是可读的,并被允许通过将短路逻辑转换为无分支来发明对它的访问。但是如果它的成本启发式没有看到真正的大好处(如自动矢量化),它可能会选择不这样做。在实践中,由于true和false(以及NAs)的随机混合,布兰奇代码通常会更慢。

正如您在编译器的ASM输出中看到的那样(gobolt上的clang 15-O2),选项1使用SIMD自动矢量化,并行无分支处理4个可选布尔值(仅使用SSE2,更多使用-mar=本机)。(感谢评论中的@Richard精心制作了gobolt链接;这可能反映了Apple Clang将对main中的真实代码做什么。)

支持NA状态的3状态bool可以用2位实现,按位AND执行<代码>

  • F=00
  • N=10(二进制,因此C0b10aka2
  • T=11
  • 使用val转换为布尔值

<代码>F

这也适用于按位||
F|N==N 。同样,对于任何相同的输入, x|x==x也可以,所以我们仍然可以。

“或”运算时,< code>N = 0b10不会设置低位,但“与”运算时会将其清零。

我忘了您说的是C而不是C,所以这个基本类包装器(仅足以演示几个测试调用方)可能不相关,而是一个执行c1[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,比如Cstd::optional,它是一个可能存在也可能不存在的值。)

这可以在gobolt上使用GCC和clang进行编译。Clang自动向量化相当好,使用展开的循环执行vandps。(clang的选择有点奇怪;在-mar=haswell上,vpand具有更好的吞吐量。)但无论如何仍然受到1/时钟存储和2/时钟负载的限制;即使数据在L1d缓存中很热,这也非常阻碍了计算强度如此低的load/store。

(英特尔的优化手册称,尽管Skylake的峰值L1d带宽是每时钟2次加载1次存储(96字节,32字节向量),但持续带宽更像是每时钟84字节)

使用AVX,它仍然可以相对接近每时钟周期32字节的AND。所以是32 NBool

可以使用pslld-xmm,7/pmovmskb将NBools压缩为1位bool的压缩位图,以提取每个字节的低位(将其移到高位后)。

如果每个字节存储4个,则一些SIMD位操作是为了打包为布尔值,也许vpshufb作为4位LUT,将一对NBool打包为半字节底部的一对布尔值,然后组合?或者使用标量BMI2pext从64位中每隔一位提取一次,如果您在Zen3或Haswell或更高版本上,则用于快速pext

 类似资料:
  • 编辑:为什么在局部变量上这么快?(~16秒进行相同的迭代,但对函数内部的局部变量进行迭代)

  • 这是一个交集方法,在该方法中,我应该返回一个包含列表和中所有相同元素的列表,但该程序正在无限循环,并且该方法中唯一的循环可以无限循环,所以我不明白它为什么不工作。 和方法工作非常好。 我更新了代码,我认为它会起作用,但现在在toString方法的行中出现了另一个错误。这是一个空指针异常,但为什么它是一个错误?

  • 很长一段时间以来,我一直认为C比JavaScript快。然而,今天我制作了一个基准脚本来比较两种语言的浮点计算速度,结果令人惊叹! JavaScript似乎比C快近4倍! 我让这两种语言在我的i5-430M笔记本电脑上做同样的工作,执行了100000000次。C需要大约410毫秒,而JavaScript只需要大约120毫秒。 我真的不知道为什么JavaScript在这种情况下运行得这么快。有人能解

  • 我发现这样的php代码: 我希望这个循环会执行4次,因为$I变成了对$的引用(对吗?)。然而,循环只执行一次,并输出: a=10,i=10 我不明白为什么它会这样工作。有什么想法吗?

  • 我需要在向量中找到max元素,所以我使用了,但我发现它是一个非常慢的函数,所以我编写了自己的版本,并设法获得更好的x3性能,下面是代码: 输出: 平均而言,要比多花费x3个时间。那么为什么我能够这么容易地创建一个更快的std函数呢?既然std这么慢,我是不是应该停止使用std并编写自己的函数呢? 注意:一开始我以为这是因为我在for循环中使用了andinteger而不是迭代器,但现在看来这并不重要

  • 尝试编写一个程序,其中用户输入他们一周的费用。麻烦在于我希望程序重新询问用户是否输入了不可接受的输入。我想出了如何检测负值的方法,但是当尝试捕获输入不匹配异常(如果像输入字符或字符串一样)时,循环只是无限运行,要求“星期一费用:”我如何使它,以便用户有另一个回答的机会?我试着Rest一下;但是这也打破了做而循环,我不想要。 这是我目前为止的代码: 谢谢你的帮助