对于布尔值,(p ^ q) 和 (p != q) 之间是否有有用的区别?

12

Java有两种检查两个布尔值是否不同的方式。您可以使用!=^(异或)进行比较。当然,在所有情况下,这两个操作符产生相同的结果。尽管如此,将它们都包含在内是有意义的,正如在What's the difference between XOR and NOT-EQUAL-TO?中所讨论的那样。甚至开发人员根据上下文喜欢其中之一也是有道理的 - 有时"这两个布尔变量确切地有一个为真"读起来更好,而其他时候"这两个布尔变量不同"则更能表达意图。因此,也许使用哪个应该是品味和风格问题。

令我惊讶的是,javac并没有将它们视为相同!考虑以下类:

class Test {
  public boolean xor(boolean p, boolean q) {
    return p ^ q;
  }
  public boolean inequal(boolean p, boolean q) {
    return p != q;
  }
}

显然,这两种方法具有相同的可见行为。但它们具有不同的字节码:
$ javap -c Test
Compiled from "Test.java"
class Test {
  Test();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public boolean xor(boolean, boolean);
    Code:
       0: iload_1
       1: iload_2
       2: ixor
       3: ireturn

  public boolean inequal(boolean, boolean);
    Code:
       0: iload_1
       1: iload_2
       2: if_icmpeq     9
       5: iconst_1
       6: goto          10
       9: iconst_0
      10: ireturn
}

如果我必须猜测的话,我会说 xor 的表现更好,因为它只返回其比较的结果; 添加跳转和额外的负载似乎是浪费的工作。但是,我使用Clojure的“criterium”基准测试工具对这两种方法进行了数十亿次调用的基准测试,结果非常接近,虽然看起来xor要快一些,但我不太擅长统计学,无法确定结果是否有显著差异:

user=> (let [t (Test.)] (bench (.xor t true false)))
Evaluation count : 4681301040 in 60 samples of 78021684 calls.
             Execution time mean : 4.273428 ns
    Execution time std-deviation : 0.168423 ns
   Execution time lower quantile : 4.044192 ns ( 2.5%)
   Execution time upper quantile : 4.649796 ns (97.5%)
                   Overhead used : 8.723577 ns

Found 2 outliers in 60 samples (3.3333 %)
    low-severe   2 (3.3333 %)
 Variance from outliers : 25.4745 % Variance is moderately inflated by outliers
user=> (let [t (Test.)] (bench (.inequal t true false)))
Evaluation count : 4570766220 in 60 samples of 76179437 calls.
             Execution time mean : 4.492847 ns
    Execution time std-deviation : 0.162946 ns
   Execution time lower quantile : 4.282077 ns ( 2.5%)
   Execution time upper quantile : 4.813433 ns (97.5%)
                   Overhead used : 8.723577 ns

Found 2 outliers in 60 samples (3.3333 %)
    low-severe   2 (3.3333 %)
 Variance from outliers : 22.2554 % Variance is moderately inflated by outliers

在性能方面,有没有更喜欢写其中一种的理由呢1?是否有一些情况下它们的实现差异使得其中一种更加适合?还是说,有人知道为什么javac会以如此不同的方式实现这两个相同的操作吗?

1 当然,我不会草率地使用这些信息进行微调优。我只是好奇这一切是如何工作的。


3
引入测试分支显然会对性能产生一定影响。这种影响的大小取决于各种因素,其中最重要的是该分支的可预测性。关于这个问题有很多现成的研究资料,我会不客气地推荐我的答案作为一个起点。我无法发布实际答案,因为我不熟悉Java字节码如何转换成机器代码。是否存在位于两者之间的优化器?可能是的。无论如何,要注意过早的微观优化。首先编写表达您意图的代码。 - Cody Gray
2
p != q 表示使用比较指令,而 p ^ q 建议使用 xor 指令。这是在字节码中看到的。如果以这种自然方式进一步编译成机器代码,那么如果结果用作数字或存储到内存,则 p ^ q 可能会更快,但如果用作分支条件,则稍微慢一些。 - zch
1
@CodyGray 的确,从字节码翻译是很复杂的,因为它涉及到一个优化器。通常,字节码会被解释一段时间,只有在运行时确定其为性能热点后,才会被 JIT 编译成本机代码。JIT 优化器可以使用运行时信息来指导其优化 - 我不是专家,但我想它可能能够利用这一点来指导其分支预测等方面的优化。这也是 JVM 基准测试“热身 JIT”(如 criterium 所做)非常重要的原因之一。 - amalloy
1
@CodyGray,但如果编译器直接使用xor和它的标志位,仍然可能在某些情况下损害优化,因为它会改变保存p(或q)的寄存器。 - zch
1
这是一个复杂的话题,@zch。几乎所有现代架构都会在算术或位运算中设置标志,包括XOR/EOR,因此分支可以直接根据这些标志的状态进行操作,包括“上次的结果是否为零?”如果存在任何优化器(amalloy确认确实存在),那么你是对的,它肯定不应该如此愚蠢。关于XOR操作数之一的摧毁,一些架构支持三操作数指令,其中目标保持单独。x86不支持,但reg-reg复制(mov)非常便宜/免费,因为它具有重命名功能。 - Cody Gray
显示剩余6条评论
1个回答

