Skip to content

Avoid an allocation on every GetOrCreateComInterfaceForObject call - #132481

Open
Sergio0694 wants to merge 3 commits into
dotnet:mainfrom
Sergio0694:dev/comwrappers-ccw-state-struct
Open

Avoid an allocation on every GetOrCreateComInterfaceForObject call#132481
Sergio0694 wants to merge 3 commits into
dotnet:mainfrom
Sergio0694:dev/comwrappers-ccw-state-struct

Conversation

@Sergio0694

Copy link
Copy Markdown
Contributor

ComWrappers.GetOrCreateComInterfaceForObject passes the state its ConditionalWeakTable.GetOrAdd factory needs as an anonymous type:

ManagedObjectWrapperHolder managedObjectWrapper = _managedObjectWrapperTable.GetOrAdd(instance, static (c, state) =>
{
    ManagedObjectWrapper* value = state.This.CreateManagedObjectWrapper(c, state.flags);
    return new ManagedObjectWrapperHolder(value, c);
}, new { This = this, flags });

Anonymous types are classes, so that's 32 bytes allocated on every call. The factory only runs when the object doesn't have a wrapper yet, and in practice almost every call finds one that already exists, so nearly all of it is garbage that never gets used. This is the path every managed object takes on its way into native code, so it adds up.

GetOrAdd<TArg> doesn't constrain TArg (it even allows ref structs), so a small struct works here and costs nothing to pass:

private readonly struct CreateManagedObjectWrapperState(ComWrappers comWrappers, CreateComInterfaceFlags flags)
{
    public ComWrappers ComWrappers { get; } = comWrappers;

    public CreateComInterfaceFlags Flags { get; } = flags;
}

No behaviour change, no API change.

Benchmarks

Release runtime, x64, two alternating passes per build. The allocation numbers were identical across runs.

Scenario main this PR
Wrapper already exists, 8000 distinct objects 90.30 ns, 32 B 71.18 ns, 0 B −21%
Wrapper already exists, same object each time 37.35 ns, 32 B 34.37 ns, 0 B −8%
Wrapper has to be created 598.4 ns, 138 B 575.7 ns, 106 B −4%

The lookup path is allocation free now. The first row gains more than the second because the garbage was also costing GC pressure and cache misses, not just the allocation itself. The creation row is within noise on time, since it's dominated by building the wrapper, but it still drops the 32 bytes.

Testing

ComWrappersTests, ComWrappersTestsBuiltInComDisabled, GcRestrictedCalloutReversePInvoke, GlobalInstanceMarshallingTests, GlobalInstanceMarshallingTestsBuiltInComDisabled, GlobalInstanceTrackerSupportTests_TargetWindows and WeakReferenceTest all pass against the modified CoreLib, built Checked.

Note

Parts of this pull request description were generated with GitHub Copilot. All benchmark numbers in it were measured locally.

GetOrCreateComInterfaceForObject passes the state its ConditionalWeakTable factory needs as
an anonymous type, which is a class, so every call allocates one. The factory only runs when
a wrapper has to be created, and almost every call finds one that already exists, so that
allocation is garbage produced by nearly every transition of a managed object into native code.

The state is a struct now, which GetOrAdd supports as its TArg is unconstrained, so the
lookup path allocates nothing at all.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI lite review requested due to automatic review settings August 18, 2026 18:44
@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.

@dotnet-policy-service dotnet-policy-service Bot added the community-contribution Indicates that the PR has been added by a community member label Aug 18, 2026
@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.

@jkoritzinsky jkoritzinsky left a comment

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.

It's really disappointing that we can't use anonymous types here TBH. Effectively makes things overly wordy when it really shouldn't be necessary.

LGTM

@jkoritzinsky
jkoritzinsky enabled auto-merge (squash) August 18, 2026 18:59

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 removes a per-call heap allocation in ComWrappers.GetOrCreateComInterfaceForObject by replacing an anonymous-type state object passed to ConditionalWeakTable.GetOrAdd with a small readonly struct state payload.

Changes:

  • Replace new { This = this, flags } with CreateManagedObjectWrapperState to avoid allocating state on every call.
  • Update the GetOrAdd value-factory to use the new state shape (state.ComWrappers, state.Flags).
  • Add a private nested state struct (with documentation) to carry ComWrappers and CreateComInterfaceFlags.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
auto-merge was automatically disabled August 18, 2026 20:20

Head branch was pushed to by a user without write access

Copilot AI review requested due to automatic review settings August 18, 2026 20:20
Co-authored-by: Aaron R Robinson <arobins@microsoft.com>

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 1 out of 1 changed files in this pull request and generated no new comments.

@AaronRobinsonMSFT
AaronRobinsonMSFT enabled auto-merge (squash) August 18, 2026 20:45
@MattParkerDev

Copy link
Copy Markdown

It's really disappointing that we can't use anonymous types here TBH. Effectively makes things overly wordy when it really shouldn't be necessary.

It could be a tuple instead?

@Sergio0694

Copy link
Copy Markdown
Contributor Author

@MattParkerDev we tried using value tuples before in ComWrappers and we reverted it. It roots a bunch of additional cruft that can't be trimmed and was causing something like a 7KB size regression on NAOT for no reason. A custom struct is better.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants