microsoft / microsoft/clrmd

ClrMD 4.1: resolving closed-generic `ClrInstanceField.Type` is ~7x slower than 3.1

Open
#1,503 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
C#
Stars
1.2k
Forks
269
Avg merge
2d 5h
Merged PRs (30d)
4

Description

ClrMD 4.1: resolving closed-generic ClrInstanceField.Type is ~7x slower than 3.1

Description

ClrMD 4.1.741901 has a large performance regression relative to 3.1.512801
when resolving ClrInstanceField.Type for compiler-generated
<>t__builder fields on generic async state machines.

This is not a heap-enumeration regression. Raw, careful, typed, and
type-name-cached heap walks are equal or faster on 4.1. The regression begins
with the single added operation:

ClrType? builderType = FindField(stateMachine.Type, "<>t__builder")?.Type;

Environment

  • Windows x64
  • .NET 10 framework-dependent analyzer
  • x64 .NET Framework 4.8 dump
  • Matching local DAC supplied explicitly
  • No symbols or source resolution
  • ClrMD 3.1.512801 compared with 4.1.741901
  • ClrMD 4.1 package source commit:
    1ac2ef003da03d39ead37c9c1ab9c1e3afe4858e

Measurements

All runs used the same static dump and found:

  • 4,239,237 heap objects
  • 14,848 AsyncMethodBuilderCore+MoveNextRunner objects
  • 957 unique active state machines
  • 109 unique active state-machine types
  • 3.1 returned a builder type for 747 instances across 100 state-machine
    types; 4.1 returned one for all 957 instances across all 109 types
Operation ClrMD 3.1 ClrMD 4.1
Raw heap enumeration 1,075-1,153 ms 880-994 ms
Careful heap enumeration 1,031-1,051 ms 1,008-1,015 ms
Per-object Type and MethodTable 1,822-1,863 ms 972-1,058 ms
Resolve name once per 44,604 MethodTables 2,069-2,136 ms 2,073-2,076 ms
Runner and state-field extraction 1,745-1,769 ms 1,098-1,185 ms
Runner/state extraction plus <>t__builder field Type 1,685-1,707 ms 12,079-12,942 ms

A larger analyzer using the same source against both packages completed in
1.75-1.92 seconds on 3.1 and 15.53 seconds on 4.1.

A diagnostic build of the exact 4.1 package source removed only the concrete
argument passed here:

- IReadOnlyList<ClrType?>? concreteTypeArgs =
-     ContainingType.GetConcreteGenericTypeArguments();
  type = ContainingType.Heap.GetOrCreateTypeFromSignature(
      ContainingType.Module,
      sigParser,
      ContainingType.EnumerateGenericParameters(),
-     Array.Empty<ClrGenericParameter>(),
-     concreteTypeArgs);
+     Array.Empty<ClrGenericParameter>());

That changed the larger analyzer from:

Phase Official 4.1 4.1 without concrete-argument resolution
State-machine discovery 12,591 ms 1,228 ms
Awaiter inspection 2,829 ms 28 ms
Total 15,530 ms 1,366 ms

The complete state-machine, sparse-Task, type, module, blocker, warning, and
UI-marker output matched official 4.1 exactly for this dump. This diagnostic is
not proposed as the general fix because it can regress the field-type
correctness added by #1373; it demonstrates causality.

Trace

A process-scoped dotnet-trace sampled-thread-time trace on 4.1 attributed:

  • 12.43 seconds to the async builder lookup.
  • 15.26 seconds inclusive to ClrField.ResolveType across builder and awaiter
    processing.
  • 10.72 seconds to ClrType.GetConcreteGenericTypeArguments.
  • 15.25 seconds to ClrHeap.GetTypeByName(string).
  • 10.84 seconds to DAC SOSDac.GetString.
  • Only 0.86 seconds to ClrHeap.EnumerateObjects.

The corresponding builder lookup was approximately 68 ms in the 3.1 trace.

The hot stack is:

ClrInstanceField.Type
  ClrField.GetClrType
    ClrField.ResolveType
      ClrType.GetConcreteGenericTypeArguments
        ClrHeap.GetTypeByName(string)
          ClrHeap.FindTypeName
            SOSDac.GetString / GetMethodTableName

Suspected cause

PR #1373 added concrete generic argument resolution for correct generic field
types. ClrField.ResolveType now calls
ContainingType.GetConcreteGenericTypeArguments() while resolving a field
signature. That method parses the containing type's name and resolves each
argument through global ClrHeap.GetTypeByName.

Global name lookup scans TypeDef maps across all modules before its constructed
type fallback. For the diverse closed-generic async state machines in this
dump, those first-use lookups repeatedly invoke expensive DAC type-name calls.

PR #1430 fixed #1428 for callers that only access Name or Attributes by
making type resolution lazy. This remaining case is a caller that legitimately
needs field.Type, so it still pays the full #1373 path. Current ClrMD main
retains this implementation.

A simple completed-result cache, cache-first linear lookup, and an indexed
lookup of already-constructed types did not materially improve this workload.
Most expensive generic-argument lookups therefore appear to require first-use
resolution rather than repeated positive-cache hits.

Removing only the GetConcreteGenericTypeArguments call restores and exceeds
3.1 performance, confirming that this path—not heap enumeration, field-name
lookup, or ReadStruct—causes the regression.

Correctness observation

ClrMD 4.1 resolves more builder field types than 3.1, so reverting #1373 is not
the desired fix. However, in the consuming async analyzer, 4.1 also failed to
associate one generic state machine with its builder Task and represented the
continuation as a standalone Task instead. The same analyzer source on 3.1
produced the direct state-machine continuation.

Suggested direction

Could concrete generic arguments be obtained without global module-wide
name searches for every field type, or could ClrMD expose/use a cheap runtime
layout type for ReadStruct separately from the fully concrete semantic field
type?

Other possible areas to investigate:

  • Cache concrete generic arguments on the containing ClrType.
  • Avoid resolving containing generic arguments when the field signature does
    not reference Var/MVar.
  • Use FieldInfo.MethodTable as a layout fast path while preserving the
    concrete public ClrField.Type.
  • Add a direct DAC/runtime path for generic argument MethodTables instead of
    parsing names and calling global GetTypeByName.

Reproducer

The attached project has separate 3.1 and 4.1 projects sharing one source file.
Run each mode in a fresh process against the same dump:

dotnet run --project .\ClrMDFieldTypeRepro31.csproj -c Release -- <dump> <dac> runner-state
dotnet run --project .\ClrMDFieldTypeRepro41.csproj -c Release -- <dump> <dac> runner-state

dotnet run --project .\ClrMDFieldTypeRepro31.csproj -c Release -- <dump> <dac> runner-builder-type
dotnet run --project .\ClrMDFieldTypeRepro41.csproj -c Release -- <dump> <dac> runner-builder-type

The dump itself is not included.

ClrMDFieldTypeRepro.zip

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with ClrField.ResolveType, ClrType.GetConcreteGenericTypeArguments, and ClrHeap.GetTypeByName, then run the attached ClrMDFieldTypeRepro31.csproj and ClrMDFieldTypeRepro41.csproj modes against the same dump and DAC. Compare the traces and outputs while preserving the concrete field-type correctness from #1373. Done means eliminating the reported 4.1 regression without changing the analyzer's state-machine and builder associations.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
devtools, performance
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Needs clarification
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.