It probably comes down to code size and inlining rules. The first function will be smaller and will not contain a throw
and therefore might be able to be inlined into the calling method. This won't show up on a microbenchmark of just this method as the inlining will not be performed but in real code this can have an impact especially if the method is in an inner loop.
This issue contains more detailed discussion: https://github.com/dotnet/runtime/issues/4381
The assasembly code generated on SharpLab:
C.EnsureReadSize1(Int32)
L0000: push edi
L0001: push esi
L0002: mov esi, ecx
L0004: mov eax, [esi+4]
L0007: mov ecx, eax
L0009: sar ecx, 0x1f
L000c: add edx, eax
L000e: adc ecx, 0
L0011: mov eax, [esi+8]
L0014: mov edi, eax
L0016: sar edi, 0x1f
L0019: cmp eax, edx
L001b: sbb edi, ecx
L001d: jl short L002b
L001f: mov byte ptr [esi+0xc], 1
L0023: mov eax, 1
L0028: pop esi
L0029: pop edi
L002a: ret
L002b: mov ecx, esi
L002d: call dword ptr [0x1a44c70c]
L0033: mov byte ptr [esi+0xc], 0
L0037: xor eax, eax
L0039: pop esi
L003a: pop edi
L003b: ret
C.ThrowException()
L0000: push esi
L0001: cmp byte ptr [ecx+0xd], 0
L0005: jne short L0009
L0007: pop esi
L0008: ret
L0009: mov ecx, 0x6005e28
L000e: call 0x05b630d0
L0013: mov esi, eax
L0015: mov ecx, 1
L001a: mov edx, 0x1a44c040
L001f: call 0x72184e00
L0024: mov edx, eax
L0026: mov ecx, esi
L0028: call System.InvalidOperationException..ctor(System.String)
L002d: mov ecx, esi
L002f: call 0x72178100
L0034: int3
C.EnsureReadSize2(Int32)
L0000: push ebp
L0001: mov ebp, esp
L0003: push edi
L0004: push esi
L0005: mov eax, [ecx+4]
L0008: mov esi, eax
L000a: sar esi, 0x1f
L000d: add edx, eax
L000f: adc esi, 0
L0012: mov eax, [ecx+8]
L0015: mov edi, eax
L0017: sar edi, 0x1f
L001a: cmp eax, edx
L001c: sbb edi, esi
L001e: jl short L002d
L0020: mov byte ptr [ecx+0xc], 1
L0024: mov eax, 1
L0029: pop esi
L002a: pop edi
L002b: pop ebp
L002c: ret
L002d: cmp byte ptr [ecx+0xd], 0
L0031: jne short L003d
L0033: mov byte ptr [ecx+0xc], 0
L0037: xor eax, eax
L0039: pop esi
L003a: pop edi
L003b: pop ebp
L003c: ret
L003d: mov ecx, 0x6005e28
L0042: call 0x05b630d0
L0047: mov esi, eax
L0049: mov ecx, 1
L004e: mov edx, 0x1a44c040
L0053: call 0x72184e00
L0058: mov edx, eax
L005a: mov ecx, esi
L005c: call System.InvalidOperationException..ctor(System.String)
L0061: mov ecx, esi
L0063: call 0x72178100
L0068: int3
Edit
Use a longer run to get more consistent results - the difference between the two results was about the same as the overhead cost of running the benchmark. I changed the number of inner iterations to 4096.
Use a throw helper which is not inlined as it will help the containing method be inlined.
No need to use inlining hint for JIT if the method is small and does not contain a throw
. In fact the inlining hint does not do anything for the version which has a string constructor but it does force inlining when the parameterless constructor is used.
It's perhaps strangely coincidental that the throw helpers here all seem to take only a constant enum as a parameter which is then transformed into a string inside the throw helper.
Use BenchmarkDotNet dissasembly diagnoser to get an idea of when inlining actually happens.
Another blog entry with more information on how methods are considered for inlining: https://egorbo.com/how-inlining-works.html