В 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 Конечно, я не буду опрометчиво использовать эту информацию для микрооптимизации. Мне просто интересно, как это все работает.
Введение test-and-branch, очевидно, окажет некоторое влияние на производительность. Насколько многое зависит от множества факторов, не последним из которых является предсказуемость этой ветви. Много предшествующего уровня техники по этому вопросу; Я без зазрения совести подключу мой собственный ответ в качестве отправной точки. Я не могу опубликовать фактический ответ, потому что я не знаком с тем, как байт-код Java транслируется в машинный код. Есть ли оптимизатор, расположенный между ними? Вероятно, да. В любом случае, остерегайтесь преждевременных микрооптимизаций. Сначала напишите код, чтобы сказать, что вы имеете в виду.
p != q
предлагает использовать инструкцию сравнения, а p ^ q
предлагает использовать инструкцию xor
. Это то, что вы видите в байт-коде. Если он далее компилируется в машинный код таким естественным образом, то p ^ q
, вероятно, будет несколько быстрее, если результат используется как число или сохраняется в памяти, но немного медленнее, если используется в качестве условия ветвления.
Какой из них более читабелен?
!= более распространен и, следовательно, его легче читать и понимать большему количеству программистов, что не имеет значения.
Почему p ^ q
будет «немного медленнее, если использовать его как условие перехода», @zch?
@CodyGray, я подумал о том, чтобы сделать xor
инструкцию, а затем отдельно проверить результат. Возможно, вы правы в том, что ни один компилятор производственного уровня не будет таким тупым.
@CodyGray Действительно, перевод из байт-кода сложен и требует оптимизатора. Часто байт-код некоторое время интерпретируется и JIT-компилируется в собственный код только после того, как он определен как точка доступа во время выполнения. Оптимизатор JIT может использовать информацию времени выполнения для управления своей оптимизацией - я не эксперт, но я полагаю, что он может использовать это, например, для прогнозирования ветвления. Это одна из причин, по которой для тестов JVM важно «разогревать JIT», как это делает критерий.
@CodyGray, но если компилятор использует xor
и его флаги напрямую, в некоторых случаях он все равно может повредить оптимизации, поскольку он изменяет регистр, содержащий p
(или q
).
Это сложная тема, @zch. Практически все современные архитектуры устанавливают флаги в результате арифметической или побитовой операции, включая XOR/EOR, поэтому переход можно выполнять непосредственно по состоянию этих флагов, включая «был ли последний результат нулевым?». Если между ними есть какой-то оптимизатор (и amalloy подтверждает, что он есть), вы правы, он определенно не должен быть таким тупым. Что касается затирания одного из операндов в XOR, некоторые архитектуры поддерживают инструкции с тремя операндами, в которых назначение сохраняется отдельно. x86 нет, но копия reg-reg (mov
) стоит очень дешево/бесплатно из-за переименования.
Я отредактировал ответ, чтобы включить более подробную информацию
Что ж, я собираюсь вскоре показать, как ЦП преобразует это, и обновить пост, но тем временем вы смотрите на слишком маленькую разницу, чтобы ее волновать.
байт-код в 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
.
Ваша более широкая точка зрения достаточно верна — разница будет крошечной. Но вам нужно быть очень осторожным, пытаясь доказать это с помощью эталонных показателей. Микро-бенчмаркинг печально известный сложен, потому что на ваши измерения влияют всевозможные искажающие факторы, включая то, что делает сам ЦП, например прогнозирование ветвлений, кэширование и т. д. Помимо этого, сам инструмент измерения также может повлиять на результаты. Не говоря уже о весьма отчетливой возможности того, что с достаточно хорошим оптимизатором все кода можно было бы исключить, а это означает, что вы практически ничего не тестируете.
@CodyGray Я отредактировал ответ ... если у вас есть время объяснить некоторые вещи, которые я не понимаю ... спасибо!
Придерживайтесь собственного оригинального вывода: «предпочитайте одно другому в зависимости от контекста».