ClrMD 4.1: resolving closed-generic `ClrInstanceField.Type` is ~7x slower than 3.1
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+MoveNextRunnerobjects - 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.ResolveTypeacross 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 referenceVar/MVar. - Use
FieldInfo.MethodTableas a layout fast path while preserving the
concrete publicClrField.Type. - Add a direct DAC/runtime path for generic argument MethodTables instead of
parsing names and calling globalGetTypeByName.
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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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