dotnet / runtime

.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.
https://docs.microsoft.com/dotnet/core/
MIT License
15.44k stars 4.76k forks source link

ILC: Virtual method resolution likely uses exponential algorithm #103034

Open filipnavara opened 5 months ago

filipnavara commented 5 months ago

When compile a large application that uses WinForms there are many classes that derive from System.Windows.Forms.Control/Form and have class hierarchy depth around 12-13 levels deep.

This hits a code path in ILC type system that doesn't scale well:

image

Bulk of the time in the marking phase (several minutes) is spent in Internal.TypeSystem.MetadataVirtualMethodAlgorithm.ResolveInterfaceMethodToVirtualMethodOnType(MethodDesc, MetadataType). It recursively calls into itself through ResolveInterfaceMethodToVirtualMethodOnTypeRecursive. ResolveInterfaceMethodToVirtualMethodOnTypeRecursive also walks the class hierarchy upwards. This results in an algorithm that doesn't scale well with the class hierarchy depth.

Perhaps there's a way to short-circuit it to prevent double recursion? Or maybe the algorithm can be rewritten in more efficient manner? Dynamic programming? I am open to ideas.

Fixing this is likely to cut the compilation time by several minutes for this particular application. I expect it to affect every WinForms application (once the scenario is supported) in one way or another.

dotnet-policy-service[bot] commented 5 months ago

Tagging subscribers to this area: @agocke, @MichalStrehovsky, @jkotas See info in area-owners.md if you want to be subscribed.

filipnavara commented 5 months ago

One thing I forgot to note is that this is happening in the non-parallelized mark part of the scanning. There's likely a brute force option to precompute some of this information in parallel, but I would consider it a last resort.

MichalStrehovsky commented 5 months ago

What we generally do is "do the best to avoid calling into this multiple times". I made multiple fixes in the past to reduce number of times we need to resolve virtual/interface methods. There's probably still opportunities left.

I tried to introduce a straight cache in front of this in the past, it just made things worse. I can see how a cache could make the WinForms case better, but it will probably regress any other normal app where this is not the hottest thing.

filipnavara commented 5 months ago

This accounts for somewhere between 30%-50% of the compilation time for our app (likely > 3 minutes) so it's quite a huge problematic spot. The compiler basically spends > 0.01 seconds for each type derived from Control/Form in MightHaveInterfaceDispatchMap in our case, which quickly adds up, especially since this phase is not running in parallel.

I understand that straightforward cache may make things worse but the algorithm itself seems to be doing some extremely bad tree traversal (with some pruning).

filipnavara commented 5 months ago

Throwing out a wild idea:

--- a/src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/DependencyAnalysis/InterfaceDispatchMapNode.cs
+++ b/src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/DependencyAnalysis/InterfaceDispatchMapNode.cs
@@ -89,7 +89,7 @@ public static bool MightHaveInterfaceDispatchMap(TypeDesc type, NodeFactory fact

             DefType declType = type.GetClosestDefType();

-            for (int interfaceIndex = 0; interfaceIndex < declType.RuntimeInterfaces.Length; interfaceIndex++)
+            for (int interfaceIndex = declType.RuntimeInterfaces.Length - 1; interfaceIndex >= 0; interfaceIndex--)
             {
                 DefType interfaceType = declType.RuntimeInterfaces[interfaceIndex];
                 InstantiatedType interfaceOnDefinitionType = interfaceType.IsTypeDefinition ?

This seems to save around 1-2 minutes on the compilation time with some benchmarked invocations of MightHaveInterfaceDispatchMap showing 9x improvements. Presumably the last interfaces on the list are more likely to be coming from the less nested classes and allow short circuiting earlier in most cases?

filipnavara commented 5 months ago

Second naive idea:

--- a/src/coreclr/tools/Common/TypeSystem/Common/MetadataVirtualMethodAlgorithm.cs
+++ b/src/coreclr/tools/Common/TypeSystem/Common/MetadataVirtualMethodAlgorithm.cs
@@ -610,7 +610,7 @@ public override MethodDesc ResolveVariantInterfaceMethodToStaticVirtualMethodOnT
         //    function returns null if the interface method implementation is not defined by the current type in
         //    the hierarchy.For variance to work correctly, this requires that interfaces be queried in correct order.
         //    See current interface call resolution for details on how that happens.
-        private static MethodDesc ResolveInterfaceMethodToVirtualMethodOnType(MethodDesc interfaceMethod, MetadataType currentType)
+        private static MethodDesc ResolveInterfaceMethodToVirtualMethodOnType(MethodDesc interfaceMethod, MetadataType currentType, bool returnRecursive = false)
         {
             Debug.Assert(!interfaceMethod.Signature.IsStatic);

@@ -665,7 +665,7 @@ private static MethodDesc ResolveInterfaceMethodToVirtualMethodOnType(MethodDesc
                 MethodDesc baseClassImplementationOfInterfaceMethod = ResolveInterfaceMethodToVirtualMethodOnTypeRecursive(interfaceMethod, baseType);
                 if (baseClassImplementationOfInterfaceMethod != null)
                 {
-                    return null;
+                    return returnRecursive ? baseClassImplementationOfInterfaceMethod : null;
                 }
                 else
                 {
@@ -729,7 +729,7 @@ private static MethodDesc ResolveInterfaceMethodToVirtualMethodOnTypeRecursive(M
                     return null;
                 }

-                MethodDesc currentTypeInterfaceResolution = ResolveInterfaceMethodToVirtualMethodOnType(interfaceMethod, currentType);
+                MethodDesc currentTypeInterfaceResolution = ResolveInterfaceMethodToVirtualMethodOnType(interfaceMethod, currentType, returnRecursive: true);
                 if (currentTypeInterfaceResolution != null)
                     return currentTypeInterfaceResolution;
MichalStrehovsky commented 5 months ago

The first one looks reasonable to me. The second one is a change to the virtual method resolution algorithm so I can't really comment on that.

filipnavara commented 5 months ago

The idea behind the second one is that you are already in an outer loop which goes up in the class hierarchy so the result should essentially not change and you don't have to recompute it time and again. I'm not sure how sound the idea is and who would be the right person to review it. As a smoke test I can run the compilation with both code paths and check that the returned values match.

(UPD: Smoke test passed.)