Skip to content

Partition the ComWrappers RCW cache into per-processor buckets - #132033

Open
Sergio0694 wants to merge 2 commits into
dotnet:mainfrom
Sergio0694:dev/comwrappers-rcw-cache-buckets
Open

Partition the ComWrappers RCW cache into per-processor buckets#132033
Sergio0694 wants to merge 2 commits into
dotnet:mainfrom
Sergio0694:dev/comwrappers-rcw-cache-buckets

Conversation

@Sergio0694

@Sergio0694 Sergio0694 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Motivation

ComWrappers keeps a per-instance RCW (Runtime Callable Wrapper) identity cache, mapping a COM identity pointer to the NativeObjectWrapper tracking the managed proxy for it. Until now that cache was a single Dictionary<IntPtr, GCHandle> behind a single ReaderWriterLockSlim.

That single lock is a process-wide serialization point on a very hot path:

  • It is consulted on essentially every native-to-managed transition, via TryGetOrCreateObjectForComInstanceInternalFindProxyForComInstance. In steady state this is a genuine cache hit, not a miss.
  • At the same time, the finalizer thread takes write locks on it to remove entries for collected RCWs (NativeObjectWrapper.ReleaseRcwCache.Remove).

So application threads doing read lookups continuously contend with the finalizer thread doing writes. Profiling CsWinRT 3.0 on NativeAOT showed this as the single largest source of overhead in WinRT interop:

  • RhSpinWait 19.1% and ntdll!RtlpEnterCriticalSectionContended 11.4% exclusive on the main thread — i.e. the main thread mostly waiting.
  • ReaderWriterLockSlim.TryEnterReadLockCore/ExitReadLock 9.1% combined and RcwCache.FindProxyForComInstance 6.4% in another benchmark.

Even with no writers at all, ReaderWriterLockSlim.EnterReadLock still has to CAS a shared counter, so concurrent readers alone bounce a single cache line between cores.

What this changes

1. Partition the cache into buckets (a50b8c7)

RcwCache becomes a thin facade over a Bucket[], where each Bucket holds exactly the same Dictionary + ReaderWriterLockSlim + logic as the old single cache. The bucket for a given COM instance is chosen by hashing its pointer, so unrelated COM objects no longer contend.

The bucket count is BitOperations.RoundUpToPowerOf2(Environment.ProcessorCount) — matching the default concurrency level ConcurrentDictionary uses, and rounded to a power of two so the index is a mask rather than a division.

Some details worth calling out:

  • The hash matters. COM instances are heap allocated and in practice at least 16-byte aligned, so their low bits are constant. Masking the pointer directly would put essentially everything in bucket 0. This uses multiply-shift ("Fibonacci") hashing — multiply by 0x9E3779B97F4A7C15 and take the high half — which lowers to a multiply, a shift and an and. Distribution was validated two ways over 200k keys, because the two input classes have different correct expectations:
    • Aligned, sequentially allocated pointers (16/32/64/96-byte strides, i.e. what an allocator actually produces). Bucket counts land within ±0.3% of n/k (χ² between 0.01 and 0.52 for k = 16/64/128). These keys are an arithmetic progression, not a random sample, so χ² ~ χ²(k−1) does not apply here — a multiply-shift hash stratifies an arithmetic progression almost perfectly, and χ² ≈ 0 is the desired outcome rather than a suspicious one. The contrast is the point: the exact same keys under raw masking give χ² ≈ 3×10⁶–1.3×10⁷, i.e. every object in one bucket.
    • i.i.d. random pointers, where χ² ~ χ²(k−1) does apply. Mean χ² over 20 trials was 13.95 / 61.22 / 125.64 for k = 16 / 64 / 128 against df = 15 / 63 / 127, with p-values spread across the range (15–17 of 20 trials in [0.05, 0.95]). That is indistinguishable from a uniform hash.
  • Both RcwCache and Bucket are readonly structs. RcwCache is inlined into the ComWrappers object and Bucket instances live inline in the array, which avoids two pointer chases on the lookup path and lets several buckets share a cache line. GetBucket returns ref readonly Bucket so the 16-byte struct is never copied. There is no false sharing to worry about: the bucket fields are only ever written during construction, and all mutable state lives in the referenced lock and dictionary.
  • RemoveAll no longer removes as one atomic batch, since wrappers can now span buckets. Its only caller is apartment/context teardown in TrackerObjectManager, and the cache is only ever observed one entry at a time, so nothing could depend on that atomicity.

2. Use WeakGCHandle<NativeObjectWrapper> (2074c0d)

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 (which also removes a benign race where the two reads could disagree).

Benchmark results

All numbers measured locally on a 32-core Windows x64 machine (Hyper-V), Release runtime, comparing main against this branch.

Official CsWinRT benchmarks (ProjectedConstructionPerf)

Run against both CsWinRT 2.3.0-prerelease and 3.0.0-preview on the same runtime build, twice per configuration. Mean of 2 runs, in µs:

Benchmark 2.x main 2.x PR Δ 3.0 main 3.0 PR Δ
ConstructProjectedClassWithInt 1.318 1.220 −7.4% 1.251 1.150 −8.1%
ConstructProjectedClassWithString 5.073 5.009 −1.3% 5.125 5.003 −2.4%
ConstructFastAbiProjectedClassWithInt 1.310 1.184 −9.6% 1.261 1.167 −7.5%
ConstructDerivedFastAbiProjectedClassWithInt 1.303 1.220 −6.4% 1.210 1.204 −0.5%
ConstructProjectedClassWithInterface 1.672 1.497 −10.5% 1.442 1.373 −4.8%

Allocations are unchanged throughout. WithString barely moves because it is dominated by HSTRING marshalling.

Sustained construction (10k instances per invocation)

A loop variant that constructs 10,000 projected objects per invocation, so RCWs from earlier iterations are being finalized while the loop is still running. The projected classes call GC.AddMemoryPressure, which makes the GC run frequently. This is the scenario the change targets most directly. Mean of 2 runs:

main PR Δ
CsWinRT 2.x 1.375 µs 1.205 µs −12.4%
CsWinRT 3.0 1.272 µs 1.191 µs −6.3%

The GC counters show the mechanism. Gen2 collections per 1000 operations:

main PR Δ
CsWinRT 2.x 0.218 0.088 −60%
CsWinRT 3.0 0.074 0.055 −26%

Gen0 and Gen1 track the same way. With a single lock, the finalizer thread stalls acquiring the write lock, so dead RCWs sit in the finalization queue long enough to be promoted instead of dying in gen0. Partitioning lets the finalizer drain faster, so fewer objects survive — a reduction in GC count on top of the reduction in lock waiting.

CsWinRT 3.0 benefits less here precisely because it already produces roughly 3x less GC pressure than 2.x (0.074 vs 0.218 gen2 collections), so there was less finalizer traffic to contend with in the first place.

Microbenchmark: lookup throughput

Steady-state cache hits through ComWrappers.GetOrCreateObjectForComInstance, with rooted RCWs (source in the collapsed section below):

Live RCWs Uncontended (1 thread) 32 threads
1 60.4 → 61.2 ns (1.01x) 261.0 → 227.4 ns (0.87x)
8 60.4 → 62.2 ns (1.03x) 375.0 → 95.2 ns (0.25x)
64 63.2 → 64.4 ns (1.02x) 391.3 → 35.0 ns (0.09x)
1024 63.9 → 70.8 ns (1.11x) 267.9 → 19.6 ns (0.07x)

Up to 13.6x faster under contention. Note the ~60 ns floor is dominated by the QueryInterface transition in the harness, so the cache-specific deltas are understated in relative terms.

Tradeoffs

Two costs, both intentional and called out explicitly:

1. Slightly slower uncontended lookups with a large working set. 1–3% for small working sets, growing to ~11% with 1024 live RCWs. This is cache footprint: each lookup now touches one of N locks and N dictionaries rather than always the same one. Reducing the bucket count would trade some of the contention win to shrink this.

2. More memory per ComWrappers instance. Measured 576 B → 6,536 B on a 32-core machine (one ReaderWriterLockSlim + one Dictionary per bucket, ~186 B/bucket). This scales with ProcessorCount, so it is larger on bigger machines. Most processes have very few ComWrappers instances, but that is worth confirming for this to be a good default. Allocating buckets lazily would avoid paying it for CCW-only or UniqueInstance usage that never touches the RCW cache, at the cost of a branch on the lookup path.

Both the bucket count and eager-vs-lazy allocation are easy to adjust — happy to tune based on feedback.

Testing

  • 3,461 interop tests pass (System.Runtime.InteropServices.Tests and ComInterfaceGenerator.Tests) against a Checked CoreLib, so Debug.Assert is active — including the assert in RegisterWrapperForObject that verifies the RCW cache maps the identity to the expected proxy, which directly validates bucket selection.
  • src/tests/Interop/COM/ComWrappers runtime tests pass. 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.
  • ComWrappersNoLockAroundQueryInterface was updated. That test is a regression test asserting no cache lock is held across a QueryInterface callback. It made its nested cross-thread call using a different COM pointer, which with partitioning would only map to the same bucket by chance (~1/32), so it would have quietly stopped catching the regression it exists for. It now reuses the same identity pointer, which guarantees the same bucket, and additionally exercises the "lost the race" path in RegisterObjectForComInstance. It also now asserts the timed Thread.Join actually succeeded rather than ignoring the result (a timeout previously still passed). The join result is recorded and asserted by the caller rather than inside the callback, since that callback is invoked through the COM ABI and a managed exception cannot propagate through it.
Standalone benchmark source (no WinRT, runs cross-platform)
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.Marshalling;
using System.Threading;
using System.Threading.Tasks;

BenchmarkSwitcher.FromAssembly(typeof(RcwCacheBenchmarks).Assembly).Run(args);

public class RcwCacheBenchmarks
{
    private const int ThreadCount = 32;
    private const int TotalOperations = 256_000;

    private static readonly StrategyBasedComWrappers ComWrappers = new();

    private nint[] _instances = [];
    private object[] _proxies = [];

    [Params(8, 1024)]
    public int InstanceCount { get; set; }

    [GlobalSetup]
    public void Setup()
    {
        _instances = new nint[InstanceCount];
        _proxies = new object[InstanceCount];

        for (int i = 0; i < InstanceCount; i++)
        {
            _instances[i] = FakeComObject.Create();

            // Keep the RCWs rooted, so that every lookup below is a steady state cache hit
            _proxies[i] = ComWrappers.GetOrCreateObjectForComInstance(_instances[i], CreateObjectFlags.None);
        }
    }

    [Benchmark(OperationsPerInvoke = TotalOperations / ThreadCount)]
    public void Lookup()
    {
        nint[] instances = _instances;
        int mask = InstanceCount - 1;

        for (int i = 0; i < TotalOperations / ThreadCount; i++)
        {
            _ = ComWrappers.GetOrCreateObjectForComInstance(instances[i & mask], CreateObjectFlags.None);
        }
    }

    [Benchmark(OperationsPerInvoke = TotalOperations)]
    public void Lookup_Concurrent_T32()
    {
        const int OperationsPerThread = TotalOperations / ThreadCount;

        nint[] instances = _instances;
        int mask = InstanceCount - 1;

        Parallel.For(0, ThreadCount, new ParallelOptions { MaxDegreeOfParallelism = ThreadCount }, t =>
        {
            for (int i = 0; i < OperationsPerThread; i++)
            {
                _ = ComWrappers.GetOrCreateObjectForComInstance(instances[(i + (t * 37)) & mask], CreateObjectFlags.None);
            }
        });
    }
}

/// <summary>
/// A minimal native 'IUnknown' implementation, so the benchmark doesn't need any native code.
/// </summary>
internal static unsafe class FakeComObject
{
    private static readonly Guid IID_IUnknown = new(0x00000000, 0x0000, 0x0000, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46);

    public static nint Create()
    {
        nint* vtable = (nint*)NativeMemory.Alloc(3, (nuint)sizeof(nint));

        vtable[0] = (nint)(delegate* unmanaged<nint, Guid*, nint*, int>)&QueryInterface;
        vtable[1] = (nint)(delegate* unmanaged<nint, uint>)&AddRef;
        vtable[2] = (nint)(delegate* unmanaged<nint, uint>)&Release;

        // The instance is just the vtable pointer followed by a reference count
        nint* instance = (nint*)NativeMemory.Alloc(2, (nuint)sizeof(nint));

        instance[0] = (nint)vtable;
        instance[1] = 1;

        return (nint)instance;
    }

    private static ref int RefCount(nint thisPtr) => ref *(int*)(thisPtr + sizeof(nint));

    [UnmanagedCallersOnly]
    private static int QueryInterface(nint thisPtr, Guid* iid, nint* ppvObject)
    {
        if (*iid != IID_IUnknown)
        {
            *ppvObject = 0;

            return unchecked((int)0x80004002); // E_NOINTERFACE
        }

        *ppvObject = thisPtr;

        Interlocked.Increment(ref RefCount(thisPtr));

        return 0; // S_OK
    }

    [UnmanagedCallersOnly]
    private static uint AddRef(nint thisPtr) => (uint)Interlocked.Increment(ref RefCount(thisPtr));

