为什么(a * b!= 0)比Java中的(a!= 0 && b!= 0)快?


412

我正在用Java写一些代码,在某些时候,程序的流程是由两个int变量“ a”和“ b”是否非零(请注意:a和b永远都不为负,以及永远不会在整数溢出范围内)。

我可以用

if (a != 0 && b != 0) { /* Some code */ }

或者

if (a*b != 0) { /* Some code */ }

因为我希望这段代码每次运行可以运行数百万次,所以我想知道哪一个更快。我通过在一个巨大的随机生成的数组上进行比较来进行实验,我也很好奇该数组的稀疏性(数据分数= 0)如何影响结果:

long time;
final int len = 50000000;
int arbitrary = 0;
int[][] nums = new int[2][len];

for (double fraction = 0 ; fraction <= 0.9 ; fraction += 0.0078125) {
    for(int i = 0 ; i < 2 ; i++) {
        for(int j = 0 ; j < len ; j++) {
            double random = Math.random();

            if(random < fraction) nums[i][j] = 0;
            else nums[i][j] = (int) (random*15 + 1);
        }
    }

    time = System.currentTimeMillis();

    for(int i = 0 ; i < len ; i++) {
        if( /*insert nums[0][i]*nums[1][i]!=0 or nums[0][i]!=0 && nums[1][i]!=0*/ ) arbitrary++;
    }
    System.out.println(System.currentTimeMillis() - time);
}

结果表明,如果您希望“ a”或“ b”等于0的时间超过〜3%,a*b != 0则比a!=0 && b!=0

a AND b非零结果的图形图

我很好奇为什么。谁能给我一些启示?是编译器还是在硬件级别?

编辑: 出于好奇...现在我了解了分支预测,我想知道对于OR b 的模拟比较将显示非零值:

a或b的图非零

我们确实看到了与预期的分支预测相同的效果,有趣的是,该图沿X轴略有翻转。

更新资料

1-我添加!(a==0 || b==0)了分析以查看会发生什么。

2 -我也包括在内a != 0 || b != 0(a+b) != 0(a|b) != 0出于好奇,了解分支预测之后。但是它们在逻辑上不等同于其他表达式,因为只有OR b需要为非零才能返回true,因此它们并不意味着要进行处理效率比较。

3-我还添加了用于分析的实际基准,它只是对任意int变量进行迭代。

4-有人建议使用a != 0 & b != 0,而不是a != 0 && b != 0,因为它a*b != 0会消除分支预测的影响,因此预测其行为会更接近。我不知道&可以将其与布尔变量一起使用,我认为它仅用于整数操作。

注意:在我正在考虑所有这些情况的情况下,int溢出不是问题,但这绝对是一般情况下的重要考虑因素。

CPU:英特尔酷睿i7-3610QM @ 2.3GHz

Java版本:1.8.0_45
Java™SE运行时环境(内部版本1.8.0_45-b14)
Java HotSpot™64位服务器VM(内部版本25.45-b02,混合模式)


11
if (!(a == 0 || b == 0))呢 众所周知,微基准测试是不可靠的,这不太可能真正测量到(〜3%听起来对我来说是误差范围)。
Elliott Frisch

9
或者a != 0 & b != 0
Louis Wasserman

16
如果预测的分支错误,则分支速度很慢。a*b!=0少了一个分支
Erwin Bolwidt '16

19
(1<<16) * (1<<16) == 0但两者都不为零。
CodesInChaos '16

13
@Gene:您建议的优化无效。即使忽略溢出,a*b是零,如果一个ab为零; a|b仅当两者都为时为零。
hmakholm在莫妮卡(Monica)

Answers:


240

我忽略了您的基准测试可能存在缺陷的问题,而将结果视为无价之宝。

是编译器还是在硬件级别?

我认为后者:

  if (a != 0 && b != 0)

将编译为2个内存负载和两个条件分支

  if (a * b != 0)

将编译为2个内存负载,一个乘法和一个条件分支。

如果硬件级分支预测无效,则乘法可能比第二个条件分支快。随着比率的增加,分支预测的有效性降低。

条件分支较慢的原因是它们导致指令执行管道停止。分支预测是通过预测分支将要走的路并推测性地选择下一条指令来避免停顿。如果预测失败,则在加载另一个方向的指令时会有延迟。

(注:上面的解释过于简单化为了更准确的解释,你需要看看由CPU制造商汇编语言编码器和编译器作者提供了文献上的维基百科页面。分支预测器是很好的背景。)


但是,此优化需要注意一件事。是否有任何值a * b != 0会给出错误的答案?考虑计算乘积导致整数溢出的情况。


更新

您的图表倾向于证实我所说的话。

  • 在条件分支a * b != 0情况下也有“分支预测”效应,这在图中显示出来。

  • 如果将曲线投影到X轴上超过0.9,则看起来1)它们将在大约1.0处相遇,并且2)交汇点的Y值将与X = 0.0大致相同。


更新2

我不明白为什么曲线对于a + b != 0a | b != 0情况会有所不同。有可能是一些在分支预测逻辑聪明。否则它可能表明其他情况。

(请注意,这种情况可能特定于特定的芯片型号甚至版本。基准测试的结果在其他系统上可能会有所不同。)

然而,它们都具有对所有非负值工作的优势ab


1
@DebosmitRay-1)应该没有软件。中间结果将保存在寄存器中。2)在第二种情况下,有两个可用的分支:一个执行“某些代码”,另一个跳转到之后的下一条语句if
斯蒂芬·C

1
@StephenC您应该对a + b和a | b感到困惑,因为曲线相同的,我认为这是真的很接近的颜色。道歉给盲人!
Maljam '16

