Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Consistency: Chains a call to NotNull #45

Closed
scottdorman opened this issue Sep 26, 2020 · 12 comments
Closed

Consistency: Chains a call to NotNull #45

scottdorman opened this issue Sep 26, 2020 · 12 comments

Comments

@scottdorman
Copy link
Contributor

In all of the other methods that validate the parameter is not null, we're explicitly testing if (value is null) with a comment that reads

// To whoever is doing random code cleaning:
// Consider the performance when changing the code to delegate to NotNull.
// In general do not chain call to another function, check first and return as earlier as possible.

However, in Requires.NotNullOrEmpty we don't do that and instead, call NotNull. Should we change this so it's consistent?

NotNull(values, parameterName);

@AArnott
Copy link
Owner

AArnott commented Sep 27, 2020

Actually I think we should go the other way: go ahead and use NotNull everywhere. The reason the JIT doesn't inline (I've since learned) is probably because the method contains a throw statement. So we should move the throw to a ThrowHelper (.NET does this all the time in the BCL) so the JIT will inline the call to NotNull everywhere -- not just in this class but in all callers too.

Do you know how to test what the JIT inlines so we can validate either hypothesis?

But if you do make a change to this code, please send a PR to vs-validation as well to help them stay in sync. :)

@scottdorman
Copy link
Contributor Author

I can change them to call NotNull and add a ThrowHelper.

I don't know how to validate what the JIT inlines, though, short of looking at the compiled code in something like JustDecompile...or is that what you're thinking of?

I'll submit a separate PR for this, and then once it gets approved will submit one to vs-validation. That way, any changes that might be needed can be done here first and the PR to vs-validation ends up being cleaner.

@scottdorman
Copy link
Contributor Author

So, this is interesting. I used shaplab.io to look at the JIT Asm code for a much slimmed down version of the Requires class. (I did have to remove the generics as sharplab can't JIT-compile open generics, but I think it's a close enough comparison.)

#nullable enable
namespace Validation
{
    using System;
    using System.Collections.Generic;
    using System.Diagnostics.CodeAnalysis;
    using System.Runtime;
    using System.Runtime.CompilerServices;
    
    public static class Requires
    {
        [TargetedPatchingOptOut("Performance critical to inline across NGen image boundaries")]
        public static IEnumerable<string> NotNull([NotNull] IEnumerable<string> value, string? parameterName)
        {
            if (value is null) throw new ArgumentNullException(parameterName);
            return value;
        }

        public static void NotNullEmptyOrNullElements([NotNull] IEnumerable<string> values, string? parameterName)
        {
            NotNull(values, parameterName);
            bool hasElements = false;
            foreach (string? value in values)
            {
                hasElements = true;
                if (value is null) throw new ArgumentException(Format("{0} cannot be null", parameterName), parameterName);
            }

            if (!hasElements) throw new ArgumentException(Format("{0} cannot be an empty array", parameterName), parameterName);
        }

        private static string Format(string format, params object?[] arguments) => String.Format(format, arguments);
    }
}
#nullable restore

The generated IL (in Release mode) for NotNullEmptyOrNullElements looks like this:

