Есть ли полезная разница между (p ^ q) и (p != q) для логических значений?

В Java есть два способа проверить, различаются ли два логических значения. Вы можете сравнить их с != или с ^ (xor). Конечно, эти два оператора дают один и тот же результат во всех случаях. Тем не менее, имеет смысл включить их обоих, как обсуждалось, например, в В чем разница между XOR и 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 для тестирования «критерий». Это достаточно близко, так что, хотя 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 Конечно, я не буду опрометчиво использовать эту информацию для микрооптимизации. Мне просто интересно, как это все работает.

Придерживайтесь собственного оригинального вывода: «предпочитайте одно другому в зависимости от контекста».

Andreas 09.04.2019 20:01

Введение test-and-branch, очевидно, окажет некоторое влияние на производительность. Насколько многое зависит от множества факторов, не последним из которых является предсказуемость этой ветви. Много предшествующего уровня техники по этому вопросу; Я без зазрения совести подключу мой собственный ответ в качестве отправной точки. Я не могу опубликовать фактический ответ, потому что я не знаком с тем, как байт-код Java транслируется в машинный код. Есть ли оптимизатор, расположенный между ними? Вероятно, да. В любом случае, остерегайтесь преждевременных микрооптимизаций. Сначала напишите код, чтобы сказать, что вы имеете в виду.

Cody Gray 09.04.2019 20:08
p != q предлагает использовать инструкцию сравнения, а p ^ q предлагает использовать инструкцию xor. Это то, что вы видите в байт-коде. Если он далее компилируется в машинный код таким естественным образом, то p ^ q, вероятно, будет несколько быстрее, если результат используется как число или сохраняется в памяти, но немного медленнее, если используется в качестве условия ветвления.
zch 09.04.2019 20:09

Какой из них более читабелен?

Yassin Hajaj 09.04.2019 20:12

!= более распространен и, следовательно, его легче читать и понимать большему количеству программистов, что не имеет значения.

Joakim Danielson 09.04.2019 20:13

Почему p ^ q будет «немного медленнее, если использовать его как условие перехода», @zch?

Cody Gray 09.04.2019 20:13

@CodyGray, я подумал о том, чтобы сделать xor инструкцию, а затем отдельно проверить результат. Возможно, вы правы в том, что ни один компилятор производственного уровня не будет таким тупым.

zch 09.04.2019 20:17

@CodyGray Действительно, перевод из байт-кода сложен и требует оптимизатора. Часто байт-код некоторое время интерпретируется и JIT-компилируется в собственный код только после того, как он определен как точка доступа во время выполнения. Оптимизатор JIT может использовать информацию времени выполнения для управления своей оптимизацией - я не эксперт, но я полагаю, что он может использовать это, например, для прогнозирования ветвления. Это одна из причин, по которой для тестов JVM важно «разогревать JIT», как это делает критерий.

amalloy 09.04.2019 20:21

@CodyGray, но если компилятор использует xor и его флаги напрямую, в некоторых случаях он все равно может повредить оптимизации, поскольку он изменяет регистр, содержащий p (или q).

zch 09.04.2019 20:24

Это сложная тема, @zch. Практически все современные архитектуры устанавливают флаги в результате арифметической или побитовой операции, включая XOR/EOR, поэтому переход можно выполнять непосредственно по состоянию этих флагов, включая «был ли последний результат нулевым?». Если между ними есть какой-то оптимизатор (и amalloy подтверждает, что он есть), вы правы, он определенно не должен быть таким тупым. Что касается затирания одного из операндов в XOR, некоторые архитектуры поддерживают инструкции с тремя операндами, в которых назначение сохраняется отдельно. x86 нет, но копия reg-reg (mov) стоит очень дешево/бесплатно из-за переименования.

Cody Gray 09.04.2019 20:31

Я отредактировал ответ, чтобы включить более подробную информацию

Eugene 03.05.2019 10:50
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
Как вычислять биты и понимать побитовые операторы в Java - объяснение с примерами
Как вычислять биты и понимать побитовые операторы в Java - объяснение с примерами
В компьютерном программировании биты играют важнейшую роль в представлении и манипулировании данными на двоичном уровне. Побитовые операции...
Поднятие тревоги для долго выполняющихся методов в Spring Boot
Поднятие тревоги для долго выполняющихся методов в Spring Boot
Приходилось ли вам сталкиваться с требованиями, в которых вас могли попросить поднять тревогу или выдать ошибку, когда метод Java занимает больше...
Полный курс Java для разработчиков веб-сайтов и приложений
Полный курс Java для разработчиков веб-сайтов и приложений
Получите сертификат Java Web и Application Developer, используя наш курс.
11
11
304
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Что ж, я собираюсь вскоре показать, как ЦП преобразует это, и обновить пост, но тем временем вы смотрите на слишком маленькую разницу, чтобы ее волновать.

байт-код в java не является показателем того, насколько быстро (или нет) будет выполняться метод, есть два JIT-компилятора, которые заставят этот метод выглядеть совершенно по-другому, как только они станут достаточно горячими. также известно, что javac делает очень мало оптимизаций, когда код компилирует, настоящие оптимизации исходят от JIT.

Я провел несколько тестов, используя JMH для этого, используя либо только C1 компилятор, либо заменив C2 на GraalVM, либо вообще не JIT... (следует много тестового кода, вы можете пропустить его и просто посмотреть на результаты, это сделано используя jdk-12 кстати). Этот код использует ДМХ - инструмент де-факто для использования в мире микротестов 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 compiler only with !=

/*
 * 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 в eax, compare (edx, esi) -> если не равно, поставьте 1 в eax. вернуть eax & 1.

C1 compiler with ^:

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, иначе это тоже довольно просто, я думаю.

But if I enable C2 compiler, things are a lot more interesting.

/**
 * 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

AFAIK cmp/cmove лучше, чем cmp/je из-за предсказания ветвления - это, по крайней мере, то, что я читал...

XOR with C2 compiler:

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.

Ваша более широкая точка зрения достаточно верна — разница будет крошечной. Но вам нужно быть очень осторожным, пытаясь доказать это с помощью эталонных показателей. Микро-бенчмаркинг печально известный сложен, потому что на ваши измерения влияют всевозможные искажающие факторы, включая то, что делает сам ЦП, например прогнозирование ветвлений, кэширование и т. д. Помимо этого, сам инструмент измерения также может повлиять на результаты. Не говоря уже о весьма отчетливой возможности того, что с достаточно хорошим оптимизатором все кода можно было бы исключить, а это означает, что вы практически ничего не тестируете.

Cody Gray 10.04.2019 00:19

@CodyGray Я отредактировал ответ ... если у вас есть время объяснить некоторые вещи, которые я не понимаю ... спасибо!

Eugene 03.05.2019 10:50

Другие вопросы по теме