The Best Way to ArgumentValidation

Introduction to Argument Validation

In .NET7 we can do 3 different style of argument validation against null arguments. In this post I will check which one is the most performant.

The first example is the poor man's validation. We check if the input parameter is null, and if so an ArgumentNullException object is instantiated and thrown. This style of argument validation has been around for an awfully long time in .NET.

public int CustomArgumentValidationImpl(Data? input)
{
    if (input == null)
        throw new ArgumentNullException(nameof(input));
    return input.Value;
}

The second type of argument validation has been around for a few year using the null coalescing operator. In this case if the input is null the null coalescing operator will throw the new ArgumentNullException object. Otherwise, the result of the null coalescing is discarded. Personally, I prefer this approach as it avoids the extra if condition and corresponding { } curly braces when coding styles demand such.

public int ArgumentValidationNullCoalescingImpl(Data? input)
{
    _ = input ?? throw new ArgumentNullException(nameof(input));
    return input.Value;
}

Finally, static methods of ArgumentNullException can be also used to validate input argument. The benefit of this approach (from code style point of view) is being the tidiest:

public int ArgumentValidationImpl(Data? input)
{
    ArgumentNullException.ThrowIfNull(input);
    return input.Value;
}

Performance Comparison

I used BenchmarkDotNet to compare the performance results of the three methods. I enabled method optimization and tiered compilation. Note, that to achieve this, the above methods are invoked from a call site named with the corresponding method name, but without the Impl suffix. All methods were given non-null inputs, as that is genereally case we shall optimize for.

|                           Method |  Null |     Mean |    Error |   StdDev |
|--------------------------------- |------ |---------:|---------:|---------:|
|               ArgumentValidation | False | 10.89 ns | 0.210 ns | 0.186 ns |
|         CustomArgumentValidation | False | 12.57 ns | 0.040 ns | 0.034 ns |
| ArgumentValidationNullCoalescing | False | 10.44 ns | 0.092 ns | 0.082 ns |

Comparing the results, it is visible that the null coalescing operator and the static ThrowIfNull() method are faster to the custom if condition.

  • Why are these methods faster?

  • Are there any further differences between ArgumentValidation and ArgumentValidationNullCoalescing?

IL Code

First the corresponding part of CustomArgumentValidationImpl's IL code:

IL_0000: ldarg.1
IL_0001: ldnull
IL_0002: call bool Data::op_Equality(class Data, class Data)
IL_0007: brfalse.s IL_0014

// throw new ArgumentNullException("input");
IL_0009: ldstr "input"
IL_000e: newobj instance void [System.Runtime]System.ArgumentNullException::.ctor(string)
IL_0013: throw

It loads the first argument, compares it against null, and when that is null is constructs a new ArgumentNullException object and throws it.

Note, that Data type is record type, hence the op_Equality is called for the comparison.

ArgumentValidationNullCoalescingImpl seems to load and compare the first argument in a more efficient way as there is no method call against the op_Equality. The rest of the IL Code is similar, about constructing and throwing a new ArgumentNullException object.

IL_0000: ldarg.1
IL_0001: brtrue.s IL_000e

// throw new ArgumentNullException("input");
IL_0003: ldstr "input"
IL_0008: newobj instance void [System.Runtime]System.ArgumentNullException::.ctor(string)
IL_000d: throw

Finally, the corresponding IL section of ArgumentValidationImpl:

IL_0000: ldarg.1
IL_0001: ldstr "input"
IL_0006: call void [System.Runtime]System.ArgumentNullException::ThrowIfNull(object, string)

This solution loads the first argument and calls ArgumentNullException::ThrowIfNull method. How can this solution compete with the previous solution, which simply used brtrue - as a method call supposed to be more expensive, doesn't it?

Assembly Code

To further understand these differences, I compared the JITted assembly code of these solutions. To do this, I used a neat new feature of .NET: an environment variable may be set during runtime, and the compiled assembly code will be printed on the output. For example, to display the JITted code of ArgumentValidationImpl the following environment variable may be set:

$env:DOTNET_JitDisasm="ArgumentValidationImpl"

CustomArgumentValidationImpl has the longest assembly code, a total of 100 bytes. It does what is described by the IL. It compares null with the Equality method of Data record type. When the result is null, it creates and throws the exception at G_M000_IG06.

; Assembly listing for method Benchmarks:CustomArgumentValidationImpl(Data):int:this
; Emitting BLENDED_CODE for X64 CPU with AVX - Windows
; Tier-1 compilation
; optimized code
; rsp based frame
; partially interruptible
; No PGO data
; 0 inlinees with PGO data; 1 single block inlinees; 1 inlinees without PGO data

G_M000_IG01:                ;; offset=0000H
       56                   push     rsi
       4883EC20             sub      rsp, 32
       488BF2               mov      rsi, rdx

G_M000_IG02:                ;; offset=0008H
       4885F6               test     rsi, rsi
       741C                 je       SHORT G_M000_IG06

G_M000_IG03:                ;; offset=000DH
       488BCE               mov      rcx, rsi
       33D2                 xor      rdx, rdx
       488B06               mov      rax, qword ptr [rsi]
       488B4040             mov      rax, qword ptr [rax+40H]
       FF5030               call     [rax+30H]Data:Equals(Data):bool:this
       85C0                 test     eax, eax
       7509                 jne      SHORT G_M000_IG06

