Skip to content

JIT: Devirtualize shared generic virtual methods - #123323

Closed
hez2010 wants to merge 65 commits into
dotnet:mainfrom
hez2010:gvm-devirt-shared
Closed

JIT: Devirtualize shared generic virtual methods#123323
hez2010 wants to merge 65 commits into
dotnet:mainfrom
hez2010:gvm-devirt-shared

Conversation

@hez2010

@hez2010hez2010 commented Jan 18, 2026

Copy link
Copy Markdown
Contributor

Previously in #122023, we hit an issue with GVM devirtualization when the devirtualized target is a shared generic method. GVM calls are imported with a runtime lookup that is specific to the base method. After devirtualization, the call requires the instantiation argument for the implementing method, and the existing lookup cannot be reused.

This PR unblocks devirtualization for shared generic targets by ensuring the call receives the correct instantiation parameter for the devirtualized method:

  • The multiple ad-hoc flags in dvInfo now have been unified into a single instParamLookup

  • When the target does not require a runtime lookup, we already know the exact generic context. We pass the instantiating stub as the inst param (shared with the existing array interface devirtualization path).

  • Store the instantiating stub (when necessary) directly in the exactContext, and devirtualizedMethod now can never be an instantiating stub. Remove the unnecessary getInstantiatedEntry roundtrip.

  • When the target requires a runtime lookup, we now introduced a new DictionaryEntryKind::DevirtualizedMethodDescSlot, and pass it to the instParamLookup so that later the VM knows that it needs to encode the class token from the devirtualized method instead of the original token. And in this case, the devirtualized method pDevirtMD will be passed as template method.

Also due to the instParamLookup change I implement the support for R2R as well.

NativeAOT still needs extra work in JIT to enable GVM devirts.

Example:

IVritualGenericInterfacei=newProcessor();Test(i,"test");VirtualGenericClassc=newProcessor();Test(c,"test");staticvoidTest<T>(IVritualGenericInterfaceifce,Titem)whereT:notnull{ifce.Process(item);}staticvoidTest<T>(VirtualGenericClassbaseClass,Titem)whereT:notnull{baseClass.Process(item);}publicclassProcessor:VirtualGenericClass,IVritualGenericInterface{[MethodImpl(MethodImplOptions.AggressiveInlining)]publicoverridevoidProcess<T>(Titem){Console.WriteLine(typeof(T));Console.WriteLine(item.ToString());}}

Codegen diff:

 G_M27646_IG01:
- push rdi
push rsi
push rbx
- sub rsp, 32+ sub rsp, 40- ;; size=7 bbWeight=1 PerfScore 3.25+ ;; size=6 bbWeight=1 PerfScore 2.25
G_M27646_IG02:
- mov rbx, 0xD1FFAB1E ; Program+Processor+ mov rbx, 0xD1FFAB1E ; 'System.String'
mov rcx, rbx
- call CORINFO_HELP_NEWSFAST+ call [System.Console:WriteLine(System.Object)]- mov rsi, rax+ mov rsi, 0xD1FFAB1E ; 'test'- mov rdi, 0xD1FFAB1E ; 'test'
mov rcx, rsi
- mov rdx, 0xD1FFAB1E ; Program+IVritualGenericInterface+ call [System.Console:WriteLine(System.String)]- mov r8, 0xD1FFAB1E ; token handle- call CORINFO_HELP_VIRTUAL_FUNC_PTR+ mov rcx, rbx+ call [System.Console:WriteLine(System.Object)]
mov rcx, rsi
- mov rdx, rdi+ call [System.Console:WriteLine(System.String)]- call rax+ nop- mov rcx, rbx+ ;; size=57 bbWeight=1 PerfScore 13.75- call CORINFO_HELP_NEWSFAST+G_M27646_IG03:- mov rbx, rax+ add rsp, 40- mov rcx, rbx+ pop rbx- mov rdx, 0xD1FFAB1E ; Program+VirtualGenericClass+ pop rsi- mov r8, 0xD1FFAB1E ; token handle+ ret- call CORINFO_HELP_VIRTUAL_FUNC_PTR+ ;; size=7 bbWeight=1 PerfScore 2.25- mov rcx, rbx- mov rdx, rdi- call rax- nop- ;; size=115 bbWeight=1 PerfScore 14.25-G_M27646_IG03:- add rsp, 32- pop rbx- pop rsi- pop rdi- ret- ;; size=8 bbWeight=1 PerfScore 2.75

Another example that involves runtime lookup:

MyBasec=newMyImpl<string[]>();Console.WriteLine(c.Method("hello"));abstractclassMyBase{abstractpublicTMethod<T>(Titem)whereT:notnull;}classMyImpl<U>:MyBase{[MethodImpl(MethodImplOptions.NoInlining)]publicoverrideTMethod<T>(Titem){MyBaseb=newMyImpl2();b.Method(item);returnitem;}}classMyImpl2:MyBase{[MethodImpl(MethodImplOptions.AggressiveInlining)]publicoverrideTMethod<T>(Titem){Console.WriteLine(item.ToString());returnitem;}}

Codegen diff:

 ; Assembly listing for method Program+MyImpl`1[System.__Canon]:Method[System.__Canon](System.__Canon):System.__Canon:this (FullOpts)
; Emitting BLENDED_CODE for x64 + VEX on Windows
; FullOpts code
; optimized code
; rsp based frame
; partially interruptible
; No PGO data
-; 0 inlinees with PGO data; 3 single block inlinees; 0 inlinees without PGO data+; 0 inlinees with PGO data; 4 single block inlinees; 0 inlinees without PGO data
; Final local variable assignments
;
;* V00 this [V00 ] ( 0, 0 ) ref -> zero-ref this class-hnd single-def <Program+MyImpl`1[System.__Canon]>
-; V01 TypeCtx [V01,T00] ( 5, 4.20) long -> rbx single-def+; V01 TypeCtx [V01,T01] ( 4, 4 ) long -> rdx single-def-; V02 arg1 [V02,T01] ( 4, 4 ) ref -> rsi class-hnd single-def <System.__Canon>+; V02 arg1 [V02,T00] ( 5, 5 ) ref -> rbx class-hnd single-def <System.__Canon>
; V03 OutArgs [V03 ] ( 1, 1 ) struct (32) [rsp+0x00] do-not-enreg[XS] addr-exposed "OutgoingArgSpace" <UNNAMED>
-; V04 tmp1 [V04,T02] ( 3, 6 ) ref -> rdi class-hnd exact single-def "NewObj constructor temp" <Program+MyImpl2>+;* V04 tmp1 [V04 ] ( 0, 0 ) long -> zero-ref class-hnd exact "NewObj constructor temp" <Program+MyImpl2>
;* V05 tmp2 [V05 ] ( 0, 0 ) long -> zero-ref "spilling helperCall"
-; V06 tmp3 [V06,T05] ( 2, 4 ) long -> r8 "argument with side effect"+;* V06 tmp3 [V06 ] ( 0, 0 ) ref -> zero-ref ld-addr-op class-hnd single-def "Inlining Arg" <System.__Canon>-; V07 rat0 [V07,T04] ( 3, 4 ) long -> r8 "runtime lookup"+;* V07 tmp4 [V07 ] ( 0, 0 ) struct ( 8) zero-ref do-not-enreg[SF] "stack allocated Program+MyImpl2" <Program+MyImpl2>-; V08 rat1 [V08,T03] ( 3, 5.60) long -> r8 "fgMakeTemp is creating a new local variable"+;* V08 rat0 [V08,T04] ( 0, 0 ) long -> zero-ref "runtime lookup"+; V09 rat1 [V09,T02] ( 2, 4 ) long -> rcx "spilling expr"+;* V10 rat2 [V10,T03] ( 0, 0 ) long -> zero-ref "fgMakeTemp is creating a new local variable"
;
; Lcl frame size = 48
G_M37466_IG01:
- push rdi- push rsi
push rbx
sub rsp, 48
mov qword ptr [rsp+0x28], rdx
- mov rbx, rdx+ mov rbx, r8- mov rsi, r8+ ;; size=13 bbWeight=1 PerfScore 2.50- ;; size=18 bbWeight=1 PerfScore 4.75
G_M37466_IG02:
- mov rcx, 0xD1FFAB1E ; Program+MyImpl2+ mov rcx, qword ptr [rdx+0x48]- call CORINFO_HELP_NEWSFAST+ cmp qword ptr [rcx+0x08], 24- mov rdi, rax+ jle SHORT G_M37466_IG03- mov rcx, qword ptr [rbx+0x48]+ ;; size=11 bbWeight=1 PerfScore 6.00- mov r8, qword ptr [rcx+0x10]- test r8, r8- je SHORT G_M37466_IG05- ;; size=31 bbWeight=1 PerfScore 6.75
G_M37466_IG03:
- mov rcx, rdi+ mov rcx, rbx- mov rdx, 0xD1FFAB1E ; Program+MyBase+ mov rax, qword ptr [rbx]- call CORINFO_HELP_VIRTUAL_FUNC_PTR+ mov rax, qword ptr [rax+0x48]- mov rcx, rdi+ call [rax+0x08]System.Object:ToString():System.String:this- mov rdx, rsi+ mov rcx, rax- call rax+ call [System.Console:WriteLine(System.String)]- mov rax, rsi+ mov rax, rbx- ;; size=29 bbWeight=1 PerfScore 5.25+ ;; size=25 bbWeight=1 PerfScore 10.75
G_M37466_IG04:
add rsp, 48
pop rbx
- pop rsi- pop rdi
ret
- ;; size=8 bbWeight=1 PerfScore 2.75+ ;; size=6 bbWeight=1 PerfScore 1.75-G_M37466_IG05:+- mov rcx, rbx+; Total bytes of code 55, prolog size 10, PerfScore 21.00, instruction count 17, allocated bytes for code 55 (MethodHash=7bd26da5) for method Program+MyImpl`1[System.__Canon]:Method[System.__Canon](System.__Canon):System.__Canon:this (FullOpts)- mov rdx, 0xD1FFAB1E ; global ptr- call CORINFO_HELP_RUNTIMEHANDLE_METHOD- mov r8, rax- jmp SHORT G_M37466_IG03- ;; size=23 bbWeight=0.20 PerfScore 0.75--; Total bytes of code 109, prolog size 12, PerfScore 20.25, instruction count 31, allocated bytes for code 109 (MethodHash=7bd26da5) for method Program+MyImpl`1[System.__Canon]:Method[System.__Canon](System.__Canon):System.__Canon:this (FullOpts)-; ============================================================--; Assembly listing for method Program+MyImpl2:Method[System.__Canon](System.__Canon):System.__Canon:this (FullOpts)-; Emitting BLENDED_CODE for x64 + VEX on Windows-; FullOpts code-; optimized code-; rsp based frame-; partially interruptible-; No PGO data-; Final local variable assignments-;-;* V00 this [V00 ] ( 0, 0 ) ref -> zero-ref this class-hnd single-def <Program+MyImpl2>-;* V01 TypeCtx [V01 ] ( 0, 0 ) long -> zero-ref single-def-; V02 arg1 [V02,T00] ( 5, 5 ) ref -> rbx ld-addr-op class-hnd single-def <System.__Canon>-; V03 OutArgs [V03 ] ( 1, 1 ) struct (32) [rsp+0x00] do-not-enreg[XS] addr-exposed "OutgoingArgSpace" <UNNAMED>-;-; Lcl frame size = 32--G_M26681_IG01:- push rbx- sub rsp, 32- mov rbx, r8- ;; size=8 bbWeight=1 PerfScore 1.50-G_M26681_IG02:- mov rcx, rbx- mov rax, qword ptr [rbx]- mov rax, qword ptr [rax+0x48]- call [rax+0x08]System.Object:ToString():System.String:this- mov rcx, rax- call [System.Console:WriteLine(System.String)]- mov rax, rbx- ;; size=25 bbWeight=1 PerfScore 10.75-G_M26681_IG03:- add rsp, 32- pop rbx- ret- ;; size=6 bbWeight=1 PerfScore 1.75--; Total bytes of code 39, prolog size 5, PerfScore 14.00, instruction count 13, allocated bytes for code 39 (MethodHash=630397c6) for method Program+MyImpl2:Method[System.__Canon](System.__Canon):System.__Canon:this (FullOpts)-; ============================================================

