Skip to content

Cache version-resilient hash code on MethodTableAuxiliaryData to fix superlinear behavior with polymorphic recursion#126534

Merged
jkotas merged 11 commits intomainfrom
copilot/cache-version-resilient-hash-code
Apr 9, 2026
Merged

Cache version-resilient hash code on MethodTableAuxiliaryData to fix superlinear behavior with polymorphic recursion#126534
jkotas merged 11 commits intomainfrom
copilot/cache-version-resilient-hash-code

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 4, 2026

GetVersionResilientTypeHashCode recomputes hash codes recursively without memoization. With polymorphic recursion, each level doubles the number of unique generic instantiations, causing exponential blowup — n=31 took ~15s before this fix.

Description

  • src/coreclr/vm/methodtable.h: Added int m_cachedVersionResilientHashCode to MethodTableAuxiliaryData. The field is placed immediately after the 4-byte m_dwFlags union, filling the 4 bytes of implicit alignment padding that already exists before the 8-byte-aligned m_pLoaderModule pointer on 64-bit platforms. This means the struct size is unchanged on 64-bit (0 bytes / 0% increase). The field is lazily initialized — 0 means not yet set, and is zero-initialized for free by the loader heap so no explicit init is needed for new instantiations.

  • src/coreclr/vm/versionresilienthashcode.cpp: In GetVersionResilientTypeHashCode, the separate early-return branch for array types has been removed. Since TypeHandle::IsArray() is defined as !IsTypeDesc() && AsMethodTable()->IsArray(), arrays are always MethodTable-backed and share the same MethodTableAuxiliaryData. Both array and non-array MethodTable-backed types are now handled in a single !type.IsTypeDesc() branch, with the cache check and store applied uniformly to all of them. The type-specific hash computation (ComputeArrayTypeHashCode vs. name/generic logic) is selected inside the branch after the cache check. Types whose hash code is exactly 0 are not cached (sentinel collision), but this is vanishingly rare given the non-zero initial state of the hash function (hash1 = 0x6DA3B944).

  • src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.CoreCLR.cs: Added the matching _cachedVersionResilientHashCode field to the managed mirror of MethodTableAuxiliaryData. This struct uses [StructLayout(LayoutKind.Sequential)] and must exactly mirror the C++ layout. Without this fix, the new C++ field shifted LoaderModule and ExposedClassObjectRaw by 4 bytes on 32-bit platforms (x86, ARM32, WASM) where there is no alignment padding — corrupting all accesses to those fields. On 64-bit, the C# compiler's natural alignment padding incidentally covered the gap, so 64-bit was unaffected.

Memory Impact

On 64-bit release builds, MethodTableAuxiliaryData already had 4 bytes of implicit alignment padding between the union and m_pLoaderModule. The new field fills that slot exactly:

Before After
sizeof(MethodTableAuxiliaryData) (release x64) 24 bytes 24 bytes
Increase 0 bytes (0%)

On 32-bit, all pointers are 4 bytes so no alignment gap exists; the struct grows by 4 bytes (12 → 16 bytes, 33%). The managed mirror in RuntimeHelpers.CoreCLR.cs is updated to match this growth on both sides.

Hash Algorithm Effectiveness

The existing ComputeGenericInstanceHashCode algorithm was evaluated against:

  • All 32 unique generic types produced by the polymorphic recursion benchmark: 0 collisions, good bit distribution
  • Common .NET generic instantiations (List<T>, Dictionary<K,V>, Task<T>, Nullable<T>, IEnumerable<T> with int/string/bool/double): 0 collisions

The existing algorithm produces high-quality hashes for generic instantiations; the exponential blowup was purely a memoization problem, not a hash quality issue.

Performance

Benchmark from the issue (polymorphic recursion across two type parameters):

n Before After
16 16 ms 4 ms
20 93 ms 4 ms
24 555 ms 4 ms
28 3716 ms 4 ms
31 ~15 s 4 ms

Constant time after the first instantiation at each depth.

@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 and others added 2 commits April 4, 2026 05:12
…superlinear behavior

