Skip to content

Reduce the GC handle cost of every RCW in ComWrappers - #132040

Draft
Sergio0694 wants to merge 6 commits into
dotnet:mainfrom
Sergio0694:dev/comwrappers-skip-resurrection-handle
Draft

Reduce the GC handle cost of every RCW in ComWrappers#132040
Sergio0694 wants to merge 6 commits into
dotnet:mainfrom
Sergio0694:dev/comwrappers-skip-resurrection-handle

Conversation

@Sergio0694

Copy link
Copy Markdown
Contributor

Note

Based on top of #132033, leaving as draft until that's merged.

Motivation

Every RCW that ComWrappers creates allocates two weak GC handles for its proxy object:

_proxyHandle = GCHandle.Alloc(comProxy, GCHandleType.Weak);
_proxyHandleTrackingResurrection = GCHandle.Alloc(comProxy, GCHandleType.WeakTrackResurrection);

The second one exists only so that the NativeObjectWrapper is 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/TableAllocSingleHandleFromCache are prominent on the allocating thread, and QuickSort/CompareHandlesByFreeOrder — the GC handle table sorting free handles — account for roughly 35% of the finalizer thread. WeakTrackResurrection handles 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 internal RuntimeHelpers.ObjectHasFinalizer on each runtime — modelled on the existing RuntimeHelpers.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 ComWrappers user whose proxies have no finalizer. That includes CsWinRT, whose WindowsRuntimeObject (the object it passes to GetOrRegisterObjectForComInstance) has no finalizer — the finalizer it needs lives on a separate WindowsRuntimeObjectReference object.

The existing finalizer and Release paths 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.ReRegisterForFinalize would not have fired anyway, since s_nativeObjectWrapperTable keeps the wrapper alive while the proxy is alive. The saving is the handle itself.

2. Use WeakGCHandle<T> for both proxy handles

Both handles always track 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.

This is safe even though _proxyHandle is mirrored in native code (NativeObjectWrapperObject in interoplibinterface_comwrappers.h, read via GetProxyHandle) and flows through the on-stack COM struct used for reference tracker callbacks (FindReferenceTargetsCallback.Instance.RootObject). GCHandle only alters the stored value for pinned handles:

IntPtr handle = InternalAlloc(value, type);
if (type == GCHandleType.Pinned) { handle |= 1; }
_handle = handle;

so a weak GCHandle and a WeakGCHandle<T> hold bit-identical raw handles, and the layout is unchanged.

The redundant IsAllocated checks around disposal are also dropped, since WeakGCHandle<T>.Dispose already 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.

bucketing is the parent branch (RCW cache partitioning); +handles is this branch.

CsWinRT 2.x

Benchmark main bucketing +handles vs main
ConstructProjectedClassWithInt 1.318 1.220 1.136 −13.8%
ConstructFastAbiProjectedClassWithInt 1.310 1.184 1.104 −15.7%
ConstructDerivedFastAbiProjectedClassWithInt 1.303 1.220 1.075 −17.5%
ConstructProjectedClassWithInterface 1.672 1.497 1.414 −15.4%
ConstructManyProjectedClassesWithInt (10k/invoke) 1.375 1.205 1.116 −18.8%
ConstructProjectedClassWithString 5.073 5.009 4.942 −2.6%

CsWinRT 3.0

Benchmark main bucketing +handles vs main
ConstructProjectedClassWithInt 1.251 1.150 1.107 −11.5%
ConstructFastAbiProjectedClassWithInt 1.261 1.167 1.073 −14.9%
ConstructDerivedFastAbiProjectedClassWithInt 1.210 1.204 1.074 −11.2%
ConstructProjectedClassWithInterface 1.442 1.373 1.283 −11.0%
ConstructManyProjectedClassesWithInt (10k/invoke) 1.272 1.191 1.092 −14.2%
ConstructProjectedClassWithString 5.125 5.003 5.049 −1.5%

This branch contributes roughly 4–12% on top of the parent branch. ConstructProjectedClassWithString barely 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, GcRestrictedCalloutReversePInvoke and WeakReferenceTest all pass against the modified CoreLib.
  • Three GlobalInstance tests fail identically on main and 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.

Sergio0694 and others added 5 commits August 7, 2026 22:38
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>
Copilot AI lite review requested due to automatic review settings August 8, 2026 16:59
@dotnet-policy-service dotnet-policy-service Bot added the community-contribution Indicates that the PR has been added by a community member label Aug 8, 2026
@azure-pipelines

Copy link
Copy Markdown
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.

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 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 NativeObjectWrapper proxy handles to WeakGCHandle<object> and skip allocating the resurrection-tracking handle when RuntimeHelpers.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 TryGetTarget rather than GCHandle.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).

Comment on lines +611 to +617
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);
Comment on lines +452 to +453
// Returns true iff the type of the object declares a finalizer.
// Callers are required to keep obj alive
Comment on lines +216 to +217
// 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>
Copilot AI review requested due to automatic review settings August 8, 2026 17:11
@Sergio0694
Sergio0694 force-pushed the dev/comwrappers-skip-resurrection-handle branch from f61051e to 6ac4779 Compare August 8, 2026 17: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 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.TryGetTarget can fail for a weak handle. The current code ignores the return value and may pass null into AddReferencePath, which expects non-null object arguments 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

  • ComWrappers now calls RuntimeHelpers.ObjectHasFinalizer, but that intrinsic is only added for CoreCLR and NativeAOT in this PR. RuntimeHelpers.Mono.cs does 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 Join times 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

  • DetachNonPromotedObjects currently skips wrappers whose proxy weak handle has already been cleared (i.e., TryGetTarget returns 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))

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

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

Labels

area-Interop-coreclr community-contribution Indicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants