【问题标题】:Why is (a*b != 0) faster than (a != 0 && b != 0) in Java?为什么 (a*b != 0) 在 Java 中比 (a != 0 && b != 0) 快?
【发布时间】:2016-06-02 13:33:10
【问题描述】:

我正在用 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”在大约 3% 以上的时间中等于 0,a*b != 0a!=0 &amp;&amp; b!=0 快:

我很想知道为什么。任何人都可以解释一下吗?是编译器还是硬件级别的?

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

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

更新

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

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

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

4- 有人建议包含 a != 0 &amp; b != 0 而不是 a != 0 &amp;&amp; b != 0,并预测它的行为会更接近 a*b != 0,因为我们将移除分支预测效应。我不知道&amp; 可以与布尔变量一起使用,我以为它只用于整数的二进制操作。

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

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

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

【问题讨论】:

  • if (!(a == 0 || b == 0)) 怎么样?众所周知,微基准测试不可靠,这不太可能真正可衡量(对我来说,~3% 听起来像是一个误差范围)。
  • a != 0 &amp; b != 0
  • 如果预测分支错误,则分支会变慢。 a*b!=0 少了一个分支
  • (1&lt;&lt;16) * (1&lt;&lt;16) == 0 但两者都不为零。
  • @Gene:您建议的优化无效。即使忽略溢出,如果ab 中的一个 为零,则a*b 为零; a|b 仅当两者都为零时才为零。

标签: java performance processing-efficiency microbenchmark branch-prediction


【解决方案1】:

我忽略了您的基准测试可能存在缺陷的问题,并从表面上看结果。

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

我认为后者:

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

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

  if (a * b != 0)

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

如果硬件级分支预测无效,则乘法可能比第二个条件分支更快。随着比率的增加……分支预测的效果越来越差。

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

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


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


更新

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

  • 在条件分支a * b != 0 的情况下也有一个“分支预测”效应,这在图中可以看出。

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


更新 2

我不明白为什么 a + b != 0a | b != 0 案例的曲线不同。在分支预测器逻辑中可能有一些巧妙之处。或者它可能表明其他东西。

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

但是,它们都具有适用于 ab 的所有非负值的优势。

【讨论】:

  • @DebosmitRay - 1) 应该没有软件。中间结果将保存在寄存器中。 2)在第二种情况下,有两个可用的分支:一个执行“一些代码”,另一个跳到if之后的下一条语句。
  • @StephenC 你对 a+b 和 a|b 感到困惑是对的,因为曲线相同的,我认为颜色非常接近。向色盲人士道歉!
  • @njzk2 从概率的角度来看,这些情况应该根据 50% 的轴对称(a&amp;ba|b 的概率为零)。他们是,但并不完美,这就是难题。
  • @StephenC a*b != 0a+b != 0 基准测试不同的原因是因为 a+b != 0 根本不等效,不应该进行基准测试。例如,对于 a = 1, b = 0,第一个表达式的计算结果为 false,但第二个表达式的计算结果为 true。乘法有点像 and 运算符,而加法有点像 or 运算符。
  • @AntonínLejsek 我认为概率会有所不同。如果您有n 零,那么ab 为零的可能性随着n 的增加而增加。在AND 操作中,n 越高,其中之一非零的概率就会增加并且满足条件。这与OR 操作相反(其中任何一个为零的概率随着n 而增加)。这是基于数学的观点。我不确定硬件是否是这样工作的。
【解决方案2】:

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

  • (a|b)!=0(a+b)!=0 测试 其中一个 值是否非零,而 a != 0 &amp;&amp; b != 0(a*b)!=0 测试 两者 是否非零零。所以你不只是比较算术的时间:如果条件更频繁地为真,它会导致更多的if主体的执行,这也需要更多的时间。

  • (a+b)!=0 会对总和为零的正负值执行错误的操作,因此您不能在一般情况下使用它,即使它在这里有效。

  • 同样,(a*b)!=0 会为溢出的值做错事。 (随机示例:196608 * 327680 为 0,因为真正的结果恰好可以被 232 整除,因此它的低 32 位为 0,如果是 int 操作,这些位就是你得到的全部.)

  • VM 将在外部 (fraction) 循环的前几次运行期间优化表达式,此时 fraction 为 0,几乎从不采用分支。如果您从 0.5 开始 fraction,优化器可能会做不同的事情。

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

  • CPU 分支预测器检测数据中的短模式,或所有分支的运行是否被采用。您随机生成的基准数据是worst-case scenario for a branch predictor。如果现实世界的数据具有可预测的模式,或者它具有全零和全非零值的长期运行,则分支的成本可能会少得多

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

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

存在很多不确定性,而且对于此类微优化,总是很难说出任何确定的信息,因为在一个 VM 或 CPU 上更快的技巧在另一个 VM 或 CPU 上可能会更慢。如果运行 32 位 HotSpot JVM,而不是 64 位版本,请注意它有两种类型:“客户端”VM 与“服务器”VM 相比具有不同(较弱)的优化。

如果你可以disassemble the machine code generated by the VM,那就去做吧,而不是试图猜测它的作用!

【讨论】:

    【解决方案3】:

    这里的答案很好,虽然我有一个可能会改进的想法。

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

    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[0][i] 为假,它必须执行一个额外的分支以避免评估nums[1][i]。现在,您可能不在乎 nums[1][i] 被评估,但编译器不能确定它不会在您这样做时抛出超出范围或 null ref。通过将 if 块简化为简单的布尔值,编译器可能足够聪明,可以意识到不必要地评估第二个布尔值不会产生负面影响。

    【讨论】:

    • 赞成,虽然我觉得这并没有完全回答这个问题。
    • 这是一种在不改变非分支逻辑的情况下引入分支的方法(如果你获得ab 的方式有副作用,你会保留它们)。你还有&amp;&amp;,所以你还有一个分支。
    【解决方案4】:

    当我们取乘时,即使一个数是0,那么乘积也是0。写的时候

        (a*b != 0)
    

    它评估乘积的结果,从而消除从 0 开始的前几次迭代。结果比较少于条件为时的比较

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

    每个元素都与 0 进行比较并进行评估。因此所需的时间更少。但我相信第二个条件可能会给你更准确的解决方案。

    【讨论】:

    • 在第二个表达式中,如果a 为零,则不需要评估b,因为整个表达式已经为假。所以比较每个元素是不正确的。
    【解决方案5】:

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

    就是这么说的。我看不出a*b != 0 怎么能比(a|b) != 0 快。通常整数乘法比按位或更昂贵。但是这样的事情偶尔会变得很奇怪。例如,参见Gallery of Processor Cache Effects 中的“示例 7:硬件复杂性”示例。

    【讨论】:

    • &amp; 不是“按位或”,而是(在这种情况下)“逻辑与”,因为两个操作数都是布尔值,而不是 | ;-)
    • @siegi TIL Java '&' 实际上是一个没有短路的逻辑与。
    猜你喜欢
    • 2015-03-20
    • 1970-01-01
    • 2021-11-19
    • 1970-01-01
    • 2017-01-21
    • 2019-03-13
    • 2021-10-10
    • 2015-01-18
    • 1970-01-01
    相关资源
    最近更新 更多