5

我很快就会提供CPU如何翻译的方法并更新这篇文章,但与此同时,您看到的差异太小,不值得关注。

Java中的字节码并不能说明一个方法执行的快慢,因为有两个JIT编译器,一旦它们变热了,这个方法会完全不同。另外,javac 编译代码后做的优化很少,真正的优化来自 JIT

我使用 JMH 进行了一些测试,只使用 C1 编译器或将 C2 替换为 GraalVM 或完全没有 JIT...(接下来是大量测试代码,您可以跳过它并查看结果,这是在 jdk-12 下完成的)。这段代码使用 JMH - 这是 Java 微基准测试领域中使用的事实标准工具(如果手动进行测试容易出现错误)。

@Warmup(iterations = 10)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Measurement(iterations = 2, time = 2, timeUnit = TimeUnit.SECONDS)
public class BooleanCompare {

    public static void main(String[] args) throws Exception {
        Options opt = new OptionsBuilder()
            .include(BooleanCompare.class.getName())
            .build();

        new Runner(opt).run();
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(1)
    public boolean xor(BooleanExecutionPlan plan) {
        return plan.booleans()[0] ^ plan.booleans()[1];
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(1)
    public boolean plain(BooleanExecutionPlan plan) {
        return plan.booleans()[0] != plan.booleans()[1];
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(value = 1, jvmArgsAppend = "-Xint")
    public boolean xorNoJIT(BooleanExecutionPlan plan) {
        return plan.booleans()[0] != plan.booleans()[1];
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(value = 1, jvmArgsAppend = "-Xint")
    public boolean plainNoJIT(BooleanExecutionPlan plan) {
        return plan.booleans()[0] != plan.booleans()[1];
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(value = 1, jvmArgsAppend = "-XX:-TieredCompilation")
    public boolean xorC2Only(BooleanExecutionPlan plan) {
        return plan.booleans()[0] != plan.booleans()[1];
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(value = 1, jvmArgsAppend = "-XX:-TieredCompilation")
    public boolean plainC2Only(BooleanExecutionPlan plan) {
        return plan.booleans()[0] != plan.booleans()[1];
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(value = 1, jvmArgsAppend = "-XX:TieredStopAtLevel=1")
    public boolean xorC1Only(BooleanExecutionPlan plan) {
        return plan.booleans()[0] != plan.booleans()[1];
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(value = 1, jvmArgsAppend = "-XX:TieredStopAtLevel=1")
    public boolean plainC1Only(BooleanExecutionPlan plan) {
        return plan.booleans()[0] != plan.booleans()[1];
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(value = 1,
        jvmArgsAppend = {
            "-XX:+UnlockExperimentalVMOptions",
            "-XX:+EagerJVMCI",
            "-Dgraal.ShowConfiguration=info",
            "-XX:+UseJVMCICompiler",
            "-XX:+EnableJVMCI"
        })
    public boolean xorGraalVM(BooleanExecutionPlan plan) {
        return plan.booleans()[0] != plan.booleans()[1];
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(value = 1,
        jvmArgsAppend = {
            "-XX:+UnlockExperimentalVMOptions",
            "-XX:+EagerJVMCI",
            "-Dgraal.ShowConfiguration=info",
            "-XX:+UseJVMCICompiler",
            "-XX:+EnableJVMCI"
        })
    public boolean plainGraalVM(BooleanExecutionPlan plan) {
        return plan.booleans()[0] != plan.booleans()[1];
    }

}

以下是结果:

BooleanCompare.plain         avgt    2    3.125          ns/op
BooleanCompare.xor           avgt    2    2.976          ns/op

BooleanCompare.plainC1Only   avgt    2    3.400          ns/op
BooleanCompare.xorC1Only     avgt    2    3.379          ns/op

BooleanCompare.plainC2Only   avgt    2    2.583          ns/op
BooleanCompare.xorC2Only     avgt    2    2.685          ns/op

BooleanCompare.plainGraalVM  avgt    2    2.980          ns/op
BooleanCompare.xorGraalVM    avgt    2    3.868          ns/op

BooleanCompare.plainNoJIT    avgt    2  243.348          ns/op
BooleanCompare.xorNoJIT      avgt    2  201.342          ns/op

我不是一个能够熟练阅读汇编语言的人,尽管有时候我会喜欢这样做...这里有一些有趣的事情。如果我们做:

只使用“!=”运算符的C1编译器

/*
 * run many iterations of this with :
 *  java -XX:+UnlockDiagnosticVMOptions  
 *       -XX:TieredStopAtLevel=1  
 *       "-XX:CompileCommand=print,com/so/BooleanCompare.compare"  
 *       com.so.BooleanCompare
 */
public static boolean compare(boolean left, boolean right) {
    return left != right;
}

我们得到:

  0x000000010d1b2bc7: push   %rbp
  0x000000010d1b2bc8: sub    $0x30,%rsp  ;*iload_0 {reexecute=0 rethrow=0 return_oop=0}
                                         ; - com.so.BooleanCompare::compare@0 (line 22)

  0x000000010d1b2bcc: cmp    %edx,%esi
  0x000000010d1b2bce: mov    $0x0,%eax
  0x000000010d1b2bd3: je     0x000000010d1b2bde
  0x000000010d1b2bd9: mov    $0x1,%eax
  0x000000010d1b2bde: and    $0x1,%eax
  0x000000010d1b2be1: add    $0x30,%rsp
  0x000000010d1b2be5: pop    %rbp

对我来说,这段代码有点显而易见:将0放入eaxcompare (edx, esi) -> 如果不相等,则将1放入eax。返回eax & 1

带^的C1编译器:

public static boolean compare(boolean left, boolean right) {
     return left ^ right;
}



  # parm0:    rsi       = boolean
  # parm1:    rdx       = boolean
  #           [sp+0x40]  (sp of caller)
  0x000000011326e5c0: mov    %eax,-0x14000(%rsp)
  0x000000011326e5c7: push   %rbp
  0x000000011326e5c8: sub    $0x30,%rsp   ;*iload_0 {reexecute=0 rethrow=0 return_oop=0}
                                          ; - com.so.BooleanCompare::compare@0 (line 22)

  0x000000011326e5cc: xor    %rdx,%rsi
  0x000000011326e5cf: and    $0x1,%esi
  0x000000011326e5d2: mov    %rsi,%rax
  0x000000011326e5d5: add    $0x30,%rsp
  0x000000011326e5d9: pop    %rbp

我真的不知道为什么这里需要and $0x1,%esi,否则这也很简单,我想。

但是如果我启用C2编译器,事情会变得更加有趣。

/**
 * run with java
 * -XX:+UnlockDiagnosticVMOptions
 * -XX:CICompilerCount=2
 * -XX:-TieredCompilation
 * "-XX:CompileCommand=print,com/so/BooleanCompare.compare"
 * com.so.BooleanCompare
 */
public static boolean compare(boolean left, boolean right) {
    return left != right;
}



  # parm0:    rsi       = boolean
  # parm1:    rdx       = boolean
  #           [sp+0x20]  (sp of caller)
  0x000000011a2bbfa0: sub    $0x18,%rsp
  0x000000011a2bbfa7: mov    %rbp,0x10(%rsp)                

  0x000000011a2bbfac: xor    %r10d,%r10d
  0x000000011a2bbfaf: mov    $0x1,%eax
  0x000000011a2bbfb4: cmp    %edx,%esi
  0x000000011a2bbfb6: cmove  %r10d,%eax                     

  0x000000011a2bbfba: add    $0x10,%rsp
  0x000000011a2bbfbe: pop    %rbp

我甚至没有看到经典的结尾语句push ebp; mov ebp, esp; sub esp, x,取而代之的是一些非常不同寻常的内容(至少对我来说是这样),方法如下:

 sub    $0x18,%rsp
 mov    %rbp,0x10(%rsp)

 ....
 add    $0x10,%rsp
 pop    %rbp

希望比我更熟练的人能够解释。否则,它就像是一个更好的C1生成版本:

xor    %r10d,%r10d // put zero into r10d
mov    $0x1,%eax   // put 1 into eax
cmp    %edx,%esi   // compare edx and esi
cmove  %r10d,%eax  // conditionally move the contents of r10d into eax

据我所知,cmp/cmove 优于 cmp/je,原因是分支预测 - 至少这是我读过的...

C2 编译器中的 XOR:

public static boolean compare(boolean left, boolean right) {
    return left ^ right;
}



  0x000000010e6c9a20: sub    $0x18,%rsp
  0x000000010e6c9a27: mov    %rbp,0x10(%rsp)                

  0x000000010e6c9a2c: xor    %edx,%esi
  0x000000010e6c9a2e: mov    %esi,%eax
  0x000000010e6c9a30: and    $0x1,%eax
  0x000000010e6c9a33: add    $0x10,%rsp
  0x000000010e6c9a37: pop    %rbp

看起来它几乎与C1编译器生成的代码相同。


1
你的更广泛的观点是足够正确的——差异将非常微小。但是,你需要非常小心地尝试使用基准数字来证明这一点。微基准测试因为各种混淆因素影响你的测量结果而臭名昭著,包括CPU本身执行的事情,如分支预测、缓存等。除此之外,测量工具本身也可能会影响结果。更不用说有一个非常明显的可能性,即在优化器足够好的情况下,所有代码都可以被省略,这意味着你测试的实际上什么都没有。 - Cody Gray
@CodyGray 我已经编辑了答案... 如果你有时间解释一下我不理解的东西... 谢谢! - Eugene

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接