Skip to content

Reuse typical instantiation DispatchMap for generic instantiations#130530

Open
davidwrighton wants to merge 4 commits into
dotnet:mainfrom
davidwrighton:dispatchmap-reuse-for-generic-instantiations
Open

Reuse typical instantiation DispatchMap for generic instantiations#130530
davidwrighton wants to merge 4 commits into
dotnet:mainfrom
davidwrighton:dispatchmap-reuse-for-generic-instantiations

Conversation

@davidwrighton

Copy link
Copy Markdown
Member

This PR contains two related optimizations to generic type loading / interface slot handling in the CoreCLR type loader.

1. Reuse typical instantiation DispatchMap for generic instantiations

When loading a non-typical instantiation of a generic type that undergoes a full MethodTableBuilder run (the __Canon canonical form and value-type instantiations such as List<int>), the interface DispatchMap was rebuilt from scratch via PlaceInterfaceMethods for every instantiation. The encoded DispatchMap is instantiation-independent (it stores type IDs and slot numbers), so it can instead be built once while constructing the type's typical instantiation and reused for all of its non-typical instantiations.

  • Release: PlaceInterfaceMethods is skipped for a non-typical instantiation when the typical instantiation's DispatchMap can be reused, and the typical instantiation's encoded map bytes are copied into the new MethodTable's inline DispatchMap.
  • Debug/Checked: the specific instantiation's DispatchMap is still built and asserted to be byte-for-byte identical to the typical instantiation's map, guarding the instantiation-independence invariant.

This is safe because PlaceInterfaceMethods only produces DispatchMap interface entries (it does not mutate the vtable; PlaceMethodImpls still always runs), and the two consumers that read the half-built dispatch map after PlaceInterfaceMethods (ValidateInterfaceMethodConstraints and VerifyVirtualMethodsImplemented) are already skipped for non-typical instantiations because fNoSanityChecks is TRUE for them.

2. Avoid iterating interface methods to size virtual-static slot table

bmtInterfaceEntry::CreateSlotTable walked every method of an interface that has virtual static methods, counting the static+virtual ones solely to size the bmtInterfaceSlotImpl array. The subsequent loop already recomputes the exact per-method placement, so the array can simply be over-allocated to the interface's method count, eliminating the extra MethodIterator walk.

Validation

Performance

Measured on an Rx cold-start micro-benchmark that isolates the System.Reactive construct+initialize type-loading path (interleaved A/B, 60 iterations each, release coreclr.dll swapped, baseline = before both changes):

Variant Median workload vs baseline
Baseline (neither change) 252.15 ms
DispatchMap reuse only 248.61 ms −3.07 ms (−1.2%)
Both changes 248.62 ms −3.53 ms (−1.4%)

Standard deviation was ~1.8 ms, so the ~1.4% improvement is a clear signal on type-loading-bound workloads. On WPF R2R startup (where type loading is a much smaller fraction of total startup) the effect is smaller and within run-to-run noise.

davidwrighton and others added 2 commits July 10, 2026 15:02
When loading a non-typical instantiation of a generic type that undergoes a
full MethodTableBuilder run (the __Canon canonical form and value-type
instantiations such as List<int>), the interface DispatchMap was rebuilt from
scratch via PlaceInterfaceMethods for every instantiation. The encoded
DispatchMap is instantiation-independent (it stores type IDs and slot numbers),
so it can instead be built once while constructing the type's typical
instantiation and reused for all of its non-typical instantiations.

In release builds, PlaceInterfaceMethods is skipped for a non-typical
instantiation when the typical instantiation's DispatchMap can be reused, and
the typical instantiation's encoded map bytes are copied into the new
MethodTable's inline DispatchMap.

In debug/checked builds the specific instantiation's DispatchMap is still built
and asserted to be byte-for-byte identical to the typical instantiation's map,
guarding the instantiation-independence invariant.

This is safe because PlaceInterfaceMethods only produces DispatchMap interface
entries (it does not mutate the vtable; PlaceMethodImpls still always runs), and
the two consumers that read the half-built dispatch map after
PlaceInterfaceMethods (ValidateInterfaceMethodConstraints and
VerifyVirtualMethodsImplemented) are already skipped for non-typical
instantiations because fNoSanityChecks is TRUE for them.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3bea685d-50eb-4245-b1ea-4310df00c7d3
bmtInterfaceEntry::CreateSlotTable walked every method of an interface that has
virtual static methods, counting the static+virtual ones solely to size the
bmtInterfaceSlotImpl array. The subsequent loop already recomputes the exact
per-method placement, so the array can simply be over-allocated to the
interface's method count, eliminating the extra MethodIterator walk. The reused
pDeclMD from the placement loop replaces the redundant it.GetDeclMethodDesc()
call.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3bea685d-50eb-4245-b1ea-4310df00c7d3
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @agocke
See info in area-owners.md if you want to be subscribed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR optimizes CoreCLR generic type loading by avoiding redundant work when building interface dispatch maps for non-typical generic instantiations, and by reducing per-interface iteration overhead when sizing virtual-static slot tables.

Changes:

  • Reuse the typical instantiation’s encoded DispatchMap for eligible non-typical generic instantiations (release), with byte-for-byte validation against a rebuilt map in _DEBUG/Checked builds.
  • Reduce work in bmtInterfaceEntry::CreateSlotTable by avoiding an extra MethodIterator pass when sizing storage for interfaces with virtual static methods.
  • Add a DispatchMap helper to expose the encoded map bytes for reuse/copying.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
src/coreclr/vm/methodtablebuilder.h Tracks typical instantiation MethodTable on all builds and adds helper declaration for DispatchMap reuse.
src/coreclr/vm/methodtablebuilder.cpp Implements DispatchMap reuse (skip PlaceInterfaceMethods in release when safe) and adjusts interface slot table sizing for virtual static methods.
src/coreclr/vm/contractimpl.h Adds DispatchMap::GetEncodedMapData() to access encoded map bytes for reuse/copying.

@github-actions

github-actions Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Workflow state for the Holistic Review Orchestrator.

{
  "version": 5,
  "last_dispatched_commit": "e7f56e871d121042f4ece28f0b034b08acf72020",
  "last_dispatched_base_ref": "main",
  "last_dispatched_base_sha": "2f3204f6e8df27046832bb56afa980a923deaf91",
  "last_reviewed_commit": "e7f56e871d121042f4ece28f0b034b08acf72020",
  "last_reviewed_base_ref": "main",
  "last_reviewed_base_sha": "2f3204f6e8df27046832bb56afa980a923deaf91",
  "last_recorded_worker_run_id": "29681926970",
  "review_attempt_commit": "",
  "review_attempt_base_ref": "",
  "review_attempt_count": 0,
  "max_review_attempts": 5,
  "review_history_format": "holistic-review-disclosure-v1",
  "review_history": [
    {
      "commit": "e7f56e871d121042f4ece28f0b034b08acf72020",
      "review_id": 4730545488
    }
  ]
}

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Holistic Review

Motivation: Two related type-loader optimizations in CoreCLR's MethodTableBuilder. (1) The encoded interface DispatchMap is instantiation-independent (type IDs + slot numbers), yet it was rebuilt from scratch via PlaceInterfaceMethods for every non-typical instantiation of a generic type that undergoes a full builder run. (2) bmtInterfaceEntry::CreateSlotTable walked every method of an interface with virtual-static methods purely to size a temporary array. Both add measurable overhead on type-loading-bound workloads.

Approach: For (1), a new GetTypicalMethodTableForDispatchMapReuse helper identifies non-typical generic instantiations whose typical instantiation already has a DispatchMap. In release builds, PlaceInterfaceMethods is skipped for those and AllocateNewMT copies the typical instantiation's encoded map bytes (via the new DispatchMap::GetEncodedMapData) directly into the new MethodTable's inline map. In debug/checked builds, PlaceInterfaceMethods still always runs and the freshly built map is asserted byte-for-byte identical to the typical map, guarding the instantiation-independence invariant. The previously debug-only dbg_pTypicalInstantiationMT field is promoted to a real field (pTypicalInstantiationMT) since it is now consumed in release. For (2), the counting MethodIterator walk is removed and the array is over-allocated to the interface's method count.