Validation.Requires.NotNullEmptyOrNullElements(System.Collections.Generic.IEnumerable`1<System.String>, System.String)
    L0000: push ebp
    L0001: mov ebp, esp
    L0003: push edi
    L0004: push esi
    L0005: push ebx
    L0006: sub esp, 0x18
    L0009: xor eax, eax
    L000b: mov [ebp-0x20], eax
    L000e: mov [ebp-0x1c], eax
    L0011: mov [ebp-0x18], eax
    L0014: mov [ebp-0x14], eax
    L0017: mov esi, edx
    L0019: test ecx, ecx
    L001b: je L00fa
    L0021: xor edi, edi
    L0023: call dword ptr [0x265e7010]
    L0029: mov [ebp-0x24], eax
    L002c: mov ecx, [ebp-0x24]
    L002f: call dword ptr [0x265e7014]
    L0035: test eax, eax
    L0037: je L00d1
    L003d: mov ecx, [ebp-0x24]
    L0040: call dword ptr [0x265e7018]
    L0046: mov edi, 1
    L004b: test eax, eax
    L004d: je short L005e
    L004f: mov ecx, [ebp-0x24]
    L0052: call dword ptr [0x265e7014]
    L0058: test eax, eax
    L005a: jne short L003d
    L005c: jmp short L00d1
    L005e: mov ecx, 0x65c3a7c
    L0063: mov edx, 1
    L0068: call 0x064d31c8
    L006d: mov edi, eax
    L006f: push esi
    L0070: mov ecx, edi
    L0072: xor edx, edx
    L0074: call 0x0f3f7e70
    L0079: mov ecx, 0x6c214d0
    L007e: call 0x064d30cc
    L0083: mov ebx, eax
    L0085: mov ecx, 1
    L008a: mov edx, 0x265ec040
    L008f: call 0x0f39ad30
    L0094: mov ecx, eax
    L0096: mov edx, edi
    L0098: call System.String.Format(System.String, System.Object[])
    L009d: mov dword ptr [ebx+0x38], 0xe0434352
    L00a4: mov dword ptr [ebx+0x3c], 0x80131500
    L00ab: lea edx, [ebx+8]
    L00ae: call 0x0f3da350
    L00b3: mov dword ptr [ebx+0x3c], 0x80131501
    L00ba: lea edx, [ebx+0x40]
    L00bd: call 0x0f3da3e0
    L00c2: mov dword ptr [ebx+0x3c], 0x80070057
    L00c9: mov ecx, ebx
    L00cb: call 0x0f380620
    L00d0: int3
    L00d1: mov ecx, [ebp-0x24]
    L00d4: call dword ptr [0x265e701c]
    L00da: jmp short L00ee
    L00dc: cmp dword ptr [ebp-0x24], 0
    L00e0: je short L00eb
    L00e2: mov ecx, [ebp-0x24]
    L00e5: call dword ptr [0x265e701c]
    L00eb: pop eax
    L00ec: jmp eax
    L00ee: test edi, edi
    L00f0: je short L0116
    L00f2: lea esp, [ebp-0xc]
    L00f5: pop ebx
    L00f6: pop esi
    L00f7: pop edi
    L00f8: pop ebp
    L00f9: ret
    L00fa: mov ecx, 0x6c215b8
    L00ff: call 0x064d30cc
    L0104: mov edi, eax
    L0106: mov ecx, edi
    L0108: mov edx, esi
    L010a: call System.ArgumentNullException..ctor(System.String)
    L010f: mov ecx, edi
    L0111: call 0x0f380620
    L0116: mov ecx, 0x65c3a7c
    L011b: mov edx, 1
    L0120: call 0x064d31c8
    L0125: mov edi, eax
    L0127: push esi
    L0128: mov ecx, edi
    L012a: xor edx, edx
    L012c: call 0x0f3f7e70
    L0131: mov ecx, 0x6c214d0
    L0136: call 0x064d30cc
    L013b: mov ebx, eax
    L013d: mov ecx, 0x27
    L0142: mov edx, 0x265ec040
    L0147: call 0x0f39ad30
    L014c: mov ecx, eax
    L014e: mov edx, edi
    L0150: call System.String.Format(System.String, System.Object[])
    L0155: mov edx, eax
    L0157: push esi
    L0158: mov ecx, ebx
    L015a: call System.ArgumentException..ctor(System.String, System.String)
    L015f: mov ecx, ebx
    L0161: call 0x0f380620
    L0166: int3

That looks like the call to NotNull is being inlined. In fact, if I add an explicit [MethodImpl(MethodImplOptions.NoInlining)] attribute to NotNull, the first handful of lines look like

Validation.Requires.NotNullEmptyOrNullElements(System.Collections.Generic.IEnumerable`1<System.String>, System.String)
    L0000: push ebp
    L0001: mov ebp, esp
    L0003: push edi
    L0004: push esi
    L0005: push ebx
    L0006: sub esp, 0x18
    L0009: xor eax, eax
    L000b: mov [ebp-0x20], eax
    L000e: mov [ebp-0x1c], eax
    L0011: mov [ebp-0x18], eax
    L0014: mov [ebp-0x14], eax
    L0017: mov esi, ecx
    L0019: mov edi, edx
    L001b: mov ecx, esi
    L001d: mov edx, edi
    L001f: call Validation.Requires.NotNull(System.Collections.Generic.IEnumerable`1<System.String>, System.String)
    L0024: xor ebx, ebx
    L0026: mov ecx, esi
    L0028: call dword ptr [0x26607010]
    L002e: mov [ebp-0x24], eax
    L0031: mov ecx, [ebp-0x24]
    L0034: call dword ptr [0x26607014]
    L003a: test eax, eax

All of the above was using the "Default" compilation option, which is Core CLR v4.700.20.41105 on x86.

If I change it to use Desktop CLR v4.8.4180.00 on x86, I get this as the IL no matter what the MethodImpl attribute says. (So, it appears that the JIT is generating the same instructions whether I have MethodImpl(MethodImplOptions.AggressiveInlining) or not.

Validation.Requires.NotNullEmptyOrNullElements(System.Collections.Generic.IEnumerable`1<System.String>, System.String)
    L0000: push ebp
    L0001: mov ebp, esp
    L0003: push edi
    L0004: push esi
    L0005: sub esp, 0x2c
    L0008: mov esi, ecx
    L000a: lea edi, [ebp-0x28]
    L000d: mov ecx, 0x7
    L0012: xor eax, eax
    L0014: rep stosd
    L0016: mov ecx, esi
    L0018: mov [ebp-0x24], edx
    L001b: mov esi, ecx
    L001d: mov ecx, esi
    L001f: mov edx, [ebp-0x24]
    L0022: call Validation.Requires.NotNull(System.Collections.Generic.IEnumerable`1<System.String>, System.String)
    L0027: xor edx, edx
    L0029: mov [ebp-0x20], edx
    L002c: mov ecx, esi
    L002e: call dword [0x22e40084]
    L0034: mov [ebp-0x28], eax
    L0037: mov ecx, [ebp-0x28]
    L003a: call dword [0x22e40088]
    L0040: test eax, eax

Even if I added a helper method

private static void ThrowArgumentNullException(string? parameterName) => throw new ArgumentNullException(parameterName);

and changed the code in NotNull to use it instead of throwing the exception directly, I saw no change in the IL here.

Unless I'm missing something, or I'm interpreting the results incorrectly, we wouldn't be able to get the calls to NotNull to inline when this code is running on .NET Framework, but they will when running on .NET Core (and presumably .NET 5.0 as well).

@AArnott
Copy link
Owner

AArnott commented Sep 27, 2020

Very interesting. Well, let's move toward writing out the check and throw by hand then instead of calling NotNull. :)

@scottdorman
Copy link
Contributor Author

Agreed. I was a bit surprised there was that much difference between netstandard2.0 and the. Net Framework. I was even more surprised that the aggressive inlining didn't seem to make any difference.

There are only a couple of places that I saw where NotNull was being called directly.

@AArnott
Copy link
Owner

AArnott commented Sep 27, 2020

at much difference between netstandard2.0 and the. Net Framework.

To be clear, it isn't .NET Standard that makes the difference in what gets inlined. It's the JIT in the runtime. So it's a comparison of .NET Core and .NET Framework.

@scottdorman
Copy link
Contributor Author

True. That's what I meant. I usually think of them as synonymous, but they really aren't.

@scottdorman
Copy link
Contributor Author

Should we only focus on the two NotNull calls? As I was fixing those, I see a lot of similar calls in Report (which chain Fail) and in Assumes (which chains Assumes.NotNull, Fail, True, and False). Since Report is a debug-only class, it seems like those would be ok. Assumes doesn't have a similar of only being in debug code, so should we look at changing these as well (or make it a debug-only class)?

@AArnott
Copy link
Owner

AArnott commented Sep 28, 2020

Ya, I wouldn't worry about perf for Report.
I wouldn't worry about calls to Assumes.Fail either, since at that point we're going down the failure path anyway.
But if you see other places where we forward calls to other methods and you can tell they're not getting inlined on NetFX, sure, I'd take a PR to improve it.

BTW, perf in this area hasn't come up for years. I personally wouldn't bother authoring these micro-optimizations. But if you're passionate about it for whatever reason, so long as it doesn't regress anything, I'm happy to take them.

@scottdorman
Copy link
Contributor Author

I only looked at Assumes. It looks like the calls to True, False, and Fail are being inlined but the calls to NotNull are not. As there are only 3 places in Assumes that call it, I'll go ahead and manually inline the test. It's fairly strange though. According to what I'm seeing in sharplab,

public static void NotNullOrEmpty<T>(ICollection<T>? values)
{
   NotNull(values);
   True(values.Count > 0);
}

won't inline the call to NotNull, but if I change it to this

public static void NotNullOrEmpty<T>(ICollection<T>? values)
{
   True(values is object);
   True(values.Count > 0);
}

then it does inline. What's strange is that True(value is object) is exactly what NotNull does. Could this have something to do with the fact that NotNull is an open generic, so the JIT isn't able to inline it?

@AArnott
Copy link
Owner

AArnott commented Sep 28, 2020

Generics might explain it. The PerfView ETW events include explanations from the JIT as to why something won't inline, BTW.

@scottdorman
Copy link
Contributor Author

Nice. I didn't know the ETW events included explanations. I'll have to check that out.

AArnott added a commit that referenced this issue Sep 29, 2020
Replaces the call to NotNull with a direct test. Closes #45.
AArnott added a commit that referenced this issue May 2, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants