12

I'm curious about how Java does optimization for multiple "if" statements that have mutually exclusive conditions, but I don't have the knowledge to analyze it myself. The question is basically the Java version of this question Performance difference of "if if" vs "if else if"

I've seen this answered for if statements that return, but this question is for if statements that have mutually exclusive conditions but don't return.

1. Multiple if statements

if (x == 0) doSomething();
if (x == 2) doSomething();
if (x == 5) doSomething();

2. Chained If-else statements

if (x == 0) doSomething();
else if (x == 2) doSomething();
else if (x == 5) doSomething();

Question
Do #1 and #2 perform the same post-compilation?
(Also: if so, how complex of a conditional can Java optimize?)

John Kugelman
  • 349,597
  • 67
  • 533
  • 578
Jeck
  • 184
  • 2
  • 10
  • 4
    They do *not* generate the same Java bytecode, as you can verify at http://javabytes.io/ . However, I suspect they generate the same JITted code. – Brennan Vincent May 02 '19 at 23:06
  • @BrennanVincent Based on timing the two methods, it seems that *if-else* is faster even with the JIT compiler and branch prediction. See my answer. – Daniel Williams May 03 '19 at 01:09

4 Answers4

4

Nothing beats a good old fashioned timing test:

long total = 0;
long startTime;
long endTime;

for (int j = 0; j < 10; j++) {
    startTime = System.currentTimeMillis();

    for (int i = 0; i < 100000000; i++) {
        if (i % 3 == 0) total += 1;
        if (i % 3 == 1) total += 2;
        if (i % 3 == 2) total += 3;
    }

    endTime = System.currentTimeMillis();
    System.out.println("If only: " + (endTime - startTime));

    startTime = System.currentTimeMillis();

    for (int i = 0; i < 100000000; i++) {
        if (i % 3 == 0) total += 1;
        else if (i % 3 == 1) total += 2;
        else if (i % 3 == 2) total += 3;
    }

    endTime = System.currentTimeMillis();
    System.out.println("If-else: " + (endTime - startTime));
}
System.out.println(total);

(the 'total' value is necessary to prevent the compiler from removing the whole loop!)

The output:

If only: 215
If-else: 137
If only: 214
If-else: 121
If only: 210
If-else: 120
If only: 211
If-else: 120
If only: 211
If-else: 121
If only: 210
If-else: 121
If only: 210
If-else: 121
If only: 211
If-else: 120
If only: 211
If-else: 120
If only: 211
If-else: 121
3999999980

As we can see, the if-else blocks do run significantly faster even when the if-conditions are clearly mutually exclusive. Because the two loops take a different length of time, the compiled code must be different for each loop. Apparently the compiler doesn't optimize this. Nor does the JIT or CPU branch prediction entirely. There is still a substantial difference.

My suggestion: Use If-else whenever possible

EDIT: I also tried swapping the two loops and got the same result. If-else if much faster.

EDIT 2: I've added a for-loop around the whole test to eliminate any difference in initialization or warmup. The result is the same.

Daniel Williams
  • 635
  • 7
  • 14
  • I mentioned in my answer that this performance hit only matters if the `doSomething()` was something simple. I would say the test you picked is the definitive in "simple," so this performance hit is indeed about as bad as it can get! – Cort Ammon May 03 '19 at 00:54
  • I'd treat these results with a very large pinch of salt. Drawing reliable conclusions from these types of timing tests is notoriously difficult. – sprinter May 03 '19 at 00:55
  • @sprinter Regarding the difficulty of simple timing tests like this, I also tried swapping the two loops and got the same result. The if-else still performs much better. – Daniel Williams May 03 '19 at 01:00
  • 1
    This answered the question since it shows that the final machine code is not the same (as opposed to bytecode which could be further optimized by the JIT compiler), at the very least for the particular JIT compiler implementation @DanielWilliams is using – Jeck May 03 '19 at 02:05
  • @DanielWilliams unfortunately there's a lot more to it than just swapping the loops. Microbenchmarking in any language is hard but it's especially hard in Java with so much environment control outside the scope of your code. See https://stackoverflow.com/questions/504103/how-do-i-write-a-correct-micro-benchmark-in-java for more details. – sprinter May 03 '19 at 03:11
  • @sprinter correct. see [this](https://stackoverflow.com/a/55968994/1059372) – Eugene May 03 '19 at 11:33
  • @DanielWilliams your point is plain wrong and a bad advice, read [this](https://stackoverflow.com/a/55968994/1059372) – Eugene May 03 '19 at 11:33
  • @Jeck where do you see _any_ machine code in this answer? – Eugene May 03 '19 at 11:38
  • @sprinter the linked question was helpful, didn't realize how sensitive micro-benchmarks were. I've unaccepted the answer because I'm not sure if it's a robust microbenchmark. What exactly would this be missing, considering it's just comparing two code blocks instead of trying to get time/iteration? – Jeck May 03 '19 at 14:50
  • Swapping the two loops and getting the same result takes care of Rules 1, 4, and 5 in that other post regarding microbenchmarks. Regardless of the initialization, order, or warm up of Java code, or any other compilation theory, the if-else *always* runs faster in a fair, real implementation. The post regarding JMH test shows nearly the same timing results. I'll update the post with a more robust microbenchmark. – Daniel Williams May 03 '19 at 19:46
2

There is a difference, though it is rather minor. The key question is whether any step of the process is done by software smart enough to deduce that if x==0, then x==2 and x==5 must be false.

At the Java bytecode level, they typically produce different results. There's no obligation that the compiler be smart enough to analyze the difference. (Eugene's answer to the related question shows that Sun's Java 12 compiler is indeed smart enough to make this optimization for you in some cases)

Just in time Compilers tend to be rather aggressive. They are more likely to realize that the code can only flow through one of the three branches and optimize it away. But that's still a tool-dependent statement. The Java language itself treats them as different.

Now, speaking practically, this will not matter in the slightest unless you are doing a very tight loop. The #1 rule in optimization is "profile, then optimize." There's no reason to optimize such details in at least 99% of cases.

To be specific, in the example you give, even if a compiler and JIT fails to optimize the code for you, the performance cost will be negligible. On an "average" CPU, a successfully predicted branch is roughly a tenth of the cost of a function call, so the fact that you called doSomething() on these branches will dwarf the cost. If the extra calls cause some additional branch misprediction, you may see worse effects, but nothing as expensive as the fact that you called a function.

Now, assuming that doSomething() was actually a placeholder for something fast like x += 1, then you would need to profile to determine if it was right.

So, my recommendation would be to write if/if/if or if/else if/else if based on whichever one is correct. Whichever one makes the most sense for the kind of logic you want to use is the right answer. If this is intended to be a branch where exactly one path is taken, I'd recommend else if. If this is intended to be a case where the function might execute many branches in the future, but it just so happens that the current list of branches are mutually exclusive, do if/if/if to convey to the reader the intended results.

Then profile. Always profile. If you find this function is a hot spot, then consider worrying about whether the if statements are expensive.

As an aside, its difficult for a compiler to prove that they can convert if into else if. It must do an analysis of x to see whether it is possible for another thread to modify it. If it is a local variable, no other thread may modify it. However, if it is a member variable, its possible for another thread to modify x in the middle of your if/if/if block, causing it to take two paths. You may know that nobody else will modify x like this, but the compiler has to prove it before making such an optimization, or at least prove that what it writes is consistent with its implementation for the rules for Java's Memory Model.

Cort Ammon
  • 10,221
  • 31
  • 45
  • for _random_ data though, the decompiled code is [exactly the same](https://stackoverflow.com/a/55968994/1059372) – Eugene May 03 '19 at 11:34
  • @Eugene For the tests you ran they are the same. The result is compiler specific *and* dependent on where `x` is located. In your tests, `x` was always a local variable. Doing the closure analysis to prove that `x` cannot be modified by another thread is easy in that case because Java guarantees no other thread can see a thread's local variables. If `x` was elsewhere, that may not be true (and the OP's snippit did not specify where `x` was) – Cort Ammon May 03 '19 at 15:44
  • @Eugene I've edited to point out that your version of Java 12 is smart enough to make the optimization, in this situation. – Cort Ammon May 03 '19 at 15:46
  • I dont get your point, how would reading `x` would affect if/else and _of course_ this is compiler specific. I also dont know why are bringing memory model into this discussion. – Eugene May 03 '19 at 16:21
  • @Eugene The conversion from if/if to if/else is only valid if the compiler can assume that the value of x does not change between subsequent tests. This could come from `doSomething()` not being fully inspectable at compile time, or it could come from the compiler having to deal with other threads as defined by the Java Memory Model. – Cort Ammon May 03 '19 at 16:30
  • to be honest I dont understand what conversion you are talking about. There is `cmp/je` and potentially `cmp/cmove`. You have something else in your mind? – Eugene May 03 '19 at 16:36
  • @Eugene To convert `if (cond1) ... if (cond2)` to `if(cond1) ... else if (cond2)` you must be able to prove that it is safe to assume `cond1` and `cond2` are mutually exclusive. Doing so involves proving it is safe to assume that `x` does not change between evaluations of those conditions. In some cases that is easy (such as if `x` is a local variable). In other cases, that involves quite a lot more work on the compiler's part. – Cort Ammon May 03 '19 at 16:41
  • this sounds a lot more complicated than I assumed in such a case. Will need to adjust the jmh tests, might take a while. Thx for your input – Eugene May 03 '19 at 16:58
2

Well, only a proper JMH test would prove how fast or not a certain method is. Of course, with the caveat that you should also understand the underlying machine code if you really want to know why the numbers are the way they are. I am leaving that up to you and just presenting the numbers here in this test, only slightly showing you some specifics.

package com.so;

import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Warmup;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

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

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

        new Runner(opt).run();
    }

    private int resolveValueMultipleIfs(IfElseExecutionPlan plan) {

        int x = -1;

        if (plan.value() == 0) {
            x = 0;
        }

        if (plan.value() == 1) {
            x = 1;
        }

        if (plan.value() == 2) {
            x = 2;
        }

        assert x != -1;
        return x;
    }

    private int resolveValueIfElse(IfElseExecutionPlan plan) {
        int x = -1;
        if (plan.value() == 0) {
            x = 0;
        } else if (plan.value() == 1) {
            x = 1;
        } else if (plan.value() == 2) {
            x = 2;
        }

        assert x != -1;
        return x;
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(1)
    public int multipleIf(IfElseExecutionPlan plan) {
        return resolveValueMultipleIfs(plan);
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(1)
    public int ifElse(IfElseExecutionPlan plan) {
        return resolveValueIfElse(plan);
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(value = 1, jvmArgsAppend = "-Xint")
    public int multipleIfsfNoJIT(IfElseExecutionPlan plan) {
        return resolveValueMultipleIfs(plan);
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(value = 1, jvmArgsAppend = "-Xint")
    public int ifElseNoJIT(IfElseExecutionPlan plan) {
        return resolveValueIfElse(plan);
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(value = 1, jvmArgsAppend = "-XX:-TieredCompilation")
    public int multipleIfsC2Only(IfElseExecutionPlan plan) {
        return resolveValueMultipleIfs(plan);
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(value = 1, jvmArgsAppend = "-XX:-TieredCompilation")
    public int ifElseC2Only(IfElseExecutionPlan plan) {
        return resolveValueIfElse(plan);
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(value = 1, jvmArgsAppend = "-XX:TieredStopAtLevel=1")
    public int multipleIfsC1Only(IfElseExecutionPlan plan) {
        return resolveValueMultipleIfs(plan);
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(value = 1, jvmArgsAppend = "-XX:TieredStopAtLevel=1")
    public int ifElseC1Only(IfElseExecutionPlan plan) {
        return resolveValueIfElse(plan);
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(value = 1,
        jvmArgsAppend = {
            "-XX:+UnlockExperimentalVMOptions",
            "-XX:+EagerJVMCI",
            "-Dgraal.ShowConfiguration=info",
            "-XX:+UseJVMCICompiler",
            "-XX:+EnableJVMCI"
        })
    public int multipleIfsGraalVM(IfElseExecutionPlan plan) {
        return resolveValueMultipleIfs(plan);
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(value = 1,
        jvmArgsAppend = {
            "-XX:+UnlockExperimentalVMOptions",
            "-XX:+EagerJVMCI",
            "-Dgraal.ShowConfiguration=info",
            "-XX:+UseJVMCICompiler",
            "-XX:+EnableJVMCI"
        })
    public int ifElseGraalVM(IfElseExecutionPlan plan) {
        return resolveValueIfElse(plan);
    }
}

And here are the results:

IfElseCompare.ifElse              avgt    2    2.826          ns/op
IfElseCompare.multipleIf          avgt    2    3.061          ns/op

IfElseCompare.ifElseC1Only        avgt    2    3.927          ns/op
IfElseCompare.multipleIfsC1Only   avgt    2    4.397          ns/op

IfElseCompare.ifElseC2Only        avgt    2    2.507          ns/op
IfElseCompare.multipleIfsC2Only   avgt    2    2.428          ns/op

IfElseCompare.ifElseGraalVM       avgt    2    2.587          ns/op
IfElseCompare.multipleIfsGraalVM  avgt    2    2.854          ns/op

IfElseCompare.ifElseNoJIT         avgt    2  232.418          ns/op   
IfElseCompare.multipleIfsfNoJIT   avgt    2  303.371          ns/op

If you decompile the version with multiple if conditions:

  0x000000010cf8542c: test   %esi,%esi
  0x000000010cf8542e: je     0x000000010cf8544f             ;*ifne {reexecute=0 rethrow=0 return_oop=0}
                                                            ; - com.so.IfElseCompare::resolveValueMultipleIfs@3 (line 21)

  0x000000010cf85430: cmp    $0x1,%esi
  0x000000010cf85433: je     0x000000010cf8545e             ;*if_icmpne {reexecute=0 rethrow=0 return_oop=0}
                                                            ; - com.so.IfElseCompare::resolveValueMultipleIfs@10 (line 25)

  0x000000010cf85435: cmp    $0x2,%esi
  0x000000010cf85438: je     0x000000010cf8546e             ;*if_icmpne {reexecute=0 rethrow=0 return_oop=0}
                                                            ; - com.so.IfElseCompare::resolveValueMultipleIfs@17 (line 29)

A series of cmp/je - compare and jump if equal, well, very much expected.

The decompiled code for if/else is the same thing (I'll let you decompile and see with your own eyes); The generated ASM code using (java-12):

java -XX:+UnlockDiagnosticVMOptions  
     -XX:CICompilerCount=2 
     -XX:-TieredCompilation  
     "-XX:CompileCommand=print,com/so/IfElseCompare.resolveValueMultipleIfs"  
     com.so.IfElseCompare
Eugene
  • 117,005
  • 15
  • 201
  • 306
  • Since the machine code appears to be the same, would the ~10% variation in times plausibly be just noise? – Jeck May 03 '19 at 15:09
  • @Jeck most probably, yes. I might re-run these for a lot longer and more heap, may be that evens the results slightly. This is still under nano-second difference... – Eugene May 03 '19 at 15:38
-1

Let me tell you how conditional operator "if()" works. When you write if() statement, it checks the truthfulness of the condition that you have provided in these "()". If the condition fails then the compiler looks for alternate statement or block of code which can be used when if() condition fails. Now for this alternate content we use "else" block.

Now according to your question, the answer is pretty easy to understand. There is a big difference in both ways.

1). Multiple If statements

if (x == 0) doSomething();
if (x == 2) doSomething();
if (x == 5) doSomething();

In above code all the if statements will be parsed by the compiler whether any of the condition is satisfied or not. Because they are used separately and without any alternative part.

2). Chained If-else statements

if (x == 0) doSomething();
else if (x == 2) doSomething();
else if (x == 5) doSomething();

Now in above code there is one main condition checker (x==0) now if this fails then there are other alternatives present so compiler will check them until it finds the satisfactory solution.

Performance Issue

In first case the compiler has to check each condition since they are all separate and this may take more time. But in second case it will only compile the "else if" part when the if() statement fails to meet the conditions. So yes there can be a little bit difference between them in case of performance.

I hope it helps.

Pradhumn Sharma
  • 1,663
  • 2
  • 10
  • 19