Summary: The changes are correct and carefully guarded. Safety reasoning holds: PlaceInterfaceMethods only produces DispatchMap interface entries and does not mutate the vtable (PlaceMethodImpls/ProcessMethodImpls still always run), and the two consumers that read the half-built map (ValidateInterfaceMethodConstraints, VerifyVirtualMethodsImplemented) are already skipped for non-typical instantiations via fNoSanityChecks. The over-allocation to GetNumMethods() is safe because instance-virtual and static-virtual methods are disjoint subsets of the interface's method set, so cSlots + numStaticVirtual <= GetNumMethods(). DispatchMap's constructor copies the source bytes (memcpyNoGCRefs), so aliasing the typical instantiation's immutable buffer as the source is fine. The debug byte-equality assertion (exercised 141 times per the PR notes) provides strong ongoing protection against a future change that would break instantiation-independence. The gating on dispatchMapAllocationSize > 0 (replacing the builder-count check) is consistent across both the reuse and normal paths. No correctness, memory-safety, or lifetime issues found; DAC contract on the new accessor is appropriate. Verdict: LGTM.

Detailed Findings

No blocking issues.

Non-actionable observations:

  • The debug/release divergence (debug always builds + validates; release reuses) is intentional and clearly documented, but it does mean the release-only fast path (skipping PlaceInterfaceMethods and encoding) receives no direct in-CI functional coverage beyond the equality invariant validated in checked builds. This is an acceptable and common pattern here.
  • GetEncodedMapData returns a pointer into the typical instantiation's live DispatchMap; correctness relies on that MethodTable remaining loaded for the duration of AllocateNewMT. Since the typical instantiation is a load-time dependency of the non-typical one, this holds.

Note

This review was generated by this repository's Holistic Review agentic workflow to complement the built-in Copilot review.

Generated by Holistic Review · 68.4 AIC · ⌖ 10.4 AIC · ⊞ 10K

Copilot AI review requested due to automatic review settings July 21, 2026 21:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Comment thread src/coreclr/vm/methodtablebuilder.cpp Outdated
…ric instantiations

GetTypicalMethodTableForDispatchMapReuse previously returned NULL both when there was no typical MethodTable to reuse and when the typical MethodTable had no DispatchMap slot. In the latter case the resulting DispatchMap is known to be empty, so PlaceInterfaceMethods was run needlessly. Return a DispatchMapReuseKind enum so callers can skip PlaceInterfaceMethods for the known-empty case and produce an empty map directly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 11ab8b48-7c38-47af-a41b-d1a9637c5f96
Copilot AI review requested due to automatic review settings July 22, 2026 22:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread src/coreclr/vm/contractimpl.h
cSlotsTotal++;
}
}
cSlotsTotal = GetInterfaceType()->GetMethodTable()->GetNumMethods();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous logic seems substantially different from calling the non-descripe GetNumMethods() on a MethodTable instance. Even if it is the one that represents the interface type, it isn't clear to me they are both marked static and virtual. If the previously loop only look at IsVirtual() I could see it being the same, but the IsStatic() seems to indicate another attribute of the methods to consider.

// typical instantiation's DispatchMap can be reused, or when the resulting DispatchMap is
// already known to be empty (see GetTypicalMethodTableForDispatchMapReuse).
MethodTable *pUnusedTypicalMTForDispatchMap = NULL;
if (GetTypicalMethodTableForDispatchMapReuse(&pUnusedTypicalMTForDispatchMap) == DispatchMapReuseKind::BuildNormally)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't seem right. Yes, we always want to run PlaceInterfaceMethods() as a validation state on a non-Release build, but we also should want to run GetTypicalMethodTableForDispatchMapReuse() on non-Release builds too.

