Reduce the GC handle cost of every RCW in ComWrappers - #132040
Reduce the GC handle cost of every RCW in ComWrappers#132040Sergio0694 wants to merge 6 commits into
Conversation
The RCW cache is consulted on essentially every native to managed transition, and the finalizer thread concurrently takes write locks on it to remove entries for collected RCWs. With a single dictionary behind a single reader-writer lock, all of that traffic serializes on one lock, which shows up as the dominant cost in WinRT interop profiles. Split the cache into independent buckets, each holding the same dictionary and reader-writer lock as before, and select the bucket for a given COM instance by hashing its pointer. The number of buckets matches the processor count (rounded up to a power of two), the same default concurrency level used by ConcurrentDictionary. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The cached handles always point to a NativeObjectWrapper, so a strongly typed weak handle expresses that directly. It skips the type check on every read, allocates through GCHandle.InternalAlloc without revalidating the handle type, and reads the target once instead of twice when checking whether it was collected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Every RCW allocates two weak GC handles for its proxy: a plain weak one, and a second 'WeakTrackResurrection' one that exists only so the NativeObjectWrapper is cleaned up after the proxy's finalizer has run (the proxy's finalizer may access the native object). If the proxy's type declares no finalizer, it can never observe the native object once it becomes unreachable, and it can never be resurrected, so the second handle would always be cleared at the same time as the first. Detect that case from the MethodTable and skip allocating it. This is worth doing because allocating, clearing and freeing GC handles is a substantial part of the cost of every RCW, and resurrection tracking handles are more expensive for the GC to process than plain weak ones. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The handle always tracks the proxy object, so a strongly typed weak handle expresses that directly: it allocates through GCHandle.InternalAlloc without revalidating the handle type, and skips the cast when reading the target. Only this handle is converted. The '_proxyHandle' field and the handles in 'GCHandleSet' are mirrored in native code (see 'NativeObjectWrapperObject' in interoplibinterface_comwrappers.h, read via 'GetProxyHandle') and flow through the on-stack COM struct used for reference tracker callbacks, so converting those is a wider change that needs to be validated against the native side. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Both handles tracking the proxy object are now strongly typed weak handles. They allocate through GCHandle.InternalAlloc without revalidating the handle type, and skip the cast when reading the target. The layout is unchanged, so the native mirror of 'NativeObjectWrapper' still matches: 'GCHandle' only alters the stored value for pinned handles, so a weak handle holds the raw handle in both representations. This also applies to the on-stack COM struct used for reference tracker callbacks, which passes the handle straight through to native code. The redundant 'IsAllocated' checks around the disposal calls are also dropped, as 'WeakGCHandle<T>.Dispose' already handles a default handle. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Azure Pipelines: Successfully started running 3 pipeline(s). 13 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
There was a problem hiding this comment.
Pull request overview
This PR reduces per-RCW overhead in ComWrappers by avoiding an extra resurrection-tracking handle when the managed proxy type doesn’t require finalization, and by using strongly-typed WeakGCHandle<T> to streamline weak-handle operations. It also updates reference-tracker plumbing and tests to align with the new handle usage and (as noted in the description) includes the RCW-cache bucketing work it’s stacked on.
Changes:
- Switch
NativeObjectWrapperproxy handles toWeakGCHandle<object>and skip allocating the resurrection-tracking handle whenRuntimeHelpers.ObjectHasFinalizer(comProxy)is false. - Partition the RCW cache into per-processor buckets and use
WeakGCHandle<NativeObjectWrapper>for cached entries. - Update tracker/test call sites to use
TryGetTargetrather thanGCHandle.Target.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/tests/Interop/COM/ComWrappers/API/Program.cs | Tightens the “no lock around QI” regression test to ensure the nested call targets the same COM instance and asserts completion. |
| src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.cs | Uses ProxyHandle.TryGetTarget when collecting proxies to release. |
| src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs | Core change: uses WeakGCHandle for proxy handles, conditionally allocates resurrection tracking, and partitions RCW cache into buckets. |
| src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.CoreCLR.cs | Adds RuntimeHelpers.ObjectHasFinalizer (CoreCLR). |
| src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.NativeAot.cs | Updates NativeAOT reference-tracker callback plumbing to use WeakGCHandle<object>. |
| src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.NativeAot.cs | Adds RuntimeHelpers.ObjectHasFinalizer (NativeAOT). |
| if (RuntimeHelpers.ObjectHasFinalizer(comProxy)) | ||
| { | ||
| _proxyHandleTrackingResurrection = new WeakGCHandle<object>(comProxy, trackResurrection: true); | ||
| } | ||
|
|
||
| // 'ObjectHasFinalizer' reads the MethodTable, which requires the object to be kept alive | ||
| GC.KeepAlive(comProxy); |
| // Returns true iff the type of the object declares a finalizer. | ||
| // Callers are required to keep obj alive |
| // Returns true iff the type of the object declares a finalizer. | ||
| // Callers are required to keep obj alive |
| } | ||
|
|
||
| object sourceObject = ((FindReferenceTargetsCallback.Instance*)pThis)->RootObject.Target!; | ||
| _ = ((FindReferenceTargetsCallback.Instance*)pThis)->RootObject.TryGetTarget(out object? sourceObject); |
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
f61051e to
6ac4779
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (4)
src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.NativeAot.cs:248
RootObject.TryGetTargetcan fail for a weak handle. The current code ignores the return value and may passnullintoAddReferencePath, which expects non-nullobjectarguments and can throw during a GC callback. Bail out early when the source object is no longer available.
_ = ((FindReferenceTargetsCallback.Instance*)pThis)->RootObject.TryGetTarget(out object? sourceObject);
if (!TryGetObject(referenceTrackerTarget, out object? targetObject))
{
return HResults.S_FALSE;
src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs:617
ComWrappersnow callsRuntimeHelpers.ObjectHasFinalizer, but that intrinsic is only added for CoreCLR and NativeAOT in this PR.RuntimeHelpers.Mono.csdoes not define it, so Mono builds will fail unless a Mono implementation/stub is added (even if COM interop is unsupported there).
if (RuntimeHelpers.ObjectHasFinalizer(comProxy))
{
_proxyHandleTrackingResurrection = new WeakGCHandle<object>(comProxy, trackResurrection: true);
}
// 'ObjectHasFinalizer' reads the MethodTable, which requires the object to be kept alive
GC.KeepAlive(comProxy);
src/tests/Interop/COM/ComWrappers/API/Program.cs:1123
- The worker thread is created as a foreground thread. If
Jointimes out (or the thread deadlocks), the test process can hang even after the assertion fails because the thread will keep the process alive. Mark the thread as background (or otherwise ensure it cannot outlive the test) to prevent hangs on failure paths.
Thread thread = new Thread(() =>
{
// Make sure that ComWrappers isn't locking in GetOrCreateObjectForComInstance
// around the QI call by calling it on a different thread from within a QI call to register a new managed wrapper
// for a COM object representing "this".
src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.NativeAot.cs:190
DetachNonPromotedObjectscurrently skips wrappers whose proxy weak handle has already been cleared (i.e.,TryGetTargetreturns false). Those objects are precisely the ones that are not promoted and should be detached/notified. Preserve the previous behavior by treating a missing target as "not promoted".
ReferenceTrackerNativeObjectWrapper? nativeObjectWrapper = Unsafe.As<ReferenceTrackerNativeObjectWrapper>(weakNativeObjectWrapperHandle.Target);
if (nativeObjectWrapper != null &&
nativeObjectWrapper.TrackerObject != IntPtr.Zero &&
nativeObjectWrapper.ProxyHandle.TryGetTarget(out object? proxyTarget) &&
!RuntimeImports.RhIsPromoted(proxyTarget))
|
Tagging subscribers to this area: @dotnet/interop-contrib |
Note
Based on top of #132033, leaving as draft until that's merged.
Motivation
Every RCW that
ComWrapperscreates allocates two weak GC handles for its proxy object:The second one exists only so that the
NativeObjectWrapperis cleaned up after the proxy has been finalized, because the proxy's finalizer may still call into the native object. Until then, other callers (notably the reference tracker runtime) must observe the proxy as dead as soon as it is eligible for finalization, which is what the first, plain weak handle provides.Allocating, clearing and freeing GC handles is a substantial part of the per-RCW cost. In a NativeAOT profile of CsWinRT,
HndCreateHandle/TableAllocSingleHandleFromCacheare prominent on the allocating thread, andQuickSort/CompareHandlesByFreeOrder— the GC handle table sorting free handles — account for roughly 35% of the finalizer thread.WeakTrackResurrectionhandles are also more expensive for the GC than plain weak handles, since they are processed in a separate, later phase.What this changes
1. Skip the resurrection tracking handle when the proxy has no finalizer
If the proxy's type declares no finalizer, it can never observe the native object after it becomes unreachable, and it can never be resurrected. The second handle would therefore always be cleared at exactly the same time as the first, so it is pure overhead. That condition is detected directly from the
MethodTable, via a new internalRuntimeHelpers.ObjectHasFinalizeron each runtime — modelled on the existingRuntimeHelpers.ObjectHasComponentSize, which has the same shape and the same "callers are required to keep obj alive" contract.No public API change, and no opt-in required from callers: this applies automatically to any
ComWrappersuser whose proxies have no finalizer. That includes CsWinRT, whoseWindowsRuntimeObject(the object it passes toGetOrRegisterObjectForComInstance) has no finalizer — the finalizer it needs lives on a separateWindowsRuntimeObjectReferenceobject.The existing finalizer and
Releasepaths already tolerate an unallocated handle, so the change is a single conditional allocation. Note that this does not remove a GC cycle for such wrappers: with a finalizer-free proxy,GC.ReRegisterForFinalizewould not have fired anyway, sinces_nativeObjectWrapperTablekeeps the wrapper alive while the proxy is alive. The saving is the handle itself.2. Use
WeakGCHandle<T>for both proxy handlesBoth handles always track the proxy object, so a strongly typed weak handle expresses that directly: it allocates through
GCHandle.InternalAllocwithout revalidating the handle type, and skips the cast when reading the target.This is safe even though
_proxyHandleis mirrored in native code (NativeObjectWrapperObjectininteroplibinterface_comwrappers.h, read viaGetProxyHandle) and flows through the on-stack COM struct used for reference tracker callbacks (FindReferenceTargetsCallback.Instance.RootObject).GCHandleonly alters the stored value for pinned handles:so a weak
GCHandleand aWeakGCHandle<T>hold bit-identical raw handles, and the layout is unchanged.The redundant
IsAllocatedchecks around disposal are also dropped, sinceWeakGCHandle<T>.Disposealready handles a default handle.Benchmark results
Measured on a 32-core Windows x64 machine, Release runtime, using the CsWinRT benchmark suite (
ProjectedConstructionPerf) against both CsWinRT 2.3.0-prerelease and 3.0.0-preview on the same runtime build. Mean of 2 runs, in µs.bucketingis the parent branch (RCW cache partitioning);+handlesis this branch.CsWinRT 2.x
CsWinRT 3.0
This branch contributes roughly 4–12% on top of the parent branch.
ConstructProjectedClassWithStringbarely moves because it is dominated by HSTRING marshalling.Fewer GC handles also means less GC work: Gen2 collections per 1000 operations on the 10k-construction loop (CsWinRT 2.x) went 0.218 (main) → 0.088 (bucketing) → ~0.070 (this branch).
Testing
ComWrappersTests,ComWrappersTestsBuiltInComDisabled,GcRestrictedCalloutReversePInvokeandWeakReferenceTestall pass against the modified CoreLib.GlobalInstancetests fail identically onmainand on this branch in my environment (8007007E, COM class factory registration), so they are pre-existing and unrelated.Note
Parts of this pull request description were generated with GitHub Copilot. All benchmark numbers in it were measured locally.