Contributes to #112596

CopilotAI review requested due to automatic review settings January 18, 2026 11:52
@github-actionsgithub-actionsBot added the area-CodeGen-coreclr CLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI label Jan 18, 2026
@dotnet-policy-servicedotnet-policy-serviceBot added the community-contribution Indicates that the PR has been added by a community member label Jan 18, 2026

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR enables JIT devirtualization for shared generic virtual methods (GVM) that don't require runtime lookups. Previously, all shared GVMs were blocked from devirtualization due to concerns about having the right generic context. This change unblocks devirtualization when the instantiating stub doesn't need a runtime lookup, by checking for the presence of a GT_RUNTIMELOOKUP node before proceeding.

Changes:

  • Introduced needsMethodContext flag to track when a method context is needed for devirtualization
  • For shared generic methods, obtain the instantiating stub and set needsMethodContext = true
  • Unified handling of array interface and generic virtual method devirtualization paths
  • Added runtime lookup check in the JIT to bail out when a lookup is needed but context is unavailable

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

FileDescription
src/coreclr/vm/jitinterface.cppAdded logic to detect shared generic methods and obtain instantiating stubs, unified array and GVM devirtualization handling
src/coreclr/jit/importercalls.cppUpdated assertions to allow GVM in AOT scenarios, added runtime lookup check to prevent devirtualization when context is unavailable

Comment threadsrc/coreclr/vm/jitinterface.cpp Outdated
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@hez2010

Copy link
Copy Markdown
ContributorAuthor

@MihuBot

Comment threadsrc/coreclr/jit/importercalls.cpp Outdated
@hez2010

hez2010 commented Jan 18, 2026

Copy link
Copy Markdown
ContributorAuthor

Failures seem to be caused by missing context during spmi replay. Otherwise all tests are passing.

Comment threadsrc/coreclr/vm/jitinterface.cpp
CopilotAI review requested due to automatic review settings February 1, 2026 18:06
@hez2010hez2010 changed the title JIT: Devirtualize shared GVM that doesn't need a runtime lookupJIT: Devirtualize shared generic virtual methodsFeb 1, 2026

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Comment threadsrc/coreclr/jit/importercalls.cpp Outdated
Comment threadsrc/coreclr/jit/importercalls.cpp Outdated
Comment threadsrc/coreclr/vm/jitinterface.cpp Outdated
@hez2010
hez2010 marked this pull request as draft February 1, 2026 18:23
CopilotAI review requested due to automatic review settings February 1, 2026 19:06
CopilotAI review requested due to automatic review settings March 3, 2026 16:44

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated no new comments.

CopilotAI review requested due to automatic review settings March 6, 2026 16:15

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated 4 comments.

Comment on lines +218 to +221
var test2 = new Derived();
Delegate m1 = test2.Foo<List<T>>();
Delegate m2 = test2.Foo<List<List<T>>>;
Assert.Equal(m1, m2);

CopilotAIMar 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test code relies on converting a method group (e.g., test2.Foo<List<List<T>>>) to System.Delegate. C# method-group conversions require a specific delegate type (e.g., Func<Delegate>), so this is likely a compile-time error. Consider changing the API/locals to use a concrete delegate type (like Func<Delegate>) or explicitly constructing the delegate via new Func<Delegate>(...)/Delegate.CreateDelegate(...) so the test builds reliably.

Copilot uses AI. Check for mistakes.
@@ -8590,8 +8598,9 @@ bool CEEInfo::resolveVirtualMethodHelper(CORINFO_DEVIRTUALIZATION_INFO * info)
info->detail = CORINFO_DEVIRTUALIZATION_UNKNOWN;
memset(&info->resolvedTokenDevirtualizedMethod, 0, sizeof(info->resolvedTokenDevirtualizedMethod));
memset(&info->resolvedTokenDevirtualizedUnboxedMethod, 0, sizeof(info->resolvedTokenDevirtualizedUnboxedMethod));

CopilotAIMar 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveVirtualMethodHelper initializes instParamLookup.lookupKind.needsRuntimeLookup, but does not initialize instParamLookup.lookupKind.runtimeLookupKind. This leaves the enum potentially uninitialized when no runtime lookup is needed, which can cause nondeterminism in SuperPMI recordings/dumps (lookupKind is always serialized). Initialize the whole instParamLookup to zero (e.g., memset) or explicitly set runtimeLookupKind to a deterministic value.

Suggested change
memset(&info->resolvedTokenDevirtualizedUnboxedMethod, 0, sizeof(info->resolvedTokenDevirtualizedUnboxedMethod));
memset(&info->resolvedTokenDevirtualizedUnboxedMethod, 0, sizeof(info->resolvedTokenDevirtualizedUnboxedMethod));
memset(&info->instParamLookup, 0, sizeof(info->instParamLookup));

Copilot uses AI. Check for mistakes.
Comment on lines +8891 to +8895
CORINFO_METHOD_HANDLE instParam = NO_METHOD_HANDLE;

if (dvInfo.isInstantiatingStub)
if (derivedMethod != nullptr && needsInstParam && !needsRuntimeLookup)
{
// We should only end up with generic methods that needs a method context (eg. array interface, GVM).
// We should only end up with generic methods that need a method context (eg. array interface, GVM).

CopilotAIMar 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The local name instParam is used for a CORINFO_METHOD_HANDLE here, and later reused for the GenTree* instantiation-argument node. This shadowing makes the code harder to follow and increases the chance of mistakes when editing. Consider renaming the handle variable (e.g., instParamHnd) to keep handle vs tree distinct.

Copilot uses AI. Check for mistakes.
Comment on lines +9007 to +9016
GenTree* instParam = getLookupTree(&dvInfo.instParamLookup, GTF_ICON_METHOD_HDL, compileTimeHandle);

if (instParam == nullptr)
{
// If we're inlining, impLookupToTree can return nullptr after recording a fatal observation.
JITDUMP("Failed to produce the lookup for devirtualized call, sorry.\n");
return;
}

call->gtArgs.InsertInstParam(this, instParam);

CopilotAIMar 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This declares GenTree* instParam inside the instantiation-arg insertion block, shadowing the earlier CORINFO_METHOD_HANDLE instParam. Consider renaming this node variable (e.g., instParamNode) so the compile-time handle selection and insertion logic are easier to reason about.

Suggested change
GenTree* instParam = getLookupTree(&dvInfo.instParamLookup, GTF_ICON_METHOD_HDL, compileTimeHandle);
if (instParam == nullptr)
{
// If we're inlining, impLookupToTree can return nullptr after recording a fatal observation.
JITDUMP("Failed to produce the lookup for devirtualized call, sorry.\n");
return;
}
call->gtArgs.InsertInstParam(this, instParam);
GenTree* instParamNode = getLookupTree(&dvInfo.instParamLookup, GTF_ICON_METHOD_HDL, compileTimeHandle);
if (instParamNode == nullptr)
{
// If we're inlining, impLookupToTree can return nullptr after recording a fatal observation.
JITDUMP("Failed to produce the lookup for devirtualized call, sorry.\n");
return;
}
call->gtArgs.InsertInstParam(this, instParamNode);

Copilot uses AI. Check for mistakes.
@hez2010

Copy link
Copy Markdown
ContributorAuthor

@jakobbotsch can you trigger a pri1 and pgo-stress run? I want to make sure GDV is not regressed due to my recent changes.

Also as discussed on discord, I'm going to split this PR into several parts to make things eaiser.

@jakobbotsch

Copy link
Copy Markdown
Member

/azp run runtime-coreclr outerloop, runtime-coreclr pgostress

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 2 pipeline(s).

@hez2010

Copy link
Copy Markdown
ContributorAuthor

Seems that somehow I regressed the runtime lookup case in crossgen2.
Anyway, I'm going to submit the splitted PRs, and crossgen2 runtime lookup support will come later.

@JulieLeeMSFT

Copy link
Copy Markdown
Member

Waiting for the splitted PR.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

This pull request has been automatically marked no-recent-activity because it has not had any activity for 14 days. It will be closed if no further activity occurs within 14 more days. Any new comment (by anyone, not necessarily the author) will remove no-recent-activity.

@hez2010

Copy link
Copy Markdown
ContributorAuthor

The first split has been sent: #126947

@hez2010

Copy link
Copy Markdown
ContributorAuthor

I'm closing this PR as the splits already live.

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMIcommunity-contributionIndicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants

@hez2010@jakobbotsch@MichalStrehovsky@davidwrighton@JulieLeeMSFT@AndyAyersMS