From 18dd0f566088567ad2d560090a6f53daf58ae571 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Strehovsk=C3=BD?= Date: Wed, 18 Feb 2026 12:47:00 +0900 Subject: [PATCH 1/2] Avoid resolving direct awaits to thunks (IL scanner part) Reacts to #124283. Fixes native AOT outerloops with runtime async turned on. --- .../tools/Common/Compiler/MethodExtensions.cs | 22 +++++++++++++++ .../tools/Common/JitInterface/CorInfoImpl.cs | 24 +--------------- .../IL/ILImporter.Scanner.cs | 28 +++++++++++++++---- .../JitInterface/CorInfoImpl.RyuJit.cs | 2 +- 4 files changed, 46 insertions(+), 30 deletions(-) diff --git a/src/coreclr/tools/Common/Compiler/MethodExtensions.cs b/src/coreclr/tools/Common/Compiler/MethodExtensions.cs index 8494e5bb552037..5949052cb51032 100644 --- a/src/coreclr/tools/Common/Compiler/MethodExtensions.cs +++ b/src/coreclr/tools/Common/Compiler/MethodExtensions.cs @@ -154,6 +154,28 @@ public static bool ReturnsTaskOrValueTask(this MethodSignature method) return false; } + public static bool IsCallEffectivelyDirect(this MethodDesc method) + { + if (!method.IsVirtual) + { + return true; + } + + // Final/sealed has no meaning for interfaces, but might let us devirtualize otherwise + if (method.OwningType.IsInterface) + { + return false; + } + + // Check if we can devirt per metadata + if (method.IsFinal || method.OwningType.IsSealed()) + { + return true; + } + + return false; + } + /// /// Determines whether a method uses the async calling convention. /// Returns true for async variants (compiler-generated wrappers around Task-returning methods) diff --git a/src/coreclr/tools/Common/JitInterface/CorInfoImpl.cs b/src/coreclr/tools/Common/JitInterface/CorInfoImpl.cs index e0943d3fb6c99d..f82c3914a4cf57 100644 --- a/src/coreclr/tools/Common/JitInterface/CorInfoImpl.cs +++ b/src/coreclr/tools/Common/JitInterface/CorInfoImpl.cs @@ -1784,28 +1784,6 @@ private object GetRuntimeDeterminedObjectForToken(ref CORINFO_RESOLVED_TOKEN pRe return result; } - private bool IsCallEffectivelyDirect(MethodDesc method) - { - if (!method.IsVirtual) - { - return true; - } - - // Final/sealed has no meaning for interfaces, but might let us devirtualize otherwise - if (method.OwningType.IsInterface) - { - return false; - } - - // Check if we can devirt per metadata - if (method.IsFinal || method.OwningType.IsSealed()) - { - return true; - } - - return false; - } - private static object GetRuntimeDeterminedObjectForToken(MethodILScope methodIL, object typeOrMethodContext, mdToken token) { object result = ResolveTokenInScope(methodIL, typeOrMethodContext, token); @@ -1903,7 +1881,7 @@ private void resolveToken(ref CORINFO_RESOLVED_TOKEN pResolvedToken) #if !READYTORUN if (allowAsyncVariant) { - bool isDirect = pResolvedToken.tokenType == CorInfoTokenKind.CORINFO_TOKENKIND_Await || IsCallEffectivelyDirect(method); + bool isDirect = pResolvedToken.tokenType == CorInfoTokenKind.CORINFO_TOKENKIND_Await || method.IsCallEffectivelyDirect(); if (isDirect && !method.IsAsync) { // Async variant would be a thunk. Do not resolve direct calls diff --git a/src/coreclr/tools/aot/ILCompiler.Compiler/IL/ILImporter.Scanner.cs b/src/coreclr/tools/aot/ILCompiler.Compiler/IL/ILImporter.Scanner.cs index 2b61264f1fbe33..4394d08ad8240b 100644 --- a/src/coreclr/tools/aot/ILCompiler.Compiler/IL/ILImporter.Scanner.cs +++ b/src/coreclr/tools/aot/ILCompiler.Compiler/IL/ILImporter.Scanner.cs @@ -476,9 +476,27 @@ private void ImportCall(ILOpcode opcode, int token) // If this is the task await pattern, we're actually going to call the variant // so switch our focus to the variant. - if (method.GetTypicalMethodDefinition().Signature.ReturnsTaskOrValueTask() - && !method.OwningType.IsDelegate - && MatchTaskAwaitPattern()) + + // in rare cases a method that returns Task is not actually TaskReturning (i.e. returns T). + // we cannot resolve to an Async variant in such case. + bool allowAsyncVariant = method.GetTypicalMethodDefinition().Signature.ReturnsTaskOrValueTask(); + + // Don't get async variant of Delegate.Invoke method; the pointed to method is not an async variant either. + allowAsyncVariant = allowAsyncVariant && !method.OwningType.IsDelegate; + + if (allowAsyncVariant) + { + bool isDirect = opcode == ILOpcode.call || method.IsCallEffectivelyDirect(); + if (isDirect && !method.IsAsync) + { + // Async variant would be a thunk. Do not resolve direct calls + // to async thunks. That just creates and JITs unnecessary + // thunks, and the thunks are harder for the JIT to optimize. + allowAsyncVariant = false; + } + } + + if (allowAsyncVariant && MatchTaskAwaitPattern()) { runtimeDeterminedMethod = _factory.TypeSystemContext.GetAsyncVariantMethod(runtimeDeterminedMethod); method = _factory.TypeSystemContext.GetAsyncVariantMethod(method); @@ -688,9 +706,7 @@ private void ImportCall(ILOpcode opcode, int token) } else { - if (!targetMethod.IsVirtual || - // Final/sealed has no meaning for interfaces, but lets us devirtualize otherwise - (!targetMethod.OwningType.IsInterface && (targetMethod.IsFinal || targetMethod.OwningType.IsSealed()))) + if (targetMethod.IsCallEffectivelyDirect()) { directCall = true; } diff --git a/src/coreclr/tools/aot/ILCompiler.RyuJit/JitInterface/CorInfoImpl.RyuJit.cs b/src/coreclr/tools/aot/ILCompiler.RyuJit/JitInterface/CorInfoImpl.RyuJit.cs index a0c3129a49241f..052d6572e481fa 100644 --- a/src/coreclr/tools/aot/ILCompiler.RyuJit/JitInterface/CorInfoImpl.RyuJit.cs +++ b/src/coreclr/tools/aot/ILCompiler.RyuJit/JitInterface/CorInfoImpl.RyuJit.cs @@ -1363,7 +1363,7 @@ private void getCallInfo(ref CORINFO_RESOLVED_TOKEN pResolvedToken, CORINFO_RESO else { // We can devirtualize the callvirt if the method is not virtual to begin with - bool canDevirt = IsCallEffectivelyDirect(targetMethod); + bool canDevirt = targetMethod.IsCallEffectivelyDirect(); // We might be able to devirt based on whole program view if (!canDevirt From 4cb71970fe415c97687313e217a87d8cf56d3a35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Strehovsk=C3=BD?= Date: Wed, 18 Feb 2026 12:48:48 +0900 Subject: [PATCH 2/2] Test --- .../async/staticvirtual/staticvirtual.cs | 28 +++++++++++++++++++ .../async/staticvirtual/staticvirtual.csproj | 5 ++++ 2 files changed, 33 insertions(+) create mode 100644 src/tests/async/staticvirtual/staticvirtual.cs create mode 100644 src/tests/async/staticvirtual/staticvirtual.csproj diff --git a/src/tests/async/staticvirtual/staticvirtual.cs b/src/tests/async/staticvirtual/staticvirtual.cs new file mode 100644 index 00000000000000..d74644de6f3e59 --- /dev/null +++ b/src/tests/async/staticvirtual/staticvirtual.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using Xunit; + +public class StaticVirtual +{ + interface IHaveStaticVirtuals + { + static abstract Task DoTask(); + } + + class ClassWithStaticVirtuals : IHaveStaticVirtuals + { + public static async Task DoTask() => await Task.Yield(); + } + + static async Task CallDoTask() where T : IHaveStaticVirtuals => await T.DoTask(); + + [Fact] + public static void TestEntryPoint() + { + CallDoTask().Wait(); + } +} diff --git a/src/tests/async/staticvirtual/staticvirtual.csproj b/src/tests/async/staticvirtual/staticvirtual.csproj new file mode 100644 index 00000000000000..3fc50cde4b3443 --- /dev/null +++ b/src/tests/async/staticvirtual/staticvirtual.csproj @@ -0,0 +1,5 @@ + + + + +