Null-coalescing Operator vs. Equals - Equals
08/31/2019
6 minutes
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.