Fixes exponential time complexity in GetVersionResilientTypeHashCode for deeply nested
generic type instantiations (e.g., polymorphic recursion). The hash code is now lazily
computed and stored in m_cachedVersionResilientHashCode on MethodTableAuxiliaryData.
A stored value of 0 indicates the field hasn't been initialized yet. The memory is
zero-initialized by the loader heap, so no explicit initialization is required for
new instantiations.

Before fix: n=31 took ~15s (exponential growth)
After fix:  n=31 takes ~4ms (constant time)

Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/3a3fba93-8075-497c-bb07-a3fe274858a9

Co-authored-by: davidwrighton <10779849+davidwrighton@users.noreply.github.com>
Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/3a3fba93-8075-497c-bb07-a3fe274858a9

Co-authored-by: davidwrighton <10779849+davidwrighton@users.noreply.github.com>
Copilot AI changed the title [WIP] Add caching for version resilient hash code in MethodTable Cache version-resilient hash code on MethodTableAuxiliaryData to fix superlinear behavior with polymorphic recursion Apr 4, 2026
Copilot AI requested a review from davidwrighton April 4, 2026 05:15
@davidwrighton
Copy link
Copy Markdown
Member

@copilot This increases the memory needed on the loader heap. Please measure the memory increase for this scenario, and present the increase both in bytes and by percentage. Also, this change allows us to consider more capable hash combining algorithms. Please measure how effective the current hash combining algorithm is for generic instatiations.

@davidwrighton
Copy link
Copy Markdown
Member

@copilot arrays are also MethodTable and can use the cache.

… slot

Place the int field immediately after the union to fill the 4-byte implicit
alignment padding that already exists between the union (4 bytes) and
m_pLoaderModule (8-byte aligned pointer) on 64-bit platforms.

sizeof(MethodTableAuxiliaryData) remains 24 bytes before and after this change
on 64-bit release builds, so the memory overhead is 0 bytes / 0%.

Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/c5ceb726-3055-4d02-a92f-e0d52438fab9

Co-authored-by: davidwrighton <10779849+davidwrighton@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 4, 2026

@copilot This increases the memory needed on the loader heap. Please measure the memory increase for this scenario, and present the increase both in bytes and by percentage. Also, this change allows u...

Memory impact (commit d9e3911): MethodTableAuxiliaryData already has 4 bytes of implicit alignment padding between the 4-byte m_dwFlags union and the 8-byte-aligned m_pLoaderModule pointer on 64-bit. By placing m_cachedVersionResilientHashCode in that padding slot (immediately after the union), sizeof(MethodTableAuxiliaryData) stays at 24 bytes — 0 bytes / 0% increase on 64-bit release builds.

On 32-bit, all pointers are 4 bytes so no alignment padding exists; the struct would grow by 4 bytes (12 → 16 bytes, 33%).

Hash algorithm effectiveness: I simulated the hash algorithm on the 32 distinct generic types produced by the polymorphic recursion benchmark and found zero collisions with good bit distribution across the full 32-bit hash space. I also checked common .NET generic instantiations (List<int>, Dictionary<string,int>, Task<bool>, etc.) — again no collisions. The existing algorithm appears adequate; the caching is the key fix here.

Arrays are also backed by MethodTable (TypeHandle::IsArray() returns
!IsTypeDesc() && AsMethodTable()->IsArray()), so they have the same
MethodTableAuxiliaryData and can benefit from the same caching mechanism.

Restructure GetVersionResilientTypeHashCode to handle both arrays and
non-array types in the single !type.IsTypeDesc() branch, checking/storing
the cache before and after the type-specific hash computation.

Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/1e4f951d-0276-47ac-8fc3-7d5609071844

Co-authored-by: davidwrighton <10779849+davidwrighton@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 4, 2026

@copilot arrays are also MethodTable and can use the cache.

Done in commit f8a7695. TypeHandle::IsArray() is defined as !IsTypeDesc() && AsMethodTable()->IsArray(), so arrays are always MethodTable-backed. The separate early-return IsArray() branch has been removed; both arrays and non-array types are now handled in the single !type.IsTypeDesc() branch, sharing the same cache check/store logic around the type-specific hash computation.

@github-actions

This comment has been minimized.

@jkotas jkotas marked this pull request as ready for review April 5, 2026 03:16
Copilot AI review requested due to automatic review settings April 5, 2026 03:16
@github-actions

This comment has been minimized.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 adds memoization for GetVersionResilientTypeHashCode(TypeHandle) by caching the computed hash code on MethodTableAuxiliaryData, addressing exponential recomputation in polymorphic recursion scenarios.

Changes:

  • Added a lazily-initialized cached version-resilient hash code field to MethodTableAuxiliaryData.
  • Updated GetVersionResilientTypeHashCode(TypeHandle) to use the cache for all MethodTable-backed types (including arrays), computing and storing the hash once per MethodTable.

Reviewed changes

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

File Description
src/coreclr/vm/versionresilienthashcode.cpp Uses a per-MethodTable cached hash to avoid repeated recursive recomputation; unifies array/non-array MethodTable handling under the same cache path.
src/coreclr/vm/methodtable.h Adds m_cachedVersionResilientHashCode to MethodTableAuxiliaryData to store the memoized result (intended to fit existing x64 alignment padding).

@github-actions

This comment has been minimized.

Copilot AI review requested due to automatic review settings April 9, 2026 02:19
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 2 out of 2 changed files in this pull request and generated no new comments.

@github-actions

This comment has been minimized.

@jkotas
Copy link
Copy Markdown
Member

jkotas commented Apr 9, 2026

@copilot A lot of tests are failing on all 32-bit platforms (x64, arm, wasm). The extra field likely broke an hidden assumption in the code about the layout of MethodTableAuxiliaryData structure. Please investigate.

…uxiliaryData

The managed mirror of MethodTableAuxiliaryData in RuntimeHelpers.CoreCLR.cs
was missing the new m_cachedVersionResilientHashCode field. On 32-bit
platforms (x86, ARM32, WASM), pointer fields are 4 bytes and there is no
alignment padding between the flags union and the pointer fields. The extra
4-byte field in the C++ struct shifted LoaderModule and ExposedClassObjectRaw
by 4 bytes relative to what the managed struct expected, corrupting all access
to these fields on 32-bit platforms.

Fix by adding the corresponding private field to the managed struct so its
layout exactly matches the C++ struct on all platforms:
- 64-bit: same as before (fills existing alignment padding, size unchanged)
- 32-bit: struct grows from 12 to 16 bytes on both sides, offsets now agree

Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/71f12275-cf7d-46ac-a8cd-53247cc1990d

Co-authored-by: jkotas <6668460+jkotas@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 9, 2026

@copilot A lot of tests are failing on all 32-bit platforms (x64, arm, wasm). The extra field likely broke an hidden assumption in the code about the layout of MethodTableAuxiliaryData structure. Plea...

Fixed in commit ee4e1b8. The root cause was that RuntimeHelpers.CoreCLR.cs contains a managed mirror of MethodTableAuxiliaryData used by the JIT/GC helpers, but it was missing the new _cachedVersionResilientHashCode field. On 64-bit, alignment padding between the Flags field and the pointer-sized LoaderModule field incidentally filled the gap, so offsets remained correct. On 32-bit (x86, ARM32, WASM), there is no such padding — pointers are 4 bytes and pack immediately after Flags — so LoaderModule and ExposedClassObjectRaw were displaced by 4 bytes, corrupting all reads from those fields.

Copilot AI requested a review from jkotas April 9, 2026 05:33
Copilot AI review requested due to automatic review settings April 9, 2026 05:34
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 9, 2026

Note

This review was generated by Copilot and may contain inaccuracies. A human reviewer should verify the findings.

🤖 Copilot Code Review — PR #126534

Holistic Assessment

Motivation: This PR addresses a real and significant performance problem — exponential time complexity in GetVersionResilientTypeHashCode for deeply nested generic type instantiations (polymorphic recursion). The issue is well-understood: without caching, each level of a nested generic like List<List<List<...>>> recomputes all inner levels, yielding O(2^n) work. The reported improvement (n=31: 15s → 4ms) is compelling.

Approach: Caching the hash code on MethodTableAuxiliaryData is a clean, low-risk approach. The field is placed to exploit existing alignment padding on 64-bit platforms (zero memory overhead), and the lazy-init pattern using 0 as sentinel is idiomatic for the runtime. The restructuring to handle arrays in the same !IsTypeDesc() branch is correct since arrays also have MethodTable backing.

