Skip to content

Add ComWrappersObject and share RCW cache handles - #133309

Draft
Sergio0694 wants to merge 5 commits into
dotnet:mainfrom
Sergio0694:dev/comwrappers-object-rcw-cache
Draft

Add ComWrappersObject and share RCW cache handles#133309
Sergio0694 wants to merge 5 commits into
dotnet:mainfrom
Sergio0694:dev/comwrappers-object-rcw-cache

Conversation

@Sergio0694

Copy link
Copy Markdown
Contributor

Fixes #132490.

Important

Draft prototype for API review. The proposed public API is not yet approved; this PR is not ready to merge.

Adds an optional ComWrappersObject base class so RCWs can keep their ComWrappers association on the object instead of in a global ConditionalWeakTable. The source-generated COM ComObject adopts it automatically. The RCW cache borrows the NativeObjectWrapper's existing proxy handle instead of allocating its own weak handle. These are one combined feature, rather than independent optimizations.

Objects using the base class can also cache their CCW. The per-ComWrappers CWT remains the canonical selector, every cached holder is checked against its owner, and publication occurs after diagnostics registration. Returning a CCW uses the known runtime IUnknown table index, while preserving the interface-table search for CallerDefinedIUnknown.

The implementation preserves the publication/resolution guarantee from #133164, including tracker-registration completion and removal of the exact cache entry before disposing its borrowed handle. It also adds the Mono API stub, pointer-sized holder IDs with native layout checks, field-backed RCW lookup in DAC/cDAC, contract documentation, and expanded identity, concurrency, and lifetime coverage.

Performance

Windows x64, with separately built, matching main and feature runtimes. The baseline is b5f06b0847863033c479487e1abe17d30a9a9ab7. The feature column includes both base-class adoption and shared RCW handles.

Synthetic results are pooled p10 values from 12 process-order-rotating rounds. CsWinRT results are means from two independent BenchmarkDotNet ShortRun rounds, with default tiering and the selected CoreCLR runtime. All values are ns/op; negative changes are faster.

Scenario Main Combined feature Change
RCW cached lookup, one object 71.90 71.50 -0.6%
RCW creation, single-threaded 522.20 351.00 -32.8%
RCW creation, 8 threads 787.30 595.80 -24.3%
RCW creation, 16 threads 880.20 713.10 -19.0%
Source-generated ComObject creation 939.50 764.70 -18.6%
Cached CCW lookup, one object 84.56 10.46 -87.6%
Cached CCW lookup, 2,000 objects 105.02 15.52 -85.2%
Cold CCW creation 649.88 649.60 Flat
Aggregated construction 1,274.08 1,105.20 -13.3%
Aggregated CCW lookup 90.56 15.10 -83.3%
NativeAOT RCW creation 499.90 340.70 -31.8%
NativeAOT cached CCW lookup 45.40 20.94 -53.9%
CsWinRT: new sealed object 1,047.67 709.17 -32.3%
CsWinRT: existing sealed object 77.78 79.38 +2.1%
CsWinRT: projected construction with interface 1,219.05 775.39 -36.4%
CsWinRT: ordinary-object CCW marshalling 110.48 115.62 +4.7%
CsWinRT: composed-object native query 321.28 303.69 -5.5%

The source-generated COM row uses eight independent rounds. The CsWinRT comparison includes all 58 existing object-return, projected-construction, query-interface, and collection cases. The adopted WinRT.Runtime was rebuilt from the exact source revision corresponding to the packaged runtime, with the WindowsRuntimeObject base class changed conditionally; it is not a synthetic stand-in. NativeAOT measurements cover the synthetic COM, CCW, and aggregation harnesses, not the actual CsWinRT suite.

Tradeoffs and variability

Non-adopting RCW cache hits now recover their wrapper through the CWT and regress materially: approximately 22-25% in the single-threaded synthetic cases. Ordinary CCWs are generally flat or faster, but there is no claim that every row improves. Concurrent ordinary-CCW p10 outliers remain, including +1.2%/+2.5% on NativeAOT. The CoreCLR 8-thread ordinary-CCW p10 is +4.3%, while its median is slightly faster.