    [UnmanagedCallersOnly]
    private static uint Release(nint thisPtr) => (uint)Interlocked.Decrement(ref RefCount(thisPtr));
}

Note

Parts of this pull request description were generated with GitHub Copilot. All benchmark numbers in it were measured locally and are reproducible with the sources linked above.

Sergio0694 and others added 2 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>
Copilot AI lite review requested due to automatic review settings August 8, 2026 07:00
@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 improves ComWrappers RCW identity cache scalability by partitioning the per-instance cache into multiple per-processor buckets (each with its own lock and dictionary) and by switching cached entries to use a strongly-typed WeakGCHandle<NativeObjectWrapper>.

Changes:

  • Partition the RCW cache into multiple buckets selected via a pointer hash to reduce lock contention on hot-path cache hits.
  • Replace GCHandle-based cache entries with WeakGCHandle<NativeObjectWrapper> for more direct and cheaper weak-handle operations.
  • Update the ComWrappersNoLockAroundQueryInterface regression test to reliably exercise the same cache bucket/lock and to assert the cross-thread call completes.

Reviewed changes

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

File Description
src/tests/Interop/COM/ComWrappers/API/Program.cs Updates the regression test to reuse the same COM identity pointer under the new bucketed cache and adds a completion assertion.
src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs Implements the bucketed RCW cache design and moves cache entries to WeakGCHandle<NativeObjectWrapper>.

Comment thread src/tests/Interop/COM/ComWrappers/API/Program.cs
@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.

Comment on lines +1318 to +1319
int bucketCount = (int)BitOperations.RoundUpToPowerOf2((uint)Environment.ProcessorCount);
Bucket[] buckets = new Bucket[bucketCount];

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.

Suggested change
int bucketCount = (int)BitOperations.RoundUpToPowerOf2((uint)Environment.ProcessorCount);
Bucket[] buckets = new Bucket[bucketCount];
uint bucketCount = BitOperations.RoundUpToPowerOf2((uint)Environment.ProcessorCount);
Bucket[] buckets = new Bucket[bucketCount];

Cast not needed?

Comment on lines +1339 to +1348
// COM instances are heap allocated, so they're always at least pointer aligned (and 16-byte aligned
// in practice). That means their low bits are constant and can't be used to select a bucket directly.
// Multiplying by a large odd constant (2^64 divided by the golden ratio) mixes every input bit into
// the high half of the product, which is then masked to produce the index. The whole sequence lowers
// to a multiply, a shift and a mask, which is negligible next to the lookup that follows.
ulong hash = (ulong)(nuint)comPointer * 0x9E3779B97F4A7C15;
uint index = (uint)(hash >> 32) & (uint)(buckets.Length - 1);

// Return the bucket by reference, so that it's addressed in place in the array rather than copied
return ref buckets[index];

@MichalPetryka MichalPetryka Aug 8, 2026

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.

This logic seems like it could use some asserts instead of cryptic outofrange. EDIT: noticed the & here now

Comment on lines +1339 to +1341
// COM instances are heap allocated, so they're always at least pointer aligned (and 16-byte aligned
// in practice). That means their low bits are constant and can't be used to select a bucket directly.
// Multiplying by a large odd constant (2^64 divided by the golden ratio) mixes every input bit into

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.

Make sure this doesnt break with pointer tagging since it can be used with GC and native allocators.

@jkoritzinsky

This comment has been minimized.

/// important because the cache is consulted on essentially every transition from native to managed code, and
/// because the finalizer thread concurrently takes write locks to remove entries for collected RCWs.
/// </remarks>
private readonly struct RcwCache

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.

Align the design with ConcurrentDictionary/ConcurrentUnifier with a class exterior and inner Container struct?

@jkoritzinsky

Copy link
Copy Markdown
Member

@EgorBot --filter Interop.ComWrappersTests.*

_lock.EnterReadLock();
try
private readonly ReaderWriterLockSlim _lock;
private readonly Dictionary<IntPtr, WeakGCHandle<NativeObjectWrapper>> _cache;

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.

Using a dictionary within each bucket feels like we may be leaving perf on the table.

A few ideas:

  • Use a custom hash comparer that has an inverse distribution and to the bucketing one (to avoid collisions re-colliding)
  • Use ConcurrentUnifier instead of our own type
  • Use an array of KeyValuePair like a regular dictionary and handle bucket resizing.
  • Have a "single pair or dictionary type" to optimize for the "single bucket entry" case.

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

Labels

area-System.Runtime.InteropServices community-contribution Indicates that the PR has been added by a community member tenet-performance Performance related issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants