Skip to content

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

@davidwrighton

Description

@davidwrighton

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

  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:

methodDesc->ResetCodeEntryPoint();
if (methodDesc->MayHaveEntryPointSlotsToBackpatch())
{
    Precode::GetPrecodeFromEntryPoint(methodDesc->GetTemporaryEntryPoint())->ResetTargetInterlocked();
}

Metadata

Metadata

Assignees

Labels

area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMItenet-performancePerformance related issue

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions