性能方面,按位运算符与正常模数的速度有多快

Performance wise, how fast are Bitwise Operators vs. Normal Modulus?

本文关键字:常模数 速度 方面 运算符 性能      更新时间:2023-10-16

在正常流中使用按位运算或条件语句(如 forif 等(是否会提高整体性能,在可能的情况下使用它们会更好吗?例如:

if(i++ & 1) {
}

与。

if(i % 2) {
}

除非你使用的是古老的编译器,否则它已经可以自己处理这种级别的转换。也就是说,现代编译器可以并且将使用按位AND指令实现i % 2,前提是在目标 CPU 上这样做是有意义的(公平地说,它通常会这样做(。

换句话说,不要指望看到它们之间的性能有任何差异,至少对于一个相当现代的编译器和一个相当称职的优化器。在这种情况下,"合理地"也有一个相当广泛的定义 - 即使是相当多的几十年前的编译器也可以毫无困难地处理这种微优化。

TL;DR 首先针对语义编写,其次优化测量的热点。

在 CPU 级别,整数模数和除法是最慢的操作之一。但是你不是在 CPU 级别编写,而是用 C++ 编写,编译器将其转换为中间表示,最终根据您正在编译的 CPU 模型转换为汇编。

在此过程中,编译器将应用窥视孔优化,其中包括强度降低优化,例如(由维基百科提供(:

Original Calculation  Replacement Calculation
y = x / 8             y = x >> 3
y = x * 64            y = x << 6
y = x * 2             y = x << 1
y = x * 15            y = (x << 4) - x

最后一个例子也许是最有趣的一个例子。虽然乘以或除以 2 的幂很容易(手动(转换为位移运算,但编译器通常被教导执行更聪明的转换,您可能会自己考虑并且不那么容易识别(至少,我个人不会立即认识到(x << 4) - x意味着x * 15(。

这显然取决于 CPU,但您可以预期按位运算永远不会花费更多(通常需要更少的 CPU 周期(来完成。 通常,整数/%是出了名的慢,就像 CPU 指令一样。 也就是说,现代 CPU 管道具有更早完成的特定指令并不意味着您的程序必须运行得更快。

最佳做法是编写可理解、可维护且能够表达其实现的逻辑的代码。 这种微优化产生切实差异的情况极为罕见,因此只有在分析表明存在关键瓶颈并且被证明会产生显着差异时才应使用它。 此外,如果在某些特定平台上它确实产生了显着差异,则编译器优化器可能已经在替换按位运算,当它可以看到这是等效的(这通常需要您按常量/ -ing 或 % -ing(。

无论它的价值如何,特别是在 x86 指令上 - 当除数是一个运行时变量值时,因此不能简单地优化为例如位移或按位 AND,可以在此处查找 CPU 周期中/%操作所花费的时间。 有太多的x86兼容芯片在这里列出,但作为最近CPU的任意示例 - 如果我们采用Agner的"Sunny Cove (Ice Lake("(即第10代Intel Core(数据,DIV和IDIV指令的延迟在12到19个周期之间,而按位-AND有1个周期。 在许多较旧的 CPU 上,DIV 可能会差 40-60 倍。

默认情况下,应使用最能表达预期含义的操作,因为应针对可读代码进行优化。(今天大多数时候,最稀缺的资源是人类程序员。

因此,如果您提取位,请使用&,如果您测试可分性,请使用%,即值是偶数还是奇数。

对于无符号值,这两个操作具有完全相同的效果,并且编译器应该足够智能,可以将除法替换为相应的位操作。 如果您担心,可以检查它生成的汇编代码。

不幸的是,整数除法在有符号值上略有不规则,因为它四舍五入到零,% 的结果会根据第一个操作数改变符号。另一方面,位操作总是向下舍入。 因此,编译器不能只用简单的位运算替换除法。相反,它可以调用整数除法例程,或者用具有附加逻辑的位运算替换它来处理不规则性。这可能取决于优化级别以及哪些操作数是常量。

这种零时的不规则性甚至可能是一件坏事,因为它是非线性的。例如,我最近遇到一个案例,我们对ADC的有符号值使用除法,这在ARM Cortex M0上必须非常快。在这种情况下,最好用右移代替它,既是为了性能,也是为了摆脱非线性。

C 运算符不能在"性能"的温度下进行有意义的比较。在语言级别上没有"更快"或"更慢"的运算符。只能分析结果编译的机器代码的性能。在您的特定示例中,生成的机器代码通常完全相同(如果我们忽略第一个条件出于某种原因包含后缀增量的事实(,这意味着性能不会有任何差异。

下面是编译器 (GCC 4.6( 为这两个选项生成的优化 -O3 代码:

int i = 34567;
int opt1 = i++ & 1;
int opt2 = i % 2;

为 opt1 生成的代码:

l     %r1,520(%r11)
nilf  %r1,1
st    %r1,516(%r11)
asi   520(%r11),1

为 opt2 生成的代码:

l     %r1,520(%r11)
nilf  %r1,2147483649
ltr   %r1,%r1
jhe  .L14
ahi   %r1,-1
oilf  %r1,4294967294
ahi   %r1,1
.L14: st %r1,512(%r11)

所以 4 个额外的说明...这对于生产环境来说不算什么。这将是一个过早的优化,只会引入复杂性

总是这些关于编译器有多聪明的答案,人们甚至不应该考虑他们的代码的性能,他们不应该敢质疑她的聪明 编译器,等等等等......结果是人们确信每次使用% [SOME POWER OF TWO]编译器都会神奇地将他们的代码转换为& ([SOME POWER OF TWO] - 1)。这根本不是真的。如果共享库具有此功能:

int modulus (int a, int b) {
    return a % b;
}

并且程序启动modulus(135, 16)编译后的代码中不会有任何按位魔法的痕迹。原因何在?编译器很聪明,但在编译库时没有水晶球。它看到一个通用的模数计算,没有任何关于只涉及 2 的幂这一事实的信息,它就这样离开了。

但是你可以知道是否只有 2 的幂会传递给函数。如果是这种情况,优化代码的唯一方法是将函数重写为

unsigned int modulus_2 (unsigned int a, unsigned int b) {
    return a & (b - 1);
}

编译器无法为您执行此操作。

按位运算要快得多。这就是编译器将为您使用按位运算的原因。实际上,我认为将其实现为:

~i & 1

同样,如果您查看编译器生成的汇编代码,您可能会看到类似 x ^= x 而不是 x=0 的内容。但是(我希望(你不会在你的C++代码中使用它。

总之,做你自己,以及任何需要维护你的代码的人,帮个忙。使您的代码可读,并让编译器进行这些微优化。它会做得更好。