Skip to content

Move the cache for the marshaler methods to be stored on the RuntimeType instead of in a ConditionalWeakTable#126621

Open
jkoritzinsky wants to merge 3 commits intodotnet:mainfrom
jkoritzinsky:marshal-regression-fix
Open

Move the cache for the marshaler methods to be stored on the RuntimeType instead of in a ConditionalWeakTable#126621
jkoritzinsky wants to merge 3 commits intodotnet:mainfrom
jkoritzinsky:marshal-regression-fix

Conversation

@jkoritzinsky
Copy link
Copy Markdown
Member

@jkoritzinsky jkoritzinsky commented Apr 7, 2026

Fixes #126608

Move the marshalling info to be cached on the type itself instead of in a ConditionalWeakTable.

Move some of the validation (in particular the "has layout" validation) to be done only at "create" time so we don't have to rerun it for every time someone calls the Marshal methods with the same (valid) type.

Use the blittable fallbacks on StructureMarshaler<T> and LayoutClassMarshaler<T> instead of inlining the calls. The cost may go up slightly (delegate call to a method that calls SpanHelpers.Memmove instead of direct call to SpanHelpers.Memmove) but still much faster than the existing implementation that does the "is blittable" checks each time.

…ype instead of in a ConditionalWeakTable

Should fix dotnet#126608
Copilot AI review requested due to automatic review settings April 7, 2026 23:32
@jkoritzinsky
Copy link
Copy Markdown
Member Author

@MihuBot benchmark Interop.StructureToPtr