The broad CsWinRT sequence is sensitive to process history. Eight isolated repetitions of the ordinary-object CCW benchmark measured 103.93 -> 102.70 ns (-1.2%), rather than the broad sequence's +4.7%. Four focused existing-RCW runs measured 73.10 -> 73.37 ns (+0.4%). An apparent 42% improvement in a fast-interface control was rejected because of an anomalous baseline.

Samples within a synthetic process are correlated; pooled p10 is a lower-envelope comparison, not a confidence interval. Fully saturated 32-thread results are unstable on this machine and are not used for directional performance claims.

Memory and handles

For the minimal non-finalizable RCW, explicit weak handles fall from two to one. ComObject, which also needs a resurrection handle, goes from three to two. Adopting objects additionally avoid their RCW CWT entry and its dependent handle.

Retained managed memory in the minimal RCW case falls from 145.3 to 127.2 bytes per object (-12.5%). For ComObject, it falls from 542.3 to 519.2 bytes. These are forced-GC deltas over 199,000 additional live RCWs, not total process working-set measurements.

The base class adds two reference fields, and each CCW holder adds a pointer-sized owner ID. CCW adoption retains the canonical per-owner CWT.

Validation

All 31 x64 Checked COM suites and all six supported x86 ComWrappers suites passed, including 66 API cases in each built-in-COM configuration on each architecture. Coverage includes both object shapes, caller/runtime-provided IUnknown, tracker support, two-owner isolation, concurrent creation, losing registrations, resurrection, and GC pressure.

The interop library suite completed 3,267 cases with 30 skips and zero failures; source-generated COM completed 194 cases. cDAC completed 3,164 unit cases, including 64 new storage/version/architecture cases, and all six fresh R2R/JIT ComWrappers dump integrations passed. Mono CoreLib builds with the platform stub. NativeAOT benchmark identity/context prologues and all measured scenarios executed successfully.

No ARM64/Linux validation or CI matrix execution is claimed.

Note

This PR description was generated by GitHub Copilot.

Sergio0694 and others added 5 commits September 5, 2026 00:14
ComWrappers keeps the NativeObjectWrapper for every RCW in a static
ConditionalWeakTable keyed on the RCW. Keying that table on the object
requires a hash code for it, which on first use has to be assigned into
the object header, and that turns out to be one of the largest single
costs of creating an RCW.

Add a ComWrappersObject base type that RCWs can optionally derive from,
which carries a field for the wrapper. Deriving from it is not required,
and objects that don't keep going through the table exactly as before.
The field is only ever published with a compare exchange, so the first
registration wins and every caller gets that winner back, which is the
same contract the table provided.

This also speeds up marshalling an RCW back to native code, as looking
up its native pointer becomes a type check and a field read rather than
a hash lookup.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ComObject is what StrategyBasedComWrappers hands back for every source generated COM
interface, so deriving it from ComWrappersObject moves all of that interop off the
ConditionalWeakTable without any change to user code.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
An object that derives from ComWrappersObject and is handed to native code repeatedly went
through the per instance ConditionalWeakTable on every call. In WinRT this is what a user type
deriving from a projected class does: it cannot be unwrapped to a native pointer the way a plain
projected type can, so every marshal is a table lookup.

The wrapper is cached in a second field on the object instead. That table is per ComWrappers
instance rather than global, so the field holds whichever instance got there first and records
which one that was; anyone else falls back to the table exactly as before.

The instance is identified by a number rather than a reference, because a wrapper is allowed to
outlive the ComWrappers that made it, so nothing reachable from one may refer to it. That number
lives after the fields the runtime mirrors in ManagedObjectWrapperHolderObject, which is declared
alongside it, as CoreLibBinder checks the two agree on size.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The cache stored a weak handle to the NativeObjectWrapper, which then held a second weak handle to
the RCW itself. Every cached RCW therefore cost two GC handles, and finding one meant dereferencing
both of them.

The wrapper is reachable from the RCW it tracks, so a handle to the RCW keeps exactly the same
entries alive as a handle to the wrapper did. The cache now stores a copy of the handle the wrapper
already had, which halves the handles a cached RCW costs, takes the handle allocation out of the
write lock, and removes an indirection from the lookup that every native to managed transition
performs.

