我是一名将C用于算法目的的R开发人员,我有一个问题,为什么看起来很慢的C循环实际上比其他方法更快。
在R中,我们的布尔类型实际上可以有3个值,true
、false
和na
,我们在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_out
和v_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方法之间的区别。也就是说,一些答案表明无分支方法可以快得多,我真的很感谢那些用户提供的解释!这极大地影响了我正在开发的实现的最终版本。
请注意,它的工作原理如下
与其将值表示为严格的枚举,不如允许2
或3
的数值来表示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));
}
}
(所有这些强制转换可能不是必需的,但我认为使用无符号类型进行按位操作是一种很好的做法。无论如何,它们都应该得到优化。)
为什么这个看起来较慢的C循环实际上是另一种方式的两倍?
从更高的层次上讲,这是您所使用的编译器和执行环境的一种怪癖。除非arrayv_x
被声明为volatile
,否则编译器可以以完全相同的方式解释代码的两种变体。
我有点希望第二个选项更快,因为它避免了在不需要时访问v_x[i]
。
如果编译器的优化器判断为真,那么它可以利用这一判断有条件地避免与第一个代码一起读取< code>v_x[i]。
但在较低的层次上,如果编译器生成的代码确实有条件地避免读取选项2中的v_x[i]
,而不是选项1中的,那么您可能在选项2中观察到了分支预测失误的影响。完全可能的是,无条件读取v_x[i]
比遭受大量涉及是否应该读取的分支预测失误惩罚要便宜。
其中一个要点是,在现代硬件上,分支可能比人们预期的要贵得多,尤其是当分支对CPU来说难以预测时。如果可以通过无分支方法执行相同的计算,这可能会在实践中产生性能优势,通常会以源代码清晰度为代价。@KarlKnechtel的答案提出了您尝试执行的计算的可能无分支(但用于测试for
循环条件,这是非常可预测的)变化。
如果您想要快速矢量化代码,请不要进行短路评估,也不要进行分支。您希望编译器能够使用8位元素,通过SIMD操作一次处理16或32个元素。(if
s可以转换为无分支代码,前提是无条件执行工作是安全的,包括取消引用。)
你也不希望编译器担心不允许接触一些内存,因为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
(二进制,因此C0b10
aka2
)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,比如C
std::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一下;但是这也打破了做而循环,我不想要。 这是我目前为止的代码: 谢谢你的帮助