Summary: ✅ LGTM. The change is well-structured, the thread-safety approach is correct for this scenario, the managed/native layout is consistent, and the caching logic handles all type paths correctly. The only minor observation is advisory (no blocking issues found).


Detailed Findings

✅ Correctness — Hash caching logic is sound

The caching logic in versionresilienthashcode.cpp is correct:

  • Cache check: VolatileLoadWithoutBarrier at line 119 reads the cached value before any computation.
  • Array handling: Arrays are correctly handled in the same !type.IsTypeDesc() branch — TypeHandle::IsArray() returns !IsTypeDesc() && AsMethodTable()->IsArray(), so arrays always have a MethodTable with MethodTableAuxiliaryData.
  • Cache store: Only non-zero hash codes are stored (line 153), with the 0 sentinel well-documented in the comment.
  • Assertion update: The assertion at line 117 (pMT->IsArray() || !IsNilToken(pMT->GetCl())) correctly merges the old separate array and non-array assertions.
  • The computation is idempotent — concurrent threads computing the same hash will always produce the same result, so the race between compute-and-store is benign.

✅ Thread Safety — Volatile pattern is appropriate

The VolatileLoadWithoutBarrier (read) + VolatileStore (write) pattern is the correct choice:

  • Read path: VolatileLoadWithoutBarrier prevents compiler reordering/optimization. A memory fence is not needed because this is a lazy cache — reading a stale 0 just means recomputation, which produces the identical value.
  • Write path: VolatileStore provides release semantics on ARM64 (__atomic_store(..., __ATOMIC_RELEASE)) and volatile write on x86/x64, ensuring the written value is visible to other threads.
  • Concurrent writes: Two threads computing and storing the same hash simultaneously is safe — they write the same value (idempotent), and int writes are naturally atomic on all supported platforms.
  • This pattern is consistent with other caching patterns in methodtable.h (e.g., m_iNextChainDepth at line 3382).

✅ Memory Layout — Native and managed structs match

  • Native (methodtable.h): m_dwFlags (4B) → m_cachedVersionResilientHashCode (4B) → m_pLoaderModule (8B on 64-bit). The new field fills existing alignment padding — sizeof(MethodTableAuxiliaryData) remains 24 bytes on 64-bit.
  • Managed (RuntimeHelpers.CoreCLR.cs): uint Flagsint CachedVersionResilientHashCodevoid* LoaderModulenint ExposedClassObjectRaw. Uses [StructLayout(LayoutKind.Sequential)], matching the native layout exactly.
  • 32-bit: The struct grows by 4 bytes on both native and managed sides (from 12 to 16 bytes), with offsets matching. The commit message for ee4e1b844 correctly identified and fixed this 32-bit mismatch.
  • cDAC (datadescriptor.inc): Uses offsetof() for all field offsets, so the shifted m_pLoaderModule offset is picked up automatically at compile time. No changes needed.

✅ Zero-initialization — Loader heap guarantees initial sentinel value

The loader heap (AllocMem) guarantees zero-initialized memory (verified in loaderheap.cpp line 984: "Callers of loaderheap assume allocated memory is zero-inited"). This means m_cachedVersionResilientHashCode starts at 0 without explicit initialization, correctly indicating "not yet cached."

💡 Sentinel value 0 — Negligible edge case, well-documented

Types with a version-resilient hash code of exactly 0 will not be cached and will be recomputed on every call. This is:

  • Extremely rare: The hashing algorithm involves XOR with magic constants (0xd5313556 for arrays, 0x12D0/0x4C85 for pointer/byref), making natural zero results near-impossible for real types.
  • Still correct: The uncached path produces the correct hash code.
  • Well-documented: Lines 150–152 clearly explain the tradeoff.

No action needed — this is the standard sentinel-value pattern used throughout the runtime.

Generated by Code Review for issue #126534 ·

@jkotas
Copy link
Copy Markdown
Member

jkotas commented Apr 9, 2026

/ba-g infrastructure timeouts

@jkotas jkotas merged commit 6c7c5de into main Apr 9, 2026
105 of 115 checks passed
@jkotas jkotas deleted the copilot/cache-version-resilient-hash-code branch April 9, 2026 21:29
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.

Superlinear behavior with polymorphic recursion in runtime async

5 participants