Description
When DOTNET_TC_CallCountingStubs=0 is configured, virtual (backpatchable) methods are never promoted from Tier0 to Tier1. Call counting is never set up for these methods, so they remain at Tier0 permanently.
This is a performance-only issue affecting a non-default configuration. Correctness is not affected.
Root Cause
The !UseCallCountingStubs mode relies on routing calls through the prestub to count them. For this to work, the precode target must be reset to prestub after the tiering delay expires. Two gaps prevent this for backpatchable methods:
Gap 1: DeactivateTieringDelay skips backpatchable methods entirely
In tieredcompilation.cpp, DeactivateTieringDelay iterates recorded methods and calls CallCountingManager::SetCodeEntryPoint for each. However, it first checks:
PCODE codeEntryPoint = activeCodeVersion.GetNativeCode();
if (codeEntryPoint == (PCODE)NULL)
{
continue; // skipped!
}
For backpatchable methods with the default code version, NativeCodeVersion::GetNativeCode() calls MethodDesc::GetNativeCode(), which returns NULL because HasPrecode() is always true for these methods (method.cpp line 1083). So the method is skipped and call counting is never set up.
Gap 2: ResetCodeEntryPoint without precode reset in CallCountingManager::SetCodeEntryPoint
For explicit NativeCodeVersionNodes (e.g., profiler ReJIT versions) that store m_pNativeCode separately, GetNativeCode() can return non-null, so Gap 1 does not apply. However, at callcounting.cpp line 622:
if (!g_pConfig->TieredCompilation_UseCallCountingStubs())
{
if (wasMethodCalled)
{
return false;
}
methodDesc->ResetCodeEntryPoint(); // does NOT reset precode target for backpatchable methods
return true;
}
ResetCodeEntryPoint() for backpatchable methods resets recorded vtable slots but does not touch the precode target. The precode still points at native code, so calls bypass the prestub and are never counted. Other callers of ResetCodeEntryPoint in callcounting.cpp (lines 854-858 and 997-1003) correctly follow it with ResetTargetInterlocked() for backpatchable methods, but this site does not.
Impact
- Scope: Only affects
DOTNET_TC_CallCountingStubs=0 (non-default; default is 1)
- Affected methods: Virtual methods in classes that are eligible for tiered compilation (i.e.,
MayHaveEntryPointSlotsToBackpatch() returns true)
- Symptom: Methods stay at Tier0 permanently and are never promoted to Tier1
- Severity: Performance-only; no correctness issues
Reproduction
Set DOTNET_TC_CallCountingStubs=0 and observe that virtual methods are never recompiled at Tier1, while static/non-virtual methods are promoted normally.
Suggested Fix
-
Gap 1: In DeactivateTieringDelay, handle backpatchable methods whose default NativeCodeVersion::GetNativeCode() returns NULL by looking up the native code from the precode target instead of skipping them.
-
Gap 2: At callcounting.cpp line 622, add ResetTargetInterlocked() after ResetCodeEntryPoint() for backpatchable methods, matching the pattern at lines 854-858 and 997-1003:
methodDesc->ResetCodeEntryPoint();
if (methodDesc->MayHaveEntryPointSlotsToBackpatch())
{
Precode::GetPrecodeFromEntryPoint(methodDesc->GetTemporaryEntryPoint())->ResetTargetInterlocked();
}
Description
When
DOTNET_TC_CallCountingStubs=0is configured, virtual (backpatchable) methods are never promoted from Tier0 to Tier1. Call counting is never set up for these methods, so they remain at Tier0 permanently.This is a performance-only issue affecting a non-default configuration. Correctness is not affected.
Root Cause
The
!UseCallCountingStubsmode relies on routing calls through the prestub to count them. For this to work, the precode target must be reset to prestub after the tiering delay expires. Two gaps prevent this for backpatchable methods:Gap 1:
DeactivateTieringDelayskips backpatchable methods entirelyIn
tieredcompilation.cpp,DeactivateTieringDelayiterates recorded methods and callsCallCountingManager::SetCodeEntryPointfor each. However, it first checks:For backpatchable methods with the default code version,
NativeCodeVersion::GetNativeCode()callsMethodDesc::GetNativeCode(), which returns NULL becauseHasPrecode()is always true for these methods (method.cppline 1083). So the method is skipped and call counting is never set up.Gap 2:
ResetCodeEntryPointwithout precode reset inCallCountingManager::SetCodeEntryPointFor explicit
NativeCodeVersionNodes (e.g., profiler ReJIT versions) that storem_pNativeCodeseparately,GetNativeCode()can return non-null, so Gap 1 does not apply. However, atcallcounting.cppline 622:ResetCodeEntryPoint()for backpatchable methods resets recorded vtable slots but does not touch the precode target. The precode still points at native code, so calls bypass the prestub and are never counted. Other callers ofResetCodeEntryPointin callcounting.cpp (lines 854-858 and 997-1003) correctly follow it withResetTargetInterlocked()for backpatchable methods, but this site does not.Impact
DOTNET_TC_CallCountingStubs=0(non-default; default is1)MayHaveEntryPointSlotsToBackpatch()returns true)Reproduction
Set
DOTNET_TC_CallCountingStubs=0and observe that virtual methods are never recompiled at Tier1, while static/non-virtual methods are promoted normally.Suggested Fix
Gap 1: In
DeactivateTieringDelay, handle backpatchable methods whose defaultNativeCodeVersion::GetNativeCode()returns NULL by looking up the native code from the precode target instead of skipping them.Gap 2: At
callcounting.cppline 622, addResetTargetInterlocked()afterResetCodeEntryPoint()for backpatchable methods, matching the pattern at lines 854-858 and 997-1003: