Null-coalescing Operator vs. Equals - Equals

I have been asked the following question over-and-over: which solution is the faster and better to use in the condition part of the if keyword, to branch based on a property which might be defined on a nullable reference type:

  • to use the null-coalescing operator (??)

  • or the equals operator (==)

To give some context let's assume that we branch our code based on a reference type's bool property:

public class SomeClass
{
    public bool Condition { get; set; }
}

We might not be sure if the reference type has a value. To express this in a nullable reference type semantics, we could say that we are testing against a property defined as:

public SomeClass? Instance { get; set; }

Benchmarkig the code

To perform a benchmark comparison, I use the BenchmarkDotNet library from NuGet.

I have two set of tests, one where the Instance property has an actual object assigned to its backing field, and one where it returns null.

The actual initial benchmark methods:

[Benchmark]
public int EqualsEquals()
{
    if (Instance?.Condition == true)
        return Param1;
    return Param2;
}

[Benchmark]
public int QuestionmarkQuestionmark()
{
    if (Instance?.Condition ?? false)
        return Param1;
    return Param2;
}

In the EqualsEquals method I am using a comparison with the == operator against true, so if the Instance is not null and the Condition property is true, Param1 is returned, otherwise Param2.

In the QuestionmarkQuestionmark method I am using the ?? operator and substitute the null case with false. When the Instance is not null and Condition property is true, Param1 is returned, otherwise Param2.

Both Param1 and Param2 are parameterized by BenchmarkDotNet [Params] attribute, to avoid hardcoded values and shortcuts by the compiler.These tests also have a return value to avoid dangling code optimizations.

I am using .net core 2.2 to perform the benchmarks.

BenchmarkDotNet=v0.11.5, OS=Windows 10.0.17134.950 (1803/April2018Update/Redstone4), VM=Hyper-V
Intel Xeon CPU E5-2673 v3 2.40GHz, 1 CPU, 2 logical and 2 physical cores
.NET Core SDK=2.2.203
  [Host]     : .NET Core 2.2.4 (CoreCLR 4.6.27521.02, CoreFX 4.6.27521.01), 64bit RyuJIT
  DefaultJob : .NET Core 2.2.4 (CoreCLR 4.6.27521.02, CoreFX 4.6.27521.01), 64bit RyuJIT

Method

Param1

Param2

Mean

Error

StdDev

Median

EqualsEquals

18

12

0.0074 ns

0.0139 ns

0.0116 ns

0.0000 ns

QuestionmarkQuestionmark

18

12

0.0688 ns

0.0425 ns

0.0650 ns

0.0613 ns

EqualsEqualsNull

18

12

0.4651 ns

0.0533 ns

0.1065 ns

0.4506 ns

QuestionmarkQuestionmarkNull

18

12

0.4673 ns

0.0548 ns

0.1167 ns

0.4452 ns

MethodName suffixed with 'Null' indicates when the instance is null.

The initial benchmarks show significant difference in the case when the Instance property was not null. Comparison with the equals operator is significantly faster. Having a second look, we may notice that the Error column shows a larger value in case of the null-coalescing operator. Is this just some negligible difference caused by measurement errors?

Repeating the tests confirms that measurement errors may occur for the EqualsEquals testcase as well, having the results in opposite ratio. To mitigate the measurement error, I increase the test length with an iterator and scale the results with OperationsPerInvoke attribute:

[Benchmark(OperationsPerInvoke = 10000)]
public int QuestionmarkQuestionmark()
{
    int result = 0;
    for (int i = 0; i < 10000; i++)
    {
        if (Instance?.Condition ?? false)
            result = Param1;
        else
            result = Param2;
    }
    return result;
}

Repeating the benchmarks now show a more balanced result:

Method

Param1

Param2

Mean

Error

StdDev

EqualsEquals

18

12

0.8224 ns

0.0162 ns

0.0373 ns

QuestionmarkQuestionmark

18

12

0.7757 ns

0.0150 ns

0.0220 ns

EqualsEqualsNull

18

12

0.8161 ns

0.0173 ns

0.0337 ns

QuestionmarkQuestionmarkNull

18

12

0.7992 ns

0.0160 ns

0.0323 ns

What is inside the box?

Are these solution equivalent? Do they produce the same IL and machine code? These are the questions I am answering in the following sections.

First, let's compare the generated IL for both solutions.

The EqualsEquals method - without the for loop - has the following IL code. For the better understanding I add some description as comments after each line.

.method public hidebysig 
	instance int32 EqualsEquals () cil managed 
{
	// Method begins at RVA 0x226d
	// Code size 34 (0x22)
	.maxstack 8

	// SomeClass instance = this.Instance;
	IL_0000: ldarg.0 // Loads 'this' 
	IL_0001: call instance class IfNullTestBenchmark.SomeClass IfNullTestBenchmark.Program::get_Instance()
	// if (instance != null && instance.Condition)
	IL_0006: dup // Duplicates topmost value on the evaluation stack, at this point the value of Instance property
	IL_0007: brtrue.s IL_000d // If the value is not null, jumps to IL_000d

	// (no C# code)
	IL_0009: pop // If the value is null, pop the top of eval stack (Instance property)
	IL_000a: ldc.i4.0 // Pushes the integer value of 0 onto the evaluation stack 
	IL_000b: br.s IL_0012 // Jumps to IL_0012

	IL_000d: call instance bool IfNullTestBenchmark.SomeClass::get_Condition()

	IL_0012: brfalse.s IL_001b // Transfers control to a target instruction if value is false, a null reference, or zero. If the code jumped here from IL_000b, this condition will always be false, so it jumps ahead to IL_001b. Otherwise it branches based on the value of the Condition property.

	// return this.Param1;
	IL_0014: ldarg.0
	IL_0015: call instance int32 IfNullTestBenchmark.Program::get_Param1()
	// (no C# code)
	IL_001a: ret

	// return this.Param2;
	IL_001b: ldarg.0
	IL_001c: call instance int32 IfNullTestBenchmark.Program::get_Param2()
	// (no C# code)
	IL_0021: ret
} // end of method Program::EqualsEquals

Comparing this with the QuestionmarkQuestionmark, the only difference we may notice is the name of the method.

The final question I have, if there are any differences (optimizations) made by the JIT compiler to the above logic of the IL code. To check the machine code, I will use WinDBG with some inserted breakpoints, where I may examine the machine code.

  • Fire up WinDBG

  • Load SOS extension for the coreclr

  • Find the method in the method table with !name2ee command

  • Proint the Jitted code with !u

0:008> .loadby sos coreclr
0:008> !name2ee IfNullTestBenchmark!Program.EqualsEquals
Module:      00007ff99f674590
Assembly:    IfNullTestBenchmark.dll
Token:       000000000600001a
MethodDesc:  00007ff99f6755c8
Name:        IfNullTestBenchmark.Program.EqualsEquals()
JITTED Code Address: 00007ff99f791590
0:008> !U /d 00007ff99f791590

[path]\IfNullTestBenchmark\Program.cs @ 27:
>>> 00007ff9`9f791590 488b4108        mov     rax,qword ptr [rcx+8]
00007ff9`9f791594 4885c0          test    rax,rax
00007ff9`9f791597 740c            je      00007ff9`9f7915a5
00007ff9`9f791599 0fb64008        movzx   eax,byte ptr [rax+8]
00007ff9`9f79159d 85c0            test    eax,eax
00007ff9`9f79159f 7404            je      00007ff9`9f7915a5
00007ff9`9f7915a1 8b4110          mov     eax,dword ptr [rcx+10h]
00007ff9`9f7915a4 c3              ret
00007ff9`9f7915a5 8b4114          mov     eax,dword ptr [rcx+14h]
00007ff9`9f7915a8 c3              ret

Looking at the code, we may notice that the additional jump (when Instance was null in the IL code) has been optimized away, and the logic stands a lot closer to the logic of the C# method. It can be also confirmed that the QuestionmarkQuestionmark compiles exactly to the same code.

So are there any differences between the two approaches? It seems that the current C# compiler and RyuJIT compiles our code exactly to the same machine code, leaving us only with the textual difference in the source code.So which one shall I use? I think it is up to the developer, whichever is easier to read and understand in the given context. I would choose the one which has a smaller mental weight in the given context.