Comment on lines +11002 to +11010
#ifdef _DEBUG
// Validate that the DispatchMap we just built for this specific instantiation is
// byte-for-byte identical to the typical instantiation's DispatchMap. If this fires,
// the DispatchMap is not actually instantiation-independent and cannot be reused.
_ASSERTE_MSG(cbTypicalMap == cbDispatchMapTemp,
"Typical instantiation DispatchMap size differs from the specific instantiation's DispatchMap");
_ASSERTE_MSG((cbTypicalMap == 0) || (memcmp(pbTypicalMap, pbDispatchMapTemp, cbTypicalMap) == 0),
"Typical instantiation DispatchMap contents differ from the specific instantiation's DispatchMap");
#else

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
#ifdef _DEBUG
// Validate that the DispatchMap we just built for this specific instantiation is
// byte-for-byte identical to the typical instantiation's DispatchMap. If this fires,
// the DispatchMap is not actually instantiation-independent and cannot be reused.
_ASSERTE_MSG(cbTypicalMap == cbDispatchMapTemp,
"Typical instantiation DispatchMap size differs from the specific instantiation's DispatchMap");
_ASSERTE_MSG((cbTypicalMap == 0) || (memcmp(pbTypicalMap, pbDispatchMapTemp, cbTypicalMap) == 0),
"Typical instantiation DispatchMap contents differ from the specific instantiation's DispatchMap");
#else
// Validate that the DispatchMap we just built for this specific instantiation is
// byte-for-byte identical to the typical instantiation's DispatchMap. If this fires,
// the DispatchMap is not actually instantiation-independent and cannot be reused.
_ASSERTE_MSG(cbTypicalMap == cbDispatchMapTemp,
"Typical instantiation DispatchMap size differs from the specific instantiation's DispatchMap");
_ASSERTE_MSG((cbTypicalMap == 0) || (memcmp(pbTypicalMap, pbDispatchMapTemp, cbTypicalMap) == 0),
"Typical instantiation DispatchMap contents differ from the specific instantiation's DispatchMap");
#ifndef _DEBUG

The _ASSERT_MSG are only set in non-Debug, no need to wrap them in an ifdef.

Comment on lines +11018 to +11027
#ifdef _DEBUG
else if (dispatchMapReuseKind == DispatchMapReuseKind::KnownEmpty)
{
// The typical instantiation has no DispatchMap, so this instantiation's DispatchMap must be
// empty too. In debug builds PlaceInterfaceMethods always runs, so validate that it indeed
// produced nothing.
_ASSERTE_MSG(bmtVT->pDispatchMapBuilder->Count() == 0,
"Non-typical instantiation produced DispatchMap entries even though its typical instantiation has no DispatchMap");
}
#endif // _DEBUG

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
#ifdef _DEBUG
else if (dispatchMapReuseKind == DispatchMapReuseKind::KnownEmpty)
{
// The typical instantiation has no DispatchMap, so this instantiation's DispatchMap must be
// empty too. In debug builds PlaceInterfaceMethods always runs, so validate that it indeed
// produced nothing.
_ASSERTE_MSG(bmtVT->pDispatchMapBuilder->Count() == 0,
"Non-typical instantiation produced DispatchMap entries even though its typical instantiation has no DispatchMap");
}
#endif // _DEBUG
else if (dispatchMapReuseKind == DispatchMapReuseKind::KnownEmpty)
{
// The typical instantiation has no DispatchMap, so this instantiation's DispatchMap must be
// empty too. In debug builds PlaceInterfaceMethods always runs, so validate that it indeed
// produced nothing.
_ASSERTE_MSG(bmtVT->pDispatchMapBuilder->Count() == 0,
"Non-typical instantiation produced DispatchMap entries even though its typical instantiation has no DispatchMap");
}

Dead branches will be elided. There is no need to put an ifdef around, unless an "else" clause exists.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants