Laszlo

Hello, I am Laszlo

Software-Engineer, .NET developer

Contact Me

Deep dive into .NET 11 Performance Improvements #5

In this series, I explore some of the most interesting .NET 11 performance improvements described in Stephen Toub's blog post, Performance Improvements in .NET 11.

Each post focuses on one example from the original article while digging a little deeper into an additional aspect that was not covered there. The series covers the following topics:

  1. Allocation free numbers
  2. Virtual generic method calls
  3. Vector zero equality
  4. Guid creation on Linux
  5. Current post: JIT generating implicit-else assembly instructions
  6. Guid equality
  7. Copy-on-write locking strategy

JIT generating implicit-else assembly instructions

In this optimization, the JIT improves if-conversion, which can allow an if-else statement to be represented as a conditional operation. This can be useful when a branch is difficult to predict: a misprediction can waste CPU cycles while the processor recovers. A conditional move can avoid a control flow branch, but it is not always faster; performance depends on the surrounding code and the target CPU. Consider this example:

private static int ImplicitElse(int left)
{
    int count = 0;
    if (left < 1000)
        count += 1;
    else
        count += -1;
    return count;
}

The ImplicitElse method is JIT-compiled to the following x64 assembly:

; Benchmarks.ImplicitElse(Int32)
       mov       eax,0FFFFFFFF
       mov       edx,1
       cmp       ecx,3E8
       cmovl     eax,edx
       ret
; Total bytes of code 20

The cmp instruction compares left with 0x3E8 (1000 in decimal), and cmovl is a conditional move. The eax register initially holds -1, while edx holds 1. If the comparison indicates that left is less than 1000, cmovl copies the value in edx to eax; otherwise, eax remains -1.

Conditional moves can be useful when branch outcomes are hard to predict, such as when comparing random values. However, the JIT does not always preserve this code shape when the logic is placed in a loop. In my tests, a loop with three iterations was unrolled, producing three cmovl instructions. With ten iterations, the generated code changes:

private static int ImplicitElse(int left)
{
    int count = 0;
    // Different assembly depending on the iteration limit: 1,3 and 10
    for (int i = 0; i < 10; i++)
    {
        if (left < 1000)
            count += 1;
        else
            count += -1;
    }
    return count;
}

The conditional move is replaced by conditional branches and jumps:

; Benchmarks.ImplicitElse(Int32)
       xor       eax,eax
       mov       edx,0A
M01_L00:
       cmp       ecx,3E8
       jge       short 0000000000002176
       inc       eax
M01_L01:
       dec       edx
       jne       short 0000000000002167
       ret
M01_L02:
       dec       eax
       jmp       short 0000000000002171
; Total bytes of code 26

In this example, left < 1000 is loop-invariant, so the branch outcome remains the same on every iteration; the disassembly alone does not show that this version is slower.

This could be an opportunity for a future JIT improvement, but how much performance would replacing these branches actually gain? To explore that, I changed the comparison to use i instead of 1000:

private static int ImplicitElse(int left)
{
    int count = 0;
    for (int i = 0; i < 10; i++)
    {
        if (left < i)
            count += 1;
        else
            count += -1;
    }
    return count;
}

Next, I tried using arithmetic right shifting to extract the sign bit of the difference. The expression contributes +1 when left - i is negative and -1 otherwise:

    private static int ImplicitElseNoInlined(int left)
    {
        int count = 0;
        for (int i = 0; i < 10; i++)
            count -= ((left - i) >> 31) | 1;
        return count;
    }

This is not completely equivalent: when left is close to int.MinValue and i is positive, the subtraction can overflow and produce the wrong sign. One can use a checked block for correctness, but this expression would still have narrower preconditions.

; Benchmarks.ImplicitElseNoInlined(Int32)
       xor       eax,eax
       mov       edx,0A
M01_L00:
       mov       r8d,ecx
       sar       r8d,1F
       or        r8d,1
       sub       eax,r8d
       dec       ecx
       dec       edx
       jne       short 00000000000023E7
       ret
; Total bytes of code 28

In my benchmark, this alternative was a few nanoseconds faster for this example. It does not use cmovl, and the result does not establish that it will be faster in other cases; I would still be interested in whether C# can express a pattern that reliably leads the JIT to generate a conditional move.

Conclusion

This post examined how the .NET 11 JIT can generate a conditional move for a simple if-else, while emitting branches for similar logic inside a loop. A bit shift based alternative was a few nanoseconds faster in this benchmark, but it is not equivalent for every integer value and the result may not generalize to other workloads.