Tiered compilation: !UseCallCountingStubs mode does not support Tier1 promotion for backpatchable (virtual) methods
- Dominant language
- C#
- Stars
- 18.3k
- Forks
- 5.6k
- PR merge metrics
- PR metrics pending
Description
## 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:
```cpp
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 `NativeCodeVersionNode`s (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:
```cpp
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
1. **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.
2. **Gap 2**: At `callcounting.cpp` line 622, add `ResetTargetInterlocked()` after `ResetCodeEntryPoint()` for backpatchable methods, matching the pattern at lines 854-858 and 997-1003:
```cpp
methodDesc->ResetCodeEntryPoint();
if (methodDesc->MayHaveEntryPointSlotsToBackpatch())
{
Precode::GetPrecodeFromEntryPoint(methodDesc->GetTemporaryEntryPoint())->ResetTargetInterlocked();
}
```
Contributor guide
Assessment
This issue has not been assessed yet.