关于性能:为什么是(A*B!=0)快于(a!=0&B!= 0)在Java中?

Why is (a*b != 0) faster than (a != 0 && b != 0) in Java?

我在Java中编写了一些代码,在某种程度上,程序的流程是由两个int变量"a"和"b"是否为非零来确定的(注:A和B都不为负,并且永远不在整数溢出范围内)。

我可以用

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

或者

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

因为我期望这段代码在每次运行时运行数百万次,所以我想知道哪段代码更快。我在一个巨大的随机生成的数组上对它们进行了比较,并且我也很好奇数组的稀疏度(数据的分数=0)将如何影响结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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"大于约3%的时间,那么a*b != 0a!=0 && b!=0快:

Graphical graph of the results of a AND b non-zero

我很好奇为什么。有人能发光吗?它是编译器还是硬件级的?

编辑:出于好奇…既然我了解了分支预测,我想知道A或B的模拟比较结果是非零的:

Graph of a or b non-zero

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

更新

我在分析中加入了!(a==0 || b==0),看看会发生什么。

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

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

4-有些人建议将a != 0 & b != 0a != 0 && b != 0进行比较,因为我们会消除分支预测效应,因此预测它将更接近a*b != 0。我不知道&可以用于布尔变量,我认为它只用于整数的二进制运算。

注意:在我考虑所有这些的上下文中,int溢出不是一个问题,但在一般上下文中这绝对是一个重要的考虑。

CPU:英特尔酷睿[email protected]

Java版本:1.80y45Java(TM)SE运行时环境(构建1.80y45-B14)Java热点(TM)64位服务器VM(构建25.45-B02,混合模式)


我忽略了这样一个问题,即你的基准测试可能存在缺陷,并以表面价值衡量结果。

Is it the compiler or is it at the hardware level?

我认为后者:

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

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

1
  if (a * b != 0)

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

如果硬件级别的分支预测无效,则乘法可能比第二个条件分支更快。当你提高比率…分支预测越来越不有效。

条件分支变慢的原因是它们导致指令执行管道暂停。分支预测是通过预测分支将朝哪个方向发展,并根据预测来推测选择下一条指令,从而避免出现停顿。如果预测失败,则在加载另一个方向的指令时会有延迟。

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

但是,有一件事需要您在这个优化中小心。是否有任何值a * b != 0会给出错误的答案?考虑计算产品导致整数溢出的情况。

更新

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

  • 在条件分支a * b != 0的情况下,也有一个"分支预测"效果,这在图中显示出来。

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

更新2

我不明白为什么a + b != 0a | b != 0的曲线不同。在分支预测器逻辑中可能有一些聪明的东西。或者它可以指示其他的东西。

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

但是,它们都具有为ab的所有非负值工作的优势。


我认为你的基准测试有一些缺陷,可能对推断真正的程序没有帮助。以下是我的想法:

  • 对于溢出的值,(a*b)!=0会做错误的事情,而(a+b)!=0会另外对总和为零的正值和负值做错误的事情,因此在一般情况下,即使它们在这里工作,也不能使用这两个表达式中的任何一个。

  • (a|b)!=0(a+b)!=0两个值中的任何一个值为非零时进行测试,而(a*b)!=0a != 0 && b != 0两个值均为非零时进行测试。对于相同百分比的数据,这两种类型的条件将不是真的。

  • fraction为0时,当分支几乎从未取下时,vm将在外部(fraction循环的前几次运行期间优化表达式。如果以0.5启动fraction,优化器可能会做不同的事情。

  • 除非vm能够在这里消除一些数组边界检查,否则表达式中还有其他四个分支只是由于边界检查,这是一个复杂的因素,当试图弄清楚在低级别上发生了什么。如果将二维数组拆分为两个平面数组,将nums[0][i]nums[1][i]更改为nums0[i]nums1[i],可能会得到不同的结果。

  • CPU分支预测器试图检测数据中的短模式,或者检测正在执行或未执行的所有分支的运行情况。随机生成的基准数据对于分支预测器来说是最糟糕的事情。如果您的真实数据有一个可预测的模式,或者它有所有零值和所有非零值的长时间运行,那么分支的成本可能会低很多。

  • 在满足条件后执行的特定代码会影响评估条件本身的性能,因为它会影响诸如循环是否可以展开、哪些CPU寄存器可用以及在评估条件后是否需要重用任何已获取的nums值之类的事情。仅仅增加基准中的计数器并不是真正代码所能做的完美的占位符。

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

正如你所看到的,有很多不确定性,而且总是很难用这些微优化来确定任何事情,因为在一个虚拟机或CPU上速度更快的技巧在另一个虚拟机或CPU上速度更快。如果您的虚拟机是热点,请注意还有另外两种类型,与"服务器"虚拟机相比,"客户机"虚拟机具有不同(较弱)的优化。

如果您可以反汇编由VM生成的机器代码,那么就这样做,而不是试图猜测它做了什么!


这里的答案很好,尽管我有一个想法可以改善事情。

由于两个分支和关联的分支预测可能是罪魁祸首,因此我们可以在不改变逻辑的情况下将分支减少到单个分支。

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

这也可能有效

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

原因是,根据短路规则,如果第一个布尔值为假,则不应计算第二个布尔值。如果nums[0][i]是假的,它必须执行额外的分支以避免评估nums[1][i]。现在,您可能不关心nums[1][i]的计算结果,但是编译器不能确定它不会在执行此操作时抛出超出范围或空引用。通过将if块减少为简单的bools,编译器可能足够聪明,从而认识到不必要地计算第二个布尔值不会产生负面的副作用。


当我们进行乘法时,即使一个数是0,那么积也是0。写作时

1
    (a*b != 0)

它评估产品的结果,从而消除从0开始的前几次迭代。结果,当条件为

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

其中每个元素都与0进行比较并进行计算。因此所需时间较少。但我相信第二个条件可能会给你更准确的答案。


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

就是这么说的。我不知道a*b != 0(a|b) != 0快多少。一般来说,整数乘法比按位或运算更昂贵。但像这样的事情有时会变得很奇怪。请参阅处理器缓存效果库中的"示例7:硬件复杂性"示例。