3
@ njzk2从概率角度来看,这些情况应根据轴对称于50%(概率为a&b和的零a|b)。他们是(但不是完全)难题。
安东宁Lejsek

3
@StephenC a*b != 0a+b != 0基准不同的原因是因为a+b != 0它根本不等效,并且永远不应该进行基准测试。例如,使用a = 1, b = 0,第一个表达式的计算结果为false,而第二个表达式的计算结果为true。乘法的作用类似于运算符,而加法的作用类似于运算符。
JS1 2016年

2
@AntonínLejsek我认为概率会有所不同。如果您n有零,则两者ab为零的可能性会随增大n。在一个AND操作中,其中n之一为非零的可能性增加,并且满足条件。对于OR操作而言,这是相反的(其中一个为零的概率随增大n)。这是基于数学观点。我不确定这是否就是硬件的工作方式。
所见即所得

70

我认为您的基准测试有一些缺陷,对于推断真实程序可能没有用。这是我的想法:

  • (a|b)!=0(a+b)!=0测试,如果任一值是非零的,而a != 0 && b != 0(a*b)!=0测试,如果两者都是非零的。因此,您不是在比较仅算术运算的时间:如果条件更经常为真,则它会导致执行更多的if主体,这也需要更多的时间。

  • (a+b)!=0 对于总和为零的正值和负值会做错事,因此即使在这里正常工作,也不能在一般情况下使用它。

  • 同样,(a*b)!=0对于溢出的值将做错误的事情。(随机示例:196608 * 327680为0,因为真实结果恰好可以被2 32整除,因此其低32位为0,如果是一次int操作,这些位就可以得到。)

  • VM将在external(fraction)循环的前几次运行中优化表达式,当它fraction为0时,几乎从不使用分支。如果从fraction0.5 开始,优化器可能会做不同的事情。

  • 除非VM能够消除此处的一些数组边界检查,否则表达式中还会有其他四个分支,这仅是由于边界检查所致,这在试图弄清底层情况时是一个复杂的因素。如果将二维数组分成两个平面数组,将nums[0][i]and 更改nums[1][i]为to nums0[i],则可能会得到不同的结果nums1[i]

  • CPU分支预测变量会检测数据中的短模式,或者正在执行或未执行所有分支的运行。对于分支预测器,随机生成的基准数据是最坏的情况。如果现实世界中的数据具有可预测的模式,或者长期运行的全零和全非零值,则分支的成本可能低得多。

  • 满足条件后执行的特定代码可能会影响评估条件本身的性能,因为它会影响循环是否可以展开,哪些CPU寄存器可用以及是否nums需要获取任何取回的值等问题。评估条件后可以重用。仅在基准测试中增加一个计数器对于实际代码所做的并不是一个完美的占位符。

  • System.currentTimeMillis()在大多数系统上,它的准确度不超过+/- 10毫秒。System.nanoTime()通常更准确。

存在许多不确定性,并且总是很难用这些微优化来确定任何东西,因为在一个VM或CPU上更快的技巧在另一个VM或CPU上更快。如果运行的是32位HotSpot JVM(而不是64位版本),请注意它有两种形式:“客户端” VM与“服务器” VM相比具有不同(较弱)的优化。

如果您可以反汇编VM生成的机器代码,请执行此操作,而不要尝试猜测它的作用!


24

尽管我的想法可能会有所改善,但这里的答案还是不错的。

由于两个分支和关联的分支预测可能是罪魁祸首,因此我们可以将分支减少到单个分支,而根本不更改逻辑。

bool aNotZero = (nums[0][i] != 0);
bool bNotZero = (nums[1][i] != 0);
if (aNotZero && bNotZero) { /* Some code */ }

它可能也可以做

int a = nums[0][i];
int b = nums[1][i];
if (a != 0 && b != 0) { /* Some code */ }

原因是,根据短路规则,如果第一个布尔值为假,则不应评估第二个布尔值。它必须执行一个额外的分支以避免评估nums[1][i]是否nums[0][i]为假。现在,您可能并不在乎是否要nums[1][i]对其求值,但是编译器无法确定这样做时不会抛出超出范围或null的引用。通过将if块简化为简单的布尔值,编译器可能足够聪明,以至于意识到不必要地评估第二个布尔值不会产生负面影响。


3
尽管我感觉这并不能完全回答问题,但还是提出了建议。
皮埃尔·阿洛德

3
这是在不改变非分支逻辑的情况下引入分支的一种方法(如果您获得ab具有副作用,您将保留它们)。您仍然有,&&所以您仍然有一个分支。
乔恩·汉纳

11

当我们进行乘法运算时,即使一个数字为0,乘积也为0。

    (a*b != 0)

它评估乘积的结果,从而消除了从0开始的前几次迭代。结果,与条件为

   (a != 0 && b != 0)

每个元素都与0进行比较并进行评估。因此所需的时间更少。但我相信第二种情况可能会为您提供更准确的解决方案。


4
在第二个表达式中,如果a为零,则b由于整个表达式已经为false,因此无需评估。因此,每个元素进行比较都是不正确的。
库巴Wyrostek

9

您正在使用随机输入数据,这会使分支变得不可预测。实际上,分支通常是可预测的(〜90%),因此在实际代码中,分支代码可能会更快。

那就是。我看不出a*b != 0有比快(a|b) != 0。通常,整数乘法比按位OR昂贵。但是像这样的事情有时会变得很奇怪。例如,请参阅“ 处理器高速缓存效果库 ”中的“示例7:硬件复杂性”示例。


2
&不是“按位或”,而是(在这种情况下)是“逻辑与”,因为两个操作数都是布尔值,并且不是|;-)
siegi

1
@siegi TIL Java'&'实际上是一个逻辑AND,没有短路。
StackedCrooked
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.