Every handle in the cache belongs to the wrapper that created it, so the cache only ever drops
entries and never frees them, and NativeObjectWrapper.Release removes its entry before freeing its
handle. Entries are identified by the handle rather than by what it points at, because once an RCW
is collected several dead entries are indistinguishable by their targets.

An entry is resolved back to its wrapper through the RCW, so the two are published together under
the cache lock, which also closes a window where another thread could be handed an RCW before it
could be resolved. For an RCW deriving from ComWrappersObject that resolution is a field on the
object, so publishing them together costs nothing. For any other RCW it is the table keyed on the
object, whose lock covers the whole process, so those do serialize the buckets against each other.
That is the price of having nowhere on the object to put the wrapper.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add Mono availability, pointer-sized holder IDs with native layout validation, and field-backed RCW lookup in DAC and cDAC. Preserve tracker publication ordering and exact-handle cache lifetime. Streamline the known IUnknown return path and extend identity, concurrency, resurrection, and diagnostic coverage.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2a77edfd-ecc2-4e25-91b5-715ac3cb5fca
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 5 pipeline(s).
11 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service dotnet-policy-service Bot added the community-contribution Indicates that the PR has been added by a community member label Sep 5, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @steveisok, @tommcdon, @dotnet/dotnet-diag
See info in area-owners.md if you want to be subscribed.

@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.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @steveisok, @tommcdon, @dotnet/dotnet-diag
See info in area-owners.md if you want to be subscribed.

@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 +80 to +82
// Identifies the ComWrappers instance that created this wrapper. Unused natively, but the managed and
// native declarations of this type must agree on size, which CoreLibBinder verifies on startup.
uintptr_t _comWrappersId;

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.

Can we please start with the NativeObjectWrapper optimizations first and do the MOW optimization in a separate PR?

Comment on lines +37 to +42
/// <remarks>
/// This takes the place of the entry that would otherwise be in <c>ComWrappers.s_nativeObjectWrapperTable</c>,
/// and it has the same lifetime: the wrapper is kept alive for exactly as long as the object it tracks is.
/// It is only ever assigned through <see cref="Interlocked.CompareExchange{T}(ref T, T, T)"/>, so that the
/// first registration wins, as it would in the table.
/// </remarks>

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.

Suggested change
/// <remarks>
/// This takes the place of the entry that would otherwise be in <c>ComWrappers.s_nativeObjectWrapperTable</c>,
/// and it has the same lifetime: the wrapper is kept alive for exactly as long as the object it tracks is.
/// It is only ever assigned through <see cref="Interlocked.CompareExchange{T}(ref T, T, T)"/>, so that the
/// first registration wins, as it would in the table.
/// </remarks>

Comment on lines +526 to +528
// Keep this pointer-sized: a wider field would be reordered ahead of '_wrapper' on 32-bit runtimes,
// invalidating the native ManagedObjectWrapperHolderObject layout.
private readonly nuint _comWrappersId;

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.

We should avoid this sort of comment. If sequential layout is needed, then mark it as such.

{
_wrapper = wrapper;
_wrappedObject = wrappedObject;
// Zero never identifies a ComWrappers instance. IDs that outgrow a pointer bypass the field 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.

IDs that outgrow a pointer bypass the field cache.

What does this mean?

Comment on lines +544 to +547
/// <summary>
/// The <see cref="ComWrappers._id"/> of the instance that created this wrapper, or zero if it does not fit
/// in a pointer. Every instance computes its own vtables, so a wrapper is only valid for its creator.
/// </summary>

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.

What does this mean?

&& managedObjectWrapper.ComWrappersId != 0
&& comWrappersObject._managedObjectWrapper is null)
{
Volatile.Write(ref comWrappersObject._managedObjectWrapper, managedObjectWrapper);

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.

Why is this Volatile needed? it seems like the entire process is racy here and I'm not sure why this particular part has a volatile concern.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[API Proposal]: ComWrappersObject

3 participants