The Best Way to ArgumentValidation
02/25/2023
8 minutes
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
andArgumentValidationNullCoalescing
?
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 theop_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 |