G_M000_IG04:                ;; offset=0020H
       8B4608               mov      eax, dword ptr [rsi+08H]

G_M000_IG05:                ;; offset=0023H
       4883C420             add      rsp, 32
       5E                   pop      rsi
       C3                   ret

G_M000_IG06:                ;; offset=0029H
       48B9E0AB24DDFD7F0000 mov      rcx, 0x7FFDDD24ABE0
       E838B1AE5F           call     CORINFO_HELP_NEWSFAST
       488BF0               mov      rsi, rax
       B925000000           mov      ecx, 37
       48BA38CF21DDFD7F0000 mov      rdx, 0x7FFDDD21CF38
       E8E126AB5F           call     CORINFO_HELP_STRCNS
       488BD0               mov      rdx, rax
       488BCE               mov      rcx, rsi
       FF1585AD1000         call     [ArgumentNullException:.ctor(String):this]
       488BCE               mov      rcx, rsi
       E86D28995F           call     CORINFO_HELP_THROW
       CC                   int3

; Total bytes of code 100

The assembly code of ArgumentValidationNullCoalescingImpl is significantly shorter, 78 bytes only. The null comparison is optimized, no Equality method is invoked in this case. However, assembly code contains throwing the exception the same way as the previous method, but this time under G_M000_IG04 label.

; Assembly listing for method Benchmarks:ArgumentValidationNullCoalescingImpl(Data):int:this
; Emitting BLENDED_CODE for X64 CPU with AVX - Windows
; Tier-1 compilation
; optimized code
; rsp based frame
; partially interruptible
; No PGO data
; 0 inlinees with PGO data; 1 single block inlinees; 0 inlinees without PGO data

G_M000_IG01:                ;; offset=0000H
       56                   push     rsi
       4883EC20             sub      rsp, 32

G_M000_IG02:                ;; offset=0005H
       4885D2               test     rdx, rdx
       7409                 je       SHORT G_M000_IG04
       8B4208               mov      eax, dword ptr [rdx+08H]

G_M000_IG03:                ;; offset=000DH
       4883C420             add      rsp, 32
       5E                   pop      rsi
       C3                   ret

G_M000_IG04:                ;; offset=0013H
       48B9E0AB21DDFD7F0000 mov      rcx, 0x7FFDDD21ABE0
       E84EB1B15F           call     CORINFO_HELP_NEWSFAST
       488BF0               mov      rsi, rax
       B925000000           mov      ecx, 37
       48BA38CF1EDDFD7F0000 mov      rdx, 0x7FFDDD1ECF38
       E8F726AE5F           call     CORINFO_HELP_STRCNS
       488BD0               mov      rdx, rax
       488BCE               mov      rcx, rsi
       FF159BAD1000         call     [ArgumentNullException:.ctor(String):this]
       488BCE               mov      rcx, rsi
       E883289C5F           call     CORINFO_HELP_THROW
       CC                   int3

; Total bytes of code 78

Finally, the assembly code of ArgumentValidationImpl is the tidiest, 47 bytes only. While the null comparison and branching is the same as for ArgumentValidationNullCoalescingImpl, the exception being thrown at G_M000_IG04 is implemented as a separate method call.

; Tier-1 compilation
; optimized code
; rsp based frame
; partially interruptible
; No PGO data
; 1 inlinees with PGO data; 1 single block inlinees; 0 inlinees without PGO data

G_M000_IG01:                ;; offset=0000H
       4883EC28             sub      rsp, 40

G_M000_IG02:                ;; offset=0004H
       4885D2               test     rdx, rdx
       7408                 je       SHORT G_M000_IG04
       8B4208               mov      eax, dword ptr [rdx+08H]

G_M000_IG03:                ;; offset=000CH
       4883C428             add      rsp, 40
       C3                   ret

G_M000_IG04:                ;; offset=0011H
       B925000000           mov      ecx, 37
       48BA38CF4242FE7F0000 mov      rdx, 0x7FFE4242CF38
       E8BB24AB5F           call     CORINFO_HELP_STRCNS
       488BC8               mov      rcx, rax
       FF1512C71000         call     [ArgumentNullException:Throw(String)]
       CC                   int3

; Total bytes of code 47

Notice, that the .NET runtime can optimize the code the most efficient way for ArgumentNullException.ThrowIfNull(input); method call. While the null check and comparison is inlined, throwing the actual exception is still not inlined, and requires a method call. This way the happy path (when the input is not null) is fast and inlined, while the code for the edge case is not inlined, keeping the size of this method the smallest of the above solutions. Small methods are beneficial for CPU instruction cache, hence gaining an overall advantage for the whole application's performance.

Please note, that the above performance test has been comparing argument validation against a nullable record type. Record types are new to .NET, that follow value semantics. Repeating the same performance test with a non-record type yields different results:

|                           Method |  Null |     Mean |    Error |   StdDev |   Median |
|--------------------------------- |------ |---------:|---------:|---------:|---------:|
|               ArgumentValidation | False | 10.55 ns | 0.080 ns | 0.067 ns | 10.54 ns |
|         CustomArgumentValidation | False | 10.73 ns | 0.116 ns | 0.103 ns | 10.72 ns |
| ArgumentValidationNullCoalescing | False | 11.79 ns | 0.262 ns | 0.491 ns | 11.56 ns |