@dotnet-policy-service
Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-reflection
See info in area-owners.md if you want to be subscribed.

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 moves the cache for Marshal.LayoutTypeMarshalerMethods from a ConditionalWeakTable<Type, …> to the per-RuntimeType generic cache infrastructure to address the reported interop performance regressions (issue #126608).

Changes:

  • Adds LayoutTypeMarshalerMethods storage to RuntimeType.CompositeCacheEntry so it can live in the existing RuntimeType generic cache.
  • Refactors LayoutTypeMarshalerMethods to implement RuntimeType.IGenericCacheEntry<T> and retrieves it via RuntimeType.GetOrCreateCacheEntry<T>().
  • Switches from delegate creation (CreateDelegate) to caching raw function pointers (MethodHandle.GetFunctionPointer) for the marshaler methods.

Reviewed changes

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

File Description
src/coreclr/System.Private.CoreLib/src/System/RuntimeType.GenericCache.cs Adds a new composite-cache field to hold the marshaler-methods cache on RuntimeType.
src/coreclr/System.Private.CoreLib/src/System/Runtime/InteropServices/Marshal.CoreCLR.cs Reworks LayoutTypeMarshalerMethods to use RuntimeType cache entries and cached function pointers instead of a ConditionalWeakTable.

@MihuBot
Copy link
Copy Markdown

MihuBot commented Apr 7, 2026

Interop.StructureToPtr
BenchmarkDotNet v0.16.0-nightly.20260320.467, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V74 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 2.07 GB Available
  Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
  Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False  OutlierMode=Default  PowerPlanMode=
IterationTime=250ms  MaxIterationCount=20  MemoryRandomization=Default
MinIterationCount=15  WarmupCount=1
Method Toolchain Mean Error Ratio Allocated Alloc Ratio
MarshalStructureToPtr Main 53.46 ns 0.045 ns 1.00 24 B 1.00
MarshalStructureToPtr PR 41.83 ns 0.073 ns 0.78 24 B 1.00
MarshalPtrToStructure Main 57.27 ns 0.111 ns 1.00 24 B 1.00
MarshalPtrToStructure PR 43.72 ns 0.169 ns 0.76 24 B 1.00
MarshalDestroyStructure Main 109.19 ns 0.141 ns 1.00 96 B 1.00
MarshalDestroyStructure PR 87.02 ns 0.241 ns 0.80 96 B 1.00

@jkoritzinsky
Copy link
Copy Markdown
Member Author

@MihuBot benchmark Interop.StructureToPtr

@MihuBot
Copy link
Copy Markdown

MihuBot commented Apr 8, 2026

Interop.StructureToPtr
BenchmarkDotNet v0.16.0-nightly.20260320.467, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V74 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 1.78 GB Available
  Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
  Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False  OutlierMode=Default  PowerPlanMode=
IterationTime=250ms  MaxIterationCount=20  MemoryRandomization=Default
MinIterationCount=15  WarmupCount=1
Method Toolchain Mean Error Ratio Allocated Alloc Ratio
MarshalStructureToPtr Main 64.20 ns 0.578 ns 1.00 24 B 1.00
MarshalStructureToPtr PR 30.70 ns 0.567 ns 0.48 24 B 1.00
MarshalPtrToStructure Main 62.55 ns 0.117 ns 1.00 24 B 1.00
MarshalPtrToStructure PR 37.14 ns 0.026 ns 0.59 24 B 1.00
MarshalDestroyStructure Main 109.97 ns 0.152 ns 1.00 96 B 1.00
MarshalDestroyStructure PR 68.63 ns 0.158 ns 0.62 96 B 1.00

@jkoritzinsky jkoritzinsky marked this pull request as ready for review April 8, 2026 18:18
Copilot AI review requested due to automatic review settings April 8, 2026 18:18
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 5 comments.

Comment on lines 281 to +289
[RequiresDynamicCode("Marshalling code for the object might not be available.")]
internal static LayoutTypeMarshalerMethods GetMarshalMethodsForType(Type t)
public static LayoutTypeMarshalerMethods Create(RuntimeType type)
{
return s_marshalerCache.GetOrAdd(t, CreateMarshalMethods);
if (!HasLayout(new QCallTypeHandle(ref type), out _, out int size))
throw new ArgumentException(SR.Argument_MustHaveLayoutOrBeBlittable, nameof(type));

static LayoutTypeMarshalerMethods CreateMarshalMethods(Type type)
{
Type instantiatedMarshaler = typeof(BoxedLayoutTypeMarshaler<>).MakeGenericType([type]);
return new LayoutTypeMarshalerMethods(instantiatedMarshaler);
}
Type instantiatedMarshaler = typeof(BoxedLayoutTypeMarshaler<>).MakeGenericType([type]);
return new LayoutTypeMarshalerMethods(instantiatedMarshaler, size);
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

LayoutTypeMarshalerMethods.Create now throws ArgumentException(..., nameof(type)) when HasLayout is false. This exception can bubble out of public APIs like StructureToPtr / DestroyStructure, changing the observable ParamName from the prior values (structure / structuretype). Consider catching and rethrowing with the original parameter name at the public entrypoints (or in GetMarshalMethodsForType) so API behavior stays consistent, while keeping the per-type cache creation.

Copilot uses AI. Check for mistakes.
Comment on lines 313 to 331
public static unsafe void StructureToPtr(object structure, IntPtr ptr, bool fDeleteOld)
{
ArgumentNullException.ThrowIfNull(ptr);
ArgumentNullException.ThrowIfNull(structure);

RuntimeType type = (RuntimeType)structure.GetType();

if (type.IsGenericType)
throw new ArgumentException(SR.Argument_NeedNonGenericObject, nameof(structure));

if (!HasLayout(new QCallTypeHandle(ref type), out bool isBlittable, out int size))
throw new ArgumentException(SR.Argument_MustHaveLayoutOrBeBlittable, nameof(structure));

if (isBlittable)
{
SpanHelpers.Memmove(ref *(byte*)ptr, ref structure.GetRawData(), (nuint)size);
return;
}

LayoutTypeMarshalerMethods methods = LayoutTypeMarshalerMethods.GetMarshalMethodsForType(type);

if (fDeleteOld)
{
methods.Free(structure, (byte*)ptr, size, ref Unsafe.NullRef<CleanupWorkListElement?>());
methods.Free((byte*)ptr);
}

methods.ConvertToUnmanaged(structure, (byte*)ptr, size, ref Unsafe.NullRef<CleanupWorkListElement?>());
methods.ConvertToUnmanaged(structure, (byte*)ptr, ref Unsafe.NullRef<CleanupWorkListElement?>());
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

The previous fast-path for blittable layout types (calling HasLayout(..., out isBlittable, out size) and doing a direct SpanHelpers.Memmove) was removed. The new path always routes through LayoutTypeMarshalerMethods / BoxedLayoutTypeMarshaler, which (for blittable structs/classes) performs extra delegate dispatch and also zeros the destination buffer (NativeMemory.Clear) before copying. This is a measurable perf/behavior change for the blittable case; if the intent of this PR is to address perf regressions, consider reintroducing a blittable fast-path (potentially using cached isBlittable + nativeSize in the RuntimeType cache entry) so blittable types remain a simple memmove without clearing.

Copilot uses AI. Check for mistakes.
Comment on lines 354 to 368
public static unsafe void DestroyStructure(IntPtr ptr, Type structuretype)
{
ArgumentNullException.ThrowIfNull(ptr);
ArgumentNullException.ThrowIfNull(structuretype);

if (structuretype is not RuntimeType rt)
throw new ArgumentException(SR.Argument_MustBeRuntimeType, nameof(structuretype));

if (rt.IsGenericType)
throw new ArgumentException(SR.Argument_NeedNonGenericType, nameof(structuretype));

if (!HasLayout(new QCallTypeHandle(ref rt), out bool isBlittable, out int size))
throw new ArgumentException(SR.Argument_MustHaveLayoutOrBeBlittable, nameof(structuretype));
LayoutTypeMarshalerMethods methods = LayoutTypeMarshalerMethods.GetMarshalMethodsForType(rt);

if (!isBlittable)
{
LayoutTypeMarshalerMethods methods = LayoutTypeMarshalerMethods.GetMarshalMethodsForType(structuretype);

methods.Free(null, (byte*)ptr, size, ref Unsafe.NullRef<CleanupWorkListElement?>());
}
methods.Free((byte*)ptr);
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

DestroyStructure previously did nothing for blittable types; it now always calls methods.Free((byte*)ptr). For blittable structs, StructureMarshaler<T>.Free clears the native memory (NativeMemory.Clear), which is a behavioral change and adds work on a hot path. Consider preserving the old semantics by skipping Free entirely for blittable types (again, this could be driven off cached isBlittable in the per-RuntimeType cache entry).

Copilot uses AI. Check for mistakes.
Comment on lines 1947 to 1953
internal static unsafe void LayoutTypeConvertToUnmanaged(object obj, byte* pNative, ref CleanupWorkListElement? pCleanupWorkList)
{
RuntimeType type = (RuntimeType)obj.GetType();
bool hasLayout = Marshal.HasLayout(new QCallTypeHandle(ref type), out bool isBlittable, out int size);
Debug.Assert(hasLayout);

if (isBlittable)
{
SpanHelpers.Memmove(ref *pNative, ref obj.GetRawData(), (nuint)size);
return;
}

Marshal.LayoutTypeMarshalerMethods methods = Marshal.LayoutTypeMarshalerMethods.GetMarshalMethodsForType(type);

methods.ConvertToUnmanaged(obj, pNative, size, ref pCleanupWorkList);
methods.ConvertToUnmanaged(obj, pNative, ref pCleanupWorkList);
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

The previous blittable fast-path was removed here as well. LayoutTypeConvertToUnmanaged now always goes through Marshal.LayoutTypeMarshalerMethods, which for blittable types introduces extra indirection and (via StructureMarshaler / LayoutClassMarshaler) may clear the native buffer before copying. If these helpers are used on interop hot paths, consider restoring a blittable memmove fast-path using cached layout info to avoid the extra work.

Copilot uses AI. Check for mistakes.
Comment on lines +276 to +279
internal static LayoutTypeMarshalerMethods GetMarshalMethodsForType(RuntimeType t)
{
return t.GetOrCreateCacheEntry<LayoutTypeMarshalerMethods>();
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

LayoutTypeMarshalerMethods.GetMarshalMethodsForType(RuntimeType) is no longer annotated with [RequiresDynamicCode], even though it can trigger MakeGenericType / delegate creation via the cache entry’s Create method. Previously the "get" helper carried the annotation, which helps flow the requirement to internal callers (e.g., StubHelpers.LayoutTypeConvertTo*). Consider restoring [RequiresDynamicCode] on GetMarshalMethodsForType (or otherwise ensuring the requirement is correctly surfaced) so trimming/AOT analysis remains accurate.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

[Perf] Windows/x64: 3 Regressions on 4/2/2026 1:30:32 AM +00:00

3 participants