dotnet / dotnet/runtime

Tiered compilation: !UseCallCountingStubs mode does not support Tier1 promotion for backpatchable (virtual) methods

Open
#126,042 1 comment 0 reactions 1 assignee Claimed by @davidwrighton View on GitHub
area-CodeGen-coreclr tenet-performance
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.